authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-06 12:04:27-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-06 08:04:27-07:00
logac8e9453ca0948469ccf5651b765086d8d9ecd64
tree4ea0ebf121fae1fcc17167a7f62f89b0d04130a6
parent9bd1d8009a6b7c50f9dd9458e2bf27d481ff3b43
signature Signed by PGP key B5690EEEBB952194

feat(preview): run the browser on the environment server (#15328)


165 files changed, 14298 insertions(+), 8447 deletions(-)

.github/workflows/ci.yml+3
...@@ -255,6 +255,9 @@ jobs:...@@ -255,6 +255,9 @@ jobs:
255 # No Electron setup here: `t3` (apps/server) has no Electron dependency255 # No Electron setup here: `t3` (apps/server) has no Electron dependency
256 # and none of its tests touch the runtime. Only the non-server `test`256 # and none of its tests touch the runtime. Only the non-server `test`
257 # job, which covers @t3tools/desktop, needs the download.257 # job, which covers @t3tools/desktop, needs the download.
258 - name: Install Chromium for server browser integration tests
259 run: node apps/server/node_modules/playwright-core/cli.js install --with-deps --only-shell chromium
260
258 - name: Test261 - name: Test
259 env:262 env:
260 T3CODE_TRANSFER_BUDGET_REPORT_PATH: ${{ runner.temp }}/t3code-transfer-budget.md263 T3CODE_TRANSFER_BUDGET_REPORT_PATH: ${{ runner.temp }}/t3code-transfer-budget.md
apps/desktop/src/backend/DesktopBackendConfiguration.ts+2
...@@ -568,6 +568,8 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv...@@ -568,6 +568,8 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv
568 tailscaleServePort: backendExposure.tailscaleServePort,568 tailscaleServePort: backendExposure.tailscaleServePort,
569 desktopTelemetryFd: 4,569 desktopTelemetryFd: 4,
570 desktopTelemetryControlFd: 5,570 desktopTelemetryControlFd: 5,
571 desktopBrowserFd: 6,
572 desktopBrowserControlFd: 7,
571 ...Option.match(input.resourceMonitorPath, {573 ...Option.match(input.resourceMonitorPath, {
572 onNone: () => ({}),574 onNone: () => ({}),
573 onSome: (resourceMonitorPath) => ({ resourceMonitorPath }),575 onSome: (resourceMonitorPath) => ({ resourceMonitorPath }),
apps/desktop/src/backend/DesktopBackendManager.test.ts+2
...@@ -27,6 +27,7 @@ import * as DesktopApp from "../app/DesktopApp.ts";...@@ -27,6 +27,7 @@ import * as DesktopApp from "../app/DesktopApp.ts";
27import * as DesktopBackendPool from "./DesktopBackendPool.ts";27import * as DesktopBackendPool from "./DesktopBackendPool.ts";
28import * as DesktopObservability from "../app/DesktopObservability.ts";28import * as DesktopObservability from "../app/DesktopObservability.ts";
29import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";29import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";
30import * as DesktopBrowserHost from "../preview/DesktopBrowserHost.ts";
30import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";31import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";
3132
32const decodeDesktopBackendBootstrap = Schema.decodeEffect(33const decodeDesktopBackendBootstrap = Schema.decodeEffect(
...@@ -173,6 +174,7 @@ function makeTestInstance(input: MakeInstanceInput) {...@@ -173,6 +174,7 @@ function makeTestInstance(input: MakeInstanceInput) {
173 updateCancellations: Stream.empty,174 updateCancellations: Stream.empty,
174 ...input.desktopTelemetryPublisher,175 ...input.desktopTelemetryPublisher,
175 }),176 }),
177 DesktopBrowserHost.layer,
176 DesktopWslEnvironment.layerTest(178 DesktopWslEnvironment.layerTest(
177 input.pruneRuntimes === undefined ? {} : { pruneRuntimes: input.pruneRuntimes },179 input.pruneRuntimes === undefined ? {} : { pruneRuntimes: input.pruneRuntimes },
178 ),180 ),
apps/desktop/src/backend/DesktopBackendManager.ts+42
...@@ -52,6 +52,7 @@ import { waitForHttpReady as waitForHttpReadyShared } from "@t3tools/shared/http...@@ -52,6 +52,7 @@ import { waitForHttpReady as waitForHttpReadyShared } from "@t3tools/shared/http
5252
53import * as DesktopObservability from "../app/DesktopObservability.ts";53import * as DesktopObservability from "../app/DesktopObservability.ts";
54import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";54import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";
55import * as DesktopBrowserHost from "../preview/DesktopBrowserHost.ts";
55import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";56import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";
5657
57const INITIAL_RESTART_DELAY = Duration.millis(500);58const INITIAL_RESTART_DELAY = Duration.millis(500);
...@@ -222,6 +223,10 @@ export type BackendProcessError = typeof BackendProcessError.Type;...@@ -222,6 +223,10 @@ export type BackendProcessError = typeof BackendProcessError.Type;
222223
223interface RunBackendProcessOptions extends DesktopBackendStartConfig {224interface RunBackendProcessOptions extends DesktopBackendStartConfig {
224 readonly desktopTelemetryStream: Stream.Stream<Uint8Array>;225 readonly desktopTelemetryStream: Stream.Stream<Uint8Array>;
226 /** Events for the desktop's browser tabs, written to `desktopBrowserFd`. */
227 readonly desktopBrowserStream?: Stream.Stream<Uint8Array>;
228 /** Each line the backend writes to `desktopBrowserControlFd`. */
229 readonly onDesktopBrowserCommand?: (line: string) => Effect.Effect<void>;
225 readonly onDesktopTelemetryControl?: (230 readonly onDesktopTelemetryControl?: (
226 message: DesktopTelemetryControlMessageValue,231 message: DesktopTelemetryControlMessageValue,
227 ) => Effect.Effect<void>;232 ) => Effect.Effect<void>;
...@@ -472,6 +477,18 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* (...@@ -472,6 +477,18 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* (
472 type: "output",477 type: "output",
473 };478 };
474 }479 }
480 if (
481 options.bootstrap.desktopBrowserFd !== undefined &&
482 options.desktopBrowserStream !== undefined
483 ) {
484 additionalFds[`fd${options.bootstrap.desktopBrowserFd}`] = {
485 type: "input",
486 stream: options.desktopBrowserStream,
487 };
488 }
489 if (options.bootstrap.desktopBrowserControlFd !== undefined) {
490 additionalFds[`fd${options.bootstrap.desktopBrowserControlFd}`] = { type: "output" };
491 }
475 }492 }
476 const command = ChildProcess.make(options.executablePath, options.args, {493 const command = ChildProcess.make(options.executablePath, options.args, {
477 cwd: options.cwd,494 cwd: options.cwd,
...@@ -542,6 +559,26 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* (...@@ -542,6 +559,26 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* (
542 Effect.forkScoped,559 Effect.forkScoped,
543 );560 );
544 }561 }
562 if (
563 options.bootstrap.desktopBrowserControlFd !== undefined &&
564 options.onDesktopBrowserCommand !== undefined
565 ) {
566 const browserFd = options.bootstrap.desktopBrowserControlFd;
567 const handleCommand = options.onDesktopBrowserCommand;
568 yield* handle.getOutputFd(browserFd).pipe(
569 Stream.decodeText(),
570 Stream.splitLines,
571 Stream.filter((line) => line.length > 0),
572 Stream.runForEach(handleCommand),
573 Effect.catchCause((cause) =>
574 logBackendProcessWarning("desktop browser command stream stopped", {
575 fd: browserFd,
576 cause: Cause.pretty(cause),
577 }),
578 ),
579 Effect.forkScoped,
580 );
581 }
545 if (options.captureOutput) {582 if (options.captureOutput) {
546 const outputContext = {583 const outputContext = {
547 executablePath: options.executablePath,584 executablePath: options.executablePath,
...@@ -642,6 +679,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (...@@ -642,6 +679,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (
642 | HttpClient.HttpClient679 | HttpClient.HttpClient
643 | DesktopObservability.DesktopBackendOutputLogFactory680 | DesktopObservability.DesktopBackendOutputLogFactory
644 | DesktopTelemetryPublisher.DesktopTelemetryPublisher681 | DesktopTelemetryPublisher.DesktopTelemetryPublisher
682 | DesktopBrowserHost.DesktopBrowserHost
645 | DesktopWslEnvironment.DesktopWslEnvironment683 | DesktopWslEnvironment.DesktopWslEnvironment
646 | Scope.Scope684 | Scope.Scope
647> {685> {
...@@ -650,6 +688,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (...@@ -650,6 +688,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (
650 const backendOutputLogFactory = yield* DesktopObservability.DesktopBackendOutputLogFactory;688 const backendOutputLogFactory = yield* DesktopObservability.DesktopBackendOutputLogFactory;
651 const backendOutputLog = yield* backendOutputLogFactory.forInstance(spec.id);689 const backendOutputLog = yield* backendOutputLogFactory.forInstance(spec.id);
652 const desktopTelemetryPublisher = yield* DesktopTelemetryPublisher.DesktopTelemetryPublisher;690 const desktopTelemetryPublisher = yield* DesktopTelemetryPublisher.DesktopTelemetryPublisher;
691 const desktopBrowserHost = yield* DesktopBrowserHost.DesktopBrowserHost;
653 const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment;692 const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment;
654 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;693 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
655 const httpClient = yield* HttpClient.HttpClient;694 const httpClient = yield* HttpClient.HttpClient;
...@@ -907,6 +946,9 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (...@@ -907,6 +946,9 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (
907 const program = runBackendProcess({946 const program = runBackendProcess({
908 ...config.value,947 ...config.value,
909 desktopTelemetryStream: desktopTelemetryPublisher.encoded,948 desktopTelemetryStream: desktopTelemetryPublisher.encoded,
949 // Only a bootstrap that names the browser fds (the local primary) gets them.
950 desktopBrowserStream: desktopBrowserHost.events,
951 onDesktopBrowserCommand: desktopBrowserHost.handleCommandLine,
910 onDesktopTelemetryControl: (message) =>952 onDesktopTelemetryControl: (message) =>
911 desktopTelemetryPublisher.handleControlForSource(spec.id, message),953 desktopTelemetryPublisher.handleControlForSource(spec.id, message),
912 onStarted: Effect.fn("desktop.backendInstance.onStarted")(function* (pid) {954 onStarted: Effect.fn("desktop.backendInstance.onStarted")(function* (pid) {
apps/desktop/src/backend/DesktopBackendPool.test.ts+2
...@@ -12,6 +12,7 @@ import { ChildProcessSpawner } from "effect/process";...@@ -12,6 +12,7 @@ import { ChildProcessSpawner } from "effect/process";
12import * as DesktopObservability from "../app/DesktopObservability.ts";12import * as DesktopObservability from "../app/DesktopObservability.ts";
13import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";13import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
14import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";14import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";
15import * as DesktopBrowserHost from "../preview/DesktopBrowserHost.ts";
15import * as ElectronDialog from "../electron/ElectronDialog.ts";16import * as ElectronDialog from "../electron/ElectronDialog.ts";
16import * as DesktopWindow from "../window/DesktopWindow.ts";17import * as DesktopWindow from "../window/DesktopWindow.ts";
17import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";18import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";
...@@ -75,6 +76,7 @@ function layerPool(labelRef: Ref.Ref<string>): Layer.Layer<DesktopBackendPool.De...@@ -75,6 +76,7 @@ function layerPool(labelRef: Ref.Ref<string>): Layer.Layer<DesktopBackendPool.De
75 updateCommits: Stream.empty,76 updateCommits: Stream.empty,
76 updateCancellations: Stream.empty,77 updateCancellations: Stream.empty,
77 }),78 }),
79 DesktopBrowserHost.layer,
78 Layer.succeed(DesktopBackendConfiguration.DesktopBackendConfiguration, {80 Layer.succeed(DesktopBackendConfiguration.DesktopBackendConfiguration, {
79 resolvePrimary: Effect.die("unexpected primary config resolve"),81 resolvePrimary: Effect.die("unexpected primary config resolve"),
80 resolvePrimaryLabel: Ref.get(labelRef),82 resolvePrimaryLabel: Ref.get(labelRef),
apps/desktop/src/backend/DesktopBackendPool.ts+2
...@@ -98,6 +98,7 @@ import * as DesktopBackendManager from "./DesktopBackendManager.ts";...@@ -98,6 +98,7 @@ import * as DesktopBackendManager from "./DesktopBackendManager.ts";
98import * as DesktopObservability from "../app/DesktopObservability.ts";98import * as DesktopObservability from "../app/DesktopObservability.ts";
99import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";99import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
100import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";100import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";
101import * as DesktopBrowserHost from "../preview/DesktopBrowserHost.ts";
101import * as DesktopWindow from "../window/DesktopWindow.ts";102import * as DesktopWindow from "../window/DesktopWindow.ts";
102import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";103import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";
103import * as ElectronDialog from "../electron/ElectronDialog.ts";104import * as ElectronDialog from "../electron/ElectronDialog.ts";
...@@ -180,6 +181,7 @@ export type BackendInstanceFactoryRequirements =...@@ -180,6 +181,7 @@ export type BackendInstanceFactoryRequirements =
180 | HttpClient.HttpClient181 | HttpClient.HttpClient
181 | DesktopObservability.DesktopBackendOutputLogFactory182 | DesktopObservability.DesktopBackendOutputLogFactory
182 | DesktopTelemetryPublisher.DesktopTelemetryPublisher183 | DesktopTelemetryPublisher.DesktopTelemetryPublisher
184 | DesktopBrowserHost.DesktopBrowserHost
183 | DesktopWslEnvironment.DesktopWslEnvironment;185 | DesktopWslEnvironment.DesktopWslEnvironment;
184186
185interface ActiveRegisteredInstance {187interface ActiveRegisteredInstance {
apps/desktop/src/ipc/channels.ts+1-8
...@@ -78,6 +78,7 @@ export const PREVIEW_REFRESH_CHANNEL = "desktop:preview-refresh";...@@ -78,6 +78,7 @@ export const PREVIEW_REFRESH_CHANNEL = "desktop:preview-refresh";
78export const PREVIEW_ZOOM_IN_CHANNEL = "desktop:preview-zoom-in";78export const PREVIEW_ZOOM_IN_CHANNEL = "desktop:preview-zoom-in";
79export const PREVIEW_ZOOM_OUT_CHANNEL = "desktop:preview-zoom-out";79export const PREVIEW_ZOOM_OUT_CHANNEL = "desktop:preview-zoom-out";
80export const PREVIEW_RESET_ZOOM_CHANNEL = "desktop:preview-reset-zoom";80export const PREVIEW_RESET_ZOOM_CHANNEL = "desktop:preview-reset-zoom";
81export const PREVIEW_SET_ZOOM_FACTOR_CHANNEL = "desktop:preview-set-zoom-factor";
81export const PREVIEW_HARD_RELOAD_CHANNEL = "desktop:preview-hard-reload";82export const PREVIEW_HARD_RELOAD_CHANNEL = "desktop:preview-hard-reload";
82export const PREVIEW_SET_COLOR_SCHEME_CHANNEL = "desktop:preview-set-color-scheme";83export const PREVIEW_SET_COLOR_SCHEME_CHANNEL = "desktop:preview-set-color-scheme";
83export const PREVIEW_SET_AUDIO_MUTED_CHANNEL = "desktop:preview-set-audio-muted";84export const PREVIEW_SET_AUDIO_MUTED_CHANNEL = "desktop:preview-set-audio-muted";
...@@ -96,14 +97,6 @@ export const PREVIEW_COPY_ARTIFACT_CHANNEL = "desktop:preview-copy-artifact";...@@ -96,14 +97,6 @@ export const PREVIEW_COPY_ARTIFACT_CHANNEL = "desktop:preview-copy-artifact";
96export const PREVIEW_PICTURE_IN_PICTURE_OPEN_CHANNEL = "desktop:preview-pip-open";97export const PREVIEW_PICTURE_IN_PICTURE_OPEN_CHANNEL = "desktop:preview-pip-open";
97export const PREVIEW_PICTURE_IN_PICTURE_CLOSE_CHANNEL = "desktop:preview-pip-close";98export const PREVIEW_PICTURE_IN_PICTURE_CLOSE_CHANNEL = "desktop:preview-pip-close";
98export const PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL = "desktop:preview-pip-frame";99export const PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL = "desktop:preview-pip-frame";
99export const PREVIEW_AUTOMATION_STATUS_CHANNEL = "desktop:preview-automation-status";
100export const PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL = "desktop:preview-automation-snapshot";
101export const PREVIEW_AUTOMATION_CLICK_CHANNEL = "desktop:preview-automation-click";
102export const PREVIEW_AUTOMATION_TYPE_CHANNEL = "desktop:preview-automation-type";
103export const PREVIEW_AUTOMATION_PRESS_CHANNEL = "desktop:preview-automation-press";
104export const PREVIEW_AUTOMATION_SCROLL_CHANNEL = "desktop:preview-automation-scroll";
105export const PREVIEW_AUTOMATION_EVALUATE_CHANNEL = "desktop:preview-automation-evaluate";
106export const PREVIEW_AUTOMATION_WAIT_FOR_CHANNEL = "desktop:preview-automation-wait-for";
107export const PREVIEW_RECORDING_START_CHANNEL = "desktop:preview-recording-start";100export const PREVIEW_RECORDING_START_CHANNEL = "desktop:preview-recording-start";
108export const PREVIEW_RECORDING_STOP_CHANNEL = "desktop:preview-recording-stop";101export const PREVIEW_RECORDING_STOP_CHANNEL = "desktop:preview-recording-stop";
109export const PREVIEW_RECORDING_SAVE_CHANNEL = "desktop:preview-recording-save";102export const PREVIEW_RECORDING_SAVE_CHANNEL = "desktop:preview-recording-save";
apps/desktop/src/ipc/methods/preview.test.ts-26
...@@ -128,32 +128,6 @@ describe("preview IPC methods", () => {...@@ -128,32 +128,6 @@ describe("preview IPC methods", () => {
128 ),128 ),
129 );129 );
130130
131 effectIt.effect("returns automation status for long runtime tab ids", () =>
132 Effect.gen(function* () {
133 const tabId =
134 `["environment-1","thread:delegated-task:${"a".repeat(120)}",` +
135 `"server-epoch-1","preview-1"]`;
136 const status = {
137 available: false,
138 visible: true,
139 tabId,
140 url: null,
141 title: null,
142 loading: false,
143 };
144 const manager = PreviewManager.PreviewManager.of({
145 automationStatus: () => Effect.succeed(status),
146 } as unknown as PreviewManager.PreviewManager["Service"]);
147
148 expect(tabId.length).toBeGreaterThan(128);
149 expect(
150 yield* PreviewIpc.automationStatus
151 .handler({ tabId })
152 .pipe(Effect.provideService(PreviewManager.PreviewManager, manager)),
153 ).toEqual(status);
154 }),
155 );
156
157 it("keeps the public automation status tab id limit", () => {131 it("keeps the public automation status tab id limit", () => {
158 const encode = Schema.encodeUnknownSync(PreviewAutomationStatus);132 const encode = Schema.encodeUnknownSync(PreviewAutomationStatus);
159 const tabId = "t".repeat(129);133 const tabId = "t".repeat(129);
apps/desktop/src/ipc/methods/preview.ts+13-97
...@@ -1,13 +1,6 @@...@@ -1,13 +1,6 @@
1import {1import {
2 DesktopPreviewAnnotationThemeInputSchema,2 DesktopPreviewAnnotationThemeInputSchema,
3 DesktopPreviewArtifactInputSchema,3 DesktopPreviewArtifactInputSchema,
4 DesktopPreviewAutomationClickInputSchema,
5 DesktopPreviewAutomationEvaluateInputSchema,
6 DesktopPreviewAutomationPressInputSchema,
7 DesktopPreviewAutomationScrollInputSchema,
8 DesktopPreviewAutomationStatusSchema,
9 DesktopPreviewAutomationTypeInputSchema,
10 DesktopPreviewAutomationWaitForInputSchema,
11 DesktopPreviewConfigInputSchema,4 DesktopPreviewConfigInputSchema,
12 DesktopPreviewNavigateInputSchema,5 DesktopPreviewNavigateInputSchema,
13 DesktopPreviewRecordingArtifactSchema,6 DesktopPreviewRecordingArtifactSchema,
...@@ -16,6 +9,7 @@ import {...@@ -16,6 +9,7 @@ import {
16 DesktopPreviewScreenshotArtifactSchema,9 DesktopPreviewScreenshotArtifactSchema,
17 DesktopPreviewSetAudioMutedInputSchema,10 DesktopPreviewSetAudioMutedInputSchema,
18 DesktopPreviewSetColorSchemeInputSchema,11 DesktopPreviewSetColorSchemeInputSchema,
12 DesktopPreviewSetZoomFactorInputSchema,
19 BrowserImportResult,13 BrowserImportResult,
20 BrowserImportSource,14 BrowserImportSource,
21 DesktopPreviewClearDataInputSchema,15 DesktopPreviewClearDataInputSchema,
...@@ -24,7 +18,6 @@ import {...@@ -24,7 +18,6 @@ import {
24 DesktopPreviewTabInputSchema,18 DesktopPreviewTabInputSchema,
25 DesktopPreviewWebviewConfigSchema,19 DesktopPreviewWebviewConfigSchema,
26 PreviewAnnotationSubmissionResultSchema,20 PreviewAnnotationSubmissionResultSchema,
27 PreviewAutomationSnapshot,
28 DEFAULT_BROWSER_PROFILE_ID,21 DEFAULT_BROWSER_PROFILE_ID,
29 INCOGNITO_BROWSER_PROFILE_ID,22 INCOGNITO_BROWSER_PROFILE_ID,
30} from "@t3tools/contracts";23} from "@t3tools/contracts";
...@@ -68,9 +61,10 @@ export const createTab = DesktopIpc.makeIpcMethod({...@@ -68,9 +61,10 @@ export const createTab = DesktopIpc.makeIpcMethod({
68 tabId,61 tabId,
69 zoomFactor,62 zoomFactor,
70 colorScheme,63 colorScheme,
64 serverTab,
71 }) {65 }) {
72 const manager = yield* PreviewManager.PreviewManager;66 const manager = yield* PreviewManager.PreviewManager;
73 yield* manager.createTab(tabId, { zoomFactor, colorScheme });67 yield* manager.createTab(tabId, { zoomFactor, colorScheme, serverTab });
74 }),68 }),
75});69});
7670
...@@ -157,6 +151,15 @@ export const hardReload = tabMethod(...@@ -157,6 +151,15 @@ export const hardReload = tabMethod(
157 "desktop.ipc.preview.hardReload",151 "desktop.ipc.preview.hardReload",
158 (manager, tabId) => manager.hardReload(tabId),152 (manager, tabId) => manager.hardReload(tabId),
159);153);
154export const setZoomFactor = DesktopIpc.makeIpcMethod({
155 channel: IpcChannels.PREVIEW_SET_ZOOM_FACTOR_CHANNEL,
156 payload: DesktopPreviewSetZoomFactorInputSchema,
157 result: Schema.Void,
158 handler: Effect.fn("desktop.ipc.preview.setZoomFactor")(function* ({ tabId, zoomFactor }) {
159 const manager = yield* PreviewManager.PreviewManager;
160 yield* manager.setZoomFactor(tabId, zoomFactor);
161 }),
162});
160export const setColorScheme = DesktopIpc.makeIpcMethod({163export const setColorScheme = DesktopIpc.makeIpcMethod({
161 channel: IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL,164 channel: IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL,
162 payload: DesktopPreviewSetColorSchemeInputSchema,165 payload: DesktopPreviewSetColorSchemeInputSchema,
...@@ -392,86 +395,6 @@ export const copyArtifactToClipboard = DesktopIpc.makeIpcMethod({...@@ -392,86 +395,6 @@ export const copyArtifactToClipboard = DesktopIpc.makeIpcMethod({
392 }),395 }),
393});396});
394397
395export const automationStatus = DesktopIpc.makeIpcMethod({
396 channel: IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL,
397 payload: DesktopPreviewTabInputSchema,
398 result: DesktopPreviewAutomationStatusSchema,
399 handler: Effect.fn("desktop.ipc.preview.automationStatus")(function* ({ tabId }) {
400 const manager = yield* PreviewManager.PreviewManager;
401 return yield* manager.automationStatus(tabId);
402 }),
403});
404
405export const automationSnapshot = DesktopIpc.makeIpcMethod({
406 channel: IpcChannels.PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL,
407 payload: DesktopPreviewTabInputSchema,
408 result: PreviewAutomationSnapshot,
409 handler: Effect.fn("desktop.ipc.preview.automationSnapshot")(function* ({ tabId }) {
410 const manager = yield* PreviewManager.PreviewManager;
411 return yield* manager.automationSnapshot(tabId);
412 }),
413});
414
415export const automationClick = DesktopIpc.makeIpcMethod({
416 channel: IpcChannels.PREVIEW_AUTOMATION_CLICK_CHANNEL,
417 payload: DesktopPreviewAutomationClickInputSchema,
418 result: Schema.Void,
419 handler: Effect.fn("desktop.ipc.preview.automationClick")(function* ({ tabId, input }) {
420 const manager = yield* PreviewManager.PreviewManager;
421 yield* manager.automationClick(tabId, input);
422 }),
423});
424
425export const automationType = DesktopIpc.makeIpcMethod({
426 channel: IpcChannels.PREVIEW_AUTOMATION_TYPE_CHANNEL,
427 payload: DesktopPreviewAutomationTypeInputSchema,
428 result: Schema.Void,
429 handler: Effect.fn("desktop.ipc.preview.automationType")(function* ({ tabId, input }) {
430 const manager = yield* PreviewManager.PreviewManager;
431 yield* manager.automationType(tabId, input);
432 }),
433});
434
435export const automationPress = DesktopIpc.makeIpcMethod({
436 channel: IpcChannels.PREVIEW_AUTOMATION_PRESS_CHANNEL,
437 payload: DesktopPreviewAutomationPressInputSchema,
438 result: Schema.Void,
439 handler: Effect.fn("desktop.ipc.preview.automationPress")(function* ({ tabId, input }) {
440 const manager = yield* PreviewManager.PreviewManager;
441 yield* manager.automationPress(tabId, input);
442 }),
443});
444
445export const automationScroll = DesktopIpc.makeIpcMethod({
446 channel: IpcChannels.PREVIEW_AUTOMATION_SCROLL_CHANNEL,
447 payload: DesktopPreviewAutomationScrollInputSchema,
448 result: Schema.Void,
449 handler: Effect.fn("desktop.ipc.preview.automationScroll")(function* ({ tabId, input }) {
450 const manager = yield* PreviewManager.PreviewManager;
451 yield* manager.automationScroll(tabId, input);
452 }),
453});
454
455export const automationEvaluate = DesktopIpc.makeIpcMethod({
456 channel: IpcChannels.PREVIEW_AUTOMATION_EVALUATE_CHANNEL,
457 payload: DesktopPreviewAutomationEvaluateInputSchema,
458 result: Schema.Unknown,
459 handler: Effect.fn("desktop.ipc.preview.automationEvaluate")(function* ({ tabId, input }) {
460 const manager = yield* PreviewManager.PreviewManager;
461 return yield* manager.automationEvaluate(tabId, input);
462 }),
463});
464
465export const automationWaitFor = DesktopIpc.makeIpcMethod({
466 channel: IpcChannels.PREVIEW_AUTOMATION_WAIT_FOR_CHANNEL,
467 payload: DesktopPreviewAutomationWaitForInputSchema,
468 result: Schema.Void,
469 handler: Effect.fn("desktop.ipc.preview.automationWaitFor")(function* ({ tabId, input }) {
470 const manager = yield* PreviewManager.PreviewManager;
471 yield* manager.automationWaitFor(tabId, input);
472 }),
473});
474
475export const saveRecording = DesktopIpc.makeIpcMethod({398export const saveRecording = DesktopIpc.makeIpcMethod({
476 channel: IpcChannels.PREVIEW_RECORDING_SAVE_CHANNEL,399 channel: IpcChannels.PREVIEW_RECORDING_SAVE_CHANNEL,
477 payload: DesktopPreviewRecordingSaveInputSchema,400 payload: DesktopPreviewRecordingSaveInputSchema,
...@@ -495,6 +418,7 @@ export const methods = [...@@ -495,6 +418,7 @@ export const methods = [
495 resetZoom,418 resetZoom,
496 hardReload,419 hardReload,
497 setColorScheme,420 setColorScheme,
421 setZoomFactor,
498 setAudioMuted,422 setAudioMuted,
499 openDevTools,423 openDevTools,
500 clearCookies,424 clearCookies,
...@@ -508,14 +432,6 @@ export const methods = [...@@ -508,14 +432,6 @@ export const methods = [
508 copyArtifactToClipboard,432 copyArtifactToClipboard,
509 openPictureInPicture,433 openPictureInPicture,
510 closePictureInPicture,434 closePictureInPicture,
511 automationStatus,
512 automationSnapshot,
513 automationClick,
514 automationType,
515 automationPress,
516 automationScroll,
517 automationEvaluate,
518 automationWaitFor,
519 startRecording,435 startRecording,
520 stopRecording,436 stopRecording,
521 saveRecording,437 saveRecording,
apps/desktop/src/main.ts+2
...@@ -67,6 +67,7 @@ import * as DesktopUpdates from "./updates/DesktopUpdates.ts";...@@ -67,6 +67,7 @@ import * as DesktopUpdates from "./updates/DesktopUpdates.ts";
67import * as BrowserImport from "./preview/BrowserImport/BrowserImport.ts";67import * as BrowserImport from "./preview/BrowserImport/BrowserImport.ts";
68import * as LinuxBrowserSecret from "./preview/BrowserImport/LinuxBrowserSecret.ts";68import * as LinuxBrowserSecret from "./preview/BrowserImport/LinuxBrowserSecret.ts";
69import * as BrowserSession from "./preview/BrowserSession.ts";69import * as BrowserSession from "./preview/BrowserSession.ts";
70import * as DesktopBrowserHost from "./preview/DesktopBrowserHost.ts";
70import * as PreviewManager from "./preview/Manager.ts";71import * as PreviewManager from "./preview/Manager.ts";
71import * as DesktopWindow from "./window/DesktopWindow.ts";72import * as DesktopWindow from "./window/DesktopWindow.ts";
72import * as DesktopWslBackend from "./wsl/DesktopWslBackend.ts";73import * as DesktopWslBackend from "./wsl/DesktopWslBackend.ts";
...@@ -161,6 +162,7 @@ const layerDesktopServerExposure = DesktopServerExposure.layer.pipe(...@@ -161,6 +162,7 @@ const layerDesktopServerExposure = DesktopServerExposure.layer.pipe(
161);162);
162163
163const layerDesktopPreview = PreviewManager.layer.pipe(164const layerDesktopPreview = PreviewManager.layer.pipe(
165 Layer.provideMerge(DesktopBrowserHost.layer),
164 // Merged rather than provided so the IPC handlers can reach the import166 // Merged rather than provided so the IPC handlers can reach the import
165 // service alongside the manager; both sit on the same BrowserSession.167 // service alongside the manager; both sit on the same BrowserSession.
166 Layer.provideMerge(BrowserImport.layer.pipe(Layer.provide(LinuxBrowserSecret.layer))),168 Layer.provideMerge(BrowserImport.layer.pipe(Layer.provide(LinuxBrowserSecret.layer))),
apps/desktop/src/preload.ts+3-18
...@@ -303,6 +303,7 @@ contextBridge.exposeInMainWorld("desktopBridge", {...@@ -303,6 +303,7 @@ contextBridge.exposeInMainWorld("desktopBridge", {
303 ipcRenderer.invoke(IpcChannels.PREVIEW_CREATE_TAB_CHANNEL, {303 ipcRenderer.invoke(IpcChannels.PREVIEW_CREATE_TAB_CHANNEL, {
304 tabId,304 tabId,
305 zoomFactor: defaults?.zoomFactor,305 zoomFactor: defaults?.zoomFactor,
306 serverTab: defaults?.serverTab,
306 colorScheme: defaults?.colorScheme,307 colorScheme: defaults?.colorScheme,
307 }),308 }),
308 closeTab: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_CLOSE_TAB_CHANNEL, { tabId }),309 closeTab: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_CLOSE_TAB_CHANNEL, { tabId }),
...@@ -316,6 +317,8 @@ contextBridge.exposeInMainWorld("desktopBridge", {...@@ -316,6 +317,8 @@ contextBridge.exposeInMainWorld("desktopBridge", {
316 zoomIn: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_ZOOM_IN_CHANNEL, { tabId }),317 zoomIn: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_ZOOM_IN_CHANNEL, { tabId }),
317 zoomOut: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_ZOOM_OUT_CHANNEL, { tabId }),318 zoomOut: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_ZOOM_OUT_CHANNEL, { tabId }),
318 resetZoom: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_RESET_ZOOM_CHANNEL, { tabId }),319 resetZoom: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_RESET_ZOOM_CHANNEL, { tabId }),
320 setZoomFactor: (tabId, zoomFactor) =>
321 ipcRenderer.invoke(IpcChannels.PREVIEW_SET_ZOOM_FACTOR_CHANNEL, { tabId, zoomFactor }),
319 hardReload: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_HARD_RELOAD_CHANNEL, { tabId }),322 hardReload: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_HARD_RELOAD_CHANNEL, { tabId }),
320 setColorScheme: (tabId, colorScheme) =>323 setColorScheme: (tabId, colorScheme) =>
321 ipcRenderer.invoke(IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL, { tabId, colorScheme }),324 ipcRenderer.invoke(IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL, { tabId, colorScheme }),
...@@ -379,24 +382,6 @@ contextBridge.exposeInMainWorld("desktopBridge", {...@@ -379,24 +382,6 @@ contextBridge.exposeInMainWorld("desktopBridge", {
379 ipcRenderer.removeListener(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, wrappedListener);382 ipcRenderer.removeListener(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, wrappedListener);
380 },383 },
381 },384 },
382 automation: {
383 status: (tabId) =>
384 ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL, { tabId }),
385 snapshot: (tabId) =>
386 ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL, { tabId }),
387 click: (tabId, input) =>
388 ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_CLICK_CHANNEL, { tabId, input }),
389 type: (tabId, input) =>
390 ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_TYPE_CHANNEL, { tabId, input }),
391 press: (tabId, input) =>
392 ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_PRESS_CHANNEL, { tabId, input }),
393 scroll: (tabId, input) =>
394 ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_SCROLL_CHANNEL, { tabId, input }),
395 evaluate: (tabId, input) =>
396 ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_EVALUATE_CHANNEL, { tabId, input }),
397 waitFor: (tabId, input) =>
398 ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_WAIT_FOR_CHANNEL, { tabId, input }),
399 },
400 onStateChange: (listener) => {385 onStateChange: (listener) => {
401 const wrappedListener = (386 const wrappedListener = (
402 _event: Electron.IpcRendererEvent,387 _event: Electron.IpcRendererEvent,
apps/desktop/src/preview/CdpRelay.test.ts created+174
...@@ -0,0 +1,174 @@
1import { describe, expect, it, vi } from "vite-plus/test";
2
3import { createCdpRelayConnection, type CdpRelayTarget } from "./CdpRelay.ts";
4
5const makeTarget = (
6 send: CdpRelayTarget["send"],
7 setDownloadDirectory: CdpRelayTarget["setDownloadDirectory"] = () => {},
8): CdpRelayTarget => ({
9 send,
10 targetId: async () => "GUEST-TARGET",
11 url: () => "http://localhost:4719/",
12 title: () => "Fixture",
13 userAgent: () => "Electron",
14 setDownloadDirectory,
15});
16
17// Each relay reply resolves after a few microtasks; one macrotask lets them all land.
18const settle = () => new Promise<void>((resolve) => setImmediate(resolve));
19
20describe("CDP relay", () => {
21 it("announces only the tab's page, under its real target id", async () => {
22 const written: Array<Record<string, unknown>> = [];
23 const relay = createCdpRelayConnection(makeTarget(vi.fn()), (raw) =>
24 written.push(JSON.parse(raw)),
25 );
26 relay.receive(JSON.stringify({ id: 1, method: "Target.setAutoAttach", params: {} }));
27 relay.receive(JSON.stringify({ id: 2, method: "Target.createTarget", params: {} }));
28 await settle();
29 const attach = written.findIndex((message) => message["id"] === 1);
30 // The page is announced right after the auto-attach reply, never before it.
31 expect(written.slice(attach, attach + 2)).toEqual([
32 { id: 1, result: {} },
33 {
34 method: "Target.attachedToTarget",
35 params: expect.objectContaining({
36 sessionId: "t3-preview-page",
37 targetInfo: expect.objectContaining({ targetId: "GUEST-TARGET", type: "page" }),
38 }),
39 },
40 ]);
41 expect(written).toContainEqual({
42 id: 2,
43 error: expect.objectContaining({ message: expect.stringContaining("createTarget") }),
44 });
45 });
46
47 it("forwards page commands to the tab and lets a slow one finish last", async () => {
48 const pending: Array<() => void> = [];
49 const send = vi.fn(
50 (method: string) =>
51 new Promise<unknown>((resolve) => pending.push(() => resolve({ method }))),
52 );
53 const written: Array<Record<string, unknown>> = [];
54 const relay = createCdpRelayConnection(makeTarget(send), (raw) =>
55 written.push(JSON.parse(raw)),
56 );
57 relay.receive(
58 JSON.stringify({ id: 5, method: "Page.captureScreenshot", sessionId: "t3-preview-page" }),
59 );
60 relay.receive(JSON.stringify({ id: 6, method: "DOM.enable", sessionId: "iframe-session" }));
61 // A slow screenshot must not hold up the reply behind it.
62 pending[1]!();
63 await settle();
64 expect(written).toEqual([
65 { id: 6, result: { method: "DOM.enable" }, sessionId: "iframe-session" },
66 ]);
67 pending[0]!();
68 await settle();
69 expect(send).toHaveBeenNthCalledWith(1, "Page.captureScreenshot", {}, undefined);
70 expect(send).toHaveBeenNthCalledWith(2, "DOM.enable", {}, "iframe-session");
71 expect(written.at(-1)).toEqual({
72 id: 5,
73 result: { method: "Page.captureScreenshot" },
74 sessionId: "t3-preview-page",
75 });
76 });
77
78 it("routes tab events to the page session once the page is announced", async () => {
79 const written: Array<Record<string, unknown>> = [];
80 const relay = createCdpRelayConnection(makeTarget(vi.fn()), (raw) =>
81 written.push(JSON.parse(raw)),
82 );
83 relay.event("Page.loadEventFired", {}, undefined);
84 expect(written).toEqual([]);
85 relay.receive(JSON.stringify({ id: 1, method: "Target.setAutoAttach", params: {} }));
86 await settle();
87 // Electron passes an empty session id for the page's own events.
88 relay.event("Page.loadEventFired", { timestamp: 1 }, "");
89 relay.event("Runtime.consoleAPICalled", {}, "iframe-session");
90 expect(written.slice(-2)).toEqual([
91 { method: "Page.loadEventFired", params: { timestamp: 1 }, sessionId: "t3-preview-page" },
92 { method: "Runtime.consoleAPICalled", params: {}, sessionId: "iframe-session" },
93 ]);
94 });
95});
96
97describe("CDP relay extra sessions", () => {
98 it("gives a second page session the page's commands and events", async () => {
99 const send = vi.fn(async () => ({}));
100 const written: Array<Record<string, unknown>> = [];
101 const relay = createCdpRelayConnection(makeTarget(send), (raw) =>
102 written.push(JSON.parse(raw)),
103 );
104 relay.receive(JSON.stringify({ id: 1, method: "Target.setAutoAttach", params: {} }));
105 relay.receive(JSON.stringify({ id: 2, method: "Target.attachToBrowserTarget" }));
106 await settle();
107 const browserSession = (written.find((m) => m["id"] === 2)!["result"] as { sessionId: string })
108 .sessionId;
109 relay.receive(
110 JSON.stringify({
111 id: 3,
112 method: "Target.attachToTarget",
113 params: { targetId: "GUEST-TARGET", flatten: true },
114 sessionId: browserSession,
115 }),
116 );
117 relay.receive(
118 JSON.stringify({
119 id: 4,
120 method: "Target.attachToTarget",
121 params: { targetId: "SOMETHING-ELSE" },
122 sessionId: browserSession,
123 }),
124 );
125 await settle();
126 const extra = (written.find((m) => m["id"] === 3)!["result"] as { sessionId: string })
127 .sessionId;
128 expect(written.find((m) => m["id"] === 4)).toHaveProperty("error");
129 relay.receive(JSON.stringify({ id: 5, method: "Page.startScreencast", sessionId: extra }));
130 await settle();
131 expect(send).toHaveBeenLastCalledWith("Page.startScreencast", {}, undefined);
132 written.length = 0;
133 relay.event("Page.screencastFrame", { data: "x" }, undefined);
134 expect(written.map((m) => m["sessionId"])).toEqual(["t3-preview-page", extra]);
135 });
136});
137
138describe("CDP relay downloads", () => {
139 it("applies the server's download directory and reports downloads on the root session", async () => {
140 const send = vi.fn(async () => ({}));
141 const directories: Array<string | null> = [];
142 const written: Array<Record<string, unknown>> = [];
143 const relay = createCdpRelayConnection(
144 makeTarget(send, (directory) => directories.push(directory)),
145 (raw) => written.push(JSON.parse(raw)),
146 );
147 relay.receive(JSON.stringify({ id: 1, method: "Target.setAutoAttach", params: {} }));
148 relay.receive(
149 JSON.stringify({
150 id: 2,
151 method: "Browser.setDownloadBehavior",
152 params: {
153 behavior: "allowAndName",
154 browserContextId: "t3-preview",
155 downloadPath: "/srv/artifacts",
156 eventsEnabled: true,
157 },
158 }),
159 );
160 await settle();
161 expect(directories).toEqual(["/srv/artifacts"]);
162 // The tab's own debugger has no such context, so the id stays here.
163 expect(send).toHaveBeenCalledWith(
164 "Browser.setDownloadBehavior",
165 { behavior: "allowAndName", downloadPath: "/srv/artifacts", eventsEnabled: true },
166 undefined,
167 );
168 written.length = 0;
169 relay.event("Browser.downloadWillBegin", { guid: "g1", suggestedFilename: "r.csv" }, "");
170 expect(written).toEqual([
171 { method: "Browser.downloadWillBegin", params: { guid: "g1", suggestedFilename: "r.csv" } },
172 ]);
173 });
174});
apps/desktop/src/preview/CdpRelay.ts created+219
...@@ -0,0 +1,219 @@
1/**
2 * Presents one preview tab's debugger as a minimal CDP browser endpoint, so
3 * the environment server can drive a desktop `<webview>` with the same
4 * Playwright engine it uses for headless tabs.
5 *
6 * Only the tab's own page is reachable. Browser-level commands Playwright sends
7 * while connecting are answered here, and every page command goes to the tab's
8 * `webContents.debugger`. Electron allows one debugger client per webContents,
9 * so the relay shares the attach the preview manager already holds.
10 */
11
12export interface CdpRelayTarget {
13 /** Sends a command to the tab's page, or to a child session such as an iframe. */
14 readonly send: (
15 method: string,
16 params: Record<string, unknown>,
17 sessionId: string | undefined,
18 ) => Promise<unknown>;
19 /** The page's own CDP target id; Playwright keys the main frame by it. */
20 readonly targetId: () => Promise<string>;
21 readonly url: () => string;
22 readonly title: () => string;
23 readonly userAgent: () => string;
24 /**
25 * Where the server wants this tab's downloads, named by their CDP guid. The
26 * desktop app and the server it launched share a disk, so the server's
27 * Playwright reads the file where it asked for it.
28 */
29 readonly setDownloadDirectory: (directory: string | null) => void;
30}
31
32export interface CdpRelayConnection {
33 /** Feed one message from the server. */
34 readonly receive: (raw: string) => void;
35 /** Feed one debugger event from the tab. */
36 readonly event: (method: string, params: unknown, sessionId: string | undefined) => void;
37}
38
39interface CdpCommand {
40 readonly id: number;
41 readonly method: string;
42 readonly params?: Record<string, unknown>;
43 readonly sessionId?: string;
44}
45
46const isCommand = (value: unknown): value is CdpCommand =>
47 typeof value === "object" &&
48 value !== null &&
49 typeof (value as { id?: unknown }).id === "number" &&
50 typeof (value as { method?: unknown }).method === "string";
51
52const BROWSER_CONTEXT_ID = "t3-preview";
53/** Marks an event to send right after a command's reply. */
54const ANNOUNCE = Symbol("announce");
55/** The page's root session id, as Playwright sees it. */
56const PAGE_SESSION_ID = "t3-preview-page";
57
58/** Browser-level commands that only need acknowledging for a page the desktop owns. */
59const ACKNOWLEDGED = new Set([
60 "Target.setDiscoverTargets",
61 "Browser.grantPermissions",
62 "Browser.resetPermissions",
63 "Browser.cancelDownload",
64]);
65
66export function createCdpRelayConnection(
67 target: CdpRelayTarget,
68 write: (message: string) => void,
69): CdpRelayConnection {
70 const send = (message: Record<string, unknown>) => write(JSON.stringify(message));
71 let attached = false;
72 /**
73 * Sessions this relay invented. Extra page sessions (Playwright's
74 * `newCDPSession`) all reach the one real debugger session, so each gets the
75 * page's events; other ids are the page's own child sessions, such as iframes.
76 */
77 const sessions = new Map<string, "browser" | "page">([[PAGE_SESSION_ID, "page"]]);
78 let sessionSequence = 0;
79 const newSession = (kind: "browser" | "page") => {
80 const sessionId = `t3-preview-${kind}-${++sessionSequence}`;
81 sessions.set(sessionId, kind);
82 return sessionId;
83 };
84 const targetInfo = async () => ({
85 targetId: await target.targetId(),
86 type: "page",
87 title: target.title(),
88 url: target.url(),
89 attached: true,
90 canAccessOpener: false,
91 browserContextId: BROWSER_CONTEXT_ID,
92 });
93
94 const browserCommand = async (command: CdpCommand): Promise<unknown> => {
95 if (ACKNOWLEDGED.has(command.method)) return {};
96 switch (command.method) {
97 case "Browser.setDownloadBehavior": {
98 // The tab's debugger reports downloads as Chromium does, so the server
99 // sees them as its own. Only the directory needs the desktop's help.
100 const directory = command.params?.["downloadPath"];
101 const allowed = command.params?.["behavior"] !== "deny" && typeof directory === "string";
102 target.setDownloadDirectory(allowed ? directory : null);
103 const { browserContextId: _ignored, ...params } = command.params ?? {};
104 return target.send("Browser.setDownloadBehavior", params, undefined);
105 }
106 case "Browser.getVersion":
107 return {
108 protocolVersion: "1.3",
109 product: "Electron",
110 revision: "",
111 userAgent: target.userAgent(),
112 jsVersion: "",
113 };
114 case "Target.getTargetInfo":
115 return {
116 targetInfo: {
117 targetId: "browser",
118 type: "browser",
119 title: "",
120 url: "",
121 attached: true,
122 canAccessOpener: false,
123 },
124 };
125 case "Target.getBrowserContexts":
126 return { browserContextIds: [] };
127 case "Target.getTargets":
128 return { targetInfos: [await targetInfo()] };
129 case "Target.attachToBrowserTarget":
130 return { sessionId: newSession("browser") };
131 case "Target.attachToTarget": {
132 const targetId = command.params?.["targetId"];
133 if (targetId !== (await target.targetId()))
134 throw new Error("Only this preview tab's page can be attached.");
135 return { sessionId: newSession("page") };
136 }
137 case "Target.detachFromTarget": {
138 const sessionId = command.params?.["sessionId"];
139 if (typeof sessionId === "string" && sessionId !== PAGE_SESSION_ID)
140 sessions.delete(sessionId);
141 return {};
142 }
143 case "Target.setAutoAttach": {
144 if (attached) return {};
145 attached = true;
146 // The page is announced once, right after this reply, as Chromium does.
147 const info = await targetInfo();
148 return {
149 [ANNOUNCE]: {
150 method: "Target.attachedToTarget",
151 params: { sessionId: PAGE_SESSION_ID, targetInfo: info, waitingForDebugger: false },
152 },
153 };
154 }
155 default:
156 throw new Error(`Not supported for a desktop preview tab: ${command.method}`);
157 }
158 };
159
160 const pageCommand = (command: CdpCommand): Promise<unknown> => {
161 // The page is already running; nothing waits for a debugger.
162 if (command.method === "Runtime.runIfWaitingForDebugger") return Promise.resolve({});
163 const child = sessions.get(command.sessionId!) === "page" ? undefined : command.sessionId;
164 return target.send(command.method, command.params ?? {}, child);
165 };
166
167 return {
168 receive: (raw) => {
169 let command: unknown;
170 try {
171 command = JSON.parse(raw);
172 } catch {
173 return;
174 }
175 if (!isCommand(command)) return;
176 const result =
177 command.sessionId === undefined || sessions.get(command.sessionId) === "browser"
178 ? browserCommand(command)
179 : pageCommand(command);
180 const route = command.sessionId === undefined ? {} : { sessionId: command.sessionId };
181 // Replies leave as commands finish, as Chromium's do, so a slow command
182 // such as a screenshot never holds up the ones behind it.
183 void result.then(
184 (value) => {
185 const announce =
186 typeof value === "object" && value !== null && ANNOUNCE in value
187 ? (value as { [ANNOUNCE]: Record<string, unknown> })[ANNOUNCE]
188 : undefined;
189 send({ id: command.id, result: announce ? {} : (value ?? {}), ...route });
190 if (announce) send(announce);
191 },
192 (cause: unknown) =>
193 send({
194 id: command.id,
195 error: {
196 code: -32000,
197 message: cause instanceof Error ? cause.message : String(cause),
198 },
199 ...route,
200 }),
201 );
202 },
203 event: (method, params, sessionId) => {
204 if (!attached) return;
205 // Download events are the browser's; Playwright listens on its root session.
206 if (method.startsWith("Browser.download")) {
207 send({ method, params });
208 return;
209 }
210 // Electron reports the page's own events with an empty session id.
211 if (sessionId) {
212 send({ method, params, sessionId });
213 return;
214 }
215 for (const [id, kind] of sessions)
216 if (kind === "page") send({ method, params, sessionId: id });
217 },
218 };
219}
apps/desktop/src/preview/DesktopBrowserHost.test.ts created+122
...@@ -0,0 +1,122 @@
1// @effect-diagnostics nodeBuiltinImport:off - Stands in for an Electron debugger.
2import { describe, expect, it } from "@effect/vitest";
3import { DesktopBrowserEvent } from "@t3tools/contracts";
4import * as Effect from "effect/Effect";
5import * as Fiber from "effect/Fiber";
6import * as Schema from "effect/Schema";
7import * as Stream from "effect/Stream";
8import * as NodeEvents from "node:events";
9
10import * as DesktopBrowserHost from "./DesktopBrowserHost.ts";
11
12const decodeEvent = Schema.decodeUnknownSync(Schema.fromJsonString(DesktopBrowserEvent));
13const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
14const decodeCdpReply = Schema.decodeUnknownSync(
15 Schema.fromJsonString(Schema.Struct({ id: Schema.Number })),
16);
17const key = { threadId: "thread-1", tabId: "tab-1" };
18
19/** A tab's webContents and debugger, with the debugger's commands left pending until released. */
20const makeDebuggee = () => {
21 const emitter = new NodeEvents.EventEmitter();
22 const pending: Array<() => void> = [];
23 const debuggee = Object.assign(emitter, {
24 sendCommand: (method: string) =>
25 new Promise((resolve) => {
26 if (method === "Target.getTargetInfo") {
27 resolve({ targetInfo: { targetId: "GUEST" } });
28 return;
29 }
30 pending.push(() => resolve({ method }));
31 }),
32 });
33 const webContents = {
34 getURL: () => "http://localhost/",
35 getTitle: () => "Page",
36 getUserAgent: () => "Electron",
37 };
38 return {
39 tab: {
40 webContents: webContents as unknown as Electron.WebContents,
41 debugger: debuggee as unknown as Electron.Debugger,
42 },
43 emit: (method: string, params: unknown) => emitter.emit("message", {}, method, params, ""),
44 release: () => pending.splice(0).forEach((resolve) => resolve()),
45 };
46};
47
48/** Reads `count` events from one backend's subscription. */
49const takeEvents = (host: DesktopBrowserHost.DesktopBrowserHost["Service"], count: number) =>
50 host.events.pipe(
51 Stream.take(count),
52 Stream.runCollect,
53 Effect.map((lines) => lines.map((line) => decodeEvent(new TextDecoder().decode(line)))),
54 );
55
56describe("DesktopBrowserHost", () => {
57 it.effect("announces tabs already attached to a backend that starts later", () =>
58 Effect.gen(function* () {
59 const host = yield* DesktopBrowserHost.make;
60 host.attach(key, makeDebuggee().tab);
61 // A restarted backend subscribes after the attach and still hears it.
62 expect(yield* takeEvents(host, 1)).toEqual([{ type: "attached", ...key }]);
63 expect(yield* takeEvents(host, 1)).toEqual([{ type: "attached", ...key }]);
64 }),
65 );
66
67 it.effect("drops replies from a relay the server released", () =>
68 Effect.gen(function* () {
69 const host = yield* DesktopBrowserHost.make;
70 const debuggee = makeDebuggee();
71 host.attach(key, debuggee.tab);
72 const reader = yield* takeEvents(host, 2).pipe(Effect.forkScoped);
73 // Wait until the reader has received the announcement.
74 yield* Effect.yieldNow;
75 const command = (id: number, method: string) =>
76 host.handleCommandLine(
77 encodeJson({
78 type: "cdp",
79 ...key,
80 message: encodeJson({ id, method, sessionId: "t3-preview-page" }),
81 }),
82 );
83 yield* command(1, "Page.captureScreenshot");
84 yield* host.handleCommandLine(encodeJson({ type: "release", ...key }));
85 yield* command(2, "DOM.enable");
86 debuggee.release();
87 yield* Effect.promise(() => new Promise((resolve) => setImmediate(resolve)));
88 const [, reply] = yield* Fiber.join(reader);
89 // Only the new connection's reply arrives; the old one's id could collide.
90 expect(reply).toMatchObject({ type: "cdp" });
91 expect(decodeCdpReply((reply as { message: string }).message).id).toBe(2);
92 }).pipe(Effect.scoped),
93 );
94
95 it.effect("saves a server tab's download under its CDP guid where the server asked", () =>
96 Effect.gen(function* () {
97 const host = yield* DesktopBrowserHost.make;
98 const debuggee = makeDebuggee();
99 host.attach(key, debuggee.tab);
100 const paths: Array<string> = [];
101 const item = {
102 setSavePath: (path: string) => void paths.push(path),
103 } as unknown as Electron.DownloadItem;
104 // Before the server sets a directory, Electron keeps its own handling.
105 expect(host.placeDownload(debuggee.tab.webContents, item)).toBe(false);
106 yield* host.handleCommandLine(
107 encodeJson({
108 type: "cdp",
109 ...key,
110 message: encodeJson({
111 id: 1,
112 method: "Browser.setDownloadBehavior",
113 params: { behavior: "allowAndName", downloadPath: "/srv/downloads" },
114 }),
115 }),
116 );
117 debuggee.emit("Browser.downloadWillBegin", { guid: "guid-1", suggestedFilename: "r.csv" });
118 expect(host.placeDownload(debuggee.tab.webContents, item)).toBe(true);
119 expect(paths).toEqual(["/srv/downloads/guid-1"]);
120 }),
121 );
122});
apps/desktop/src/preview/DesktopBrowserHost.ts created+225
...@@ -0,0 +1,225 @@
1// @effect-diagnostics nodeBuiltinImport:off - Names download files on the shared disk.
2/**
3 * The desktop end of the desktop browser channel (see `DesktopBrowserEvent` in
4 * contracts). The primary backend gets two file descriptors at spawn: this
5 * service writes events for the desktop's tabs to one and reads commands from
6 * the other. Each attached tab is reachable only through its `CdpRelay`.
7 *
8 * A tab is attached once its `<webview>` registers with a key the web app
9 * gave it. The preview manager owns the tab's single debugger session and hands
10 * it here; the relay shares it.
11 */
12import {
13 DesktopBrowserCommand,
14 DesktopBrowserEvent,
15 type DesktopBrowserEvent as DesktopBrowserEventType,
16} from "@t3tools/contracts";
17import * as Context from "effect/Context";
18import * as NodePath from "node:path";
19import * as Effect from "effect/Effect";
20import * as Layer from "effect/Layer";
21import * as Option from "effect/Option";
22import * as PubSub from "effect/PubSub";
23import * as Schema from "effect/Schema";
24import * as Stream from "effect/Stream";
25
26import { createCdpRelayConnection, type CdpRelayConnection } from "./CdpRelay.ts";
27
28const encodeEvent = Schema.encodeSync(Schema.fromJsonString(DesktopBrowserEvent));
29const decodeCommand = Schema.decodeUnknownOption(Schema.fromJsonString(DesktopBrowserCommand));
30const lineEncoder = new TextEncoder();
31
32export interface DesktopBrowserTabKey {
33 readonly threadId: string;
34 readonly tabId: string;
35}
36
37/** A tab's debugger, as the preview manager lends it to the relay. */
38export interface DesktopBrowserTabDebugger {
39 readonly webContents: Electron.WebContents;
40 readonly debugger: Electron.Debugger;
41}
42
43const keyOf = ({ threadId, tabId }: DesktopBrowserTabKey) => `${threadId}\u0000${tabId}`;
44
45interface AttachedTab {
46 readonly key: DesktopBrowserTabKey;
47 readonly debuggee: DesktopBrowserTabDebugger;
48 relay: CdpRelayConnection | null;
49 /** Where the server wants this tab's downloads; null keeps Electron's own handling. */
50 downloadDirectory: string | null;
51 /** The guid CDP gave the download that is about to start. */
52 pendingDownloadGuid: string | null;
53 readonly onMessage: (
54 event: Electron.Event,
55 method: string,
56 params: unknown,
57 sessionId: string,
58 ) => void;
59}
60
61export class DesktopBrowserHost extends Context.Service<
62 DesktopBrowserHost,
63 {
64 /**
65 * Newline-delimited events for a backend's browser fd. Each run starts by
66 * announcing the tabs already attached, so a restarted backend hears them.
67 */
68 readonly events: Stream.Stream<Uint8Array>;
69 /** One line from the backend's browser control fd. */
70 readonly handleCommandLine: (line: string) => Effect.Effect<void>;
71 /** Offers a server tab's `<webview>` to the server. */
72 readonly attach: (key: DesktopBrowserTabKey, debuggee: DesktopBrowserTabDebugger) => void;
73 /** Withdraws it: closed, swapped, crashed, or devtools needs the debugger. */
74 readonly detach: (key: DesktopBrowserTabKey) => void;
75 /** Points a server tab's download at the server; false for any other download. */
76 readonly placeDownload: (source: Electron.WebContents, item: Electron.DownloadItem) => boolean;
77 /** The agent's cursor positions for attached tabs, keyed by their server tab. */
78 readonly pointers: Stream.Stream<{
79 readonly key: DesktopBrowserTabKey;
80 readonly phase: "move" | "click";
81 readonly x: number;
82 readonly y: number;
83 }>;
84 }
85>()("@t3tools/desktop/preview/DesktopBrowserHost") {}
86
87export const make = Effect.gen(function* () {
88 const outbox = yield* PubSub.unbounded<DesktopBrowserEventType>();
89 const pointers = yield* PubSub.sliding<{
90 readonly key: DesktopBrowserTabKey;
91 readonly phase: "move" | "click";
92 readonly x: number;
93 readonly y: number;
94 }>(16);
95 const runFork = Effect.runForkWith(yield* Effect.context<never>());
96 const tabs = new Map<string, AttachedTab>();
97 const emit = (event: DesktopBrowserEventType) => runFork(PubSub.publish(outbox, event));
98
99 const relayFor = (tab: AttachedTab) => {
100 if (tab.relay) return tab.relay;
101 const { webContents, debugger: debuggee } = tab.debuggee;
102 const relay: CdpRelayConnection = createCdpRelayConnection(
103 {
104 send: (method, params, sessionId) =>
105 sessionId === undefined
106 ? debuggee.sendCommand(method, params)
107 : debuggee.sendCommand(method, params, sessionId),
108 targetId: () =>
109 debuggee
110 .sendCommand("Target.getTargetInfo")
111 .then((result: { targetInfo: { targetId: string } }) => result.targetInfo.targetId),
112 url: () => webContents.getURL(),
113 title: () => webContents.getTitle(),
114 userAgent: () => webContents.getUserAgent(),
115 setDownloadDirectory: (directory) => {
116 tab.downloadDirectory = directory;
117 },
118 },
119 // A released relay's late replies belong to a connection that is gone.
120 (message) => {
121 if (tab.relay === relay && tabs.get(keyOf(tab.key)) === tab) {
122 emit({ type: "cdp", ...tab.key, message });
123 }
124 },
125 );
126 tab.relay = relay;
127 return relay;
128 };
129
130 /**
131 * Saves a download from a server tab where the server's Playwright expects
132 * it. Without a path Electron would open its Save dialog over the app for a
133 * file the agent asked for. CDP names the download just before this runs.
134 */
135 const placeDownload = (source: Electron.WebContents, item: Electron.DownloadItem) => {
136 const tab = [...tabs.values()].find(
137 (candidate) => candidate.debuggee.webContents === source && candidate.downloadDirectory,
138 );
139 if (!tab?.downloadDirectory || !tab.pendingDownloadGuid) return false;
140 item.setSavePath(NodePath.join(tab.downloadDirectory, tab.pendingDownloadGuid));
141 tab.pendingDownloadGuid = null;
142 return true;
143 };
144
145 const detach = (key: DesktopBrowserTabKey) => {
146 const id = keyOf(key);
147 const tab = tabs.get(id);
148 if (!tab) return;
149 tabs.delete(id);
150 tab.debuggee.debugger.off("message", tab.onMessage);
151 emit({ type: "detached", ...key });
152 };
153
154 const attach = (key: DesktopBrowserTabKey, debuggee: DesktopBrowserTabDebugger) => {
155 const id = keyOf(key);
156 if (tabs.get(id)?.debuggee.webContents === debuggee.webContents) return;
157 detach(key);
158 const tab: AttachedTab = {
159 key,
160 debuggee,
161 relay: null,
162 downloadDirectory: null,
163 pendingDownloadGuid: null,
164 onMessage: (_event, method, params, sessionId) => {
165 if (method === "Browser.downloadWillBegin") {
166 const guid = (params as { guid?: unknown } | undefined)?.guid;
167 tab.pendingDownloadGuid = typeof guid === "string" ? guid : null;
168 }
169 tab.relay?.event(method, params, sessionId);
170 },
171 };
172 tabs.set(id, tab);
173 debuggee.debugger.on("message", tab.onMessage);
174 emit({ type: "attached", ...key });
175 };
176
177 const handleCommandLine = (line: string) =>
178 Effect.sync(() => {
179 const command = decodeCommand(line);
180 if (Option.isNone(command)) return;
181 const tab = tabs.get(keyOf(command.value));
182 if (!tab) return;
183 if (command.value.type === "pointer") {
184 const { threadId, tabId, phase, x, y } = command.value;
185 runFork(PubSub.publish(pointers, { key: { threadId, tabId }, phase, x, y }));
186 return;
187 }
188 if (command.value.type === "release") {
189 // A new server connection starts with a fresh relay and fresh sessions.
190 tab.relay = null;
191 return;
192 }
193 relayFor(tab).receive(command.value.message);
194 });
195
196 // Read when a backend starts, not when the host is built.
197 const announceAll = Effect.suspend(() =>
198 Effect.forEach(
199 [...tabs.values()],
200 (tab) => {
201 tab.relay = null;
202 return PubSub.publish(outbox, { type: "attached", ...tab.key });
203 },
204 { discard: true },
205 ),
206 );
207
208 return DesktopBrowserHost.of({
209 pointers: Stream.fromPubSub(pointers),
210 // Subscribes before announcing, so no attach falls between the two.
211 events: Stream.unwrap(
212 Effect.gen(function* () {
213 const subscription = yield* PubSub.subscribe(outbox);
214 yield* announceAll;
215 return Stream.fromSubscription(subscription);
216 }),
217 ).pipe(Stream.map((event) => lineEncoder.encode(`${encodeEvent(event)}\n`))),
218 handleCommandLine,
219 attach,
220 detach,
221 placeDownload,
222 });
223});
224
225export const layer = Layer.effect(DesktopBrowserHost, make);
apps/desktop/src/preview/Manager.test.ts+37-905
...@@ -1,5 +1,3 @@...@@ -1,5 +1,3 @@
1import * as NodeVM from "node:vm";
2import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
3import { it as effectIt } from "@effect/vitest";1import { it as effectIt } from "@effect/vitest";
4import { DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER } from "@t3tools/contracts";2import { DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER } from "@t3tools/contracts";
5import type {3import type {
...@@ -13,11 +11,12 @@ import * as Effect from "effect/Effect";...@@ -13,11 +11,12 @@ import * as Effect from "effect/Effect";
13import * as Exit from "effect/Exit";11import * as Exit from "effect/Exit";
14import * as FileSystem from "effect/FileSystem";12import * as FileSystem from "effect/FileSystem";
15import * as Fiber from "effect/Fiber";13import * as Fiber from "effect/Fiber";
14import * as Queue from "effect/Queue";
15import * as Stream from "effect/Stream";
16import * as Layer from "effect/Layer";16import * as Layer from "effect/Layer";
17import * as Logger from "effect/Logger";17import * as Logger from "effect/Logger";
18import * as Option from "effect/Option";18import * as Option from "effect/Option";
19import * as Path from "effect/Path";19import * as Path from "effect/Path";
20import * as Schema from "effect/Schema";
21import type * as Scope from "effect/Scope";20import type * as Scope from "effect/Scope";
22import { TestClock } from "effect/testing";21import { TestClock } from "effect/testing";
23import { beforeEach, describe, expect, it, vi } from "vite-plus/test";22import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
...@@ -26,6 +25,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";...@@ -26,6 +25,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
26import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts";25import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts";
27import * as ElectronWindow from "../electron/ElectronWindow.ts";26import * as ElectronWindow from "../electron/ElectronWindow.ts";
28import * as BrowserSession from "./BrowserSession.ts";27import * as BrowserSession from "./BrowserSession.ts";
28import * as DesktopBrowserHost from "./DesktopBrowserHost.ts";
29import * as PreviewManager from "./Manager.ts";29import * as PreviewManager from "./Manager.ts";
3030
31describe("fitPictureInPictureContentSize", () => {31describe("fitPictureInPictureContentSize", () => {
...@@ -276,14 +276,13 @@ const layer = PreviewManager.layer.pipe(...@@ -276,14 +276,13 @@ const layer = PreviewManager.layer.pipe(
276 shutdown: Effect.void,276 shutdown: Effect.void,
277 }),277 }),
278 ),278 ),
279 Layer.provideMerge(DesktopBrowserHost.layer),
279 Layer.provideMerge(layerBrowserSession),280 Layer.provideMerge(layerBrowserSession),
280 Layer.provideMerge(layerEnvironment),281 Layer.provideMerge(layerEnvironment),
281 Layer.provideMerge(layerFileSystem),282 Layer.provideMerge(layerFileSystem),
282 Layer.provideMerge(Path.layer),283 Layer.provideMerge(Path.layer),
283 Layer.provideMerge(NodeCrypto.layer),
284 Layer.provideMerge(Layer.succeed(HostProcessPlatform, "darwin")),284 Layer.provideMerge(Layer.succeed(HostProcessPlatform, "darwin")),
285);285);
286const encodePreviewManagerError = Schema.encodeSync(PreviewManager.PreviewManagerError);
287286
288const withManager = <A>(287const withManager = <A>(
289 use: (288 use: (
...@@ -676,33 +675,6 @@ describe("PreviewManager", () => {...@@ -676,33 +675,6 @@ describe("PreviewManager", () => {
676 ),675 ),
677 );676 );
678677
679 effectIt.effect("reports an unregistered webview as temporarily unavailable", () =>
680 withManager((manager) =>
681 Effect.gen(function* () {
682 expect(yield* manager.automationStatus("tab_1")).toEqual({
683 available: false,
684 visible: true,
685 tabId: "tab_1",
686 url: null,
687 title: null,
688 loading: false,
689 });
690
691 yield* manager.createTab("tab_1");
692
693 expect(yield* manager.automationStatus("tab_1")).toEqual({
694 available: false,
695 visible: true,
696 tabId: "tab_1",
697 url: null,
698 title: null,
699 loading: false,
700 });
701 expect(fromId).not.toHaveBeenCalled();
702 }),
703 ),
704 );
705
706 effectIt.effect("rejects a destroyed webview during registration", () =>678 effectIt.effect("rejects a destroyed webview during registration", () =>
707 withManager((manager) =>679 withManager((manager) =>
708 Effect.gen(function* () {680 Effect.gen(function* () {
...@@ -832,15 +804,6 @@ describe("PreviewManager", () => {...@@ -832,15 +804,6 @@ describe("PreviewManager", () => {
832804
833 yield* manager.navigate("tab_pending", "localhost:3200");805 yield* manager.navigate("tab_pending", "localhost:3200");
834806
835 expect(yield* manager.automationStatus("tab_pending")).toEqual({
836 available: false,
837 visible: true,
838 tabId: "tab_pending",
839 url: "http://localhost:3200/",
840 title: "",
841 loading: true,
842 });
843
844 yield* manager.registerWebview("tab_pending", 42);807 yield* manager.registerWebview("tab_pending", 42);
845 yield* Effect.yieldNow;808 yield* Effect.yieldNow;
846809
...@@ -2721,6 +2684,39 @@ describe("PreviewManager", () => {...@@ -2721,6 +2684,39 @@ describe("PreviewManager", () => {
2721 ),2684 ),
2722 );2685 );
27232686
2687 effectIt.effect("tells the server when a tab it renders natively closes", () =>
2688 Effect.gen(function* () {
2689 const host = yield* DesktopBrowserHost.DesktopBrowserHost;
2690 const serverTab = { threadId: "thread-1", tabId: "server-tab-1" };
2691 const lines = yield* Queue.unbounded<string>();
2692 yield* host.events.pipe(
2693 Stream.runForEach((line) => Queue.offer(lines, new TextDecoder().decode(line))),
2694 Effect.forkScoped,
2695 );
2696 const nextType = Queue.take(lines).pipe(
2697 Effect.map((line) => /"type":"(\w+)"/.exec(line)?.[1]),
2698 );
2699 const capturePage = vi.fn(async () => ({
2700 toPNG: () => Buffer.from("png"),
2701 toJPEG: () => Buffer.from("jpeg"),
2702 getSize: () => ({ width: 100, height: 80 }),
2703 }));
2704 fromId.mockReturnValue(
2705 Object.assign(makeTestPreviewWebContents(capturePage, 42), {
2706 isDevToolsOpened: () => false,
2707 getUserAgent: () => "Electron",
2708 }),
2709 );
2710 const manager = yield* PreviewManager.PreviewManager;
2711 yield* manager.createTab("tab_1", { serverTab });
2712 yield* manager.registerWebview("tab_1", 42);
2713 // The debugger attaches in the background; the server hears it here.
2714 expect(yield* nextType).toBe("attached");
2715 yield* manager.closeTab("tab_1");
2716 expect(yield* nextType).toBe("detached");
2717 }).pipe(Effect.provide(layer), Effect.scoped),
2718 );
2719
2724 effectIt.effect("stops capture retries when the tab swaps during the retry delay", () =>2720 effectIt.effect("stops capture retries when the tab swaps during the retry delay", () =>
2725 withManager((manager) =>2721 withManager((manager) =>
2726 Effect.gen(function* () {2722 Effect.gen(function* () {
...@@ -2783,65 +2779,6 @@ describe("PreviewManager", () => {...@@ -2783,65 +2779,6 @@ describe("PreviewManager", () => {
2783 ),2779 ),
2784 );2780 );
27852781
2786 effectIt.effect("releases snapshot control when every capture attempt stalls", () =>
2787 withManager((manager) =>
2788 Effect.gen(function* () {
2789 const capturePage = vi.fn(() => new Promise<TestCapturedPreviewImage>(() => {}));
2790 const wc = makeTestPreviewWebContents(capturePage);
2791 Object.assign(wc, { isDevToolsOpened: () => false });
2792 Object.assign(wc.debugger, {
2793 sendCommand: vi.fn(async (method: string, params?: Record<string, unknown>) => {
2794 if (method === "Runtime.evaluate") {
2795 return {
2796 result: {
2797 value:
2798 params?.["expression"] === "42"
2799 ? 42
2800 : {
2801 url: "https://example.com",
2802 title: "Example",
2803 loading: false,
2804 visibleText: "Example",
2805 interactiveElements: [],
2806 },
2807 },
2808 };
2809 }
2810 return method === "Accessibility.getFullAXTree" ? { nodes: [] } : undefined;
2811 }),
2812 });
2813 fromId.mockReturnValue(wc);
2814 yield* manager.createTab("tab_1");
2815 yield* manager.registerWebview("tab_1", 42);
2816
2817 const snapshot = yield* Effect.exit(manager.automationSnapshot("tab_1")).pipe(
2818 Effect.forkChild({ startImmediately: true }),
2819 );
2820 yield* TestClock.adjust(100);
2821 expect(capturePage).toHaveBeenCalledOnce();
2822 const evaluate = yield* manager
2823 .automationEvaluate("tab_1", { expression: "42" })
2824 .pipe(Effect.forkChild({ startImmediately: true }));
2825 expect(evaluate.pollUnsafe()).toBeUndefined();
2826
2827 yield* TestClock.adjust(4_000);
2828 const exit = yield* Fiber.join(snapshot);
2829 expect(Exit.isFailure(exit)).toBe(true);
2830 expect(capturePage).toHaveBeenCalledTimes(3);
2831 if (Exit.isSuccess(exit)) return;
2832 const error = Option.getOrThrow(Cause.findErrorOption(exit.cause));
2833 expect(error).toMatchObject({
2834 _tag: "PreviewOperationError",
2835 operation: "automationSnapshot.capturePage",
2836 tabId: "tab_1",
2837 webContentsId: 42,
2838 cause: { _tag: "TimeoutError" },
2839 });
2840 expect(yield* Fiber.join(evaluate)).toBe(42);
2841 }),
2842 ),
2843 );
2844
2845 effectIt.effect("grants each concurrent preview recording its own tab frame", () =>2782 effectIt.effect("grants each concurrent preview recording its own tab frame", () =>
2846 withManager((manager) =>2783 withManager((manager) =>
2847 Effect.gen(function* () {2784 Effect.gen(function* () {
...@@ -4192,725 +4129,6 @@ describe("PreviewManager", () => {...@@ -4192,725 +4129,6 @@ describe("PreviewManager", () => {
4192 }),4129 }),
4193 ),4130 ),
4194 );4131 );
4195
4196 effectIt.effect(
4197 "records the resolved pointer target before dispatching an automation click",
4198 () =>
4199 withManager((manager) =>
4200 Effect.gen(function* () {
4201 let humanInput: ((_event: unknown, signal: unknown) => void) | undefined;
4202 const activity: string[] = [];
4203 const sendCommand = vi.fn(async (method: string, params?: Record<string, unknown>) => {
4204 if (method === "Runtime.evaluate") {
4205 return {
4206 result: {
4207 value: { width: 800, height: 600 },
4208 },
4209 };
4210 }
4211 if (method === "Input.dispatchMouseEvent" && params?.type === "mousePressed") {
4212 activity.push("mousePressed");
4213 humanInput?.({}, { kind: "pointer", x: params.x, y: params.y, button: 0 });
4214 }
4215 return undefined;
4216 });
4217 fromId.mockReturnValue({
4218 id: 42,
4219 hostWebContents: makeTestHostWebContents(),
4220 capturePage: vi.fn(async () => ({ toPNG: () => Buffer.from("frame") })),
4221 setBackgroundThrottling: vi.fn(),
4222 isDestroyed: () => false,
4223 getType: () => "webview",
4224 getURL: () => "https://example.com",
4225 getTitle: () => "Example",
4226 isLoading: () => false,
4227 isDevToolsOpened: () => false,
4228 getZoomFactor: () => 1,
4229 setZoomFactor: vi.fn(),
4230 setAudioMuted: vi.fn(),
4231 isCurrentlyAudible: () => false,
4232 on: vi.fn(),
4233 off: vi.fn(),
4234 ipc: {
4235 on: vi.fn((channel: string, listener: typeof humanInput) => {
4236 if (channel === "preview:human-input") humanInput = listener;
4237 }),
4238 off: vi.fn(),
4239 },
4240 send: webviewSend,
4241 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
4242 setIgnoreMenuShortcuts: vi.fn(),
4243 setWindowOpenHandler: vi.fn(),
4244 debugger: {
4245 isAttached: () => false,
4246 attach: vi.fn(),
4247 sendCommand,
4248 on: vi.fn(),
4249 off: vi.fn(),
4250 },
4251 } as never);
4252
4253 yield* manager.subscribePointerEvents((event) =>
4254 Effect.sync(() => {
4255 activity.push(event.phase);
4256 }),
4257 );
4258 yield* manager.createTab("tab_1");
4259 yield* manager.registerWebview("tab_1", 42);
4260 yield* manager.startRecording("tab_1");
4261 const click = yield* manager
4262 .automationClick("tab_1", { x: 120, y: 80 })
4263 .pipe(Effect.forkChild({ startImmediately: true }));
4264 yield* TestClock.adjust(200);
4265 yield* Fiber.join(click);
4266
4267 expect(activity).toEqual(["move", "click", "mousePressed"]);
4268 expect(
4269 webviewSend.mock.calls
4270 .filter(([channel]) => channel === "preview:recording-controller")
4271 .map(([, controller]) => controller),
4272 ).toEqual(["agent", "none"]);
4273
4274 const recordedPointer = webviewSend.mock.calls
4275 .filter(([channel]) => channel === "preview:recording-pointer")
4276 .map(([, event]) => event);
4277 expect(recordedPointer).toEqual([
4278 expect.objectContaining({ phase: "move", x: 120, y: 80 }),
4279 expect.objectContaining({ phase: "click", x: 120, y: 80 }),
4280 ]);
4281 expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", {
4282 type: "mousePressed",
4283 x: 120,
4284 y: 80,
4285 button: "left",
4286 clickCount: 1,
4287 });
4288 yield* manager.stopRecording("tab_1");
4289 expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", {
4290 type: "mouseReleased",
4291 x: 120,
4292 y: 80,
4293 button: "left",
4294 clickCount: 1,
4295 });
4296 }),
4297 ),
4298 );
4299
4300 effectIt.effect("types in background webviews and enables native key input", () =>
4301 withManager((manager) =>
4302 Effect.gen(function* () {
4303 let failKeyDown = false;
4304 let routeToIframe = false;
4305 let interruptFrameKeyDown = false;
4306 let holdKeyUp = false;
4307 let releaseKeyUp: (() => void) | undefined;
4308 let notifyKeyUpQueued: (() => void) | undefined;
4309 const keyUpQueued = new Promise<void>((resolve) => {
4310 notifyKeyUpQueued = resolve;
4311 });
4312 const listeners = new Map<string, (event: unknown) => void>();
4313 const eventCounts = new Map<string, number>();
4314 const animationFrames = new Map<number, () => void>();
4315 let animationFrameId = 0;
4316 const renderFrame = () => {
4317 const callbacks = [...animationFrames.values()];
4318 animationFrames.clear();
4319 callbacks.forEach((callback) => callback());
4320 };
4321 const frameContext = NodeVM.createContext({
4322 performance: { eventCounts },
4323 requestAnimationFrame: (callback: () => void) => {
4324 const id = ++animationFrameId;
4325 animationFrames.set(id, callback);
4326 return id;
4327 },
4328 cancelAnimationFrame: (id: number) => animationFrames.delete(id),
4329 window: {
4330 addEventListener: (type: string, listener: (event: unknown) => void) =>
4331 listeners.set(type, listener),
4332 removeEventListener: (type: string) => listeners.delete(type),
4333 },
4334 });
4335 const frame = {
4336 executeJavaScript: vi.fn(async (expression: string) =>
4337 NodeVM.runInContext(expression, frameContext),
4338 ),
4339 };
4340 let humanInput: ((_event: unknown, signal: unknown) => void) | undefined;
4341 const sendCommand = vi.fn(
4342 async (method: string, params?: Record<string, unknown>, sessionId?: string) => {
4343 if (method === "Runtime.evaluate") {
4344 if (params?.["returnByValue"] === true) return { result: { value: { ok: true } } };
4345 return {
4346 result:
4347 routeToIframe && !sessionId
4348 ? { objectId: "focused-iframe-object" }
4349 : { subtype: "null" },
4350 };
4351 }
4352 if (method === "DOM.describeNode") return { node: { frameId: "focused-frame" } };
4353 if (method === "Target.getTargets")
4354 return {
4355 targetInfos: [
4356 { targetId: "unrelated-frame", type: "iframe" },
4357 { targetId: "focused-frame", type: "iframe" },
4358 ],
4359 };
4360 if (method === "Target.attachToTarget") return { sessionId: "child-session" };
4361 if (method === "Input.dispatchKeyEvent" && params?.["type"] !== "keyUp") {
4362 if (failKeyDown) throw new Error("key dispatch failed");
4363 if (interruptFrameKeyDown)
4364 humanInput?.({}, { kind: "pointer", x: 80, y: 40, button: 0 });
4365 }
4366 return undefined;
4367 },
4368 );
4369 const sendInputEvent = vi.fn((input: Electron.KeyboardInputEvent) => {
4370 const signal = {
4371 kind: "key",
4372 key: input.keyCode,
4373 code: input.keyCode === "!" ? "Digit1" : `Key${input.keyCode.toUpperCase()}`,
4374 };
4375 if (input.type === "keyUp") {
4376 const deliver = () => {
4377 eventCounts.set("keyup", (eventCounts.get("keyup") ?? 0) + 1);
4378 renderFrame();
4379 };
4380 if (holdKeyUp) {
4381 releaseKeyUp = deliver;
4382 notifyKeyUpQueued?.();
4383 } else {
4384 queueMicrotask(deliver);
4385 }
4386 }
4387 if (input.type !== "keyDown") return;
4388 if (failKeyDown) throw new Error("key dispatch failed");
4389 humanInput?.({}, signal);
4390 });
4391 const restoreFocus = vi.fn();
4392 const focus = vi.fn();
4393 getFocusedWebContents.mockReturnValue({
4394 id: 7,
4395 isDestroyed: () => false,
4396 focus: restoreFocus,
4397 } as never);
4398 fromId.mockReturnValue({
4399 id: 42,
4400 mainFrame: { framesInSubtree: [frame] },
4401 isDestroyed: () => false,
4402 getType: () => "webview",
4403 getURL: () => "https://example.com",
4404 getTitle: () => "Example",
4405 isLoading: () => false,
4406 isDevToolsOpened: () => false,
4407 focus,
4408 sendInputEvent,
4409 getZoomFactor: () => 1,
4410 setZoomFactor: vi.fn(),
4411 setAudioMuted: vi.fn(),
4412 isCurrentlyAudible: () => false,
4413 on: vi.fn(),
4414 off: vi.fn(),
4415 ipc: {
4416 on: vi.fn((channel: string, listener: typeof humanInput) => {
4417 if (channel === "preview:human-input") humanInput = listener;
4418 }),
4419 off: vi.fn(),
4420 },
4421 send: webviewSend,
4422 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
4423 setIgnoreMenuShortcuts: vi.fn(),
4424 setWindowOpenHandler: vi.fn(),
4425 debugger: {
4426 isAttached: () => false,
4427 attach: vi.fn(),
4428 sendCommand,
4429 on: vi.fn(),
4430 off: vi.fn(),
4431 },
4432 } as never);
4433
4434 yield* manager.createTab("tab_input");
4435 yield* manager.registerWebview("tab_input", 42);
4436 yield* manager.automationType("tab_input", { text: "hello", clear: true });
4437 yield* manager.automationType("tab_input", { text: "", clear: true });
4438 yield* manager.automationPress("tab_input", { key: "x" });
4439
4440 const calls = sendCommand.mock.calls;
4441 const methods = calls.map(([method]) => method);
4442 const enableIndex = methods.indexOf("Input.setIgnoreInputEvents");
4443 const focusOnIndex = calls.findIndex(
4444 ([method, params]) =>
4445 method === "Emulation.setFocusEmulationEnabled" && params?.["enabled"] === true,
4446 );
4447 const focusOffIndex = calls.findIndex(
4448 ([method, params]) =>
4449 method === "Emulation.setFocusEmulationEnabled" && params?.["enabled"] === false,
4450 );
4451 const typeEvaluation = sendCommand.mock.calls.find(
4452 ([method, params]) =>
4453 method === "Runtime.evaluate" &&
4454 typeof params === "object" &&
4455 params !== null &&
4456 "expression" in params &&
4457 typeof params.expression === "string" &&
4458 params.expression.includes('document.execCommand("insertText"'),
4459 );
4460 expect(typeEvaluation).toBeDefined();
4461 const clearOnlyEvaluation = sendCommand.mock.calls.find(
4462 ([method, params]) =>
4463 method === "Runtime.evaluate" &&
4464 typeof params === "object" &&
4465 params !== null &&
4466 "expression" in params &&
4467 typeof params.expression === "string" &&
4468 params.expression.includes('const text = ""') &&
4469 params.expression.includes("Object.getOwnPropertyDescriptor"),
4470 );
4471 expect(clearOnlyEvaluation).toBeDefined();
4472 expect(methods).not.toContain("Input.insertText");
4473 expect(enableIndex).toBeGreaterThanOrEqual(0);
4474 expect(methods).not.toContain("Page.bringToFront");
4475 expect(methods).not.toContain("Input.dispatchKeyEvent");
4476 expect(enableIndex).toBeLessThan(focusOnIndex);
4477 expect(sendCommand.mock.invocationCallOrder[focusOnIndex]).toBeLessThan(
4478 sendInputEvent.mock.invocationCallOrder[0]!,
4479 );
4480 expect(sendInputEvent.mock.invocationCallOrder[2]).toBeLessThan(
4481 sendCommand.mock.invocationCallOrder[focusOffIndex]!,
4482 );
4483 expect(sendInputEvent.mock.calls.map(([input]) => input.type)).toEqual([
4484 "keyDown",
4485 "char",
4486 "keyUp",
4487 ]);
4488 expect(sendCommand).toHaveBeenCalledWith("Input.setIgnoreInputEvents", { ignore: false });
4489 expect(listeners.size).toBe(0);
4490 expect(animationFrames.size).toBe(0);
4491
4492 sendCommand.mockClear();
4493 sendInputEvent.mockClear();
4494 getFocusedWebContents.mockReturnValue(null);
4495 holdKeyUp = true;
4496 const backgroundPress = yield* manager
4497 .automationPress("tab_input", { key: "x" })
4498 .pipe(Effect.forkChild({ startImmediately: true }));
4499 yield* Effect.promise(() => keyUpQueued);
4500 expect(sendCommand).not.toHaveBeenCalledWith("Emulation.setFocusEmulationEnabled", {
4501 enabled: false,
4502 });
4503 renderFrame();
4504 expect(animationFrames.size).toBe(1);
4505 releaseKeyUp?.();
4506 yield* Fiber.join(backgroundPress);
4507 expect(listeners.size).toBe(0);
4508 expect(animationFrames.size).toBe(0);
4509 expect(sendCommand).toHaveBeenCalledWith("Emulation.setFocusEmulationEnabled", {
4510 enabled: false,
4511 });
4512 holdKeyUp = false;
4513
4514 // Both native failures and expected-input matching must leave focus emulation off.
4515 for (const key of ["y", "!"]) {
4516 sendCommand.mockClear();
4517 sendInputEvent.mockClear();
4518 failKeyDown = key === "y";
4519 const exit = yield* Effect.exit(manager.automationPress("tab_input", { key }));
4520 expect(Exit.isFailure(exit)).toBe(failKeyDown);
4521 expect(sendInputEvent.mock.calls.map(([input]) => input.type)).toEqual(
4522 failKeyDown ? ["keyDown", "keyUp"] : ["keyDown", "char", "keyUp"],
4523 );
4524 expect(sendCommand).toHaveBeenCalledWith("Emulation.setFocusEmulationEnabled", {
4525 enabled: false,
4526 });
4527 }
4528
4529 routeToIframe = true;
4530 sendInputEvent.mockClear();
4531 for (const outcome of ["success", "failure", "interrupted"]) {
4532 sendCommand.mockClear();
4533 failKeyDown = outcome === "failure";
4534 interruptFrameKeyDown = outcome === "interrupted";
4535 const exit = yield* Effect.exit(
4536 manager.automationPress("tab_input", {
4537 key: outcome === "success" ? "Enter" : "x",
4538 }),
4539 );
4540 expect(Exit.isSuccess(exit)).toBe(outcome === "success");
4541 if (outcome === "interrupted" && Exit.isFailure(exit)) {
4542 expect(Option.getOrThrow(Cause.findErrorOption(exit.cause))).toMatchObject({
4543 _tag: "PreviewAutomationControlInterruptedError",
4544 });
4545 }
4546 expect(sendInputEvent).not.toHaveBeenCalled();
4547 expect(sendCommand).toHaveBeenCalledWith("Target.attachToTarget", {
4548 targetId: "focused-frame",
4549 flatten: true,
4550 });
4551 expect(
4552 sendCommand.mock.calls
4553 .filter(([method]) => method === "Input.dispatchKeyEvent")
4554 .map(([, params, sessionId]) => ({ type: params?.["type"], sessionId })),
4555 ).toEqual([
4556 { type: "keyDown", sessionId: "child-session" },
4557 { type: "keyUp", sessionId: "child-session" },
4558 ]);
4559 expect(sendCommand).toHaveBeenCalledWith(
4560 "Emulation.setFocusEmulationEnabled",
4561 { enabled: false },
4562 "child-session",
4563 );
4564 expect(sendCommand).toHaveBeenCalledWith("Target.detachFromTarget", {
4565 sessionId: "child-session",
4566 });
4567 }
4568 expect(focus).not.toHaveBeenCalled();
4569 expect(restoreFocus).not.toHaveBeenCalled();
4570 }),
4571 ),
4572 );
4573
4574 effectIt.effect("still interrupts agent control for a different human pointer event", () =>
4575 withManager((manager) =>
4576 Effect.gen(function* () {
4577 let humanInput: ((_event: unknown, signal: unknown) => void) | undefined;
4578 const sendCommand = vi.fn(async (method: string) => {
4579 if (method === "Runtime.evaluate") {
4580 return {
4581 result: {
4582 value: { width: 800, height: 600 },
4583 },
4584 };
4585 }
4586 if (method === "Input.dispatchMouseEvent") {
4587 humanInput?.({}, { kind: "pointer", x: 400, y: 300, button: 0 });
4588 }
4589 return undefined;
4590 });
4591 fromId.mockReturnValue({
4592 id: 42,
4593 hostWebContents: makeTestHostWebContents(),
4594 capturePage: vi.fn(async () => ({ toPNG: () => Buffer.from("frame") })),
4595 setBackgroundThrottling: vi.fn(),
4596 isDestroyed: () => false,
4597 getType: () => "webview",
4598 getURL: () => "https://example.com",
4599 getTitle: () => "Example",
4600 isLoading: () => false,
4601 isDevToolsOpened: () => false,
4602 getZoomFactor: () => 1,
4603 setZoomFactor: vi.fn(),
4604 setAudioMuted: vi.fn(),
4605 isCurrentlyAudible: () => false,
4606 on: vi.fn(),
4607 off: vi.fn(),
4608 ipc: {
4609 on: vi.fn((channel: string, listener: typeof humanInput) => {
4610 if (channel === "preview:human-input") humanInput = listener;
4611 }),
4612 off: vi.fn(),
4613 },
4614 send: webviewSend,
4615 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
4616 setIgnoreMenuShortcuts: vi.fn(),
4617 setWindowOpenHandler: vi.fn(),
4618 debugger: {
4619 isAttached: () => false,
4620 attach: vi.fn(),
4621 sendCommand,
4622 on: vi.fn(),
4623 off: vi.fn(),
4624 },
4625 } as never);
4626
4627 yield* manager.createTab("tab_1");
4628 yield* manager.registerWebview("tab_1", 42);
4629 yield* manager.startRecording("tab_1");
4630
4631 const click = yield* manager
4632 .automationClick("tab_1", { x: 120, y: 80 })
4633 .pipe(Effect.forkChild({ startImmediately: true }));
4634 yield* TestClock.adjust(200);
4635 const exit = yield* Fiber.await(click);
4636 expect(Exit.isFailure(exit)).toBe(true);
4637 expect(
4638 webviewSend.mock.calls
4639 .filter(([channel]) => channel === "preview:recording-controller")
4640 .map(([, controller]) => controller),
4641 ).toEqual(["agent", "human", "none"]);
4642 yield* manager.stopRecording("tab_1");
4643 if (Exit.isSuccess(exit)) return;
4644 const error = Option.getOrThrow(Cause.findErrorOption(exit.cause));
4645 expect(error).toMatchObject({
4646 _tag: "PreviewAutomationControlInterruptedError",
4647 operation: "click",
4648 tabId: "tab_1",
4649 webContentsId: 42,
4650 });
4651 expect(error).toBeInstanceOf(Error);
4652 if (error instanceof Error) {
4653 expect(error.name).toBe("PreviewAutomationControlInterruptedError");
4654 }
4655 expect("cause" in error).toBe(false);
4656 }),
4657 ),
4658 );
4659
4660 effectIt.effect(
4661 "releases evaluation objects after success, exceptions, takeover, and cancellation",
4662 () =>
4663 withManager((manager) =>
4664 Effect.gen(function* () {
4665 const retained = new Map<string, number>();
4666 const pendingEvaluation = yield* Deferred.make<void>();
4667 const lateRelease = yield* Deferred.make<void>();
4668 let finishEvaluation: (() => void) | undefined;
4669 let cancelledGroup: string | undefined;
4670 let humanInput: ((event: unknown, signal: unknown) => void) | undefined;
4671 const wc = makeTestPreviewWebContents(vi.fn());
4672 Object.assign(wc, { isDevToolsOpened: () => false });
4673 Object.assign(wc.ipc, {
4674 on: vi.fn((channel: string, listener: typeof humanInput) => {
4675 if (channel === "preview:human-input") humanInput = listener;
4676 }),
4677 });
4678 Object.assign(wc.debugger, {
4679 sendCommand: vi.fn(async (method: string, params?: Record<string, unknown>) => {
4680 const group = String(params?.objectGroup ?? "");
4681 if (method === "Runtime.releaseObjectGroup") {
4682 retained.delete(group);
4683 if (group === cancelledGroup) Deferred.doneUnsafe(lateRelease, Effect.void);
4684 }
4685 if (method !== "Runtime.evaluate") return undefined;
4686 if (params?.expression === "cancelled") {
4687 cancelledGroup = group;
4688 Deferred.doneUnsafe(pendingEvaluation, Effect.void);
4689 await new Promise<void>((resolve) => {
4690 finishEvaluation = resolve;
4691 });
4692 }
4693 retained.set(group, 2);
4694 if (params?.expression === "exception") {
4695 return {
4696 result: { objectId: "exception-result" },
4697 exceptionDetails: {
4698 text: "failure",
4699 exception: { objectId: "exception-detail" },
4700 },
4701 };
4702 }
4703 if (params?.expression === "takeover") {
4704 humanInput?.({}, { kind: "pointer", x: 1, y: 2, button: 0 });
4705 }
4706 return { result: { value: 42 } };
4707 }),
4708 });
4709 fromId.mockReturnValue(wc);
4710 yield* manager.createTab("tab_1");
4711 yield* manager.registerWebview("tab_1", 42);
4712
4713 expect(yield* manager.automationEvaluate("tab_1", { expression: "success" })).toBe(42);
4714 expect(retained.size).toBe(0);
4715 const failure = yield* Effect.exit(
4716 manager.automationEvaluate("tab_1", { expression: "exception" }),
4717 );
4718 expect(Exit.isFailure(failure)).toBe(true);
4719 expect(retained.size).toBe(0);
4720 const takeover = yield* Effect.exit(
4721 manager.automationEvaluate("tab_1", { expression: "takeover" }),
4722 );
4723 expect(Exit.isFailure(takeover)).toBe(true);
4724 if (Exit.isFailure(takeover)) {
4725 expect(Option.getOrThrow(Cause.findErrorOption(takeover.cause))).toMatchObject({
4726 _tag: "PreviewAutomationControlInterruptedError",
4727 });
4728 }
4729 expect(retained.size).toBe(0);
4730 const cancelled = yield* manager
4731 .automationEvaluate("tab_1", { expression: "cancelled" })
4732 .pipe(Effect.forkChild({ startImmediately: true }));
4733 yield* Deferred.await(pendingEvaluation);
4734 yield* Fiber.interrupt(cancelled);
4735 finishEvaluation?.();
4736 yield* Deferred.await(lateRelease);
4737 expect(retained.size).toBe(0);
4738 }),
4739 ),
4740 );
4741
4742 effectIt.effect("derives evaluation detail kind and length from the same non-empty source", () =>
4743 withManager((manager) =>
4744 Effect.gen(function* () {
4745 const text = "ReferenceError: fallbackDetail is not defined";
4746 const exceptionDetails = {
4747 text,
4748 exception: { description: "" },
4749 };
4750 const sendCommand = vi.fn(async (method: string) =>
4751 method === "Runtime.evaluate" ? { exceptionDetails } : undefined,
4752 );
4753 fromId.mockReturnValue({
4754 id: 42,
4755 isDestroyed: () => false,
4756 getType: () => "webview",
4757 getURL: () => "https://example.com",
4758 getTitle: () => "Example",
4759 isLoading: () => false,
4760 isDevToolsOpened: () => false,
4761 getZoomFactor: () => 1,
4762 setZoomFactor: vi.fn(),
4763 setAudioMuted: vi.fn(),
4764 isCurrentlyAudible: () => false,
4765 on: vi.fn(),
4766 off: vi.fn(),
4767 ipc: { on: vi.fn(), off: vi.fn() },
4768 send: webviewSend,
4769 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
4770 setIgnoreMenuShortcuts: vi.fn(),
4771 setWindowOpenHandler: vi.fn(),
4772 debugger: {
4773 isAttached: () => false,
4774 attach: vi.fn(),
4775 sendCommand,
4776 on: vi.fn(),
4777 off: vi.fn(),
4778 },
4779 } as never);
4780
4781 yield* manager.createTab("tab_1");
4782 yield* manager.registerWebview("tab_1", 42);
4783 const exit = yield* Effect.exit(
4784 manager.automationEvaluate("tab_1", { expression: "fallbackDetail" }),
4785 );
4786
4787 expect(Exit.isFailure(exit)).toBe(true);
4788 if (Exit.isSuccess(exit)) return;
4789 const error = Option.getOrThrow(Cause.findErrorOption(exit.cause));
4790 expect(error).toMatchObject({
4791 _tag: "PreviewAutomationEvaluationError",
4792 detailKind: "exception-text",
4793 detailLength: text.length,
4794 cause: exceptionDetails,
4795 });
4796 }),
4797 ),
4798 );
4799 effectIt.effect("saves downloads from agent-driven pages without a Save dialog", () =>
4800 withManager((manager) =>
4801 Effect.gen(function* () {
4802 let humanInput: ((event: unknown, signal?: unknown) => void) | undefined;
4803 const wc = makeTestPreviewWebContents(vi.fn());
4804 Object.assign(wc, {
4805 isDevToolsOpened: () => false,
4806 loadURL: vi.fn(async () => undefined),
4807 reload: vi.fn(),
4808 });
4809 Object.assign(wc.ipc, {
4810 on: vi.fn((channel: string, listener: typeof humanInput) => {
4811 if (channel === "preview:human-input") humanInput = listener;
4812 }),
4813 });
4814 let holdEvaluate = false;
4815 let releaseEvaluate: (() => void) | undefined;
4816 Object.assign(wc.debugger, {
4817 sendCommand: vi.fn(async (method: string, params?: { expression?: string }) => {
4818 if (method !== "Runtime.evaluate") return undefined;
4819 if (params?.expression?.includes("matched"))
4820 return { result: { value: { matched: true } } };
4821 if (holdEvaluate) {
4822 holdEvaluate = false;
4823 await new Promise<void>((resolve) => {
4824 releaseEvaluate = resolve;
4825 });
4826 }
4827 return { result: { value: 42 } };
4828 }),
4829 });
4830 fromId.mockReturnValue(wc);
4831 const takeovers = [yield* Deferred.make<void>(), yield* Deferred.make<void>()];
4832 let takeoverCount = 0;
4833 let controller = "none";
4834 yield* manager.subscribeStateChanges((_tabId, state) => {
4835 const takeover =
4836 controller !== "human" && state.controller === "human"
4837 ? takeovers[takeoverCount++]
4838 : undefined;
4839 controller = state.controller;
4840 return takeover ? Deferred.succeed(takeover, undefined).pipe(Effect.asVoid) : Effect.void;
4841 });
4842 yield* manager.getBrowserSession();
4843 yield* manager.getBrowserSession();
4844 const installs = previewSession.on.mock.calls.filter(
4845 ([event]) => event === "will-download",
4846 );
4847 expect(installs).toHaveLength(1);
4848 const willDownload = installs[0]![1] as (
4849 event: unknown,
4850 item: {
4851 getFilename: () => string;
4852 getStartTime: () => number;
4853 setSavePath: (path: string) => void;
4854 },
4855 source: Electron.WebContents,
4856 ) => void;
4857 const download = () => {
4858 const setSavePath = vi.fn();
4859 willDownload(
4860 {},
4861 { getFilename: () => "chart.png", getStartTime: () => 1_790_844_530.5, setSavePath },
4862 wc,
4863 );
4864 return setSavePath;
4865 };
4866 yield* manager.createTab("tab_1");
4867 yield* manager.registerWebview("tab_1", 42);
4868
4869 expect(download()).not.toHaveBeenCalled();
4870
4871 yield* manager.automationEvaluate("tab_1", { expression: "42" });
4872 const startedAt = (1_790_844_530_500).toString(36);
4873 expect(download()).toHaveBeenCalledWith(
4874 `/tmp/t3/dev/browser-artifacts/browser-download-${startedAt}-0-chart.png`,
4875 );
4876 // Same name, same millisecond: still a separate file.
4877 expect(download()).toHaveBeenCalledWith(
4878 `/tmp/t3/dev/browser-artifacts/browser-download-${startedAt}-1-chart.png`,
4879 );
4880
4881 humanInput?.({}, { kind: "pointer", x: 10, y: 10, button: 0 });
4882 yield* Deferred.await(takeovers[0]!);
4883 expect(download()).not.toHaveBeenCalled();
4884
4885 // An action still waiting for the page when the human takes over must
4886 // not mark it as agent-driven again.
4887 holdEvaluate = true;
4888 const running = yield* manager
4889 .automationEvaluate("tab_1", { expression: "42" })
4890 .pipe(Effect.forkChild({ startImmediately: true }));
4891 yield* settle(() => releaseEvaluate !== undefined);
4892 const queued = yield* manager
4893 .automationEvaluate("tab_1", { expression: "42" })
4894 .pipe(Effect.forkChild({ startImmediately: true }));
4895 humanInput?.({}, { kind: "pointer", x: 20, y: 20, button: 0 });
4896 yield* Deferred.await(takeovers[1]!);
4897 releaseEvaluate?.();
4898 yield* Fiber.await(running);
4899 expect(Exit.isFailure(yield* Fiber.await(queued))).toBe(true);
4900 expect(download()).not.toHaveBeenCalled();
4901
4902 // URL-bar navigation hands the page back to the human.
4903 yield* manager.automationEvaluate("tab_1", { expression: "42" });
4904 expect(download()).toHaveBeenCalled();
4905 yield* manager.navigate("tab_1", "https://example.com/report.csv");
4906 expect(download()).not.toHaveBeenCalled();
4907
4908 // Reading the page does not make it agent-driven.
4909 yield* manager.automationWaitFor("tab_1", { text: "Example" });
4910 expect(download()).not.toHaveBeenCalled();
4911 }),
4912 ),
4913 );
4914});4132});
49154133
4916describe("PreviewOperationError", () => {4134describe("PreviewOperationError", () => {
...@@ -4927,89 +4145,3 @@ describe("PreviewOperationError", () => {...@@ -4927,89 +4145,3 @@ describe("PreviewOperationError", () => {
4927 expect(PreviewManager.PreviewOperationError.toTimelineMessage(error)).toBe(cause.message);4145 expect(PreviewManager.PreviewOperationError.toTimelineMessage(error)).toBe(cause.message);
4928 });4146 });
4929});4147});
4930
4931describe("Preview automation diagnostics", () => {
4932 it("keeps browser exception detail out of structural diagnostics", () => {
4933 const secret = "unrelated-browser-payload-secret";
4934 const detail = "ReferenceError: missingValue is not defined";
4935 const cause = {
4936 text: "Uncaught Error",
4937 exception: { description: detail },
4938 unsafePayload: secret,
4939 };
4940 const error = new PreviewManager.PreviewAutomationEvaluationError({
4941 tabId: "tab_1",
4942 detailKind: "exception-description",
4943 detailLength: detail.length,
4944 cause,
4945 });
4946
4947 const encoded = encodePreviewManagerError(error);
4948 const { cause: encodedCause, ...encodedDiagnostics } = encoded as typeof encoded & {
4949 readonly cause?: unknown;
4950 };
4951
4952 expect(error.cause).toBe(cause);
4953 expect(encodedCause).toStrictEqual(cause);
4954 expect(error.message).toBe("Preview JavaScript evaluation failed in tab tab_1");
4955 expect(error.message).not.toContain(secret);
4956 expect(JSON.stringify(encodedDiagnostics)).not.toContain(secret);
4957 expect("detail" in error).toBe(false);
4958 expect(PreviewManager.PreviewAutomationEvaluationError.toTimelineMessage(error)).toBe(detail);
4959 expect(PreviewManager.PreviewAutomationEvaluationError.toTimelineMessage(error)).not.toContain(
4960 secret,
4961 );
4962 });
4963
4964 it("retains bounded selector diagnostics without exposing selector or reason text", () => {
4965 const selector = "role=button[name='selector-secret']";
4966 const reason = "Unexpected token near reason-secret";
4967 const cause = { invalidSelector: true as const, message: reason };
4968 const error = new PreviewManager.PreviewAutomationInvalidSelectorError({
4969 operation: "click",
4970 tabId: "tab_1",
4971 selectorKind: "locator",
4972 selectorLength: selector.length,
4973 reasonLength: reason.length,
4974 cause,
4975 });
4976
4977 const encoded = encodePreviewManagerError(error);
4978 const { cause: encodedCause, ...encodedDiagnostics } = encoded as typeof encoded & {
4979 readonly cause?: unknown;
4980 };
4981
4982 expect(error.cause).toBe(cause);
4983 expect(encodedCause).toStrictEqual(cause);
4984 expect(error).toMatchObject({
4985 selectorKind: "locator",
4986 selectorLength: selector.length,
4987 reasonLength: reason.length,
4988 });
4989 expect(error.detail).toEqual({
4990 selectorKind: "locator",
4991 selectorLength: selector.length,
4992 });
4993 expect(error.message).not.toContain("secret");
4994 expect(JSON.stringify(encodedDiagnostics)).not.toContain("secret");
4995 expect("selector" in error).toBe(false);
4996 expect("reason" in error).toBe(false);
4997 expect(PreviewManager.PreviewAutomationInvalidSelectorError.toTimelineMessage(error)).toBe(
4998 reason,
4999 );
5000 });
5001
5002 it("does not retain a missing target locator", () => {
5003 const selector = "[data-token='target-secret']";
5004 const error = new PreviewManager.PreviewAutomationTargetNotFoundError({
5005 operation: "scroll",
5006 tabId: "tab_1",
5007 selectorKind: "selector",
5008 selectorLength: selector.length,
5009 });
5010
5011 expect(error.message).not.toContain(selector);
5012 expect(JSON.stringify(error)).not.toContain(selector);
5013 expect("locator" in error).toBe(false);
5014 });
5015});
apps/desktop/src/preview/Manager.ts+107-1737
...@@ -11,7 +11,6 @@ import {...@@ -11,7 +11,6 @@ import {
11} from "@t3tools/contracts";11} from "@t3tools/contracts";
12import type {12import type {
13 DesktopPreviewAnnotationTheme,13 DesktopPreviewAnnotationTheme,
14 DesktopPreviewAutomationStatus,
15 DesktopPreviewColorScheme,14 DesktopPreviewColorScheme,
16 DesktopPreviewFavicon,15 DesktopPreviewFavicon,
17 DesktopPreviewPointerEvent,16 DesktopPreviewPointerEvent,
...@@ -23,16 +22,6 @@ import type {...@@ -23,16 +22,6 @@ import type {
23 DesktopPreviewRecordingInputEvent,22 DesktopPreviewRecordingInputEvent,
24 DesktopPreviewScreenshotArtifact,23 DesktopPreviewScreenshotArtifact,
25 DesktopPreviewTabDefaults,24 DesktopPreviewTabDefaults,
26 PreviewAutomationClickInput,
27 PreviewAutomationActionEvent,
28 PreviewAutomationConsoleEntry,
29 PreviewAutomationEvaluateInput,
30 PreviewAutomationPressInput,
31 PreviewAutomationNetworkEntry,
32 PreviewAutomationScrollInput,
33 PreviewAutomationSnapshot,
34 PreviewAutomationTypeInput,
35 PreviewAutomationWaitForInput,
36} from "@t3tools/contracts";25} from "@t3tools/contracts";
37import { HostProcessPlatform } from "@t3tools/shared/hostProcess";26import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
38import { normalizePreviewUrl } from "@t3tools/shared/preview";27import { normalizePreviewUrl } from "@t3tools/shared/preview";
...@@ -48,7 +37,6 @@ import {...@@ -48,7 +37,6 @@ import {
48import * as Cause from "effect/Cause";37import * as Cause from "effect/Cause";
49import * as Clock from "effect/Clock";38import * as Clock from "effect/Clock";
50import * as Context from "effect/Context";39import * as Context from "effect/Context";
51import * as Crypto from "effect/Crypto";
52import * as DateTime from "effect/DateTime";40import * as DateTime from "effect/DateTime";
53import * as Deferred from "effect/Deferred";41import * as Deferred from "effect/Deferred";
54import * as Effect from "effect/Effect";42import * as Effect from "effect/Effect";
...@@ -62,6 +50,7 @@ import * as Queue from "effect/Queue";...@@ -62,6 +50,7 @@ import * as Queue from "effect/Queue";
62import * as Ref from "effect/Ref";50import * as Ref from "effect/Ref";
63import * as Schema from "effect/Schema";51import * as Schema from "effect/Schema";
64import * as Semaphore from "effect/Semaphore";52import * as Semaphore from "effect/Semaphore";
53import * as Stream from "effect/Stream";
65import * as Schedule from "effect/Schedule";54import * as Schedule from "effect/Schedule";
66import * as Scope from "effect/Scope";55import * as Scope from "effect/Scope";
67import * as SynchronizedRef from "effect/SynchronizedRef";56import * as SynchronizedRef from "effect/SynchronizedRef";
...@@ -69,6 +58,7 @@ import * as SynchronizedRef from "effect/SynchronizedRef";...@@ -69,6 +58,7 @@ import * as SynchronizedRef from "effect/SynchronizedRef";
69import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";58import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
70import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts";59import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts";
71import { PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL } from "../ipc/channels.ts";60import { PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL } from "../ipc/channels.ts";
61import * as DesktopBrowserHost from "./DesktopBrowserHost.ts";
72import * as BrowserSession from "./BrowserSession.ts";62import * as BrowserSession from "./BrowserSession.ts";
73import {63import {
74 ANNOTATION_CAPTURED_CHANNEL,64 ANNOTATION_CAPTURED_CHANNEL,
...@@ -78,19 +68,12 @@ import {...@@ -78,19 +68,12 @@ import {
78 HUMAN_INPUT_CHANNEL,68 HUMAN_INPUT_CHANNEL,
79 MOUSE_NAVIGATE_CHANNEL,69 MOUSE_NAVIGATE_CHANNEL,
80 RECORDING_CURSOR_CHANNEL,70 RECORDING_CURSOR_CHANNEL,
81 RECORDING_POINTER_CHANNEL,
82 RECORDING_KEY_CHANNEL,
83 RECORDING_INPUT_CHANNEL,71 RECORDING_INPUT_CHANNEL,
84 RECORDING_CONTROLLER_CHANNEL,72 RECORDING_CONTROLLER_CHANNEL,
73 RECORDING_POINTER_CHANNEL,
85 START_PICK_CHANNEL,74 START_PICK_CHANNEL,
86} from "./GuestProtocol.ts";75} from "./GuestProtocol.ts";
87import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts";76import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts";
88import { playwrightInjectedRuntimeInstallExpression } from "./PlaywrightInjectedRuntime.ts";
89import {
90 makePreviewAutomationKeySequence,
91 makePreviewAutomationNativeKeySequence,
92 previewAutomationEditingCommandExpression,
93} from "./PreviewKeyboard.ts";
94import { captureFavicon, safeHttpOrigin, selectFaviconCandidates } from "./FaviconCapture.ts";77import { captureFavicon, safeHttpOrigin, selectFaviconCandidates } from "./FaviconCapture.ts";
95import { DEFAULT_RECORDING_INPUT_OPTIONS, type RecordingInputOptions } from "./RecordingInput.ts";78import { DEFAULT_RECORDING_INPUT_OPTIONS, type RecordingInputOptions } from "./RecordingInput.ts";
9679
...@@ -121,6 +104,8 @@ export interface PreviewTabState {...@@ -121,6 +104,8 @@ export interface PreviewTabState {
121 audible: boolean;104 audible: boolean;
122 controller: "human" | "agent" | "none";105 controller: "human" | "agent" | "none";
123 favicon?: DesktopPreviewFavicon;106 favicon?: DesktopPreviewFavicon;
107 /** Set for a tab of the desktop's own server, which drives it over the browser channel. */
108 serverTab?: { readonly threadId: string; readonly tabId: string };
124 updatedAt: string;109 updatedAt: string;
125}110}
126111
...@@ -131,16 +116,11 @@ const ZOOM_LEVELS: ReadonlyArray<number> = [...@@ -131,16 +116,11 @@ const ZOOM_LEVELS: ReadonlyArray<number> = [
131116
132const DEFAULT_ZOOM_FACTOR = 1.0;117const DEFAULT_ZOOM_FACTOR = 1.0;
133const ZOOM_EPSILON = 0.001;118const ZOOM_EPSILON = 0.001;
134const MAX_EVALUATION_BYTES = 64_000;
135const MAX_VISIBLE_TEXT_LENGTH = 20_000;
136const MAX_INTERACTIVE_ELEMENTS = 200;
137/**119/**
138 * A `[role]` container's innerText is its whole subtree, which turned one120 * A `[role]` container's innerText is its whole subtree, which turned one
139 * snapshot's element list into 60 KB of repeated page text. Names are labels,121 * snapshot's element list into 60 KB of repeated page text. Names are labels,
140 * not content, so cap them where they are read.122 * not content, so cap them where they are read.
141 */123 */
142const MAX_INTERACTIVE_ELEMENT_NAME_LENGTH = 200;
143const MAX_SCREENSHOT_WIDTH = 1280;
144/** How long an armed tab keeps the exclusive display-media slot before another tab may take it. */124/** How long an armed tab keeps the exclusive display-media slot before another tab may take it. */
145const RECORDING_ARM_GRACE_MS = 10_000;125const RECORDING_ARM_GRACE_MS = 10_000;
146const PICTURE_IN_PICTURE_FRAME_INTERVAL_MS = Math.ceil(1_000 / 12);126const PICTURE_IN_PICTURE_FRAME_INTERVAL_MS = Math.ceil(1_000 / 12);
...@@ -157,13 +137,9 @@ const PICTURE_IN_PICTURE_INITIAL_HEIGHT = 320;...@@ -157,13 +137,9 @@ const PICTURE_IN_PICTURE_INITIAL_HEIGHT = 320;
157const PICTURE_IN_PICTURE_MIN_WIDTH = 240;137const PICTURE_IN_PICTURE_MIN_WIDTH = 240;
158const PICTURE_IN_PICTURE_MIN_HEIGHT = 160;138const PICTURE_IN_PICTURE_MIN_HEIGHT = 160;
159const PICTURE_IN_PICTURE_ASPECT_RATIO_EPSILON = 0.002;139const PICTURE_IN_PICTURE_ASPECT_RATIO_EPSILON = 0.002;
160const DIAGNOSTIC_BUFFER_LIMIT = 200;
161const MAX_ARTIFACT_SITE_SLUG_LENGTH = 80;140const MAX_ARTIFACT_SITE_SLUG_LENGTH = 80;
162const AGENT_CURSOR_MOVE_MS = 160;
163const AGENT_CURSOR_CLICK_LEAD_MS = 40;
164const requestRecordingCaptureExpression = (tabId: string): string =>141const requestRecordingCaptureExpression = (tabId: string): string =>
165 `globalThis[${JSON.stringify(DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER)}]?.(${JSON.stringify(tabId)}) === true`;142 `globalThis[${JSON.stringify(DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER)}]?.(${JSON.stringify(tabId)}) === true`;
166const encodeUnknownJson = Schema.encodeUnknownEffect(Schema.fromJsonString(Schema.Unknown));
167const DEFAULT_ANNOTATION_THEME: DesktopPreviewAnnotationTheme = {143const DEFAULT_ANNOTATION_THEME: DesktopPreviewAnnotationTheme = {
168 colorScheme: "light",144 colorScheme: "light",
169 radius: "0.625rem",145 radius: "0.625rem",
...@@ -253,17 +229,6 @@ const artifactSiteSlug = (rawUrl: string): string => {...@@ -253,17 +229,6 @@ const artifactSiteSlug = (rawUrl: string): string => {
253 }229 }
254};230};
255231
256interface CdpEvaluationResult {
257 readonly result?: {
258 readonly value?: unknown;
259 readonly description?: string;
260 };
261 readonly exceptionDetails?: {
262 readonly text?: string;
263 readonly exception?: { readonly description?: string };
264 };
265}
266
267export const PreviewAutomationSelectorKind = Schema.Literals([232export const PreviewAutomationSelectorKind = Schema.Literals([
268 "focused-element",233 "focused-element",
269 "selector",234 "selector",
...@@ -279,36 +244,6 @@ export const PreviewAutomationEvaluationDetailKind = Schema.Literals([...@@ -279,36 +244,6 @@ export const PreviewAutomationEvaluationDetailKind = Schema.Literals([
279export type PreviewAutomationEvaluationDetailKind =244export type PreviewAutomationEvaluationDetailKind =
280 typeof PreviewAutomationEvaluationDetailKind.Type;245 typeof PreviewAutomationEvaluationDetailKind.Type;
281246
282const previewAutomationEvaluationDetail = (exceptionDetails: unknown) => {
283 if (typeof exceptionDetails !== "object" || exceptionDetails === null) {
284 return { detailKind: "unknown" as const };
285 }
286 const details = exceptionDetails as Record<string, unknown>;
287 const exception = details["exception"];
288 const description =
289 typeof exception === "object" &&
290 exception !== null &&
291 typeof (exception as Record<string, unknown>)["description"] === "string"
292 ? (exception as Record<string, unknown>)["description"]
293 : undefined;
294 if (typeof description === "string" && description.length > 0) {
295 return { detailKind: "exception-description" as const, detail: description };
296 }
297 const text = details["text"];
298 if (typeof text === "string" && text.length > 0) {
299 return { detailKind: "exception-text" as const, detail: text };
300 }
301 return { detailKind: "unknown" as const };
302};
303
304const previewAutomationTargetLabel = (
305 selectorKind: PreviewAutomationSelectorKind,
306 selectorLength?: number,
307) =>
308 selectorKind === "focused-element"
309 ? "the focused element"
310 : `${selectorKind} (${selectorLength ?? 0} characters)`;
311
312interface PreviewOperationContext {247interface PreviewOperationContext {
313 readonly operation: string;248 readonly operation: string;
314 readonly tabId?: string;249 readonly tabId?: string;
...@@ -492,23 +427,12 @@ interface BrowserControlSession {...@@ -492,23 +427,12 @@ interface BrowserControlSession {
492 ) => void;427 ) => void;
493}428}
494429
495interface BrowserDiagnostics {
496 readonly consoleEntries: ReadonlyArray<PreviewAutomationConsoleEntry>;
497 readonly networkEntries: ReadonlyArray<PreviewAutomationNetworkEntry>;
498 readonly requests: ReadonlyMap<string, { url: string; method: string }>;
499}
500
501const isRecordingInput = Schema.is(DesktopPreviewRecordingInputSchema);430const isRecordingInput = Schema.is(DesktopPreviewRecordingInputSchema);
502431
503type RecordingInputListener = (event: DesktopPreviewRecordingInputEvent) => Effect.Effect<void>;432type RecordingInputListener = (event: DesktopPreviewRecordingInputEvent) => Effect.Effect<void>;
504433
505type PointerEventListener = (event: DesktopPreviewPointerEvent) => Effect.Effect<void>;434type PointerEventListener = (event: DesktopPreviewPointerEvent) => Effect.Effect<void>;
506435
507interface ExpectedAgentInput {
508 readonly signal: PreviewInputSignal;
509 readonly expiresAt: number;
510}
511
512/**436/**
513 * Protocols a preview page may open in a real popup window.437 * Protocols a preview page may open in a real popup window.
514 *438 *
...@@ -522,9 +446,6 @@ interface ExpectedAgentInput {...@@ -522,9 +446,6 @@ interface ExpectedAgentInput {
522 */446 */
523const POPUP_PROTOCOLS = new Set(["http:", "https:"]);447const POPUP_PROTOCOLS = new Set(["http:", "https:"]);
524448
525/** Control actions that only read the page, so they do not make it agent-driven. */
526const READ_ONLY_CONTROL_ACTIONS = new Set(["snapshot", "waitFor"]);
527
528const isPopupUrl = (rawUrl: string): boolean => {449const isPopupUrl = (rawUrl: string): boolean => {
529 try {450 try {
530 return POPUP_PROTOCOLS.has(new URL(rawUrl).protocol);451 return POPUP_PROTOCOLS.has(new URL(rawUrl).protocol);
...@@ -618,23 +539,6 @@ const isPreviewInputSignal = (value: unknown): value is PreviewInputSignal => {...@@ -618,23 +539,6 @@ const isPreviewInputSignal = (value: unknown): value is PreviewInputSignal => {
618 );539 );
619};540};
620541
621const inputSignalsMatch = (left: PreviewInputSignal, right: PreviewInputSignal): boolean => {
622 if (left.kind !== right.kind) return false;
623 if (left.kind === "pointer" && right.kind === "pointer") {
624 return (
625 Math.abs(left.x - right.x) <= 1 &&
626 Math.abs(left.y - right.y) <= 1 &&
627 left.button === right.button
628 );
629 }
630 return (
631 left.kind === "key" &&
632 right.kind === "key" &&
633 left.key === right.key &&
634 left.code === right.code
635 );
636};
637
638const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function* (542const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function* (
639 artifactDirectory: string,543 artifactDirectory: string,
640 pictureInPicturePreloadPath: string,544 pictureInPicturePreloadPath: string,
...@@ -643,14 +547,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -643,14 +547,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
643 const rendererHistory = yield* DesktopRendererHistory.DesktopRendererHistory;547 const rendererHistory = yield* DesktopRendererHistory.DesktopRendererHistory;
644 const hostPlatform = yield* HostProcessPlatform;548 const hostPlatform = yield* HostProcessPlatform;
645 const path = yield* Path.Path;549 const path = yield* Path.Path;
646 const crypto = yield* Crypto.Crypto;
647 const parentScope = yield* Scope.Scope;550 const parentScope = yield* Scope.Scope;
551 const browserHost = yield* DesktopBrowserHost.DesktopBrowserHost;
648 const context = yield* Effect.context<never>();552 const context = yield* Effect.context<never>();
649 const runFork = Effect.runForkWith(context);553 const runFork = Effect.runForkWith(context);
650 const resolvedArtifactDirectory = path.resolve(artifactDirectory);554 const resolvedArtifactDirectory = path.resolve(artifactDirectory);
651 const playwrightInstallExpression = yield* Effect.cached(
652 playwrightInjectedRuntimeInstallExpression(),
653 );
654555
655 const annotationThemeRef = yield* Ref.make(DEFAULT_ANNOTATION_THEME);556 const annotationThemeRef = yield* Ref.make(DEFAULT_ANNOTATION_THEME);
656 const mainWindowRef = yield* Ref.make<Option.Option<BrowserWindow>>(Option.none());557 const mainWindowRef = yield* Ref.make<Option.Option<BrowserWindow>>(Option.none());
...@@ -668,16 +569,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -668,16 +569,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
668 const controlSessionsRef = yield* SynchronizedRef.make<569 const controlSessionsRef = yield* SynchronizedRef.make<
669 ReadonlyMap<number, BrowserControlSession>570 ReadonlyMap<number, BrowserControlSession>
670 >(new Map());571 >(new Map());
671 const diagnosticsRef = yield* Ref.make<ReadonlyMap<number, BrowserDiagnostics>>(new Map());
672 const expectedAgentInputsRef = yield* Ref.make<
673 ReadonlyMap<string, ReadonlyArray<ExpectedAgentInput>>
674 >(new Map());
675 const controlEpochRef = yield* Ref.make<ReadonlyMap<string, number>>(new Map());
676 const actionTimelineRef = yield* Ref.make<
677 ReadonlyMap<string, ReadonlyArray<PreviewAutomationActionEvent>>
678 >(new Map());
679 const actionSequenceRef = yield* Ref.make(0);
680 const pointerSequenceRef = yield* Ref.make(0);
681 const frameCaptureSessionsRef = yield* SynchronizedRef.make<572 const frameCaptureSessionsRef = yield* SynchronizedRef.make<
682 ReadonlyMap<string, FrameCaptureSession>573 ReadonlyMap<string, FrameCaptureSession>
683 >(new Map());574 >(new Map());
...@@ -692,10 +583,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -692,10 +583,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
692 // (electron#44618) and now always rejects with NotAllowedError.583 // (electron#44618) and now always rejects with NotAllowedError.
693 let pendingRecording: PendingRecording | null = null;584 let pendingRecording: PendingRecording | null = null;
694 const displayMediaHandlerSessions = new WeakSet<Session>();585 const displayMediaHandlerSessions = new WeakSet<Session>();
695 const downloadHandlerSessions = new WeakSet<Session>();
696 let downloadCount = 0;
697 // Preview pages whose latest input came from an agent action, not the human.
698 const agentDrivenWebContents = new WeakSet<Electron.WebContents>();
699 let frameCaptureWindowOpen = true;586 let frameCaptureWindowOpen = true;
700 let currentMainWindow: BrowserWindow | undefined;587 let currentMainWindow: BrowserWindow | undefined;
701 let mainWindowCleanupFiber: Fiber.Fiber<void, never> | undefined;588 let mainWindowCleanupFiber: Fiber.Fiber<void, never> | undefined;
...@@ -756,12 +643,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -756,12 +643,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
756 });643 });
757 const currentIso = DateTime.now.pipe(Effect.map(DateTime.formatIso));644 const currentIso = DateTime.now.pipe(Effect.map(DateTime.formatIso));
758 const currentMillis = Clock.currentTimeMillis;645 const currentMillis = Clock.currentTimeMillis;
759 const encodeJson = (errorContext: PreviewOperationContext, value: unknown) =>
760 encodeUnknownJson(value).pipe(
761 Effect.mapError((cause) => new PreviewOperationError({ ...errorContext, cause })),
762 );
763 const nextCounter = (ref: Ref.Ref<number>) =>
764 Ref.modify(ref, (value) => [value, value + 1] as const);
765 const replaceMap = <K, V>(646 const replaceMap = <K, V>(
766 source: ReadonlyMap<K, V>,647 source: ReadonlyMap<K, V>,
767 update: (copy: Map<K, V>) => void,648 update: (copy: Map<K, V>) => void,
...@@ -1143,139 +1024,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1143,139 +1024,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1143 );1024 );
1144 });1025 });
11451026
1146 const pushBounded = <A>(buffer: ReadonlyArray<A>, entry: A): ReadonlyArray<A> =>
1147 [...buffer, entry].slice(-DIAGNOSTIC_BUFFER_LIMIT);
1148
1149 const captureDiagnosticMessage = Effect.fnUntraced(function* (
1150 webContentsId: number,
1151 method: string,
1152 params: Record<string, unknown>,
1153 ) {
1154 const timestamp = yield* currentIso;
1155 yield* Ref.update(diagnosticsRef, (allDiagnostics) => {
1156 const current = allDiagnostics.get(webContentsId);
1157 if (!current) return allDiagnostics;
1158 const requestId = typeof params["requestId"] === "string" ? params["requestId"] : null;
1159 const next = (() => {
1160 if (method === "Runtime.consoleAPICalled") {
1161 const args = Array.isArray(params["args"]) ? params["args"] : [];
1162 const text = args
1163 .map((arg) => {
1164 if (typeof arg !== "object" || arg === null) return String(arg);
1165 const value = arg as Record<string, unknown>;
1166 return String(value["value"] ?? value["description"] ?? "");
1167 })
1168 .join(" ");
1169 return {
1170 ...current,
1171 consoleEntries: pushBounded(current.consoleEntries, {
1172 level: typeof params["type"] === "string" ? params["type"] : "log",
1173 text,
1174 timestamp,
1175 source: "console",
1176 }),
1177 };
1178 }
1179 if (method === "Runtime.exceptionThrown") {
1180 const details =
1181 typeof params["exceptionDetails"] === "object" && params["exceptionDetails"] !== null
1182 ? (params["exceptionDetails"] as Record<string, unknown>)
1183 : {};
1184 return {
1185 ...current,
1186 consoleEntries: pushBounded(current.consoleEntries, {
1187 level: "error",
1188 text: String(details["text"] ?? "Uncaught exception"),
1189 timestamp,
1190 source: "exception",
1191 }),
1192 };
1193 }
1194 if (method === "Log.entryAdded") {
1195 const entry =
1196 typeof params["entry"] === "object" && params["entry"] !== null
1197 ? (params["entry"] as Record<string, unknown>)
1198 : {};
1199 return {
1200 ...current,
1201 consoleEntries: pushBounded(current.consoleEntries, {
1202 level: typeof entry["level"] === "string" ? entry["level"] : "info",
1203 text: String(entry["text"] ?? ""),
1204 timestamp,
1205 source: typeof entry["source"] === "string" ? entry["source"] : "log",
1206 }),
1207 };
1208 }
1209 if (method === "Network.requestWillBeSent" && requestId) {
1210 const request =
1211 typeof params["request"] === "object" && params["request"] !== null
1212 ? (params["request"] as Record<string, unknown>)
1213 : {};
1214 return {
1215 ...current,
1216 requests: replaceMap(current.requests, (copy) => {
1217 copy.set(requestId, {
1218 url: String(request["url"] ?? ""),
1219 method: String(request["method"] ?? "GET"),
1220 });
1221 }),
1222 };
1223 }
1224 if (method === "Network.responseReceived" && requestId) {
1225 const request = current.requests.get(requestId);
1226 const response =
1227 typeof params["response"] === "object" && params["response"] !== null
1228 ? (params["response"] as Record<string, unknown>)
1229 : {};
1230 const status = typeof response["status"] === "number" ? response["status"] : null;
1231 return request && status !== null && status >= 400
1232 ? {
1233 ...current,
1234 networkEntries: pushBounded(current.networkEntries, {
1235 ...request,
1236 status,
1237 failed: true,
1238 timestamp,
1239 }),
1240 }
1241 : current;
1242 }
1243 if (method === "Network.loadingFailed" && requestId) {
1244 const request = current.requests.get(requestId);
1245 return {
1246 ...current,
1247 requests: replaceMap(current.requests, (copy) => {
1248 copy.delete(requestId);
1249 }),
1250 networkEntries: request
1251 ? pushBounded(current.networkEntries, {
1252 ...request,
1253 status: null,
1254 failed: true,
1255 errorText: String(params["errorText"] ?? "Network request failed"),
1256 timestamp,
1257 })
1258 : current.networkEntries,
1259 };
1260 }
1261 if (method === "Network.loadingFinished" && requestId) {
1262 return {
1263 ...current,
1264 requests: replaceMap(current.requests, (copy) => {
1265 copy.delete(requestId);
1266 }),
1267 };
1268 }
1269 return current;
1270 })();
1271 return replaceMap(allDiagnostics, (copy) => {
1272 copy.set(webContentsId, next);
1273 });
1274 });
1275 });
1276
1277 const detachControlSession = Effect.fn("PreviewManager.detachControlSession")(function* (1027 const detachControlSession = Effect.fn("PreviewManager.detachControlSession")(function* (
1278 webContentsId: number,1028 webContentsId: number,
1029 /** The server tab it rendered, when the caller already took the tab out of `tabsRef`. */
1030 closedServerTab?: PreviewTabState["serverTab"],
1279 ) {1031 ) {
1280 const control = yield* SynchronizedRef.modify(controlSessionsRef, (sessions) => [1032 const control = yield* SynchronizedRef.modify(controlSessionsRef, (sessions) => [
1281 sessions.get(webContentsId),1033 sessions.get(webContentsId),
...@@ -1284,14 +1036,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1284,14 +1036,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1284 }),1036 }),
1285 ]);1037 ]);
1286 if (control) {1038 if (control) {
1039 // The server can only drive a tab while the desktop holds its debugger.
1040 if (closedServerTab) browserHost.detach(closedServerTab);
1041 for (const tab of (yield* SynchronizedRef.get(tabsRef)).values()) {
1042 if (tab.webContentsId === webContentsId && tab.serverTab) browserHost.detach(tab.serverTab);
1043 }
1287 yield* Scope.close(control.scope, Exit.void).pipe(Effect.ignore);1044 yield* Scope.close(control.scope, Exit.void).pipe(Effect.ignore);
1288 return;
1289 }1045 }
1290 yield* Ref.update(diagnosticsRef, (diagnostics) =>
1291 replaceMap(diagnostics, (copy) => {
1292 copy.delete(webContentsId);
1293 }),
1294 );
1295 });1046 });
12961047
1297 const ensureControlSession = Effect.fn("PreviewManager.ensureControlSession")(function* (1048 const ensureControlSession = Effect.fn("PreviewManager.ensureControlSession")(function* (
...@@ -1400,7 +1151,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1400,7 +1151,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1400 }1151 }
1401 }1152 }
1402 }1153 }
1403 yield* captureDiagnosticMessage(wc.id, method, params);
1404 if (method === "Runtime.consoleAPICalled" || method === "Runtime.exceptionThrown") {1154 if (method === "Runtime.consoleAPICalled" || method === "Runtime.exceptionThrown") {
1405 yield* Queue.offer(consoleReleases, undefined);1155 yield* Queue.offer(consoleReleases, undefined);
1406 }1156 }
...@@ -1410,20 +1160,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1410,20 +1160,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1410 };1160 };
1411 yield* Scope.addFinalizer(1161 yield* Scope.addFinalizer(
1412 scope,1162 scope,
1413 Effect.all(1163 attempt({ operation: "detachControlSession", webContentsId: wc.id }, () => {
1414 [1164 wcDebugger.off("message", onMessage);
1415 Ref.update(diagnosticsRef, (diagnostics) =>1165 if (wcDebugger.isAttached()) wcDebugger.detach();
1416 replaceMap(diagnostics, (copy) => {1166 }).pipe(Effect.ignore),
1417 copy.delete(wc.id);
1418 }),
1419 ),
1420 attempt({ operation: "detachControlSession", webContentsId: wc.id }, () => {
1421 wcDebugger.off("message", onMessage);
1422 if (wcDebugger.isAttached()) wcDebugger.detach();
1423 }).pipe(Effect.ignore),
1424 ],
1425 { discard: true },
1426 ),
1427 );1167 );
1428 const control: BrowserControlSession = {1168 const control: BrowserControlSession = {
1429 webContentsId: wc.id,1169 webContentsId: wc.id,
...@@ -1433,15 +1173,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1433,15 +1173,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1433 onMessage,1173 onMessage,
1434 };1174 };
1435 const initialize = Effect.fn("PreviewManager.initializeControlSession")(function* () {1175 const initialize = Effect.fn("PreviewManager.initializeControlSession")(function* () {
1436 yield* Ref.update(diagnosticsRef, (diagnostics) =>
1437 replaceMap(diagnostics, (copy) => {
1438 copy.set(wc.id, {
1439 consoleEntries: [],
1440 networkEntries: [],
1441 requests: new Map(),
1442 });
1443 }),
1444 );
1445 yield* attempt({ operation: "attachDebuggerListeners", webContentsId: wc.id }, () => {1176 yield* attempt({ operation: "attachDebuggerListeners", webContentsId: wc.id }, () => {
1446 wcDebugger.on("message", onMessage);1177 wcDebugger.on("message", onMessage);
1447 wcDebugger.attach("1.3");1178 wcDebugger.attach("1.3");
...@@ -1461,15 +1192,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1461,15 +1192,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1461 color: { r: 255, g: 255, b: 255, a: 1 },1192 color: { r: 255, g: 255, b: 255, a: 1 },
1462 }),1193 }),
1463 );1194 );
1464 yield* Effect.forEach(
1465 ["Runtime.enable", "Accessibility.enable", "Network.enable", "Log.enable"],
1466 (method) =>
1467 attemptPromise(
1468 { operation: `initializeDebugger.${method}`, webContentsId: wc.id },
1469 () => wcDebugger.sendCommand(method),
1470 ),
1471 { concurrency: "unbounded", discard: true },
1472 );
1473 return [1195 return [
1474 control,1196 control,
1475 replaceMap(sessions, (copy) => {1197 replaceMap(sessions, (copy) => {
...@@ -1486,250 +1208,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1486,250 +1208,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1486 );1208 );
1487 });1209 });
14881210
1489 const pushAction = (tabId: string, event: PreviewAutomationActionEvent) =>
1490 Ref.update(actionTimelineRef, (timelines) =>
1491 replaceMap(timelines, (copy) => {
1492 copy.set(tabId, [...(timelines.get(tabId) ?? []), event].slice(-200));
1493 }),
1494 );
1495 const replaceAction = (tabId: string, event: PreviewAutomationActionEvent) =>
1496 Ref.update(actionTimelineRef, (timelines) => {
1497 const timeline = timelines.get(tabId);
1498 if (!timeline) return timelines;
1499 return replaceMap(timelines, (copy) => {
1500 copy.set(
1501 tabId,
1502 timeline.map((candidate) => (candidate.id === event.id ? event : candidate)),
1503 );
1504 });
1505 });
1506
1507 type SendCommand = (
1508 method: string,
1509 commandParams?: Record<string, unknown>,
1510 sessionId?: string,
1511 ) => Effect.Effect<unknown, PreviewManagerError>;
1512
1513 const prepareAutomationInput = Effect.fn("PreviewManager.prepareAutomationInput")(function* (
1514 send: SendCommand,
1515 enableRuntime: boolean,
1516 ) {
1517 yield* Effect.all(
1518 [
1519 ...(enableRuntime ? [send("Runtime.enable")] : []),
1520 send("Input.setIgnoreInputEvents", { ignore: false }),
1521 ],
1522 { concurrency: 2, discard: true },
1523 );
1524 });
1525
1526 const withControlSession = Effect.fn("PreviewManager.withControlSession")(function* <A>(
1527 tabId: string,
1528 wc: Electron.WebContents,
1529 action: string,
1530 use: (
1531 send: SendCommand,
1532 sendCleanup: SendCommand,
1533 checkControl: Effect.Effect<void, PreviewManagerError>,
1534 ) => Effect.Effect<A, PreviewManagerError>,
1535 ) {
1536 const sequence = yield* nextCounter(actionSequenceRef);
1537 const startedAt = yield* currentIso;
1538 const millis = yield* currentMillis;
1539 const actionEvent: PreviewAutomationActionEvent = {
1540 id: `browser-action-${millis.toString(36)}-${sequence.toString(36)}`,
1541 action,
1542 status: "running",
1543 startedAt,
1544 };
1545 yield* pushAction(tabId, actionEvent);
1546 const epoch = (yield* Ref.get(controlEpochRef)).get(tabId) ?? 0;
1547 const control = yield* ensureControlSession(wc);
1548 const execute = Effect.fn("PreviewManager.executeControlAction")(function* () {
1549 const checkControl = Effect.gen(function* () {
1550 const currentEpoch = (yield* Ref.get(controlEpochRef)).get(tabId) ?? 0;
1551 if (currentEpoch !== epoch) {
1552 return yield* new PreviewAutomationControlInterruptedError({
1553 operation: action,
1554 tabId,
1555 webContentsId: wc.id,
1556 });
1557 }
1558 });
1559 // A human who took over while this action waited for the permit owns
1560 // the page; marking it agent-driven would hide their Save dialog.
1561 yield* checkControl;
1562 if (!READ_ONLY_CONTROL_ACTIONS.has(action)) agentDrivenWebContents.add(wc);
1563 yield* update(tabId, { controller: "agent" });
1564 const send: SendCommand = Effect.fn("PreviewManager.sendCommand")(
1565 function* (method, commandParams, sessionId) {
1566 yield* checkControl;
1567 const result = yield* attemptPromise(
1568 { operation: `${action}.${method}`, tabId, webContentsId: wc.id },
1569 async () => {
1570 try {
1571 return await (sessionId === undefined
1572 ? control.debugger.sendCommand(method, commandParams)
1573 : control.debugger.sendCommand(method, commandParams, sessionId));
1574 } finally {
1575 const objectGroup =
1576 method === "Runtime.evaluate" ? commandParams?.["objectGroup"] : undefined;
1577 // Cancelling an Effect does not cancel the CDP promise. Release
1578 // after it settles, even if the caller has already left, so a
1579 // late result cannot recreate handles after an early cleanup.
1580 if (typeof objectGroup === "string" && objectGroup.startsWith("t3-evaluation-")) {
1581 const params = { objectGroup };
1582 await (
1583 sessionId === undefined
1584 ? control.debugger.sendCommand("Runtime.releaseObjectGroup", params)
1585 : control.debugger.sendCommand(
1586 "Runtime.releaseObjectGroup",
1587 params,
1588 sessionId,
1589 )
1590 ).catch(() => undefined);
1591 }
1592 }
1593 },
1594 );
1595 yield* checkControl;
1596 return result;
1597 },
1598 );
1599 // Cleanup commands must still run after human input invalidates the action's
1600 // control epoch. Otherwise a partially dispatched input can leave Chromium
1601 // with a held key or focus emulation enabled for subsequent actions.
1602 const sendCleanup: SendCommand = Effect.fn("PreviewManager.sendCleanupCommand")(
1603 function* (method, commandParams, sessionId) {
1604 return yield* attemptPromise(
1605 {
1606 operation: `${action}.cleanup.${method}`,
1607 tabId,
1608 webContentsId: wc.id,
1609 },
1610 () =>
1611 sessionId === undefined
1612 ? control.debugger.sendCommand(method, commandParams)
1613 : control.debugger.sendCommand(method, commandParams, sessionId),
1614 );
1615 },
1616 );
1617 return yield* use(send, sendCleanup, checkControl);
1618 });
1619 const finalize = Effect.fn("PreviewManager.finalizeControlAction")(function* (
1620 exit: Exit.Exit<A, PreviewManagerError>,
1621 ) {
1622 const completedAt = yield* currentIso;
1623 if (exit._tag === "Success") {
1624 yield* replaceAction(tabId, {
1625 ...actionEvent,
1626 status: "succeeded",
1627 completedAt,
1628 });
1629 } else {
1630 const error = Option.getOrNull(Cause.findErrorOption(exit.cause));
1631 const interrupted = isPreviewAutomationControlInterruptedError(error);
1632 const errorMessage = isPreviewOperationError(error)
1633 ? PreviewOperationError.toTimelineMessage(error)
1634 : isPreviewAutomationEvaluationError(error)
1635 ? PreviewAutomationEvaluationError.toTimelineMessage(error)
1636 : isPreviewAutomationInvalidSelectorError(error)
1637 ? PreviewAutomationInvalidSelectorError.toTimelineMessage(error)
1638 : error instanceof Error
1639 ? error.message
1640 : String(error);
1641 yield* replaceAction(tabId, {
1642 ...actionEvent,
1643 status: interrupted ? "interrupted" : "failed",
1644 completedAt,
1645 error: errorMessage,
1646 });
1647 }
1648 const tabs = yield* SynchronizedRef.get(tabsRef);
1649 if (tabs.has(tabId)) yield* update(tabId, { controller: "none" });
1650 });
1651 return yield* control.semaphore.withPermit(execute().pipe(Effect.onExit(finalize)));
1652 });
1653
1654 const evaluateWithDebugger = <A = unknown>(
1655 tabId: string,
1656 send: SendCommand,
1657 expression: string,
1658 returnByValue: boolean,
1659 awaitPromise = true,
1660 ): Effect.Effect<A, PreviewManagerError> =>
1661 Effect.gen(function* () {
1662 const objectGroup = `t3-evaluation-${yield* crypto.randomUUIDv4.pipe(Effect.orDie)}`;
1663 return yield* send("Runtime.evaluate", {
1664 expression,
1665 awaitPromise,
1666 returnByValue,
1667 userGesture: true,
1668 objectGroup,
1669 }).pipe(
1670 Effect.flatMap((rawResponse) => {
1671 const response = rawResponse as CdpEvaluationResult;
1672 if (!response.exceptionDetails) {
1673 return Effect.succeed(response.result?.value as A);
1674 }
1675 const detail = previewAutomationEvaluationDetail(response.exceptionDetails);
1676 return Effect.fail(
1677 new PreviewAutomationEvaluationError({
1678 tabId,
1679 detailKind: detail.detailKind,
1680 detailLength: detail.detail?.length ?? 0,
1681 cause: response.exceptionDetails,
1682 }),
1683 );
1684 }),
1685 );
1686 });
1687
1688 const automationLocator = (input: {
1689 readonly selector?: string | undefined;
1690 readonly locator?: string | undefined;
1691 }): string | null => input.locator ?? (input.selector ? `css=${input.selector}` : null);
1692
1693 const automationSelectorDiagnostics = (input: {
1694 readonly selector?: string | undefined;
1695 readonly locator?: string | undefined;
1696 }): {
1697 readonly selectorKind: PreviewAutomationSelectorKind;
1698 readonly selectorLength?: number;
1699 } => {
1700 if (input.locator !== undefined) {
1701 return { selectorKind: "locator", selectorLength: input.locator.length };
1702 }
1703 if (input.selector !== undefined) {
1704 return { selectorKind: "selector", selectorLength: input.selector.length };
1705 }
1706 return { selectorKind: "focused-element" };
1707 };
1708
1709 const ensurePlaywrightInjected = Effect.fn("PreviewManager.ensurePlaywrightInjected")(function* (
1710 tabId: string,
1711 send: SendCommand,
1712 ) {
1713 const installed = yield* evaluateWithDebugger<boolean>(
1714 tabId,
1715 send,
1716 "Boolean(globalThis.__t3PlaywrightInjected)",
1717 true,
1718 );
1719 if (installed) return;
1720 const expression = yield* playwrightInstallExpression.pipe(
1721 Effect.mapError(
1722 (cause) =>
1723 new PreviewOperationError({
1724 operation: "ensurePlaywrightInjected",
1725 tabId,
1726 cause,
1727 }),
1728 ),
1729 );
1730 yield* evaluateWithDebugger(tabId, send, expression, true);
1731 });
1732
1733 const cancelPickElement = Effect.fn("PreviewManager.cancelPickElement")(function* (1211 const cancelPickElement = Effect.fn("PreviewManager.cancelPickElement")(function* (
1734 tabId: string,1212 tabId: string,
1735 ) {1213 ) {
...@@ -1760,44 +1238,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1760,44 +1238,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1760 return { kind: "Success", url, title };1238 return { kind: "Success", url, title };
1761 };1239 };
17621240
1763 const consumeExpectedAgentInput = Effect.fn("PreviewManager.consumeExpectedAgentInput")(
1764 function* (tabId: string, signal: PreviewInputSignal) {
1765 const now = yield* currentMillis;
1766 return yield* Ref.modify(expectedAgentInputsRef, (allExpected) => {
1767 const pending = (allExpected.get(tabId) ?? []).filter(
1768 (expected) => expected.expiresAt > now,
1769 );
1770 const index = pending.findIndex((expected) => inputSignalsMatch(expected.signal, signal));
1771 const matched = index >= 0;
1772 const nextPending = matched
1773 ? pending.filter((_, pendingIndex) => pendingIndex !== index)
1774 : pending;
1775 return [
1776 matched,
1777 replaceMap(allExpected, (copy) => {
1778 if (nextPending.length === 0) copy.delete(tabId);
1779 else copy.set(tabId, nextPending);
1780 }),
1781 ] as const;
1782 });
1783 },
1784 );
1785
1786 const expectAgentInput = Effect.fn("PreviewManager.expectAgentInput")(function* (
1787 tabId: string,
1788 signal: PreviewInputSignal,
1789 ) {
1790 const now = yield* currentMillis;
1791 yield* Ref.update(expectedAgentInputsRef, (allExpected) =>
1792 replaceMap(allExpected, (copy) => {
1793 const pending = (allExpected.get(tabId) ?? []).filter(
1794 (expected) => expected.expiresAt > now,
1795 );
1796 copy.set(tabId, [...pending, { signal, expiresAt: now + 1_000 }]);
1797 }),
1798 );
1799 });
1800
1801 const attachListeners = Effect.fn("PreviewManager.attachListeners")(function* (1241 const attachListeners = Effect.fn("PreviewManager.attachListeners")(function* (
1802 tabId: string,1242 tabId: string,
1803 wc: Electron.WebContents,1243 wc: Electron.WebContents,
...@@ -2007,15 +1447,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -2007,15 +1447,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
2007 const handleHumanInput = Effect.fn("PreviewManager.handleHumanInput")(function* (1447 const handleHumanInput = Effect.fn("PreviewManager.handleHumanInput")(function* (
2008 rawSignal?: unknown,1448 rawSignal?: unknown,
2009 ) {1449 ) {
2010 if (isPreviewInputSignal(rawSignal) && (yield* consumeExpectedAgentInput(tabId, rawSignal))) {
2011 return;
2012 }
2013 agentDrivenWebContents.delete(wc);
2014 yield* Ref.update(controlEpochRef, (epochs) =>
2015 replaceMap(epochs, (copy) => {
2016 copy.set(tabId, (epochs.get(tabId) ?? 0) + 1);
2017 }),
2018 );
2019 yield* update(1450 yield* update(
2020 tabId,1451 tabId,
2021 { controller: "human" },1452 { controller: "human" },
...@@ -2214,6 +1645,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -2214,6 +1645,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
2214 zoomFactor: normalizeZoomFactor(defaults?.zoomFactor),1645 zoomFactor: normalizeZoomFactor(defaults?.zoomFactor),
2215 pictureInPicture: false,1646 pictureInPicture: false,
2216 colorScheme: defaults?.colorScheme ?? "system",1647 colorScheme: defaults?.colorScheme ?? "system",
1648 ...(defaults?.serverTab === undefined ? {} : { serverTab: defaults.serverTab }),
2217 audioMuted: false,1649 audioMuted: false,
2218 audible: false,1650 audible: false,
2219 controller: "none",1651 controller: "none",
...@@ -2269,7 +1701,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -2269,7 +1701,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
2269 const closedTab = tab.value;1701 const closedTab = tab.value;
2270 if (closedTab.webContentsId != null) {1702 if (closedTab.webContentsId != null) {
2271 yield* Effect.all(1703 yield* Effect.all(
2272 [detachControlSession(closedTab.webContentsId), detachListeners(closedTab.webContentsId)],1704 [
1705 detachControlSession(closedTab.webContentsId, closedTab.serverTab),
1706 detachListeners(closedTab.webContentsId),
1707 ],
2273 { concurrency: 2, discard: true },1708 { concurrency: 2, discard: true },
2274 );1709 );
2275 }1710 }
...@@ -2593,7 +2028,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -2593,7 +2028,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
2593 }2028 }
2594 // URL-bar navigation hands the page back to the human. The agent's own2029 // URL-bar navigation hands the page back to the human. The agent's own
2595 // navigation shares this path; its next action marks the page again.2030 // navigation shares this path; its next action marks the page again.
2596 agentDrivenWebContents.delete(wc);
2597 if (wc.getURL() === url) {2031 if (wc.getURL() === url) {
2598 yield* attempt({ operation: "navigate.reload", tabId, webContentsId: wc.id }, () =>2032 yield* attempt({ operation: "navigate.reload", tabId, webContentsId: wc.id }, () =>
2599 wc.reload(),2033 wc.reload(),
...@@ -2887,6 +2321,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -2887,6 +2321,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
2887 yield* detachControlSession(wc.id);2321 yield* detachControlSession(wc.id);
2888 return;2322 return;
2889 }2323 }
2324 if (afterAttach.serverTab) {
2325 yield* listenForAgentPointers;
2326 browserHost.attach(afterAttach.serverTab, { webContents: wc, debugger: control.debugger });
2327 }
2890 if (afterAttach.colorScheme !== "system") {2328 if (afterAttach.colorScheme !== "system") {
2891 yield* attemptPromise({ operation: "applyColorScheme", tabId, webContentsId: wc.id }, () =>2329 yield* attemptPromise({ operation: "applyColorScheme", tabId, webContentsId: wc.id }, () =>
2892 control.debugger.sendCommand("Emulation.setEmulatedMedia", {2330 control.debugger.sendCommand("Emulation.setEmulatedMedia", {
...@@ -3623,24 +3061,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -3623,24 +3061,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
3623 });3061 });
3624 };3062 };
36253063
3626 // Installed once per session. Electron opens a native Save dialog for a
3627 // download with no save path, so every export an agent clicked while testing
3628 // a page put a modal over the whole app. Downloads from a page the agent drove
3629 // last go to the artifact directory instead. Downloads the human starts keep
3630 // the Save dialog.
3631 const installDownloadHandler = (session: Session) => {
3632 if (downloadHandlerSessions.has(session)) return;
3633 downloadHandlerSessions.add(session);
3634 session.on("will-download", (_event, item, source) => {
3635 if (!agentDrivenWebContents.has(source)) return;
3636 // The start time keeps names unique across restarts; the count keeps two
3637 // same-name downloads in one millisecond from overwriting each other.
3638 const id = `${Math.round(item.getStartTime() * 1000).toString(36)}-${(downloadCount++).toString(36)}`;
3639 const fileName = `browser-download-${id}-${path.basename(item.getFilename())}`;
3640 item.setSavePath(path.join(resolvedArtifactDirectory, fileName));
3641 });
3642 };
3643
3644 const startRecording = Effect.fn("PreviewManager.startRecording")(function* (3064 const startRecording = Effect.fn("PreviewManager.startRecording")(function* (
3645 tabId: string,3065 tabId: string,
3646 options: RecordingInputOptions = DEFAULT_RECORDING_INPUT_OPTIONS,3066 options: RecordingInputOptions = DEFAULT_RECORDING_INPUT_OPTIONS,
...@@ -3763,956 +3183,86 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -3763,956 +3183,86 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
3763 };3183 };
3764 });3184 });
37653185
3766 const automationStatus = Effect.fn("PreviewManager.automationStatus")(function* (tabId: string) {3186 const revealArtifact = Effect.fn("PreviewManager.revealArtifact")(function* (
3767 const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId);3187 artifactPath: string,
3768 if (!tab || tab.webContentsId == null) {
3769 const navStatus = tab?.navStatus;
3770 return {
3771 available: false,
3772 visible: true,
3773 tabId,
3774 url: !navStatus || navStatus.kind === "Idle" ? null : navStatus.url,
3775 title: !navStatus || navStatus.kind === "Idle" ? null : navStatus.title,
3776 loading: navStatus?.kind === "Loading",
3777 };
3778 }
3779 const wc = webContents.fromId(tab.webContentsId);
3780 return !wc || wc.isDestroyed()
3781 ? {
3782 available: false,
3783 visible: true,
3784 tabId,
3785 url: null,
3786 title: null,
3787 loading: false,
3788 }
3789 : {
3790 available: true,
3791 visible: true,
3792 tabId,
3793 url: wc.getURL() || null,
3794 title: wc.getTitle() || null,
3795 loading: wc.isLoading(),
3796 };
3797 });
3798
3799 const captureAutomationSnapshot = Effect.fn("PreviewManager.captureAutomationSnapshot")(
3800 function* (tabId: string, wc: Electron.WebContents, send: SendCommand) {
3801 yield* Effect.all([send("Runtime.enable"), send("Accessibility.enable")], {
3802 concurrency: 2,
3803 discard: true,
3804 });
3805 const page = yield* evaluateWithDebugger<{
3806 url: string;
3807 title: string;
3808 loading: boolean;
3809 visibleText: string;
3810 interactiveElements: PreviewAutomationSnapshot["interactiveElements"];
3811 }>(
3812 tabId,
3813 send,
3814 `(() => {
3815 const selectorFor = (element) => {
3816 if (element.id) return "#" + CSS.escape(element.id);
3817 for (const attribute of ["data-testid", "name"]) {
3818 const value = element.getAttribute(attribute);
3819 if (value) return element.tagName.toLowerCase() + "[" + attribute + "=" + JSON.stringify(value) + "]";
3820 }
3821 const buildParts = (current, parts = []) => {
3822 if (!current || current.nodeType !== Node.ELEMENT_NODE || parts.length >= 8) {
3823 return parts;
3824 }
3825 const parent = current.parentElement;
3826 const siblings = parent
3827 ? Array.from(parent.children).filter((child) => child.tagName === current.tagName)
3828 : [];
3829 const base = current.tagName.toLowerCase();
3830 const part = siblings.length > 1
3831 ? base + ":nth-of-type(" + (siblings.indexOf(current) + 1) + ")"
3832 : base;
3833 return buildParts(parent, [part, ...parts]);
3834 };
3835 return buildParts(element).join(" > ");
3836 };
3837 const visible = (element) => {
3838 const style = getComputedStyle(element);
3839 const rect = element.getBoundingClientRect();
3840 return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0;
3841 };
3842 const elements = Array.from(document.querySelectorAll(
3843 "a[href],button,input,textarea,select,[role],[tabindex]"
3844 )).filter(visible).slice(0, ${MAX_INTERACTIVE_ELEMENTS}).map((element) => {
3845 const rect = element.getBoundingClientRect();
3846 return {
3847 tag: element.tagName.toLowerCase(),
3848 role: element.getAttribute("role"),
3849 name: (element.getAttribute("aria-label") || element.innerText || element.getAttribute("name") || "").slice(0, ${MAX_INTERACTIVE_ELEMENT_NAME_LENGTH}),
3850 selector: selectorFor(element),
3851 x: rect.x,
3852 y: rect.y,
3853 width: rect.width,
3854 height: rect.height
3855 };
3856 });
3857 return {
3858 url: location.href,
3859 title: document.title,
3860 loading: document.readyState !== "complete",
3861 visibleText: (document.body?.innerText || "").slice(0, ${MAX_VISIBLE_TEXT_LENGTH}),
3862 interactiveElements: elements
3863 };
3864 })()`,
3865 true,
3866 );
3867 const [accessibility, sourceImage, diagnostics, timelines] = yield* Effect.all([
3868 send("Accessibility.getFullAXTree"),
3869 capturePageWithRetry(
3870 {
3871 operation: "automationSnapshot.capturePage",
3872 tabId,
3873 webContentsId: wc.id,
3874 },
3875 tabId,
3876 wc,
3877 ),
3878 Ref.get(diagnosticsRef),
3879 Ref.get(actionTimelineRef),
3880 ]);
3881 const sourceSize = sourceImage.getSize();
3882 const image =
3883 sourceSize.width > MAX_SCREENSHOT_WIDTH
3884 ? sourceImage.resize({ width: MAX_SCREENSHOT_WIDTH })
3885 : sourceImage;
3886 const size = image.getSize();
3887 const browserDiagnostics = diagnostics.get(wc.id);
3888 return {
3889 ...page,
3890 accessibilityTree: accessibility,
3891 consoleEntries: [...(browserDiagnostics?.consoleEntries ?? [])],
3892 networkEntries: [...(browserDiagnostics?.networkEntries ?? [])],
3893 actionTimeline: [...(timelines.get(tabId) ?? [])],
3894 screenshot: {
3895 mimeType: "image/png" as const,
3896 data: image.toPNG().toString("base64"),
3897 width: size.width,
3898 height: size.height,
3899 },
3900 };
3901 },
3902 );
3903
3904 const automationSnapshot = Effect.fn("PreviewManager.automationSnapshot")(function* (
3905 tabId: string,
3906 ) {3188 ) {
3907 const wc = yield* requireWebContents(tabId);3189 const resolvedPath = yield* resolveArtifactPath(artifactPath);
3908 return yield* withControlSession(tabId, wc, "snapshot", (send) =>3190 yield* attempt({ operation: "revealArtifact", artifactPath: resolvedPath }, () =>
3909 captureAutomationSnapshot(tabId, wc, send),3191 shell.showItemInFolder(resolvedPath),
3910 );3192 );
3911 });3193 });
39123194
3913 const resolveClickPoint = Effect.fn("PreviewManager.resolveClickPoint")(function* (3195 const copyArtifactToClipboard = Effect.fn("PreviewManager.copyArtifactToClipboard")(function* (
3914 tabId: string,3196 artifactPath: string,
3915 send: SendCommand,
3916 input: PreviewAutomationClickInput,
3917 ) {3197 ) {
3918 if (!("selector" in input) && !("locator" in input)) {3198 const resolvedPath = yield* resolveArtifactPath(artifactPath);
3919 return { x: input.x!, y: input.y! };3199 const image = yield* attempt(
3920 }3200 { operation: "copyArtifactToClipboard.load", artifactPath: resolvedPath },
3921 const locator = automationLocator(input)!;3201 () => nativeImage.createFromPath(resolvedPath),
3922 yield* ensurePlaywrightInjected(tabId, send);
3923 const locatorJson = yield* encodeJson(
3924 { operation: "automationClick.encodeLocator", tabId },
3925 locator,
3926 );
3927 const point = yield* evaluateWithDebugger<
3928 { x: number; y: number } | { invalidSelector: true; message: string } | { notFound: true }
3929 >(
3930 tabId,
3931 send,
3932 `(() => {
3933 try {
3934 const injected = globalThis.__t3PlaywrightInjected;
3935 const parsed = injected.parseSelector(${locatorJson});
3936 const element = injected.querySelector(parsed, document, true);
3937 if (!element) return { notFound: true };
3938 const visible = injected.elementState(element, "visible");
3939 const enabled = injected.elementState(element, "enabled");
3940 if (!visible.matches || !enabled.matches) return { notFound: true };
3941 element.scrollIntoView({ block: "center", inline: "center" });
3942 const rect = element.getBoundingClientRect();
3943 return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
3944 } catch (error) {
3945 return { invalidSelector: true, message: String(error) };
3946 }
3947 })()`,
3948 true,
3949 );3202 );
3950 if ("invalidSelector" in point) {3203 if (image.isEmpty()) {
3951 return yield* new PreviewAutomationInvalidSelectorError({3204 return yield* new PreviewArtifactImageLoadError({ artifactPath: resolvedPath });
3952 operation: "click",
3953 tabId,
3954 ...automationSelectorDiagnostics(input),
3955 reasonLength: point.message.length,
3956 cause: point,
3957 });
3958 }
3959 if ("notFound" in point) {
3960 return yield* new PreviewAutomationTargetNotFoundError({
3961 operation: "click",
3962 tabId,
3963 ...automationSelectorDiagnostics(input),
3964 });
3965 }3205 }
3966 return point;3206 yield* attemptPromise(
3207 { operation: "copyArtifactToClipboard.write", artifactPath: resolvedPath },
3208 () =>
3209 clipboard.write([
3210 new ClipboardItem({
3211 "image/png": new Blob([Uint8Array.from(image.toPNG())], { type: "image/png" }),
3212 }),
3213 ]),
3214 );
3967 });3215 });
39683216
3969 const emitPointerEvent = Effect.fn("PreviewManager.emitPointerEvent")(function* (3217 let pointerSequence = 0;
3970 event: DesktopPreviewPointerEvent,3218 /**
3971 ) {3219 * The server reports where an agent action is about to land on a tab it
3972 const recording = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(event.tabId);3220 * drives here. The live cursor and desktop recordings draw it like any agent.
3973 const tab = (yield* SynchronizedRef.get(tabsRef)).get(event.tabId);3221 */
3974 const webContentsId = tab?.webContentsId;3222 const emitAgentPointer = Effect.fn("PreviewManager.emitAgentPointer")(function* (pointer: {
3223 readonly key: { readonly threadId: string; readonly tabId: string };
3224 readonly phase: "move" | "click";
3225 readonly x: number;
3226 readonly y: number;
3227 }) {
3228 const tab = [...(yield* SynchronizedRef.get(tabsRef)).values()].find(
3229 (candidate) =>
3230 candidate.serverTab?.threadId === pointer.key.threadId &&
3231 candidate.serverTab.tabId === pointer.key.tabId,
3232 );
3233 if (!tab) return;
3234 const event: DesktopPreviewPointerEvent = {
3235 tabId: tab.tabId,
3236 phase: pointer.phase,
3237 x: pointer.x,
3238 y: pointer.y,
3239 sequence: ++pointerSequence,
3240 createdAt: yield* currentIso,
3241 };
3242 const recording = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(tab.tabId);
3243 const webContentsId = tab.webContentsId;
3975 if (recording?.consumers.has("recording") && webContentsId != null) {3244 if (recording?.consumers.has("recording") && webContentsId != null) {
3976 yield* attempt({ operation: "recording.pointer", tabId: event.tabId }, () => {3245 yield* attempt({ operation: "recording.pointer", tabId: tab.tabId }, () => {
3977 const contents = webContents.fromId(webContentsId);3246 const contents = webContents.fromId(webContentsId);
3978 if (contents && !contents.isDestroyed()) contents.send(RECORDING_POINTER_CHANNEL, event);3247 if (contents && !contents.isDestroyed()) contents.send(RECORDING_POINTER_CHANNEL, event);
3979 });3248 }).pipe(Effect.ignore);
3980 }3249 }
3981 const listeners = yield* Ref.get(pointerEventListenersRef);3250 const listeners = yield* Ref.get(pointerEventListenersRef);
3982 yield* Effect.forEach(3251 yield* Effect.forEach(
3983 listeners,3252 listeners,
3984 (listener) => deliverEvent("pointer-event", event.tabId, () => listener(event)),3253 (listener) => deliverEvent("pointer-event", tab.tabId, () => listener(event)),
3985 { discard: true },3254 { discard: true },
3986 );3255 );
3987 });3256 });
39883257 // Listening starts with the first tab of the desktop's own server.
3989 const performAutomationClick = Effect.fn("PreviewManager.performAutomationClick")(function* (3258 let pointersStarted = false;
3990 tabId: string,3259 const listenForAgentPointers = Effect.suspend(() => {
3991 input: PreviewAutomationClickInput,3260 if (pointersStarted) return Effect.void;
3992 send: SendCommand,3261 pointersStarted = true;
3993 ) {3262 return browserHost.pointers.pipe(
3994 yield* prepareAutomationInput(send, true);3263 Stream.runForEach(emitAgentPointer),
3995 const point = yield* resolveClickPoint(tabId, send, input);3264 Effect.forkIn(parentScope),
3996 const viewport = yield* evaluateWithDebugger<{ width: number; height: number }>(3265 Effect.asVoid,
3997 tabId,
3998 send,
3999 "({ width: window.innerWidth, height: window.innerHeight })",
4000 true,
4001 );
4002 if (point.x < 0 || point.y < 0 || point.x > viewport.width || point.y > viewport.height) {
4003 return yield* new PreviewAutomationCoordinatesOutsideViewportError({
4004 tabId,
4005 x: point.x,
4006 y: point.y,
4007 viewportWidth: viewport.width,
4008 viewportHeight: viewport.height,
4009 });
4010 }
4011 const moveSequence = yield* nextCounter(pointerSequenceRef);
4012 const moveCreatedAt = yield* currentIso;
4013 yield* emitPointerEvent({
4014 tabId,
4015 phase: "move",
4016 ...point,
4017 sequence: moveSequence,
4018 createdAt: moveCreatedAt,
4019 });
4020 yield* Effect.sleep(AGENT_CURSOR_MOVE_MS);
4021 const clickSequence = yield* nextCounter(pointerSequenceRef);
4022 const clickCreatedAt = yield* currentIso;
4023 yield* emitPointerEvent({
4024 tabId,
4025 phase: "click",
4026 ...point,
4027 sequence: clickSequence,
4028 createdAt: clickCreatedAt,
4029 });
4030 yield* Effect.sleep(AGENT_CURSOR_CLICK_LEAD_MS);
4031 yield* expectAgentInput(tabId, { kind: "pointer", ...point, button: 0 });
4032 yield* send("Input.dispatchMouseEvent", {
4033 type: "mousePressed",
4034 ...point,
4035 button: "left",
4036 clickCount: 1,
4037 });
4038 yield* send("Input.dispatchMouseEvent", {
4039 type: "mouseReleased",
4040 ...point,
4041 button: "left",
4042 clickCount: 1,
4043 });
4044 });
4045
4046 const automationClick = Effect.fn("PreviewManager.automationClick")(function* (
4047 tabId: string,
4048 input: PreviewAutomationClickInput,
4049 ) {
4050 const wc = yield* requireWebContents(tabId);
4051 yield* withControlSession(tabId, wc, "click", (send) =>
4052 performAutomationClick(tabId, input, send),
4053 );
4054 });
4055
4056 const typeIntoAutomationTarget = Effect.fn("PreviewManager.typeIntoAutomationTarget")(function* (
4057 tabId: string,
4058 send: SendCommand,
4059 input: PreviewAutomationTypeInput,
4060 ) {
4061 const locator = automationLocator(input);
4062 if (locator) yield* ensurePlaywrightInjected(tabId, send);
4063 const locatorJson = locator
4064 ? yield* encodeJson({ operation: "automationType.encodeLocator", tabId }, locator)
4065 : null;
4066 const textJson = yield* encodeJson(
4067 { operation: "automationType.encodeText", tabId },
4068 input.text,
4069 );
4070 const result = yield* evaluateWithDebugger<
4071 | { ok: true }
4072 | { invalidSelector: true; message: string }
4073 | { notEditable: true }
4074 | { notFound: true }
4075 >(
4076 tabId,
4077 send,
4078 `(() => {
4079 try {
4080 const element = ${locatorJson ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${locatorJson}), document, true); })()` : "document.activeElement"};
4081 if (!element) return { notFound: true };
4082 const textControl =
4083 element instanceof HTMLTextAreaElement ||
4084 (element instanceof HTMLInputElement &&
4085 !new Set(["button", "checkbox", "color", "file", "hidden", "image", "radio", "range", "reset", "submit"]).has(element.type));
4086 const editable = textControl || element.isContentEditable;
4087 if (!editable || element.disabled || element.readOnly) return { notEditable: true };
4088 element.focus();
4089 if (document.activeElement !== element) return { notEditable: true };
4090 const clear = ${input.clear ?? false};
4091 if (clear) {
4092 if (textControl) {
4093 element.select();
4094 } else {
4095 const range = document.createRange();
4096 range.selectNodeContents(element);
4097 const selection = document.getSelection();
4098 selection?.removeAllRanges();
4099 selection?.addRange(range);
4100 }
4101 }
4102 const text = ${textJson};
4103 let inserted = true;
4104 if (text.length > 0) {
4105 inserted = document.execCommand("insertText", false, text);
4106 } else if (clear) {
4107 document.execCommand("delete", false);
4108 const cleared = textControl
4109 ? element.value.length === 0
4110 : (element.textContent ?? "").length === 0;
4111 if (!cleared) {
4112 if (textControl) {
4113 const prototype = element instanceof HTMLTextAreaElement
4114 ? HTMLTextAreaElement.prototype
4115 : HTMLInputElement.prototype;
4116 const valueSetter = Object.getOwnPropertyDescriptor(prototype, "value")?.set;
4117 if (valueSetter) valueSetter.call(element, "");
4118 else element.value = "";
4119 } else {
4120 element.replaceChildren();
4121 }
4122 element.dispatchEvent(new InputEvent("input", {
4123 bubbles: true,
4124 inputType: "deleteContentBackward",
4125 }));
4126 }
4127 }
4128 if (!inserted) return { notEditable: true };
4129 element.dispatchEvent(new Event("change", { bubbles: true }));
4130 return { ok: true };
4131 } catch (error) {
4132 return { invalidSelector: true, message: String(error) };
4133 }
4134 })()`,
4135 true,
4136 );
4137 if ("invalidSelector" in result) {
4138 return yield* new PreviewAutomationInvalidSelectorError({
4139 operation: "type",
4140 tabId,
4141 ...automationSelectorDiagnostics(input),
4142 reasonLength: result.message.length,
4143 cause: result,
4144 });
4145 }
4146 if ("notFound" in result) {
4147 return yield* new PreviewAutomationTargetNotFoundError({
4148 operation: "type",
4149 tabId,
4150 ...automationSelectorDiagnostics(input),
4151 });
4152 }
4153 if ("notEditable" in result) {
4154 return yield* new PreviewAutomationTargetNotEditableError({
4155 tabId,
4156 ...automationSelectorDiagnostics(input),
4157 });
4158 }
4159 });
4160
4161 const performAutomationType = Effect.fn("PreviewManager.performAutomationType")(function* (
4162 tabId: string,
4163 input: PreviewAutomationTypeInput,
4164 send: SendCommand,
4165 ) {
4166 // CDP Input.insertText silently drops text until Electron has activated a hidden
4167 // guest WebContents with a pointer event. Editing in the page runtime keeps
4168 // background automation deterministic without stealing foreground app focus.
4169 yield* typeIntoAutomationTarget(tabId, send, input);
4170 });
4171
4172 const automationType = Effect.fn("PreviewManager.automationType")(function* (
4173 tabId: string,
4174 input: PreviewAutomationTypeInput,
4175 ) {
4176 const wc = yield* requireWebContents(tabId);
4177 yield* withControlSession(tabId, wc, "type", (send) =>
4178 performAutomationType(tabId, input, send),
4179 );
4180 });
4181
4182 const withNativeKeyReceipt = Effect.fn("PreviewManager.withNativeKeyReceipt")(function* (
4183 tabId: string,
4184 wc: Electron.WebContents,
4185 dispatch: Effect.Effect<void, PreviewManagerError>,
4186 checkControl: Effect.Effect<void, PreviewManagerError>,
4187 ) {
4188 const context = { operation: "automationPress.awaitNativeKey", tabId, webContentsId: wc.id };
4189 const evaluate = (frame: Electron.WebFrameMain, expression: string) =>
4190 attemptPromise(context, () => frame.executeJavaScript(expression));
4191 const receiptId = yield* crypto.randomUUIDv4.pipe(Effect.orDie);
4192 const { frames, receiptKey } = yield* Effect.acquireRelease(
4193 attempt(context, () => ({
4194 frames: wc.mainFrame.framesInSubtree,
4195 receiptKey: JSON.stringify(`__t3NativeKey_${receiptId}`),
4196 })),
4197 ({ frames, receiptKey }) =>
4198 Effect.forEach(
4199 frames,
4200 (frame) =>
4201 evaluate(frame, `globalThis[${receiptKey}]?.dispose()`).pipe(
4202 Effect.timeoutOption(1_000),
4203 Effect.ignore,
4204 ),
4205 { concurrency: "unbounded", discard: true },
4206 ),
4207 );
4208 yield* Effect.gen(function* () {
4209 for (const frame of frames) {
4210 yield* checkControl;
4211 yield* evaluate(
4212 frame,
4213 `(() => {
4214 const receiptKey = ${receiptKey};
4215 const counts = performance.eventCounts;
4216 if (!counts) throw new Error("Native key delivery counters are unavailable.");
4217 const keyUpsBefore = counts.get("keyup") ?? 0;
4218 const keyDownsBefore = counts.get("keydown") ?? 0;
4219 let settle;
4220 let animationFrame = 0;
4221 const promise = new Promise(resolve => { settle = resolve; });
4222 const finish = delivered => {
4223 cancelAnimationFrame(animationFrame);
4224 window.removeEventListener("pagehide", onPageHide, true);
4225 settle(delivered);
4226 };
4227 // Chromium counts trusted keys before dispatching page listeners,
4228 // so stopImmediatePropagation cannot hide completed input.
4229 const observe = () => {
4230 if ((counts.get("keyup") ?? 0) > keyUpsBefore) finish(true);
4231 else animationFrame = requestAnimationFrame(observe);
4232 };
4233 const onPageHide = () => finish(
4234 (counts.get("keyup") ?? 0) > keyUpsBefore ||
4235 (counts.get("keydown") ?? 0) > keyDownsBefore,
4236 );
4237 globalThis[receiptKey] = { promise, dispose: () => {
4238 finish(false);
4239 delete globalThis[receiptKey];
4240 }};
4241 window.addEventListener("pagehide", onPageHide, true);
4242 animationFrame = requestAnimationFrame(observe);
4243 })()`,
4244 );
4245 }
4246 yield* checkControl;
4247 yield* dispatch;
4248 yield* attemptPromise(context, () =>
4249 Promise.any(
4250 frames.map(async (frame) => {
4251 const delivered: unknown = await frame.executeJavaScript(
4252 `globalThis[${receiptKey}]?.promise`,
4253 );
4254 if (delivered !== true)
4255 throw new Error(
4256 "The preview document changed before native key delivery was confirmed.",
4257 );
4258 }),
4259 ),
4260 );
4261 yield* checkControl;
4262 }).pipe(
4263 Effect.timeout(5_000),
4264 Effect.catchTags({
4265 TimeoutError: () =>
4266 Effect.fail(new PreviewAutomationTimeoutError({ tabId, timeoutMs: 5_000 })),
4267 }),
4268 );
4269 }, Effect.scoped);
4270
4271 const resolveKeyboardTarget = Effect.fn("PreviewManager.resolveKeyboardTarget")(function* (
4272 tabId: string,
4273 send: SendCommand,
4274 sendCleanup: SendCommand,
4275 checkControl: Effect.Effect<void, PreviewManagerError>,
4276 ) {
4277 const context = { operation: "automationPress.resolveFocusedFrame", tabId };
4278 let sessionId: string | undefined;
4279 let contextId: number | undefined;
4280 while (true) {
4281 const evaluated = (yield* send(
4282 "Runtime.evaluate",
4283 {
4284 expression: `(() => {
4285 let element = document.activeElement;
4286 while (element?.shadowRoot?.activeElement) element = element.shadowRoot.activeElement;
4287 return element?.tagName === "IFRAME" ? element : null;
4288 })()`,
4289 ...(contextId === undefined ? {} : { contextId }),
4290 },
4291 sessionId,
4292 )) as { result?: { objectId?: string; subtype?: string } };
4293 if (evaluated.result?.subtype === "null") break;
4294 const objectId = evaluated.result?.objectId;
4295 if (!objectId)
4296 return yield* new PreviewOperationError({
4297 ...context,
4298 cause: new Error("The focused preview frame could not be resolved."),
4299 });
4300 const described = (yield* send("DOM.describeNode", { objectId }, sessionId).pipe(
4301 Effect.ensuring(
4302 sendCleanup("Runtime.releaseObject", { objectId }, sessionId).pipe(Effect.ignore),
4303 ),
4304 )) as { node?: { frameId?: string } };
4305 const frameId = described.node?.frameId;
4306 if (!frameId)
4307 return yield* new PreviewOperationError({
4308 ...context,
4309 cause: new Error("The focused preview iframe is unavailable."),
4310 });
4311 const targets = (yield* send("Target.getTargets")) as {
4312 targetInfos?: ReadonlyArray<{ targetId: string; type: string }>;
4313 };
4314 if (
4315 targets.targetInfos?.some(
4316 (target) => target.type === "iframe" && target.targetId === frameId,
4317 )
4318 ) {
4319 yield* checkControl;
4320 // Register cleanup before checking the epoch again: a successful
4321 // attach must be released even when human input interrupts its reply.
4322 sessionId = yield* Effect.acquireRelease(
4323 sendCleanup("Target.attachToTarget", { targetId: frameId, flatten: true }).pipe(
4324 Effect.flatMap((response) =>
4325 attempt(context, () => {
4326 const attached = response as { sessionId?: string };
4327 if (!attached.sessionId)
4328 throw new Error("The focused preview iframe could not be attached.");
4329 return attached.sessionId;
4330 }),
4331 ),
4332 ),
4333 (attachedSessionId) =>
4334 sendCleanup("Target.detachFromTarget", { sessionId: attachedSessionId }).pipe(
4335 Effect.ignore,
4336 ),
4337 );
4338 yield* checkControl;
4339 contextId = undefined;
4340 } else {
4341 const world = (yield* send(
4342 "Page.createIsolatedWorld",
4343 {
4344 frameId,
4345 worldName: "t3-preview-key-target",
4346 },
4347 sessionId,
4348 )) as { executionContextId?: number };
4349 if (typeof world.executionContextId !== "number")
4350 return yield* new PreviewOperationError({
4351 ...context,
4352 cause: new Error("The focused preview iframe context is unavailable."),
4353 });
4354 contextId = world.executionContextId;
4355 }
4356 }
4357 return { sessionId, contextId };
4358 });
4359
4360 const performAutomationPress = Effect.fn("PreviewManager.performAutomationPress")(function* (
4361 tabId: string,
4362 wc: Electron.WebContents,
4363 input: PreviewAutomationPressInput,
4364 send: SendCommand,
4365 sendCleanup: SendCommand,
4366 checkControl: Effect.Effect<void, PreviewManagerError>,
4367 ) {
4368 yield* prepareAutomationInput(send, false);
4369 const keySequence = makePreviewAutomationNativeKeySequence(input, {
4370 isMac: hostPlatform === "darwin",
4371 });
4372 const recording = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(tabId);
4373 if (recording?.consumers.has("recording") && recording.recordingInputOptions?.showKeyPresses) {
4374 yield* attempt({ operation: "recording.key", tabId, webContentsId: wc.id }, () =>
4375 wc.send(RECORDING_KEY_CHANNEL, {
4376 key: keySequence.signal.key || input.key,
4377 metaKey: input.modifiers?.includes("Meta") ?? false,
4378 ctrlKey: input.modifiers?.includes("Control") ?? false,
4379 altKey: input.modifiers?.includes("Alt") ?? false,
4380 shiftKey: input.modifiers?.includes("Shift") ?? false,
4381 }),
4382 );
4383 }
4384 // CDP keyboard dispatch follows the embedder's focused renderer, and
4385 // WebContents.focus() is a no-op for webview guests. Native input targets
4386 // this guest's widget directly, so Enter cannot submit the host composer.
4387 yield* Effect.gen(function* () {
4388 const { sessionId, contextId } = yield* resolveKeyboardTarget(
4389 tabId,
4390 send,
4391 sendCleanup,
4392 checkControl,
4393 );
4394 // Only descendant renderer sessions bypass Chromium's desktop focus lookup.
4395 if (sessionId) {
4396 const keys = makePreviewAutomationKeySequence(input, { isMac: hostPlatform === "darwin" });
4397 yield* Effect.acquireRelease(Effect.void, () =>
4398 sendCleanup("Emulation.setFocusEmulationEnabled", { enabled: false }, sessionId).pipe(
4399 Effect.ignore,
4400 ),
4401 );
4402 yield* send("Emulation.setFocusEmulationEnabled", { enabled: true }, sessionId);
4403 yield* Effect.acquireRelease(Effect.void, () =>
4404 sendCleanup("Input.dispatchKeyEvent", keys.keyUp, sessionId).pipe(Effect.ignore),
4405 );
4406 yield* send("Input.dispatchKeyEvent", keys.keyDown, sessionId);
4407 return;
4408 }
4409 if (keySequence.commands?.length) {
4410 const context = {
4411 operation: "automationPress.editFocusedFrame",
4412 tabId,
4413 webContentsId: wc.id,
4414 };
4415 const evaluate = (expression: string, cleanup = false) =>
4416 evaluateWithDebugger(
4417 tabId,
4418 (method, params) =>
4419 (cleanup ? sendCleanup : send)(method, {
4420 ...params,
4421 ...(contextId === undefined ? {} : { contextId }),
4422 }),
4423 expression,
4424 true,
4425 );
4426 const clipboardData = keySequence.commands.includes("paste")
4427 ? yield* attemptPromise(context, async () => {
4428 const formats: Array<{ type: string; data: string }> = [];
4429 for (const item of await clipboard.read()) {
4430 for (const type of item.types) {
4431 if (type.startsWith("electron ")) continue;
4432 const blob = await item.getType(type);
4433 if (!("arrayBuffer" in blob)) continue;
4434 formats.push({
4435 type,
4436 data: type.startsWith("text/")
4437 ? await blob.text()
4438 : Buffer.from(await blob.arrayBuffer()).toString("base64"),
4439 });
4440 }
4441 }
4442 return formats;
4443 })
4444 : [];
4445 yield* checkControl;
4446 const expression = previewAutomationEditingCommandExpression(
4447 input,
4448 keySequence,
4449 clipboardData,
4450 );
4451 const selectionId = yield* crypto.randomUUIDv4.pipe(Effect.orDie);
4452 const selectionKey = yield* encodeJson(context, `__t3EditingSelection_${selectionId}`);
4453 // Editing requires an active document. Preserve the target
4454 // and selection across focus handlers without focusing the desktop.
4455 yield* Effect.acquireUseRelease(
4456 evaluate(`(() => {
4457 let element = document.activeElement;
4458 while (element?.shadowRoot?.activeElement) element = element.shadowRoot.activeElement;
4459 const selection = document.getSelection();
4460 const range = selection?.rangeCount ? selection.getRangeAt(0).cloneRange() : null;
4461 const backward = selection?.direction === "backward";
4462 const start = element?.selectionStart;
4463 const end = element?.selectionEnd;
4464 const direction = element?.selectionDirection;
4465 globalThis[${selectionKey}] = () => {
4466 element?.focus({ preventScroll: true });
4467 if (typeof start === "number") element.setSelectionRange(start, end, direction);
4468 else {
4469 selection?.removeAllRanges();
4470 if (range && backward) selection.setBaseAndExtent(
4471 range.endContainer, range.endOffset, range.startContainer, range.startOffset,
4472 );
4473 else if (range) selection.addRange(range);
4474 }
4475 };
4476 })()`),
4477 () =>
4478 Effect.gen(function* () {
4479 yield* send("Emulation.setFocusEmulationEnabled", { enabled: true });
4480 yield* evaluate(`globalThis[${selectionKey}]();${expression}`);
4481 }),
4482 () => evaluate(`delete globalThis[${selectionKey}]`, true).pipe(Effect.ignore),
4483 );
4484 yield* checkControl;
4485 return;
4486 }
4487 yield* send("Emulation.setFocusEmulationEnabled", { enabled: true });
4488 yield* withNativeKeyReceipt(
4489 tabId,
4490 wc,
4491 Effect.gen(function* () {
4492 yield* expectAgentInput(tabId, keySequence.signal);
4493 yield* attempt(
4494 { operation: "automationPress.sendInputEvent", tabId, webContentsId: wc.id },
4495 () => {
4496 try {
4497 wc.sendInputEvent(keySequence.keyDown);
4498 if (keySequence.char) wc.sendInputEvent(keySequence.char);
4499 } finally {
4500 wc.sendInputEvent(keySequence.keyUp);
4501 }
4502 },
4503 );
4504 }),
4505 checkControl,
4506 );
4507 }).pipe(
4508 Effect.scoped,
4509 Effect.ensuring(
4510 sendCleanup("Emulation.setFocusEmulationEnabled", { enabled: false }).pipe(Effect.ignore),
4511 ),
4512 );
4513 });
4514
4515 const automationPress = Effect.fn("PreviewManager.automationPress")(function* (
4516 tabId: string,
4517 input: PreviewAutomationPressInput,
4518 ) {
4519 const wc = yield* requireWebContents(tabId);
4520 yield* withControlSession(tabId, wc, "press", (send, sendCleanup, checkControl) =>
4521 performAutomationPress(tabId, wc, input, send, sendCleanup, checkControl),
4522 );
4523 });
4524
4525 const performAutomationScroll = Effect.fn("PreviewManager.performAutomationScroll")(function* (
4526 tabId: string,
4527 input: PreviewAutomationScrollInput,
4528 send: SendCommand,
4529 ) {
4530 yield* send("Runtime.enable");
4531 const locator = automationLocator(input);
4532 if (locator) yield* ensurePlaywrightInjected(tabId, send);
4533 const locatorJson = locator
4534 ? yield* encodeJson({ operation: "automationScroll.encodeLocator", tabId }, locator)
4535 : null;
4536 const result = yield* evaluateWithDebugger<
4537 { ok: true } | { invalidSelector: true; message: string } | { notFound: true }
4538 >(
4539 tabId,
4540 send,
4541 `(() => {
4542 try {
4543 const target = ${locatorJson ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${locatorJson}), document, true); })()` : "window"};
4544 if (!target) return { notFound: true };
4545 target.scrollBy({ left: ${input.deltaX ?? 0}, top: ${input.deltaY ?? 0}, behavior: "instant" });
4546 return { ok: true };
4547 } catch (error) {
4548 return { invalidSelector: true, message: String(error) };
4549 }
4550 })()`,
4551 true,
4552 );
4553 if ("invalidSelector" in result) {
4554 return yield* new PreviewAutomationInvalidSelectorError({
4555 operation: "scroll",
4556 tabId,
4557 ...automationSelectorDiagnostics(input),
4558 reasonLength: result.message.length,
4559 cause: result,
4560 });
4561 }
4562 if ("notFound" in result) {
4563 return yield* new PreviewAutomationTargetNotFoundError({
4564 operation: "scroll",
4565 tabId,
4566 ...automationSelectorDiagnostics(input),
4567 });
4568 }
4569 });
4570
4571 const automationScroll = Effect.fn("PreviewManager.automationScroll")(function* (
4572 tabId: string,
4573 input: PreviewAutomationScrollInput,
4574 ) {
4575 const wc = yield* requireWebContents(tabId);
4576 yield* withControlSession(tabId, wc, "scroll", (send) =>
4577 performAutomationScroll(tabId, input, send),
4578 );
4579 });
4580
4581 const performAutomationEvaluate = Effect.fn("PreviewManager.performAutomationEvaluate")(
4582 function* (tabId: string, input: PreviewAutomationEvaluateInput, send: SendCommand) {
4583 yield* send("Runtime.enable");
4584 const value = yield* evaluateWithDebugger(
4585 tabId,
4586 send,
4587 input.expression,
4588 input.returnByValue ?? true,
4589 input.awaitPromise ?? true,
4590 );
4591 const serialized = yield* encodeJson(
4592 { operation: "automationEvaluate.encodeResult", tabId },
4593 value,
4594 );
4595 const actualBytes = Buffer.byteLength(serialized, "utf8");
4596 if (actualBytes > MAX_EVALUATION_BYTES) {
4597 return yield* new PreviewAutomationResultTooLargeError({
4598 tabId,
4599 actualBytes,
4600 maximumBytes: MAX_EVALUATION_BYTES,
4601 });
4602 }
4603 return value;
4604 },
4605 );
4606
4607 const automationEvaluate = Effect.fn("PreviewManager.automationEvaluate")(function* (
4608 tabId: string,
4609 input: PreviewAutomationEvaluateInput,
4610 ) {
4611 const wc = yield* requireWebContents(tabId);
4612 return yield* withControlSession(tabId, wc, "evaluate", (send) =>
4613 performAutomationEvaluate(tabId, input, send),
4614 );
4615 });
4616
4617 const performAutomationWaitFor = Effect.fn("PreviewManager.performAutomationWaitFor")(function* (
4618 tabId: string,
4619 input: PreviewAutomationWaitForInput,
4620 send: SendCommand,
4621 ) {
4622 const timeoutMs = input.timeoutMs ?? 15_000;
4623 yield* send("Runtime.enable");
4624 const locator = automationLocator(input);
4625 if (locator) yield* ensurePlaywrightInjected(tabId, send);
4626 const [locatorJson, textJson, urlIncludesJson] = yield* Effect.all([
4627 locator
4628 ? encodeJson({ operation: "automationWaitFor.encodeLocator", tabId }, locator)
4629 : Effect.succeed(null),
4630 input.text
4631 ? encodeJson({ operation: "automationWaitFor.encodeText", tabId }, input.text)
4632 : Effect.succeed(null),
4633 input.urlIncludes
4634 ? encodeJson({ operation: "automationWaitFor.encodeUrl", tabId }, input.urlIncludes)
4635 : Effect.succeed(null),
4636 ]);
4637 const deadline = (yield* currentMillis) + timeoutMs;
4638 while ((yield* currentMillis) <= deadline) {
4639 const result = yield* evaluateWithDebugger<
4640 { matched: boolean } | { invalidSelector: true; message: string }
4641 >(
4642 tabId,
4643 send,
4644 `(() => {
4645 try {
4646 const selectorMatched = ${locatorJson ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${locatorJson}), document, false) !== null; })()` : "true"};
4647 const textMatched = ${
4648 textJson ? `(document.body?.innerText || "").includes(${textJson})` : "true"
4649 };
4650 const urlMatched = ${
4651 urlIncludesJson ? `location.href.includes(${urlIncludesJson})` : "true"
4652 };
4653 return { matched: selectorMatched && textMatched && urlMatched };
4654 } catch (error) {
4655 return { invalidSelector: true, message: String(error) };
4656 }
4657 })()`,
4658 true,
4659 );
4660 if ("invalidSelector" in result) {
4661 return yield* new PreviewAutomationInvalidSelectorError({
4662 operation: "waitFor",
4663 tabId,
4664 ...automationSelectorDiagnostics(input),
4665 reasonLength: result.message.length,
4666 cause: result,
4667 });
4668 }
4669 if (result.matched) return;
4670 yield* Effect.sleep(100);
4671 }
4672 return yield* new PreviewAutomationTimeoutError({
4673 tabId,
4674 timeoutMs,
4675 });
4676 });
4677
4678 const automationWaitFor = Effect.fn("PreviewManager.automationWaitFor")(function* (
4679 tabId: string,
4680 input: PreviewAutomationWaitForInput,
4681 ) {
4682 const wc = yield* requireWebContents(tabId);
4683 yield* withControlSession(tabId, wc, "waitFor", (send) =>
4684 performAutomationWaitFor(tabId, input, send),
4685 );
4686 });
4687
4688 const revealArtifact = Effect.fn("PreviewManager.revealArtifact")(function* (
4689 artifactPath: string,
4690 ) {
4691 const resolvedPath = yield* resolveArtifactPath(artifactPath);
4692 yield* attempt({ operation: "revealArtifact", artifactPath: resolvedPath }, () =>
4693 shell.showItemInFolder(resolvedPath),
4694 );
4695 });
4696
4697 const copyArtifactToClipboard = Effect.fn("PreviewManager.copyArtifactToClipboard")(function* (
4698 artifactPath: string,
4699 ) {
4700 const resolvedPath = yield* resolveArtifactPath(artifactPath);
4701 const image = yield* attempt(
4702 { operation: "copyArtifactToClipboard.load", artifactPath: resolvedPath },
4703 () => nativeImage.createFromPath(resolvedPath),
4704 );
4705 if (image.isEmpty()) {
4706 return yield* new PreviewArtifactImageLoadError({ artifactPath: resolvedPath });
4707 }
4708 yield* attemptPromise(
4709 { operation: "copyArtifactToClipboard.write", artifactPath: resolvedPath },
4710 () =>
4711 clipboard.write([
4712 new ClipboardItem({
4713 "image/png": new Blob([Uint8Array.from(image.toPNG())], { type: "image/png" }),
4714 }),
4715 ]),
4716 );3266 );
4717 });3267 });
47183268
...@@ -4736,7 +3286,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -4736,7 +3286,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
4736 yield* Effect.all(3286 yield* Effect.all(
4737 [3287 [
4738 Ref.set(listenersRef, new Set()),3288 Ref.set(listenersRef, new Set()),
4739 Ref.set(expectedAgentInputsRef, new Map()),
4740 Ref.set(pointerEventListenersRef, new Set()),3289 Ref.set(pointerEventListenersRef, new Set()),
4741 Ref.set(recordingFrameListenersRef, new Set()),3290 Ref.set(recordingFrameListenersRef, new Set()),
4742 Ref.set(recordingInputListenersRef, new Set()),3291 Ref.set(recordingInputListenersRef, new Set()),
...@@ -4748,14 +3297,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -4748,14 +3297,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
4748 yield* Effect.addFinalizer(() => destroy().pipe(Effect.ignore));3297 yield* Effect.addFinalizer(() => destroy().pipe(Effect.ignore));
47493298
4750 return {3299 return {
4751 automationClick,
4752 automationEvaluate,
4753 automationPress,
4754 automationScroll,
4755 automationSnapshot,
4756 automationStatus,
4757 automationType,
4758 automationWaitFor,
4759 cancelPickElement,3300 cancelPickElement,
4760 captureScreenshot,3301 captureScreenshot,
4761 closeTab,3302 closeTab,
...@@ -4764,7 +3305,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -4764,7 +3305,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
4764 goBack,3305 goBack,
4765 goForward,3306 goForward,
4766 hardReload,3307 hardReload,
4767 installDownloadHandler,
4768 navigate,3308 navigate,
4769 openPictureInPicture,3309 openPictureInPicture,
4770 openDevTools,3310 openDevTools,
...@@ -4774,6 +3314,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -4774,6 +3314,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
4774 refresh,3314 refresh,
4775 registerWebview,3315 registerWebview,
4776 resetZoom: (tabId: string) => applyZoom(tabId, () => DEFAULT_ZOOM_FACTOR),3316 resetZoom: (tabId: string) => applyZoom(tabId, () => DEFAULT_ZOOM_FACTOR),
3317 setZoomFactor: (tabId: string, zoomFactor: number) =>
3318 applyZoom(tabId, () => normalizeZoomFactor(zoomFactor)),
4777 revealArtifact,3319 revealArtifact,
4778 saveRecording,3320 saveRecording,
4779 setAnnotationTheme,3321 setAnnotationTheme,
...@@ -4921,143 +3463,6 @@ export class PreviewAutomationDebuggerAttachedError extends Schema.TaggedError<P...@@ -4921,143 +3463,6 @@ export class PreviewAutomationDebuggerAttachedError extends Schema.TaggedError<P
4921 }3463 }
4922}3464}
49233465
4924export class PreviewAutomationEvaluationError extends Schema.TaggedError<PreviewAutomationEvaluationError>()(
4925 "PreviewAutomationEvaluationError",
4926 {
4927 tabId: Schema.String,
4928 detailKind: PreviewAutomationEvaluationDetailKind,
4929 detailLength: Schema.Number,
4930 cause: Schema.Defect(),
4931 },
4932) {
4933 static toTimelineMessage(error: PreviewAutomationEvaluationError): string {
4934 return previewAutomationEvaluationDetail(error.cause).detail ?? error.message;
4935 }
4936
4937 override get message(): string {
4938 return `Preview JavaScript evaluation failed in tab ${this.tabId}`;
4939 }
4940}
4941
4942export class PreviewAutomationTargetNotFoundError extends Schema.TaggedError<PreviewAutomationTargetNotFoundError>()(
4943 "PreviewAutomationTargetNotFoundError",
4944 {
4945 operation: Schema.String,
4946 tabId: Schema.String,
4947 selectorKind: PreviewAutomationSelectorKind,
4948 selectorLength: Schema.optionalKey(Schema.Number),
4949 },
4950) {
4951 override get message(): string {
4952 const target = previewAutomationTargetLabel(this.selectorKind, this.selectorLength);
4953 return `Preview automation ${this.operation} could not find ${target} in tab ${this.tabId}`;
4954 }
4955}
4956
4957export class PreviewAutomationTargetNotEditableError extends Schema.TaggedError<PreviewAutomationTargetNotEditableError>()(
4958 "PreviewAutomationTargetNotEditableError",
4959 {
4960 tabId: Schema.String,
4961 selectorKind: PreviewAutomationSelectorKind,
4962 selectorLength: Schema.optionalKey(Schema.Number),
4963 },
4964) {
4965 override get message(): string {
4966 const target = previewAutomationTargetLabel(this.selectorKind, this.selectorLength);
4967 return `Preview automation type found ${target}, but it is not editable in tab ${this.tabId}`;
4968 }
4969}
4970
4971export class PreviewAutomationCoordinatesOutsideViewportError extends Schema.TaggedError<PreviewAutomationCoordinatesOutsideViewportError>()(
4972 "PreviewAutomationCoordinatesOutsideViewportError",
4973 {
4974 tabId: Schema.String,
4975 x: Schema.Number,
4976 y: Schema.Number,
4977 viewportWidth: Schema.Number,
4978 viewportHeight: Schema.Number,
4979 },
4980) {
4981 override get message(): string {
4982 return `Click coordinates (${this.x}, ${this.y}) are outside the ${this.viewportWidth}x${this.viewportHeight} preview viewport for tab ${this.tabId}`;
4983 }
4984}
4985
4986export class PreviewAutomationInvalidSelectorError extends Schema.TaggedError<PreviewAutomationInvalidSelectorError>()(
4987 "PreviewAutomationInvalidSelectorError",
4988 {
4989 operation: Schema.String,
4990 tabId: Schema.String,
4991 selectorKind: PreviewAutomationSelectorKind,
4992 selectorLength: Schema.optionalKey(Schema.Number),
4993 reasonLength: Schema.Number,
4994 cause: Schema.Defect(),
4995 },
4996) {
4997 static toTimelineMessage(error: PreviewAutomationInvalidSelectorError): string {
4998 if (typeof error.cause !== "object" || error.cause === null) return error.message;
4999 const reason = (error.cause as Record<string, unknown>)["message"];
5000 return typeof reason === "string" && reason.length > 0 ? reason : error.message;
5001 }
5002
5003 get detail(): {
5004 readonly selectorKind: PreviewAutomationSelectorKind;
5005 readonly selectorLength?: number;
5006 } {
5007 return {
5008 selectorKind: this.selectorKind,
5009 ...(this.selectorLength === undefined ? {} : { selectorLength: this.selectorLength }),
5010 };
5011 }
5012
5013 override get message(): string {
5014 const target = previewAutomationTargetLabel(this.selectorKind, this.selectorLength);
5015 return `Preview automation ${this.operation} rejected ${target} in tab ${this.tabId}`;
5016 }
5017}
5018
5019export class PreviewAutomationResultTooLargeError extends Schema.TaggedError<PreviewAutomationResultTooLargeError>()(
5020 "PreviewAutomationResultTooLargeError",
5021 {
5022 tabId: Schema.String,
5023 actualBytes: Schema.Number,
5024 maximumBytes: Schema.Number,
5025 },
5026) {
5027 get detail(): { readonly maximumBytes: number } {
5028 return { maximumBytes: this.maximumBytes };
5029 }
5030
5031 override get message(): string {
5032 return `Preview evaluation result in tab ${this.tabId} was ${this.actualBytes} bytes; maximum is ${this.maximumBytes} bytes`;
5033 }
5034}
5035
5036export class PreviewAutomationTimeoutError extends Schema.TaggedError<PreviewAutomationTimeoutError>()(
5037 "PreviewAutomationTimeoutError",
5038 {
5039 tabId: Schema.String,
5040 timeoutMs: Schema.Number,
5041 },
5042) {
5043 override get message(): string {
5044 return `Preview condition did not match within ${this.timeoutMs}ms in tab ${this.tabId}`;
5045 }
5046}
5047
5048export class PreviewAutomationControlInterruptedError extends Schema.TaggedError<PreviewAutomationControlInterruptedError>()(
5049 "PreviewAutomationControlInterruptedError",
5050 {
5051 operation: Schema.String,
5052 tabId: Schema.String,
5053 webContentsId: Schema.Number,
5054 },
5055) {
5056 override get message(): string {
5057 return `Preview automation ${this.operation} was interrupted by human input in tab ${this.tabId}`;
5058 }
5059}
5060
5061export const PreviewManagerError = Schema.Union([3466export const PreviewManagerError = Schema.Union([
5062 PreviewTabNotFoundError,3467 PreviewTabNotFoundError,
5063 PreviewWebContentsNotFoundError,3468 PreviewWebContentsNotFoundError,
...@@ -5070,23 +3475,9 @@ export const PreviewManagerError = Schema.Union([...@@ -5070,23 +3475,9 @@ export const PreviewManagerError = Schema.Union([
5070 PreviewArtifactImageLoadError,3475 PreviewArtifactImageLoadError,
5071 PreviewAutomationDevToolsOpenError,3476 PreviewAutomationDevToolsOpenError,
5072 PreviewAutomationDebuggerAttachedError,3477 PreviewAutomationDebuggerAttachedError,
5073 PreviewAutomationEvaluationError,
5074 PreviewAutomationTargetNotFoundError,
5075 PreviewAutomationTargetNotEditableError,
5076 PreviewAutomationCoordinatesOutsideViewportError,
5077 PreviewAutomationInvalidSelectorError,
5078 PreviewAutomationResultTooLargeError,
5079 PreviewAutomationTimeoutError,
5080 PreviewAutomationControlInterruptedError,
5081]);3478]);
5082export type PreviewManagerError = typeof PreviewManagerError.Type;3479export type PreviewManagerError = typeof PreviewManagerError.Type;
50833480
5084const isPreviewAutomationControlInterruptedError = Schema.is(
5085 PreviewAutomationControlInterruptedError,
5086);
5087const isPreviewAutomationEvaluationError = Schema.is(PreviewAutomationEvaluationError);
5088const isPreviewAutomationInvalidSelectorError = Schema.is(PreviewAutomationInvalidSelectorError);
5089
5090export class PreviewManager extends Context.Service<3481export class PreviewManager extends Context.Service<
5091 PreviewManager,3482 PreviewManager,
5092 {3483 {
...@@ -5122,6 +3513,10 @@ export class PreviewManager extends Context.Service<...@@ -5122,6 +3513,10 @@ export class PreviewManager extends Context.Service<
5122 tabId: string,3513 tabId: string,
5123 colorScheme: DesktopPreviewColorScheme,3514 colorScheme: DesktopPreviewColorScheme,
5124 ) => Effect.Effect<void, PreviewManagerError>;3515 ) => Effect.Effect<void, PreviewManagerError>;
3516 readonly setZoomFactor: (
3517 tabId: string,
3518 zoomFactor: number,
3519 ) => Effect.Effect<void, PreviewManagerError>;
5125 readonly setAudioMuted: (3520 readonly setAudioMuted: (
5126 tabId: string,3521 tabId: string,
5127 audioMuted: boolean,3522 audioMuted: boolean,
...@@ -5162,36 +3557,6 @@ export class PreviewManager extends Context.Service<...@@ -5162,36 +3557,6 @@ export class PreviewManager extends Context.Service<
5162 mimeType: string,3557 mimeType: string,
5163 data: Uint8Array,3558 data: Uint8Array,
5164 ) => Effect.Effect<DesktopPreviewRecordingArtifact, PreviewManagerError>;3559 ) => Effect.Effect<DesktopPreviewRecordingArtifact, PreviewManagerError>;
5165 readonly automationStatus: (
5166 tabId: string,
5167 ) => Effect.Effect<DesktopPreviewAutomationStatus, PreviewManagerError>;
5168 readonly automationSnapshot: (
5169 tabId: string,
5170 ) => Effect.Effect<PreviewAutomationSnapshot, PreviewManagerError>;
5171 readonly automationClick: (
5172 tabId: string,
5173 input: PreviewAutomationClickInput,
5174 ) => Effect.Effect<void, PreviewManagerError>;
5175 readonly automationType: (
5176 tabId: string,
5177 input: PreviewAutomationTypeInput,
5178 ) => Effect.Effect<void, PreviewManagerError>;
5179 readonly automationPress: (
5180 tabId: string,
5181 input: PreviewAutomationPressInput,
5182 ) => Effect.Effect<void, PreviewManagerError>;
5183 readonly automationScroll: (
5184 tabId: string,
5185 input: PreviewAutomationScrollInput,
5186 ) => Effect.Effect<void, PreviewManagerError>;
5187 readonly automationEvaluate: (
5188 tabId: string,
5189 input: PreviewAutomationEvaluateInput,
5190 ) => Effect.Effect<unknown, PreviewManagerError>;
5191 readonly automationWaitFor: (
5192 tabId: string,
5193 input: PreviewAutomationWaitForInput,
5194 ) => Effect.Effect<void, PreviewManagerError>;
5195 readonly subscribeStateChanges: (listener: Listener) => Effect.Effect<void, never, Scope.Scope>;3560 readonly subscribeStateChanges: (listener: Listener) => Effect.Effect<void, never, Scope.Scope>;
5196 readonly subscribePointerEvents: (3561 readonly subscribePointerEvents: (
5197 listener: PointerEventListener,3562 listener: PointerEventListener,
...@@ -5209,6 +3574,18 @@ export class PreviewManager extends Context.Service<...@@ -5209,6 +3574,18 @@ export class PreviewManager extends Context.Service<
5209export const make = Effect.gen(function* PreviewManagerMake() {3574export const make = Effect.gen(function* PreviewManagerMake() {
5210 const environment = yield* DesktopEnvironment.DesktopEnvironment;3575 const environment = yield* DesktopEnvironment.DesktopEnvironment;
5211 const browserSession = yield* BrowserSession.BrowserSession;3576 const browserSession = yield* BrowserSession.BrowserSession;
3577 const browserHost = yield* DesktopBrowserHost.DesktopBrowserHost;
3578 const downloadSessions = new WeakSet<Electron.Session>();
3579 // Server tabs save downloads where the server's engine reads them. Downloads
3580 // the person starts in a tab the server is not driving keep Electron's dialog.
3581 const placeServerDownloads = (session: Electron.Session) => {
3582 if (downloadSessions.has(session)) return;
3583 downloadSessions.add(session);
3584 session.on("will-download", (_event, item, source) => {
3585 browserHost.placeDownload(source, item);
3586 });
3587 };
3588
5212 const operations = yield* makeNativeOperations(3589 const operations = yield* makeNativeOperations(
5213 environment.browserArtifactsDir,3590 environment.browserArtifactsDir,
5214 environment.path.join(environment.dirname, "preview-pip-preload.cjs"),3591 environment.path.join(environment.dirname, "preview-pip-preload.cjs"),
...@@ -5225,7 +3602,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {...@@ -5225,7 +3602,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {
5225 (cause) => new PreviewOperationError({ operation: "getBrowserSession", cause }),3602 (cause) => new PreviewOperationError({ operation: "getBrowserSession", cause }),
5226 ),3603 ),
5227 );3604 );
5228 operations.installDownloadHandler(session);3605 placeServerDownloads(session);
5229 return session;3606 return session;
5230 },3607 },
5231 ),3608 ),
...@@ -5244,6 +3621,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {...@@ -5244,6 +3621,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {
5244 reapplyZoom: operations.reapplyZoom,3621 reapplyZoom: operations.reapplyZoom,
5245 hardReload: operations.hardReload,3622 hardReload: operations.hardReload,
5246 setColorScheme: operations.setColorScheme,3623 setColorScheme: operations.setColorScheme,
3624 setZoomFactor: operations.setZoomFactor,
5247 setAudioMuted: operations.setAudioMuted,3625 setAudioMuted: operations.setAudioMuted,
5248 openDevTools: operations.openDevTools,3626 openDevTools: operations.openDevTools,
5249 clearCookies: Effect.fn("PreviewManager.clearCookies")(function* (partitions) {3627 clearCookies: Effect.fn("PreviewManager.clearCookies")(function* (partitions) {
...@@ -5284,14 +3662,6 @@ export const make = Effect.gen(function* PreviewManagerMake() {...@@ -5284,14 +3662,6 @@ export const make = Effect.gen(function* PreviewManagerMake() {
5284 startRecording: operations.startRecording,3662 startRecording: operations.startRecording,
5285 stopRecording: operations.stopRecording,3663 stopRecording: operations.stopRecording,
5286 saveRecording: operations.saveRecording,3664 saveRecording: operations.saveRecording,
5287 automationStatus: operations.automationStatus,
5288 automationSnapshot: operations.automationSnapshot,
5289 automationClick: operations.automationClick,
5290 automationType: operations.automationType,
5291 automationPress: operations.automationPress,
5292 automationScroll: operations.automationScroll,
5293 automationEvaluate: operations.automationEvaluate,
5294 automationWaitFor: operations.automationWaitFor,
5295 subscribeStateChanges: operations.subscribeStateChanges,3665 subscribeStateChanges: operations.subscribeStateChanges,
5296 subscribePointerEvents: operations.subscribePointerEvents,3666 subscribePointerEvents: operations.subscribePointerEvents,
5297 subscribeRecordingFrames: operations.subscribeRecordingFrames,3667 subscribeRecordingFrames: operations.subscribeRecordingFrames,
apps/desktop/src/preview/PlaywrightInjectedRuntime.test.ts deleted-80
...@@ -1,80 +0,0 @@
1import { it as effectIt } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import { describe, expect } from "vite-plus/test";
4
5import {
6 extractPlaywrightInjectedRuntimeSource,
7 playwrightInjectedRuntimeInstallExpression,
8 playwrightInjectedRuntimeSource,
9} from "./PlaywrightInjectedRuntime.ts";
10
11const bundleWithSourceLiteral = (literal: string): string =>
12 `const source3 = ${literal};\n }\n});`;
13
14describe("playwright injected runtime", () => {
15 effectIt.effect("extracts the pinned runtime from playwright-core", () =>
16 Effect.gen(function* () {
17 const source = yield* playwrightInjectedRuntimeSource();
18 expect(source.length).toBeGreaterThan(100_000);
19 expect(source).toContain("InjectedScript");
20 }),
21 );
22
23 effectIt.effect("builds an idempotent install expression", () =>
24 Effect.gen(function* () {
25 const expression = yield* playwrightInjectedRuntimeInstallExpression();
26 expect(expression).toContain("__t3PlaywrightInjected");
27 expect(expression).toContain('testIdAttributeName":"data-testid');
28 }),
29 );
30
31 effectIt.effect("reports a missing source marker without an artificial cause", () =>
32 Effect.gen(function* () {
33 const error = yield* Effect.flip(
34 extractPlaywrightInjectedRuntimeSource("const source = 'missing';", "/tmp/coreBundle.js"),
35 );
36
37 expect(error).toMatchObject({
38 _tag: "PlaywrightSourceMarkerNotFoundError",
39 bundlePath: "/tmp/coreBundle.js",
40 marker: "source3 = ",
41 });
42 expect("cause" in error).toBe(false);
43 }),
44 );
45
46 effectIt.effect("keeps source validation metadata cause-free", () =>
47 Effect.gen(function* () {
48 const error = yield* Effect.flip(
49 extractPlaywrightInjectedRuntimeSource(
50 bundleWithSourceLiteral('"short"'),
51 "/tmp/coreBundle.js",
52 ),
53 );
54
55 expect(error).toMatchObject({
56 _tag: "PlaywrightSourceValidationError",
57 bundlePath: "/tmp/coreBundle.js",
58 actualType: "string",
59 actualLength: 5,
60 minimumLength: 100_000,
61 });
62 expect("cause" in error).toBe(false);
63 }),
64 );
65
66 effectIt.effect("preserves the source evaluation cause", () =>
67 Effect.gen(function* () {
68 const error = yield* Effect.flip(
69 extractPlaywrightInjectedRuntimeSource(bundleWithSourceLiteral("("), "/tmp/coreBundle.js"),
70 );
71
72 expect(error).toMatchObject({
73 _tag: "PlaywrightSourceEvaluationError",
74 bundlePath: "/tmp/coreBundle.js",
75 timeoutMs: 1_000,
76 cause: expect.objectContaining({ name: "SyntaxError" }),
77 });
78 }),
79 );
80});
apps/desktop/src/preview/PlaywrightInjectedRuntime.ts deleted-214
...@@ -1,214 +0,0 @@
1// @effect-diagnostics nodeBuiltinImport:off - Extracts Playwright's installed Node bundle for browser injection.
2import * as NodeFSP from "node:fs/promises";
3import * as NodeModule from "node:module";
4import * as NodePath from "node:path";
5import * as NodeVM from "node:vm";
6
7import * as Effect from "effect/Effect";
8import * as Schema from "effect/Schema";
9
10const require = NodeModule.createRequire(import.meta.url);
11const encodeUnknownJson = Schema.encodeUnknownEffect(Schema.fromJsonString(Schema.Unknown));
12const PLAYWRIGHT_PACKAGE_SPECIFIER = "playwright-core/package.json";
13const PLAYWRIGHT_SOURCE_MARKER = "source3 = ";
14const PLAYWRIGHT_SOURCE_TERMINATOR = ";\n }\n});";
15const PLAYWRIGHT_SOURCE_MINIMUM_LENGTH = 100_000;
16const PLAYWRIGHT_SOURCE_EVALUATION_TIMEOUT_MS = 1_000;
17const PLAYWRIGHT_SDK_LANGUAGE = "javascript";
18const PLAYWRIGHT_BROWSER_NAME = "chromium";
19
20export class PlaywrightPackageResolveError extends Schema.TaggedError<PlaywrightPackageResolveError>()(
21 "PlaywrightPackageResolveError",
22 {
23 specifier: Schema.String,
24 cause: Schema.Defect(),
25 },
26) {
27 override get message(): string {
28 return `Failed to resolve Playwright package: ${this.specifier}`;
29 }
30}
31
32export class PlaywrightCoreBundleReadError extends Schema.TaggedError<PlaywrightCoreBundleReadError>()(
33 "PlaywrightCoreBundleReadError",
34 {
35 bundlePath: Schema.String,
36 cause: Schema.Defect(),
37 },
38) {
39 override get message(): string {
40 return `Failed to read Playwright core bundle: ${this.bundlePath}`;
41 }
42}
43
44export class PlaywrightSourceMarkerNotFoundError extends Schema.TaggedError<PlaywrightSourceMarkerNotFoundError>()(
45 "PlaywrightSourceMarkerNotFoundError",
46 {
47 bundlePath: Schema.String,
48 marker: Schema.String,
49 },
50) {
51 override get message(): string {
52 return `Playwright injected runtime marker ${JSON.stringify(this.marker)} was not found in ${this.bundlePath}`;
53 }
54}
55
56export class PlaywrightSourceTerminatorNotFoundError extends Schema.TaggedError<PlaywrightSourceTerminatorNotFoundError>()(
57 "PlaywrightSourceTerminatorNotFoundError",
58 {
59 bundlePath: Schema.String,
60 terminator: Schema.String,
61 },
62) {
63 override get message(): string {
64 return `Playwright injected runtime terminator ${JSON.stringify(this.terminator)} was not found in ${this.bundlePath}`;
65 }
66}
67
68export class PlaywrightSourceEvaluationError extends Schema.TaggedError<PlaywrightSourceEvaluationError>()(
69 "PlaywrightSourceEvaluationError",
70 {
71 bundlePath: Schema.String,
72 timeoutMs: Schema.Number,
73 cause: Schema.Defect(),
74 },
75) {
76 override get message(): string {
77 return `Failed to evaluate the Playwright injected runtime literal from ${this.bundlePath} within ${this.timeoutMs}ms`;
78 }
79}
80
81export class PlaywrightSourceValidationError extends Schema.TaggedError<PlaywrightSourceValidationError>()(
82 "PlaywrightSourceValidationError",
83 {
84 bundlePath: Schema.String,
85 actualType: Schema.String,
86 actualLength: Schema.NullOr(Schema.Number),
87 minimumLength: Schema.Number,
88 },
89) {
90 override get message(): string {
91 const actual =
92 this.actualLength === null
93 ? this.actualType
94 : `${this.actualType} with ${this.actualLength} characters`;
95 return `Playwright injected runtime from ${this.bundlePath} was ${actual}; expected a string with at least ${this.minimumLength} characters`;
96 }
97}
98
99export class PlaywrightOptionsEncodeError extends Schema.TaggedError<PlaywrightOptionsEncodeError>()(
100 "PlaywrightOptionsEncodeError",
101 {
102 sdkLanguage: Schema.String,
103 browserName: Schema.String,
104 cause: Schema.Defect(),
105 },
106) {
107 override get message(): string {
108 return `Failed to encode ${this.browserName} Playwright injected runtime options for ${this.sdkLanguage}`;
109 }
110}
111
112export const PlaywrightInjectedRuntimeError = Schema.Union([
113 PlaywrightPackageResolveError,
114 PlaywrightCoreBundleReadError,
115 PlaywrightSourceMarkerNotFoundError,
116 PlaywrightSourceTerminatorNotFoundError,
117 PlaywrightSourceEvaluationError,
118 PlaywrightSourceValidationError,
119 PlaywrightOptionsEncodeError,
120]);
121export type PlaywrightInjectedRuntimeError = typeof PlaywrightInjectedRuntimeError.Type;
122
123export const extractPlaywrightInjectedRuntimeSource = Effect.fn(
124 "PlaywrightInjectedRuntime.extractSource",
125)(function* (coreBundle: string, bundlePath: string) {
126 const start = coreBundle.indexOf(PLAYWRIGHT_SOURCE_MARKER);
127 if (start < 0) {
128 return yield* new PlaywrightSourceMarkerNotFoundError({
129 bundlePath,
130 marker: PLAYWRIGHT_SOURCE_MARKER,
131 });
132 }
133 const literalStart = start + PLAYWRIGHT_SOURCE_MARKER.length;
134 const literalEnd = coreBundle.indexOf(PLAYWRIGHT_SOURCE_TERMINATOR, literalStart);
135 if (literalEnd < 0) {
136 return yield* new PlaywrightSourceTerminatorNotFoundError({
137 bundlePath,
138 terminator: PLAYWRIGHT_SOURCE_TERMINATOR,
139 });
140 }
141 const literal = coreBundle.slice(literalStart, literalEnd);
142 const source = yield* Effect.try({
143 try: () =>
144 NodeVM.runInNewContext(literal, Object.create(null), {
145 timeout: PLAYWRIGHT_SOURCE_EVALUATION_TIMEOUT_MS,
146 }),
147 catch: (cause) =>
148 new PlaywrightSourceEvaluationError({
149 bundlePath,
150 timeoutMs: PLAYWRIGHT_SOURCE_EVALUATION_TIMEOUT_MS,
151 cause,
152 }),
153 });
154 if (typeof source !== "string" || source.length < PLAYWRIGHT_SOURCE_MINIMUM_LENGTH) {
155 return yield* new PlaywrightSourceValidationError({
156 bundlePath,
157 actualType: typeof source,
158 actualLength: typeof source === "string" ? source.length : null,
159 minimumLength: PLAYWRIGHT_SOURCE_MINIMUM_LENGTH,
160 });
161 }
162 return source;
163});
164
165export const playwrightInjectedRuntimeSource = Effect.fn("PlaywrightInjectedRuntime.source")(
166 function* () {
167 const packageJsonPath = yield* Effect.try({
168 try: () => require.resolve(PLAYWRIGHT_PACKAGE_SPECIFIER),
169 catch: (cause) =>
170 new PlaywrightPackageResolveError({
171 specifier: PLAYWRIGHT_PACKAGE_SPECIFIER,
172 cause,
173 }),
174 });
175 const bundlePath = NodePath.join(NodePath.dirname(packageJsonPath), "lib/coreBundle.js");
176 const coreBundle = yield* Effect.tryPromise({
177 try: () => NodeFSP.readFile(bundlePath, "utf8"),
178 catch: (cause) => new PlaywrightCoreBundleReadError({ bundlePath, cause }),
179 });
180 return yield* extractPlaywrightInjectedRuntimeSource(coreBundle, bundlePath);
181 },
182);
183
184export const playwrightInjectedRuntimeInstallExpression = Effect.fn(
185 "PlaywrightInjectedRuntime.installExpression",
186)(function* () {
187 const source = yield* playwrightInjectedRuntimeSource();
188 const options = yield* encodeUnknownJson({
189 isUnderTest: false,
190 sdkLanguage: PLAYWRIGHT_SDK_LANGUAGE,
191 testIdAttributeName: "data-testid",
192 stableRafCount: 1,
193 browserName: PLAYWRIGHT_BROWSER_NAME,
194 shouldPrependErrorPrefix: false,
195 isUtilityWorld: false,
196 customEngines: [],
197 }).pipe(
198 Effect.mapError(
199 (cause) =>
200 new PlaywrightOptionsEncodeError({
201 sdkLanguage: PLAYWRIGHT_SDK_LANGUAGE,
202 browserName: PLAYWRIGHT_BROWSER_NAME,
203 cause,
204 }),
205 ),
206 );
207 return `(() => {
208 if (globalThis.__t3PlaywrightInjected) return true;
209 const module = { exports: {} };
210 ${source}
211 globalThis.__t3PlaywrightInjected = new (module.exports.InjectedScript())(globalThis, ${options});
212 return true;
213 })()`;
214});
apps/desktop/src/preview/PreviewKeyboard.test.ts deleted-184
...@@ -1,184 +0,0 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 makePreviewAutomationKeySequence,
5 makePreviewAutomationNativeKeySequence,
6} from "./PreviewKeyboard.ts";
7
8describe("preview keyboard packets", () => {
9 it("includes the Chromium virtual key code and Enter text", () => {
10 expect(makePreviewAutomationKeySequence({ key: "Enter" })).toEqual({
11 keyDown: {
12 type: "keyDown",
13 key: "Enter",
14 code: "Enter",
15 modifiers: 0,
16 windowsVirtualKeyCode: 13,
17 location: 0,
18 isKeypad: false,
19 text: "\r",
20 unmodifiedText: "\r",
21 },
22 keyUp: {
23 type: "keyUp",
24 key: "Enter",
25 code: "Enter",
26 modifiers: 0,
27 windowsVirtualKeyCode: 13,
28 location: 0,
29 isKeypad: false,
30 },
31 signal: { kind: "key", key: "Enter", code: "Enter" },
32 });
33 });
34
35 it("dispatches printable keys as text key-down events", () => {
36 const sequence = makePreviewAutomationKeySequence({ key: "z" });
37 expect(sequence.keyDown).toMatchObject({
38 type: "keyDown",
39 key: "z",
40 code: "KeyZ",
41 windowsVirtualKeyCode: 90,
42 text: "z",
43 });
44 expect(sequence.keyUp).not.toHaveProperty("text");
45 });
46
47 it("suppresses text and uses raw key-down for shortcuts", () => {
48 expect(
49 makePreviewAutomationKeySequence({ key: "a", modifiers: ["Meta"] }, { isMac: true }).keyDown,
50 ).toEqual({
51 type: "rawKeyDown",
52 key: "a",
53 code: "KeyA",
54 modifiers: 4,
55 windowsVirtualKeyCode: 65,
56 location: 0,
57 isKeypad: false,
58 commands: ["selectAll"],
59 });
60 });
61
62 it("maps common macOS editing shortcuts without changing other platforms", () => {
63 expect(
64 makePreviewAutomationKeySequence({ key: "z", modifiers: ["Shift", "Meta"] }, { isMac: true })
65 .keyDown.commands,
66 ).toEqual(["redo"]);
67 expect(
68 makePreviewAutomationKeySequence({ key: "a", modifiers: ["Meta"] }).keyDown,
69 ).not.toHaveProperty("commands");
70 });
71
72 it("resolves shifted printable keys to their browser values", () => {
73 const sequence = makePreviewAutomationKeySequence({ key: "1", modifiers: ["Shift"] });
74 expect(sequence.keyDown).toMatchObject({
75 key: "!",
76 code: "Digit1",
77 modifiers: 8,
78 windowsVirtualKeyCode: 49,
79 text: "!",
80 });
81 expect(sequence.signal).toEqual({ kind: "key", key: "!", code: "Digit1" });
82 });
83
84 it("keeps shifted key values while suppressing text for modified chords", () => {
85 const sequence = makePreviewAutomationKeySequence({
86 key: "1",
87 modifiers: ["Control", "Shift"],
88 });
89 expect(sequence.keyDown).toEqual({
90 type: "rawKeyDown",
91 key: "!",
92 code: "Digit1",
93 modifiers: 10,
94 windowsVirtualKeyCode: 49,
95 location: 0,
96 isKeypad: false,
97 });
98 expect(sequence.signal).toEqual({ kind: "key", key: "!", code: "Digit1" });
99 });
100
101 it.each([
102 ["Enter", "\r"],
103 ["z", "z"],
104 ])("converts %s into native down, char, and up packets", (key, text) => {
105 const sequence = makePreviewAutomationNativeKeySequence({ key });
106 const shared = { keyCode: key, modifiers: [], skipIfUnhandled: true };
107 expect(sequence.keyDown).toEqual({ type: "keyDown", ...shared });
108 expect(sequence.char).toEqual({ type: "char", ...shared, keyCode: text });
109 expect(sequence.keyUp).toEqual({ type: "keyUp", ...shared });
110 });
111
112 it("suppresses text for shortcuts and retains macOS editing commands", () => {
113 const sequence = makePreviewAutomationNativeKeySequence(
114 { key: "a", modifiers: ["Meta"] },
115 { isMac: true },
116 );
117 expect(sequence.keyDown).toEqual({
118 type: "keyDown",
119 keyCode: "a",
120 modifiers: ["meta"],
121 skipIfUnhandled: true,
122 });
123 expect(sequence.char).toBeUndefined();
124 expect(sequence.commands).toEqual(["selectAll"]);
125 });
126
127 it.each([
128 ["ArrowLeft", "Left"],
129 ["ArrowRight", "Right"],
130 ["ArrowUp", "Up"],
131 ["ArrowDown", "Down"],
132 ])("maps %s to Electron's %s accelerator", (key, keyCode) => {
133 const sequence = makePreviewAutomationNativeKeySequence({ key });
134 expect(sequence.keyDown.keyCode).toBe(keyCode);
135 expect(sequence.keyUp.keyCode).toBe(keyCode);
136 expect(sequence.signal.key).toBe(key);
137 expect(sequence.char).toBeUndefined();
138 });
139
140 it("matches native uppercase key signals without inventing shortcut modifiers", () => {
141 const plain = makePreviewAutomationNativeKeySequence({ key: "X" });
142 expect(plain.signal).toEqual({ kind: "key", key: "x", code: "KeyX" });
143 expect(plain.char?.keyCode).toBe("X");
144 const shortcut = makePreviewAutomationNativeKeySequence({ key: "A", modifiers: ["Control"] });
145 expect(shortcut.signal).toEqual({ kind: "key", key: "a", code: "KeyA" });
146 expect(shortcut.keyDown.modifiers).toEqual(["control"]);
147 expect(shortcut.char).toBeUndefined();
148 expect(
149 makePreviewAutomationNativeKeySequence({ key: "X", modifiers: ["Shift"] }).signal,
150 ).toEqual({
151 kind: "key",
152 key: "X",
153 code: "KeyX",
154 });
155 });
156
157 it("matches native signals for Unicode text and literal spaces", () => {
158 const unicode = makePreviewAutomationNativeKeySequence({ key: "é" });
159 expect(unicode.signal).toEqual({ kind: "key", key: "", code: "" });
160 expect(unicode.char?.keyCode).toBe("é");
161 expect(makePreviewAutomationNativeKeySequence({ key: " " }).signal).toEqual({
162 kind: "key",
163 key: " ",
164 code: "Space",
165 });
166 });
167
168 it("preserves text and editing commands for isolated child renderer targets", () => {
169 const text = makePreviewAutomationKeySequence({ key: "é" });
170 expect(text.keyDown).toMatchObject({ type: "keyDown", text: "é", key: "é" });
171 expect(text.keyUp).toMatchObject({ type: "keyUp", key: "é" });
172 const shortcut = makePreviewAutomationKeySequence(
173 { key: "a", modifiers: ["Meta"] },
174 { isMac: true },
175 );
176 expect(shortcut.keyDown).toMatchObject({
177 type: "rawKeyDown",
178 modifiers: 4,
179 commands: ["selectAll"],
180 });
181 expect(shortcut.keyDown).not.toHaveProperty("text");
182 expect(shortcut.keyDown).not.toHaveProperty("nativeVirtualKeyCode");
183 });
184});
apps/desktop/src/preview/PreviewKeyboard.ts deleted-363
...@@ -1,363 +0,0 @@
1import type { PreviewAutomationPressInput } from "@t3tools/contracts";
2
3interface KeyDefinition {
4 readonly code: string;
5 readonly key: string;
6 readonly keyCode: number;
7 readonly text?: string;
8 readonly location?: number;
9 readonly shiftedKey?: string;
10}
11
12export interface PreviewAutomationKeyEvent {
13 readonly [key: string]: unknown;
14 readonly type: "keyDown" | "rawKeyDown" | "keyUp";
15 readonly key: string;
16 readonly code: string;
17 readonly modifiers: number;
18 readonly windowsVirtualKeyCode: number;
19 readonly location: number;
20 readonly isKeypad: boolean;
21 readonly text?: string;
22 readonly unmodifiedText?: string;
23 readonly commands?: ReadonlyArray<string>;
24}
25
26export interface PreviewAutomationKeySequence {
27 readonly keyDown: PreviewAutomationKeyEvent;
28 readonly keyUp: PreviewAutomationKeyEvent;
29 readonly signal: {
30 readonly kind: "key";
31 readonly key: string;
32 readonly code: string;
33 };
34}
35
36const NAMED_KEYS: Readonly<Record<string, KeyDefinition>> = {
37 Escape: { code: "Escape", key: "Escape", keyCode: 27 },
38 Backspace: { code: "Backspace", key: "Backspace", keyCode: 8 },
39 Tab: { code: "Tab", key: "Tab", keyCode: 9 },
40 Enter: { code: "Enter", key: "Enter", keyCode: 13, text: "\r" },
41 Shift: { code: "ShiftLeft", key: "Shift", keyCode: 16, location: 1 },
42 Control: { code: "ControlLeft", key: "Control", keyCode: 17, location: 1 },
43 Alt: { code: "AltLeft", key: "Alt", keyCode: 18, location: 1 },
44 Meta: { code: "MetaLeft", key: "Meta", keyCode: 91, location: 1 },
45 CapsLock: { code: "CapsLock", key: "CapsLock", keyCode: 20 },
46 Space: { code: "Space", key: " ", keyCode: 32, text: " " },
47 PageUp: { code: "PageUp", key: "PageUp", keyCode: 33 },
48 PageDown: { code: "PageDown", key: "PageDown", keyCode: 34 },
49 End: { code: "End", key: "End", keyCode: 35 },
50 Home: { code: "Home", key: "Home", keyCode: 36 },
51 ArrowLeft: { code: "ArrowLeft", key: "ArrowLeft", keyCode: 37 },
52 ArrowUp: { code: "ArrowUp", key: "ArrowUp", keyCode: 38 },
53 ArrowRight: { code: "ArrowRight", key: "ArrowRight", keyCode: 39 },
54 ArrowDown: { code: "ArrowDown", key: "ArrowDown", keyCode: 40 },
55 Insert: { code: "Insert", key: "Insert", keyCode: 45 },
56 Delete: { code: "Delete", key: "Delete", keyCode: 46 },
57};
58
59const PRINTABLE_KEYS: ReadonlyArray<KeyDefinition> = [
60 { code: "Backquote", key: "`", shiftedKey: "~", keyCode: 192 },
61 { code: "Digit1", key: "1", shiftedKey: "!", keyCode: 49 },
62 { code: "Digit2", key: "2", shiftedKey: "@", keyCode: 50 },
63 { code: "Digit3", key: "3", shiftedKey: "#", keyCode: 51 },
64 { code: "Digit4", key: "4", shiftedKey: "$", keyCode: 52 },
65 { code: "Digit5", key: "5", shiftedKey: "%", keyCode: 53 },
66 { code: "Digit6", key: "6", shiftedKey: "^", keyCode: 54 },
67 { code: "Digit7", key: "7", shiftedKey: "&", keyCode: 55 },
68 { code: "Digit8", key: "8", shiftedKey: "*", keyCode: 56 },
69 { code: "Digit9", key: "9", shiftedKey: "(", keyCode: 57 },
70 { code: "Digit0", key: "0", shiftedKey: ")", keyCode: 48 },
71 { code: "Minus", key: "-", shiftedKey: "_", keyCode: 189 },
72 { code: "Equal", key: "=", shiftedKey: "+", keyCode: 187 },
73 { code: "Backslash", key: "\\", shiftedKey: "|", keyCode: 220 },
74 { code: "BracketLeft", key: "[", shiftedKey: "{", keyCode: 219 },
75 { code: "BracketRight", key: "]", shiftedKey: "}", keyCode: 221 },
76 { code: "Semicolon", key: ";", shiftedKey: ":", keyCode: 186 },
77 { code: "Quote", key: "'", shiftedKey: '"', keyCode: 222 },
78 { code: "Comma", key: ",", shiftedKey: "<", keyCode: 188 },
79 { code: "Period", key: ".", shiftedKey: ">", keyCode: 190 },
80 { code: "Slash", key: "/", shiftedKey: "?", keyCode: 191 },
81];
82
83/**
84 * Chromium does not infer macOS editing commands from synthetic Meta chords.
85 * Keep the common browser editing/navigation shortcuts explicit so dispatched
86 * key events behave like their physical-key equivalents.
87 */
88const MAC_EDITING_COMMANDS: Readonly<Record<string, string>> = {
89 "Meta+Backspace": "deleteToBeginningOfLine",
90 "Meta+ArrowUp": "moveToBeginningOfDocument",
91 "Meta+ArrowDown": "moveToEndOfDocument",
92 "Meta+ArrowLeft": "moveToLeftEndOfLine",
93 "Meta+ArrowRight": "moveToRightEndOfLine",
94 "Shift+Meta+ArrowUp": "moveToBeginningOfDocumentAndModifySelection",
95 "Shift+Meta+ArrowDown": "moveToEndOfDocumentAndModifySelection",
96 "Shift+Meta+ArrowLeft": "moveToLeftEndOfLineAndModifySelection",
97 "Shift+Meta+ArrowRight": "moveToRightEndOfLineAndModifySelection",
98 "Meta+KeyA": "selectAll",
99 "Meta+KeyC": "copy",
100 "Meta+KeyX": "cut",
101 "Meta+KeyV": "paste",
102 "Meta+KeyZ": "undo",
103 "Shift+Meta+KeyZ": "redo",
104};
105const SHORTCUT_MODIFIER_ORDER = ["Shift", "Control", "Alt", "Meta"] as const;
106
107const macEditingCommands = (
108 code: string,
109 modifiers: PreviewAutomationPressInput["modifiers"],
110): ReadonlyArray<string> => {
111 const shortcut = [
112 ...SHORTCUT_MODIFIER_ORDER.filter((modifier) => modifiers?.includes(modifier)),
113 code,
114 ].join("+");
115 const command = MAC_EDITING_COMMANDS[shortcut];
116 return command ? [command] : [];
117};
118
119const modifierMask = (modifiers: PreviewAutomationPressInput["modifiers"]): number =>
120 (modifiers ?? []).reduce((value, modifier) => {
121 switch (modifier) {
122 case "Alt":
123 return value | 1;
124 case "Control":
125 return value | 2;
126 case "Meta":
127 return value | 4;
128 case "Shift":
129 return value | 8;
130 }
131 }, 0);
132
133function resolveKeyDefinition(input: PreviewAutomationPressInput): KeyDefinition {
134 const named = NAMED_KEYS[input.key === " " ? "Space" : input.key];
135 if (named) return named;
136
137 const functionKey = /^F([1-9]|1[0-2])$/.exec(input.key);
138 if (functionKey) {
139 const number = Number(functionKey[1]);
140 return { code: input.key, key: input.key, keyCode: 111 + number };
141 }
142
143 if (/^[a-z]$/i.test(input.key)) {
144 const upper = input.key.toUpperCase();
145 const shifted = input.modifiers?.includes("Shift") ?? false;
146 const key = shifted || input.key === upper ? upper : input.key;
147 return { code: `Key${upper}`, key, keyCode: upper.charCodeAt(0), text: key };
148 }
149
150 const printable = PRINTABLE_KEYS.find(
151 (definition) => definition.key === input.key || definition.shiftedKey === input.key,
152 );
153 if (printable) {
154 const shifted = input.modifiers?.includes("Shift") ?? false;
155 const key =
156 printable.shiftedKey && (shifted || input.key === printable.shiftedKey)
157 ? printable.shiftedKey
158 : printable.key;
159 return { ...printable, key, text: key };
160 }
161
162 return {
163 code: input.key.length > 1 ? input.key : "",
164 key: input.key,
165 keyCode: 0,
166 ...(input.key.length === 1 ? { text: input.key } : {}),
167 };
168}
169
170/**
171 * Build Chromium CDP key packets using the same required fields and down-event
172 * choice as Playwright's pinned Chromium keyboard implementation.
173 */
174export function makePreviewAutomationKeySequence(
175 input: PreviewAutomationPressInput,
176 options?: { readonly isMac?: boolean },
177): PreviewAutomationKeySequence {
178 const definition = resolveKeyDefinition(input);
179 const modifiers = modifierMask(input.modifiers);
180 const suppressText = input.modifiers?.some((modifier) => modifier !== "Shift") ?? false;
181 const text = suppressText ? "" : (definition.text ?? "");
182 const location = definition.location ?? 0;
183 const commands = options?.isMac ? macEditingCommands(definition.code, input.modifiers) : [];
184 const shared = {
185 key: definition.key,
186 code: definition.code,
187 modifiers,
188 windowsVirtualKeyCode: definition.keyCode,
189 location,
190 isKeypad: location === 3,
191 };
192
193 return {
194 keyDown: {
195 type: text ? "keyDown" : "rawKeyDown",
196 ...shared,
197 ...(text ? { text, unmodifiedText: text } : {}),
198 ...(commands.length > 0 ? { commands } : {}),
199 },
200 keyUp: { type: "keyUp", ...shared },
201 signal: { kind: "key", key: definition.key, code: definition.code },
202 };
203}
204
205/** Root CDP input can retarget the embedder; native packets address the guest widget. */
206export function makePreviewAutomationNativeKeySequence(
207 input: PreviewAutomationPressInput,
208 options?: { readonly isMac?: boolean },
209) {
210 const { keyDown, signal } = makePreviewAutomationKeySequence(input, options);
211 const modifiers = (
212 [
213 [1, "alt"],
214 [2, "control"],
215 [4, "meta"],
216 [8, "shift"],
217 ] as const
218 )
219 .filter(([mask]) => keyDown.modifiers & mask)
220 .map(([, modifier]) => modifier);
221 const shared = {
222 keyCode: keyDown.key.startsWith("Arrow") ? keyDown.key.slice(5) : keyDown.key,
223 modifiers,
224 skipIfUnhandled: true as const,
225 };
226 // Electron lowercases unshifted letters and reports no key for Unicode accelerators.
227 const key =
228 keyDown.windowsVirtualKeyCode === 0 && keyDown.key.length === 1
229 ? ""
230 : /^[A-Z]$/.test(keyDown.key) && !modifiers.includes("shift")
231 ? keyDown.key.toLowerCase()
232 : keyDown.key;
233 return {
234 keyDown: { type: "keyDown" as const, ...shared },
235 ...(keyDown.text ? { char: { type: "char" as const, ...shared, keyCode: keyDown.text } } : {}),
236 keyUp: { type: "keyUp" as const, ...shared },
237 ...(keyDown.commands ? { commands: keyDown.commands } : {}),
238 signal: { ...signal, key },
239 };
240}
241
242/** Keep macOS editing shortcuts inside the target page without native focus. */
243export function previewAutomationEditingCommandExpression(
244 input: PreviewAutomationPressInput,
245 sequence: ReturnType<typeof makePreviewAutomationNativeKeySequence>,
246 clipboardData: ReadonlyArray<{ readonly type: string; readonly data: string }> = [],
247): string {
248 const definition = resolveKeyDefinition(input);
249 const event = {
250 key: definition.key,
251 code: definition.code,
252 keyCode: definition.keyCode,
253 which: definition.keyCode,
254 location: definition.location ?? 0,
255 altKey: input.modifiers?.includes("Alt") ?? false,
256 ctrlKey: input.modifiers?.includes("Control") ?? false,
257 metaKey: input.modifiers?.includes("Meta") ?? false,
258 shiftKey: input.modifiers?.includes("Shift") ?? false,
259 bubbles: true,
260 cancelable: true,
261 composed: true,
262 };
263 return `(() => {
264 let element = document.activeElement;
265 while (element?.shadowRoot?.activeElement) element = element.shadowRoot.activeElement;
266 if (!element) return;
267 const event = ${JSON.stringify(event)};
268 try {
269 if (!element.dispatchEvent(new KeyboardEvent("keydown", event))) return;
270 for (const command of ${JSON.stringify(sequence.commands ?? [])}) {
271 // Main-process clipboard reads also work on insecure HTTP previews.
272 // Let the page's paste handler consume the clipboard MIME formats.
273 if (command === "paste") {
274 const transfer = new DataTransfer();
275 for (const { type, data } of ${JSON.stringify(clipboardData)}) {
276 if (type === "text/html") {
277 // Match native paste sanitization before page handlers or insertion.
278 const container = document.createElement("div");
279 container.setHTML(data);
280 transfer.setData(type, container.innerHTML);
281 } else if (type.startsWith("text/")) transfer.setData(type, data);
282 else {
283 const bytes = Uint8Array.from(atob(data), character => character.charCodeAt(0));
284 transfer.items.add(new File([bytes], "clipboard", { type }));
285 }
286 }
287 if (!element.dispatchEvent(new ClipboardEvent("paste", {
288 clipboardData: transfer, bubbles: true, cancelable: true, composed: true,
289 }))) continue;
290 const text = transfer.getData("text/plain");
291 if (!element.dispatchEvent(new InputEvent("beforeinput", {
292 inputType: "insertFromPaste", data: text, dataTransfer: transfer,
293 bubbles: true, cancelable: true, composed: true,
294 }))) continue;
295 const html = element.isContentEditable ? transfer.getData("text/html") : "";
296 document.execCommand(html ? "insertHTML" : "insertText", false, html || text);
297 continue;
298 }
299 const inputType = command === "deleteToBeginningOfLine" ? "deleteSoftLineBackward"
300 : command === "undo" ? "historyUndo"
301 : command === "redo" ? "historyRedo" : null;
302 // execCommand emits input without beforeinput. Let controlled editors
303 // perform the edit before applying the browser's default operation.
304 if (inputType && !element.dispatchEvent(new InputEvent("beforeinput", {
305 inputType, bubbles: true, cancelable: true, composed: true,
306 }))) continue;
307 const selection = document.getSelection();
308 if (command === "deleteToBeginningOfLine") {
309 const collapsed = typeof element.selectionStart === "number"
310 ? element.selectionStart === element.selectionEnd
311 : selection?.isCollapsed;
312 if (collapsed) selection?.modify("extend", "backward", "lineboundary");
313 document.execCommand("delete");
314 } else if (command.startsWith("moveTo")) {
315 const selectionElement = selection?.anchorNode?.nodeType === Node.ELEMENT_NODE
316 ? selection.anchorNode : selection?.anchorNode?.parentElement;
317 const editable = element.isContentEditable || selectionElement?.isContentEditable ||
318 (((element instanceof HTMLInputElement && element.selectionStart !== null) ||
319 element instanceof HTMLTextAreaElement) &&
320 !element.readOnly && !element.disabled);
321 if (!editable && (command === "moveToBeginningOfDocument" || command === "moveToEndOfDocument")) {
322 let scrollable = element === document.body ? selectionElement ?? element : element;
323 while (scrollable && !(scrollable.scrollHeight > scrollable.clientHeight &&
324 /^(auto|scroll|overlay)$/.test(getComputedStyle(scrollable).overflowY))) {
325 scrollable = scrollable.parentElement ?? scrollable.getRootNode().host;
326 }
327 scrollable ??= document.scrollingElement;
328 if (scrollable) scrollable.scrollTop = command === "moveToBeginningOfDocument"
329 ? 0 : scrollable.scrollHeight;
330 continue;
331 }
332 const direction = command.includes("Beginning") ? "backward"
333 : command.includes("Left") ? "left"
334 : command.includes("Right") ? "right" : "forward";
335 selection?.modify(
336 command.endsWith("AndModifySelection") ? "extend" : "move",
337 direction,
338 command.includes("Document") ? "documentboundary" : "lineboundary",
339 );
340 if (element instanceof HTMLInputElement && element.selectionStart !== null) {
341 if (command.includes("Left") || command.includes("Beginning")) element.scrollLeft = 0;
342 else element.scrollLeft = element.scrollWidth;
343 }
344 // Programmatic selection changes do not reveal the caret like native editing commands.
345 if (editable && command.includes("Document")) {
346 const beginning = command.includes("Beginning");
347 if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
348 element.scrollTop = beginning ? 0 : element.scrollHeight;
349 } else {
350 const caretElement = selection?.focusNode?.nodeType === Node.ELEMENT_NODE
351 ? selection.focusNode : selection?.focusNode?.parentElement;
352 caretElement?.scrollIntoView({ block: beginning ? "start" : "end", inline: "nearest" });
353 }
354 }
355 } else {
356 document.execCommand(command);
357 }
358 }
359 } finally {
360 element.dispatchEvent(new KeyboardEvent("keyup", event));
361 }
362 })()`;
363}
apps/mobile/app.config.ts+3
...@@ -270,6 +270,9 @@ const config: ExpoConfig = {...@@ -270,6 +270,9 @@ const config: ExpoConfig = {
270 NSLocalNetworkUsageDescription:270 NSLocalNetworkUsageDescription:
271 "Allow T3 Code to connect to T3 Code servers on your local network or tailnet.",271 "Allow T3 Code to connect to T3 Code servers on your local network or tailnet.",
272 NSPhotoLibraryAddUsageDescription: "Allow T3 Code to save images to your photo library.",272 NSPhotoLibraryAddUsageDescription: "Allow T3 Code to save images to your photo library.",
273 // "Audio, AirPlay, and Picture in Picture": the browser screen's system
274 // picture in picture needs it to start and to stay up outside the app.
275 UIBackgroundModes: ["audio"],
273 ITSAppUsesNonExemptEncryption: false,276 ITSAppUsesNonExemptEncryption: false,
274 // The App Store screenshot harness rotates the iPad interface from277 // The App Store screenshot harness rotates the iPad interface from
275 // inside the app (CI denies osascript the Accessibility access that278 // inside the app (CI denies osascript the Accessibility access that
apps/mobile/metro.config.js+38-18
...@@ -18,6 +18,7 @@ const licenseGeneratorSource = path.join(...@@ -18,6 +18,7 @@ const licenseGeneratorSource = path.join(
18const escapedWorkspaceRoot = workspaceRoot.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");18const escapedWorkspaceRoot = workspaceRoot.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
19const mobileShikiRoot = path.dirname(require.resolve("shiki/package.json", { paths: [__dirname] }));19const mobileShikiRoot = path.dirname(require.resolve("shiki/package.json", { paths: [__dirname] }));
20const generatedDeviceStreamRoot = path.join(__dirname, ".generated", "device-stream");20const generatedDeviceStreamRoot = path.join(__dirname, ".generated", "device-stream");
21const generatedPreviewStreamRoot = path.join(__dirname, ".generated", "preview-stream");
21const resolveShikiDependencyRoot = (packageName) => {22const resolveShikiDependencyRoot = (packageName) => {
22 const entryPath = require.resolve(packageName, { paths: [mobileShikiRoot] });23 const entryPath = require.resolve(packageName, { paths: [mobileShikiRoot] });
23 let currentDir = path.dirname(entryPath);24 let currentDir = path.dirname(entryPath);
...@@ -48,6 +49,7 @@ config.resolver = {...@@ -48,6 +49,7 @@ config.resolver = {
48 ...config.resolver?.extraNodeModules,49 ...config.resolver?.extraNodeModules,
49 "@t3tools/mobile-third-party-licenses": generatedLicenseModuleRoot,50 "@t3tools/mobile-third-party-licenses": generatedLicenseModuleRoot,
50 "@t3tools/mobile-device-stream": generatedDeviceStreamRoot,51 "@t3tools/mobile-device-stream": generatedDeviceStreamRoot,
52 "@t3tools/mobile-preview-stream": generatedPreviewStreamRoot,
51 shiki: mobileShikiRoot,53 shiki: mobileShikiRoot,
52 "@shikijs/core": resolveShikiDependencyRoot("@shikijs/core"),54 "@shikijs/core": resolveShikiDependencyRoot("@shikijs/core"),
53 "@shikijs/engine-javascript": resolveShikiDependencyRoot("@shikijs/engine-javascript"),55 "@shikijs/engine-javascript": resolveShikiDependencyRoot("@shikijs/engine-javascript"),
...@@ -95,35 +97,53 @@ async function generateMobileThirdPartyLicenses() {...@@ -95,35 +97,53 @@ async function generateMobileThirdPartyLicenses() {
95 ]);97 ]);
96}98}
9799
98async function prepareDeviceStream() {100async function prepareStreamScripts() {
99 const { generateDeviceStreamScript } = await import(101 const { generateDeviceStreamScript, generatePreviewStreamScript } = await import(
100 pathToFileURL(path.join(__dirname, "scripts", "generate-device-stream.mts")).href102 pathToFileURL(path.join(__dirname, "scripts", "generate-device-stream.mts")).href
101 );103 );
102 await generateDeviceStreamScript();104 const generateAll = () =>
105 Promise.all([generateDeviceStreamScript(), generatePreviewStreamScript()]);
106 await generateAll();
103 if (process.env.NODE_ENV !== "production") {107 if (process.env.NODE_ENV !== "production") {
104 let rebuild = Promise.resolve();108 let rebuild = Promise.resolve();
105 for (const [directory, files] of [109 for (const [directory, files, generate] of [
106 [path.join(__dirname, "src/features/devices"), ["device-stream.browser.ts"]],
107 [110 [
108 path.join(workspaceRoot, "packages/client-runtime/src/device"),111 "apps/mobile/src/features/devices",
109 ["stream.ts", "hubAccess.ts"],112 ["device-stream.browser.ts"],
113 generateDeviceStreamScript,
114 ],
115 [
116 "apps/mobile/src/features/browser",
117 ["preview-stream.browser.ts"],
118 generatePreviewStreamScript,
119 ],
120 // The preview transport also imports `hubAccess.ts`.
121 ["packages/client-runtime/src/device", ["stream.ts", "hubAccess.ts"], generateAll],
122 [
123 "packages/client-runtime/src/preview",
124 ["serverBrowserStream.ts"],
125 generatePreviewStreamScript,
110 ],126 ],
111 ]) {127 ]) {
112 // The generated module participates in Metro's normal Fast Refresh.128 // The generated modules participate in Metro's normal Fast Refresh.
113 fs.watch(directory, { persistent: false }, (_event, filename) => {129 fs.watch(path.join(workspaceRoot, directory), { persistent: false }, (_event, filename) => {
114 if (filename && !files.includes(String(filename))) return;130 if (filename && !files.includes(String(filename))) return;
115 rebuild = rebuild.then(generateDeviceStreamScript).catch((error) => {131 rebuild = rebuild
116 console.error("Could not rebuild the device stream:", error);132 .then(generate)
117 });133 .then(() => undefined)
134 .catch((error) => {
135 console.error("Could not rebuild a WebView stream script:", error);
136 });
118 });137 });
119 }138 }
120 }139 }
121}140}
122141
123module.exports = Promise.all([generateMobileThirdPartyLicenses(), prepareDeviceStream()]).then(() =>142module.exports = Promise.all([generateMobileThirdPartyLicenses(), prepareStreamScripts()]).then(
124 withUniwindConfig(config, {143 () =>
125 cssEntryFile: "./global.css",144 withUniwindConfig(config, {
126 extraThemes,145 cssEntryFile: "./global.css",
127 polyfills: { rem: 14 },146 extraThemes,
128 }),147 polyfills: { rem: 14 },
148 }),
129);149);
apps/mobile/scripts/generate-device-stream.mts+12-8
...@@ -5,8 +5,9 @@ import { build } from "vite-plus";...@@ -5,8 +5,9 @@ import { build } from "vite-plus";
55
6const mobileRoot = NodePath.resolve(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), "..");6const mobileRoot = NodePath.resolve(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), "..");
77
8/** Metro embeds the shared browser transport as a small script in the native WebView. */8/** Metro embeds each shared browser transport as a small script in a native WebView. */
9export async function generateDeviceStreamScript() {9async function generateWebViewScript(feature: string, name: string) {
10 const stem = `${name.toLowerCase()}-stream`;
10 const result = await build({11 const result = await build({
11 configFile: false,12 configFile: false,
12 logLevel: "silent",13 logLevel: "silent",
...@@ -15,8 +16,8 @@ export async function generateDeviceStreamScript() {...@@ -15,8 +16,8 @@ export async function generateDeviceStreamScript() {
15 target: "es2022",16 target: "es2022",
16 minify: true,17 minify: true,
17 lib: {18 lib: {
18 entry: NodePath.join(mobileRoot, "src/features/devices/device-stream.browser.ts"),19 entry: NodePath.join(mobileRoot, "src/features", feature, `${stem}.browser.ts`),
19 name: "T3DeviceStream",20 name: `T3${name}Stream`,
20 formats: ["iife"],21 formats: ["iife"],
21 },22 },
22 },23 },
...@@ -25,15 +26,18 @@ export async function generateDeviceStreamScript() {...@@ -25,15 +26,18 @@ export async function generateDeviceStreamScript() {
25 const chunk = bundles26 const chunk = bundles
26 .flatMap((bundle) => ("output" in bundle ? bundle.output : []))27 .flatMap((bundle) => ("output" in bundle ? bundle.output : []))
27 .find((output) => output.type === "chunk");28 .find((output) => output.type === "chunk");
28 if (!chunk) throw new Error("Device stream build did not emit a script.");29 if (!chunk) throw new Error(`${name} build did not emit a script.`);
29 const root = NodePath.join(mobileRoot, ".generated/device-stream");30 const root = NodePath.join(mobileRoot, ".generated", stem);
30 await NodeFSP.mkdir(root, { recursive: true });31 await NodeFSP.mkdir(root, { recursive: true });
31 for (const [name, contents] of [32 for (const [file, contents] of [
32 ["index.js", `module.exports = ${JSON.stringify(chunk.code)};\n`],33 ["index.js", `module.exports = ${JSON.stringify(chunk.code)};\n`],
33 ["package.json", '{"main":"index.js"}\n'],34 ["package.json", '{"main":"index.js"}\n'],
34 ] as const) {35 ] as const) {
35 const destination = NodePath.join(root, name);36 const destination = NodePath.join(root, file);
36 const previous = await NodeFSP.readFile(destination, "utf8").catch(() => null);37 const previous = await NodeFSP.readFile(destination, "utf8").catch(() => null);
37 if (previous !== contents) await NodeFSP.writeFile(destination, contents);38 if (previous !== contents) await NodeFSP.writeFile(destination, contents);
38 }39 }
39}40}
41
42export const generateDeviceStreamScript = () => generateWebViewScript("devices", "Device");
43export const generatePreviewStreamScript = () => generateWebViewScript("browser", "Preview");
apps/mobile/src/Stack.tsx+11
...@@ -43,6 +43,7 @@ import { ReviewCommentComposerSheet } from "./features/review/ReviewCommentCompo...@@ -43,6 +43,7 @@ import { ReviewCommentComposerSheet } from "./features/review/ReviewCommentCompo
43import { ReviewSheet } from "./features/review/ReviewSheet";43import { ReviewSheet } from "./features/review/ReviewSheet";
44import { ThreadTerminalRouteScreen } from "./features/terminal/ThreadTerminalRouteScreen";44import { ThreadTerminalRouteScreen } from "./features/terminal/ThreadTerminalRouteScreen";
45import { DevicePreviewRouteScreen } from "./features/devices/DevicePreviewRouteScreen";45import { DevicePreviewRouteScreen } from "./features/devices/DevicePreviewRouteScreen";
46import { BrowserPreviewRouteScreen } from "./features/browser/BrowserPreviewRouteScreen";
46import { GitBranchesSheet } from "./features/threads/git/GitBranchesSheet";47import { GitBranchesSheet } from "./features/threads/git/GitBranchesSheet";
47import { GitCommitSheet } from "./features/threads/git/GitCommitSheet";48import { GitCommitSheet } from "./features/threads/git/GitCommitSheet";
48import { GitConfirmSheet } from "./features/threads/git/GitConfirmSheet";49import { GitConfirmSheet } from "./features/threads/git/GitConfirmSheet";
...@@ -548,6 +549,7 @@ const WORKSPACE_OVERLAY_ROUTES = new Set([...@@ -548,6 +549,7 @@ const WORKSPACE_OVERLAY_ROUTES = new Set([
548 "ThreadQueue",549 "ThreadQueue",
549 "ThreadReviewComment",550 "ThreadReviewComment",
550 "ThreadDevicePreview",551 "ThreadDevicePreview",
552 "ThreadBrowserPreview",
551 "ThreadSettingsSheet",553 "ThreadSettingsSheet",
552]);554]);
553555
...@@ -705,6 +707,15 @@ const RootStackConfig = createNativeStackNavigator({...@@ -705,6 +707,15 @@ const RootStackConfig = createNativeStackNavigator({
705 navigationBarHidden: true,707 navigationBarHidden: true,
706 },708 },
707 }),709 }),
710 ThreadBrowserPreview: createNativeStackScreen({
711 screen: BrowserPreviewRouteScreen,
712 linking: `${THREAD_LINKING_PREFIX}/browser`,
713 options: {
714 presentation: "fullScreenModal",
715 headerShown: false,
716 gestureEnabled: false,
717 },
718 }),
708 ThreadReview: createNativeStackScreen({719 ThreadReview: createNativeStackScreen({
709 screen: ReviewSheet,720 screen: ReviewSheet,
710 linking: `${THREAD_LINKING_PREFIX}/review`,721 linking: `${THREAD_LINKING_PREFIX}/review`,
apps/mobile/src/features/browser/BrowserPreviewRouteScreen.tsx created+273
...@@ -0,0 +1,273 @@
1import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native";
2import { createNativeStackNavigator } from "@react-navigation/native-stack";
3import {
4 EnvironmentId,
5 FILL_PREVIEW_VIEWPORT,
6 ThreadId,
7 type PreviewSessionSnapshot,
8} from "@t3tools/contracts";
9import { normalizePreviewUrl } from "@t3tools/shared/preview";
10import { useCallback, useEffect, useRef, useState } from "react";
11import { ActivityIndicator, Alert, AppState, Platform, TextInput, View } from "react-native";
12import { useSafeAreaInsets } from "react-native-safe-area-context";
13
14import { ControlPill } from "../../components/ControlPill";
15import { ScreenHeader } from "../../components/ScreenHeader";
16import { NativeHeaderToolbar } from "../../native/StackHeader";
17import { useThreadServerBrowserTabs } from "../../state/preview";
18import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
19import { BrowserTabMenu } from "./BrowserTabMenu";
20import { browserTabTitle, browserTabUrl, latestBrowserTab } from "./browserTabs";
21import {
22 PreviewStreamWebView,
23 type PreviewPictureInPictureState,
24 type PreviewStreamRef,
25} from "./PreviewStreamWebView";
26
27const BrowserPreviewStack = createNativeStackNavigator<{ BrowserPreview: undefined }>();
28
29const NO_PICTURE_IN_PICTURE: PreviewPictureInPictureState = { supported: false, active: false };
30
31type BrowserPreviewRouteScreenProps = StaticScreenProps<{
32 readonly environmentId: string;
33 readonly threadId: string;
34 readonly tabId?: string;
35}>;
36
37/** The nested native stack supplies the navigation bar inside the modal. */
38export function BrowserPreviewRouteScreen({ route }: BrowserPreviewRouteScreenProps) {
39 const navigation = useNavigation();
40 const onClose = useCallback(() => navigation.goBack(), [navigation]);
41 return (
42 <View collapsable={false} className="flex-1 bg-sheet">
43 <BrowserPreviewStack.Navigator
44 screenOptions={{
45 headerShown: Platform.OS === "ios",
46 headerBackVisible: false,
47 headerShadowVisible: false,
48 headerTransparent: false,
49 headerTitleStyle: { fontSize: 17, fontWeight: "600" },
50 }}
51 >
52 <BrowserPreviewStack.Screen name="BrowserPreview">
53 {() => (
54 <BrowserPreviewScreen
55 environmentId={EnvironmentId.make(route.params.environmentId)}
56 threadId={ThreadId.make(route.params.threadId)}
57 initialTabId={route.params.tabId ?? null}
58 onClose={onClose}
59 />
60 )}
61 </BrowserPreviewStack.Screen>
62 </BrowserPreviewStack.Navigator>
63 </View>
64 );
65}
66
67function BrowserPreviewScreen({
68 environmentId,
69 threadId,
70 initialTabId,
71 onClose,
72}: {
73 readonly environmentId: EnvironmentId;
74 readonly threadId: ThreadId;
75 readonly initialTabId: string | null;
76 readonly onClose: () => void;
77}) {
78 const insets = useSafeAreaInsets();
79 const { themeVariables } = useAppearancePreferences();
80 const focused = useIsFocused();
81 const [foreground, setForeground] = useState(AppState.currentState !== "background");
82 const [selectedTabId, setSelectedTabId] = useState(initialTabId);
83 const [pictureInPicture, setPictureInPicture] = useState(NO_PICTURE_IN_PICTURE);
84 // Address bar commands only reach a page that is streaming.
85 const [streaming, setStreaming] = useState(false);
86 const [canControl, setCanControl] = useState(false);
87 const streamRef = useRef<PreviewStreamRef>(null);
88 const { tabs, loaded } = useThreadServerBrowserTabs({ environmentId, threadId, enabled: true });
89 const tab = tabs.find((entry) => entry.tabId === selectedTabId) ?? latestBrowserTab(tabs);
90 const tabId = tab?.tabId ?? null;
91 // Pin the fallback so another tab's activity does not switch the view.
92 if (tabId !== null && tabId !== selectedTabId) {
93 setPictureInPicture(NO_PICTURE_IN_PICTURE);
94 setSelectedTabId(tabId);
95 }
96 useEffect(() => {
97 const subscription = AppState.addEventListener("change", (state) =>
98 setForeground(state !== "background"),
99 );
100 return () => subscription.remove();
101 }, []);
102 useEffect(() => {
103 if (focused && loaded && tabs.length === 0) onClose();
104 }, [focused, loaded, tabs.length, onClose]);
105 const onPictureInPicture = useCallback((state: PreviewPictureInPictureState, detail?: string) => {
106 setPictureInPicture({ supported: state.supported, active: state.active });
107 if (detail) Alert.alert("Picture in picture is unavailable", detail);
108 }, []);
109 const selectTab = (next: string) => {
110 setPictureInPicture(NO_PICTURE_IN_PICTURE);
111 setSelectedTabId(next);
112 };
113 // System picture in picture keeps showing this stream after the app leaves the foreground.
114 const live = (focused && foreground) || pictureInPicture.active;
115
116 return (
117 <View className="flex-1 bg-sheet" style={{ paddingBottom: insets.bottom }}>
118 <ScreenHeader
119 title={tab ? browserTabTitle(tab) : "Browser"}
120 sidebar={false}
121 onBack={onClose}
122 options={{ headerBackVisible: false }}
123 actions={
124 // Android WebView cannot play video in picture in picture.
125 Platform.OS === "ios" && pictureInPicture.supported
126 ? [
127 {
128 accessibilityLabel: pictureInPicture.active
129 ? "Exit picture in picture"
130 : "Picture in picture",
131 icon: {
132 ios: pictureInPicture.active ? "pip.exit" : "pip.enter",
133 android: "visibility",
134 },
135 selected: pictureInPicture.active,
136 onPress: () => streamRef.current?.togglePictureInPicture(),
137 },
138 ]
139 : []
140 }
141 menus={
142 tabs.length > 1
143 ? [
144 {
145 title: "Tabs",
146 icon: "square.on.square",
147 items: tabs.map((entry) => ({
148 id: entry.tabId,
149 title: browserTabTitle(entry),
150 subtitle: browserTabUrl(entry),
151 selected: entry.tabId === tabId,
152 onPress: () => selectTab(entry.tabId),
153 })),
154 },
155 ]
156 : []
157 }
158 />
159 {Platform.OS === "ios" ? (
160 <NativeHeaderToolbar placement="left">
161 <NativeHeaderToolbar.Button
162 icon="xmark"
163 accessibilityLabel="Close browser"
164 onPress={onClose}
165 separateBackground
166 />
167 </NativeHeaderToolbar>
168 ) : null}
169 {tab ? (
170 <>
171 <BrowserAddressBar
172 key={tab.tabId}
173 environmentId={environmentId}
174 tab={tab}
175 streaming={streaming}
176 ready={streaming && canControl}
177 onCommand={(input) => streamRef.current?.command(input)}
178 />
179 {live ? (
180 <PreviewStreamWebView
181 key={tab.tabId}
182 environmentId={environmentId}
183 threadId={threadId}
184 tabId={tab.tabId}
185 ref={streamRef}
186 interactive
187 background={themeVariables["--color-sheet-solid"]}
188 onPictureInPicture={onPictureInPicture}
189 onStreamingChange={setStreaming}
190 onControl={(control) => setCanControl(control?.controller === "you")}
191 />
192 ) : (
193 <View className="flex-1" />
194 )}
195 </>
196 ) : (
197 <View className="flex-1 items-center justify-center">
198 {focused && foreground ? (
199 <ActivityIndicator color={themeVariables["--color-icon"]} />
200 ) : null}
201 </View>
202 )}
203 </View>
204 );
205}
206
207function BrowserAddressBar({
208 environmentId,
209 tab,
210 ready,
211 streaming,
212 onCommand,
213}: {
214 readonly environmentId: EnvironmentId;
215 readonly tab: PreviewSessionSnapshot;
216 /** The viewer controls the page, so navigation and reload apply. */
217 readonly ready: boolean;
218 /** The menu's changes need no control, only a live tab. */
219 readonly streaming: boolean;
220 readonly onCommand: PreviewStreamRef["command"];
221}) {
222 const url = browserTabUrl(tab);
223 // Null while not editing, so agent navigation keeps the field current.
224 const [draft, setDraft] = useState<string | null>(null);
225 const submit = () => {
226 const value = draft ?? url;
227 setDraft(null);
228 try {
229 onCommand({ type: "navigate", url: normalizePreviewUrl(value) });
230 } catch {
231 Alert.alert("Could not open this address", "Enter an http or https URL.");
232 }
233 };
234 return (
235 <View className="flex-row items-center gap-1 px-2 pb-2">
236 <ControlPill
237 icon="chevron.left"
238 accessibilityLabel="Back"
239 disabled={!ready || !tab.canGoBack}
240 onPress={() => onCommand({ type: "history", delta: -1 })}
241 />
242 <ControlPill
243 icon="chevron.right"
244 accessibilityLabel="Forward"
245 disabled={!ready || !tab.canGoForward}
246 onPress={() => onCommand({ type: "history", delta: 1 })}
247 />
248 <TextInput
249 accessibilityLabel="Address"
250 editable={ready}
251 value={draft ?? url}
252 onChangeText={setDraft}
253 onBlur={() => setDraft(null)}
254 onSubmitEditing={submit}
255 placeholder="Enter a URL"
256 placeholderTextColorClassName="accent-placeholder"
257 autoCapitalize="none"
258 autoCorrect={false}
259 keyboardType="url"
260 returnKeyType="go"
261 selectTextOnFocus
262 className="h-10 min-w-0 flex-1 rounded-full border border-input-border bg-input px-4 font-sans text-sm text-foreground"
263 />
264 <ControlPill
265 icon="arrow.clockwise"
266 accessibilityLabel="Reload page"
267 disabled={!ready}
268 onPress={() => onCommand({ type: "reload" })}
269 />
270 <BrowserTabMenu environmentId={environmentId} tab={tab} disabled={!streaming} />
271 </View>
272 );
273}
apps/mobile/src/features/browser/BrowserTabMenu.tsx created+157
...@@ -0,0 +1,157 @@
1import type { MenuAction } from "@react-native-menu/menu";
2import {
3 DEFAULT_PREVIEW_ZOOM_FACTOR,
4 type EnvironmentId,
5 FILL_PREVIEW_VIEWPORT,
6 PREVIEW_ZOOM_LEVELS,
7 type PreviewAdjustInput,
8 type PreviewSessionSnapshot,
9 type PreviewViewportSetting,
10} from "@t3tools/contracts";
11import { PREVIEW_VIEWPORT_PRESETS } from "@t3tools/shared/previewViewport";
12import { Alert } from "react-native";
13
14import { ControlPill, ControlPillMenu } from "../../components/ControlPill";
15import { previewEnvironment } from "../../state/preview";
16import { useAtomCommand } from "../../state/use-atom-command";
17
18const APPEARANCES = [
19 { id: "system", title: "System" },
20 { id: "light", title: "Light" },
21 { id: "dark", title: "Dark" },
22] as const;
23
24/** One preset per kind of screen; the web's device toolbar offers the full list. */
25const VIEWPORTS = [
26 { id: "fill", title: "Fit to screen", setting: FILL_PREVIEW_VIEWPORT },
27 ...(["Phone", "Tablet", "Desktop"] as const).flatMap((category) => {
28 const preset = PREVIEW_VIEWPORT_PRESETS.find((candidate) => candidate.category === category);
29 if (!preset) return [];
30 const setting: PreviewViewportSetting = {
31 _tag: "preset",
32 presetId: preset.id,
33 width: preset.width,
34 height: preset.height,
35 };
36 return [{ id: `preset:${preset.id}`, title: `${category} (${preset.label})`, setting }];
37 }),
38];
39
40/**
41 * The browser tab's menu: appearance, zoom, viewport, a hard reload, and its
42 * profile's site data. Each runs through the environment, as on web and
43 * desktop, so every client and agent sees the same tab. None need control.
44 */
45export function BrowserTabMenu({
46 environmentId,
47 tab,
48 disabled,
49}: {
50 readonly environmentId: EnvironmentId;
51 readonly tab: PreviewSessionSnapshot;
52 readonly disabled: boolean;
53}) {
54 const adjust = useAtomCommand(previewEnvironment.adjust, "browser tab change");
55 const resize = useAtomCommand(previewEnvironment.resize, "browser viewport change");
56 const target = { threadId: tab.threadId as PreviewAdjustInput["threadId"], tabId: tab.tabId };
57 const colorScheme = tab.colorScheme ?? "system";
58 const zoomFactor = tab.zoomFactor ?? DEFAULT_PREVIEW_ZOOM_FACTOR;
59 const zoomIndex = PREVIEW_ZOOM_LEVELS.indexOf(zoomFactor);
60 const zoomTo = (step: -1 | 1) =>
61 PREVIEW_ZOOM_LEVELS[
62 Math.min(Math.max((zoomIndex < 0 ? 7 : zoomIndex) + step, 0), PREVIEW_ZOOM_LEVELS.length - 1)
63 ]!;
64 const viewportId =
65 tab.viewport === undefined || tab.viewport._tag === "fill"
66 ? "fill"
67 : tab.viewport._tag === "preset"
68 ? `preset:${tab.viewport.presetId}`
69 : "custom";
70
71 const actions: MenuAction[] = [
72 { id: "hard-reload", title: "Hard reload", image: "arrow.clockwise.circle" },
73 {
74 id: "appearance",
75 title: "Appearance",
76 image: "circle.lefthalf.filled",
77 subactions: APPEARANCES.map((option) => ({
78 id: `appearance:${option.id}`,
79 title: option.title,
80 state: option.id === colorScheme ? "on" : "off",
81 })),
82 },
83 {
84 id: "zoom",
85 title: `Zoom (${Math.round(zoomFactor * 100)}%)`,
86 image: "plus.magnifyingglass",
87 subactions: [
88 { id: "zoom:in", title: "Zoom in", image: "plus.magnifyingglass" },
89 { id: "zoom:out", title: "Zoom out", image: "minus.magnifyingglass" },
90 { id: "zoom:reset", title: "Actual size", image: "1.magnifyingglass" },
91 ],
92 },
93 {
94 id: "viewport",
95 title: "Viewport",
96 image: "rectangle.and.arrow.up.right.and.arrow.down.left",
97 subactions: VIEWPORTS.map((option) => ({
98 id: `viewport:${option.id}`,
99 title: option.title,
100 state: option.id === viewportId ? "on" : "off",
101 })),
102 },
103 {
104 id: "site-data",
105 title: "Site data",
106 image: "trash",
107 subactions: [
108 { id: "clear:cookies", title: "Clear cookies", attributes: { destructive: true } },
109 { id: "clear:cache", title: "Clear cache", attributes: { destructive: true } },
110 ],
111 },
112 ];
113
114 const run = async (change: Omit<PreviewAdjustInput, "threadId" | "tabId">) => {
115 const result = await adjust({ environmentId, input: { ...target, ...change } });
116 if (result._tag === "Failure") Alert.alert("Could not change this browser tab");
117 };
118
119 const onAction = (id: string) => {
120 if (id === "hard-reload") return void run({ hardReload: true });
121 if (id === "zoom:in") return void run({ zoomFactor: zoomTo(1) });
122 if (id === "zoom:out") return void run({ zoomFactor: zoomTo(-1) });
123 if (id === "zoom:reset") return void run({ zoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR });
124 if (id === "clear:cookies") return void run({ clear: "cookies" });
125 if (id === "clear:cache") return void run({ clear: "cache" });
126 if (id.startsWith("appearance:")) {
127 const option = APPEARANCES.find((candidate) => `appearance:${candidate.id}` === id);
128 if (option) void run({ colorScheme: option.id });
129 return;
130 }
131 if (id.startsWith("viewport:")) {
132 const option = VIEWPORTS.find((candidate) => `viewport:${candidate.id}` === id);
133 if (!option) return;
134 void resize({ environmentId, input: { ...target, viewport: option.setting } }).then(
135 (result) => {
136 if (result._tag === "Failure") Alert.alert("Could not change the viewport");
137 },
138 );
139 }
140 };
141
142 const pill = (
143 <ControlPill icon="ellipsis" accessibilityLabel="Browser tab options" disabled={disabled} />
144 );
145 if (disabled) return pill;
146 return (
147 <ControlPillMenu
148 accessible
149 accessibilityLabel="Browser tab options"
150 accessibilityRole="button"
151 actions={actions}
152 onPressAction={({ nativeEvent }) => onAction(nativeEvent.event)}
153 >
154 {pill}
155 </ControlPillMenu>
156 );
157}
apps/mobile/src/features/browser/PreviewStreamWebView.tsx created+534
...@@ -0,0 +1,534 @@
1import previewStreamScript from "@t3tools/mobile-preview-stream";
2import {
3 previewStreamControlLabel,
4 previewStreamHostSetupMessage,
5 type PreviewStreamControl,
6 type PreviewStreamDownload,
7 type PreviewStreamFileChooser,
8 type PreviewStreamInput,
9} from "@t3tools/client-runtime/preview/server-browser-stream";
10import type { EnvironmentId, PreviewStreamHostSetup } from "@t3tools/contracts";
11import {
12 useEffect,
13 useEffectEvent,
14 useImperativeHandle,
15 useLayoutEffect,
16 useMemo,
17 useRef,
18 useState,
19 type Ref,
20} from "react";
21import { ActivityIndicator, Alert, Platform, Pressable, TextInput, View } from "react-native";
22import { WebView } from "react-native-webview";
23import * as Clipboard from "expo-clipboard";
24
25import { AppText } from "../../components/AppText";
26import { downloadAndShareAttachment } from "../../lib/attachmentDownload";
27import { beginForegroundHandoff } from "../../lib/foreground-handoff";
28import { usePreviewStreamAccess } from "../../state/preview";
29
30import {
31 previewStreamDocument,
32 previewStreamMessage,
33 type PreviewStreamConfiguration,
34} from "./preview-stream-document";
35
36export interface PreviewStreamRef {
37 /** Navigation requires current control of the browser. */
38 readonly command: (input: PreviewStreamInput) => void;
39 readonly togglePictureInPicture: () => void;
40}
41
42export interface PreviewPictureInPictureState {
43 readonly supported: boolean;
44 readonly active: boolean;
45}
46
47type NativeStreamBridge = {
48 readonly ref?: Ref<PreviewStreamRef>;
49 /** Refresh stream access; new access remounts the document with a fresh ticket. */
50 readonly onUnauthorized: () => void;
51 /** The tab was closed on the server. */
52 readonly onGone?: () => void;
53 readonly onViewport?: (viewport: { readonly width: number; readonly height: number }) => void;
54 readonly onControl?: (control: PreviewStreamControl | null) => void;
55 readonly onPictureInPicture?: (state: PreviewPictureInPictureState, detail?: string) => void;
56 /** True while frames show, so commands reach the page. Pass a stable function. */
57 readonly onStreamingChange?: (streaming: boolean) => void;
58 /** The floating player shows a spinner without text or a reconnect button. */
59 readonly compact?: boolean;
60};
61
62/** Picks files on this device and sends them to the page's open picker; none cancels it. */
63async function sendFilesToPage(chooser: PreviewStreamFileChooser, pick: boolean) {
64 const body = new FormData();
65 if (pick) {
66 const { getDocumentAsync } = await import("expo-document-picker");
67 const endHandoff = beginForegroundHandoff();
68 let result: Awaited<ReturnType<typeof getDocumentAsync>>;
69 try {
70 result = await getDocumentAsync({ multiple: chooser.multiple, copyToCacheDirectory: true });
71 } finally {
72 endHandoff();
73 }
74 for (const asset of result.canceled ? [] : result.assets) {
75 // React Native's FormData uploads a file part from its URI.
76 body.append("file", {
77 uri: asset.uri,
78 name: asset.name,
79 type: asset.mimeType ?? "application/octet-stream",
80 } as unknown as Blob);
81 }
82 }
83 const response = await fetch(chooser.uploadUrl, {
84 method: "POST",
85 body,
86 credentials: chooser.credentials ? "include" : "omit",
87 });
88 if (!response.ok) throw new Error((await response.text()) || "The upload was refused.");
89}
90
91/** The file is on the environment; saving it here goes through the share sheet. */
92function offerDownload(download: PreviewStreamDownload) {
93 Alert.alert(`Downloaded ${download.fileName}`, undefined, [
94 { text: "Not now", style: "cancel" },
95 {
96 text: "Save or share",
97 onPress: () =>
98 void downloadAndShareAttachment({
99 url: download.url,
100 attachment: { name: download.fileName, mimeType: "application/octet-stream" },
101 signal: new AbortController().signal,
102 }).catch((cause: unknown) =>
103 Alert.alert(
104 "Could not save the download",
105 cause instanceof Error ? cause.message : undefined,
106 ),
107 ),
108 },
109 ]);
110}
111
112// Consecutive refused tickets before the view stops and offers Reconnect, e.g. a
113// session without operate scope.
114const MAX_REFUSALS = 3;
115
116export function PreviewStreamWebView(
117 props: Omit<PreviewStreamConfiguration, "access"> &
118 Omit<NativeStreamBridge, "onUnauthorized"> & {
119 readonly environmentId: EnvironmentId;
120 readonly paused?: boolean;
121 },
122) {
123 const { access, error, refresh } = usePreviewStreamAccess(props.environmentId);
124 if (access && !props.paused) {
125 return <AuthorizedPreviewStream {...props} access={access} onUnauthorized={refresh} />;
126 }
127 return (
128 <View
129 className={
130 props.compact
131 ? "flex-1 items-center justify-center"
132 : "flex-1 items-center justify-center gap-4 px-6"
133 }
134 >
135 {props.compact || !error ? <ActivityIndicator colorClassName="accent-icon" /> : null}
136 {!props.compact && (
137 <>
138 <AppText
139 selectable={!!error}
140 className={
141 error ? "text-center text-sm text-foreground-muted" : "text-sm text-foreground-muted"
142 }
143 >
144 {error || "Connecting to browser..."}
145 </AppText>
146 {error && (
147 <Pressable
148 accessibilityRole="button"
149 className="rounded-full border border-secondary-border bg-secondary px-6 py-3"
150 onPress={refresh}
151 >
152 <AppText className="text-secondary-foreground">Retry</AppText>
153 </Pressable>
154 )}
155 </>
156 )}
157 </View>
158 );
159}
160
161function AuthorizedPreviewStream({
162 ref,
163 ...props
164}: PreviewStreamConfiguration & NativeStreamBridge) {
165 const [attempt, setAttempt] = useState(0);
166 const [previousAccess, setPreviousAccess] = useState(props.access);
167 // Wait for refreshed access, including cookie credentials with unchanged JSON.
168 if (props.access !== previousAccess) {
169 setPreviousAccess(props.access);
170 setAttempt((current) => current + 1);
171 }
172 const processRetried = useRef(false);
173 const unauthorized = useRef(0);
174 const refreshTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
175 useEffect(
176 () => () => {
177 if (refreshTimer.current !== null) clearTimeout(refreshTimer.current);
178 },
179 [],
180 );
181 const configuration = JSON.stringify({
182 access: props.access,
183 threadId: props.threadId,
184 tabId: props.tabId,
185 interactive: props.interactive,
186 background: props.background,
187 } satisfies PreviewStreamConfiguration);
188 return (
189 <PreviewStreamDocumentView
190 key={`${attempt}:${configuration}`}
191 {...props}
192 ref={ref}
193 configuration={configuration}
194 onUnauthorized={() => {
195 // The client has stopped. Restart it with a fresh ticket, backing off between refusals.
196 const refusals = ++unauthorized.current;
197 if (refusals >= MAX_REFUSALS) return false;
198 if (refreshTimer.current !== null) clearTimeout(refreshTimer.current);
199 refreshTimer.current = setTimeout(
200 () => {
201 refreshTimer.current = null;
202 props.onUnauthorized();
203 },
204 refusals === 1 ? 0 : 1_000 * 2 ** (refusals - 1),
205 );
206 return true;
207 }}
208 onRetry={() => {
209 processRetried.current = false;
210 unauthorized.current = 0;
211 props.onUnauthorized();
212 }}
213 onStreaming={() => {
214 processRetried.current = false;
215 unauthorized.current = 0;
216 }}
217 onRecoverProcess={() => {
218 if (processRetried.current) return false;
219 processRetried.current = true;
220 setAttempt((current) => current + 1);
221 return true;
222 }}
223 />
224 );
225}
226
227function PreviewStreamDocumentView({
228 ref,
229 configuration,
230 background,
231 compact,
232 onUnauthorized,
233 onGone,
234 onViewport,
235 onControl,
236 onPictureInPicture,
237 onStreamingChange,
238 onRetry,
239 onStreaming,
240 onRecoverProcess,
241}: Omit<NativeStreamBridge, "onUnauthorized"> & {
242 readonly configuration: string;
243 readonly background: string;
244 /** False when the view should stop retrying and fail. */
245 readonly onUnauthorized: () => boolean;
246 readonly onRetry: () => void;
247 readonly onStreaming: () => void;
248 readonly onRecoverProcess: () => boolean;
249}) {
250 const webView = useRef<WebView<object>>(null);
251 const active = useRef(true);
252 const failed = useRef(false);
253 const [status, setStatus] = useState<"connecting" | "streaming" | "error">("connecting");
254 const [error, setError] = useState<string | null>(null);
255 const [gone, setGone] = useState(false);
256 const [hostSetup, setHostSetup] = useState<PreviewStreamHostSetup | null>(null);
257 const [started, setStarted] = useState(false);
258 const [control, setControl] = useState<PreviewStreamControl | null>(null);
259 const [promptText, setPromptText] = useState("");
260 const [fileChooser, setFileChooser] = useState<PreviewStreamFileChooser | null>(null);
261 const answerFileChooser = (pick: boolean) => {
262 const chooser = fileChooser;
263 if (!chooser) return;
264 setFileChooser(null);
265 void sendFilesToPage(chooser, pick).catch((cause: unknown) =>
266 Alert.alert(
267 "Could not send the files to the page",
268 cause instanceof Error ? cause.message : undefined,
269 ),
270 );
271 };
272 const controlChanged = useEffectEvent((next: PreviewStreamControl | null) => onControl?.(next));
273 const command = (input: PreviewStreamInput) =>
274 webView.current?.injectJavaScript(
275 `window.T3PreviewStream?.command(${JSON.stringify(input)}); true;`,
276 );
277 const fail = (message: string) => {
278 if (!active.current || failed.current) return;
279 failed.current = true;
280 webView.current?.injectJavaScript("window.T3PreviewStream?.stop(); true;");
281 onStreamingChange?.(false);
282 setControl(null);
283 setFileChooser(null);
284 onControl?.(null);
285 setError(message);
286 setStatus("error");
287 };
288 // The shared transport owns reconnects once the document acknowledges startup.
289 const bootstrapTimedOut = useEffectEvent(() =>
290 fail("Browser viewer could not start. Reconnect to try again."),
291 );
292 useEffect(() => {
293 if (started) return;
294 const timer = setTimeout(bootstrapTimedOut, 15_000);
295 return () => clearTimeout(timer);
296 }, [started]);
297 const source = useMemo(
298 () => ({
299 html: previewStreamDocument(configuration, previewStreamScript),
300 baseUrl: Platform.OS === "android" ? "https://localhost/" : "file:///",
301 }),
302 [configuration],
303 );
304 useImperativeHandle(ref, () => ({
305 command,
306 togglePictureInPicture: () =>
307 webView.current?.injectJavaScript("window.T3PreviewStream?.pictureInPicture(); true;"),
308 }));
309 useLayoutEffect(() => {
310 active.current = true;
311 const view = webView.current;
312 return () => {
313 active.current = false;
314 view?.injectJavaScript("window.T3PreviewStream?.stop(); true;");
315 };
316 }, []);
317 useEffect(() => () => onStreamingChange?.(false), [onStreamingChange]);
318 useEffect(() => () => controlChanged(null), []);
319 const processTerminated = () => {
320 if (!active.current || failed.current) return;
321 if (!onRecoverProcess()) fail("Browser viewer stopped. Reconnect to try again.");
322 };
323 return (
324 <View className="flex-1" style={{ backgroundColor: background }}>
325 {!compact ? (
326 <View className="flex-row items-center justify-between gap-2 border-b border-secondary-border px-3 py-2">
327 <AppText className="text-xs text-foreground-muted">
328 {previewStreamControlLabel(control)}
329 </AppText>
330 {control?.canOperate ? (
331 <Pressable
332 accessibilityRole="button"
333 disabled={control.controller === "another-viewer"}
334 accessibilityState={{ disabled: control.controller === "another-viewer" }}
335 className="rounded-full border border-secondary-border bg-secondary px-3 py-2"
336 onPress={() =>
337 command({ type: control.controller === "you" ? "releaseControl" : "takeControl" })
338 }
339 >
340 <AppText className="text-xs text-secondary-foreground">
341 {control.controller === "you" ? "Release control" : "Take control"}
342 </AppText>
343 </Pressable>
344 ) : null}
345 </View>
346 ) : null}
347 <WebView<object>
348 ref={webView}
349 source={source}
350 originWhitelist={["*"]}
351 scrollEnabled={false}
352 bounces={false}
353 mixedContentMode="always"
354 allowUniversalAccessFromFileURLs
355 contentInsetAdjustmentBehavior="never"
356 setSupportMultipleWindows={false}
357 // Picture in picture plays the canvas as an inline muted video, started from native chrome.
358 allowsInlineMediaPlayback
359 allowsPictureInPictureMediaPlayback
360 mediaPlaybackRequiresUserAction={false}
361 style={{ flex: 1, backgroundColor: background }}
362 onError={() => fail("Browser viewer could not load. Reconnect to try again.")}
363 onHttpError={() => fail("Browser viewer could not load. Reconnect to try again.")}
364 onContentProcessDidTerminate={processTerminated}
365 onRenderProcessGone={processTerminated}
366 onShouldStartLoadWithRequest={(request) =>
367 request.url === "about:blank" || request.url === source.baseUrl
368 }
369 onMessage={(event) => {
370 if (!active.current || failed.current) return;
371 const message = previewStreamMessage(event.nativeEvent.data);
372 if (message === null) return;
373 switch (message.type) {
374 case "control":
375 setControl(message);
376 setPromptText(message.dialog?.defaultValue ?? "");
377 onControl?.(message);
378 return;
379 case "unauthorized":
380 if (!onUnauthorized()) {
381 fail("This session can't open the browser stream. Reconnect to try again.");
382 }
383 return;
384 case "gone":
385 setGone(true);
386 fail("This tab was closed.");
387 onGone?.();
388 return;
389 case "hostSetup":
390 setHostSetup({ need: message.need, command: message.command });
391 fail(previewStreamHostSetupMessage(message));
392 return;
393 case "viewport":
394 onViewport?.(message);
395 return;
396 case "clipboard":
397 void Clipboard.setStringAsync(message.text).catch(() => undefined);
398 return;
399 case "download":
400 offerDownload(message);
401 return;
402 case "fileChooser":
403 setFileChooser(message.chooser);
404 return;
405 case "pictureInPicture":
406 onPictureInPicture?.(message, message.detail);
407 return;
408 case "status":
409 setStarted(true);
410 if (message.status === "error") {
411 fail(message.detail ?? "Browser stream failed.");
412 return;
413 }
414 setStatus(message.status);
415 if (message.status === "connecting") {
416 setControl(null);
417 onControl?.(null);
418 }
419 onStreamingChange?.(message.status === "streaming");
420 if (message.status === "streaming") onStreaming();
421 }
422 }}
423 />
424 {!compact && fileChooser && control?.controller === "you" ? (
425 <View className="absolute inset-x-3 top-16 gap-3 rounded-xl border border-secondary-border bg-secondary p-4">
426 <AppText className="text-sm text-secondary-foreground">
427 The page asks for {fileChooser.multiple ? "files" : "a file"}.
428 </AppText>
429 <View className="flex-row justify-end gap-3">
430 <Pressable
431 accessibilityRole="button"
432 className="px-3 py-2"
433 onPress={() => answerFileChooser(false)}
434 >
435 <AppText className="text-secondary-foreground">Cancel</AppText>
436 </Pressable>
437 <Pressable
438 accessibilityRole="button"
439 className="px-3 py-2"
440 onPress={() => answerFileChooser(true)}
441 >
442 <AppText className="text-secondary-foreground">
443 Choose {fileChooser.multiple ? "files" : "file"}
444 </AppText>
445 </Pressable>
446 </View>
447 </View>
448 ) : null}
449 {!compact && control?.dialog ? (
450 <View className="absolute inset-x-3 top-16 gap-3 rounded-xl border border-secondary-border bg-secondary p-4">
451 <AppText className="text-sm text-secondary-foreground">{control.dialog.message}</AppText>
452 {control.controller === "you" ? (
453 <>
454 {control.dialog.type === "prompt" ? (
455 <TextInput
456 accessibilityLabel="Dialog response"
457 className="rounded-lg border border-secondary-border bg-background px-3 py-2 text-foreground"
458 value={promptText}
459 onChangeText={setPromptText}
460 />
461 ) : null}
462 <View className="flex-row justify-end gap-3">
463 <Pressable
464 accessibilityRole="button"
465 className="px-3 py-2"
466 onPress={() => command({ type: "dialog", accept: false })}
467 >
468 <AppText className="text-secondary-foreground">Dismiss</AppText>
469 </Pressable>
470 <Pressable
471 accessibilityRole="button"
472 className="px-3 py-2"
473 onPress={() =>
474 command({
475 type: "dialog",
476 accept: true,
477 ...(control.dialog?.type === "prompt" ? { promptText } : {}),
478 })
479 }
480 >
481 <AppText className="text-secondary-foreground">Accept</AppText>
482 </Pressable>
483 </View>
484 </>
485 ) : (
486 <AppText className="text-xs text-foreground-muted">Take control to respond.</AppText>
487 )}
488 </View>
489 ) : null}
490 {status !== "streaming" ? (
491 <View
492 className="absolute inset-0 items-center justify-center gap-4 px-6"
493 style={{ backgroundColor: background }}
494 >
495 {status === "connecting" ? <ActivityIndicator colorClassName="accent-icon" /> : null}
496 {compact ? null : (
497 <AppText
498 accessibilityLiveRegion="polite"
499 className="text-center text-sm text-foreground-muted"
500 >
501 {status === "error" ? error : "Connecting to browser..."}
502 </AppText>
503 )}
504 {status === "error" && hostSetup && !compact ? (
505 <Pressable
506 accessibilityRole="button"
507 accessibilityLabel={`Copy ${hostSetup.command}`}
508 className="w-full flex-row items-center gap-2 rounded-xl border border-secondary-border bg-secondary px-4 py-3"
509 onPress={() => {
510 void Clipboard.setStringAsync(hostSetup.command).catch(() => undefined);
511 }}
512 >
513 <AppText selectable className="flex-1 font-mono text-sm text-secondary-foreground">
514 {hostSetup.command}
515 </AppText>
516 <AppText className="text-xs text-foreground-muted">Copy</AppText>
517 </Pressable>
518 ) : null}
519 {status === "error" && !gone && !compact ? (
520 <Pressable
521 accessibilityRole="button"
522 className="rounded-full border border-secondary-border bg-secondary px-6 py-3"
523 onPress={onRetry}
524 >
525 <AppText className="text-secondary-foreground">
526 {hostSetup ? "Try again" : "Reconnect"}
527 </AppText>
528 </Pressable>
529 ) : null}
530 </View>
531 ) : null}
532 </View>
533 );
534}
apps/mobile/src/features/browser/ThreadBrowserFloat.tsx created+126
...@@ -0,0 +1,126 @@
1import { useIsFocused } from "@react-navigation/native";
2import type { EnvironmentId, PreviewSessionSnapshot, ThreadId } from "@t3tools/contracts";
3import { useCallback, useEffect, useRef, useState } from "react";
4import { AppState, Pressable, View } from "react-native";
5import Animated, { FadeIn, FadeOut, ReduceMotion } from "react-native-reanimated";
6
7import { SymbolView } from "../../components/AppSymbol";
8import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
9import { browserTabTitle } from "./browserTabs";
10import { PreviewStreamWebView } from "./PreviewStreamWebView";
11
12const PLAYER_LONG_SIDE = 184;
13const PLAYER_ENTERING = FadeIn.duration(180).reduceMotion(ReduceMotion.System);
14const PLAYER_EXITING = FadeOut.duration(120).reduceMotion(ReduceMotion.System);
15
16export function ThreadBrowserFloat(props: {
17 readonly environmentId: EnvironmentId;
18 readonly threadId: ThreadId;
19 readonly tabs: ReadonlyArray<PreviewSessionSnapshot>;
20 readonly loaded: boolean;
21 readonly top: number;
22 readonly onOpen: (tabId: string) => void;
23}) {
24 const [tabId, setTabId] = useState<string | null>(null);
25 const known = useRef<ReadonlyMap<string, string | undefined> | null>(null);
26 useEffect(() => {
27 if (!props.loaded) return;
28 const previous = known.current;
29 known.current = new Map(props.tabs.map((tab) => [tab.tabId, tab.revealRequest?.id]));
30 // The first list is a baseline, so reopening a thread does not resurface old tabs.
31 if (previous === null) return;
32 const opened = props.tabs.findLast(
33 (tab) =>
34 tab.reveal === true &&
35 (tab.revealRequest
36 ? previous.get(tab.tabId) !== tab.revealRequest.id
37 : !previous.has(tab.tabId)),
38 );
39 if (opened !== undefined) setTabId(opened.tabId);
40 }, [props.loaded, props.tabs]);
41 const focused = useIsFocused();
42 const [foreground, setForeground] = useState(AppState.currentState !== "background");
43 useEffect(() => {
44 const subscription = AppState.addEventListener("change", (state) =>
45 setForeground(state !== "background"),
46 );
47 return () => subscription.remove();
48 }, []);
49 const tab = props.tabs.find((entry) => entry.tabId === tabId) ?? null;
50 const onOpen = props.onOpen;
51 const open = useCallback(() => {
52 if (tabId === null) return;
53 setTabId(null);
54 onOpen(tabId);
55 }, [onOpen, tabId]);
56 const close = useCallback(() => setTabId(null), []);
57 if (!tab || !focused) return null;
58 return (
59 <FloatingBrowserPlayer
60 environmentId={props.environmentId}
61 threadId={props.threadId}
62 tab={tab}
63 live={foreground}
64 top={props.top}
65 onOpen={open}
66 onClose={close}
67 />
68 );
69}
70
71function FloatingBrowserPlayer(props: {
72 readonly environmentId: EnvironmentId;
73 readonly threadId: ThreadId;
74 readonly tab: PreviewSessionSnapshot;
75 readonly live: boolean;
76 readonly top: number;
77 readonly onOpen: () => void;
78 readonly onClose: () => void;
79}) {
80 const { themeVariables } = useAppearancePreferences();
81 const [viewport, setViewport] = useState<{ width: number; height: number } | null>(null);
82 // The page keeps its own size; the player scales it into a box of the same shape.
83 const aspect = Math.min(Math.max(viewport ? viewport.width / viewport.height : 16 / 10, 0.5), 2);
84 const width = aspect >= 1 ? PLAYER_LONG_SIDE : Math.round(PLAYER_LONG_SIDE * aspect);
85 const height = Math.round(width / aspect);
86 const background = themeVariables["--color-sheet-solid"];
87 return (
88 <Animated.View
89 entering={PLAYER_ENTERING}
90 exiting={PLAYER_EXITING}
91 className="absolute right-3 z-30 overflow-hidden rounded-2xl border border-border shadow-md shadow-black/20"
92 style={{ top: props.top, width, height, backgroundColor: background }}
93 >
94 <Pressable
95 accessibilityRole="button"
96 accessibilityLabel={`Open browser, ${browserTabTitle(props.tab)}`}
97 accessibilityHint="Opens the browser tab full screen"
98 onPress={props.onOpen}
99 className="flex-1"
100 >
101 <View pointerEvents="none" className="flex-1">
102 <PreviewStreamWebView
103 environmentId={props.environmentId}
104 threadId={props.threadId}
105 tabId={props.tab.tabId}
106 interactive={false}
107 background={background}
108 compact
109 paused={!props.live}
110 onViewport={setViewport}
111 onGone={props.onClose}
112 />
113 </View>
114 </Pressable>
115 <Pressable
116 accessibilityRole="button"
117 accessibilityLabel="Close floating preview"
118 hitSlop={8}
119 onPress={props.onClose}
120 className="absolute right-1.5 top-1.5 size-6 items-center justify-center rounded-full bg-black/55"
121 >
122 <SymbolView name="xmark" size={11} tintColor="#ffffff" type="monochrome" />
123 </Pressable>
124 </Animated.View>
125 );
126}
apps/mobile/src/features/browser/browser-preview-button.tsx created+37
...@@ -0,0 +1,37 @@
1import { Pressable, View } from "react-native";
2
3import { AppText } from "../../components/AppText";
4import { SymbolView } from "../../components/AppSymbol";
5
6/** Opens the thread's server-hosted browser tabs. Sits beside the device preview button. */
7export function BrowserPreviewButton(props: {
8 readonly count: number;
9 readonly onPress: () => void;
10 readonly compact?: boolean;
11}) {
12 const compact = props.compact ?? true;
13 return (
14 <Pressable
15 accessibilityRole="button"
16 accessibilityLabel={props.count === 1 ? "View browser" : `View ${props.count} browser tabs`}
17 accessibilityHint="Watch and control browser tabs open in this thread"
18 onPress={props.onPress}
19 className={
20 compact
21 ? "size-11 shrink-0 items-center justify-center rounded-full active:bg-subtle"
22 : "h-11 shrink-0 flex-row items-center justify-center gap-2 rounded-full px-4 active:bg-subtle"
23 }
24 >
25 <SymbolView name="globe" size={20} tintColorClassName="accent-icon" type="monochrome" />
26 {!compact ? (
27 <AppText className="font-t3-medium text-xs text-foreground">
28 {props.count === 1 ? "One tab open" : `${props.count} tabs open`}
29 </AppText>
30 ) : props.count > 1 ? (
31 <View className="absolute right-0.5 top-0.5 min-w-4 items-center rounded-full bg-primary px-1">
32 <AppText className="text-2xs font-t3-bold text-primary-foreground">{props.count}</AppText>
33 </View>
34 ) : null}
35 </Pressable>
36 );
37}
apps/mobile/src/features/browser/browserTabs.ts created+23
...@@ -0,0 +1,23 @@
1import type { PreviewSessionSnapshot } from "@t3tools/contracts";
2
3/** The tab the agent touched last, the default when nothing is selected. */
4export function latestBrowserTab(tabs: ReadonlyArray<PreviewSessionSnapshot>) {
5 return tabs.reduce<PreviewSessionSnapshot | null>(
6 (latest, tab) => (latest === null || tab.updatedAt > latest.updatedAt ? tab : latest),
7 null,
8 );
9}
10
11export function browserTabUrl(tab: PreviewSessionSnapshot) {
12 return tab.navStatus._tag === "Idle" ? "" : tab.navStatus.url;
13}
14
15export function browserTabTitle(tab: PreviewSessionSnapshot) {
16 if (tab.navStatus._tag === "Idle") return "New tab";
17 if (tab.navStatus.title.trim()) return tab.navStatus.title;
18 try {
19 return new URL(tab.navStatus.url).host || tab.navStatus.url;
20 } catch {
21 return tab.navStatus.url;
22 }
23}
apps/mobile/src/features/browser/preview-stream-document.ts created+84
...@@ -0,0 +1,84 @@
1import type { DeviceHubAccess } from "@t3tools/client-runtime/device/hub-access";
2import type { PreviewStreamHostSetup } from "@t3tools/contracts";
3import type {
4 PreviewStreamControl,
5 PreviewStreamDownload,
6 PreviewStreamFileChooser,
7} from "@t3tools/client-runtime/preview/server-browser-stream";
8
9export interface PreviewStreamConfiguration {
10 readonly access: DeviceHubAccess;
11 readonly threadId: string;
12 readonly tabId: string;
13 /** Taps, scrolls, keys, and `resize` to the view size. The floating player only watches. */
14 readonly interactive: boolean;
15 readonly background: string;
16}
17
18/** Messages the WebView document posts to the native view. */
19export type PreviewStreamMessage =
20 | ({ readonly type: "control" } & PreviewStreamControl)
21 | {
22 readonly type: "status";
23 readonly status: "connecting" | "streaming" | "error";
24 readonly detail?: string;
25 }
26 | { readonly type: "unauthorized" }
27 | { readonly type: "gone" }
28 | ({ readonly type: "hostSetup" } & PreviewStreamHostSetup)
29 | { readonly type: "viewport"; readonly width: number; readonly height: number }
30 | { readonly type: "clipboard"; readonly text: string }
31 | ({ readonly type: "download" } & PreviewStreamDownload)
32 | { readonly type: "fileChooser"; readonly chooser: PreviewStreamFileChooser | null }
33 | {
34 readonly type: "pictureInPicture";
35 readonly supported: boolean;
36 readonly active: boolean;
37 readonly detail?: string;
38 };
39
40export function previewStreamDocument(configuration: string, script: string) {
41 // Tickets and URLs are data, including any HTML delimiter characters.
42 const safeConfiguration = configuration.replace(/</g, "\\u003c");
43 const safeScript = script.replace(/<\/script/gi, "<\\/script");
44 const failure = `window.ReactNativeWebView.postMessage(JSON.stringify({type:"status",status:"error",detail:"Browser viewer stopped unexpectedly."}));`;
45 return `<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
46<style>
47 html, body { height: 100%; overflow: hidden; }
48 body { margin: 0; }
49 body > div { position: fixed; left: 0; top: 0; width: 100%; height: 100%; overflow: hidden; }
50 canvas, video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; }
51 canvas { touch-action: none; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; }
52 video { pointer-events: none; }
53 /* The agent's pointer, positioned over the letterboxed frame by the viewer script. */
54 .agent-cursor {
55 position: absolute; left: 0; top: 0; width: 20px; height: 20px; pointer-events: none;
56 opacity: 0; transition: opacity 150ms ease-out, transform 150ms ease-out;
57 }
58 .agent-cursor svg { position: relative; display: block; filter: drop-shadow(0 1px 1px rgba(0,0,0,.35)); }
59 .agent-cursor .ping {
60 position: absolute; left: 2px; top: 2px; width: 16px; height: 16px; border-radius: 50%;
61 background: rgba(59,130,246,.3); animation: agent-cursor-ping 600ms ease-out forwards;
62 }
63 @keyframes agent-cursor-ping { from { transform: scale(.6); opacity: 1; } to { transform: scale(2.2); opacity: 0; } }
64 @media (prefers-reduced-motion: reduce) {
65 .agent-cursor { transition: none; }
66 .agent-cursor .ping { animation: none; opacity: 0; }
67 }
68 /* Pinned so focus never scrolls; 16px keeps iOS from zooming on focus. */
69 textarea {
70 position: fixed; left: 0; top: 0; width: 1px; height: 1px;
71 padding: 0; margin: -1px; border: 0; overflow: hidden;
72 clip: rect(0, 0, 0, 0); white-space: nowrap; font-size: 16px;
73 }
74</style></head><body><script>window.addEventListener("error",function(){${failure}});window.addEventListener("unhandledrejection",function(){${failure}});\n${safeScript}\ntry{T3PreviewStream.start(${safeConfiguration});}catch{${failure}}</script></body></html>`;
75}
76
77export function previewStreamMessage(data: string): PreviewStreamMessage | null {
78 try {
79 // Only our bundled viewer runs in this WebView; the remote page never does.
80 return JSON.parse(data) as PreviewStreamMessage;
81 } catch {
82 return null;
83 }
84}
apps/mobile/src/features/browser/preview-stream.browser.ts created+711
...@@ -0,0 +1,711 @@
1import {
2 createPreviewFramePainter,
3 createPreviewStreamClient,
4 previewStreamModifiers,
5 type PreviewStreamClient,
6 type PreviewStreamControl,
7 type PreviewStreamInput,
8 type PreviewStreamPointer,
9 type PreviewStreamViewport,
10} from "@t3tools/client-runtime/preview/server-browser-stream";
11
12import type { PreviewStreamConfiguration, PreviewStreamMessage } from "./preview-stream-document";
13
14declare global {
15 interface Window {
16 ReactNativeWebView: { postMessage: (message: string) => void };
17 }
18}
19
20/** WebKit's presentation API, the only picture in picture entry on older iOS. */
21interface PresentationVideo extends HTMLVideoElement {
22 webkitSetPresentationMode?: (mode: "inline" | "picture-in-picture") => void;
23 webkitPresentationMode?: string;
24}
25
26type WheelInput = Extract<PreviewStreamInput, { type: "wheel" }>;
27type MouseInput = Extract<PreviewStreamInput, { type: "mouse" }>;
28const mouseButton = (button: number): MouseInput["button"] =>
29 button === 0 ? "left" : button === 1 ? "middle" : button === 2 ? "right" : "none";
30
31const RESIZE_DEBOUNCE_MS = 150;
32const TAP_SLOP_PX = 8;
33const MULTI_CLICK_MS = 500;
34const MULTI_CLICK_SLOP_PX = 4;
35const WHEEL_LINE_PX = 16;
36// JPEG frames past 2x cost bandwidth without a visible gain on a phone.
37const MAX_PIXEL_RATIO = 2;
38// A tap this close to an answered probe, this soon, reuses its answer. On a slow
39// link the answer lands after touch end, too late for iOS to raise the keyboard.
40const PROBE_REUSE_PX = 24;
41const PROBE_REUSE_MS = 10_000;
42// Kept in the hidden textarea so a soft keyboard's backspace has something to
43// delete and fires `input`; Gboard's keydown carries keyCode 229 and no key.
44const SENTINEL = "\u200b";
45// The agent cursor stays bright while the agent acts, then dims like the web panel's.
46const AGENT_CURSOR_ACTIVE_MS = 700;
47const AGENT_CURSOR_SVG =
48 '<svg width="20" height="20" viewBox="0 0 24 24" fill="#fff" stroke="#3b82f6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.037 4.688a.495.495 0 0 1 .651-.651l16 6.5a.5.5 0 0 1-.063.947l-6.124 1.58a2 2 0 0 0-1.438 1.435l-1.579 6.126a.5.5 0 0 1-.947.063z"/></svg>';
49
50interface Viewer {
51 readonly stop: () => void;
52 readonly command: (input: PreviewStreamInput) => void;
53 readonly togglePictureInPicture: () => Promise<void>;
54}
55
56let activeViewer: Viewer | null = null;
57
58export function stop() {
59 activeViewer?.stop();
60 activeViewer = null;
61}
62
63/** Navigation and history from the native chrome require current browser control. */
64export function command(input: PreviewStreamInput) {
65 activeViewer?.command(input);
66}
67
68export function pictureInPicture() {
69 void activeViewer?.togglePictureInPicture();
70}
71
72export function start(configuration: PreviewStreamConfiguration) {
73 stop();
74 const post = (message: PreviewStreamMessage) => {
75 // oxlint-disable-next-line unicorn/require-post-message-target-origin -- The native WebView bridge takes one string.
76 window.ReactNativeWebView.postMessage(JSON.stringify(message));
77 };
78 const { interactive } = configuration;
79 document.body.style.background = configuration.background;
80 const container = document.createElement("div");
81 const canvas = document.createElement("canvas");
82 canvas.setAttribute("role", "img");
83 canvas.setAttribute("aria-label", "Browser page");
84 const agentCursor = document.createElement("div");
85 agentCursor.className = "agent-cursor";
86 agentCursor.setAttribute("aria-hidden", "true");
87 agentCursor.innerHTML = AGENT_CURSOR_SVG;
88 container.append(canvas, agentCursor);
89 const input = document.createElement("textarea");
90 input.setAttribute("aria-label", "Browser page input");
91 input.autocapitalize = "off";
92 input.autocomplete = "off";
93 input.spellcheck = false;
94 input.setAttribute("autocorrect", "off");
95 input.disabled = true;
96 document.body.replaceChildren(container, ...(interactive ? [input] : []));
97
98 let stopped = false;
99 let streaming = false;
100 let viewport: PreviewStreamViewport | null = null;
101 let size: { width: number; height: number } | null = null;
102 // Frame cap in device px, fixed per socket. It only grows, so only outgrowing it reconnects.
103 let cap: { width: number; height: number } | null = null;
104 let client: PreviewStreamClient | null = null;
105 let control: PreviewStreamControl | null = null;
106 let resizeTimer: ReturnType<typeof setTimeout> | null = null;
107 let wheelFrame: number | null = null;
108 let pendingWheel: WheelInput | null = null;
109 let mouseFrame: number | null = null;
110 let pendingMouse: MouseInput | null = null;
111 let mouseButtons = 0;
112 const mouseClicks = { left: 1, middle: 1, right: 1, none: 1 };
113 // The latest probe. `editable` is the answer its tap acts on, null until known;
114 // `end` records what touch end did, so a late answer can still act or correct it.
115 let probe: {
116 readonly x: number;
117 readonly y: number;
118 readonly clientX: number;
119 readonly clientY: number;
120 editable: boolean | null;
121 end: "touching" | "acted" | "late" | "panned";
122 } | null = null;
123 let probeCache: {
124 readonly clientX: number;
125 readonly clientY: number;
126 readonly editable: boolean;
127 readonly time: number;
128 } | null = null;
129 // A keydown already sent this key; its `input` must not send it again.
130 let keySent = false;
131 let touch: {
132 start: PointerEvent;
133 last: PointerEvent;
134 panning: boolean;
135 } | null = null;
136 let lastTap: {
137 time: number;
138 x: number;
139 y: number;
140 count: number;
141 button: MouseInput["button"];
142 } | null = null;
143
144 const reportStatus = (status: "connecting" | "streaming") => {
145 streaming = status === "streaming";
146 post({ type: "status", status });
147 };
148 const painter = createPreviewFramePainter(canvas, () => {
149 if (!streaming && !stopped) reportStatus("streaming");
150 });
151 const send = (message: PreviewStreamInput) => client?.send(message) ?? false;
152 const clearInput = () => {
153 if (wheelFrame !== null) cancelAnimationFrame(wheelFrame);
154 if (mouseFrame !== null) cancelAnimationFrame(mouseFrame);
155 wheelFrame = mouseFrame = null;
156 pendingWheel = pendingMouse = null;
157 mouseButtons = 0;
158 Object.assign(mouseClicks, { left: 1, middle: 1, right: 1, none: 1 });
159 probe = probeCache = touch = lastTap = null;
160 keySent = false;
161 input.value = SENTINEL;
162 input.disabled = true;
163 input.blur();
164 };
165
166 const connect = () => {
167 // Stopping suppresses the old socket's disconnect report; native also drops its control.
168 if (client) reportStatus("connecting");
169 client?.stop();
170 control = null;
171 clearInput();
172 if (!cap || stopped) return;
173 const next = createPreviewStreamClient(
174 {
175 access: configuration.access,
176 threadId: configuration.threadId,
177 tabId: configuration.tabId,
178 maxWidth: cap.width,
179 maxHeight: cap.height,
180 interactive,
181 },
182 {
183 onFrame: (jpeg) => painter.paint(jpeg),
184 onPointer: showAgentCursor,
185 onClipboard: (text) => post({ type: "clipboard", text }),
186 onDownload: (download) => post({ type: "download", ...download }),
187 onFileChooser: (chooser) => post({ type: "fileChooser", chooser }),
188 onViewport: (page) => {
189 if (viewport?.width === page.width && viewport.height === page.height) return;
190 viewport = page;
191 post({ type: "viewport", width: page.width, height: page.height });
192 },
193 onProbe: (result) => {
194 if (control?.controller !== "you") return;
195 const current = probe;
196 if (!current || current.x !== result.x || current.y !== result.y) return;
197 probeCache = {
198 clientX: current.clientX,
199 clientY: current.clientY,
200 editable: result.editable,
201 time: performance.now(),
202 };
203 if (current.end === "touching") {
204 current.editable = result.editable;
205 return;
206 }
207 probe = null;
208 if (current.end === "panned") return;
209 if (current.end === "acted" && current.editable === result.editable) return;
210 // Late or corrected answer: Android still raises the keyboard; iOS waits
211 // for the next tap, which can reuse this answer.
212 if (result.editable) input.focus({ preventScroll: true });
213 else input.blur();
214 },
215 onControl: (nextControl) => {
216 const previous = control;
217 control = nextControl;
218 post({ type: "control", ...nextControl });
219 // Taking over hides the agent cursor; the person's own touch is the pointer now.
220 if (nextControl.controller === "you") agentCursor.style.opacity = "0";
221 if (nextControl.controller !== "you") clearInput();
222 else {
223 input.disabled = !interactive;
224 if (interactive && size && previous?.controller !== "you")
225 next.send({ type: "resize", ...size });
226 }
227 },
228 onConnectedChange: (connected) => {
229 if (!connected) {
230 control = null;
231 clearInput();
232 if (streaming) reportStatus("connecting");
233 return;
234 }
235 },
236 onUnauthorized: () => post({ type: "unauthorized" }),
237 onGone: () => post({ type: "gone" }),
238 onHostSetup: (setup) => post({ type: "hostSetup", ...setup }),
239 },
240 );
241 client = next;
242 };
243
244 const measure = () => {
245 resizeTimer = null;
246 const rect = container.getBoundingClientRect();
247 if (rect.width < 1 || rect.height < 1) return;
248 const next = { width: Math.round(rect.width), height: Math.round(rect.height) };
249 if (size?.width === next.width && size.height === next.height) return;
250 size = next;
251 if (interactive) send({ type: "resize", ...next });
252 const ratio = Math.min(window.devicePixelRatio || 1, MAX_PIXEL_RATIO);
253 // The floating player scales a page of any shape into its box, so its cap is square.
254 const side = Math.max(next.width, next.height);
255 const width = Math.round((interactive ? next.width : side) * ratio);
256 const height = Math.round((interactive ? next.height : side) * ratio);
257 if (cap && cap.width >= width && cap.height >= height) return;
258 cap = { width: Math.max(width, cap?.width ?? 0), height: Math.max(height, cap?.height ?? 0) };
259 connect();
260 };
261 const observer = new ResizeObserver(() => {
262 if (resizeTimer !== null) clearTimeout(resizeTimer);
263 // The first size connects right away; later ones settle before resizing the page.
264 resizeTimer = setTimeout(measure, size === null ? 0 : RESIZE_DEBOUNCE_MS);
265 });
266 observer.observe(container);
267
268 // iOS keeps the layout viewport under the soft keyboard; follow the visible area
269 // so the page resizes above it.
270 const visualViewport = window.visualViewport;
271 const followVisualViewport = () => {
272 if (!visualViewport) return;
273 container.style.top = `${visualViewport.offsetTop}px`;
274 container.style.height = `${visualViewport.height}px`;
275 };
276 if (interactive) {
277 visualViewport?.addEventListener("resize", followVisualViewport);
278 visualViewport?.addEventListener("scroll", followVisualViewport);
279 }
280
281 let agentCursorTimer: ReturnType<typeof setTimeout> | null = null;
282 /** Mirrors the frame's letterbox so the cursor lands on the element the agent targets. */
283 const showAgentCursor = (pointer: PreviewStreamPointer) => {
284 if (!viewport || canvas.width === 0 || canvas.height === 0 || control?.controller === "you")
285 return;
286 const box = { width: canvas.clientWidth, height: canvas.clientHeight };
287 const fit = Math.min(box.width / canvas.width, box.height / canvas.height);
288 const width = canvas.width * fit;
289 const height = canvas.height * fit;
290 const left = (box.width - width) / 2 + (pointer.x * width) / viewport.width;
291 const top = (box.height - height) / 2 + (pointer.y * height) / viewport.height;
292 agentCursor.style.transform = `translate3d(${left}px, ${top}px, 0)`;
293 agentCursor.style.opacity = "1";
294 if (pointer.phase === "click") {
295 agentCursor.querySelector(".ping")?.remove();
296 const ping = document.createElement("span");
297 ping.className = "ping";
298 agentCursor.prepend(ping);
299 }
300 if (agentCursorTimer !== null) clearTimeout(agentCursorTimer);
301 agentCursorTimer = setTimeout(() => {
302 agentCursor.style.opacity = control?.controller === "agent" ? "0.35" : "0";
303 }, AGENT_CURSOR_ACTIVE_MS);
304 };
305
306 const pagePoint = (clientX: number, clientY: number, clamp: boolean) => {
307 if (!viewport || canvas.width === 0 || canvas.height === 0 || control?.controller !== "you")
308 return null;
309 const rect = canvas.getBoundingClientRect();
310 // `object-fit: contain` letterboxes the frame inside the canvas box.
311 const fit = Math.min(rect.width / canvas.width, rect.height / canvas.height);
312 const width = canvas.width * fit;
313 const height = canvas.height * fit;
314 if (!(width > 0 && height > 0)) return null;
315 const scale = viewport.width / width;
316 const x = (clientX - rect.left - (rect.width - width) / 2) * scale;
317 const y = (clientY - rect.top - (rect.height - height) / 2) * (viewport.height / height);
318 const inside = x >= 0 && y >= 0 && x <= viewport.width && y <= viewport.height;
319 if (!inside && !clamp) return null;
320 return {
321 x: Math.min(Math.max(x, 0), viewport.width),
322 y: Math.min(Math.max(y, 0), viewport.height),
323 scale,
324 };
325 };
326
327 // Wheel deltas coalesce to one message per animation frame.
328 const flushWheel = () => {
329 if (wheelFrame !== null) cancelAnimationFrame(wheelFrame);
330 wheelFrame = null;
331 const wheel = pendingWheel;
332 pendingWheel = null;
333 if (wheel) send(wheel);
334 };
335 const queueWheel = (point: { x: number; y: number }, deltaX: number, deltaY: number) => {
336 pendingWheel = {
337 type: "wheel",
338 x: point.x,
339 y: point.y,
340 deltaX: (pendingWheel?.deltaX ?? 0) + deltaX,
341 deltaY: (pendingWheel?.deltaY ?? 0) + deltaY,
342 modifiers: 0,
343 };
344 wheelFrame ??= requestAnimationFrame(flushWheel);
345 };
346
347 const flushMouse = () => {
348 if (mouseFrame !== null) cancelAnimationFrame(mouseFrame);
349 mouseFrame = null;
350 if (pendingMouse) send(pendingMouse);
351 pendingMouse = null;
352 };
353 const countClick = (event: PointerEvent, button: MouseInput["button"]) => {
354 const last = lastTap;
355 const count =
356 last &&
357 last.button === button &&
358 event.timeStamp - last.time < MULTI_CLICK_MS &&
359 Math.hypot(event.clientX - last.x, event.clientY - last.y) < MULTI_CLICK_SLOP_PX
360 ? last.count + 1
361 : 1;
362 lastTap = { time: event.timeStamp, x: event.clientX, y: event.clientY, count, button };
363 return count;
364 };
365 const mouseInput = (
366 event: PointerEvent,
367 point: { x: number; y: number },
368 action: MouseInput["action"],
369 button: MouseInput["button"],
370 buttons = event.buttons,
371 clickCount = mouseClicks[button],
372 ): MouseInput => ({
373 type: "mouse",
374 action,
375 x: point.x,
376 y: point.y,
377 button,
378 buttons,
379 clickCount,
380 modifiers: previewStreamModifiers(event),
381 });
382 const onPointerDown = (event: PointerEvent) => {
383 if (control?.controller !== "you") return;
384 if (!event.isPrimary) return;
385 event.preventDefault();
386 if (event.pointerType !== "touch") {
387 const point = pagePoint(event.clientX, event.clientY, false);
388 if (!point) return;
389 canvas.setPointerCapture(event.pointerId);
390 input.focus({ preventScroll: true });
391 flushMouse();
392 flushWheel();
393 mouseButtons = event.buttons & 7;
394 const button = mouseButton(event.button);
395 mouseClicks[button] = countClick(event, button);
396 send(mouseInput(event, point, "down", button));
397 return;
398 }
399 touch = { start: event, last: event, panning: false };
400 const point = pagePoint(event.clientX, event.clientY, false);
401 if (!point) {
402 probe = null;
403 return;
404 }
405 const cached = probeCache;
406 const reuse =
407 cached !== null &&
408 performance.now() - cached.time < PROBE_REUSE_MS &&
409 Math.hypot(event.clientX - cached.clientX, event.clientY - cached.clientY) <= PROBE_REUSE_PX;
410 probe = {
411 x: point.x,
412 y: point.y,
413 clientX: event.clientX,
414 clientY: event.clientY,
415 editable: reuse ? cached.editable : null,
416 end: "touching",
417 };
418 // Sent even with a cached answer, so the next tap reuses a fresh one.
419 send({ type: "probe", x: point.x, y: point.y });
420 };
421 const onPointerMove = (event: PointerEvent) => {
422 if (control?.controller !== "you") return;
423 if (event.pointerType !== "touch") {
424 const point = pagePoint(event.clientX, event.clientY, mouseButtons !== 0);
425 if (!point) return;
426 // Chorded presses and releases arrive as pointermove while another button is held.
427 const changed = mouseButtons ^ (event.buttons & 7);
428 if (mouseButtons !== 0 && changed !== 0) {
429 flushMouse();
430 flushWheel();
431 for (const bit of [1, 2, 4]) {
432 if (!(changed & bit)) continue;
433 const button = bit === 1 ? "left" : bit === 2 ? "right" : "middle";
434 const down = (event.buttons & bit) !== 0;
435 mouseButtons ^= bit;
436 if (down) mouseClicks[button] = countClick(event, button);
437 send(mouseInput(event, point, down ? "down" : "up", button, mouseButtons));
438 }
439 }
440 const button =
441 mouseButtons & 1
442 ? "left"
443 : mouseButtons & 2
444 ? "right"
445 : mouseButtons & 4
446 ? "middle"
447 : "none";
448 pendingMouse = mouseInput(event, point, "move", button, event.buttons, 0);
449 mouseFrame ??= requestAnimationFrame(flushMouse);
450 return;
451 }
452 if (!touch || touch.start.pointerId !== event.pointerId) return;
453 if (
454 !touch.panning &&
455 Math.hypot(event.clientX - touch.start.clientX, event.clientY - touch.start.clientY) <
456 TAP_SLOP_PX
457 ) {
458 return;
459 }
460 touch.panning = true;
461 const point = pagePoint(event.clientX, event.clientY, true);
462 // Dragging the page up scrolls it down, following the finger.
463 if (point) {
464 queueWheel(
465 point,
466 (touch.last.clientX - event.clientX) * point.scale,
467 (touch.last.clientY - event.clientY) * point.scale,
468 );
469 }
470 touch.last = event;
471 };
472 const releaseMouse = (event: PointerEvent, cancelled: boolean) => {
473 let buttons = mouseButtons;
474 if (buttons === 0) return;
475 mouseButtons = cancelled ? 0 : event.buttons & 7;
476 flushMouse();
477 flushWheel();
478 const point = pagePoint(event.clientX, event.clientY, true);
479 if (!point) return;
480 for (const bit of [1, 2, 4]) {
481 if (!(buttons & bit) || (mouseButtons & bit) !== 0) continue;
482 buttons &= ~bit;
483 const button = bit === 1 ? "left" : bit === 2 ? "right" : "middle";
484 send(mouseInput(event, point, "up", button, buttons));
485 }
486 };
487 const onPointerEnd = (event: PointerEvent) => {
488 const cancelled = event.type === "pointercancel";
489 if (event.pointerType !== "touch") {
490 releaseMouse(event, cancelled);
491 return;
492 }
493 const ended = touch;
494 if (!ended || ended.start.pointerId !== event.pointerId) return;
495 touch = null;
496 const answered = probe;
497 if (cancelled || ended.panning) {
498 if (answered) answered.end = "panned";
499 return;
500 }
501 const point = pagePoint(event.clientX, event.clientY, false);
502 if (!point) return;
503 // Focusing inside the tap's user activation is what lets iOS raise the keyboard.
504 if (answered?.editable === true) input.focus({ preventScroll: true });
505 else if (answered?.editable === false) input.blur();
506 if (answered) answered.end = answered.editable === null ? "late" : "acted";
507 flushWheel();
508 const clickCount = countClick(event, "left");
509 const at = { x: point.x, y: point.y, modifiers: 0 };
510 send({ type: "mouse", action: "move", ...at, button: "none", buttons: 0, clickCount: 0 });
511 send({ type: "mouse", action: "down", ...at, button: "left", buttons: 1, clickCount });
512 send({ type: "mouse", action: "up", ...at, button: "left", buttons: 0, clickCount });
513 };
514 // Trackpads and mice on tablets scroll with wheel events.
515 const onWheel = (event: WheelEvent) => {
516 const point = pagePoint(event.clientX, event.clientY, false);
517 if (!point) return;
518 event.preventDefault();
519 const unit =
520 event.deltaMode === WheelEvent.DOM_DELTA_LINE
521 ? WHEEL_LINE_PX
522 : event.deltaMode === WheelEvent.DOM_DELTA_PAGE
523 ? (viewport?.height ?? 0)
524 : 1;
525 queueWheel(point, event.deltaX * unit, event.deltaY * unit);
526 };
527 // Keeps focus in the page input and stops native selection and callouts.
528 const preventDefault = (event: Event) => event.preventDefault();
529
530 const onKey = (action: "down" | "up", event: KeyboardEvent) => {
531 if (control?.controller !== "you") return;
532 // IME and soft keyboards deliver text through composition and input events.
533 if (
534 event.isComposing ||
535 event.keyCode === 229 ||
536 event.key === "Process" ||
537 event.key === "Unidentified"
538 ) {
539 return;
540 }
541 const shortcut = event.ctrlKey || event.metaKey;
542 // Paste arrives as input text from this device's clipboard. Copy and cut run in
543 // the page, which sends the copied text back. Shift+Insert is the same paste.
544 if (shortcut && event.key.toLowerCase() === "v") return;
545 if (event.shiftKey && event.key === "Insert") return;
546 // Enter carries "\r" like Puppeteer's key table, so forms submit and textareas break lines.
547 const text = shortcut
548 ? undefined
549 : [...event.key].length === 1
550 ? event.key
551 : event.key === "Enter"
552 ? "\r"
553 : undefined;
554 send({
555 type: "key",
556 action,
557 key: event.key,
558 code: event.code,
559 keyCode: event.keyCode,
560 ...(action === "down" && text !== undefined ? { text } : {}),
561 modifiers: previewStreamModifiers(event),
562 });
563 // Some Android keyboards edit the textarea even when keydown is prevented.
564 // Shortcuts keep their default, so a paste still arrives as text.
565 keySent =
566 action === "down" &&
567 !shortcut &&
568 (text !== undefined || event.key === "Backspace" || event.key === "Delete");
569 if (!shortcut) event.preventDefault();
570 };
571 const resetInput = () => {
572 input.value = SENTINEL;
573 input.setSelectionRange(SENTINEL.length, SENTINEL.length);
574 };
575 const pressKey = (key: "Backspace" | "Delete", keyCode: number) => {
576 for (const action of ["down", "up"] as const) {
577 send({ type: "key", action, key, code: key, keyCode, modifiers: 0 });
578 }
579 };
580 const onInput = (event: Event) => {
581 if (event instanceof InputEvent && event.isComposing) return;
582 const inputType = event instanceof InputEvent ? event.inputType : "";
583 if (keySent) keySent = false;
584 else if (inputType === "deleteContentBackward") pressKey("Backspace", 8);
585 else if (inputType === "deleteContentForward") pressKey("Delete", 46);
586 else {
587 const value = input.value;
588 const text = value.startsWith(SENTINEL) ? value.slice(SENTINEL.length) : value;
589 if (text) send({ type: "text", text });
590 }
591 resetInput();
592 };
593 const onCompositionEnd = (event: CompositionEvent) => {
594 const text = event.data.startsWith(SENTINEL) ? event.data.slice(SENTINEL.length) : event.data;
595 if (text) send({ type: "text", text });
596 resetInput();
597 };
598
599 if (interactive) {
600 canvas.addEventListener("pointerdown", onPointerDown);
601 canvas.addEventListener("pointermove", onPointerMove);
602 canvas.addEventListener("pointerup", onPointerEnd);
603 canvas.addEventListener("pointercancel", onPointerEnd);
604 canvas.addEventListener("wheel", onWheel, { passive: false });
605 canvas.addEventListener("mousedown", preventDefault);
606 canvas.addEventListener("contextmenu", preventDefault);
607 input.addEventListener("keydown", (event) => onKey("down", event));
608 input.addEventListener("keyup", (event) => onKey("up", event));
609 input.addEventListener("input", onInput);
610 input.addEventListener("compositionend", onCompositionEnd);
611 input.addEventListener("focus", resetInput);
612 // Copying the input would put its sentinel on this device's clipboard.
613 input.addEventListener("copy", preventDefault);
614 input.addEventListener("cut", preventDefault);
615 resetInput();
616 }
617
618 // WebKit reports no support on a video that has not loaded yet; check the prototype.
619 const videoPrototype: PresentationVideo = HTMLVideoElement.prototype;
620 const pictureInPictureSupported =
621 interactive &&
622 typeof canvas.captureStream === "function" &&
623 ((document.pictureInPictureEnabled === true &&
624 typeof videoPrototype.requestPictureInPicture === "function") ||
625 typeof videoPrototype.webkitSetPresentationMode === "function");
626 let video: PresentationVideo | null = null;
627 const pictureInPictureActive = () =>
628 video !== null &&
629 (document.pictureInPictureElement === video ||
630 video.webkitPresentationMode === "picture-in-picture");
631 const reportPictureInPicture = (detail?: string) =>
632 post({
633 type: "pictureInPicture",
634 supported: pictureInPictureSupported,
635 active: pictureInPictureActive(),
636 ...(detail ? { detail } : {}),
637 });
638 const ensureVideo = () => {
639 if (video) return video;
640 const element: PresentationVideo = document.createElement("video");
641 element.muted = true;
642 element.playsInline = true;
643 element.autoplay = true;
644 // Under the canvas at full size: WebKit pauses muted video it considers off screen.
645 element.srcObject = canvas.captureStream();
646 // A static page sends no new frames; repaint once so the stream has one.
647 if (canvas.width > 0 && canvas.height > 0) canvas.getContext("2d")?.drawImage(canvas, 0, 0);
648 for (const name of [
649 "enterpictureinpicture",
650 "leavepictureinpicture",
651 "webkitpresentationmodechanged",
652 ]) {
653 element.addEventListener(name, () => {
654 if (!pictureInPictureActive()) element.pause();
655 reportPictureInPicture();
656 });
657 }
658 container.prepend(element);
659 video = element;
660 return element;
661 };
662 const togglePictureInPicture = async () => {
663 if (!pictureInPictureSupported) return;
664 try {
665 if (pictureInPictureActive()) {
666 if (document.pictureInPictureElement) await document.exitPictureInPicture();
667 else video?.webkitSetPresentationMode?.("inline");
668 return;
669 }
670 const element = ensureVideo();
671 await element.play();
672 if (document.pictureInPictureEnabled) await element.requestPictureInPicture();
673 else element.webkitSetPresentationMode?.("picture-in-picture");
674 } catch (error) {
675 reportPictureInPicture(
676 error instanceof Error ? error.message : "Picture in picture is unavailable.",
677 );
678 }
679 };
680
681 activeViewer = {
682 stop: () => {
683 stopped = true;
684 observer.disconnect();
685 visualViewport?.removeEventListener("resize", followVisualViewport);
686 visualViewport?.removeEventListener("scroll", followVisualViewport);
687 if (resizeTimer !== null) clearTimeout(resizeTimer);
688 if (wheelFrame !== null) cancelAnimationFrame(wheelFrame);
689 if (mouseFrame !== null) cancelAnimationFrame(mouseFrame);
690 if (agentCursorTimer !== null) clearTimeout(agentCursorTimer);
691 painter.stop();
692 client?.stop();
693 client = null;
694 if (video) {
695 for (const track of video.srcObject instanceof MediaStream
696 ? video.srcObject.getTracks()
697 : []) {
698 track.stop();
699 }
700 video.srcObject = null;
701 }
702 },
703 command: (message) => {
704 send(message);
705 },
706 togglePictureInPicture,
707 };
708 window.addEventListener("pagehide", stop, { once: true });
709 reportStatus("connecting");
710 if (interactive) reportPictureInPicture();
711}
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+36
...@@ -95,6 +95,8 @@ import { editPendingThreadMessage } from "../../state/edit-pending-thread-messag...@@ -95,6 +95,8 @@ import { editPendingThreadMessage } from "../../state/edit-pending-thread-messag
95import { deviceEnvironment } from "../../state/device";95import { deviceEnvironment } from "../../state/device";
96import { useEnvironmentQuery } from "../../state/query";96import { useEnvironmentQuery } from "../../state/query";
97import { threadDevicePreviews } from "../devices/threadDevicePreviews";97import { threadDevicePreviews } from "../devices/threadDevicePreviews";
98import { ThreadBrowserFloat } from "../browser/ThreadBrowserFloat";
99import { useThreadServerBrowserTabs } from "../../state/preview";
98import type { QueuedThreadMessage } from "../../state/thread-outbox-model";100import type { QueuedThreadMessage } from "../../state/thread-outbox-model";
99import { scopedThreadKey } from "../../lib/scopedEntities";101import { scopedThreadKey } from "../../lib/scopedEntities";
100import {102import {
...@@ -333,6 +335,22 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -333,6 +335,22 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
333 threadId: props.selectedThread.id,335 threadId: props.selectedThread.id,
334 });336 });
335 }, [navigation, props.environmentId, props.selectedThread.id]);337 }, [navigation, props.environmentId, props.selectedThread.id]);
338 const browserTabs = useThreadServerBrowserTabs({
339 environmentId: props.environmentId,
340 threadId: props.selectedThread.id,
341 enabled: props.serverConfig?.environment.capabilities.serverBrowser === true,
342 });
343 const openBrowserPreview = useCallback(
344 (tabId?: string) => {
345 Keyboard.dismiss();
346 navigation.navigate("ThreadBrowserPreview", {
347 environmentId: props.environmentId,
348 threadId: props.selectedThread.id,
349 ...(tabId === undefined ? {} : { tabId }),
350 });
351 },
352 [navigation, props.environmentId, props.selectedThread.id],
353 );
336 const insets = useSafeAreaInsets();354 const insets = useSafeAreaInsets();
337 const isKeyboardVisible = useKeyboardState((state) => state.isVisible);355 const isKeyboardVisible = useKeyboardState((state) => state.isVisible);
338 const liveKeyboardHeight = useKeyboardState((state) => state.height);356 const liveKeyboardHeight = useKeyboardState((state) => state.height);
...@@ -512,6 +530,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -512,6 +530,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
512 queuedCount > 0 ||530 queuedCount > 0 ||
513 agentsSegment !== null ||531 agentsSegment !== null ||
514 devicePreviews.length > 0 ||532 devicePreviews.length > 0 ||
533 browserTabs.tabs.length > 0 ||
515 props.connectionStateLabel !== "connected" ||534 props.connectionStateLabel !== "connected" ||
516 props.queuedMessages.length > 0 ||535 props.queuedMessages.length > 0 ||
517 props.selectedThreadFeed.some(536 props.selectedThreadFeed.some(
...@@ -1129,6 +1148,18 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -1129,6 +1148,18 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
1129 <View className="flex-1" />1148 <View className="flex-1" />
1130 )}1149 )}
11311150
1151 {showContent ? (
1152 <ThreadBrowserFloat
1153 key={selectedThreadKey}
1154 environmentId={props.environmentId}
1155 threadId={props.selectedThread.id}
1156 tabs={browserTabs.tabs}
1157 loaded={browserTabs.loaded}
1158 top={navigationHeaderHeight + 8}
1159 onOpen={openBrowserPreview}
1160 />
1161 ) : null}
1162
1132 {/* Floating composer — sticks to keyboard via KeyboardStickyView */}1163 {/* Floating composer — sticks to keyboard via KeyboardStickyView */}
1133 {showContent ? (1164 {showContent ? (
1134 <KeyboardStickyView1165 <KeyboardStickyView
...@@ -1161,6 +1192,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -1161,6 +1192,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
1161 ? { count: devicePreviews.length, onPress: openDevicePreview }1192 ? { count: devicePreviews.length, onPress: openDevicePreview }
1162 : null1193 : null
1163 }1194 }
1195 browserPreview={
1196 browserTabs.tabs.length > 0
1197 ? { count: browserTabs.tabs.length, onPress: () => openBrowserPreview() }
1198 : null
1199 }
1164 showScrollToEnd={showScrollToEndButton}1200 showScrollToEnd={showScrollToEndButton}
1165 onScrollToEnd={handleScrollToEnd}1201 onScrollToEnd={handleScrollToEnd}
1166 agents={agentsSegment}1202 agents={agentsSegment}
apps/mobile/src/features/threads/floating-working-control.tsx+23-13
...@@ -26,6 +26,7 @@ import { AppText as Text } from "../../components/AppText";...@@ -26,6 +26,7 @@ import { AppText as Text } from "../../components/AppText";
26import { SymbolView } from "../../components/AppSymbol";26import { SymbolView } from "../../components/AppSymbol";
27import { ControlPill } from "../../components/ControlPill";27import { ControlPill } from "../../components/ControlPill";
28import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";28import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
29import { BrowserPreviewButton } from "../browser/browser-preview-button";
29import { DevicePreviewButton } from "../devices/device-preview-button";30import { DevicePreviewButton } from "../devices/device-preview-button";
30import type { FloatingWorkingStatus } from "./floating-working-status";31import type { FloatingWorkingStatus } from "./floating-working-status";
31import { ShimmeringWorkContent } from "./thread-work-log";32import { ShimmeringWorkContent } from "./thread-work-log";
...@@ -67,6 +68,7 @@ export function FloatingWorkingControl(props: {...@@ -67,6 +68,7 @@ export function FloatingWorkingControl(props: {
67 readonly colorScheme: "light" | "dark";68 readonly colorScheme: "light" | "dark";
68 readonly status: FloatingWorkingStatus | null;69 readonly status: FloatingWorkingStatus | null;
69 readonly devicePreview: { readonly count: number; readonly onPress: () => void } | null;70 readonly devicePreview: { readonly count: number; readonly onPress: () => void } | null;
71 readonly browserPreview: { readonly count: number; readonly onPress: () => void } | null;
70 readonly showScrollToEnd: boolean;72 readonly showScrollToEnd: boolean;
71 readonly onScrollToEnd: () => void;73 readonly onScrollToEnd: () => void;
72 readonly agents: SubagentPillSegment | null;74 readonly agents: SubagentPillSegment | null;
...@@ -81,8 +83,8 @@ export function FloatingWorkingControl(props: {...@@ -81,8 +83,8 @@ export function FloatingWorkingControl(props: {
81 const [queueWidth, setQueueWidth] = useState(0);83 const [queueWidth, setQueueWidth] = useState(0);
82 const [agentsWidth, setAgentsWidth] = useState(0);84 const [agentsWidth, setAgentsWidth] = useState(0);
83 const hasQueue = props.queuedCount > 0;85 const hasQueue = props.queuedCount > 0;
84 const hasDevicePreview = props.devicePreview !== null;86 const hasPreview = props.devicePreview !== null || props.browserPreview !== null;
85 const [deviceWidth, setDeviceWidth] = useState(0);87 const [previewWidth, setPreviewWidth] = useState(0);
86 const agents = props.agents;88 const agents = props.agents;
87 const hasAgents = agents !== null;89 const hasAgents = agents !== null;
88 // Segments keep their measured width; only the status label absorbs the90 // Segments keep their measured width; only the status label absorbs the
...@@ -94,7 +96,7 @@ export function FloatingWorkingControl(props: {...@@ -94,7 +96,7 @@ export function FloatingWorkingControl(props: {
94 32 -96 32 -
95 (hasQueue ? queueWidth : 0) -97 (hasQueue ? queueWidth : 0) -
96 (hasAgents ? agentsWidth : 0) -98 (hasAgents ? agentsWidth : 0) -
97 (hasDevicePreview ? deviceWidth : 0),99 (hasPreview ? previewWidth : 0),
98 );100 );
99 const separationProgress = useSharedValue(props.showScrollToEnd ? 1 : 0);101 const separationProgress = useSharedValue(props.showScrollToEnd ? 1 : 0);
100102
...@@ -131,7 +133,7 @@ export function FloatingWorkingControl(props: {...@@ -131,7 +133,7 @@ export function FloatingWorkingControl(props: {
131 // Forget the width while no label is shown so the next one appears at its133 // Forget the width while no label is shown so the next one appears at its
132 // own size instead of animating from the previous label's.134 // own size instead of animating from the previous label's.
133 const hasStatus = props.status !== null;135 const hasStatus = props.status !== null;
134 const hasCapsule = hasStatus || hasQueue || hasAgents || hasDevicePreview;136 const hasCapsule = hasStatus || hasQueue || hasAgents || hasPreview;
135 useEffect(() => {137 useEffect(() => {
136 if (!hasStatus) {138 if (!hasStatus) {
137 measuredWidthRef.current = null;139 measuredWidthRef.current = null;
...@@ -151,9 +153,15 @@ export function FloatingWorkingControl(props: {...@@ -151,9 +153,15 @@ export function FloatingWorkingControl(props: {
151 return null;153 return null;
152 }154 }
153155
156 // Both preview buttons fit beside each other only as icons.
157 const previewCompact =
158 hasStatus ||
159 hasAgents ||
160 hasQueue ||
161 (props.devicePreview !== null && props.browserPreview !== null);
154 // The queue, agents, and reconnect labels have separate tap targets.162 // The queue, agents, and reconnect labels have separate tap targets.
155 const statusInteractive = props.status?.kind === "connection";163 const statusInteractive = props.status?.kind === "connection";
156 const capsuleInteractive = statusInteractive || hasQueue || hasAgents || hasDevicePreview;164 const capsuleInteractive = statusInteractive || hasQueue || hasAgents || hasPreview;
157 // The host stays centered on the capsule, but its measurement constraint165 // The host stays centered on the capsule, but its measurement constraint
158 // comes from the overlay, independent of the capsule's current width.166 // comes from the overlay, independent of the capsule's current width.
159 const statusContent =167 const statusContent =
...@@ -184,16 +192,18 @@ export function FloatingWorkingControl(props: {...@@ -184,16 +192,18 @@ export function FloatingWorkingControl(props: {
184 const capsuleContent = (192 const capsuleContent = (
185 <View className="flex-row items-center">193 <View className="flex-row items-center">
186 {statusContent}194 {statusContent}
187 {props.devicePreview !== null ? (195 {hasPreview ? (
188 <View196 <View
189 className="h-11 flex-row items-center"197 className="h-11 flex-row items-center"
190 onLayout={(event) => setDeviceWidth(event.nativeEvent.layout.width)}198 onLayout={(event) => setPreviewWidth(event.nativeEvent.layout.width)}
191 >199 >
192 {hasStatus ? <View className="h-4 w-px bg-border" /> : null}200 {hasStatus ? <View className="h-4 w-px bg-border" /> : null}
193 <DevicePreviewButton201 {props.devicePreview !== null ? (
194 {...props.devicePreview}202 <DevicePreviewButton {...props.devicePreview} compact={previewCompact} />
195 compact={hasStatus || hasAgents || hasQueue}203 ) : null}
196 />204 {props.browserPreview !== null ? (
205 <BrowserPreviewButton {...props.browserPreview} compact={previewCompact} />
206 ) : null}
197 </View>207 </View>
198 ) : null}208 ) : null}
199 {agents !== null ? (209 {agents !== null ? (
...@@ -205,7 +215,7 @@ export function FloatingWorkingControl(props: {...@@ -205,7 +215,7 @@ export function FloatingWorkingControl(props: {
205 onLayout={(event) => setAgentsWidth(event.nativeEvent.layout.width)}215 onLayout={(event) => setAgentsWidth(event.nativeEvent.layout.width)}
206 className="h-11 flex-row items-center gap-1.5 px-3 active:opacity-70"216 className="h-11 flex-row items-center gap-1.5 px-3 active:opacity-70"
207 >217 >
208 {hasStatus || hasDevicePreview ? <View className="mr-1 h-4 w-px bg-border" /> : null}218 {hasStatus || hasPreview ? <View className="mr-1 h-4 w-px bg-border" /> : null}
209 <SymbolView name="person.2" size={13} tintColorClassName="accent-foreground-muted" />219 <SymbolView name="person.2" size={13} tintColorClassName="accent-foreground-muted" />
210 <Text className="font-t3-medium text-xs tabular-nums" numberOfLines={1}>220 <Text className="font-t3-medium text-xs tabular-nums" numberOfLines={1}>
211 {agents.label}221 {agents.label}
...@@ -222,7 +232,7 @@ export function FloatingWorkingControl(props: {...@@ -222,7 +232,7 @@ export function FloatingWorkingControl(props: {
222 style={{ maxWidth: Math.min(overlayWidth, windowWidth) * 0.45 }}232 style={{ maxWidth: Math.min(overlayWidth, windowWidth) * 0.45 }}
223 className="h-11 flex-row items-center gap-2 px-3 active:opacity-70"233 className="h-11 flex-row items-center gap-2 px-3 active:opacity-70"
224 >234 >
225 {hasStatus || hasDevicePreview || hasAgents ? (235 {hasStatus || hasPreview || hasAgents ? (
226 <View className="mr-1 h-4 w-px bg-border" />236 <View className="mr-1 h-4 w-px bg-border" />
227 ) : null}237 ) : null}
228 <SymbolView name="list.number" size={13} tintColorClassName="accent-foreground-muted" />238 <SymbolView name="list.number" size={13} tintColorClassName="accent-foreground-muted" />
apps/mobile/src/state/preview.ts created+166
...@@ -0,0 +1,166 @@
1import { useAtomValue } from "@effect/atom-react";
2import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime/environment";
3import { PREVIEW_STREAM_BASE_PATH } from "@t3tools/client-runtime/preview/server-browser-stream";
4import { createPreviewEnvironmentAtoms } from "@t3tools/client-runtime/state/preview";
5import { resolveDeviceHubAccess } from "@t3tools/client-runtime/state/deviceHubAccess";
6import type {
7 EnvironmentId,
8 PreviewEvent,
9 PreviewListResult,
10 PreviewSessionSnapshot,
11 ThreadId,
12} from "@t3tools/contracts";
13import * as Effect from "effect/Effect";
14import * as Option from "effect/Option";
15import { AsyncResult, Atom } from "effect/reactivity";
16import { useMemo } from "react";
17
18import { connectionAtomRuntime } from "../connection/runtime";
19import { useEnvironmentQuery } from "./query";
20import { environmentSession, usePreparedConnection } from "./session";
21
22export const previewEnvironment = createPreviewEnvironmentAtoms(connectionAtomRuntime);
23
24interface ThreadPreviewTabs {
25 readonly serverEpoch: string | null;
26 readonly revision: number;
27 readonly sessions: ReadonlyArray<PreviewSessionSnapshot>;
28 /** A list result has arrived, so `sessions` covers tabs opened before this view. */
29 readonly listed: boolean;
30}
31
32const EMPTY_TABS: ThreadPreviewTabs = {
33 serverEpoch: null,
34 revision: 0,
35 sessions: [],
36 listed: false,
37};
38const emptyTabsAtom = Atom.make(EMPTY_TABS).pipe(Atom.withLabel("mobile-preview-tabs:empty"));
39
40const MAX_REPLAY_EVENTS = 200;
41
42function applyEvent(current: ThreadPreviewTabs, event: PreviewEvent): ThreadPreviewTabs {
43 if (event.revision <= current.revision) return current;
44 const index = current.sessions.findIndex((session) => session.tabId === event.tabId);
45 const sessions = [...current.sessions];
46 if (event.type === "closed") {
47 if (index !== -1) sessions.splice(index, 1);
48 } else if (event.type === "failed") {
49 const existing = sessions[index];
50 if (existing) {
51 sessions[index] = {
52 ...existing,
53 navStatus: {
54 _tag: "LoadFailed",
55 url: event.url,
56 title: event.title,
57 code: event.code,
58 description: event.description,
59 },
60 updatedAt: event.createdAt,
61 };
62 }
63 } else {
64 // Keep a tab's place so the picker order is stable.
65 sessions[index === -1 ? sessions.length : index] = event.snapshot;
66 }
67 return { ...current, serverEpoch: event.serverEpoch, revision: event.revision, sessions };
68}
69
70const threadPreviewTabsAtom = Atom.family((threadKey: string) => {
71 const ref = parseScopedThreadKey(threadKey);
72 if (ref === null) return emptyTabsAtom;
73 const listAtom = previewEnvironment.list({
74 environmentId: ref.environmentId,
75 input: { threadId: ref.threadId },
76 });
77 const eventsAtom = previewEnvironment.events({ environmentId: ref.environmentId, input: {} });
78 return Atom.make((get) => {
79 let disposed = false;
80 let state = EMPTY_TABS;
81 // Lists can land after events they predate; replay newer events on each list.
82 let list: PreviewListResult | null = null;
83 let events: ReadonlyArray<PreviewEvent> = [];
84 const publish = (next: ThreadPreviewTabs) => {
85 if (next === state) return;
86 state = next;
87 get.setSelf(next);
88 };
89 const applyList = (result: PreviewListResult) => {
90 if (list?.serverEpoch === result.serverEpoch && result.revision < list.revision) return;
91 list = result;
92 events = events.filter(
93 (event) => event.serverEpoch === result.serverEpoch && event.revision > result.revision,
94 );
95 return events.reduce<ThreadPreviewTabs>(applyEvent, { ...result, listed: true });
96 };
97 get.addFinalizer(() => {
98 disposed = true;
99 });
100 get.subscribe(listAtom, (result) => {
101 if (!AsyncResult.isSuccess(result)) return;
102 const next = applyList(result.value);
103 if (next) publish(next);
104 });
105 get.subscribe(eventsAtom, (result) => {
106 if (!AsyncResult.isSuccess(result) || result.value.threadId !== ref.threadId) return;
107 const event = result.value;
108 if (list?.serverEpoch === event.serverEpoch && event.revision <= list.revision) return;
109 events = [...events.slice(1 - MAX_REPLAY_EVENTS), event];
110 // A restarted server resets revisions; only a fresh list is authoritative.
111 if (state.serverEpoch !== null && event.serverEpoch !== state.serverEpoch) {
112 get.refresh(listAtom);
113 return;
114 }
115 publish(applyEvent(state, event));
116 });
117 get.mount(listAtom);
118 get.mount(eventsAtom);
119 const cached = get.once(listAtom);
120 if (AsyncResult.isSuccess(cached)) state = applyList(cached.value) ?? state;
121 // The cached list can predate an agent-opened tab.
122 queueMicrotask(() => {
123 if (!disposed) get.refresh(listAtom);
124 });
125 return state;
126 }).pipe(Atom.setIdleTTL(1_000), Atom.withLabel(`mobile-preview-tabs:${threadKey}`));
127});
128
129export function useThreadServerBrowserTabs(input: {
130 readonly environmentId: EnvironmentId;
131 readonly threadId: ThreadId;
132 readonly enabled: boolean;
133}) {
134 const tabs = useAtomValue(
135 input.enabled
136 ? threadPreviewTabsAtom(
137 scopedThreadKey({ environmentId: input.environmentId, threadId: input.threadId }),
138 )
139 : emptyTabsAtom,
140 );
141 const sessions = useMemo(
142 () => tabs.sessions.filter((session) => session.runtime === "server"),
143 [tabs.sessions],
144 );
145 return { tabs: sessions, loaded: tabs.listed };
146}
147
148const previewStreamAccessAtom = Atom.family((environmentId: EnvironmentId) =>
149 connectionAtomRuntime
150 .atom((get) => {
151 const prepared = Option.getOrNull(
152 get(environmentSession.preparedConnectionValueAtom(environmentId)),
153 );
154 return prepared === null
155 ? Effect.never
156 : resolveDeviceHubAccess({ prepared, hubBasePath: PREVIEW_STREAM_BASE_PATH });
157 })
158 .pipe(Atom.setIdleTTL(60_000), Atom.withLabel(`mobile-preview-stream-access:${environmentId}`)),
159);
160
161export function usePreviewStreamAccess(environmentId: EnvironmentId) {
162 const prepared = usePreparedConnection(environmentId);
163 const query = useEnvironmentQuery(previewStreamAccessAtom(environmentId));
164 const access = query.data && query.error === null && Option.isSome(prepared) ? query.data : null;
165 return { access, error: query.error, refresh: query.refresh };
166}
apps/mobile/src/types/mobile-preview-stream.d.ts created+4
...@@ -0,0 +1,4 @@
1declare module "@t3tools/mobile-preview-stream" {
2 const script: string;
3 export default script;
4}
apps/server/package.json+1
...@@ -42,6 +42,7 @@...@@ -42,6 +42,7 @@
42 "effect": "catalog:",42 "effect": "catalog:",
43 "jose": "catalog:",43 "jose": "catalog:",
44 "node-pty": "^1.2.0-beta.15",44 "node-pty": "^1.2.0-beta.15",
45 "playwright-core": "1.60.0",
45 "proper-lockfile": "4.1.2",46 "proper-lockfile": "4.1.2",
46 "stream-chain": "^4.2.5",47 "stream-chain": "^4.2.5",
47 "stream-json": "3.6.0",48 "stream-json": "3.6.0",
apps/server/src/auth/RpcAuthorization.ts+2-3
...@@ -187,13 +187,12 @@ export const RPC_REQUIRED_SCOPES = {...@@ -187,13 +187,12 @@ export const RPC_REQUIRED_SCOPES = {
187 [WS_METHODS.previewOpen]: AuthOrchestrationOperateScope,187 [WS_METHODS.previewOpen]: AuthOrchestrationOperateScope,
188 [WS_METHODS.previewNavigate]: AuthOrchestrationOperateScope,188 [WS_METHODS.previewNavigate]: AuthOrchestrationOperateScope,
189 [WS_METHODS.previewResize]: AuthOrchestrationOperateScope,189 [WS_METHODS.previewResize]: AuthOrchestrationOperateScope,
190 [WS_METHODS.previewAdjust]: AuthOrchestrationOperateScope,
190 [WS_METHODS.previewRefresh]: AuthOrchestrationOperateScope,191 [WS_METHODS.previewRefresh]: AuthOrchestrationOperateScope,
191 [WS_METHODS.previewClose]: AuthOrchestrationOperateScope,192 [WS_METHODS.previewClose]: AuthOrchestrationOperateScope,
192 [WS_METHODS.previewList]: AuthOrchestrationReadScope,193 [WS_METHODS.previewList]: AuthOrchestrationReadScope,
194 [WS_METHODS.previewClearProfile]: AuthOrchestrationOperateScope,
193 [WS_METHODS.previewReportStatus]: AuthOrchestrationOperateScope,195 [WS_METHODS.previewReportStatus]: AuthOrchestrationOperateScope,
194 [WS_METHODS.previewAutomationConnect]: AuthOrchestrationOperateScope,
195 [WS_METHODS.previewAutomationRespond]: AuthOrchestrationOperateScope,
196 [WS_METHODS.previewAutomationFocusHost]: AuthOrchestrationOperateScope,
197 [WS_METHODS.subscribePreviewEvents]: AuthOrchestrationReadScope,196 [WS_METHODS.subscribePreviewEvents]: AuthOrchestrationReadScope,
198 [WS_METHODS.subscribeDiscoveredLocalServers]: AuthOrchestrationReadScope,197 [WS_METHODS.subscribeDiscoveredLocalServers]: AuthOrchestrationReadScope,
199 [WS_METHODS.deviceConfigure]: AuthOrchestrationOperateScope,198 [WS_METHODS.deviceConfigure]: AuthOrchestrationOperateScope,
apps/server/src/auth/http.ts+30
...@@ -157,6 +157,36 @@ export function failEnvironmentNotFound(reason: EnvironmentResourceNotFoundReaso...@@ -157,6 +157,36 @@ export function failEnvironmentNotFound(reason: EnvironmentResourceNotFoundReaso
157 );157 );
158}158}
159159
160/**
161 * `<img>` and WebSocket cannot set headers, so media routes (device hub,
162 * preview stream) authenticate the way the `/ws` upgrade does: a cookie for browser
163 * sessions, or a short-lived `wsTicket` minted over authenticated HTTP for
164 * bearer and DPoP clients. The upgrade authenticator already implements that
165 * fallback order, so it is used for plain requests as well.
166 */
167export const authenticateMediaRequest = (requiredScope: AuthEnvironmentScope) =>
168 Effect.gen(function* () {
169 const request = yield* HttpServerRequest.HttpServerRequest;
170 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
171 const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe(
172 Effect.catch((error) =>
173 Effect.gen(function* () {
174 if (EnvironmentAuth.isServerAuthCredentialError(error)) {
175 return yield* failEnvironmentAuthInvalid(
176 EnvironmentAuth.serverAuthCredentialReason(error),
177 EnvironmentAuth.serverAuthDpopFailureReason(error),
178 );
179 }
180 return yield* failEnvironmentInternal("internal_error", error);
181 }),
182 ),
183 );
184 if (!session.scopes.includes(requiredScope)) {
185 return yield* failEnvironmentScopeRequired(requiredScope);
186 }
187 return session;
188 });
189
160export function failEnvironmentInternal(reason: EnvironmentInternalErrorReason, error?: unknown) {190export function failEnvironmentInternal(reason: EnvironmentInternalErrorReason, error?: unknown) {
161 return Effect.gen(function* () {191 return Effect.gen(function* () {
162 const traceId = yield* currentEnvironmentTraceId;192 const traceId = yield* currentEnvironmentTraceId;
apps/server/src/binCli.ts+2
...@@ -10,6 +10,7 @@ import packageJson from "../package.json" with { type: "json" };...@@ -10,6 +10,7 @@ import packageJson from "../package.json" with { type: "json" };
10import { acpMcpBridgeCommand, acpMcpCallCommand } from "./cli/acpMcpBridge.ts";10import { acpMcpBridgeCommand, acpMcpCallCommand } from "./cli/acpMcpBridge.ts";
11import { authCommand } from "./cli/auth.ts";11import { authCommand } from "./cli/auth.ts";
12import { appCommand } from "./cli/app.ts";12import { appCommand } from "./cli/app.ts";
13import { browserCommand } from "./cli/browser.ts";
13import { connectCommand } from "./cli/connect.ts";14import { connectCommand } from "./cli/connect.ts";
14import { pairCommand } from "./cli/pair.ts";15import { pairCommand } from "./cli/pair.ts";
15import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";16import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";
...@@ -71,6 +72,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) =>...@@ -71,6 +72,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) =>
71 appCommand,72 appCommand,
72 pairCommand,73 pairCommand,
73 authCommand,74 authCommand,
75 browserCommand,
74 projectCommand,76 projectCommand,
75 serviceCommand,77 serviceCommand,
76 updateCommand,78 updateCommand,
apps/server/src/cli/browser.ts created+195
...@@ -0,0 +1,195 @@
1/**
2 * `t3 browser setup` - prepares a Linux host for T3's headless browser, which
3 * server browser tabs and HTML render previews share. It is the fix every
4 * browser host error names, so it does the whole job in one run:
5 *
6 * - installs the AppArmor profile that lets Chrome's sandbox run where the host
7 * restricts unprivileged user namespaces (Ubuntu 23.10+), and
8 * - installs the Debian packages for any libraries the browser cannot load.
9 *
10 * Both need root. Without it, the command prints what it would change and the
11 * `sudo` line to run. It is safe to run again; it skips what is already done.
12 */
13import {
14 HostProcessEnvironment,
15 HostProcessPlatform,
16 HostProcessUserId,
17} from "@t3tools/shared/hostProcess";
18import * as Console from "effect/Console";
19import * as Effect from "effect/Effect";
20import * as FileSystem from "effect/FileSystem";
21import * as Option from "effect/Option";
22import * as Path from "effect/Path";
23import * as Schema from "effect/Schema";
24import { Command } from "effect/cli";
25import * as ChildProcess from "effect/process/ChildProcess";
26import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
27
28import * as PreviewBrowserHost from "../preview/PreviewBrowserHost.ts";
29import { resolveBaseDir } from "../os-jank.ts";
30import { baseDirFlag } from "./config.ts";
31import { resolveRootCliCommand } from "./invocation.ts";
32
33export class BrowserSetupStepError extends Schema.TaggedError<BrowserSetupStepError>()(
34 "BrowserSetupStepError",
35 { step: Schema.String, detail: Schema.String },
36) {
37 override get message(): string {
38 return `Could not ${this.step}: ${this.detail}`;
39 }
40}
41
42/** Runs one setup command, streaming its output, and fails with its exit code. */
43const runStep = Effect.fn("browserSetup.runStep")(function* (
44 step: string,
45 command: string,
46 args: ReadonlyArray<string>,
47) {
48 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
49 const exitCode = yield* spawner
50 .exitCode(
51 ChildProcess.make(command, args, { stdin: "inherit", stdout: "inherit", stderr: "inherit" }),
52 )
53 .pipe(Effect.mapError((cause) => new BrowserSetupStepError({ step, detail: String(cause) })));
54 if (exitCode !== 0) {
55 return yield* new BrowserSetupStepError({ step, detail: `${command} exited with ${exitCode}` });
56 }
57});
58
59/**
60 * The T3 home to check. Under `sudo` the process home is root's, so an
61 * unspecified home falls back to the invoking user's `~/.t3`.
62 */
63const setupBaseDir = Effect.fn("browserSetup.baseDir")(function* (explicit: Option.Option<string>) {
64 const env = yield* HostProcessEnvironment;
65 const raw = Option.getOrUndefined(explicit) ?? env.T3CODE_HOME;
66 if (raw !== undefined || env.SUDO_USER === undefined) return yield* resolveBaseDir(raw);
67 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
68 const entry = yield* spawner
69 .string(
70 ChildProcess.make("getent", ["passwd", env.SUDO_USER], { stdin: "ignore", stderr: "ignore" }),
71 )
72 .pipe(Effect.orElseSucceed(() => ""));
73 const home = entry.trim().split(":")[5];
74 const path = yield* Path.Path;
75 return home ? path.join(home, ".t3") : yield* resolveBaseDir(undefined);
76});
77
78/** Whether apt has an installable candidate for `name`. */
79const aptOffers = Effect.fn("browserSetup.aptOffers")(function* (name: string) {
80 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
81 const policy = yield* spawner
82 .string(ChildProcess.make("apt-cache", ["policy", name], { stdin: "ignore", stderr: "ignore" }))
83 .pipe(Effect.orElseSucceed(() => ""));
84 const candidate = /Candidate:\s*(\S+)/.exec(policy)?.[1];
85 return candidate !== undefined && candidate !== "(none)";
86});
87
88/** The installed browser in this T3 home, if any, to check its libraries. */
89const installedBrowser = Effect.fn("browserSetup.installedBrowser")(function* (baseDir: string) {
90 const fs = yield* FileSystem.FileSystem;
91 const path = yield* Path.Path;
92 const root = path.join(baseDir, "tools", "chrome-headless-shell");
93 for (const platform of yield* fs.readDirectory(root).pipe(Effect.orElseSucceed(() => []))) {
94 for (const version of yield* fs
95 .readDirectory(path.join(root, platform))
96 .pipe(Effect.orElseSucceed(() => []))) {
97 const executable = path.join(root, platform, version, "chrome-headless-shell");
98 if (yield* fs.exists(executable).pipe(Effect.orElseSucceed(() => false))) {
99 return Option.some(executable);
100 }
101 }
102 }
103 return Option.none<string>();
104});
105
106const browserSetupCommand = Command.make("setup", { baseDir: baseDirFlag }).pipe(
107 Command.withDescription(
108 "Set up this Linux host for T3's browser: allow Chrome's sandbox and install its libraries.",
109 ),
110 Command.withHandler(({ baseDir }) =>
111 Effect.gen(function* () {
112 if ((yield* HostProcessPlatform) !== "linux") {
113 return yield* Console.log("Nothing to set up: T3's browser runs as is on this system.");
114 }
115 const fs = yield* FileSystem.FileSystem;
116 const isRoot = (yield* HostProcessUserId) === 0;
117 const setupCommand = yield* resolveRootCliCommand(PreviewBrowserHost.SETUP_SUBCOMMAND);
118
119 const needsProfile = yield* PreviewBrowserHost.sandboxBlocked;
120 const browser = yield* installedBrowser(yield* setupBaseDir(baseDir));
121 const missing = Option.isSome(browser)
122 ? yield* PreviewBrowserHost.missingLibraries(browser.value)
123 : [];
124 const hasApt = yield* fs.exists("/usr/bin/apt-get").pipe(Effect.orElseSucceed(() => false));
125
126 if (!needsProfile && missing.length === 0) {
127 return yield* Console.log(
128 Option.isSome(browser)
129 ? "This host is ready for T3's browser."
130 : "Chrome's sandbox is allowed here. T3's browser installs on first use; if it then reports missing libraries, run this again.",
131 );
132 }
133
134 if (!isRoot) {
135 if (needsProfile) {
136 yield* Console.log(
137 `This host blocks the sandbox T3's browser runs in. Setup installs an AppArmor profile at ${PreviewBrowserHost.APPARMOR_PROFILE_PATH} that allows it.`,
138 );
139 }
140 if (missing.length > 0) {
141 yield* Console.log(`T3's browser is missing ${missing.join(", ")}; setup installs them.`);
142 }
143 return yield* Console.log(`\nThis needs root. Run:\n\n ${setupCommand}\n`);
144 }
145
146 if (needsProfile) {
147 yield* fs
148 .writeFileString(
149 PreviewBrowserHost.APPARMOR_PROFILE_PATH,
150 PreviewBrowserHost.APPARMOR_PROFILE,
151 )
152 .pipe(
153 Effect.mapError(
154 (cause) =>
155 new BrowserSetupStepError({
156 step: `write ${PreviewBrowserHost.APPARMOR_PROFILE_PATH}`,
157 detail: cause.message,
158 }),
159 ),
160 );
161 yield* runStep("load the AppArmor profile", "apparmor_parser", [
162 "-r",
163 PreviewBrowserHost.APPARMOR_PROFILE_PATH,
164 ]);
165 yield* Console.log("Allowed Chrome's sandbox for T3's browser.");
166 }
167
168 if (missing.length > 0) {
169 if (!hasApt) {
170 return yield* Console.log(
171 `T3's browser is missing ${missing.join(", ")}. Install them with your package manager, then run this again.`,
172 );
173 }
174 yield* runStep("refresh the package lists", "apt-get", ["update"]);
175 const packages = yield* Effect.forEach(PreviewBrowserHost.DEBIAN_PACKAGES, (names) =>
176 Effect.findFirst(names, aptOffers).pipe(Effect.map(Option.getOrElse(() => names[0]!))),
177 );
178 yield* runStep("install the browser's libraries", "apt-get", [
179 "install",
180 "-y",
181 "--no-install-recommends",
182 ...packages,
183 ]);
184 yield* Console.log("Installed the browser's libraries.");
185 }
186
187 yield* Console.log("This host is ready for T3's browser.");
188 }),
189 ),
190);
191
192export const browserCommand = Command.make("browser").pipe(
193 Command.withDescription("Manage T3's headless browser on this host."),
194 Command.withSubcommands([browserSetupCommand]),
195);
apps/server/src/cli/config.ts+4
...@@ -362,6 +362,8 @@ export const resolveServerConfig = (...@@ -362,6 +362,8 @@ export const resolveServerConfig = (
362 const desktopBootstrapSecret = bootstrap?.desktopBootstrapSecret;362 const desktopBootstrapSecret = bootstrap?.desktopBootstrapSecret;
363 const desktopTelemetryFd = bootstrap?.desktopTelemetryFd;363 const desktopTelemetryFd = bootstrap?.desktopTelemetryFd;
364 const desktopTelemetryControlFd = bootstrap?.desktopTelemetryControlFd;364 const desktopTelemetryControlFd = bootstrap?.desktopTelemetryControlFd;
365 const desktopBrowserFd = bootstrap?.desktopBrowserFd;
366 const desktopBrowserControlFd = bootstrap?.desktopBrowserControlFd;
365 const resourceMonitorPath = bootstrap?.resourceMonitorPath;367 const resourceMonitorPath = bootstrap?.resourceMonitorPath;
366 const autoBootstrapProjectFromCwd = Option.getOrElse(368 const autoBootstrapProjectFromCwd = Option.getOrElse(
367 resolveOptionPrecedence(369 resolveOptionPrecedence(
...@@ -468,6 +470,8 @@ export const resolveServerConfig = (...@@ -468,6 +470,8 @@ export const resolveServerConfig = (
468 ...(desktopBootstrapSecret === undefined ? {} : { desktopBootstrapSecret }),470 ...(desktopBootstrapSecret === undefined ? {} : { desktopBootstrapSecret }),
469 desktopTelemetryFd,471 desktopTelemetryFd,
470 desktopTelemetryControlFd,472 desktopTelemetryControlFd,
473 desktopBrowserFd,
474 desktopBrowserControlFd,
471 resourceMonitorPath,475 resourceMonitorPath,
472 autoBootstrapProjectFromCwd,476 autoBootstrapProjectFromCwd,
473 logWebSocketEvents,477 logWebSocketEvents,
apps/server/src/cli/invocation.test.ts+28-1
...@@ -10,7 +10,11 @@ import * as Effect from "effect/Effect";...@@ -10,7 +10,11 @@ import * as Effect from "effect/Effect";
10import * as FileSystem from "effect/FileSystem";10import * as FileSystem from "effect/FileSystem";
11import * as Path from "effect/Path";11import * as Path from "effect/Path";
1212
13import { formatCliCommand, resolveServerInstallation } from "./invocation.ts";13import {
14 formatCliCommand,
15 resolveRootCliCommand,
16 resolveServerInstallation,
17} from "./invocation.ts";
1418
15it("formats package runner commands from their cache entry paths", () => {19it("formats package runner commands from their cache entry paths", () => {
16 for (const [entryPath, expected] of [20 for (const [entryPath, expected] of [
...@@ -98,6 +102,29 @@ it("formats serve suggestions to match the launching command", () => {...@@ -98,6 +102,29 @@ it("formats serve suggestions to match the launching command", () => {
98 );102 );
99});103});
100104
105it.effect("keeps a user-installed Node reachable when the command runs under sudo", () =>
106 Effect.gen(function* () {
107 const command = (node: string, entry: string) =>
108 resolveRootCliCommand("browser setup").pipe(
109 Effect.provideService(HostProcessExecutablePath, node),
110 Effect.provideService(HostProcessArguments, [node, entry]),
111 );
112 const npx = "/home/theo/.npm/_npx/abc/node_modules/t3/dist/bin.mjs";
113 // sudo's secure_path already has a system Node.
114 expect(yield* command("/usr/bin/node", npx)).toBe("sudo npx t3 browser setup");
115 // nvm, fnm, and tarball installs are dropped by sudo's PATH reset.
116 expect(yield* command("/home/theo/.nvm/versions/node/v24/bin/node", npx)).toBe(
117 'sudo env "PATH=$PATH" npx t3 browser setup',
118 );
119 expect(
120 yield* command(
121 "/home/theo/.local/node/bin/node",
122 "/home/theo/.local/lib/node_modules/t3/dist/bin.mjs",
123 ),
124 ).toBe('sudo env "PATH=$PATH" t3 browser setup');
125 }),
126);
127
101it.layer(NodeServices.layer)("manual server installation ownership", (it) => {128it.layer(NodeServices.layer)("manual server installation ownership", (it) => {
102 it.effect("recognizes runner caches for both script and executable packages", () =>129 it.effect("recognizes runner caches for both script and executable packages", () =>
103 Effect.gen(function* () {130 Effect.gen(function* () {
apps/server/src/cli/invocation.ts+26
...@@ -145,3 +145,29 @@ export const resolveCliCommand = (subcommand: string) =>...@@ -145,3 +145,29 @@ export const resolveCliCommand = (subcommand: string) =>
145 version: packageJson.version,145 version: packageJson.version,
146 }),146 }),
147 );147 );
148
149/**
150 * `t3 <subcommand>` as root, for setup a person runs once on the host. `sudo`
151 * resets PATH on most distributions, which drops a user-installed Node (nvm,
152 * fnm, a tarball) and with it `npx` or a global `t3`, so the command carries
153 * PATH through unless Node is on root's PATH too.
154 */
155export const resolveRootCliCommand = (subcommand: string) =>
156 Effect.gen(function* () {
157 const command = yield* resolveCliCommand(subcommand);
158 const executablePath = yield* HostProcessExecutablePath;
159 const systemNode = ROOT_PATH_DIRECTORIES.some((directory) =>
160 executablePath.startsWith(`${directory}/`),
161 );
162 return systemNode ? `sudo ${command}` : `sudo env "PATH=$PATH" ${command}`;
163 });
164
165/** Debian and Ubuntu's sudo `secure_path`, minus snap. */
166const ROOT_PATH_DIRECTORIES = [
167 "/usr/local/sbin",
168 "/usr/local/bin",
169 "/usr/sbin",
170 "/usr/bin",
171 "/sbin",
172 "/bin",
173];
apps/server/src/cli/pair.ts+2
...@@ -335,6 +335,8 @@ const makePairServerConfig = Effect.fn(function* (input: {...@@ -335,6 +335,8 @@ const makePairServerConfig = Effect.fn(function* (input: {
335 startupPresentation: "headless",335 startupPresentation: "headless",
336 desktopBootstrapToken: undefined,336 desktopBootstrapToken: undefined,
337 desktopTelemetryFd: undefined,337 desktopTelemetryFd: undefined,
338 desktopBrowserFd: undefined,
339 desktopBrowserControlFd: undefined,
338 desktopTelemetryControlFd: undefined,340 desktopTelemetryControlFd: undefined,
339 resourceMonitorPath: undefined,341 resourceMonitorPath: undefined,
340 autoBootstrapProjectFromCwd: false,342 autoBootstrapProjectFromCwd: false,
apps/server/src/config.ts+4
...@@ -97,6 +97,8 @@ export class ServerConfig extends Context.Service<...@@ -97,6 +97,8 @@ export class ServerConfig extends Context.Service<
97 readonly desktopBootstrapSecret?: string | undefined;97 readonly desktopBootstrapSecret?: string | undefined;
98 readonly desktopTelemetryFd?: number | undefined;98 readonly desktopTelemetryFd?: number | undefined;
99 readonly desktopTelemetryControlFd?: number | undefined;99 readonly desktopTelemetryControlFd?: number | undefined;
100 readonly desktopBrowserFd?: number | undefined;
101 readonly desktopBrowserControlFd?: number | undefined;
100 readonly resourceMonitorPath?: string | undefined;102 readonly resourceMonitorPath?: string | undefined;
101 readonly autoBootstrapProjectFromCwd: boolean;103 readonly autoBootstrapProjectFromCwd: boolean;
102 readonly logWebSocketEvents: boolean;104 readonly logWebSocketEvents: boolean;
...@@ -235,6 +237,8 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* (...@@ -235,6 +237,8 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* (
235 desktopBootstrapToken: undefined,237 desktopBootstrapToken: undefined,
236 desktopTelemetryFd: undefined,238 desktopTelemetryFd: undefined,
237 desktopTelemetryControlFd: undefined,239 desktopTelemetryControlFd: undefined,
240 desktopBrowserFd: undefined,
241 desktopBrowserControlFd: undefined,
238 resourceMonitorPath: undefined,242 resourceMonitorPath: undefined,
239 staticDir: undefined,243 staticDir: undefined,
240 devUrl,244 devUrl,
apps/server/src/device/DeviceHubProxy.ts+5-41
...@@ -11,11 +11,7 @@...@@ -11,11 +11,7 @@
11 * Only the routes the Device panel needs are forwarded. Anything under the11 * Only the routes the Device panel needs are forwarded. Anything under the
12 * hub's dashboard, exec, or WebRTC surface is rejected here.12 * hub's dashboard, exec, or WebRTC surface is rejected here.
13 */13 */
14import {14import { AuthOrchestrationReadScope, AuthOrchestrationOperateScope } from "@t3tools/contracts";
15 AuthOrchestrationReadScope,
16 AuthOrchestrationOperateScope,
17 type AuthEnvironmentScope,
18} from "@t3tools/contracts";
19import * as Effect from "effect/Effect";15import * as Effect from "effect/Effect";
20import * as Option from "effect/Option";16import * as Option from "effect/Option";
21import {17import {
...@@ -28,12 +24,7 @@ import {...@@ -28,12 +24,7 @@ import {
28import * as Socket from "effect/socket/Socket";24import * as Socket from "effect/socket/Socket";
29import * as NodeSocket from "@effect/platform-node/NodeSocket";25import * as NodeSocket from "@effect/platform-node/NodeSocket";
3026
31import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";27import { authenticateMediaRequest } from "../auth/http.ts";
32import {
33 failEnvironmentAuthInvalid,
34 failEnvironmentInternal,
35 failEnvironmentScopeRequired,
36} from "../auth/http.ts";
37import * as DeviceService from "./DeviceService.ts";28import * as DeviceService from "./DeviceService.ts";
3829
39const ALLOWED_PATHS: ReadonlyArray<RegExp> = [30const ALLOWED_PATHS: ReadonlyArray<RegExp> = [
...@@ -80,35 +71,6 @@ const DROPPED_REQUEST_HEADERS = new Set([...@@ -80,35 +71,6 @@ const DROPPED_REQUEST_HEADERS = new Set([
80const isWebSocketUpgrade = (request: HttpServerRequest.HttpServerRequest) =>71const isWebSocketUpgrade = (request: HttpServerRequest.HttpServerRequest) =>
81 request.headers.upgrade?.toLowerCase() === "websocket";72 request.headers.upgrade?.toLowerCase() === "websocket";
8273
83/**
84 * `<img>` and WebSocket cannot set headers, so every proxied request
85 * authenticates the way the `/ws` upgrade does: a cookie for browser
86 * sessions, or a short-lived `wsTicket` minted over authenticated HTTP for
87 * bearer and DPoP clients. The upgrade authenticator already implements that
88 * fallback order, so it is used for plain requests as well.
89 */
90const authenticate = (requiredScope: AuthEnvironmentScope) =>
91 Effect.gen(function* () {
92 const request = yield* HttpServerRequest.HttpServerRequest;
93 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
94 const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe(
95 Effect.catch((error) =>
96 Effect.gen(function* () {
97 if (EnvironmentAuth.isServerAuthCredentialError(error)) {
98 return yield* failEnvironmentAuthInvalid(
99 EnvironmentAuth.serverAuthCredentialReason(error),
100 EnvironmentAuth.serverAuthDpopFailureReason(error),
101 );
102 }
103 return yield* failEnvironmentInternal("internal_error", error);
104 }),
105 ),
106 );
107 if (!session.scopes.includes(requiredScope)) {
108 return yield* failEnvironmentScopeRequired(requiredScope);
109 }
110 });
111
112const forwardHeaders = (request: HttpServerRequest.HttpServerRequest, origin: string) => {74const forwardHeaders = (request: HttpServerRequest.HttpServerRequest, origin: string) => {
113 const headers: Record<string, string> = {};75 const headers: Record<string, string> = {};
114 for (const [name, value] of Object.entries(request.headers)) {76 for (const [name, value] of Object.entries(request.headers)) {
...@@ -206,7 +168,9 @@ const handler = Effect.gen(function* () {...@@ -206,7 +168,9 @@ const handler = Effect.gen(function* () {
206 const controlsDevice =168 const controlsDevice =
207 (upgrade && hubPath !== "/api/devices/ws") ||169 (upgrade && hubPath !== "/api/devices/ws") ||
208 (!readOnly && /\/api\/(stream-(mode|settings)|fold)$/.test(hubPath));170 (!readOnly && /\/api\/(stream-(mode|settings)|fold)$/.test(hubPath));
209 yield* authenticate(controlsDevice ? AuthOrchestrationOperateScope : AuthOrchestrationReadScope);171 yield* authenticateMediaRequest(
172 controlsDevice ? AuthOrchestrationOperateScope : AuthOrchestrationReadScope,
173 );
210 const devices = yield* DeviceService.DeviceService;174 const devices = yield* DeviceService.DeviceService;
211 const ready = yield* devices.currentReadiness(url.value.searchParams.get("hostId") ?? undefined);175 const ready = yield* devices.currentReadiness(url.value.searchParams.get("hostId") ?? undefined);
212 if (!ready) {176 if (!ready) {
apps/server/src/environment/ServerEnvironment.ts+1
...@@ -258,6 +258,7 @@ export const make = Effect.gen(function* () {...@@ -258,6 +258,7 @@ export const make = Effect.gen(function* () {
258 ? { serverSelfUpdateProgress: true }258 ? { serverSelfUpdateProgress: true }
259 : {}),259 : {}),
260 ...(desktopAppUpdate ? { desktopAppUpdate: true } : {}),260 ...(desktopAppUpdate ? { desktopAppUpdate: true } : {}),
261 serverBrowser: true,
261 },262 },
262 };263 };
263264
apps/server/src/htmlRender/HtmlRender.test.ts+1-1
...@@ -24,7 +24,7 @@ import * as NodeURL from "node:url";...@@ -24,7 +24,7 @@ import * as NodeURL from "node:url";
24import { resolveAttachmentPathById } from "../attachmentStore.ts";24import { resolveAttachmentPathById } from "../attachmentStore.ts";
25import * as ServerConfig from "../config.ts";25import * as ServerConfig from "../config.ts";
26import * as HtmlRender from "./HtmlRender.ts";26import * as HtmlRender from "./HtmlRender.ts";
27import * as PreviewBrowser from "./PreviewBrowser.ts";27import * as PreviewBrowser from "../preview/PreviewBrowser.ts";
2828
29// Real-browser tests run only when this names a chrome-headless-shell, for29// Real-browser tests run only when this names a chrome-headless-shell, for
30// example one T3 installed under <T3 home>/tools/chrome-headless-shell.30// example one T3 installed under <T3 home>/tools/chrome-headless-shell.
apps/server/src/htmlRender/HtmlRender.ts+26-35
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import type { ThreadId } from "@t3tools/contracts";1import type { ThreadId } from "@t3tools/contracts";
2import { HostProcessUserId } from "@t3tools/shared/hostProcess";2import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
3import {3import {
4 clampHtmlRenderHeight,4 clampHtmlRenderHeight,
5 HTML_RENDER_COLUMN_WIDTH,5 HTML_RENDER_COLUMN_WIDTH,
...@@ -29,9 +29,11 @@ import type * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";...@@ -29,9 +29,11 @@ import type * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
2929
30import { resolveAttachmentRelativePath } from "../attachmentPaths.ts";30import { resolveAttachmentRelativePath } from "../attachmentPaths.ts";
31import { createAttachmentId } from "../attachmentStore.ts";31import { createAttachmentId } from "../attachmentStore.ts";
32import { resolveRootCliCommand } from "../cli/invocation.ts";
32import * as ServerConfig from "../config.ts";33import * as ServerConfig from "../config.ts";
33import * as HeadlessChrome from "./headlessChrome.ts";34import * as HeadlessChrome from "./headlessChrome.ts";
34import * as PreviewBrowser from "./PreviewBrowser.ts";35import * as PreviewBrowser from "../preview/PreviewBrowser.ts";
36import * as PreviewBrowserHost from "../preview/PreviewBrowserHost.ts";
3537
36const MIB = 1024 * 1024;38const MIB = 1024 * 1024;
37const MAX_IMAGE_BYTES = 10 * MIB;39const MAX_IMAGE_BYTES = 10 * MIB;
...@@ -118,6 +120,7 @@ export class HtmlRender extends Context.Service<...@@ -118,6 +120,7 @@ export class HtmlRender extends Context.Service<
118 | PreviewBrowser.PreviewBrowserInstallError120 | PreviewBrowser.PreviewBrowserInstallError
119 | PreviewBrowser.PreviewBrowserInstallingError121 | PreviewBrowser.PreviewBrowserInstallingError
120 | PreviewBrowser.PreviewBrowserUnsupportedError122 | PreviewBrowser.PreviewBrowserUnsupportedError
123 | PreviewBrowserHost.PreviewBrowserHostError
121 | HeadlessChrome.HtmlRenderBrowserError124 | HeadlessChrome.HtmlRenderBrowserError
122 >;125 >;
123 }126 }
...@@ -336,42 +339,30 @@ const make = Effect.gen(function* () {...@@ -336,42 +339,30 @@ const make = Effect.gen(function* () {
336 FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner339 FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner
337 >();340 >();
338 const browsers = yield* Semaphore.make(MAX_CONCURRENT_BROWSERS);341 const browsers = yield* Semaphore.make(MAX_CONCURRENT_BROWSERS);
339 // Chrome refuses its sandbox as root. Other hosts that cannot provide one342 // Chrome's sandbox stays on unless the operator explicitly turns it off.
340 // (Ubuntu 23.10+ AppArmor) are learned from the first launch and remembered.343 // Chrome also refuses it as root, where that opt-out is the only way to run.
341 let noSandbox = (yield* HostProcessUserId) === 0;344 const noSandbox = PreviewBrowserHost.sandboxDisabled(yield* HostProcessEnvironment);
345 const setupCommand = yield* resolveRootCliCommand(PreviewBrowserHost.SETUP_SUBCOMMAND);
342346
347 /** Runs one browser launch; a host that cannot start it gets setup steps instead. */
343 const launching = <A>(348 const launching = <A>(
344 run: (349 executable: string,
345 noSandbox: boolean,350 run: (noSandbox: boolean) => Effect.Effect<A, HeadlessChrome.HtmlRenderBrowserError>,
346 ) => Effect.Effect<
347 A,
348 HeadlessChrome.HtmlRenderBrowserError | HeadlessChrome.HtmlRenderSandboxUnavailableError
349 >,
350 ) =>351 ) =>
351 browsers.withPermits(1)(352 browsers.withPermits(1)(
352 // Read once a permit is held, so a queued call sees a fallback learned meanwhile.353 run(noSandbox).pipe(
353 Effect.suspend(() => run(noSandbox)).pipe(
354 Effect.catchTags({354 Effect.catchTags({
355 HtmlRenderSandboxUnavailableError: () =>355 HtmlRenderBrowserError: (error) =>
356 Effect.logInfo(356 error.output === undefined
357 "Chrome's sandbox is unavailable on this host; launching it without one.",357 ? Effect.fail(error)
358 ).pipe(358 : PreviewBrowserHost.diagnoseLaunchFailure({
359 Effect.andThen(359 executable,
360 Effect.sync(() => {360 setupCommand,
361 noSandbox = true;361 output: error.output,
362 }),362 }).pipe(
363 ),363 Effect.provideContext(services),
364 Effect.andThen(run(true)),364 Effect.flatMap((hostError) => Effect.fail(hostError ?? error)),
365 ),365 ),
366 }),
367 Effect.catchTags({
368 HtmlRenderSandboxUnavailableError: (cause) =>
369 Effect.fail(
370 new HeadlessChrome.HtmlRenderBrowserError({
371 reason: "the browser has no sandbox",
372 cause,
373 }),
374 ),
375 }),366 }),
376 ),367 ),
377 );368 );
...@@ -381,7 +372,7 @@ const make = Effect.gen(function* () {...@@ -381,7 +372,7 @@ const make = Effect.gen(function* () {
381 Effect.gen(function* () {372 Effect.gen(function* () {
382 const executable = yield* previewBrowser.installed;373 const executable = yield* previewBrowser.installed;
383 if (Option.isNone(executable)) return undefined;374 if (Option.isNone(executable)) return undefined;
384 const heights = yield* launching((noSandbox) =>375 const heights = yield* launching(executable.value, (noSandbox) =>
385 HeadlessChrome.measureHtmlHeights({376 HeadlessChrome.measureHtmlHeights({
386 executable: executable.value,377 executable: executable.value,
387 noSandbox,378 noSandbox,
...@@ -473,7 +464,7 @@ const make = Effect.gen(function* () {...@@ -473,7 +464,7 @@ const make = Effect.gen(function* () {
473 appearance,464 appearance,
474 HTML_RENDER_MEASURE_FONTS,465 HTML_RENDER_MEASURE_FONTS,
475 );466 );
476 const screenshot = yield* launching((noSandbox) =>467 const screenshot = yield* launching(executable, (noSandbox) =>
477 HeadlessChrome.captureHtmlScreenshot({468 HeadlessChrome.captureHtmlScreenshot({
478 executable,469 executable,
479 noSandbox,470 noSandbox,
apps/server/src/htmlRender/PreviewBrowser.test.ts deleted-296
...@@ -1,296 +0,0 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
4import * as Deferred from "effect/Deferred";
5import type * as Duration from "effect/Duration";
6import * as DateTime from "effect/DateTime";
7import * as Effect from "effect/Effect";
8import * as Fiber from "effect/Fiber";
9import * as FileSystem from "effect/FileSystem";
10import * as Option from "effect/Option";
11import * as Path from "effect/Path";
12import * as Stream from "effect/Stream";
13import * as TestClock from "effect/testing/TestClock";
14import { HttpClient, HttpClientResponse } from "effect/http";
15import * as NodeCrypto from "node:crypto";
16import * as NodeZlib from "node:zlib";
17
18import * as PreviewBrowser from "./PreviewBrowser.ts";
19
20// POSIX modes cannot be checked on NTFS, so follow the host like the Antigravity suite.
21const hostPlatform: NodeJS.Platform =
22 HostProcessPlatform.defaultValue() === "win32" ? "win32" : "linux";
23const executableName =
24 hostPlatform === "win32" ? "chrome-headless-shell.exe" : "chrome-headless-shell";
25const ROOT = "chrome-headless-shell-fixture/";
26
27/** A stored ZIP whose entries carry Unix modes, like Chrome for Testing's archives. */
28const makeZip = (
29 entries: ReadonlyArray<{ readonly name: string; readonly data?: string; readonly mode: number }>,
30) => {
31 const records: Array<Buffer> = [];
32 const directory: Array<Buffer> = [];
33 let offset = 0;
34 for (const entry of entries) {
35 const name = Buffer.from(entry.name);
36 const data = Buffer.from(entry.data ?? "");
37 const crc = NodeZlib.crc32(data);
38 const local = Buffer.alloc(30);
39 local.writeUInt32LE(0x04034b50, 0);
40 local.writeUInt16LE(20, 4);
41 local.writeUInt32LE(crc, 14);
42 local.writeUInt32LE(data.length, 18);
43 local.writeUInt32LE(data.length, 22);
44 local.writeUInt16LE(name.length, 26);
45 const central = Buffer.alloc(46);
46 central.writeUInt32LE(0x02014b50, 0);
47 central.writeUInt16LE((3 << 8) | 20, 4);
48 central.writeUInt16LE(20, 6);
49 central.writeUInt32LE(crc, 16);
50 central.writeUInt32LE(data.length, 20);
51 central.writeUInt32LE(data.length, 24);
52 central.writeUInt16LE(name.length, 28);
53 central.writeUInt32LE((entry.mode << 16) >>> 0, 38);
54 central.writeUInt32LE(offset, 42);
55 records.push(local, name, data);
56 directory.push(central, name);
57 offset += local.length + name.length + data.length;
58 }
59 const centralDirectory = Buffer.concat(directory);
60 const end = Buffer.alloc(22);
61 end.writeUInt32LE(0x06054b50, 0);
62 end.writeUInt16LE(entries.length, 8);
63 end.writeUInt16LE(entries.length, 10);
64 end.writeUInt32LE(centralDirectory.length, 12);
65 end.writeUInt32LE(offset, 16);
66 return Buffer.concat([...records, centralDirectory, end]);
67};
68
69const browserArchive = makeZip([
70 { name: ROOT, mode: 0o40755 },
71 { name: `${ROOT}${executableName}`, data: "#!/bin/sh\n", mode: 0o100755 },
72 { name: `${ROOT}libEGL.so`, data: "library", mode: 0o100755 },
73 { name: `${ROOT}locales/`, mode: 0o40755 },
74 { name: `${ROOT}locales/en-US.pak`, data: "strings", mode: 0o100644 },
75]);
76
77const makeHarness = Effect.fn("test.makePreviewBrowser")(function* (
78 options: {
79 readonly archive?: Buffer;
80 readonly sha256?: string;
81 readonly body?: Stream.Stream<Uint8Array>;
82 readonly wait?: Duration.Input;
83 readonly unsupported?: boolean;
84 } = {},
85) {
86 const fs = yield* FileSystem.FileSystem;
87 const path = yield* Path.Path;
88 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-preview-browser-test-" });
89 const archive = options.archive ?? browserArchive;
90 const requests: Array<string> = [];
91 const browser = yield* PreviewBrowser.makePreviewBrowser({
92 baseDir,
93 release: options.unsupported
94 ? null
95 : {
96 version: "1.2.3",
97 platform: "fixture",
98 url: "https://storage.googleapis.com/chrome-headless-shell-fixture.zip",
99 bytes: archive.byteLength,
100 sha256: options.sha256 ?? NodeCrypto.createHash("sha256").update(archive).digest("hex"),
101 },
102 ...(options.wait === undefined ? {} : { wait: options.wait }),
103 }).pipe(
104 Effect.provideService(HostProcessPlatform, hostPlatform),
105 Effect.provideService(HostProcessArchitecture, "x64"),
106 Effect.provideService(
107 HttpClient.HttpClient,
108 HttpClient.make((request) =>
109 Effect.sync(() => {
110 requests.push(request.url);
111 const response = HttpClientResponse.fromWeb(request, new Response(null));
112 return Object.defineProperty(response, "stream", {
113 value: options.body ?? Stream.make(archive.subarray(0, 40), archive.subarray(40)),
114 });
115 }),
116 ),
117 ),
118 );
119 const installRoot = path.join(baseDir, "tools", "chrome-headless-shell", "fixture");
120 return { browser, fs, path, baseDir, installRoot, requests };
121});
122
123it.layer(NodeServices.layer)("PreviewBrowser", (it) => {
124 it.effect("installs a verified download with its file modes and then reuses it", () =>
125 Effect.gen(function* () {
126 const { browser, fs, path, installRoot, requests } = yield* makeHarness();
127 yield* fs.makeDirectory(path.join(installRoot, "1.0.0"), { recursive: true });
128 // Another server's install in progress, and one abandoned two hours ago.
129 const active = path.join(installRoot, ".install-other");
130 const abandoned = path.join(installRoot, ".install-abandoned");
131 yield* fs.makeDirectory(active);
132 yield* fs.makeDirectory(abandoned);
133 const twoHoursAgo = DateTime.toDate(DateTime.subtract(yield* DateTime.now, { hours: 2 }));
134 yield* fs.utimes(abandoned, twoHoursAgo, twoHoursAgo);
135 expect(yield* browser.installed).toEqual(Option.none());
136
137 const executable = yield* browser.executable;
138
139 const version = path.join(installRoot, "1.2.3");
140 expect(executable).toBe(path.join(version, executableName));
141 expect(yield* fs.readFileString(path.join(version, "locales", "en-US.pak"))).toBe("strings");
142 if (hostPlatform !== "win32") {
143 const mode = (file: string) =>
144 fs.stat(path.join(version, file)).pipe(Effect.map((info) => info.mode & 0o777));
145 expect(yield* mode(executableName)).toBe(0o755);
146 expect(yield* mode("libEGL.so")).toBe(0o755);
147 expect(yield* mode(path.join("locales", "en-US.pak"))).toBe(0o644);
148 }
149 // The old build and stale staging directories are gone; a fresh one stays.
150 expect((yield* fs.readDirectory(installRoot)).toSorted()).toEqual([
151 ".install-other",
152 "1.2.3",
153 ]);
154 expect(yield* browser.executable).toBe(executable);
155 expect(yield* browser.installed).toEqual(Option.some(executable));
156 expect(requests).toHaveLength(1);
157 }),
158 );
159
160 it.effect.each([
161 { name: "a hash mismatch", sha256: "0".repeat(64) },
162 { name: "a short download", body: Stream.make(browserArchive.subarray(0, -1)) },
163 {
164 name: "an oversized download",
165 body: Stream.make(browserArchive, Buffer.from("extra")),
166 },
167 ])("rejects $name, installs nothing, and retries on the next call", (testCase) =>
168 Effect.gen(function* () {
169 const { browser, fs, installRoot, requests } = yield* makeHarness(testCase);
170
171 const error = yield* browser.executable.pipe(Effect.flip);
172
173 expect(error._tag).toBe("PreviewBrowserInstallError");
174 expect(error.message).toMatch(
175 /^T3 could not install its HTML preview browser: .+ Call html_preview again to retry\.$/,
176 );
177 expect(yield* fs.readDirectory(installRoot)).toEqual([]);
178 yield* browser.executable.pipe(Effect.flip);
179 expect(requests).toHaveLength(2);
180 }),
181 );
182
183 it.effect.each([
184 { name: "a parent-directory entry", entry: `${ROOT}../escape` },
185 { name: "an absolute entry", entry: "/escape" },
186 { name: "an entry outside the top directory", entry: "escape" },
187 ])("rejects an archive with $name", (testCase) =>
188 Effect.gen(function* () {
189 const { browser, fs, path, baseDir, installRoot } = yield* makeHarness({
190 archive: makeZip([
191 { name: `${ROOT}${executableName}`, data: "#!/bin/sh\n", mode: 0o100755 },
192 { name: testCase.entry, data: "escaped", mode: 0o100644 },
193 ]),
194 });
195
196 const error = yield* browser.executable.pipe(Effect.flip);
197
198 expect(error._tag).toBe("PreviewBrowserInstallError");
199 expect(yield* fs.readDirectory(installRoot)).toEqual([]);
200 expect(yield* fs.exists(path.join(baseDir, "tools", "chrome-headless-shell", "escape"))).toBe(
201 false,
202 );
203 }),
204 );
205
206 it.effect("shares one install between callers and finishes it after they stop waiting", () =>
207 Effect.gen(function* () {
208 const firstChunkSent = yield* Deferred.make<void>();
209 const finishDownload = yield* Deferred.make<void>();
210 const { browser, requests } = yield* makeHarness({
211 wait: "45 seconds",
212 body: Stream.concat(
213 Stream.make(browserArchive.subarray(0, 100)),
214 Stream.fromEffect(
215 Deferred.succeed(firstChunkSent, undefined).pipe(
216 Effect.andThen(Deferred.await(finishDownload)),
217 Effect.as(browserArchive.subarray(100)),
218 ),
219 ),
220 ),
221 });
222
223 const callers = yield* Effect.forEach([1, 2], () =>
224 Effect.forkChild(browser.executable.pipe(Effect.flip)),
225 );
226 yield* Deferred.await(firstChunkSent);
227 yield* TestClock.adjust("45 seconds");
228 for (const caller of callers) {
229 expect(yield* Fiber.join(caller)).toMatchObject({
230 _tag: "PreviewBrowserInstallingError",
231 downloadedBytes: 100,
232 unpacking: false,
233 });
234 }
235
236 // The install belongs to the service, so it outlives the callers that gave up on it.
237 yield* Deferred.succeed(finishDownload, undefined);
238 expect(yield* browser.executable).toMatch(new RegExp(`${executableName}$`));
239 expect(requests).toHaveLength(1);
240 }),
241 );
242
243 it.effect("reports hosts Chrome for Testing does not build for without downloading", () =>
244 Effect.gen(function* () {
245 const { browser, requests } = yield* makeHarness({ unsupported: true });
246 expect((yield* browser.executable.pipe(Effect.flip))._tag).toBe(
247 "PreviewBrowserUnsupportedError",
248 );
249 expect(yield* browser.installed).toEqual(Option.none());
250 expect(requests).toEqual([]);
251 }),
252 );
253});
254
255it("pins one Chrome for Testing build per host", () => {
256 const hosts = [
257 ["linux", "x64"],
258 ["linux", "arm64"],
259 ["darwin", "arm64"],
260 ["darwin", "x64"],
261 ["win32", "x64"],
262 ["win32", "arm64"],
263 ["win32", "ia32"],
264 ["linux", "ia32"],
265 ["freebsd", "x64"],
266 ] as const;
267 expect(
268 hosts.map(([platform, arch]) => PreviewBrowser.previewBrowserRelease(platform, arch)?.platform),
269 ).toEqual([
270 "linux64",
271 "linux-arm64",
272 "mac-arm64",
273 "mac-x64",
274 "win64",
275 "win64",
276 "win32",
277 undefined,
278 undefined,
279 ]);
280 expect(PreviewBrowser.previewBrowserRelease("linux", "x64")).toMatchObject({
281 url: "https://storage.googleapis.com/chrome-for-testing-public/154.0.8037.92/linux64/chrome-headless-shell-linux64.zip",
282 bytes: 120_477_194,
283 });
284});
285
286it("tells the agent how far the install has come", () => {
287 expect(
288 new PreviewBrowser.PreviewBrowserInstallingError({
289 downloadedBytes: 37_200_000,
290 totalBytes: 120_477_194,
291 unpacking: false,
292 }).message,
293 ).toBe(
294 "T3 is installing its HTML preview browser (37 of 120 MB downloaded). Call html_preview again in a minute.",
295 );
296});
apps/server/src/htmlRender/PreviewBrowser.ts deleted-462
...@@ -1,462 +0,0 @@
1// @effect-diagnostics nodeBuiltinImport:off - Effect has no incremental digest.
2import * as EffectNodeStream from "@effect/platform-node/NodeStream";
3import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
4import * as Cause from "effect/Cause";
5import * as Clock from "effect/Clock";
6import * as Context from "effect/Context";
7import * as Deferred from "effect/Deferred";
8import type * as Duration from "effect/Duration";
9import * as Effect from "effect/Effect";
10import * as Exit from "effect/Exit";
11import * as FileSystem from "effect/FileSystem";
12import * as Layer from "effect/Layer";
13import * as Option from "effect/Option";
14import * as Path from "effect/Path";
15import * as Schema from "effect/Schema";
16import * as Semaphore from "effect/Semaphore";
17import * as Stream from "effect/Stream";
18import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
19import * as NodeCrypto from "node:crypto";
20
21import * as ServerConfig from "../config.ts";
22import { openZipArchive } from "../zipArchive.ts";
23
24// HTML previews only ever run T3's own pinned Chrome for Testing headless
25// shell, never a browser the user installed. To bump the pin, pick a version
26// from https://googlechromelabs.github.io/chrome-for-testing/known-good-versions-with-downloads.json,
27// download each platform's chrome-headless-shell zip, and replace the version
28// and every byte count and SHA-256 below. Hosts drop the old build after the
29// new one installs.
30const VERSION = "154.0.8037.92";
31const ARCHIVES = {
32 linux64: {
33 bytes: 120_477_194,
34 sha256: "636aa5c79f2693632e9921b8bbb050038ba11672e02346c06c20f991aed096f9",
35 },
36 "linux-arm64": {
37 bytes: 121_182_296,
38 sha256: "0ed0e47d9e9f639197f508d62ada09e5c6b4c4c60edab3160a9312a733091df6",
39 },
40 "mac-arm64": {
41 bytes: 99_221_129,
42 sha256: "77da14e75d7f2568e6f7898d3df7cdc6faac74b15e903b2c9d486ebb6ca9b929",
43 },
44 "mac-x64": {
45 bytes: 104_748_425,
46 sha256: "a54292aaacbb77f76f6ef47558e7c51ab884044e0adacca315567f83c060bcc4",
47 },
48 win32: {
49 bytes: 114_295_943,
50 sha256: "56b30d2d6c35775ebf8dc3618680f6529e1c38c87f7feb28a16e9904273d51f7",
51 },
52 win64: {
53 bytes: 120_822_223,
54 sha256: "3ac2561f02d9d87aadc0399d00b9002d718a4c365624fa67db9e7bfaf6b1a568",
55 },
56} as const;
57
58const chromePlatform = (platform: NodeJS.Platform, arch: NodeJS.Architecture) => {
59 switch (platform) {
60 case "linux":
61 return arch === "x64" ? "linux64" : arch === "arm64" ? "linux-arm64" : null;
62 case "darwin":
63 return arch === "arm64" ? "mac-arm64" : arch === "x64" ? "mac-x64" : null;
64 case "win32":
65 // There is no Windows arm64 build; Windows on Arm runs the x64 one under emulation.
66 return arch === "ia32" ? "win32" : arch === "x64" || arch === "arm64" ? "win64" : null;
67 default:
68 return null;
69 }
70};
71
72export interface PreviewBrowserRelease {
73 readonly version: string;
74 /** Chrome for Testing's platform name, which also names the archive's top directory. */
75 readonly platform: string;
76 readonly url: string;
77 readonly bytes: number;
78 readonly sha256: string;
79}
80
81export const previewBrowserRelease = (
82 platform: NodeJS.Platform,
83 arch: NodeJS.Architecture,
84): PreviewBrowserRelease | null => {
85 const chrome = chromePlatform(platform, arch);
86 return chrome === null
87 ? null
88 : {
89 version: VERSION,
90 platform: chrome,
91 url: `https://storage.googleapis.com/chrome-for-testing-public/${VERSION}/${chrome}/chrome-headless-shell-${chrome}.zip`,
92 ...ARCHIVES[chrome],
93 };
94};
95
96const megabytes = (bytes: number) => Math.round(bytes / 1_000_000);
97
98export class PreviewBrowserInstallError extends Schema.TaggedError<PreviewBrowserInstallError>()(
99 "PreviewBrowserInstallError",
100 { detail: Schema.String, cause: Schema.optional(Schema.Defect()) },
101) {
102 override get message(): string {
103 return `T3 could not install its HTML preview browser: ${this.detail} Call html_preview again to retry.`;
104 }
105}
106const isInstallError = Schema.is(PreviewBrowserInstallError);
107
108export class PreviewBrowserInstallingError extends Schema.TaggedError<PreviewBrowserInstallingError>()(
109 "PreviewBrowserInstallingError",
110 { downloadedBytes: Schema.Number, totalBytes: Schema.Number, unpacking: Schema.Boolean },
111) {
112 override get message(): string {
113 const progress = this.unpacking
114 ? "unpacking"
115 : `${megabytes(this.downloadedBytes)} of ${megabytes(this.totalBytes)} MB downloaded`;
116 return `T3 is installing its HTML preview browser (${progress}). Call html_preview again in a minute.`;
117 }
118}
119
120export class PreviewBrowserUnsupportedError extends Schema.TaggedError<PreviewBrowserUnsupportedError>()(
121 "PreviewBrowserUnsupportedError",
122 { platform: Schema.String, arch: Schema.String },
123) {
124 override get message(): string {
125 return `HTML previews are not available on ${this.platform}-${this.arch}: Chrome for Testing has no headless shell for it. html_render still works without a preview.`;
126 }
127}
128
129export class PreviewBrowser extends Context.Service<
130 PreviewBrowser,
131 {
132 /**
133 * Path to the installed headless shell. The first call starts the
134 * install; callers wait for it up to a bound and then get its progress
135 * instead, while the install keeps running.
136 */
137 readonly executable: Effect.Effect<
138 string,
139 PreviewBrowserInstallError | PreviewBrowserInstallingError | PreviewBrowserUnsupportedError
140 >;
141 /** The installed headless shell, if any. Never starts or waits on an install. */
142 readonly installed: Effect.Effect<Option.Option<string>>;
143 }
144>()("t3/htmlRender/PreviewBrowser") {}
145
146export interface PreviewBrowserOptions {
147 readonly baseDir: string;
148 readonly release?: PreviewBrowserRelease | null;
149 /** How long a caller waits on an install in progress. Provider tool calls time out near 60s. */
150 readonly wait?: Duration.Input;
151}
152
153const DOWNLOAD_TIMEOUT = "15 minutes";
154// Longer than any install can run: the download times out at 15 minutes.
155const STAGING_STALE_AFTER_MS = 60 * 60_000;
156
157const wrapFailure = (detail: string) => (cause: unknown) =>
158 isInstallError(cause) ? cause : new PreviewBrowserInstallError({ detail, cause });
159
160type InstallState =
161 | { readonly _tag: "idle" }
162 | {
163 readonly _tag: "installing";
164 readonly done: Deferred.Deferred<string, PreviewBrowserInstallError>;
165 }
166 | { readonly _tag: "installed"; readonly executable: string }
167 | { readonly _tag: "failed"; readonly error: PreviewBrowserInstallError };
168
169export const makePreviewBrowser = Effect.fn("PreviewBrowser.make")(function* (
170 options: PreviewBrowserOptions,
171) {
172 const fs = yield* FileSystem.FileSystem;
173 const path = yield* Path.Path;
174 const http = yield* HttpClient.HttpClient;
175 // Installs belong to the service, so they finish even when no caller is still waiting.
176 const serviceScope = yield* Effect.scope;
177 const platform = yield* HostProcessPlatform;
178 const arch = yield* HostProcessArchitecture;
179 const release =
180 options.release === undefined ? previewBrowserRelease(platform, arch) : options.release;
181 const wait = options.wait ?? "45 seconds";
182 const installRoot = path.join(
183 options.baseDir,
184 "tools",
185 "chrome-headless-shell",
186 release?.platform ?? `${platform}-${arch}`,
187 );
188 const executableName =
189 platform === "win32" ? "chrome-headless-shell.exe" : "chrome-headless-shell";
190 const gate = yield* Semaphore.make(1);
191 let state: InstallState = { _tag: "idle" };
192 const progress = { downloadedBytes: 0, unpacking: false };
193
194 // The version directory appears by one rename of a fully unpacked tree, so a
195 // runnable binary there is the complete-install marker.
196 const installedExecutable = (release: PreviewBrowserRelease) => {
197 const executable = path.join(installRoot, release.version, executableName);
198 return fs.stat(executable).pipe(
199 Effect.map((info) =>
200 info.type === "File" && (platform === "win32" || (info.mode & 0o111) !== 0)
201 ? Option.some(executable)
202 : Option.none<string>(),
203 ),
204 Effect.orElseSucceed(() => Option.none<string>()),
205 );
206 };
207
208 const download = Effect.fn("PreviewBrowser.download")(
209 function* (release: PreviewBrowserRelease, archivePath: string) {
210 const response = yield* http
211 .execute(HttpClientRequest.get(release.url))
212 .pipe(Effect.flatMap(HttpClientResponse.filterStatusOk));
213 const contentLength = response.headers["content-length"];
214 const contentEncoding = response.headers["content-encoding"]?.trim().toLowerCase();
215 if (
216 (contentEncoding === undefined || contentEncoding === "identity") &&
217 contentLength !== undefined &&
218 Number(contentLength) !== release.bytes
219 ) {
220 return yield* new PreviewBrowserInstallError({
221 detail: "The download size did not match the pinned release.",
222 });
223 }
224 const hash = NodeCrypto.createHash("sha256");
225 yield* response.stream.pipe(
226 Stream.tap((chunk) =>
227 Effect.suspend(() => {
228 progress.downloadedBytes += chunk.byteLength;
229 if (progress.downloadedBytes > release.bytes) {
230 return Effect.fail(
231 new PreviewBrowserInstallError({
232 detail: "The download was larger than the pinned release.",
233 }),
234 );
235 }
236 hash.update(chunk);
237 return Effect.void;
238 }),
239 ),
240 Stream.run(fs.sink(archivePath, { flag: "wx", mode: 0o600 })),
241 );
242 if (progress.downloadedBytes !== release.bytes || hash.digest("hex") !== release.sha256) {
243 return yield* new PreviewBrowserInstallError({
244 detail: "The download failed its size or SHA-256 check. Nothing was installed.",
245 });
246 }
247 },
248 Effect.timeout(DOWNLOAD_TIMEOUT),
249 Effect.mapError(
250 wrapFailure(
251 "The download from storage.googleapis.com failed. Check this machine's network access.",
252 ),
253 ),
254 );
255
256 const extract = Effect.fn("PreviewBrowser.extract")(
257 function* (release: PreviewBrowserRelease, archivePath: string, destination: string) {
258 const archive = yield* openZipArchive(
259 archivePath,
260 (detail, cause) => new PreviewBrowserInstallError({ detail, cause }),
261 );
262 const unsafe = new PreviewBrowserInstallError({
263 detail: "The archive contains an unexpected or unsafe entry.",
264 });
265 const root = `chrome-headless-shell-${release.platform}/`;
266 yield* fs.makeDirectory(destination);
267 let foundExecutable = false;
268 for (;;) {
269 const entry = yield* archive.next;
270 if (!entry) break;
271 const unixMode = entry.externalFileAttributes >>> 16;
272 const unixType = unixMode & 0o170000;
273 const isDirectory = entry.fileName.endsWith("/");
274 // Everything sits under the one top directory, which is stripped.
275 const relative = entry.fileName.startsWith(root) ? entry.fileName.slice(root.length) : null;
276 const target = relative === null ? null : path.resolve(destination, relative);
277 if (
278 relative === null ||
279 target === null ||
280 (relative !== "" && !target.startsWith(`${destination}${path.sep}`)) ||
281 relative.split("/").includes("..") ||
282 (unixType !== 0 && unixType !== (isDirectory ? 0o040000 : 0o100000)) ||
283 (entry.generalPurposeBitFlag & 1) !== 0 ||
284 ![0, 8].includes(entry.compressionMethod)
285 ) {
286 return yield* unsafe;
287 }
288 if (isDirectory) {
289 yield* fs.makeDirectory(target, { recursive: true });
290 continue;
291 }
292 yield* fs.makeDirectory(path.dirname(target), { recursive: true });
293 yield* Effect.gen(function* () {
294 const readable = yield* archive.streamEntry(entry);
295 yield* EffectNodeStream.fromReadable<Uint8Array, PreviewBrowserInstallError>({
296 evaluate: () => readable,
297 onError: wrapFailure("Could not unpack the browser."),
298 }).pipe(Stream.run(fs.sink(target, { flag: "wx" })));
299 }).pipe(Effect.scoped);
300 // The binary and its shared libraries carry exec bits in the archive.
301 if (platform !== "win32" && (unixMode & 0o777) !== 0) {
302 yield* fs.chmod(target, unixMode & 0o777);
303 }
304 if (relative === executableName) foundExecutable = true;
305 }
306 if (!foundExecutable) {
307 return yield* new PreviewBrowserInstallError({
308 detail: "The archive does not contain chrome-headless-shell.",
309 });
310 }
311 },
312 Effect.scoped,
313 Effect.mapError(
314 wrapFailure("Could not unpack the browser. Check free disk space in T3's home directory."),
315 ),
316 );
317
318 const install = Effect.fn("PreviewBrowser.install")(
319 function* (release: PreviewBrowserRelease) {
320 yield* Effect.gen(function* () {
321 yield* fs.makeDirectory(installRoot, { recursive: true });
322 // Staging shares the install root's filesystem so publishing is one rename.
323 const staging = yield* fs.makeTempDirectoryScoped({
324 directory: installRoot,
325 prefix: ".install-",
326 });
327 const archivePath = path.join(staging, "download.zip");
328 const unpacked = path.join(staging, "browser");
329 yield* download(release, archivePath);
330 progress.unpacking = true;
331 yield* extract(release, archivePath, unpacked);
332 const destination = path.join(installRoot, release.version);
333 // A directory left without a runnable binary would block the rename.
334 yield* fs.remove(destination, { recursive: true, force: true });
335 yield* fs.rename(unpacked, destination);
336 }).pipe(Effect.scoped);
337 // Older builds and abandoned staging directories. Two servers can share a
338 // home, so a staging directory touched within the last hour may be another
339 // server's install in progress and stays.
340 const entries = yield* fs.readDirectory(installRoot);
341 const now = yield* Clock.currentTimeMillis;
342 yield* Effect.forEach(
343 entries.filter((name) => name !== release.version),
344 (name) =>
345 Effect.gen(function* () {
346 const target = path.join(installRoot, name);
347 if (name.startsWith(".install-")) {
348 const info = yield* fs.stat(target);
349 const modifiedAt = Option.match(info.mtime, {
350 onNone: () => now,
351 onSome: (date) => date.getTime(),
352 });
353 if (now - modifiedAt < STAGING_STALE_AFTER_MS) return;
354 }
355 yield* fs.remove(target, { recursive: true, force: true });
356 }).pipe(
357 Effect.catch((cause) =>
358 Effect.logWarning("Could not remove an old preview browser.", { name, cause }),
359 ),
360 ),
361 { discard: true },
362 );
363 return path.join(installRoot, release.version, executableName);
364 },
365 Effect.mapError(wrapFailure("Could not save the browser in T3's home directory.")),
366 );
367
368 // Joins the current install or starts one. A failure is reported once, then cleared.
369 const join = (release: PreviewBrowserRelease) =>
370 gate.withPermit(
371 Effect.gen(function* () {
372 if (state._tag === "installing") return state;
373 if (state._tag === "failed") {
374 const failed = state;
375 state = { _tag: "idle" };
376 return failed;
377 }
378 const installed = yield* installedExecutable(release);
379 if (Option.isSome(installed)) {
380 state = { _tag: "installed", executable: installed.value };
381 return state;
382 }
383 const done = yield* Deferred.make<string, PreviewBrowserInstallError>();
384 progress.downloadedBytes = 0;
385 progress.unpacking = false;
386 const installing: InstallState = { _tag: "installing", done };
387 state = installing;
388 yield* install(release).pipe(
389 Effect.onExit((exit) =>
390 gate
391 .withPermit(
392 Effect.sync(() => {
393 state = Exit.isSuccess(exit)
394 ? { _tag: "installed", executable: exit.value }
395 : {
396 _tag: "failed",
397 error: Option.getOrElse(
398 Cause.findErrorOption(exit.cause),
399 () => new PreviewBrowserInstallError({ detail: "The install stopped." }),
400 ),
401 };
402 }),
403 )
404 .pipe(Effect.andThen(Deferred.done(done, exit))),
405 ),
406 Effect.ignoreCause,
407 // Server shutdown closes the service scope and stops an install in progress.
408 Effect.interruptible,
409 Effect.forkIn(serviceScope),
410 );
411 return installing;
412 }),
413 );
414
415 // A waiter that saw the failure has reported it; the next call retries.
416 const clearFailure = (error: PreviewBrowserInstallError) =>
417 gate.withPermit(
418 Effect.sync(() => {
419 if (state._tag === "failed" && state.error === error) state = { _tag: "idle" };
420 }),
421 );
422
423 const executable = Effect.gen(function* () {
424 if (release === null) {
425 return yield* new PreviewBrowserUnsupportedError({ platform, arch });
426 }
427 const current = yield* join(release);
428 switch (current._tag) {
429 case "installed":
430 return current.executable;
431 case "failed":
432 return yield* current.error;
433 case "installing":
434 return yield* Deferred.await(current.done).pipe(
435 Effect.tapError(clearFailure),
436 Effect.timeoutOrElse({
437 duration: wait,
438 orElse: () =>
439 Effect.fail(
440 new PreviewBrowserInstallingError({
441 downloadedBytes: progress.downloadedBytes,
442 totalBytes: release.bytes,
443 unpacking: progress.unpacking,
444 }),
445 ),
446 }),
447 );
448 }
449 }).pipe(Effect.withSpan("PreviewBrowser.executable"));
450
451 const installed = release === null ? Effect.succeedNone : installedExecutable(release);
452
453 return PreviewBrowser.of({ executable, installed });
454});
455
456export const layer = Layer.effect(
457 PreviewBrowser,
458 Effect.gen(function* () {
459 const config = yield* ServerConfig.ServerConfig;
460 return yield* makePreviewBrowser({ baseDir: config.baseDir });
461 }),
462);
apps/server/src/htmlRender/headlessChrome.ts+15-25
...@@ -14,24 +14,19 @@ import { publicProxy } from "./publicProxy.ts";...@@ -14,24 +14,19 @@ import { publicProxy } from "./publicProxy.ts";
1414
15export class HtmlRenderBrowserError extends Schema.TaggedError<HtmlRenderBrowserError>()(15export class HtmlRenderBrowserError extends Schema.TaggedError<HtmlRenderBrowserError>()(
16 "HtmlRenderBrowserError",16 "HtmlRenderBrowserError",
17 { reason: Schema.String, cause: Schema.optional(Schema.Defect()) },17 {
18 reason: Schema.String,
19 cause: Schema.optional(Schema.Defect()),
20 /** The end of the browser's stderr when it exited, for diagnosing host setup. */
21 output: Schema.optional(Schema.String),
22 },
18) {23) {
19 override get message(): string {24 override get message(): string {
20 return `Headless Chrome could not render the page: ${this.reason}.`;25 return `Headless Chrome could not render the page: ${this.reason}.`;
21 }26 }
22}27}
2328
24/** The browser died at startup because this host cannot give it a sandbox. */29type BrowserFailure = HtmlRenderBrowserError;
25export class HtmlRenderSandboxUnavailableError extends Schema.TaggedError<HtmlRenderSandboxUnavailableError>()(
26 "HtmlRenderSandboxUnavailableError",
27 {},
28) {
29 override get message(): string {
30 return "Chrome's sandbox is unavailable on this host.";
31 }
32}
33
34type BrowserFailure = HtmlRenderBrowserError | HtmlRenderSandboxUnavailableError;
3530
36export interface ConsoleMessage {31export interface ConsoleMessage {
37 readonly level: "log" | "info" | "warning" | "error";32 readonly level: "log" | "info" | "warning" | "error";
...@@ -67,9 +62,6 @@ const textEncoder = new TextEncoder();...@@ -67,9 +62,6 @@ const textEncoder = new TextEncoder();
67 */62 */
68const pageBody = (html: string) =>63const pageBody = (html: string) =>
69 Buffer.from(Buffer.from(html, "utf8").toString("base64"), "latin1");64 Buffer.from(Buffer.from(html, "utf8").toString("base64"), "latin1");
70// What Chrome prints before aborting when it cannot sandbox itself, e.g. on
71// Ubuntu 23.10+ where AppArmor restricts unprivileged user namespaces.
72const NO_SANDBOX_SIGNATURE = "No usable sandbox";
7365
74const CdpMessage = Schema.fromJsonString(66const CdpMessage = Schema.fromJsonString(
75 Schema.Struct({67 Schema.Struct({
...@@ -239,13 +231,11 @@ const launchBrowser = Effect.fnUntraced(function* (input: {...@@ -239,13 +231,11 @@ const launchBrowser = Effect.fnUntraced(function* (input: {
239 );231 );
240232
241 let stderrTail = "";233 let stderrTail = "";
242 let sandboxUnavailable = false;
243 const stderrReader = yield* child.stderr.pipe(234 const stderrReader = yield* child.stderr.pipe(
244 Stream.decodeText(),235 Stream.decodeText(),
245 Stream.runForEach((text) =>236 Stream.runForEach((text) =>
246 Effect.sync(() => {237 Effect.sync(() => {
247 stderrTail = (stderrTail + text).slice(-1_024);238 stderrTail = (stderrTail + text).slice(-2_048);
248 if (stderrTail.includes(NO_SANDBOX_SIGNATURE)) sandboxUnavailable = true;
249 }),239 }),
250 ),240 ),
251 Effect.ignore,241 Effect.ignore,
...@@ -340,13 +330,14 @@ const launchBrowser = Effect.fnUntraced(function* (input: {...@@ -340,13 +330,14 @@ const launchBrowser = Effect.fnUntraced(function* (input: {
340 return Effect.void;330 return Effect.void;
341 };331 };
342332
343 // The pipe closes when the browser exits. A sandbox abort says why on333 // The pipe closes when the browser exits. A startup abort, such as a missing
344 // stderr, which may still be draining, so give it a moment.334 // sandbox, says why on stderr, which may still be draining, so give it a moment.
345 const disconnect = Effect.gen(function* () {335 const disconnect = Effect.gen(function* () {
346 yield* Fiber.await(stderrReader).pipe(Effect.timeout("1 second"), Effect.ignore);336 yield* Fiber.await(stderrReader).pipe(Effect.timeout("1 second"), Effect.ignore);
347 const error = sandboxUnavailable337 const error = new HtmlRenderBrowserError({
348 ? new HtmlRenderSandboxUnavailableError()338 reason: "the browser exited unexpectedly",
349 : new HtmlRenderBrowserError({ reason: "the browser exited unexpectedly" });339 output: stderrTail,
340 });
350 disconnected = error;341 disconnected = error;
351 const waiters = [...pending.values()];342 const waiters = [...pending.values()];
352 pending.clear();343 pending.clear();
...@@ -396,8 +387,7 @@ const launchBrowser = Effect.fnUntraced(function* (input: {...@@ -396,8 +387,7 @@ const launchBrowser = Effect.fnUntraced(function* (input: {
396 return yield* Deferred.await(reply).pipe(387 return yield* Deferred.await(reply).pipe(
397 Effect.flatMap(Schema.decodeUnknownEffect(result)),388 Effect.flatMap(Schema.decodeUnknownEffect(result)),
398 Effect.mapError((error) =>389 Effect.mapError((error) =>
399 error._tag === "HtmlRenderBrowserError" ||390 error._tag === "HtmlRenderBrowserError"
400 error._tag === "HtmlRenderSandboxUnavailableError"
401 ? error391 ? error
402 : new HtmlRenderBrowserError({392 : new HtmlRenderBrowserError({
403 reason: `${method} returned an unexpected result`,393 reason: `${method} returned an unexpected result`,
apps/server/src/mcp/McpHttpServer.test.ts+39
...@@ -468,6 +468,45 @@ it.effect(...@@ -468,6 +468,45 @@ it.effect(
468 }).pipe(Effect.provide(layerPullRequestsTest)),468 }).pipe(Effect.provide(layerPullRequestsTest)),
469);469);
470470
471it.effect("returns server ARIA refs to agents in text and structured results", () =>
472 Effect.scoped(
473 Effect.gen(function* () {
474 const accessibilityTree =
475 '- button "delete" [ref=t3-snapshot-e1]\n- iframe [ref=t3-snapshot-e2]:\n - textbox "child" [ref=t3-snapshot-f1e1]';
476 yield* serveSnapshots("mcp-server-refs", { ...snapshotResult, accessibilityTree });
477 const result = yield* callSnapshot({ includeImage: false });
478 expect(result.isError).toBe(false);
479 expect(result.structuredContent).toMatchObject({ accessibilityTree });
480 expect(
481 result.content.some(
482 (entry) => entry.type === "text" && entry.text.includes("t3-snapshot-f1e1"),
483 ),
484 ).toBe(true);
485 }),
486 ).pipe(Effect.provide(layerTest)),
487);
488
489it.effect("bounds large ARIA trees without dropping every server locator", () =>
490 Effect.scoped(
491 Effect.gen(function* () {
492 const accessibilityTree = Array.from(
493 { length: 2_000 },
494 (_, index) => `- button "删除 ${index}" [ref=t3-snapshot-e${index}]`,
495 ).join("\n");
496 yield* serveSnapshots("mcp-large-server-refs", { ...snapshotResult, accessibilityTree });
497 const result = yield* callSnapshot({ includeImage: false });
498 expect(result.isError).toBe(false);
499 const metadata = result.structuredContent as { accessibilityTree: string };
500 expect(metadata.accessibilityTree).toContain("[ref=t3-snapshot-e0]");
501 expect(metadata.accessibilityTree.length).toBeLessThan(accessibilityTree.length);
502 const body = result.content[1];
503 expect(Buffer.byteLength(body?.type === "text" ? body.text : "", "utf8")).toBeLessThanOrEqual(
504 McpHttpServer.MAX_SNAPSHOT_TEXT_BYTES,
505 );
506 }),
507 ).pipe(Effect.provide(layerTest)),
508);
509
471it.effect("keeps the snapshot text under the agent's output ceiling", () =>510it.effect("keeps the snapshot text under the agent's output ceiling", () =>
472 Effect.scoped(511 Effect.scoped(
473 Effect.gen(function* () {512 Effect.gen(function* () {
apps/server/src/mcp/McpHttpServer.ts+14-5
...@@ -19,7 +19,6 @@ import packageJson from "../../package.json" with { type: "json" };...@@ -19,7 +19,6 @@ import packageJson from "../../package.json" with { type: "json" };
19import * as ServerConfig from "../config.ts";19import * as ServerConfig from "../config.ts";
20import * as DeviceService from "../device/DeviceService.ts";20import * as DeviceService from "../device/DeviceService.ts";
21import * as HtmlRender from "../htmlRender/HtmlRender.ts";21import * as HtmlRender from "../htmlRender/HtmlRender.ts";
22import * as PreviewBrowser from "../htmlRender/PreviewBrowser.ts";
23import * as McpInvocationContext from "./McpInvocationContext.ts";22import * as McpInvocationContext from "./McpInvocationContext.ts";
24import * as OrchestratorMcpService from "./OrchestratorMcpService.ts";23import * as OrchestratorMcpService from "./OrchestratorMcpService.ts";
25import { PreviewControlsToolkit } from "./toolkits/previewControls/tools.ts";24import { PreviewControlsToolkit } from "./toolkits/previewControls/tools.ts";
...@@ -181,7 +180,7 @@ type SnapshotMetadata = {...@@ -181,7 +180,7 @@ type SnapshotMetadata = {
181};180};
182181
183/**182/**
184 * Drops the accessibility tree, shortens page text, element names, identifiers,183 * Keeps server ARIA refs, drops legacy object trees, shortens page text, names, identifiers,
185 * and log strings, keeps only the newest log entries, and finally sheds184 * and log strings, keeps only the newest log entries, and finally sheds
186 * interactive elements until the JSON fits. Returns the bounded value, its185 * interactive elements until the JSON fits. Returns the bounded value, its
187 * text, and notes on what is missing so the agent can reach for186 * text, and notes on what is missing so the agent can reach for
...@@ -190,7 +189,8 @@ type SnapshotMetadata = {...@@ -190,7 +189,8 @@ type SnapshotMetadata = {
190const boundSnapshotMetadata = (metadata: SnapshotMetadata) => {189const boundSnapshotMetadata = (metadata: SnapshotMetadata) => {
191 const omitted: Array<string> = [];190 const omitted: Array<string> = [];
192 const { accessibilityTree, ...withoutTree } = metadata;191 const { accessibilityTree, ...withoutTree } = metadata;
193 if (accessibilityTree !== undefined) {192 const ariaTree = typeof accessibilityTree === "string" ? accessibilityTree : undefined;
193 if (accessibilityTree !== undefined && ariaTree === undefined) {
194 omitted.push("accessibilityTree (use interactiveElements locators or preview_evaluate)");194 omitted.push("accessibilityTree (use interactiveElements locators or preview_evaluate)");
195 }195 }
196 const tail = <A>(entries: ReadonlyArray<A>, label: string) => {196 const tail = <A>(entries: ReadonlyArray<A>, label: string) => {
...@@ -253,8 +253,10 @@ const boundSnapshotMetadata = (metadata: SnapshotMetadata) => {...@@ -253,8 +253,10 @@ const boundSnapshotMetadata = (metadata: SnapshotMetadata) => {
253 actionTimeline: 0,253 actionTimeline: 0,
254 };254 };
255 let visibleTextChars = Math.min(metadata.visibleText.length, MAX_SNAPSHOT_VISIBLE_TEXT_CHARS);255 let visibleTextChars = Math.min(metadata.visibleText.length, MAX_SNAPSHOT_VISIBLE_TEXT_CHARS);
256 let ariaTreeChars = Math.min(ariaTree?.length ?? 0, 20_000);
256 const value = () => ({257 const value = () => ({
257 ...bounded,258 ...bounded,
259 ...(ariaTree === undefined ? {} : { accessibilityTree: cutText(ariaTree, ariaTreeChars) }),
258 visibleText: cutText(metadata.visibleText, visibleTextChars),260 visibleText: cutText(metadata.visibleText, visibleTextChars),
259 ...lists,261 ...lists,
260 });262 });
...@@ -269,10 +271,14 @@ const boundSnapshotMetadata = (metadata: SnapshotMetadata) => {...@@ -269,10 +271,14 @@ const boundSnapshotMetadata = (metadata: SnapshotMetadata) => {
269 ? "visibleText"271 ? "visibleText"
270 : lists.interactiveElements.length > 0272 : lists.interactiveElements.length > 0
271 ? "interactiveElements"273 ? "interactiveElements"
272 : undefined);274 : ariaTreeChars > 0
275 ? "accessibilityTree"
276 : undefined);
273 if (key === undefined) break;277 if (key === undefined) break;
274 if (key === "visibleText") {278 if (key === "visibleText") {
275 visibleTextChars = Math.floor(visibleTextChars / 2);279 visibleTextChars = Math.floor(visibleTextChars / 2);
280 } else if (key === "accessibilityTree") {
281 ariaTreeChars = Math.floor(ariaTreeChars / 2);
276 } else {282 } else {
277 const keep = Math.floor(lists[key].length / 2);283 const keep = Math.floor(lists[key].length / 2);
278 dropped[key] += lists[key].length - keep;284 dropped[key] += lists[key].length - keep;
...@@ -291,6 +297,9 @@ const boundSnapshotMetadata = (metadata: SnapshotMetadata) => {...@@ -291,6 +297,9 @@ const boundSnapshotMetadata = (metadata: SnapshotMetadata) => {
291 `visibleText after ${visibleTextChars} characters (use preview_evaluate for more)`,297 `visibleText after ${visibleTextChars} characters (use preview_evaluate for more)`,
292 );298 );
293 }299 }
300 if (ariaTree !== undefined && ariaTreeChars < ariaTree.length) {
301 omitted.push(`accessibilityTree after ${ariaTreeChars} characters`);
302 }
294 for (const key of shedOrder) {303 for (const key of shedOrder) {
295 if (dropped[key] > 0) {304 if (dropped[key] > 0) {
296 omitted.push(`${dropped[key]} of ${bounded[key].length} ${key}`);305 omitted.push(`${dropped[key]} of ${bounded[key].length} ${key}`);
...@@ -683,7 +692,7 @@ const registerHtmlPreview = Effect.fn("McpHttpServer.registerHtmlPreview")(funct...@@ -683,7 +692,7 @@ const registerHtmlPreview = Effect.fn("McpHttpServer.registerHtmlPreview")(funct
683export const layerHtmlToolkit = Layer.mergeAll(692export const layerHtmlToolkit = Layer.mergeAll(
684 McpServer.toolkit(HtmlRenderToolkit).pipe(Layer.provide(HtmlHandlers.layerRender)),693 McpServer.toolkit(HtmlRenderToolkit).pipe(Layer.provide(HtmlHandlers.layerRender)),
685 Layer.effectDiscard(registerHtmlPreview()).pipe(Layer.provide(HtmlHandlers.layerPreview)),694 Layer.effectDiscard(registerHtmlPreview()).pipe(Layer.provide(HtmlHandlers.layerPreview)),
686).pipe(Layer.provide(HtmlRender.layer), Layer.provide(PreviewBrowser.layer));695).pipe(Layer.provide(HtmlRender.layer));
687696
688const layerPreviewStandardToolkitRegistration = McpServer.toolkit(PreviewStandardToolkit).pipe(697const layerPreviewStandardToolkitRegistration = McpServer.toolkit(PreviewStandardToolkit).pipe(
689 Layer.provide(PreviewHandlers.layerStandard),698 Layer.provide(PreviewHandlers.layerStandard),
apps/server/src/mcp/PreviewAutomationBroker.test.ts+108-112
...@@ -16,6 +16,7 @@ import {...@@ -16,6 +16,7 @@ import {
16 type PreviewAutomationHost,16 type PreviewAutomationHost,
17 type PreviewAutomationRequest,17 type PreviewAutomationRequest,
18 type PreviewAutomationStreamEvent,18 type PreviewAutomationStreamEvent,
19 SERVER_BROWSER_AUTOMATION_CLIENT_ID,
19} from "@t3tools/contracts";20} from "@t3tools/contracts";
20import * as Effect from "effect/Effect";21import * as Effect from "effect/Effect";
21import * as Layer from "effect/Layer";22import * as Layer from "effect/Layer";
...@@ -332,6 +333,44 @@ it.effect("announces a live replacement stream before delivering requests", () =...@@ -332,6 +333,44 @@ it.effect("announces a live replacement stream before delivering requests", () =
332 ),333 ),
333);334);
334335
336it.effect(
337 "keeps a server-host open alive for installation without extending other operations",
338 () =>
339 Effect.scoped(
340 Effect.gen(function* () {
341 const broker = yield* makeBroker;
342 const received = yield* Deferred.make<RoutedRequest>();
343 const requests = requestsFrom(yield* broker.connect(makeHost(), { preferred: true }));
344 yield* Stream.runForEach(requests, (request) =>
345 request.operation === "open"
346 ? Deferred.succeed(received, request)
347 : broker.respond({
348 clientId: "client-1",
349 connectionId: request.connectionId,
350 requestId: request.requestId,
351 ok: true,
352 result: request.timeoutMs,
353 }),
354 ).pipe(Effect.forkScoped);
355 const opening = yield* broker
356 .invoke({ scope, operation: "open", input: {} })
357 .pipe(Effect.forkScoped);
358 const request = yield* Deferred.await(received);
359 yield* TestClock.adjust(16_000);
360 yield* broker.respond({
361 clientId: "client-1",
362 connectionId: request.connectionId,
363 requestId: request.requestId,
364 ok: true,
365 result: "opened",
366 });
367 expect(yield* Fiber.join(opening)).toBe("opened");
368 expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe(15_000);
369 expect(yield* broker.invoke({ scope, operation: "navigate", input: {} })).toBe(15_000);
370 }),
371 ),
372);
373
335it.effect("preserves bounded request and remote selector diagnostics", () => {374it.effect("preserves bounded request and remote selector diagnostics", () => {
336 const locator = "role=button[name='request-secret']";375 const locator = "role=button[name='request-secret']";
337 const remoteMessage = "Unexpected token near remote-secret.";376 const remoteMessage = "Unexpected token near remote-secret.";
...@@ -481,6 +520,37 @@ it.effect.each([...@@ -481,6 +520,37 @@ it.effect.each([
481 ),520 ),
482);521);
483522
523it.effect.each([
524 { clientId: SERVER_BROWSER_AUTOMATION_CLIENT_ID, shown: true },
525 { clientId: "client-1", shown: false },
526])("tells the agent why its own server browser failed ($clientId)", ({ clientId, shown }) =>
527 Effect.scoped(
528 Effect.gen(function* () {
529 const broker = yield* makeBroker;
530 const requests = requestsFrom(yield* broker.connect(makeHost({ clientId })));
531 yield* Stream.runForEach(requests, (request) =>
532 broker.respond({
533 clientId,
534 connectionId: request.connectionId,
535 requestId: request.requestId,
536 ok: false,
537 error: {
538 _tag: "PreviewAutomationExecutionError",
539 message: "page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:4719/",
540 },
541 }),
542 ).pipe(Effect.forkScoped);
543 yield* Effect.yieldNow;
544 const error = yield* broker
545 .invoke<void>({ scope, operation: "open", input: {} })
546 .pipe(Effect.flip);
547 expect(error._tag).toBe("PreviewAutomationExecutionError");
548 // A desktop or other remote host's text stays out of the agent's context.
549 expect(error.message.includes("ERR_CONNECTION_REFUSED")).toBe(shown);
550 }),
551 ),
552);
553
484it.effect("distinguishes malformed remote failures", () =>554it.effect("distinguishes malformed remote failures", () =>
485 Effect.scoped(555 Effect.scoped(
486 Effect.gen(function* () {556 Effect.gen(function* () {
...@@ -1256,118 +1326,6 @@ it.effect("accepts responses only from the host that received the request", () =...@@ -1256,118 +1326,6 @@ it.effect("accepts responses only from the host that received the request", () =
1256 ),1326 ),
1257);1327);
12581328
1259it.effect("evicts an unanswered host and lets later calls use a healthy runtime", () =>
1260 Effect.scoped(
1261 Effect.gen(function* () {
1262 const broker = yield* makeBroker;
1263 const connected = yield* Deferred.make<string>();
1264 const received = yield* Deferred.make<RoutedRequest>();
1265 const otherReceived = yield* Deferred.make<void>();
1266 const otherCompleted = yield* Deferred.make<void>();
1267 const oldTab = PreviewTabId.make("tab-on-frozen-host");
1268 const group = RpcGroup.make(
1269 ...Array.from(WsRpcGroup.requests.values()).filter(
1270 (rpc) => rpc._tag === WS_METHODS.previewAutomationConnect,
1271 ),
1272 );
1273 const client = yield* RpcTest.makeClient(group).pipe(
1274 Effect.provide(
1275 Layer.merge(
1276 group.toLayer({
1277 [WS_METHODS.previewAutomationConnect]: (host) => Stream.unwrap(broker.connect(host)),
1278 }),
1279 RpcAuthorization.layer([AuthOrchestrationOperateScope]),
1280 ),
1281 ),
1282 );
1283 const events = client[WS_METHODS.previewAutomationConnect](makeHost());
1284 const consumer = yield* Stream.runForEach(events, (event) => {
1285 if (event.type === "connected") return Deferred.succeed(connected, event.connectionId);
1286 const request = { ...event.request, connectionId: event.connectionId };
1287 if (request.operation === "open") {
1288 return broker.respond({
1289 clientId: "client-1",
1290 connectionId: event.connectionId,
1291 requestId: request.requestId,
1292 ok: true,
1293 result: { tabId: oldTab },
1294 });
1295 }
1296 return request.operation === "snapshot"
1297 ? Deferred.succeed(received, request)
1298 : Deferred.succeed(otherReceived, undefined);
1299 }).pipe(Effect.forkScoped);
1300 const connectionId = yield* Deferred.await(connected);
1301 yield* broker.invoke({ scope, operation: "open", input: {} });
1302
1303 const healthyConnected = yield* Deferred.make<void>();
1304 const healthyRequests: RoutedRequest[] = [];
1305 const healthy = yield* broker.connect(makeHost({ clientId: "healthy" }));
1306 yield* Stream.runForEach(healthy, (event) => {
1307 if (event.type === "connected") return Deferred.succeed(healthyConnected, undefined);
1308 healthyRequests.push({ ...event.request, connectionId: event.connectionId });
1309 return broker.respond({
1310 clientId: "healthy",
1311 connectionId: event.connectionId,
1312 requestId: event.request.requestId,
1313 ok: true,
1314 result: "healthy",
1315 });
1316 }).pipe(Effect.forkScoped);
1317 yield* Deferred.await(healthyConnected);
1318
1319 const timedOut = yield* broker
1320 .invoke<void>({
1321 scope,
1322 operation: "snapshot",
1323 input: {},
1324 timeoutMs: 1_000,
1325 })
1326 .pipe(Effect.flip, Effect.forkScoped);
1327 const lateRequest = yield* Deferred.await(received);
1328 const other = yield* broker
1329 .invoke<void>({
1330 scope,
1331 operation: "evaluate",
1332 input: {},
1333 timeoutMs: 10_000,
1334 })
1335 .pipe(
1336 Effect.flip,
1337 Effect.tap(() => Deferred.succeed(otherCompleted, undefined)),
1338 Effect.forkScoped,
1339 );
1340 yield* Deferred.await(otherReceived);
1341 yield* TestClock.adjust(1_000);
1342 expect(yield* Fiber.join(timedOut)).toMatchObject({ _tag: "PreviewAutomationTimeoutError" });
1343 expect(yield* Deferred.isDone(otherCompleted)).toBe(true);
1344 expect(yield* Fiber.join(other)).toMatchObject({
1345 _tag: "PreviewAutomationClientDisconnectedError",
1346 });
1347 const consumerExit = yield* Fiber.await(consumer);
1348 expect(Exit.isSuccess(consumerExit)).toBe(true);
1349
1350 // Late traffic from the evicted connection cannot restore its assignment.
1351 yield* broker.respond({
1352 clientId: "client-1",
1353 connectionId,
1354 requestId: lateRequest.requestId,
1355 ok: true,
1356 result: { tabId: oldTab },
1357 });
1358 yield* broker.focusHost({
1359 clientId: "client-1",
1360 connectionId,
1361 environmentId: scope.environmentId,
1362 focused: true,
1363 });
1364 expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe("healthy");
1365 expect(healthyRequests).toHaveLength(1);
1366 expect(healthyRequests[0]?.tabId).toBeUndefined();
1367 }),
1368 ),
1369);
1370
1371it.effect("discards buffered actions before completing an evicted host stream", () =>1329it.effect("discards buffered actions before completing an evicted host stream", () =>
1372 Effect.scoped(1330 Effect.scoped(
1373 Effect.gen(function* () {1331 Effect.gen(function* () {
...@@ -1505,3 +1463,41 @@ it.effect("keeps a host that responds with an operation timeout", () =>...@@ -1505,3 +1463,41 @@ it.effect("keeps a host that responds with an operation timeout", () =>
1505 }),1463 }),
1506 ),1464 ),
1507);1465);
1466
1467it.effect("keeps the host connected when a background status read times out", () =>
1468 Effect.scoped(
1469 Effect.gen(function* () {
1470 const broker = yield* makeBroker;
1471 const requests = requestsFrom(yield* broker.connect(makeHost()));
1472 // A busy host answers its actions but not the metadata read behind them.
1473 yield* Stream.runForEach(requests, (request) =>
1474 request.operation === "status"
1475 ? Effect.void
1476 : broker.respond({
1477 clientId: "client-1",
1478 connectionId: request.connectionId,
1479 requestId: request.requestId,
1480 ok: true,
1481 result: { operation: request.operation },
1482 }),
1483 ).pipe(Effect.forkScoped);
1484 yield* Effect.yieldNow;
1485
1486 const status = yield* broker
1487 .invoke<void>({
1488 scope,
1489 operation: "status",
1490 input: {},
1491 timeoutMs: 500,
1492 updateCurrentTab: false,
1493 })
1494 .pipe(Effect.flip, Effect.forkScoped);
1495 yield* TestClock.adjust(500);
1496 expect(yield* Fiber.join(status)).toMatchObject({ _tag: "PreviewAutomationTimeoutError" });
1497
1498 expect(yield* broker.invoke({ scope, operation: "snapshot", input: {} })).toEqual({
1499 operation: "snapshot",
1500 });
1501 }),
1502 ),
1503);
apps/server/src/mcp/PreviewAutomationBroker.ts+49-5
...@@ -2,7 +2,9 @@ import {...@@ -2,7 +2,9 @@ import {
2 PREVIEW_AUTOMATION_V1_OPERATIONS,2 PREVIEW_AUTOMATION_V1_OPERATIONS,
3 PreviewAutomationClientDisconnectedError,3 PreviewAutomationClientDisconnectedError,
4 PreviewAutomationControlInterruptedError,4 PreviewAutomationControlInterruptedError,
5 PreviewAutomationControlReason,
5 PreviewAutomationExecutionError,6 PreviewAutomationExecutionError,
7 SERVER_BROWSER_AUTOMATION_CLIENT_ID,
6 PreviewAutomationInvalidSelectorError,8 PreviewAutomationInvalidSelectorError,
7 PreviewAutomationMalformedResponseError,9 PreviewAutomationMalformedResponseError,
8 PreviewAutomationNoAvailableHostError,10 PreviewAutomationNoAvailableHostError,
...@@ -52,11 +54,21 @@ export interface PreviewAutomationInvokeInput {...@@ -52,11 +54,21 @@ export interface PreviewAutomationInvokeInput {
52 readonly onTargetTab?: (tabId: PreviewTabId | undefined) => void;54 readonly onTargetTab?: (tabId: PreviewTabId | undefined) => void;
53}55}
5456
57export interface PreviewAutomationConnectOptions {
58 /**
59 * New agent work goes to a preferred host before any desktop. The server's
60 * own headless browser registers this way so a standalone environment keeps
61 * browsing when every desktop disconnects.
62 */
63 readonly preferred?: boolean;
64}
65
55export class PreviewAutomationBroker extends Context.Service<66export class PreviewAutomationBroker extends Context.Service<
56 PreviewAutomationBroker,67 PreviewAutomationBroker,
57 {68 {
58 readonly connect: (69 readonly connect: (
59 host: PreviewAutomationHost,70 host: PreviewAutomationHost,
71 options?: PreviewAutomationConnectOptions,
60 ) => Effect.Effect<Stream.Stream<PreviewAutomationStreamEvent>>;72 ) => Effect.Effect<Stream.Stream<PreviewAutomationStreamEvent>>;
61 readonly focusHost: (host: PreviewAutomationHostFocus) => Effect.Effect<void>;73 readonly focusHost: (host: PreviewAutomationHostFocus) => Effect.Effect<void>;
62 readonly respond: (74 readonly respond: (
...@@ -74,6 +86,7 @@ interface ClientConnection {...@@ -74,6 +86,7 @@ interface ClientConnection {
74 readonly environmentId: PreviewAutomationHost["environmentId"];86 readonly environmentId: PreviewAutomationHost["environmentId"];
75 readonly supportedOperations: ReadonlySet<PreviewAutomationOperation>;87 readonly supportedOperations: ReadonlySet<PreviewAutomationOperation>;
76 readonly focused: boolean;88 readonly focused: boolean;
89 readonly preferred: boolean;
77 readonly liveTabs: NonNullable<PreviewAutomationHostFocus["liveTabs"]>;90 readonly liveTabs: NonNullable<PreviewAutomationHostFocus["liveTabs"]>;
78 readonly focusOrder: number;91 readonly focusOrder: number;
79 readonly queue: Queue.Queue<PreviewAutomationStreamEvent, Cause.Done>;92 readonly queue: Queue.Queue<PreviewAutomationStreamEvent, Cause.Done>;
...@@ -165,6 +178,7 @@ const hostAssignmentKey = (scope: McpInvocationContext.McpThreadInvocationScope)...@@ -165,6 +178,7 @@ const hostAssignmentKey = (scope: McpInvocationContext.McpThreadInvocationScope)
165 `${scope.environmentId}\u0000${scope.thread.providerSessionId}`;178 `${scope.environmentId}\u0000${scope.thread.providerSessionId}`;
166179
167const isPreviewTabId = Schema.is(PreviewTabId);180const isPreviewTabId = Schema.is(PreviewTabId);
181const decodeControlReason = Schema.decodeUnknownOption(PreviewAutomationControlReason);
168182
169const readResultTabId = (result: unknown): PreviewTabId | null | undefined => {183const readResultTabId = (result: unknown): PreviewTabId | null | undefined => {
170 if (typeof result !== "object" || result === null || !("tabId" in result)) return undefined;184 if (typeof result !== "object" || result === null || !("tabId" in result)) return undefined;
...@@ -194,6 +208,9 @@ function remoteDetailKind(detail: unknown): RemoteDetailKind {...@@ -194,6 +208,9 @@ function remoteDetailKind(detail: unknown): RemoteDetailKind {
194 }208 }
195}209}
196210
211/** Enough for a browser's own error line, such as a refused connection and its URL. */
212const MAX_REASON_CHARS = 500;
213
197const classifyResponseError = (214const classifyResponseError = (
198 context: PreviewAutomationRequestErrorContext,215 context: PreviewAutomationRequestErrorContext,
199 error: NonNullable<PreviewAutomationResponse["error"]>,216 error: NonNullable<PreviewAutomationResponse["error"]>,
...@@ -245,15 +262,24 @@ const classifyResponseError = (...@@ -245,15 +262,24 @@ const classifyResponseError = (
245 ...context,262 ...context,
246 ...remoteDiagnostics,263 ...remoteDiagnostics,
247 });264 });
248 case "PreviewAutomationControlInterruptedError":265 case "PreviewAutomationControlInterruptedError": {
266 const reason = decodeControlReason(error.detail);
249 return new PreviewAutomationControlInterruptedError({267 return new PreviewAutomationControlInterruptedError({
250 ...context,268 ...context,
251 ...remoteDiagnostics,269 ...remoteDiagnostics,
270 ...(Option.isSome(reason) ? { reason: reason.value } : {}),
252 });271 });
272 }
253 case "PreviewAutomationInvalidSelectorError": {273 case "PreviewAutomationInvalidSelectorError": {
274 const staleRef =
275 typeof error.detail === "object" &&
276 error.detail !== null &&
277 "staleRef" in error.detail &&
278 error.detail.staleRef === true;
254 return new PreviewAutomationInvalidSelectorError({279 return new PreviewAutomationInvalidSelectorError({
255 ...context,280 ...context,
256 ...remoteDiagnostics,281 ...remoteDiagnostics,
282 ...(staleRef ? { staleRef } : {}),
257 });283 });
258 }284 }
259 case "PreviewAutomationTargetNotEditableError": {285 case "PreviewAutomationTargetNotEditableError": {
...@@ -312,6 +338,10 @@ const classifyResponseError = (...@@ -312,6 +338,10 @@ const classifyResponseError = (
312 return new PreviewAutomationExecutionError({338 return new PreviewAutomationExecutionError({
313 ...context,339 ...context,
314 ...remoteDiagnostics,340 ...remoteDiagnostics,
341 // The server's own browser writes these; other hosts' text stays out of the agent's context.
342 ...(context.clientId === SERVER_BROWSER_AUTOMATION_CLIENT_ID
343 ? { reason: error.message.slice(0, MAX_REASON_CHARS) }
344 : {}),
315 });345 });
316 }346 }
317};347};
...@@ -367,6 +397,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {...@@ -367,6 +397,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
367397
368 const acquireConnection = Effect.fn("PreviewAutomationBroker.acquireConnection")(function* (398 const acquireConnection = Effect.fn("PreviewAutomationBroker.acquireConnection")(function* (
369 host: PreviewAutomationHost,399 host: PreviewAutomationHost,
400 options: PreviewAutomationConnectOptions | undefined,
370 ) {401 ) {
371 const clientId = host.clientId;402 const clientId = host.clientId;
372 const queue = yield* Queue.unbounded<PreviewAutomationStreamEvent, Cause.Done>();403 const queue = yield* Queue.unbounded<PreviewAutomationStreamEvent, Cause.Done>();
...@@ -378,6 +409,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {...@@ -378,6 +409,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
378 environmentId: host.environmentId,409 environmentId: host.environmentId,
379 supportedOperations: new Set(host.supportedOperations ?? PREVIEW_AUTOMATION_V1_OPERATIONS),410 supportedOperations: new Set(host.supportedOperations ?? PREVIEW_AUTOMATION_V1_OPERATIONS),
380 focused: false,411 focused: false,
412 preferred: options?.preferred ?? false,
381 liveTabs: [],413 liveTabs: [],
382 focusOrder: 0,414 focusOrder: 0,
383 queue,415 queue,
...@@ -408,10 +440,10 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {...@@ -408,10 +440,10 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
408440
409 const connect: PreviewAutomationBroker["Service"]["connect"] = Effect.fn(441 const connect: PreviewAutomationBroker["Service"]["connect"] = Effect.fn(
410 "PreviewAutomationBroker.connect",442 "PreviewAutomationBroker.connect",
411 )((host) =>443 )((host, options) =>
412 Effect.succeed(444 Effect.succeed(
413 Stream.unwrap(445 Stream.unwrap(
414 Effect.acquireRelease(acquireConnection(host), (connection) =>446 Effect.acquireRelease(acquireConnection(host, options), (connection) =>
415 disconnect(connection.clientId, connection.queue),447 disconnect(connection.clientId, connection.queue),
416 ).pipe(Effect.map((connection) => Stream.fromQueue(connection.queue))),448 ).pipe(Effect.map((connection) => Stream.fromQueue(connection.queue))),
417 ),449 ),
...@@ -474,7 +506,6 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {...@@ -474,7 +506,6 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
474 const invoke = Effect.fn("PreviewAutomationBroker.invoke")(function* <A = unknown>(506 const invoke = Effect.fn("PreviewAutomationBroker.invoke")(function* <A = unknown>(
475 input: Parameters<PreviewAutomationBroker["Service"]["invoke"]>[0],507 input: Parameters<PreviewAutomationBroker["Service"]["invoke"]>[0],
476 ): Effect.fn.Return<A, PreviewAutomationError> {508 ): Effect.fn.Return<A, PreviewAutomationError> {
477 const timeoutMs = input.timeoutMs ?? 15_000;
478 const deferred = yield* Deferred.make<unknown, PreviewAutomationError>();509 const deferred = yield* Deferred.make<unknown, PreviewAutomationError>();
479 const route = yield* SynchronizedRef.modify(state, (current) => {510 const route = yield* SynchronizedRef.modify(state, (current) => {
480 const assignments = new Map(511 const assignments = new Map(
...@@ -516,6 +547,9 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {...@@ -516,6 +547,9 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
516 )547 )
517 .sort(548 .sort(
518 (left, right) =>549 (left, right) =>
550 Number(input.tabId !== undefined && ownsTargetTab(right)) -
551 Number(input.tabId !== undefined && ownsTargetTab(left)) ||
552 Number(right.preferred) - Number(left.preferred) ||
519 Number(ownsTargetTab(right, true)) - Number(ownsTargetTab(left, true)) ||553 Number(ownsTargetTab(right, true)) - Number(ownsTargetTab(left, true)) ||
520 Number(ownsTargetTab(right)) - Number(ownsTargetTab(left)) ||554 Number(ownsTargetTab(right)) - Number(ownsTargetTab(left)) ||
521 Number(right.focused) - Number(left.focused) ||555 Number(right.focused) - Number(left.focused) ||
...@@ -525,6 +559,10 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {...@@ -525,6 +559,10 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
525 if (!hasLiveAssignment) assignments.delete(assignmentKey);559 if (!hasLiveAssignment) assignments.delete(assignmentKey);
526 return [undefined, { ...current, assignments }] as const;560 return [undefined, { ...current, assignments }] as const;
527 }561 }
562 // The environment host may install Chromium on its first open (up to
563 // ten minutes). Keep that request alive without replaying its effects.
564 const timeoutMs =
565 input.timeoutMs ?? (input.operation === "open" && connection.preferred ? 660_000 : 15_000);
528 const canReuseAssignedTab =566 const canReuseAssignedTab =
529 assigned !== undefined &&567 assigned !== undefined &&
530 assigned.connectionId === connection.connectionId &&568 assigned.connectionId === connection.connectionId &&
...@@ -573,6 +611,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {...@@ -573,6 +611,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
573 });611 });
574 }612 }
575 const { connection, requestId, requestContext, requestSequence } = route;613 const { connection, requestId, requestContext, requestSequence } = route;
614 const { timeoutMs } = requestContext;
576 input.onTargetTab?.(requestContext.tabId);615 input.onTargetTab?.(requestContext.tabId);
577 const removePending = SynchronizedRef.update(state, (next) => {616 const removePending = SynchronizedRef.update(state, (next) => {
578 if (!next.pending.has(requestId)) return next;617 if (!next.pending.has(requestId)) return next;
...@@ -598,6 +637,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {...@@ -598,6 +637,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
598 threadId: input.scope.thread.threadId,637 threadId: input.scope.thread.threadId,
599 tabId: requestContext.tabId,638 tabId: requestContext.tabId,
600 tabIdExplicit: input.tabId !== undefined,639 tabIdExplicit: input.tabId !== undefined,
640 agentSessionId: hostAssignmentKey(input.scope),
601 operation: input.operation,641 operation: input.operation,
602 input: input.input,642 input: input.input,
603 timeoutMs,643 timeoutMs,
...@@ -617,7 +657,11 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {...@@ -617,7 +657,11 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
617 Effect.gen(function* () {657 Effect.gen(function* () {
618 // An unanswered request invalidates this connection. Do not replay658 // An unanswered request invalidates this connection. Do not replay
619 // actions: the client may have applied them before becoming unreachable.659 // actions: the client may have applied them before becoming unreachable.
620 yield* disconnect(connection.clientId, connection.queue, true);660 // A background metadata read has a short budget and changes nothing,
661 // so a slow one must not cut the host off from the agent's next call.
662 if (input.updateCurrentTab !== false) {
663 yield* disconnect(connection.clientId, connection.queue, true);
664 }
621 return yield* new PreviewAutomationTimeoutError(requestContext);665 return yield* new PreviewAutomationTimeoutError(requestContext);
622 }),666 }),
623 onSome: (value) => Effect.succeed(value as A),667 onSome: (value) => Effect.succeed(value as A),
apps/server/src/mcp/toolkits/core.test.ts+2
...@@ -21,6 +21,7 @@ import * as ServerConfig from "../../config.ts";...@@ -21,6 +21,7 @@ import * as ServerConfig from "../../config.ts";
21import { OrchestratorProjectionError } from "../../orchestration-v2/Orchestrator.ts";21import { OrchestratorProjectionError } from "../../orchestration-v2/Orchestrator.ts";
22import * as ProviderAdapterRegistry from "../../orchestration-v2/ProviderAdapterRegistry.ts";22import * as ProviderAdapterRegistry from "../../orchestration-v2/ProviderAdapterRegistry.ts";
23import * as ThreadManagement from "../../orchestration-v2/ThreadManagementService.ts";23import * as ThreadManagement from "../../orchestration-v2/ThreadManagementService.ts";
24import * as PreviewBrowser from "../../preview/PreviewBrowser.ts";
24import * as ProjectService from "../../project/ProjectService.ts";25import * as ProjectService from "../../project/ProjectService.ts";
25import * as ProviderRegistry from "../../provider/ProviderRegistry.ts";26import * as ProviderRegistry from "../../provider/ProviderRegistry.ts";
26import * as SecretRequests from "../../secrets/SecretRequests.ts";27import * as SecretRequests from "../../secrets/SecretRequests.ts";
...@@ -205,6 +206,7 @@ it.effect("returns an HTML render reference that Codex and Claude tool rows both...@@ -205,6 +206,7 @@ it.effect("returns an HTML render reference that Codex and Claude tool rows both
205 Effect.provide(206 Effect.provide(
206 McpHttpServer.layerHtmlToolkit.pipe(207 McpHttpServer.layerHtmlToolkit.pipe(
207 Layer.provideMerge(McpServer.McpServer.layer),208 Layer.provideMerge(McpServer.McpServer.layer),
209 Layer.provide(PreviewBrowser.layer),
208 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-html-render-" })),210 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-html-render-" })),
209 Layer.provide(NodeServices.layer),211 Layer.provide(NodeServices.layer),
210 // The preview browser is not installed in a fresh home, so nothing downloads.212 // The preview browser is not installed in a fresh home, so nothing downloads.
apps/server/src/mcp/toolkits/preview/handlers.ts+7
...@@ -13,6 +13,7 @@ import {...@@ -13,6 +13,7 @@ import {
13 type PreviewAutomationOpenInput,13 type PreviewAutomationOpenInput,
14 type PreviewAutomationRecordingStatus,14 type PreviewAutomationRecordingStatus,
15 type PreviewAutomationResizeResult,15 type PreviewAutomationResizeResult,
16 type PreviewAutomationSelectResult,
16 type PreviewAutomationSetColorSchemeResult,17 type PreviewAutomationSetColorSchemeResult,
17 type PreviewAutomationSnapshot,18 type PreviewAutomationSnapshot,
18 type PreviewAutomationStatus,19 type PreviewAutomationStatus,
...@@ -188,6 +189,7 @@ export const claimPreviewRecording = Effect.fn("PreviewToolkit.claimRecording")(...@@ -188,6 +189,7 @@ export const claimPreviewRecording = Effect.fn("PreviewToolkit.claimRecording")(
188});189});
189190
190const handlers = {191const handlers = {
192 preview_dialog: (input) => invokeTargeted<PreviewAutomationStatus>("dialog", input),
191 preview_status: (input) => invokeTargeted<PreviewAutomationStatus>("status", input ?? {}),193 preview_status: (input) => invokeTargeted<PreviewAutomationStatus>("status", input ?? {}),
192 preview_open: (input) =>194 preview_open: (input) =>
193 invokeTargeted<PreviewAutomationStatus>("open", normalizePreviewOpenInput(input)),195 invokeTargeted<PreviewAutomationStatus>("open", normalizePreviewOpenInput(input)),
...@@ -204,6 +206,11 @@ const handlers = {...@@ -204,6 +206,11 @@ const handlers = {
204 },206 },
205 preview_click: (input) => invokeTargeted<object>("click", input, input.timeoutMs),207 preview_click: (input) => invokeTargeted<object>("click", input, input.timeoutMs),
206 preview_type: (input) => invokeTargeted<object>("type", input, input.timeoutMs),208 preview_type: (input) => invokeTargeted<object>("type", input, input.timeoutMs),
209 preview_hover: (input) => invokeTargeted<object>("hover", input, input.timeoutMs),
210 preview_select: (input) =>
211 invokeTargeted<PreviewAutomationSelectResult>("select", input, input.timeoutMs),
212 preview_drag: (input) => invokeTargeted<object>("drag", input, input.timeoutMs),
213 preview_upload: (input) => invokeTargeted<object>("upload", input, input.timeoutMs),
207 preview_press: (input) => invokeTargeted<object>("press", input),214 preview_press: (input) => invokeTargeted<object>("press", input),
208 preview_scroll: (input) => invokeTargeted<object>("scroll", input),215 preview_scroll: (input) => invokeTargeted<object>("scroll", input),
209 preview_evaluate: ({ tabId, ...input }) =>216 preview_evaluate: ({ tabId, ...input }) =>
apps/server/src/mcp/toolkits/preview/tools.test.ts+3
...@@ -60,6 +60,9 @@ it("exports exact object result schemas for preview actions", () => {...@@ -60,6 +60,9 @@ it("exports exact object result schemas for preview actions", () => {
60 const actionNames = [60 const actionNames = [
61 "preview_click",61 "preview_click",
62 "preview_type",62 "preview_type",
63 "preview_hover",
64 "preview_drag",
65 "preview_upload",
63 "preview_press",66 "preview_press",
64 "preview_scroll",67 "preview_scroll",
65 "preview_wait_for",68 "preview_wait_for",
apps/server/src/mcp/toolkits/preview/tools.ts+78-4
...@@ -1,8 +1,11 @@...@@ -1,8 +1,11 @@
1import {1import {
2 ToolActivityIcon,2 ToolActivityIcon,
3 PreviewAutomationClickInput,3 PreviewAutomationClickInput,
4 PreviewAutomationDialogInput,
5 PreviewAutomationDragInput,
4 PreviewAutomationError,6 PreviewAutomationError,
5 PreviewAutomationEvaluateInput,7 PreviewAutomationEvaluateInput,
8 PreviewAutomationHoverInput,
6 PreviewAutomationNavigateInput,9 PreviewAutomationNavigateInput,
7 PreviewAutomationOpenInput,10 PreviewAutomationOpenInput,
8 PreviewAutomationPressInput,11 PreviewAutomationPressInput,
...@@ -11,12 +14,15 @@ import {...@@ -11,12 +14,15 @@ import {
11 PreviewAutomationResizeInput,14 PreviewAutomationResizeInput,
12 PreviewAutomationResizeResult,15 PreviewAutomationResizeResult,
13 PreviewAutomationScrollInput,16 PreviewAutomationScrollInput,
17 PreviewAutomationSelectInput,
18 PreviewAutomationSelectResult,
14 PreviewAutomationSetColorSchemeInput,19 PreviewAutomationSetColorSchemeInput,
15 PreviewAutomationSetColorSchemeResult,20 PreviewAutomationSetColorSchemeResult,
16 PreviewAutomationSnapshot,21 PreviewAutomationSnapshot,
17 PreviewAutomationStatus,22 PreviewAutomationStatus,
18 PreviewAutomationTabTargetInput,23 PreviewAutomationTabTargetInput,
19 PreviewAutomationTypeInput,24 PreviewAutomationTypeInput,
25 PreviewAutomationUploadInput,
20 PreviewAutomationWaitForInput,26 PreviewAutomationWaitForInput,
21} from "@t3tools/contracts";27} from "@t3tools/contracts";
22import * as Schema from "effect/Schema";28import * as Schema from "effect/Schema";
...@@ -52,7 +58,7 @@ const readonlyBrowserTool = <T extends Tool.Any>(tool: T): T =>...@@ -52,7 +58,7 @@ const readonlyBrowserTool = <T extends Tool.Any>(tool: T): T =>
5258
53const PreviewStatusTool = Tool.make("preview_status", {59const PreviewStatusTool = Tool.make("preview_status", {
54 description:60 description:
55 "Report whether a collaborative browser tab is automation-capable, including its URL, title, visibility, loading state, viewport mode, and measured CSS-pixel size. Pass tabId to inspect a specific tab; omit it to use this agent session's current tab.",61 "Report whether a collaborative browser tab is automation-capable, including its control owner, pending dialog, URL, title, visibility, loading state, viewport mode, and measured CSS-pixel size. Pass tabId to inspect a specific tab; omit it to use this agent session's current tab.",
56 parameters: PreviewAutomationTabTargetInput,62 parameters: PreviewAutomationTabTargetInput,
57 success: PreviewAutomationStatus,63 success: PreviewAutomationStatus,
58 failure: PreviewAutomationError,64 failure: PreviewAutomationError,
...@@ -66,7 +72,7 @@ const PreviewStatusTool = Tool.make("preview_status", {...@@ -66,7 +72,7 @@ const PreviewStatusTool = Tool.make("preview_status", {
66const PreviewOpenTool = browserTool(72const PreviewOpenTool = browserTool(
67 Tool.make("preview_open", {73 Tool.make("preview_open", {
68 description:74 description:
69 "Initialize a collaborative browser tab and open its thread-bound inline preview by default. Set open=false for background-only automation. Pass tabId to reuse a specific existing tab, set reuseExistingTab=false to create another tab, or omit both to use this agent session's current tab.",75 "Initialize a collaborative browser tab and open its thread-bound inline preview by default. Set open=false for background-only automation. Pass tabId to reuse a specific existing tab, set reuseExistingTab=false to create another tab, or omit both to use this agent session's current tab. Parallel subagents sharing a provider session must each open with reuseExistingTab=false and pass their returned tabId on every call. Server tabs use isolated storage and cannot be operated by a different agent session.",
70 parameters: PreviewAutomationOpenInput,76 parameters: PreviewAutomationOpenInput,
71 success: PreviewAutomationStatus,77 success: PreviewAutomationStatus,
72 failure: PreviewAutomationError,78 failure: PreviewAutomationError,
...@@ -76,6 +82,17 @@ const PreviewOpenTool = browserTool(...@@ -76,6 +82,17 @@ const PreviewOpenTool = browserTool(
76 .annotate(Tool.Destructive, false),82 .annotate(Tool.Destructive, false),
77);83);
7884
85const PreviewDialogTool = browserTool(
86 Tool.make("preview_dialog", {
87 description:
88 "Accept or dismiss the server browser dialog reported by preview_status. For a prompt, supply promptText when accepting. Requires this agent to own the tab. Desktop hosts may not support this operation.",
89 parameters: PreviewAutomationDialogInput,
90 success: PreviewAutomationStatus,
91 failure: PreviewAutomationError,
92 dependencies,
93 }).annotate(Tool.Title, "Resolve browser dialog"),
94);
95
79const PreviewNavigateTool = safeBrowserTool(96const PreviewNavigateTool = safeBrowserTool(
80 Tool.make("preview_navigate", {97 Tool.make("preview_navigate", {
81 description:98 description:
...@@ -119,7 +136,7 @@ const PreviewSetAppearanceTool = safeBrowserTool(...@@ -119,7 +136,7 @@ const PreviewSetAppearanceTool = safeBrowserTool(
119export const PreviewSnapshotTool = readonlyBrowserTool(136export const PreviewSnapshotTool = readonlyBrowserTool(
120 Tool.make("preview_snapshot", {137 Tool.make("preview_snapshot", {
121 description:138 description:
122 "Inspect a page before interacting. Pass tabId to inspect a specific tab; omit it to use this agent session's current tab. Returns page state, semantic elements, diagnostics, action history, and a PNG screenshot. The text is capped near 20 KB and lists what it omitted; use preview_evaluate to read more. Set includeImage=false for text-only output with the same page metadata. Set save=true to also write the PNG to disk and get screenshotPath back; with includeImage=false, save=true returns only the url and screenshotPath. Embed that path in your reply as ![alt](screenshotPath) so the user sees it. This is the only way to show the user a screenshot; the image in the tool result is not saved anywhere.",139 "Inspect a page before interacting. Pass tabId to inspect a specific tab; omit it to use this agent session's current tab. Returns page state, semantic elements, diagnostics, action history, and a PNG screenshot. Server snapshots include an accessibilityTree with refs; pass locator=aria-ref=<ref> to target one exact element, including inside frames. Refresh refs after navigation, another snapshot, or human takeover. The text is capped near 20 KB and lists what it omitted; use preview_evaluate to read more. Set includeImage=false for text-only output with the same page metadata. Set save=true to also write the PNG to disk and get screenshotPath back; with includeImage=false, save=true returns only the url and screenshotPath. Embed that path in your reply as ![alt](screenshotPath) so the user sees it. This is the only way to show the user a screenshot; the image in the tool result is not saved anywhere.",
123 parameters: Schema.Struct({140 parameters: Schema.Struct({
124 ...PreviewAutomationTabTargetInput.fields,141 ...PreviewAutomationTabTargetInput.fields,
125 includeImage: Schema.optional(142 includeImage: Schema.optional(
...@@ -144,7 +161,7 @@ export const PreviewSnapshotTool = readonlyBrowserTool(...@@ -144,7 +161,7 @@ export const PreviewSnapshotTool = readonlyBrowserTool(
144const PreviewClickTool = browserTool(161const PreviewClickTool = browserTool(
145 Tool.make("preview_click", {162 Tool.make("preview_click", {
146 description:163 description:
147 "Click exactly one target in the tab selected by tabId, or this agent session's current tab when omitted. Prefer a Playwright locator; selector accepts legacy CSS; x and y must be supplied together.",164 "Click exactly one target in the tab selected by tabId, or this agent session's current tab when omitted. Prefer a Playwright locator; selector accepts legacy CSS; x and y must be supplied together. Set button=right for a context menu or clickCount=2 for a double-click; server browser tabs only.",
148 parameters: PreviewAutomationClickInput,165 parameters: PreviewAutomationClickInput,
149 success: PreviewActionResult,166 success: PreviewActionResult,
150 failure: PreviewAutomationError,167 failure: PreviewAutomationError,
...@@ -163,6 +180,53 @@ const PreviewTypeTool = browserTool(...@@ -163,6 +180,53 @@ const PreviewTypeTool = browserTool(
163 }).annotate(Tool.Title, "Type into preview page"),180 }).annotate(Tool.Title, "Type into preview page"),
164);181);
165182
183const PreviewHoverTool = safeBrowserTool(
184 Tool.make("preview_hover", {
185 description:
186 "Move the mouse over exactly one target in the tab selected by tabId, or this agent session's current tab when omitted, to reveal hover menus and tooltips. Server browser tabs only.",
187 parameters: PreviewAutomationHoverInput,
188 success: PreviewActionResult,
189 failure: PreviewAutomationError,
190 dependencies,
191 }).annotate(Tool.Title, "Hover preview page"),
192);
193
194const PreviewSelectTool = browserTool(
195 Tool.make("preview_select", {
196 description:
197 "Choose options in one native <select> in the tab selected by tabId, or this agent session's current tab when omitted, by option value or visible label. Custom dropdowns are not <select>; click them open and click the option instead. Server browser tabs only.",
198 parameters: PreviewAutomationSelectInput,
199 success: Schema.Struct({
200 ...PreviewAutomationSelectResult.fields,
201 ...presentationFields,
202 }),
203 failure: PreviewAutomationError,
204 dependencies,
205 }).annotate(Tool.Title, "Select preview option"),
206);
207
208const PreviewDragTool = browserTool(
209 Tool.make("preview_drag", {
210 description:
211 "Drag one element onto another in the tab selected by tabId, or this agent session's current tab when omitted. Server browser tabs only.",
212 parameters: PreviewAutomationDragInput,
213 success: PreviewActionResult,
214 failure: PreviewAutomationError,
215 dependencies,
216 }).annotate(Tool.Title, "Drag in preview page"),
217);
218
219const PreviewUploadTool = browserTool(
220 Tool.make("preview_upload", {
221 description:
222 "Give files on the environment to the page in the tab selected by tabId, or this agent session's current tab when omitted. After clicking an upload control, preview_status reports the open fileChooser; call this with absolute paths to answer it, or with an empty list to cancel. Pass a locator for an <input type=file> to set its files without a picker. Server browser tabs only.",
223 parameters: PreviewAutomationUploadInput,
224 success: PreviewActionResult,
225 failure: PreviewAutomationError,
226 dependencies,
227 }).annotate(Tool.Title, "Upload files to preview page"),
228);
229
166const PreviewPressTool = browserTool(230const PreviewPressTool = browserTool(
167 Tool.make("preview_press", {231 Tool.make("preview_press", {
168 description:232 description:
...@@ -242,6 +306,7 @@ const PreviewRecordingStopTool = safeBrowserTool(...@@ -242,6 +306,7 @@ const PreviewRecordingStopTool = safeBrowserTool(
242);306);
243307
244export const PreviewToolkit = Toolkit.make(308export const PreviewToolkit = Toolkit.make(
309 PreviewDialogTool,
245 PreviewStatusTool,310 PreviewStatusTool,
246 PreviewOpenTool,311 PreviewOpenTool,
247 PreviewNavigateTool,312 PreviewNavigateTool,
...@@ -250,6 +315,10 @@ export const PreviewToolkit = Toolkit.make(...@@ -250,6 +315,10 @@ export const PreviewToolkit = Toolkit.make(
250 PreviewSnapshotTool,315 PreviewSnapshotTool,
251 PreviewClickTool,316 PreviewClickTool,
252 PreviewTypeTool,317 PreviewTypeTool,
318 PreviewHoverTool,
319 PreviewSelectTool,
320 PreviewDragTool,
321 PreviewUploadTool,
253 PreviewPressTool,322 PreviewPressTool,
254 PreviewScrollTool,323 PreviewScrollTool,
255 PreviewEvaluateTool,324 PreviewEvaluateTool,
...@@ -259,6 +328,7 @@ export const PreviewToolkit = Toolkit.make(...@@ -259,6 +328,7 @@ export const PreviewToolkit = Toolkit.make(
259);328);
260329
261export const PreviewStandardToolkit = Toolkit.make(330export const PreviewStandardToolkit = Toolkit.make(
331 PreviewDialogTool,
262 PreviewStatusTool,332 PreviewStatusTool,
263 PreviewOpenTool,333 PreviewOpenTool,
264 PreviewNavigateTool,334 PreviewNavigateTool,
...@@ -266,6 +336,10 @@ export const PreviewStandardToolkit = Toolkit.make(...@@ -266,6 +336,10 @@ export const PreviewStandardToolkit = Toolkit.make(
266 PreviewSetAppearanceTool,336 PreviewSetAppearanceTool,
267 PreviewClickTool,337 PreviewClickTool,
268 PreviewTypeTool,338 PreviewTypeTool,
339 PreviewHoverTool,
340 PreviewSelectTool,
341 PreviewDragTool,
342 PreviewUploadTool,
269 PreviewPressTool,343 PreviewPressTool,
270 PreviewScrollTool,344 PreviewScrollTool,
271 PreviewEvaluateTool,345 PreviewEvaluateTool,
apps/server/src/mcp/toolkits/previewControls/handlers.test.ts+14-1
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import * as NodeCrypto from "@effect/platform-node/NodeCrypto";1import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
2import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";3import { expect, it } from "@effect/vitest";
3import {4import {
4 DEFAULT_SERVER_SETTINGS,5 DEFAULT_SERVER_SETTINGS,
...@@ -12,9 +13,11 @@ import * as Effect from "effect/Effect";...@@ -12,9 +13,11 @@ import * as Effect from "effect/Effect";
12import * as Layer from "effect/Layer";13import * as Layer from "effect/Layer";
13import * as Stream from "effect/Stream";14import * as Stream from "effect/Stream";
1415
16import * as ServerConfig from "../../../config.ts";
15import * as Preview from "../../../preview/Manager.ts";17import * as Preview from "../../../preview/Manager.ts";
16import * as ServerSettings from "../../../serverSettings.ts";18import * as ServerSettings from "../../../serverSettings.ts";
17import * as McpInvocationContext from "../../McpInvocationContext.ts";19import * as McpInvocationContext from "../../McpInvocationContext.ts";
20import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts";
18import * as PreviewControlsHandlers from "./handlers.ts";21import * as PreviewControlsHandlers from "./handlers.ts";
19import { PreviewControlsToolkit } from "./tools.ts";22import { PreviewControlsToolkit } from "./tools.ts";
2023
...@@ -46,9 +49,19 @@ it.effect.each([...@@ -46,9 +49,19 @@ it.effect.each([
46 capabilities: new Set(effective.enableAgentBrowserAccess ? ["preview"] : []),49 capabilities: new Set(effective.enableAgentBrowserAccess ? ["preview"] : []),
47 issuedAt: 0,50 issuedAt: 0,
48 };51 };
49 const manager = yield* Preview.make.pipe(Effect.provide(NodeCrypto.layer));52 const manager = yield* Preview.make.pipe(
53 Effect.provide(
54 Layer.merge(
55 ServerConfig.layerTest(process.cwd(), { prefix: "t3-preview-controls-" }).pipe(
56 Layer.provide(NodeServices.layer),
57 ),
58 NodeCrypto.layer,
59 ),
60 ),
61 );
50 const tab = yield* manager.open({ threadId, url: "http://localhost:3000" });62 const tab = yield* manager.open({ threadId, url: "http://localhost:3000" });
51 const layerDependencies = Layer.mergeAll(63 const layerDependencies = Layer.mergeAll(
64 PreviewAutomationBroker.layer.pipe(Layer.provide(NodeServices.layer)),
52 Layer.succeed(Preview.PreviewManager, manager),65 Layer.succeed(Preview.PreviewManager, manager),
53 Layer.succeed(McpInvocationContext.McpInvocationContext, scope),66 Layer.succeed(McpInvocationContext.McpInvocationContext, scope),
54 Layer.mock(ServerSettings.ServerSettingsService)({67 Layer.mock(ServerSettings.ServerSettingsService)({
apps/server/src/mcp/toolkits/previewControls/handlers.ts+11
...@@ -3,6 +3,7 @@ import * as Preview from "../../../preview/Manager.ts";...@@ -3,6 +3,7 @@ import * as Preview from "../../../preview/Manager.ts";
3import { requireThreadMcpCapability } from "../../McpInvocationContext.ts";3import { requireThreadMcpCapability } from "../../McpInvocationContext.ts";
4import { unavailable } from "../../threadAccess.ts";4import { unavailable } from "../../threadAccess.ts";
5import { PreviewControlsToolkit } from "./tools.ts";5import { PreviewControlsToolkit } from "./tools.ts";
6import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts";
67
7const access = Effect.gen(function* () {8const access = Effect.gen(function* () {
8 // The preview capability already reflects the calling project's access setting.9 // The preview capability already reflects the calling project's access setting.
...@@ -25,6 +26,16 @@ export const layer = PreviewControlsToolkit.toLayer({...@@ -25,6 +26,16 @@ export const layer = PreviewControlsToolkit.toLayer({
25 t3_preview_close: (input) =>26 t3_preview_close: (input) =>
26 Effect.gen(function* () {27 Effect.gen(function* () {
27 const { scope, manager } = yield* access;28 const { scope, manager } = yield* access;
29 const { sessions } = yield* manager.list({ threadId: scope.thread.threadId });
30 if (
31 sessions.some((session) => session.tabId === input.tabId && session.runtime === "server")
32 ) {
33 const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
34 yield* broker
35 .invoke({ scope, operation: "close", input: {}, tabId: input.tabId })
36 .pipe(Effect.mapError(unavailable));
37 return {};
38 }
28 yield* manager39 yield* manager
29 .close({ threadId: scope.thread.threadId, tabId: input.tabId })40 .close({ threadId: scope.thread.threadId, tabId: input.tabId })
30 .pipe(Effect.mapError(unavailable));41 .pipe(Effect.mapError(unavailable));
apps/server/src/mcp/toolkits/previewControls/tools.ts+6-1
...@@ -8,12 +8,17 @@ import {...@@ -8,12 +8,17 @@ import {
8import * as Schema from "effect/Schema";8import * as Schema from "effect/Schema";
9import { Tool, Toolkit } from "effect/ai";9import { Tool, Toolkit } from "effect/ai";
10import * as PreviewManager from "../../../preview/Manager.ts";10import * as PreviewManager from "../../../preview/Manager.ts";
11import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts";
11import * as McpInvocationContext from "../../McpInvocationContext.ts";12import * as McpInvocationContext from "../../McpInvocationContext.ts";
1213
13const shared = {14const shared = {
14 failure: Schema.Union([OrchestratorMcpFailure, PreviewAutomationUnavailableError]),15 failure: Schema.Union([OrchestratorMcpFailure, PreviewAutomationUnavailableError]),
15 failureMode: "return" as const,16 failureMode: "return" as const,
16 dependencies: [McpInvocationContext.McpInvocationContext, PreviewManager.PreviewManager],17 dependencies: [
18 McpInvocationContext.McpInvocationContext,
19 PreviewManager.PreviewManager,
20 PreviewAutomationBroker.PreviewAutomationBroker,
21 ],
17};22};
18const PreviewListTool = Tool.make("t3_preview_list", {23const PreviewListTool = Tool.make("t3_preview_list", {
19 ...shared,24 ...shared,
apps/server/src/mcp/toolkits/worktree/registration.test.ts+2
...@@ -25,6 +25,7 @@ import * as VcsStatusBroadcaster from "../../../vcs/VcsStatusBroadcaster.ts";...@@ -25,6 +25,7 @@ import * as VcsStatusBroadcaster from "../../../vcs/VcsStatusBroadcaster.ts";
25import * as McpHttpServer from "../../McpHttpServer.ts";25import * as McpHttpServer from "../../McpHttpServer.ts";
26import * as McpSessionRegistry from "../../McpSessionRegistry.ts";26import * as McpSessionRegistry from "../../McpSessionRegistry.ts";
27import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts";27import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts";
28import * as PreviewBrowser from "../../../preview/PreviewBrowser.ts";
2829
29const layerStubServices = Layer.mergeAll(30const layerStubServices = Layer.mergeAll(
30 Layer.mock(Orchestrator.OrchestratorV2)({}),31 Layer.mock(Orchestrator.OrchestratorV2)({}),
...@@ -77,6 +78,7 @@ it.effect("production mcp layer lists worktree tools over http", () =>...@@ -77,6 +78,7 @@ it.effect("production mcp layer lists worktree tools over http", () =>
77 }),78 }),
78 ),79 ),
79 Layer.provide(PreviewAutomationBroker.layer),80 Layer.provide(PreviewAutomationBroker.layer),
81 Layer.provide(PreviewBrowser.layer),
80 Layer.provide(layerStubServices),82 Layer.provide(layerStubServices),
81 Layer.build,83 Layer.build,
82 );84 );
apps/server/src/preview/DesktopBrowserChannel.test.ts created+58
...@@ -0,0 +1,58 @@
1// @effect-diagnostics nodeBuiltinImport:off - The channel reads real file descriptors.
2import * as NodeFS from "node:fs";
3import * as NodeOS from "node:os";
4import * as NodePath from "node:path";
5
6import * as NodeServices from "@effect/platform-node/NodeServices";
7import { expect, it } from "@effect/vitest";
8import * as Context from "effect/Context";
9import * as Effect from "effect/Effect";
10import * as Exit from "effect/Exit";
11import * as Layer from "effect/Layer";
12
13import * as ServerConfig from "../config.ts";
14import * as DesktopBrowserChannel from "./DesktopBrowserChannel.ts";
15
16const key = { threadId: "thread-1", tabId: "tab-1" };
17
18/** The channel over two files: what the desktop sent, and where commands go. */
19const channelOver = (events: ReadonlyArray<Record<string, unknown>>) =>
20 Effect.gen(function* () {
21 const directory = yield* Effect.acquireRelease(
22 Effect.sync(() =>
23 NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-desktop-browser-channel-")),
24 ),
25 (directory) => Effect.sync(() => NodeFS.rmSync(directory, { recursive: true, force: true })),
26 );
27 const inbound = NodePath.join(directory, "events.ndjson");
28 NodeFS.writeFileSync(inbound, events.map((event) => `${JSON.stringify(event)}\n`).join(""));
29 const control = yield* Effect.acquireRelease(
30 Effect.sync(() => NodeFS.openSync(NodePath.join(directory, "commands.ndjson"), "w")),
31 (fd) => Effect.sync(() => NodeFS.closeSync(fd)),
32 );
33 const base = yield* ServerConfig.ServerConfig.pipe(
34 Effect.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-desktop-browser-" })),
35 );
36 const config = Layer.succeed(ServerConfig.ServerConfig, {
37 ...base,
38 desktopBrowserFd: NodeFS.openSync(inbound, "r"),
39 desktopBrowserControlFd: control,
40 });
41 // Built in the caller's scope, so the channel outlives this setup.
42 const context = yield* Layer.build(DesktopBrowserChannel.layer.pipe(Layer.provide(config)));
43 return Context.get(context, DesktopBrowserChannel.DesktopBrowserChannel);
44 });
45
46it.layer(NodeServices.layer)("DesktopBrowserChannel", (it) => {
47 it.effect("refuses an endpoint for a tab that is no longer attached", () =>
48 Effect.gen(function* () {
49 const channel = yield* channelOver([
50 { type: "attached", ...key },
51 { type: "detached", ...key },
52 ]);
53 // Whether or not the reader has reached these lines yet, the tab is not attached.
54 const exit = yield* Effect.exit(Effect.scoped(channel.endpoint(key)));
55 expect(Exit.isFailure(exit)).toBe(true);
56 }).pipe(Effect.scoped),
57 );
58});
apps/server/src/preview/DesktopBrowserChannel.ts created+225
...@@ -0,0 +1,225 @@
1// @effect-diagnostics nodeBuiltinImport:off - Bridges Playwright's WebSocket transport to the desktop's fds.
2/**
3 * The server end of the desktop browser channel (see `DesktopBrowserEvent`).
4 *
5 * Playwright connects over CDP only through a WebSocket URL, so each attached
6 * desktop tab gets a loopback endpoint with an unguessable path. Its frames
7 * cross the bootstrap file descriptors to the desktop's relay, which owns the
8 * tab's `webContents.debugger`. The endpoint only bridges to that one tab.
9 */
10import * as NodeStream from "@effect/platform-node/NodeStream";
11import * as NodeSocketServer from "@effect/platform-node/NodeSocketServer";
12import {
13 DesktopBrowserCommand,
14 DesktopBrowserEvent,
15 type DesktopBrowserCommand as DesktopBrowserCommandType,
16} from "@t3tools/contracts";
17import * as Context from "effect/Context";
18import * as Duration from "effect/Duration";
19import * as Effect from "effect/Effect";
20import * as Layer from "effect/Layer";
21import * as Option from "effect/Option";
22import * as PubSub from "effect/PubSub";
23import * as Queue from "effect/Queue";
24import * as Schema from "effect/Schema";
25import * as Scope from "effect/Scope";
26import * as Semaphore from "effect/Semaphore";
27import * as Stream from "effect/Stream";
28import * as Ndjson from "effect/encoding/Ndjson";
29import * as NodeCrypto from "node:crypto";
30import * as NodeFS from "node:fs";
31
32import * as ServerConfig from "../config.ts";
33import { writeAllToFileDescriptor } from "../resourceTelemetry/DesktopTelemetryReceiver.ts";
34
35const decodeEvent = Schema.decodeUnknownEffect(DesktopBrowserEvent);
36const encodeCommand = Schema.encodeEffect(Schema.fromJsonString(DesktopBrowserCommand));
37
38export interface DesktopTabKey {
39 readonly threadId: string;
40 readonly tabId: string;
41}
42
43const keyOf = ({ threadId, tabId }: DesktopTabKey) => `${threadId}\u0000${tabId}`;
44
45export class DesktopBrowserChannel extends Context.Service<
46 DesktopBrowserChannel,
47 {
48 /** False when this server was not started by a desktop app. */
49 readonly available: boolean;
50 /**
51 * Waits for a tab to be attached. Subscribes before it checks, so an
52 * attach landing in between is never missed. False after `timeout`.
53 */
54 readonly awaitAttached: (key: DesktopTabKey, timeout: Duration.Input) => Effect.Effect<boolean>;
55 /** Desktop tabs as they detach. */
56 readonly detached: Stream.Stream<DesktopTabKey>;
57 readonly isAttached: (key: DesktopTabKey) => Effect.Effect<boolean>;
58 /**
59 * A one-connection CDP endpoint for an attached tab. Closing the scope
60 * releases the tab on the desktop and stops the endpoint.
61 */
62 readonly endpoint: (key: DesktopTabKey) => Effect.Effect<string, never, Scope.Scope>;
63 /** Draws the agent's cursor over a tab the desktop renders. */
64 readonly pointer: (
65 key: DesktopTabKey,
66 pointer: { readonly phase: "move" | "click"; readonly x: number; readonly y: number },
67 ) => Effect.Effect<void>;
68 }
69>()("t3/preview/DesktopBrowserChannel") {}
70
71const make = Effect.gen(function* () {
72 const config = yield* ServerConfig.ServerConfig;
73 const inputFd = config.desktopBrowserFd;
74 const controlFd = config.desktopBrowserControlFd;
75 const changes = yield* PubSub.unbounded<{ key: DesktopTabKey; attached: boolean }>();
76 const attachedTabs = new Set<string>();
77 /** CDP frames from the desktop, per tab, for the endpoint connected to it. */
78 const inbound = new Map<string, Queue.Queue<string>>();
79 const writeLock = yield* Semaphore.make(1);
80
81 if (inputFd === undefined || controlFd === undefined) {
82 return DesktopBrowserChannel.of({
83 available: false,
84 awaitAttached: () => Effect.succeed(false),
85 detached: Stream.empty,
86 isAttached: () => Effect.succeed(false),
87 endpoint: () => Effect.die("No desktop app is attached to this server."),
88 pointer: () => Effect.void,
89 });
90 }
91
92 const command = (message: DesktopBrowserCommandType) =>
93 writeLock.withPermits(1)(
94 encodeCommand(message).pipe(
95 Effect.flatMap((line) => writeAllToFileDescriptor(controlFd, Buffer.from(`${line}\n`))),
96 Effect.catchCause((cause) =>
97 Effect.logWarning("desktop browser command failed", { cause }),
98 ),
99 ),
100 );
101
102 const readable = yield* Effect.acquireRelease(
103 Effect.sync(() => NodeFS.createReadStream("", { fd: inputFd, autoClose: true })),
104 (stream) => Effect.sync(() => stream.destroy()),
105 );
106 yield* NodeStream.fromReadable<Uint8Array, Error>({
107 evaluate: () => readable,
108 closeOnDone: true,
109 onError: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
110 }).pipe(
111 Stream.pipeThroughChannel(Ndjson.decode({ ignoreEmptyLines: true })),
112 Stream.mapEffect((value) => decodeEvent(value).pipe(Effect.option)),
113 Stream.runForEach((decoded) => {
114 if (Option.isNone(decoded)) return Effect.void;
115 const event = decoded.value;
116 const key = { threadId: event.threadId, tabId: event.tabId };
117 const id = keyOf(key);
118 switch (event.type) {
119 case "cdp": {
120 const queue = inbound.get(id);
121 return queue ? Queue.offer(queue, event.message).pipe(Effect.asVoid) : Effect.void;
122 }
123 case "attached":
124 attachedTabs.add(id);
125 return PubSub.publish(changes, { key, attached: true });
126 case "detached": {
127 attachedTabs.delete(id);
128 const queue = inbound.get(id);
129 return (queue ? Queue.shutdown(queue) : Effect.void).pipe(
130 Effect.andThen(PubSub.publish(changes, { key, attached: false })),
131 );
132 }
133 }
134 }),
135 Effect.catchCause((cause) => Effect.logWarning("desktop browser channel stopped", { cause })),
136 Effect.forkScoped,
137 );
138
139 const endpoint = (key: DesktopTabKey) =>
140 Effect.gen(function* () {
141 const id = keyOf(key);
142 const secret = NodeCrypto.randomBytes(24).toString("base64url");
143 const server = yield* NodeSocketServer.makeWebSocket({
144 host: "127.0.0.1",
145 port: 0,
146 path: `/${secret}`,
147 }).pipe(Effect.orDie);
148 const queue = yield* Queue.unbounded<string>();
149 inbound.set(id, queue);
150 // A detach before this registration shut down no queue, so check again.
151 if (!attachedTabs.has(id)) {
152 inbound.delete(id);
153 return yield* Effect.die("The desktop tab detached before the server connected.");
154 }
155 yield* Effect.addFinalizer(() =>
156 Effect.gen(function* () {
157 if (inbound.get(id) === queue) inbound.delete(id);
158 yield* Queue.shutdown(queue);
159 yield* command({ type: "release", ...key });
160 }),
161 );
162 // The relay serves one Playwright connection; a second would see the first's sessions.
163 let connected = false;
164 yield* server
165 .run((socket) =>
166 Effect.gen(function* () {
167 if (connected) return;
168 connected = true;
169 const writer = yield* socket.writer;
170 const reader = yield* socket.reader;
171 yield* Stream.fromQueue(queue).pipe(
172 Stream.runForEach((message) => writer.write(message)),
173 Effect.forkScoped,
174 );
175 const decoder = new TextDecoder();
176 return yield* reader.pull.pipe(
177 Effect.flatMap((frames) =>
178 Effect.forEach(
179 frames,
180 (frame) =>
181 command({
182 type: "cdp",
183 ...key,
184 message: typeof frame === "string" ? frame : decoder.decode(frame),
185 }),
186 { discard: true },
187 ),
188 ),
189 Effect.forever,
190 );
191 }).pipe(Effect.scoped, Effect.ignore),
192 )
193 .pipe(Effect.forkScoped);
194 const address = server.address;
195 if (address._tag !== "InetAddressV4") return yield* Effect.die("Unexpected relay address.");
196 return `ws://127.0.0.1:${address.port}/${secret}`;
197 });
198
199 return DesktopBrowserChannel.of({
200 available: true,
201 awaitAttached: (key, timeout) =>
202 Effect.scoped(
203 Effect.gen(function* () {
204 const subscription = yield* PubSub.subscribe(changes);
205 if (attachedTabs.has(keyOf(key))) return true;
206 return yield* Stream.fromSubscription(subscription).pipe(
207 Stream.filter((change) => change.attached && keyOf(change.key) === keyOf(key)),
208 Stream.runHead,
209 Effect.map(Option.isSome),
210 Effect.timeoutOption(timeout),
211 Effect.map((result) => Option.getOrElse(result, () => false)),
212 );
213 }),
214 ),
215 detached: Stream.fromPubSub(changes).pipe(
216 Stream.filter((change) => !change.attached),
217 Stream.map((change) => change.key),
218 ),
219 isAttached: (key) => Effect.sync(() => attachedTabs.has(keyOf(key))),
220 endpoint,
221 pointer: (key, pointer) => command({ type: "pointer", ...key, ...pointer }),
222 });
223});
224
225export const layer = Layer.effect(DesktopBrowserChannel, make);
apps/server/src/preview/Manager.test.ts+118-1
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import * as NodeCrypto from "@effect/platform-node/NodeCrypto";1import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
2import * as NodeServices from "@effect/platform-node/NodeServices";
2import { it } from "@effect/vitest";3import { it } from "@effect/vitest";
3import { type PreviewEvent, ThreadId } from "@t3tools/contracts";4import { type PreviewEvent, ThreadId } from "@t3tools/contracts";
4import { PreviewUrlNormalizationError } from "@t3tools/shared/preview";5import { PreviewUrlNormalizationError } from "@t3tools/shared/preview";
...@@ -7,6 +8,7 @@ import * as Layer from "effect/Layer";...@@ -7,6 +8,7 @@ import * as Layer from "effect/Layer";
7import * as PubSub from "effect/PubSub";8import * as PubSub from "effect/PubSub";
8import { expect } from "vite-plus/test";9import { expect } from "vite-plus/test";
910
11import * as ServerConfig from "../config.ts";
10import * as PreviewManager from "./Manager.ts";12import * as PreviewManager from "./Manager.ts";
1113
12const DRAIN_LIMIT = 100;14const DRAIN_LIMIT = 100;
...@@ -38,7 +40,13 @@ const collectEvents = Effect.gen(function* () {...@@ -38,7 +40,13 @@ const collectEvents = Effect.gen(function* () {
38 return collector;40 return collector;
39}).pipe(Effect.withSpan("preview.test.collectEvents"));41}).pipe(Effect.withSpan("preview.test.collectEvents"));
4042
41it.layer(PreviewManager.layer.pipe(Layer.provide(NodeCrypto.layer)))("PreviewManager", (it) => {43const layer = PreviewManager.layer.pipe(
44 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-preview-manager-" })),
45 Layer.provide(NodeCrypto.layer),
46 Layer.provide(NodeServices.layer),
47);
48
49it.layer(layer)("PreviewManager", (it) => {
42 it.effect("opens a session and emits opened with normalized URL", () =>50 it.effect("opens a session and emits opened with normalized URL", () =>
43 Effect.gen(function* () {51 Effect.gen(function* () {
44 const threadId = freshThreadId();52 const threadId = freshThreadId();
...@@ -92,6 +100,75 @@ it.layer(PreviewManager.layer.pipe(Layer.provide(NodeCrypto.layer)))("PreviewMan...@@ -92,6 +100,75 @@ it.layer(PreviewManager.layer.pipe(Layer.provide(NodeCrypto.layer)))("PreviewMan
92 }),100 }),
93 );101 );
94102
103 it.effect("lets any client size, theme, and zoom a server tab", () =>
104 Effect.gen(function* () {
105 const threadId = freshThreadId();
106 const manager = yield* PreviewManager.PreviewManager;
107 const collector = yield* collectEvents;
108 const opened = yield* manager.open({ threadId, runtime: "server" });
109 const viewport = {
110 _tag: "preset",
111 presetId: "iphone-12-pro",
112 width: 390,
113 height: 844,
114 } as const;
115 yield* manager.resize({ threadId, tabId: opened.tabId, viewport });
116 const adjusted = yield* manager.adjust({
117 threadId,
118 tabId: opened.tabId,
119 colorScheme: "dark",
120 zoomFactor: 1.25,
121 });
122 expect(adjusted).toMatchObject({ viewport, colorScheme: "dark", zoomFactor: 1.25 });
123 // One-off requests go to the server's browser and leave the tab's state alone.
124 yield* manager.adjust({ threadId, tabId: opened.tabId, hardReload: true, clear: "cache" });
125 const events = (yield* collector.drain).filter((event) => event.type === "resized");
126 expect(events.at(-1)).toMatchObject({
127 request: { hardReload: true, clear: "cache" },
128 snapshot: { colorScheme: "dark", zoomFactor: 1.25 },
129 });
130 expect(events.at(-2)).not.toHaveProperty("request");
131 }),
132 );
133
134 it.effect("reissues presentation requests without replaying them on navigation", () =>
135 Effect.gen(function* () {
136 const threadId = freshThreadId();
137 const manager = yield* PreviewManager.PreviewManager;
138 const collector = yield* collectEvents;
139 const opened = yield* manager.open({ threadId, runtime: "server", reveal: false });
140 yield* manager.requestReveal({ threadId, tabId: opened.tabId, force: true });
141 const first = (yield* manager.list({ threadId })).sessions[0];
142 expect(first?.reveal).toBe(true);
143 expect(first?.revealRequest?.force).toBe(true);
144 // Server tabs report navigation through their controlling browser.
145 const refused = yield* manager
146 .navigate({ threadId, tabId: opened.tabId, url: "localhost:5173" })
147 .pipe(Effect.flip);
148 expect(refused._tag).toBe("PreviewControlRequiredError");
149 yield* manager.reportStatus({
150 threadId,
151 tabId: opened.tabId,
152 serverControlled: true,
153 navStatus: { _tag: "Success", url: "http://localhost:5173/", title: "Dev" },
154 canGoBack: false,
155 canGoForward: false,
156 });
157 expect((yield* manager.list({ threadId })).sessions[0]?.revealRequest).toEqual(
158 first?.revealRequest,
159 );
160 yield* manager.requestReveal({ threadId, tabId: opened.tabId, force: false });
161 const second = (yield* manager.list({ threadId })).sessions[0];
162 expect(second?.revealRequest?.id).not.toBe(first?.revealRequest?.id);
163 expect(second?.revealRequest?.force).toBe(false);
164 const events = yield* collector.drain;
165 const last = events.at(-1);
166 expect(last?.type).toBe("navigated");
167 if (last?.type === "navigated")
168 expect(last.snapshot.revealRequest).toEqual(second?.revealRequest);
169 }),
170 );
171
95 it.effect("opens an Idle tab when no URL is supplied", () =>172 it.effect("opens an Idle tab when no URL is supplied", () =>
96 Effect.gen(function* () {173 Effect.gen(function* () {
97 const threadId = freshThreadId();174 const threadId = freshThreadId();
...@@ -266,6 +343,46 @@ it.layer(PreviewManager.layer.pipe(Layer.provide(NodeCrypto.layer)))("PreviewMan...@@ -266,6 +343,46 @@ it.layer(PreviewManager.layer.pipe(Layer.provide(NodeCrypto.layer)))("PreviewMan
266 }),343 }),
267 );344 );
268345
346 it.effect(
347 "rejects client status reports for server tabs without changing state or emitting events",
348 () =>
349 Effect.gen(function* () {
350 const threadId = freshThreadId();
351 const manager = yield* PreviewManager.PreviewManager;
352 const opened = yield* manager.open({ threadId, runtime: "server", reveal: false });
353 const collector = yield* collectEvents;
354 const before = yield* manager.list({ threadId });
355 const input = {
356 threadId,
357 tabId: opened.tabId,
358 navStatus: {
359 _tag: "Success" as const,
360 url: "http://localhost:5173/changed",
361 title: "Changed",
362 },
363 canGoBack: true,
364 canGoForward: true,
365 };
366 const rejected = yield* manager.reportStatus(input).pipe(Effect.flip);
367 expect(rejected._tag).toBe("PreviewControlRequiredError");
368 expect(yield* manager.list({ threadId })).toEqual(before);
369 expect(yield* collector.drain).toEqual([]);
370
371 yield* manager.reportStatus({ ...input, serverControlled: true });
372 expect((yield* manager.list({ threadId })).sessions[0]).toMatchObject({
373 navStatus: input.navStatus,
374 canGoBack: true,
375 canGoForward: true,
376 });
377 const events = yield* collector.drain;
378 expect(events).toHaveLength(1);
379 expect(events[0]).toMatchObject({
380 type: "navigated",
381 snapshot: { navStatus: input.navStatus },
382 });
383 }),
384 );
385
269 it.effect("reportStatus emits failed for LoadFailed nav", () =>386 it.effect("reportStatus emits failed for LoadFailed nav", () =>
270 Effect.gen(function* () {387 Effect.gen(function* () {
271 const threadId = freshThreadId();388 const threadId = freshThreadId();
apps/server/src/preview/Manager.ts+125-73
...@@ -21,10 +21,10 @@ import {...@@ -21,10 +21,10 @@ import {
21 type PreviewRefreshInput,21 type PreviewRefreshInput,
22 type PreviewReportStatusInput,22 type PreviewReportStatusInput,
23 type PreviewResizeInput,23 type PreviewResizeInput,
24 type PreviewAdjustInput,
24 FILL_PREVIEW_VIEWPORT,25 FILL_PREVIEW_VIEWPORT,
25 PreviewSessionLookupError,26 PreviewSessionLookupError,
26 type PreviewSessionSnapshot,27 type PreviewSessionSnapshot,
27 type PreviewViewportSetting,
28} from "@t3tools/contracts";28} from "@t3tools/contracts";
29import {29import {
30 isPreviewUrlNormalizationError,30 isPreviewUrlNormalizationError,
...@@ -40,18 +40,38 @@ import * as PubSub from "effect/PubSub";...@@ -40,18 +40,38 @@ import * as PubSub from "effect/PubSub";
40import * as Scope from "effect/Scope";40import * as Scope from "effect/Scope";
41import * as Stream from "effect/Stream";41import * as Stream from "effect/Stream";
42import * as SynchronizedRef from "effect/SynchronizedRef";42import * as SynchronizedRef from "effect/SynchronizedRef";
43import { PreviewControlRequiredError } from "@t3tools/contracts";
4344
44export class PreviewManager extends Context.Service<45export class PreviewManager extends Context.Service<
45 PreviewManager,46 PreviewManager,
46 {47 {
47 readonly open: (input: PreviewOpenInput) => Effect.Effect<PreviewSessionSnapshot, PreviewError>;48 readonly open: (
49 input: PreviewOpenInput & {
50 readonly automationOwner?: string;
51 /** Runs before the `opened` event publishes, so subscribers find state keyed by the tab. */
52 readonly beforePublish?: (snapshot: PreviewSessionSnapshot) => void;
53 },
54 ) => Effect.Effect<PreviewSessionSnapshot, PreviewError>;
48 readonly navigate: (55 readonly navigate: (
49 input: PreviewNavigateInput,56 input: PreviewNavigateInput,
50 ) => Effect.Effect<PreviewSessionSnapshot, PreviewError>;57 ) => Effect.Effect<PreviewSessionSnapshot, PreviewError>;
51 readonly reportStatus: (input: PreviewReportStatusInput) => Effect.Effect<void, PreviewError>;58 readonly reportStatus: (
59 input: PreviewReportStatusInput & { readonly serverControlled?: boolean },
60 ) => Effect.Effect<void, PreviewError>;
61 readonly requestReveal: (
62 input: PreviewCloseInput & { readonly tabId: string; readonly force: boolean },
63 ) => Effect.Effect<void, PreviewError>;
52 readonly resize: (64 readonly resize: (
53 input: PreviewResizeInput,65 input: PreviewResizeInput,
54 ) => Effect.Effect<PreviewSessionSnapshot, PreviewError>;66 ) => Effect.Effect<PreviewSessionSnapshot, PreviewError>;
67 /**
68 * Records a server tab's appearance or zoom and publishes it; the server's
69 * browser applies it to the page. Any client may send it, as it changes how
70 * the page renders, not what it does.
71 */
72 readonly adjust: (
73 input: PreviewAdjustInput,
74 ) => Effect.Effect<PreviewSessionSnapshot, PreviewError>;
55 readonly refresh: (input: PreviewRefreshInput) => Effect.Effect<void, PreviewError>;75 readonly refresh: (input: PreviewRefreshInput) => Effect.Effect<void, PreviewError>;
56 readonly close: (input: PreviewCloseInput) => Effect.Effect<void, PreviewError>;76 readonly close: (input: PreviewCloseInput) => Effect.Effect<void, PreviewError>;
57 readonly list: (input: PreviewListInput) => Effect.Effect<PreviewListResult>;77 readonly list: (input: PreviewListInput) => Effect.Effect<PreviewListResult>;
...@@ -117,42 +137,6 @@ const normalizeUrl = (rawUrl: string): Effect.Effect<string, PreviewInvalidUrlEr...@@ -117,42 +137,6 @@ const normalizeUrl = (rawUrl: string): Effect.Effect<string, PreviewInvalidUrlEr
117137
118const currentIsoTimestamp = DateTime.now.pipe(Effect.map(DateTime.formatIso));138const currentIsoTimestamp = DateTime.now.pipe(Effect.map(DateTime.formatIso));
119139
120const buildLoadingSnapshot = (input: {
121 readonly threadId: string;
122 readonly tabId: string;
123 readonly url: string;
124 readonly title: string;
125 readonly viewport: PreviewViewportSetting;
126 readonly profileId?: string | undefined;
127 readonly updatedAt: string;
128}): PreviewSessionSnapshot => ({
129 threadId: input.threadId,
130 tabId: input.tabId,
131 navStatus: { _tag: "Loading", url: input.url, title: input.title },
132 canGoBack: false,
133 canGoForward: false,
134 viewport: input.viewport,
135 ...(input.profileId === undefined ? {} : { profileId: input.profileId }),
136 updatedAt: input.updatedAt,
137});
138
139const buildIdleSnapshot = (input: {
140 readonly threadId: string;
141 readonly tabId: string;
142 readonly viewport: PreviewViewportSetting;
143 readonly profileId?: string | undefined;
144 readonly updatedAt: string;
145}): PreviewSessionSnapshot => ({
146 threadId: input.threadId,
147 tabId: input.tabId,
148 navStatus: { _tag: "Idle" },
149 canGoBack: false,
150 canGoForward: false,
151 viewport: input.viewport,
152 ...(input.profileId === undefined ? {} : { profileId: input.profileId }),
153 updatedAt: input.updatedAt,
154});
155
156/** @public Service construction is part of the canonical Effect module API. */140/** @public Service construction is part of the canonical Effect module API. */
157export const make = Effect.gen(function* PreviewManagerMake() {141export const make = Effect.gen(function* PreviewManagerMake() {
158 const crypto = yield* Crypto.Crypto;142 const crypto = yield* Crypto.Crypto;
...@@ -222,29 +206,27 @@ export const make = Effect.gen(function* PreviewManagerMake() {...@@ -222,29 +206,27 @@ export const make = Effect.gen(function* PreviewManagerMake() {
222206
223 const open: PreviewManager["Service"]["open"] = Effect.fn("PreviewManager.open")(207 const open: PreviewManager["Service"]["open"] = Effect.fn("PreviewManager.open")(
224 function* (input) {208 function* (input) {
225 const tabId = newPreviewTabId();209 const runtime = input.runtime;
210 // Persisted client surfaces must not bind to a different tab after a server restart.
211 const tabId = `${newPreviewTabId()}${runtime === "server" ? `_${serverEpoch}` : ""}`;
226 const updatedAt = yield* currentIsoTimestamp;212 const updatedAt = yield* currentIsoTimestamp;
227 // Clients with a configured default send the viewport up front so the213 const snapshot: PreviewSessionSnapshot = {
228 // session is born at the right size; older clients omit it and keep the214 threadId: input.threadId,
229 // historical fill-panel behaviour.215 tabId,
230 const viewport = input.viewport ?? FILL_PREVIEW_VIEWPORT;216 navStatus: input.url
231 const snapshot = input.url217 ? { _tag: "Loading", url: yield* normalizeUrl(input.url), title: "" }
232 ? buildLoadingSnapshot({218 : { _tag: "Idle" },
233 threadId: input.threadId,219 canGoBack: false,
234 tabId,220 canGoForward: false,
235 url: yield* normalizeUrl(input.url),221 viewport: input.viewport ?? FILL_PREVIEW_VIEWPORT,
236 title: "",222 ...(input.profileId === undefined ? {} : { profileId: input.profileId }),
237 viewport,223 ...(runtime === undefined ? {} : { runtime }),
238 profileId: input.profileId,224 ...(runtime === "server" && input.automationOwner !== undefined
239 updatedAt,225 ? { automationOwner: input.automationOwner }
240 })226 : {}),
241 : buildIdleSnapshot({227 ...(input.reveal === undefined ? {} : { reveal: input.reveal }),
242 threadId: input.threadId,228 updatedAt,
243 tabId,229 };
244 viewport,
245 profileId: input.profileId,
246 updatedAt,
247 });
248 yield* SynchronizedRef.modifyEffect(stateRef, (state) =>230 yield* SynchronizedRef.modifyEffect(stateRef, (state) =>
249 Effect.gen(function* () {231 Effect.gen(function* () {
250 const revision = state.revision + 1;232 const revision = state.revision + 1;
...@@ -254,6 +236,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {...@@ -254,6 +236,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {
254 tabId,236 tabId,
255 snapshot,237 snapshot,
256 });238 });
239 input.beforePublish?.(snapshot);
257 yield* PubSub.publish(eventsPubSub, {240 yield* PubSub.publish(eventsPubSub, {
258 type: "opened",241 type: "opened",
259 threadId: input.threadId,242 threadId: input.threadId,
...@@ -277,20 +260,16 @@ export const make = Effect.gen(function* PreviewManagerMake() {...@@ -277,20 +260,16 @@ export const make = Effect.gen(function* PreviewManagerMake() {
277 input.threadId,260 input.threadId,
278 input.tabId,261 input.tabId,
279 Effect.fn("PreviewManager.navigateSession")(function* (session) {262 Effect.fn("PreviewManager.navigateSession")(function* (session) {
263 if (session.snapshot.runtime === "server")
264 return yield* new PreviewControlRequiredError({ tabId: input.tabId });
280 const updatedAt = yield* currentIsoTimestamp;265 const updatedAt = yield* currentIsoTimestamp;
281 const previousTitle =266 const previousTitle =
282 session.snapshot.navStatus._tag === "Idle" ? "" : session.snapshot.navStatus.title;267 session.snapshot.navStatus._tag === "Idle" ? "" : session.snapshot.navStatus.title;
283 const resolvedTitle = input.resolvedTitle ?? previousTitle;268 const resolvedTitle = input.resolvedTitle ?? previousTitle;
284 const snapshot: PreviewSessionSnapshot = {269 const snapshot: PreviewSessionSnapshot = {
285 threadId: session.threadId,270 ...session.snapshot,
286 tabId: session.tabId,
287 navStatus: { _tag: "Success", url, title: resolvedTitle },271 navStatus: { _tag: "Success", url, title: resolvedTitle },
288 canGoBack: session.snapshot.canGoBack,
289 canGoForward: session.snapshot.canGoForward,
290 viewport: session.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT,272 viewport: session.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT,
291 ...(session.snapshot.profileId === undefined
292 ? {}
293 : { profileId: session.snapshot.profileId }),
294 updatedAt,273 updatedAt,
295 };274 };
296 return {275 return {
...@@ -316,17 +295,15 @@ export const make = Effect.gen(function* PreviewManagerMake() {...@@ -316,17 +295,15 @@ export const make = Effect.gen(function* PreviewManagerMake() {
316 input.threadId,295 input.threadId,
317 input.tabId,296 input.tabId,
318 Effect.fn("PreviewManager.reportSessionStatus")(function* (session) {297 Effect.fn("PreviewManager.reportSessionStatus")(function* (session) {
298 if (session.snapshot.runtime === "server" && !input.serverControlled)
299 return yield* new PreviewControlRequiredError({ tabId: input.tabId });
319 const updatedAt = yield* currentIsoTimestamp;300 const updatedAt = yield* currentIsoTimestamp;
320 const snapshot: PreviewSessionSnapshot = {301 const snapshot: PreviewSessionSnapshot = {
321 threadId: session.threadId,302 ...session.snapshot,
322 tabId: session.tabId,
323 navStatus: input.navStatus,303 navStatus: input.navStatus,
324 canGoBack: input.canGoBack,304 canGoBack: input.canGoBack,
325 canGoForward: input.canGoForward,305 canGoForward: input.canGoForward,
326 viewport: session.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT,306 viewport: session.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT,
327 ...(session.snapshot.profileId === undefined
328 ? {}
329 : { profileId: session.snapshot.profileId }),
330 updatedAt,307 updatedAt,
331 };308 };
332 const emit: PreviewEventDraft =309 const emit: PreviewEventDraft =
...@@ -362,6 +339,8 @@ export const make = Effect.gen(function* PreviewManagerMake() {...@@ -362,6 +339,8 @@ export const make = Effect.gen(function* PreviewManagerMake() {
362 return yield* mutateExistingSession(339 return yield* mutateExistingSession(
363 input.threadId,340 input.threadId,
364 input.tabId,341 input.tabId,
342 // Any client may size a tab, as the desktop has always allowed; the
343 // server's browser follows the published setting for its own tabs.
365 Effect.fn("PreviewManager.resizeSession")(function* (session) {344 Effect.fn("PreviewManager.resizeSession")(function* (session) {
366 const updatedAt = yield* currentIsoTimestamp;345 const updatedAt = yield* currentIsoTimestamp;
367 const snapshot: PreviewSessionSnapshot = {346 const snapshot: PreviewSessionSnapshot = {
...@@ -385,6 +364,46 @@ export const make = Effect.gen(function* PreviewManagerMake() {...@@ -385,6 +364,46 @@ export const make = Effect.gen(function* PreviewManagerMake() {
385 },364 },
386 );365 );
387366
367 const adjust: PreviewManager["Service"]["adjust"] = Effect.fn("PreviewManager.adjust")(
368 function* (input) {
369 return yield* mutateExistingSession(
370 input.threadId,
371 input.tabId,
372 Effect.fn("PreviewManager.adjustSession")(function* (session) {
373 const updatedAt = yield* currentIsoTimestamp;
374 const snapshot: PreviewSessionSnapshot = {
375 ...session.snapshot,
376 ...(input.colorScheme === undefined ? {} : { colorScheme: input.colorScheme }),
377 ...(input.zoomFactor === undefined ? {} : { zoomFactor: input.zoomFactor }),
378 updatedAt,
379 };
380 // One-off requests ride on the event for the server's browser to act on.
381 const request =
382 input.hardReload || input.clear
383 ? {
384 request: {
385 ...(input.hardReload ? { hardReload: true } : {}),
386 ...(input.clear ? { clear: input.clear } : {}),
387 },
388 }
389 : {};
390 return {
391 next: { ...session, snapshot },
392 emit: {
393 ...request,
394 type: "resized",
395 threadId: session.threadId,
396 tabId: session.tabId,
397 createdAt: snapshot.updatedAt,
398 snapshot,
399 },
400 result: snapshot,
401 };
402 }),
403 );
404 },
405 );
406
388 const refresh: PreviewManager["Service"]["refresh"] = Effect.fn("PreviewManager.refresh")(407 const refresh: PreviewManager["Service"]["refresh"] = Effect.fn("PreviewManager.refresh")(
389 function* (input) {408 function* (input) {
390 // Verify the session exists; the desktop bridge handles the actual reload409 // Verify the session exists; the desktop bridge handles the actual reload
...@@ -446,11 +465,44 @@ export const make = Effect.gen(function* PreviewManagerMake() {...@@ -446,11 +465,44 @@ export const make = Effect.gen(function* PreviewManagerMake() {
446 },465 },
447 );466 );
448467
468 const requestReveal: PreviewManager["Service"]["requestReveal"] = Effect.fn(
469 "PreviewManager.requestReveal",
470 )(function* (input) {
471 yield* mutateExistingSession(
472 input.threadId,
473 input.tabId,
474 Effect.fn("PreviewManager.revealSession")(function* (session) {
475 const snapshot = {
476 ...session.snapshot,
477 reveal: true,
478 revealRequest: {
479 id: yield* crypto.randomUUIDv4.pipe(Effect.orDie),
480 force: input.force,
481 },
482 updatedAt: yield* currentIsoTimestamp,
483 };
484 return {
485 next: { ...session, snapshot },
486 emit: {
487 type: "navigated" as const,
488 threadId: session.threadId,
489 tabId: session.tabId,
490 createdAt: snapshot.updatedAt,
491 snapshot,
492 },
493 result: undefined,
494 };
495 }),
496 );
497 });
498
449 return PreviewManager.of({499 return PreviewManager.of({
450 open,500 open,
501 requestReveal,
451 navigate,502 navigate,
452 reportStatus,503 reportStatus,
453 resize,504 resize,
505 adjust,
454 refresh,506 refresh,
455 close,507 close,
456 list,508 list,
apps/server/src/preview/PreviewBrowser.test.ts created+292
...@@ -0,0 +1,292 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
4import * as Deferred from "effect/Deferred";
5import type * as Duration from "effect/Duration";
6import * as DateTime from "effect/DateTime";
7import * as Effect from "effect/Effect";
8import * as Fiber from "effect/Fiber";
9import * as FileSystem from "effect/FileSystem";
10import * as Option from "effect/Option";
11import * as Path from "effect/Path";
12import * as Stream from "effect/Stream";
13import * as TestClock from "effect/testing/TestClock";
14import { HttpClient, HttpClientResponse } from "effect/http";
15import * as NodeCrypto from "node:crypto";
16import * as NodeZlib from "node:zlib";
17
18import * as PreviewBrowser from "./PreviewBrowser.ts";
19
20// POSIX modes cannot be checked on NTFS, so follow the host like the Antigravity suite.
21const hostPlatform: NodeJS.Platform =
22 HostProcessPlatform.defaultValue() === "win32" ? "win32" : "linux";
23const executableName =
24 hostPlatform === "win32" ? "chrome-headless-shell.exe" : "chrome-headless-shell";
25const ROOT = "chrome-headless-shell-fixture/";
26
27/** A stored ZIP whose entries carry Unix modes, like Chrome for Testing's archives. */
28const makeZip = (
29 entries: ReadonlyArray<{ readonly name: string; readonly data?: string; readonly mode: number }>,
30) => {
31 const records: Array<Buffer> = [];
32 const directory: Array<Buffer> = [];
33 let offset = 0;
34 for (const entry of entries) {
35 const name = Buffer.from(entry.name);
36 const data = Buffer.from(entry.data ?? "");
37 const crc = NodeZlib.crc32(data);
38 const local = Buffer.alloc(30);
39 local.writeUInt32LE(0x04034b50, 0);
40 local.writeUInt16LE(20, 4);
41 local.writeUInt32LE(crc, 14);
42 local.writeUInt32LE(data.length, 18);
43 local.writeUInt32LE(data.length, 22);
44 local.writeUInt16LE(name.length, 26);
45 const central = Buffer.alloc(46);
46 central.writeUInt32LE(0x02014b50, 0);
47 central.writeUInt16LE((3 << 8) | 20, 4);
48 central.writeUInt16LE(20, 6);
49 central.writeUInt32LE(crc, 16);
50 central.writeUInt32LE(data.length, 20);
51 central.writeUInt32LE(data.length, 24);
52 central.writeUInt16LE(name.length, 28);
53 central.writeUInt32LE((entry.mode << 16) >>> 0, 38);
54 central.writeUInt32LE(offset, 42);
55 records.push(local, name, data);
56 directory.push(central, name);
57 offset += local.length + name.length + data.length;
58 }
59 const centralDirectory = Buffer.concat(directory);
60 const end = Buffer.alloc(22);
61 end.writeUInt32LE(0x06054b50, 0);
62 end.writeUInt16LE(entries.length, 8);
63 end.writeUInt16LE(entries.length, 10);
64 end.writeUInt32LE(centralDirectory.length, 12);
65 end.writeUInt32LE(offset, 16);
66 return Buffer.concat([...records, centralDirectory, end]);
67};
68
69const browserArchive = makeZip([
70 { name: ROOT, mode: 0o40755 },
71 { name: `${ROOT}${executableName}`, data: "#!/bin/sh\n", mode: 0o100755 },
72 { name: `${ROOT}libEGL.so`, data: "library", mode: 0o100755 },
73 { name: `${ROOT}locales/`, mode: 0o40755 },
74 { name: `${ROOT}locales/en-US.pak`, data: "strings", mode: 0o100644 },
75]);
76
77const makeHarness = Effect.fn("test.makePreviewBrowser")(function* (
78 options: {
79 readonly archive?: Buffer;
80 readonly sha256?: string;
81 readonly body?: Stream.Stream<Uint8Array>;
82 readonly wait?: Duration.Input;
83 readonly unsupported?: boolean;
84 } = {},
85) {
86 const fs = yield* FileSystem.FileSystem;
87 const path = yield* Path.Path;
88 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-preview-browser-test-" });
89 const archive = options.archive ?? browserArchive;
90 const requests: Array<string> = [];
91 const browser = yield* PreviewBrowser.makePreviewBrowser({
92 baseDir,
93 release: options.unsupported
94 ? null
95 : {
96 version: "1.2.3",
97 platform: "fixture",
98 url: "https://storage.googleapis.com/chrome-headless-shell-fixture.zip",
99 bytes: archive.byteLength,
100 sha256: options.sha256 ?? NodeCrypto.createHash("sha256").update(archive).digest("hex"),
101 },
102 ...(options.wait === undefined ? {} : { wait: options.wait }),
103 }).pipe(
104 Effect.provideService(HostProcessPlatform, hostPlatform),
105 Effect.provideService(HostProcessArchitecture, "x64"),
106 Effect.provideService(
107 HttpClient.HttpClient,
108 HttpClient.make((request) =>
109 Effect.sync(() => {
110 requests.push(request.url);
111 const response = HttpClientResponse.fromWeb(request, new Response(null));
112 return Object.defineProperty(response, "stream", {
113 value: options.body ?? Stream.make(archive.subarray(0, 40), archive.subarray(40)),
114 });
115 }),
116 ),
117 ),
118 );
119 const installRoot = path.join(baseDir, "tools", "chrome-headless-shell", "fixture");
120 return { browser, fs, path, baseDir, installRoot, requests };
121});
122
123it.layer(NodeServices.layer)("PreviewBrowser", (it) => {
124 it.effect("installs a verified download with its file modes and then reuses it", () =>
125 Effect.gen(function* () {
126 const { browser, fs, path, installRoot, requests } = yield* makeHarness();
127 yield* fs.makeDirectory(path.join(installRoot, "1.0.0"), { recursive: true });
128 // Another server's install in progress, and one abandoned two hours ago.
129 const active = path.join(installRoot, ".install-other");
130 const abandoned = path.join(installRoot, ".install-abandoned");
131 yield* fs.makeDirectory(active);
132 yield* fs.makeDirectory(abandoned);
133 const twoHoursAgo = DateTime.toDate(DateTime.subtract(yield* DateTime.now, { hours: 2 }));
134 yield* fs.utimes(abandoned, twoHoursAgo, twoHoursAgo);
135 expect(yield* browser.installed).toEqual(Option.none());
136
137 const executable = yield* browser.executable;
138
139 const version = path.join(installRoot, "1.2.3");
140 expect(executable).toBe(path.join(version, executableName));
141 expect(yield* fs.readFileString(path.join(version, "locales", "en-US.pak"))).toBe("strings");
142 if (hostPlatform !== "win32") {
143 const mode = (file: string) =>
144 fs.stat(path.join(version, file)).pipe(Effect.map((info) => info.mode & 0o777));
145 expect(yield* mode(executableName)).toBe(0o755);
146 expect(yield* mode("libEGL.so")).toBe(0o755);
147 expect(yield* mode(path.join("locales", "en-US.pak"))).toBe(0o644);
148 }
149 // The old build and stale staging directories are gone; a fresh one stays.
150 expect((yield* fs.readDirectory(installRoot)).toSorted()).toEqual([
151 ".install-other",
152 "1.2.3",
153 ]);
154 expect(yield* browser.executable).toBe(executable);
155 expect(yield* browser.installed).toEqual(Option.some(executable));
156 expect(requests).toHaveLength(1);
157 }),
158 );
159
160 it.effect.each([
161 { name: "a hash mismatch", sha256: "0".repeat(64) },
162 { name: "a short download", body: Stream.make(browserArchive.subarray(0, -1)) },
163 {
164 name: "an oversized download",
165 body: Stream.make(browserArchive, Buffer.from("extra")),
166 },
167 ])("rejects $name, installs nothing, and retries on the next call", (testCase) =>
168 Effect.gen(function* () {
169 const { browser, fs, installRoot, requests } = yield* makeHarness(testCase);
170
171 const error = yield* browser.executable.pipe(Effect.flip);
172
173 expect(error._tag).toBe("PreviewBrowserInstallError");
174 expect(error.message).toMatch(/^T3 could not install its headless browser: .+ Try again\.$/);
175 expect(yield* fs.readDirectory(installRoot)).toEqual([]);
176 yield* browser.executable.pipe(Effect.flip);
177 expect(requests).toHaveLength(2);
178 }),
179 );
180
181 it.effect.each([
182 { name: "a parent-directory entry", entry: `${ROOT}../escape` },
183 { name: "an absolute entry", entry: "/escape" },
184 { name: "an entry outside the top directory", entry: "escape" },
185 ])("rejects an archive with $name", (testCase) =>
186 Effect.gen(function* () {
187 const { browser, fs, path, baseDir, installRoot } = yield* makeHarness({
188 archive: makeZip([
189 { name: `${ROOT}${executableName}`, data: "#!/bin/sh\n", mode: 0o100755 },
190 { name: testCase.entry, data: "escaped", mode: 0o100644 },
191 ]),
192 });
193
194 const error = yield* browser.executable.pipe(Effect.flip);
195
196 expect(error._tag).toBe("PreviewBrowserInstallError");
197 expect(yield* fs.readDirectory(installRoot)).toEqual([]);
198 expect(yield* fs.exists(path.join(baseDir, "tools", "chrome-headless-shell", "escape"))).toBe(
199 false,
200 );
201 }),
202 );
203
204 it.effect("shares one install between callers and finishes it after they stop waiting", () =>
205 Effect.gen(function* () {
206 const firstChunkSent = yield* Deferred.make<void>();
207 const finishDownload = yield* Deferred.make<void>();
208 const { browser, requests } = yield* makeHarness({
209 wait: "45 seconds",
210 body: Stream.concat(
211 Stream.make(browserArchive.subarray(0, 100)),
212 Stream.fromEffect(
213 Deferred.succeed(firstChunkSent, undefined).pipe(
214 Effect.andThen(Deferred.await(finishDownload)),
215 Effect.as(browserArchive.subarray(100)),
216 ),
217 ),
218 ),
219 });
220
221 const callers = yield* Effect.forEach([1, 2], () =>
222 Effect.forkChild(browser.executable.pipe(Effect.flip)),
223 );
224 yield* Deferred.await(firstChunkSent);
225 yield* TestClock.adjust("45 seconds");
226 for (const caller of callers) {
227 expect(yield* Fiber.join(caller)).toMatchObject({
228 _tag: "PreviewBrowserInstallingError",
229 downloadedBytes: 100,
230 unpacking: false,
231 });
232 }
233
234 // The install belongs to the service, so it outlives the callers that gave up on it.
235 yield* Deferred.succeed(finishDownload, undefined);
236 expect(yield* browser.executable).toMatch(new RegExp(`${executableName}$`));
237 expect(requests).toHaveLength(1);
238 }),
239 );
240
241 it.effect("reports hosts Chrome for Testing does not build for without downloading", () =>
242 Effect.gen(function* () {
243 const { browser, requests } = yield* makeHarness({ unsupported: true });
244 expect((yield* browser.executable.pipe(Effect.flip))._tag).toBe(
245 "PreviewBrowserUnsupportedError",
246 );
247 expect(yield* browser.installed).toEqual(Option.none());
248 expect(requests).toEqual([]);
249 }),
250 );
251});
252
253it("pins one Chrome for Testing build per host", () => {
254 const hosts = [
255 ["linux", "x64"],
256 ["linux", "arm64"],
257 ["darwin", "arm64"],
258 ["darwin", "x64"],
259 ["win32", "x64"],
260 ["win32", "arm64"],
261 ["win32", "ia32"],
262 ["linux", "ia32"],
263 ["freebsd", "x64"],
264 ] as const;
265 expect(
266 hosts.map(([platform, arch]) => PreviewBrowser.previewBrowserRelease(platform, arch)?.platform),
267 ).toEqual([
268 "linux64",
269 "linux-arm64",
270 "mac-arm64",
271 "mac-x64",
272 "win64",
273 "win64",
274 "win32",
275 undefined,
276 undefined,
277 ]);
278 expect(PreviewBrowser.previewBrowserRelease("linux", "x64")).toMatchObject({
279 url: "https://storage.googleapis.com/chrome-for-testing-public/154.0.8037.92/linux64/chrome-headless-shell-linux64.zip",
280 bytes: 120_477_194,
281 });
282});
283
284it("tells the agent how far the install has come", () => {
285 expect(
286 new PreviewBrowser.PreviewBrowserInstallingError({
287 downloadedBytes: 37_200_000,
288 totalBytes: 120_477_194,
289 unpacking: false,
290 }).message,
291 ).toBe("T3 is installing its headless browser (37 of 120 MB downloaded). Try again in a minute.");
292});
apps/server/src/preview/PreviewBrowser.ts created+463
...@@ -0,0 +1,463 @@
1// @effect-diagnostics nodeBuiltinImport:off - Effect has no incremental digest.
2import * as EffectNodeStream from "@effect/platform-node/NodeStream";
3import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
4import * as Cause from "effect/Cause";
5import * as Clock from "effect/Clock";
6import * as Context from "effect/Context";
7import * as Deferred from "effect/Deferred";
8import type * as Duration from "effect/Duration";
9import * as Effect from "effect/Effect";
10import * as Exit from "effect/Exit";
11import * as FileSystem from "effect/FileSystem";
12import * as Layer from "effect/Layer";
13import * as Option from "effect/Option";
14import * as Path from "effect/Path";
15import * as Schema from "effect/Schema";
16import * as Semaphore from "effect/Semaphore";
17import * as Stream from "effect/Stream";
18import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
19import * as NodeCrypto from "node:crypto";
20
21import * as ServerConfig from "../config.ts";
22import { openZipArchive } from "../zipArchive.ts";
23
24// The one browser T3 installs on a host. HTML render previews and server
25// browser tabs both run this pinned Chrome for Testing headless shell, so a host
26// downloads it once; neither uses a browser the user installed. To bump the pin, pick a version
27// from https://googlechromelabs.github.io/chrome-for-testing/known-good-versions-with-downloads.json,
28// download each platform's chrome-headless-shell zip, and replace the version
29// and every byte count and SHA-256 below. Hosts drop the old build after the
30// new one installs.
31const VERSION = "154.0.8037.92";
32const ARCHIVES = {
33 linux64: {
34 bytes: 120_477_194,
35 sha256: "636aa5c79f2693632e9921b8bbb050038ba11672e02346c06c20f991aed096f9",
36 },
37 "linux-arm64": {
38 bytes: 121_182_296,
39 sha256: "0ed0e47d9e9f639197f508d62ada09e5c6b4c4c60edab3160a9312a733091df6",
40 },
41 "mac-arm64": {
42 bytes: 99_221_129,
43 sha256: "77da14e75d7f2568e6f7898d3df7cdc6faac74b15e903b2c9d486ebb6ca9b929",
44 },
45 "mac-x64": {
46 bytes: 104_748_425,
47 sha256: "a54292aaacbb77f76f6ef47558e7c51ab884044e0adacca315567f83c060bcc4",
48 },
49 win32: {
50 bytes: 114_295_943,
51 sha256: "56b30d2d6c35775ebf8dc3618680f6529e1c38c87f7feb28a16e9904273d51f7",
52 },
53 win64: {
54 bytes: 120_822_223,
55 sha256: "3ac2561f02d9d87aadc0399d00b9002d718a4c365624fa67db9e7bfaf6b1a568",
56 },
57} as const;
58
59const chromePlatform = (platform: NodeJS.Platform, arch: NodeJS.Architecture) => {
60 switch (platform) {
61 case "linux":
62 return arch === "x64" ? "linux64" : arch === "arm64" ? "linux-arm64" : null;
63 case "darwin":
64 return arch === "arm64" ? "mac-arm64" : arch === "x64" ? "mac-x64" : null;
65 case "win32":
66 // There is no Windows arm64 build; Windows on Arm runs the x64 one under emulation.
67 return arch === "ia32" ? "win32" : arch === "x64" || arch === "arm64" ? "win64" : null;
68 default:
69 return null;
70 }
71};
72
73export interface PreviewBrowserRelease {
74 readonly version: string;
75 /** Chrome for Testing's platform name, which also names the archive's top directory. */
76 readonly platform: string;
77 readonly url: string;
78 readonly bytes: number;
79 readonly sha256: string;
80}
81
82export const previewBrowserRelease = (
83 platform: NodeJS.Platform,
84 arch: NodeJS.Architecture,
85): PreviewBrowserRelease | null => {
86 const chrome = chromePlatform(platform, arch);
87 return chrome === null
88 ? null
89 : {
90 version: VERSION,
91 platform: chrome,
92 url: `https://storage.googleapis.com/chrome-for-testing-public/${VERSION}/${chrome}/chrome-headless-shell-${chrome}.zip`,
93 ...ARCHIVES[chrome],
94 };
95};
96
97const megabytes = (bytes: number) => Math.round(bytes / 1_000_000);
98
99export class PreviewBrowserInstallError extends Schema.TaggedError<PreviewBrowserInstallError>()(
100 "PreviewBrowserInstallError",
101 { detail: Schema.String, cause: Schema.optional(Schema.Defect()) },
102) {
103 override get message(): string {
104 return `T3 could not install its headless browser: ${this.detail} Try again.`;
105 }
106}
107const isInstallError = Schema.is(PreviewBrowserInstallError);
108
109export class PreviewBrowserInstallingError extends Schema.TaggedError<PreviewBrowserInstallingError>()(
110 "PreviewBrowserInstallingError",
111 { downloadedBytes: Schema.Number, totalBytes: Schema.Number, unpacking: Schema.Boolean },
112) {
113 override get message(): string {
114 const progress = this.unpacking
115 ? "unpacking"
116 : `${megabytes(this.downloadedBytes)} of ${megabytes(this.totalBytes)} MB downloaded`;
117 return `T3 is installing its headless browser (${progress}). Try again in a minute.`;
118 }
119}
120
121export class PreviewBrowserUnsupportedError extends Schema.TaggedError<PreviewBrowserUnsupportedError>()(
122 "PreviewBrowserUnsupportedError",
123 { platform: Schema.String, arch: Schema.String },
124) {
125 override get message(): string {
126 return `T3's headless browser is not available on ${this.platform}-${this.arch}: Chrome for Testing has no headless shell for it.`;
127 }
128}
129
130export class PreviewBrowser extends Context.Service<
131 PreviewBrowser,
132 {
133 /**
134 * Path to the installed headless shell. The first call starts the
135 * install; callers wait for it up to a bound and then get its progress
136 * instead, while the install keeps running.
137 */
138 readonly executable: Effect.Effect<
139 string,
140 PreviewBrowserInstallError | PreviewBrowserInstallingError | PreviewBrowserUnsupportedError
141 >;
142 /** The installed headless shell, if any. Never starts or waits on an install. */
143 readonly installed: Effect.Effect<Option.Option<string>>;
144 }
145>()("t3/preview/PreviewBrowser") {}
146
147export interface PreviewBrowserOptions {
148 readonly baseDir: string;
149 readonly release?: PreviewBrowserRelease | null;
150 /** How long a caller waits on an install in progress. Provider tool calls time out near 60s. */
151 readonly wait?: Duration.Input;
152}
153
154const DOWNLOAD_TIMEOUT = "15 minutes";
155// Longer than any install can run: the download times out at 15 minutes.
156const STAGING_STALE_AFTER_MS = 60 * 60_000;
157
158const wrapFailure = (detail: string) => (cause: unknown) =>
159 isInstallError(cause) ? cause : new PreviewBrowserInstallError({ detail, cause });
160
161type InstallState =
162 | { readonly _tag: "idle" }
163 | {
164 readonly _tag: "installing";
165 readonly done: Deferred.Deferred<string, PreviewBrowserInstallError>;
166 }
167 | { readonly _tag: "installed"; readonly executable: string }
168 | { readonly _tag: "failed"; readonly error: PreviewBrowserInstallError };
169
170export const makePreviewBrowser = Effect.fn("PreviewBrowser.make")(function* (
171 options: PreviewBrowserOptions,
172) {
173 const fs = yield* FileSystem.FileSystem;
174 const path = yield* Path.Path;
175 const http = yield* HttpClient.HttpClient;
176 // Installs belong to the service, so they finish even when no caller is still waiting.
177 const serviceScope = yield* Effect.scope;
178 const platform = yield* HostProcessPlatform;
179 const arch = yield* HostProcessArchitecture;
180 const release =
181 options.release === undefined ? previewBrowserRelease(platform, arch) : options.release;
182 const wait = options.wait ?? "45 seconds";
183 const installRoot = path.join(
184 options.baseDir,
185 "tools",
186 "chrome-headless-shell",
187 release?.platform ?? `${platform}-${arch}`,
188 );
189 const executableName =
190 platform === "win32" ? "chrome-headless-shell.exe" : "chrome-headless-shell";
191 const gate = yield* Semaphore.make(1);
192 let state: InstallState = { _tag: "idle" };
193 const progress = { downloadedBytes: 0, unpacking: false };
194
195 // The version directory appears by one rename of a fully unpacked tree, so a
196 // runnable binary there is the complete-install marker.
197 const installedExecutable = (release: PreviewBrowserRelease) => {
198 const executable = path.join(installRoot, release.version, executableName);
199 return fs.stat(executable).pipe(
200 Effect.map((info) =>
201 info.type === "File" && (platform === "win32" || (info.mode & 0o111) !== 0)
202 ? Option.some(executable)
203 : Option.none<string>(),
204 ),
205 Effect.orElseSucceed(() => Option.none<string>()),
206 );
207 };
208
209 const download = Effect.fn("PreviewBrowser.download")(
210 function* (release: PreviewBrowserRelease, archivePath: string) {
211 const response = yield* http
212 .execute(HttpClientRequest.get(release.url))
213 .pipe(Effect.flatMap(HttpClientResponse.filterStatusOk));
214 const contentLength = response.headers["content-length"];
215 const contentEncoding = response.headers["content-encoding"]?.trim().toLowerCase();
216 if (
217 (contentEncoding === undefined || contentEncoding === "identity") &&
218 contentLength !== undefined &&
219 Number(contentLength) !== release.bytes
220 ) {
221 return yield* new PreviewBrowserInstallError({
222 detail: "The download size did not match the pinned release.",
223 });
224 }
225 const hash = NodeCrypto.createHash("sha256");
226 yield* response.stream.pipe(
227 Stream.tap((chunk) =>
228 Effect.suspend(() => {
229 progress.downloadedBytes += chunk.byteLength;
230 if (progress.downloadedBytes > release.bytes) {
231 return Effect.fail(
232 new PreviewBrowserInstallError({
233 detail: "The download was larger than the pinned release.",
234 }),
235 );
236 }
237 hash.update(chunk);
238 return Effect.void;
239 }),
240 ),
241 Stream.run(fs.sink(archivePath, { flag: "wx", mode: 0o600 })),
242 );
243 if (progress.downloadedBytes !== release.bytes || hash.digest("hex") !== release.sha256) {
244 return yield* new PreviewBrowserInstallError({
245 detail: "The download failed its size or SHA-256 check. Nothing was installed.",
246 });
247 }
248 },
249 Effect.timeout(DOWNLOAD_TIMEOUT),
250 Effect.mapError(
251 wrapFailure(
252 "The download from storage.googleapis.com failed. Check this machine's network access.",
253 ),
254 ),
255 );
256
257 const extract = Effect.fn("PreviewBrowser.extract")(
258 function* (release: PreviewBrowserRelease, archivePath: string, destination: string) {
259 const archive = yield* openZipArchive(
260 archivePath,
261 (detail, cause) => new PreviewBrowserInstallError({ detail, cause }),
262 );
263 const unsafe = new PreviewBrowserInstallError({
264 detail: "The archive contains an unexpected or unsafe entry.",
265 });
266 const root = `chrome-headless-shell-${release.platform}/`;
267 yield* fs.makeDirectory(destination);
268 let foundExecutable = false;
269 for (;;) {
270 const entry = yield* archive.next;
271 if (!entry) break;
272 const unixMode = entry.externalFileAttributes >>> 16;
273 const unixType = unixMode & 0o170000;
274 const isDirectory = entry.fileName.endsWith("/");
275 // Everything sits under the one top directory, which is stripped.
276 const relative = entry.fileName.startsWith(root) ? entry.fileName.slice(root.length) : null;
277 const target = relative === null ? null : path.resolve(destination, relative);
278 if (
279 relative === null ||
280 target === null ||
281 (relative !== "" && !target.startsWith(`${destination}${path.sep}`)) ||
282 relative.split("/").includes("..") ||
283 (unixType !== 0 && unixType !== (isDirectory ? 0o040000 : 0o100000)) ||
284 (entry.generalPurposeBitFlag & 1) !== 0 ||
285 ![0, 8].includes(entry.compressionMethod)
286 ) {
287 return yield* unsafe;
288 }
289 if (isDirectory) {
290 yield* fs.makeDirectory(target, { recursive: true });
291 continue;
292 }
293 yield* fs.makeDirectory(path.dirname(target), { recursive: true });
294 yield* Effect.gen(function* () {
295 const readable = yield* archive.streamEntry(entry);
296 yield* EffectNodeStream.fromReadable<Uint8Array, PreviewBrowserInstallError>({
297 evaluate: () => readable,
298 onError: wrapFailure("Could not unpack the browser."),
299 }).pipe(Stream.run(fs.sink(target, { flag: "wx" })));
300 }).pipe(Effect.scoped);
301 // The binary and its shared libraries carry exec bits in the archive.
302 if (platform !== "win32" && (unixMode & 0o777) !== 0) {
303 yield* fs.chmod(target, unixMode & 0o777);
304 }
305 if (relative === executableName) foundExecutable = true;
306 }
307 if (!foundExecutable) {
308 return yield* new PreviewBrowserInstallError({
309 detail: "The archive does not contain chrome-headless-shell.",
310 });
311 }
312 },
313 Effect.scoped,
314 Effect.mapError(
315 wrapFailure("Could not unpack the browser. Check free disk space in T3's home directory."),
316 ),
317 );
318
319 const install = Effect.fn("PreviewBrowser.install")(
320 function* (release: PreviewBrowserRelease) {
321 yield* Effect.gen(function* () {
322 yield* fs.makeDirectory(installRoot, { recursive: true });
323 // Staging shares the install root's filesystem so publishing is one rename.
324 const staging = yield* fs.makeTempDirectoryScoped({
325 directory: installRoot,
326 prefix: ".install-",
327 });
328 const archivePath = path.join(staging, "download.zip");
329 const unpacked = path.join(staging, "browser");
330 yield* download(release, archivePath);
331 progress.unpacking = true;
332 yield* extract(release, archivePath, unpacked);
333 const destination = path.join(installRoot, release.version);
334 // A directory left without a runnable binary would block the rename.
335 yield* fs.remove(destination, { recursive: true, force: true });
336 yield* fs.rename(unpacked, destination);
337 }).pipe(Effect.scoped);
338 // Older builds and abandoned staging directories. Two servers can share a
339 // home, so a staging directory touched within the last hour may be another
340 // server's install in progress and stays.
341 const entries = yield* fs.readDirectory(installRoot);
342 const now = yield* Clock.currentTimeMillis;
343 yield* Effect.forEach(
344 entries.filter((name) => name !== release.version),
345 (name) =>
346 Effect.gen(function* () {
347 const target = path.join(installRoot, name);
348 if (name.startsWith(".install-")) {
349 const info = yield* fs.stat(target);
350 const modifiedAt = Option.match(info.mtime, {
351 onNone: () => now,
352 onSome: (date) => date.getTime(),
353 });
354 if (now - modifiedAt < STAGING_STALE_AFTER_MS) return;
355 }
356 yield* fs.remove(target, { recursive: true, force: true });
357 }).pipe(
358 Effect.catch((cause) =>
359 Effect.logWarning("Could not remove an old preview browser.", { name, cause }),
360 ),
361 ),
362 { discard: true },
363 );
364 return path.join(installRoot, release.version, executableName);
365 },
366 Effect.mapError(wrapFailure("Could not save the browser in T3's home directory.")),
367 );
368
369 // Joins the current install or starts one. A failure is reported once, then cleared.
370 const join = (release: PreviewBrowserRelease) =>
371 gate.withPermit(
372 Effect.gen(function* () {
373 if (state._tag === "installing") return state;
374 if (state._tag === "failed") {
375 const failed = state;
376 state = { _tag: "idle" };
377 return failed;
378 }
379 const installed = yield* installedExecutable(release);
380 if (Option.isSome(installed)) {
381 state = { _tag: "installed", executable: installed.value };
382 return state;
383 }
384 const done = yield* Deferred.make<string, PreviewBrowserInstallError>();
385 progress.downloadedBytes = 0;
386 progress.unpacking = false;
387 const installing: InstallState = { _tag: "installing", done };
388 state = installing;
389 yield* install(release).pipe(
390 Effect.onExit((exit) =>
391 gate
392 .withPermit(
393 Effect.sync(() => {
394 state = Exit.isSuccess(exit)
395 ? { _tag: "installed", executable: exit.value }
396 : {
397 _tag: "failed",
398 error: Option.getOrElse(
399 Cause.findErrorOption(exit.cause),
400 () => new PreviewBrowserInstallError({ detail: "The install stopped." }),
401 ),
402 };
403 }),
404 )
405 .pipe(Effect.andThen(Deferred.done(done, exit))),
406 ),
407 Effect.ignoreCause,
408 // Server shutdown closes the service scope and stops an install in progress.
409 Effect.interruptible,
410 Effect.forkIn(serviceScope),
411 );
412 return installing;
413 }),
414 );
415
416 // A waiter that saw the failure has reported it; the next call retries.
417 const clearFailure = (error: PreviewBrowserInstallError) =>
418 gate.withPermit(
419 Effect.sync(() => {
420 if (state._tag === "failed" && state.error === error) state = { _tag: "idle" };
421 }),
422 );
423
424 const executable = Effect.gen(function* () {
425 if (release === null) {
426 return yield* new PreviewBrowserUnsupportedError({ platform, arch });
427 }
428 const current = yield* join(release);
429 switch (current._tag) {
430 case "installed":
431 return current.executable;
432 case "failed":
433 return yield* current.error;
434 case "installing":
435 return yield* Deferred.await(current.done).pipe(
436 Effect.tapError(clearFailure),
437 Effect.timeoutOrElse({
438 duration: wait,
439 orElse: () =>
440 Effect.fail(
441 new PreviewBrowserInstallingError({
442 downloadedBytes: progress.downloadedBytes,
443 totalBytes: release.bytes,
444 unpacking: progress.unpacking,
445 }),
446 ),
447 }),
448 );
449 }
450 }).pipe(Effect.withSpan("PreviewBrowser.executable"));
451
452 const installed = release === null ? Effect.succeedNone : installedExecutable(release);
453
454 return PreviewBrowser.of({ executable, installed });
455});
456
457export const layer = Layer.effect(
458 PreviewBrowser,
459 Effect.gen(function* () {
460 const config = yield* ServerConfig.ServerConfig;
461 return yield* makePreviewBrowser({ baseDir: config.baseDir });
462 }),
463);
apps/server/src/preview/PreviewBrowserHost.test.ts created+105
...@@ -0,0 +1,105 @@
1import { describe, expect, it } from "@effect/vitest";
2import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
3import * as Effect from "effect/Effect";
4import * as Sink from "effect/Sink";
5import * as Stream from "effect/Stream";
6import { ChildProcessSpawner } from "effect/process";
7
8import * as PreviewBrowserHost from "./PreviewBrowserHost.ts";
9
10// What `ldd` printed for the pinned headless shell in a bare debian:trixie-slim container.
11const LDD_IN_BARE_DEBIAN = `\tlinux-vdso.so.1 (0x00007ffd2d1f8000)
12\tlibdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007f0c1b6a1000)
13\tlibglib-2.0.so.0 => not found
14\tlibnss3.so => not found
15\tlibX11.so.6 => not found
16\tlibc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f0c1b4c0000)
17`;
18
19// Chrome's own abort on Ubuntu 26.04 with unprivileged user namespaces restricted.
20const SANDBOX_ABORT =
21 "[1005/163106.285700:FATAL:content/browser/zygote_host/zygote_host_impl_linux.cc:129] No usable sandbox! If you are running on Ubuntu 23.10+ or another Linux distro that has disabled unprivileged user namespaces with AppArmor, see https://chromium.googlesource.com/";
22
23const lddReporting = (stdout: string) => {
24 const commands: Array<string> = [];
25 const spawner = ChildProcessSpawner.make((command) =>
26 Effect.sync(() => {
27 const { command: name, args } = command as unknown as {
28 readonly command: string;
29 readonly args: ReadonlyArray<string>;
30 };
31 commands.push([name, ...args].join(" "));
32 return ChildProcessSpawner.makeHandle({
33 pid: ChildProcessSpawner.ProcessId(1),
34 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),
35 isRunning: Effect.succeed(false),
36 kill: () => Effect.void,
37 unref: Effect.succeed(Effect.void),
38 stdin: Sink.drain,
39 stdout: Stream.make(new TextEncoder().encode(stdout)),
40 stderr: Stream.empty,
41 all: Stream.empty,
42 getInputFd: () => Sink.drain,
43 getOutputFd: () => Stream.empty,
44 });
45 }),
46 );
47 return { spawner, commands };
48};
49
50const diagnose = (input: { platform: NodeJS.Platform; output: string; ldd: string }) => {
51 const { spawner, commands } = lddReporting(input.ldd);
52 return PreviewBrowserHost.diagnoseLaunchFailure({
53 executable: "/home/me/.t3/tools/chrome-headless-shell/linux64/154/chrome-headless-shell",
54 output: input.output,
55 setupCommand: "sudo t3 browser setup",
56 }).pipe(
57 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
58 Effect.provideService(HostProcessPlatform, input.platform),
59 Effect.map((error) => ({ error, commands })),
60 );
61};
62
63describe("diagnoseLaunchFailure", () => {
64 it.effect("turns Chrome's sandbox abort into the AppArmor steps", () =>
65 Effect.gen(function* () {
66 const { error, commands } = yield* diagnose({
67 platform: "linux",
68 output: SANDBOX_ABORT,
69 ldd: "",
70 });
71 expect(error?._tag).toBe("PreviewBrowserSandboxError");
72 expect(error?.message).toContain("Run `sudo t3 browser setup` on the host");
73 expect(commands).toEqual([]);
74 }),
75 );
76
77 it.effect("names every library the loader cannot find", () =>
78 Effect.gen(function* () {
79 const { error } = yield* diagnose({
80 platform: "linux",
81 output: "error while loading shared libraries: libglib-2.0.so.0",
82 ldd: LDD_IN_BARE_DEBIAN,
83 });
84 expect(error).toMatchObject({
85 _tag: "PreviewBrowserLibrariesError",
86 libraries: ["libglib-2.0.so.0", "libnss3.so", "libX11.so.6"],
87 });
88 expect(error?.message).toContain("Run `sudo t3 browser setup` on the host");
89 }),
90 );
91
92 it.effect("leaves other failures alone", () =>
93 Effect.gen(function* () {
94 const linux = yield* diagnose({
95 platform: "linux",
96 output: "crashed",
97 ldd: "\tlibc.so.6 => /lib/libc.so.6\n",
98 });
99 expect(linux.error).toBeUndefined();
100 const mac = yield* diagnose({ platform: "darwin", output: "crashed", ldd: "" });
101 expect(mac.error).toBeUndefined();
102 expect(mac.commands).toEqual([]);
103 }),
104 );
105});
apps/server/src/preview/PreviewBrowserHost.ts created+145
...@@ -0,0 +1,145 @@
1/**
2 * Host setup the shared headless browser needs, and the one command that does
3 * it. Every failure here names that command, so the server log, an agent's tool
4 * error, and the viewer all offer the same fix: `sudo t3 browser setup`.
5 *
6 * T3 never turns Chrome's sandbox off by itself. A host that cannot give it one
7 * gets the command instead, and only the operator's explicit
8 * `T3CODE_SERVER_BROWSER_SANDBOX=0` launches without it.
9 */
10import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
11import * as Effect from "effect/Effect";
12import * as FileSystem from "effect/FileSystem";
13import * as Schema from "effect/Schema";
14import * as ChildProcess from "effect/process/ChildProcess";
15import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
16
17/** The subcommand that sets the host up; callers render it, with `sudo`, for how T3 was launched. */
18export const SETUP_SUBCOMMAND = "browser setup";
19
20/** What Chrome prints before aborting when it cannot sandbox itself. */
21export const NO_SANDBOX_SIGNATURE = "No usable sandbox";
22
23/** Whether an operator explicitly allowed Chrome to run without its sandbox. */
24export const sandboxDisabled = (env: Readonly<Record<string, string | undefined>>) =>
25 env.T3CODE_SERVER_BROWSER_SANDBOX === "0";
26
27/** Set on Ubuntu 23.10+: unprivileged user namespaces need an AppArmor profile. */
28const USERNS_RESTRICTION = "/proc/sys/kernel/apparmor_restrict_unprivileged_userns";
29export const APPARMOR_PROFILE_PATH = "/etc/apparmor.d/t3-chrome-headless-shell";
30
31/**
32 * Lets T3's headless browser, in any T3 home and at any pinned version, create
33 * the user namespace Chrome's sandbox runs in. Modelled on the profile Ubuntu
34 * ships for Google Chrome; `unconfined` adds nothing beyond `userns`.
35 */
36export const APPARMOR_PROFILE = `# Written by \`t3 browser setup\`: lets T3 Code's headless browser use Chrome's sandbox.
37abi <abi/4.0>,
38include <tunables/global>
39
40profile t3-chrome-headless-shell /**/tools/chrome-headless-shell/*/*/chrome-headless-shell flags=(unconfined) {
41 userns,
42
43 include if exists <local/t3-chrome-headless-shell>
44}
45`;
46
47/**
48 * Chrome's Debian dependencies that minimal images leave out, from the headless
49 * shell's deb.deps. Ubuntu 24.04 and Debian 13 renamed some with a `t64`
50 * suffix; setup installs whichever name the host's apt offers.
51 */
52export const DEBIAN_PACKAGES: ReadonlyArray<ReadonlyArray<string>> = [
53 ["libnss3"],
54 ["libglib2.0-0t64", "libglib2.0-0"],
55 ["libatk1.0-0t64", "libatk1.0-0"],
56 ["libatk-bridge2.0-0t64", "libatk-bridge2.0-0"],
57 ["libatspi2.0-0t64", "libatspi2.0-0"],
58 ["libdbus-1-3"],
59 ["libx11-6"],
60 ["libxcb1"],
61 ["libxcomposite1"],
62 ["libxdamage1"],
63 ["libxext6"],
64 ["libxfixes3"],
65 ["libxrandr2"],
66 ["libxkbcommon0"],
67 ["libgbm1"],
68 ["libasound2t64", "libasound2"],
69 ["libexpat1"],
70];
71
72export class PreviewBrowserSandboxError extends Schema.TaggedError<PreviewBrowserSandboxError>()(
73 "PreviewBrowserSandboxError",
74 { setupCommand: Schema.String },
75) {
76 override get message(): string {
77 return `This host blocks the sandbox T3's browser runs in (AppArmor on Ubuntu 23.10+). Run \`${this.setupCommand}\` on the host once to allow it, then try again.`;
78 }
79}
80
81export class PreviewBrowserLibrariesError extends Schema.TaggedError<PreviewBrowserLibrariesError>()(
82 "PreviewBrowserLibrariesError",
83 { setupCommand: Schema.String, libraries: Schema.Array(Schema.String) },
84) {
85 override get message(): string {
86 return `This host is missing libraries T3's browser needs (${this.libraries.join(", ")}). Run \`${this.setupCommand}\` on the host to install them, then try again.`;
87 }
88}
89
90export type PreviewBrowserHostError = PreviewBrowserSandboxError | PreviewBrowserLibrariesError;
91
92const MISSING_LIBRARY = /^\s*(\S+) => not found$/gm;
93
94/**
95 * After a launch fails, names the host setup it is missing: the sandbox, from
96 * Chrome's own abort message, or shared libraries, from `ldd`. Undefined when
97 * neither explains it. Linux only; other hosts never need either.
98 */
99export const diagnoseLaunchFailure = Effect.fn("PreviewBrowserHost.diagnoseLaunchFailure")(
100 function* (input: {
101 readonly executable: string;
102 readonly output: string;
103 readonly setupCommand: string;
104 }) {
105 const { setupCommand } = input;
106 if (input.output.includes(NO_SANDBOX_SIGNATURE)) {
107 return new PreviewBrowserSandboxError({ setupCommand });
108 }
109 if ((yield* HostProcessPlatform) !== "linux") return undefined;
110 const libraries = yield* missingLibraries(input.executable);
111 return libraries.length === 0
112 ? undefined
113 : new PreviewBrowserLibrariesError({ setupCommand, libraries });
114 },
115);
116
117/** Shared libraries the loader cannot find for `executable`, by `ldd`. */
118export const missingLibraries = Effect.fn("PreviewBrowserHost.missingLibraries")(function* (
119 executable: string,
120) {
121 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
122 const report = yield* spawner
123 .string(ChildProcess.make("ldd", [executable], { stdin: "ignore", stderr: "ignore" }))
124 .pipe(
125 Effect.timeout("5 seconds"),
126 Effect.orElseSucceed(() => ""),
127 );
128 return [...report.matchAll(MISSING_LIBRARY)].map((match) => match[1]!);
129});
130
131/**
132 * Whether Chrome's sandbox will be blocked here: the host restricts user
133 * namespaces and T3's AppArmor profile is not installed. Readable without
134 * root, so the server checks it at startup.
135 */
136export const sandboxBlocked = Effect.gen(function* () {
137 if ((yield* HostProcessPlatform) !== "linux") return false;
138 const fs = yield* FileSystem.FileSystem;
139 const restricted = yield* fs.readFileString(USERNS_RESTRICTION).pipe(
140 Effect.map((value) => value.trim() === "1"),
141 Effect.orElseSucceed(() => false),
142 );
143 if (!restricted) return false;
144 return !(yield* fs.exists(APPARMOR_PROFILE_PATH).pipe(Effect.orElseSucceed(() => false)));
145}).pipe(Effect.withSpan("PreviewBrowserHost.sandboxBlocked"));
apps/server/src/preview/ServerBrowser.test.ts created+1092
...@@ -0,0 +1,1092 @@
1import * as NodeEvents from "node:events";
2
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { it } from "@effect/vitest";
5import {
6 EnvironmentId,
7 PreviewTabId,
8 ProviderInstanceId,
9 ThreadId,
10 type PreviewAutomationSnapshot,
11 type PreviewAutomationStatus,
12} from "@t3tools/contracts";
13import * as Clock from "effect/Clock";
14import * as Effect from "effect/Effect";
15import * as Fiber from "effect/Fiber";
16import * as FileSystem from "effect/FileSystem";
17import * as Layer from "effect/Layer";
18import * as Option from "effect/Option";
19import * as Queue from "effect/Queue";
20import * as Stream from "effect/Stream";
21import type { BrowserContext, Page } from "playwright-core";
22import { beforeEach, expect, vi } from "vite-plus/test";
23
24import * as ServerConfig from "../config.ts";
25import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
26import * as Broker from "../mcp/PreviewAutomationBroker.ts";
27import * as DesktopChannel from "./DesktopBrowserChannel.ts";
28import * as Manager from "./Manager.ts";
29import * as ServerBrowser from "./ServerBrowser.ts";
30import * as PreviewBrowser from "./PreviewBrowser.ts";
31
32// Keep the manager, broker, ownership, refs, and viewer paths real; replace Chromium I/O only.
33vi.mock("./ServerBrowserContexts.ts", () => ({
34 ServerBrowserContexts: class {
35 private readonly onClose: ((context: BrowserContext) => void) | undefined;
36 constructor(options: { onContextClose?: (context: BrowserContext) => void }) {
37 this.onClose = options.onContextClose;
38 }
39 async contextFor() {
40 if (contextFailure) throw contextFailure;
41 await contextGate?.promise;
42 const context = makeContext(this.onClose);
43 contexts.push(context);
44 return context as unknown as BrowserContext;
45 }
46 async scratchPage() {
47 return makeContext().page as unknown as Page;
48 }
49 async connectDesktopPage(endpoint: string) {
50 const context = makeContext();
51 desktopConnections.push({ endpoint, context });
52 return { browser: { close: async () => {} }, page: context.page as unknown as Page };
53 }
54 async close() {
55 for (const context of contexts) await context.close();
56 }
57 },
58}));
59
60function makeSession() {
61 return {
62 on: vi.fn(),
63 detach: vi.fn(async () => {}),
64 send: vi.fn(async (method: string, _input?: unknown): Promise<Record<string, unknown>> => {
65 if (method === "Page.getNavigationHistory") return { currentIndex: 0, entries: [{}] };
66 if (method === "Page.getLayoutMetrics") return { cssVisualViewport: { pageX: 0, pageY: 0 } };
67 if (method === "Page.captureScreenshot") return { data: "ZnJhbWU=" };
68 return { result: { value: "evaluated" } };
69 }),
70 };
71}
72
73function makeContext(onClose?: (context: BrowserContext) => void) {
74 const events = new NodeEvents.EventEmitter();
75 const sessions: ReturnType<typeof makeSession>[] = [];
76 let url = "about:blank";
77 let viewport = { width: 1280, height: 800 };
78 let closed = false;
79 let contextClosed = false;
80 const page = {
81 on: (name: string, callback: (...args: unknown[]) => void) => events.on(name, callback),
82 once: (name: string, callback: (...args: unknown[]) => void) => events.once(name, callback),
83 off: (name: string, callback: (...args: unknown[]) => void) => events.off(name, callback),
84 emit: (name: string, ...args: unknown[]) => events.emit(name, ...args),
85 emitAsync: (name: string, ...args: unknown[]) =>
86 Promise.all(events.listeners(name).map((listener) => listener(...args))),
87 setDefaultTimeout: vi.fn(),
88 setDefaultNavigationTimeout: vi.fn(),
89 context: () => context,
90 mainFrame: () => page,
91 url: () => url,
92 title: vi.fn(async () => "test page"),
93 viewportSize: () => viewport,
94 setViewportSize: vi.fn(async (size: typeof viewport) => {
95 viewport = size;
96 }),
97 goto: vi.fn(async (next: string) => {
98 url = next;
99 events.emit("load");
100 }),
101 goBack: vi.fn(async () => {}),
102 goForward: vi.fn(async () => {}),
103 reload: vi.fn(async () => {}),
104 emulateMedia: vi.fn(async () => {}),
105 waitForLoadState: vi.fn(async () => {}),
106 evaluate: vi.fn(async () => ({
107 url,
108 title: "test page",
109 loading: false,
110 visibleText: "delete",
111 interactiveElements: [],
112 })),
113 ariaSnapshot: vi.fn(async () => '- button "delete" [ref=e1]'),
114 locator: vi.fn(() => {
115 throw new Error("Unexpected locator action");
116 }),
117 isClosed: () => closed,
118 close: vi.fn(async () => {
119 if (!closed) {
120 closed = true;
121 events.emit("close");
122 }
123 }),
124 };
125 const context = {
126 page,
127 sessions,
128 newPage: async () => page as unknown as Page,
129 grantPermissions: vi.fn(async () => {}),
130 exposeBinding: vi.fn(async (_name: string, binding: ClipboardBinding) => {
131 clipboardBinding = binding;
132 }),
133 addInitScript: vi.fn(async () => {}),
134 newCDPSession: async () => {
135 const session = makeSession();
136 sessions.push(session);
137 return session;
138 },
139 close: vi.fn(async () => {
140 if (contextClosed) return;
141 contextClosed = true;
142 await page.close();
143 onClose?.(context as unknown as BrowserContext);
144 }),
145 };
146 return context;
147}
148
149const contexts: ReturnType<typeof makeContext>[] = [];
150let contextGate: PromiseWithResolvers<void> | null = null;
151type ClipboardBinding = (source: { page: unknown }, text: unknown) => void;
152let clipboardBinding: ClipboardBinding | null = null;
153let contextFailure: Error | null = null;
154/** Server tabs the fake desktop renders, and the endpoints the server connected to. */
155let desktopRendersNext = false;
156/** Pages the fake desktop takes back; the channel's detached stream emits them. */
157const desktopDetaches = new NodeEvents.EventEmitter();
158const desktopTabs = new Set<string>();
159const desktopRenders = (tabId: string) => {
160 if (desktopRendersNext) {
161 desktopRendersNext = false;
162 desktopTabs.add(tabId);
163 }
164 return desktopTabs.has(tabId);
165};
166const releasedDesktopTabs: Array<string> = [];
167const desktopConnections: Array<{ endpoint: string; context: ReturnType<typeof makeContext> }> = [];
168const testThread = {
169 threadId: ThreadId.make("browser-test-thread"),
170 providerSessionId: "agent-a",
171 providerInstanceId: ProviderInstanceId.make("codex"),
172};
173const scope = {
174 environmentId: EnvironmentId.make("browser-test-environment"),
175 thread: testThread,
176 client: undefined,
177 requestNamespace: "browser-test",
178 capabilities: new Set(["preview"] as const),
179 issuedAt: 1,
180};
181/** The same thread, as another of its agent sessions. */
182const asSession = (providerSessionId: string) => ({
183 ...scope,
184 thread: { ...testThread, providerSessionId },
185});
186const dependencies = Layer.mergeAll(
187 Broker.layer,
188 Manager.layer,
189 Layer.succeed(ServerEnvironment.ServerEnvironment, {
190 getEnvironmentId: Effect.succeed(scope.environmentId),
191 getDescriptor: Effect.die("unused descriptor"),
192 }),
193 Layer.succeed(PreviewBrowser.PreviewBrowser, {
194 executable: Effect.die("mock Chromium does not need an executable"),
195 installed: Effect.die("mock Chromium does not need an executable"),
196 }),
197 Layer.succeed(DesktopChannel.DesktopBrowserChannel, {
198 // Only tabs a test marks render on the desktop; the rest stay headless.
199 available: true,
200 awaitAttached: (key) => Effect.sync(() => desktopRenders(key.tabId)),
201 detached: Stream.callback<{ threadId: string; tabId: string }>((queue) =>
202 Effect.acquireRelease(
203 Effect.sync(() => {
204 const onDetach = (key: { threadId: string; tabId: string }) =>
205 Queue.offerUnsafe(queue, key);
206 desktopDetaches.on("detach", onDetach);
207 return onDetach;
208 }),
209 (onDetach) => Effect.sync(() => desktopDetaches.off("detach", onDetach)),
210 ),
211 ),
212 isAttached: (key) => Effect.sync(() => desktopRenders(key.tabId)),
213 endpoint: (key) =>
214 Effect.acquireRelease(Effect.succeed(`ws://desktop/${key.tabId}`), () =>
215 Effect.sync(() => releasedDesktopTabs.push(key.tabId)),
216 ),
217 pointer: () => Effect.void,
218 }),
219).pipe(
220 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-server-browser-" })),
221 Layer.provideMerge(NodeServices.layer),
222);
223const layer = ServerBrowser.layer.pipe(Layer.provideMerge(dependencies));
224const ready = Effect.gen(function* () {
225 const browser = yield* ServerBrowser.ServerBrowser;
226 const broker = yield* Broker.PreviewAutomationBroker;
227 yield* Effect.yieldNow;
228 const opened = yield* broker.invoke<PreviewAutomationStatus>({
229 scope,
230 operation: "open",
231 input: { reuseExistingTab: false, show: false },
232 });
233 const tabId = PreviewTabId.make(opened.tabId!);
234 return { browser, broker, tabId };
235});
236/** Fills a viewer's output the way a viewer that stopped reading leaves it. */
237const stallViewer = (
238 viewer: ServerBrowser.ServerBrowserViewer,
239 item: ServerBrowser.ServerBrowserViewerOutput,
240) => {
241 // The service hands out the read side of a queue it also writes to.
242 const output = viewer.output as unknown as Queue.Queue<ServerBrowser.ServerBrowserViewerOutput>;
243 while (Queue.offerUnsafe(output, item));
244};
245const viewerInput = (tabId: string, canOperate: boolean) => ({
246 threadId: scope.thread.threadId,
247 tabId,
248 canOperate,
249 maxWidth: 1280,
250 maxHeight: 800,
251 quality: 70,
252});
253
254beforeEach(() => {
255 contexts.length = 0;
256 contextGate = null;
257 contextFailure = null;
258 desktopTabs.clear();
259 desktopRendersNext = false;
260 releasedDesktopTabs.length = 0;
261 desktopConnections.length = 0;
262});
263
264it.live("readiness none responds immediately but takeover input waits for navigation commit", () =>
265 Effect.scoped(
266 Effect.gen(function* () {
267 const { browser, broker, tabId } = yield* ready;
268 const viewer = yield* browser.attachViewer(viewerInput(tabId, true));
269 const committed = Promise.withResolvers<void>();
270 const events: string[] = [];
271 contexts[0]!.page.goto.mockImplementationOnce(async () => {
272 await committed.promise;
273 events.push("navigation committed");
274 });
275 yield* Effect.addFinalizer(() => Effect.sync(() => committed.resolve()));
276 const response = yield* broker.invoke<PreviewAutomationStatus>({
277 scope,
278 tabId,
279 operation: "navigate",
280 input: { url: "http://localhost:5173/next", readiness: "none" },
281 });
282 expect(response.available).toBe(true);
283 expect(events).toEqual([]);
284 yield* Queue.clear(viewer.output);
285 const takeover = yield* viewer.input({ type: "takeControl" }).pipe(Effect.forkScoped);
286 let control = yield* Queue.take(viewer.output);
287 while (control._tag !== "control" || control.controller !== "you") {
288 control = yield* Queue.take(viewer.output);
289 }
290 const cdp = contexts[0]!.sessions.at(-1)!;
291 const send = cdp.send.getMockImplementation()!;
292 cdp.send.mockImplementation(async (operation, input) => {
293 if (operation === "Input.insertText") events.push("human typed");
294 return send(operation, input);
295 });
296 const typing = yield* viewer.input({ type: "text", text: "hello" }).pipe(Effect.forkScoped);
297 yield* broker.invoke({ scope, tabId, operation: "status", input: {} });
298 expect(events).toEqual([]);
299 committed.resolve();
300 yield* Fiber.join(takeover);
301 yield* Fiber.join(typing);
302 expect(events).toEqual(["navigation committed", "human typed"]);
303 }),
304 ).pipe(Effect.provide(layer)),
305);
306
307it.live.each([
308 { method: "goto" as const, message: { type: "navigate", url: "http://localhost:5173/next" } },
309 { method: "goBack" as const, message: { type: "history", delta: -1 } },
310 { method: "goForward" as const, message: { type: "history", delta: 1 } },
311 { method: "reload" as const, message: { type: "reload" } },
312])("release waits for viewer $method to commit before agent actions", ({ method, message }) =>
313 Effect.scoped(
314 Effect.gen(function* () {
315 const { browser, broker, tabId } = yield* ready;
316 const viewer = yield* browser.attachViewer(viewerInput(tabId, true));
317 yield* viewer.input({ type: "takeControl" });
318 yield* Queue.clear(viewer.output);
319 const started = Promise.withResolvers<void>();
320 const committed = Promise.withResolvers<void>();
321 const events: string[] = [];
322 contexts[0]!.page[method].mockImplementationOnce(async () => {
323 started.resolve();
324 await committed.promise;
325 events.push("navigation committed");
326 });
327 const navigate = yield* viewer.input(message).pipe(Effect.forkScoped);
328 yield* Effect.addFinalizer(() => Effect.sync(() => committed.resolve()));
329 yield* Effect.promise(() => started.promise);
330 const releasing = yield* viewer.input({ type: "releaseControl" }).pipe(Effect.forkScoped);
331 let control = yield* Queue.take(viewer.output);
332 while (control._tag !== "control" || control.controller !== "agent") {
333 control = yield* Queue.take(viewer.output);
334 }
335 const cdp = contexts[0]!.sessions[0]!;
336 const send = cdp.send.getMockImplementation()!;
337 cdp.send.mockImplementation(async (operation, input) => {
338 if (operation === "Runtime.evaluate") events.push("agent acted");
339 return send(operation, input);
340 });
341 const resumed = yield* broker
342 .invoke({
343 scope,
344 tabId,
345 operation: "evaluate",
346 input: { expression: "resumed()" },
347 })
348 .pipe(Effect.forkScoped);
349 yield* broker.invoke({ scope, tabId, operation: "status", input: {} });
350 expect(events).toEqual([]);
351 committed.resolve();
352 yield* Fiber.join(navigate);
353 yield* Fiber.join(releasing);
354 yield* Fiber.join(resumed);
355 expect(events).toEqual(["navigation committed", "agent acted"]);
356 const calls = contexts[0]!.page[method].mock.calls;
357 expect(calls[0]?.at(-1)).toMatchObject({ waitUntil: "commit", timeout: 15_000 });
358 }),
359 ).pipe(Effect.provide(layer)),
360);
361
362it.live("enforces provider ownership and explicit targets when a session has multiple tabs", () =>
363 Effect.scoped(
364 Effect.gen(function* () {
365 const { broker, tabId } = yield* ready;
366 const foreign = yield* broker
367 .invoke<void>({
368 scope: asSession("agent-b"),
369 tabId,
370 operation: "evaluate",
371 input: { expression: "foreign()" },
372 })
373 .pipe(Effect.flip);
374 expect(foreign).toMatchObject({
375 _tag: "PreviewAutomationControlInterruptedError",
376 reason: "agentMismatch",
377 });
378 expect(contexts[0]!.sessions[0]!.send).not.toHaveBeenCalledWith(
379 "Runtime.evaluate",
380 expect.anything(),
381 );
382 yield* broker.invoke({
383 scope,
384 operation: "open",
385 input: { reuseExistingTab: false, show: false },
386 });
387 const ambiguous = yield* broker
388 .invoke<void>({ scope, operation: "evaluate", input: { expression: "ambiguous()" } })
389 .pipe(Effect.flip);
390 expect(ambiguous).toMatchObject({
391 _tag: "PreviewAutomationControlInterruptedError",
392 reason: "tabRequired",
393 });
394 const ambiguousStop = yield* broker
395 .invoke<void>({ scope, operation: "recordingStop", input: {} })
396 .pipe(Effect.flip);
397 expect(ambiguousStop).toMatchObject({
398 _tag: "PreviewAutomationControlInterruptedError",
399 reason: "tabRequired",
400 });
401 const explicitStop = yield* broker
402 .invoke<void>({ scope, tabId, operation: "recordingStop", input: {} })
403 .pipe(Effect.flip);
404 expect(explicitStop).toMatchObject({
405 _tag: "PreviewAutomationExecutionError",
406 cause: { _tag: "PreviewAutomationRecordingNotActiveError" },
407 });
408 const result = yield* broker.invoke({
409 scope,
410 tabId,
411 operation: "evaluate",
412 input: { expression: "owned()" },
413 });
414 expect(result).toBe("evaluated");
415 expect(contexts).toHaveLength(2);
416 }),
417 ).pipe(Effect.provide(layer)),
418);
419
420it.live("streams to a read-only viewer without allowing takeover, input, or viewport changes", () =>
421 Effect.scoped(
422 Effect.gen(function* () {
423 const { browser, tabId } = yield* ready;
424 const viewer = yield* browser.attachViewer(viewerInput(tabId, false));
425 const page = contexts[0]!.page;
426 page.setViewportSize.mockClear();
427 const session = contexts[0]!.sessions.at(-1)!;
428 session.send.mockClear();
429 for (const message of [
430 { type: "takeControl" },
431 { type: "key", action: "down", key: "a", text: "a" },
432 { type: "resize", width: 390, height: 844 },
433 { type: "viewport", setting: { _tag: "freeform", width: 390, height: 844 } },
434 ])
435 yield* viewer.input(message);
436 expect(page.setViewportSize).not.toHaveBeenCalled();
437 expect(session.send.mock.calls.some(([method]) => method.startsWith("Input."))).toBe(false);
438 const outputs = yield* Queue.takeAll(viewer.output);
439 expect(outputs).toContainEqual(
440 expect.objectContaining({ _tag: "frame", data: Buffer.from("frame") }),
441 );
442 expect(outputs).toContainEqual(expect.objectContaining({ _tag: "viewport" }));
443 expect(outputs).toContainEqual(
444 expect.objectContaining({ _tag: "control", canOperate: false, controller: "agent" }),
445 );
446 const operator = yield* browser.attachViewer(viewerInput(tabId, true));
447 yield* operator.input({ type: "takeControl" });
448 yield* operator.input({ type: "text", text: "typed" });
449 yield* operator.input({ type: "resize", width: 390, height: 844 });
450 expect(contexts[0]!.sessions.at(-1)!.send).toHaveBeenCalledWith("Input.insertText", {
451 text: "typed",
452 });
453 expect(page.setViewportSize).toHaveBeenCalledWith({ width: 390, height: 844 });
454 yield* operator.input({ type: "releaseControl" });
455 }),
456 ).pipe(Effect.provide(layer)),
457);
458
459it.live("applies any client's viewport, appearance, and zoom to a headless tab", () =>
460 Effect.scoped(
461 Effect.gen(function* () {
462 const { tabId } = yield* ready;
463 const manager = yield* Manager.PreviewManager;
464 const page = contexts[0]!.page;
465 const session = contexts[0]!.sessions[0]!;
466 const target = { threadId: scope.thread.threadId, tabId };
467 // The agent owns this tab, and the client sends no takeover first.
468 yield* manager.resize({ ...target, viewport: { _tag: "freeform", width: 390, height: 844 } });
469 yield* manager.adjust({ ...target, colorScheme: "dark", zoomFactor: 1.25 });
470 // Settings apply in order, so the reload landing means the earlier ones did too.
471 const reloaded = Promise.withResolvers<void>();
472 const send = session.send.getMockImplementation()!;
473 session.send.mockImplementation(async (method, input) => {
474 if (method === "Page.reload") reloaded.resolve();
475 return send(method, input);
476 });
477 yield* manager.adjust({ ...target, hardReload: true });
478 yield* Effect.promise(() => reloaded.promise);
479 expect(session.send).toHaveBeenCalledWith("Page.reload", { ignoreCache: true });
480 expect(page.setViewportSize).toHaveBeenCalledWith({ width: 390, height: 844 });
481 expect(page.emulateMedia).toHaveBeenCalledWith({ colorScheme: "dark" });
482 // Zoom lays the page out in fewer CSS pixels and draws each one larger.
483 expect(session.send).toHaveBeenCalledWith("Emulation.setDeviceMetricsOverride", {
484 width: 312,
485 height: 675,
486 deviceScaleFactor: 2.5,
487 mobile: false,
488 });
489 }),
490 ).pipe(Effect.provide(layer)),
491);
492
493it.live(
494 "takeover waits for the running agent and revokes snapshot refs before returning control",
495 () =>
496 Effect.scoped(
497 Effect.gen(function* () {
498 const { browser, broker, tabId } = yield* ready;
499 const snapshot = yield* broker.invoke<PreviewAutomationSnapshot>({
500 scope,
501 tabId,
502 operation: "snapshot",
503 input: {},
504 });
505 const ref = /\[ref=([^\]]+)\]/.exec(String(snapshot.accessibilityTree))![1]!;
506 const viewer = yield* browser.attachViewer(viewerInput(tabId, true));
507 const started = Promise.withResolvers<void>();
508 const finish = Promise.withResolvers<Record<string, unknown>>();
509 const session = contexts[0]!.sessions[0]!;
510 session.send.mockImplementationOnce(async () => {
511 started.resolve();
512 return finish.promise;
513 });
514 const running = yield* broker
515 .invoke({ scope, tabId, operation: "evaluate", input: { expression: "pending()" } })
516 .pipe(Effect.forkScoped);
517 yield* Effect.addFinalizer(() =>
518 Effect.sync(() => finish.resolve({ result: { value: "finished" } })),
519 );
520 yield* Effect.promise(() => started.promise);
521 const takeover = yield* viewer.input({ type: "takeControl" }).pipe(Effect.forkScoped);
522 let control = yield* Queue.take(viewer.output);
523 while (control._tag !== "control" || control.controller !== "you") {
524 control = yield* Queue.take(viewer.output);
525 }
526 const rejected = yield* broker
527 .invoke<void>({ scope, tabId, operation: "evaluate", input: { expression: "racing()" } })
528 .pipe(Effect.flip);
529 expect(rejected).toMatchObject({
530 _tag: "PreviewAutomationControlInterruptedError",
531 reason: "humanControl",
532 });
533 finish.resolve({ result: { value: "finished" } });
534 expect(yield* Fiber.join(running)).toBe("finished");
535 yield* Fiber.join(takeover);
536 yield* viewer.input({ type: "releaseControl" });
537 const stale = yield* broker
538 .invoke<void>({ scope, tabId, operation: "click", input: { locator: `aria-ref=${ref}` } })
539 .pipe(Effect.flip);
540 expect(stale._tag).toBe("PreviewAutomationInvalidSelectorError");
541 expect(contexts[0]!.page.locator).not.toHaveBeenCalled();
542 expect(
543 yield* broker.invoke({
544 scope,
545 tabId,
546 operation: "evaluate",
547 input: { expression: "resumed()" },
548 }),
549 ).toBe("evaluated");
550 }),
551 ).pipe(Effect.provide(layer)),
552);
553
554it.live("reports a pending dialog without evaluating the page and resolves it explicitly", () =>
555 Effect.scoped(
556 Effect.gen(function* () {
557 const { broker, tabId } = yield* ready;
558 const page = contexts[0]!.page;
559 yield* broker.invoke({
560 scope,
561 tabId,
562 operation: "navigate",
563 input: { url: "http://localhost:5173" },
564 });
565 const started = Promise.withResolvers<void>();
566 const resolvedEvaluation = Promise.withResolvers<Record<string, unknown>>();
567 const session = contexts[0]!.sessions[0]!;
568 const send = session.send.getMockImplementation()!;
569 session.send.mockImplementation(async (method, input) => {
570 if (method !== "Runtime.evaluate") return send(method, input);
571 started.resolve();
572 return resolvedEvaluation.promise;
573 });
574 const blockedAction = yield* broker
575 .invoke({
576 scope,
577 tabId,
578 operation: "evaluate",
579 input: { expression: 'confirm("Delete row?")' },
580 })
581 .pipe(Effect.forkScoped);
582 yield* Effect.addFinalizer(() =>
583 Effect.sync(() => resolvedEvaluation.resolve({ result: { value: true } })),
584 );
585 yield* Effect.promise(() => started.promise);
586 const dialog = {
587 type: () => "confirm",
588 message: () => "Delete row?",
589 defaultValue: () => "",
590 accept: vi.fn(async () => {
591 resolvedEvaluation.resolve({ result: { value: true } });
592 }),
593 dismiss: vi.fn(async () => {}),
594 };
595 page.emit("dialog", dialog);
596 page.title.mockClear();
597 const status = yield* broker.invoke<PreviewAutomationStatus>({
598 scope,
599 tabId,
600 operation: "status",
601 input: {},
602 });
603 expect(status.dialog).toMatchObject({ type: "confirm", message: "Delete row?" });
604 expect(page.title).not.toHaveBeenCalled();
605 expect(dialog.dismiss).not.toHaveBeenCalled();
606 const reuse = yield* broker
607 .invoke<void>({
608 scope,
609 tabId,
610 operation: "open",
611 input: { url: "http://localhost:5173/other" },
612 })
613 .pipe(Effect.flip);
614 expect(reuse).toMatchObject({
615 _tag: "PreviewAutomationControlInterruptedError",
616 reason: "dialogPending",
617 });
618 expect(page.goto).toHaveBeenCalledTimes(1);
619 yield* broker.invoke({ scope, tabId, operation: "dialog", input: { accept: true } });
620 expect(yield* Fiber.join(blockedAction)).toBe(true);
621 expect(dialog.accept).toHaveBeenCalledTimes(1);
622 const resolved = yield* broker.invoke<PreviewAutomationStatus>({
623 scope,
624 tabId,
625 operation: "status",
626 input: {},
627 });
628 expect(resolved.dialog).toBeNull();
629 }),
630 ).pipe(Effect.provide(layer)),
631);
632
633it.live("the owner can close a tab while an agent action waits on its dialog", () =>
634 Effect.scoped(
635 Effect.gen(function* () {
636 const { broker, tabId } = yield* ready;
637 const page = contexts[0]!.page;
638 const started = Promise.withResolvers<void>();
639 const finish = Promise.withResolvers<Record<string, unknown>>();
640 contexts[0]!.sessions[0]!.send.mockImplementationOnce(async () => {
641 started.resolve();
642 return finish.promise;
643 });
644 page.on("close", () => finish.resolve({ result: { value: "closed" } }));
645 const running = yield* broker
646 .invoke({
647 scope,
648 tabId,
649 operation: "evaluate",
650 input: { expression: 'confirm("Delete?")' },
651 })
652 .pipe(Effect.forkScoped);
653 yield* Effect.addFinalizer(() =>
654 Effect.sync(() => finish.resolve({ result: { value: "cleanup" } })),
655 );
656 yield* Effect.promise(() => started.promise);
657 page.emit("dialog", {
658 type: () => "confirm",
659 message: () => "Delete?",
660 defaultValue: () => "",
661 accept: vi.fn(),
662 dismiss: vi.fn(),
663 });
664 const foreign = yield* broker
665 .invoke<void>({
666 scope: asSession("agent-b"),
667 tabId,
668 operation: "close",
669 input: {},
670 })
671 .pipe(Effect.flip);
672 expect(foreign).toMatchObject({
673 _tag: "PreviewAutomationControlInterruptedError",
674 reason: "agentMismatch",
675 });
676 expect(page.close).not.toHaveBeenCalled();
677 yield* broker.invoke({ scope, tabId, operation: "close", input: {} });
678 expect(yield* Fiber.join(running)).toBe("closed");
679 expect(page.close).toHaveBeenCalled();
680 const manager = yield* Manager.PreviewManager;
681 expect((yield* manager.list({ threadId: scope.thread.threadId })).sessions).toHaveLength(0);
682 const afterClose = yield* broker
683 .invoke<void>({ scope, tabId, operation: "evaluate", input: { expression: "late()" } })
684 .pipe(Effect.flip);
685 expect(afterClose._tag).toBe("PreviewAutomationTabNotFoundError");
686 }),
687 ).pipe(Effect.provide(layer)),
688);
689
690it.live("a popup becomes the agent's own tab and keeps its opener page", () =>
691 Effect.scoped(
692 Effect.gen(function* () {
693 const { broker, tabId } = yield* ready;
694 const opener = contexts[0]!.page;
695 const popup = makeContext();
696 opener.emit("popup", popup.page);
697 const manager = yield* Manager.PreviewManager;
698 let sessions = (yield* manager.list({ threadId: scope.thread.threadId })).sessions;
699 while (sessions.length < 2) {
700 yield* Effect.sleep("5 millis");
701 sessions = (yield* manager.list({ threadId: scope.thread.threadId })).sessions;
702 }
703 const popupTab = sessions.find((session) => session.tabId !== tabId)!;
704 const opened = sessions.find((session) => session.tabId === tabId)!;
705 expect(popupTab).toMatchObject({ automationOwner: opened.automationOwner, reveal: false });
706 const status = yield* broker.invoke<PreviewAutomationStatus>({
707 scope,
708 tabId,
709 operation: "status",
710 input: {},
711 });
712 expect(status.tabs).toEqual(
713 expect.arrayContaining([
714 expect.objectContaining({ tabId }),
715 expect.objectContaining({ tabId: popupTab.tabId, openerTabId: tabId }),
716 ]),
717 );
718 expect(opener.goto).not.toHaveBeenCalled();
719 expect(opener.close).not.toHaveBeenCalled();
720 // The page the popup script holds is the tab, so closing it ends the tab.
721 yield* Effect.promise(() => popup.page.close());
722 while ((yield* manager.list({ threadId: scope.thread.threadId })).sessions.length > 1) {
723 yield* Effect.sleep("5 millis");
724 }
725 }),
726 ).pipe(Effect.provide(layer)),
727);
728
729it.live("closing a tab while a viewer is still opening it does not leave its page behind", () =>
730 Effect.scoped(
731 Effect.gen(function* () {
732 const browser = yield* ServerBrowser.ServerBrowser;
733 const manager = yield* Manager.PreviewManager;
734 yield* Effect.yieldNow;
735 // The background open fails, so the tab exists only as a session.
736 contextFailure = new Error("first launch failed");
737 const snapshot = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" });
738 yield* Effect.sleep("10 millis");
739 contextFailure = null;
740 contextGate = Promise.withResolvers<void>();
741 const attaching = yield* browser
742 .attachViewer(viewerInput(snapshot.tabId, false))
743 .pipe(Effect.flip, Effect.forkScoped);
744 yield* Effect.sleep("10 millis");
745 yield* manager.close({ threadId: scope.thread.threadId, tabId: snapshot.tabId });
746 yield* Effect.sleep("10 millis");
747 contextGate.resolve();
748 expect((yield* Fiber.join(attaching))._tag).toBe("ServerBrowserTabNotFoundError");
749 expect(contexts).toHaveLength(1);
750 expect(contexts[0]!.page.close).toHaveBeenCalled();
751 }),
752 ).pipe(Effect.provide(layer)),
753);
754
755it.live("page copies reach only the controlling viewer right after its input", () =>
756 Effect.scoped(
757 Effect.gen(function* () {
758 const { browser, tabId } = yield* ready;
759 const page = contexts[0]!.page;
760 const watcher = yield* browser.attachViewer(viewerInput(tabId, false));
761 const viewer = yield* browser.attachViewer(viewerInput(tabId, true));
762 yield* viewer.input({ type: "takeControl" });
763 const clipboard = (queue: typeof viewer.output) =>
764 Queue.clear(queue).pipe(
765 Effect.map((items) => items.filter((item) => item._tag === "clipboard")),
766 );
767 // A page writing on its own, without a recent gesture, stays on the server.
768 clipboardBinding!({ page }, "unprompted");
769 expect(yield* clipboard(viewer.output)).toEqual([]);
770 yield* viewer.input({ type: "key", action: "down", key: "c", code: "KeyC", modifiers: 4 });
771 const cdp = contexts[0]!.sessions.at(-1)!;
772 expect(cdp.send).toHaveBeenCalledWith(
773 "Input.dispatchKeyEvent",
774 expect.objectContaining({ key: "c", commands: ["copy"] }),
775 );
776 clipboardBinding!({ page }, "copied");
777 expect(yield* clipboard(viewer.output)).toEqual([{ _tag: "clipboard", text: "copied" }]);
778 expect(yield* clipboard(watcher.output)).toEqual([]);
779 }),
780 ).pipe(Effect.provide(layer)),
781);
782
783it.live("a page download is saved, offered to the controller, and listed for the agent", () =>
784 Effect.scoped(
785 Effect.gen(function* () {
786 const { browser, broker, tabId } = yield* ready;
787 const page = contexts[0]!.page;
788 const viewer = yield* browser.attachViewer(viewerInput(tabId, true));
789 yield* viewer.input({ type: "takeControl" });
790 yield* Queue.clear(viewer.output);
791 // Stands in for Chromium, which writes the file itself.
792 const saved = yield* Queue.unbounded<string>();
793 const written = Promise.withResolvers<void>();
794 page.emit("download", {
795 failure: async () => null,
796 saveAs: (path: string) => {
797 Queue.offerUnsafe(saved, path);
798 return written.promise;
799 },
800 suggestedFilename: () => "report.csv",
801 url: () => "blob:http://localhost:5173/1",
802 });
803 const fs = yield* FileSystem.FileSystem;
804 const path = yield* Queue.take(saved);
805 yield* fs.writeFileString(path, "a,b");
806 written.resolve();
807 let offered = yield* Queue.take(viewer.output);
808 while (offered._tag !== "download") offered = yield* Queue.take(viewer.output);
809 expect(offered).toMatchObject({ _tag: "download", fileName: "report.csv", sizeBytes: 3 });
810 const file = yield* browser.openDownload({
811 threadId: scope.thread.threadId,
812 tabId,
813 downloadId: offered.id,
814 });
815 expect(Option.isSome(file) && file.value.fileName).toBe("report.csv");
816 const status = yield* broker.invoke<PreviewAutomationStatus>({
817 scope,
818 tabId,
819 operation: "status",
820 input: {},
821 });
822 expect(status.downloads).toEqual([
823 expect.objectContaining({ fileName: "report.csv", sizeBytes: 3 }),
824 ]);
825 expect(
826 Option.isNone(
827 yield* browser.openDownload({
828 threadId: scope.thread.threadId,
829 tabId,
830 downloadId: "guess",
831 }),
832 ),
833 ).toBe(true);
834 }),
835 ).pipe(Effect.provide(layer)),
836);
837
838it.live("a page's file picker goes to the controller and takes its uploaded files", () =>
839 Effect.scoped(
840 Effect.gen(function* () {
841 const { browser, tabId } = yield* ready;
842 const page = contexts[0]!.page;
843 const viewer = yield* browser.attachViewer(viewerInput(tabId, true));
844 yield* viewer.input({ type: "takeControl" });
845 const setFiles = vi.fn(async (_files: unknown) => {});
846 page.emit("filechooser", {
847 isMultiple: () => false,
848 element: () => ({ getAttribute: async () => ".csv" }),
849 setFiles,
850 });
851 let offered = yield* Queue.take(viewer.output);
852 while (offered._tag !== "fileChooser") offered = yield* Queue.take(viewer.output);
853 expect(offered).toMatchObject({ multiple: false, accept: ".csv" });
854 const file = (name: string) => ({ name, mimeType: "text/csv", buffer: Buffer.from(name) });
855 const answer = (chooserId: string) =>
856 browser.answerFileChooser({
857 threadId: scope.thread.threadId,
858 tabId,
859 chooserId,
860 files: [file("a.csv"), file("b.csv")],
861 });
862 expect(yield* answer("other")).toBe(false);
863 expect(yield* answer(offered.id)).toBe(true);
864 // A single-file input only receives the first file.
865 expect(setFiles).toHaveBeenCalledExactlyOnceWith([file("a.csv")]);
866 let closed = yield* Queue.take(viewer.output);
867 while (closed._tag !== "fileChooserClosed") closed = yield* Queue.take(viewer.output);
868 expect(closed.id).toBe(offered.id);
869 expect(yield* answer(offered.id)).toBe(false);
870 }),
871 ).pipe(Effect.provide(layer)),
872);
873
874it.live("a file picker replaces a stalled viewer backlog instead of being dropped", () =>
875 Effect.scoped(
876 Effect.gen(function* () {
877 const { browser, tabId } = yield* ready;
878 const page = contexts[0]!.page;
879 const viewer = yield* browser.attachViewer(viewerInput(tabId, true));
880 yield* viewer.input({ type: "takeControl" });
881 const accept = Promise.withResolvers<string>();
882 page.emit("filechooser", {
883 isMultiple: () => false,
884 element: () => ({ getAttribute: () => accept.promise }),
885 setFiles: async () => {},
886 });
887 // The viewer stopped reading: its output is full when the picker opens.
888 yield* Queue.clear(viewer.output);
889 stallViewer(viewer, { _tag: "viewport", width: 1, height: 1 });
890 accept.resolve(".csv");
891 // The picker is offered within the microtasks that follow its accept attribute.
892 yield* Effect.promise(() => new Promise<void>((resolve) => setImmediate(resolve)));
893 expect(yield* Queue.clear(viewer.output)).toEqual([
894 expect.objectContaining({ _tag: "fileChooser", accept: ".csv" }),
895 ]);
896 }),
897 ).pipe(Effect.provide(layer)),
898);
899
900it.live("control updates replace a stalled viewer backlog in the order they happen", () =>
901 Effect.scoped(
902 Effect.gen(function* () {
903 const { browser, tabId } = yield* ready;
904 const viewer = yield* browser.attachViewer(viewerInput(tabId, true));
905 yield* Queue.clear(viewer.output);
906 // Chromium is slow to take back the frames the replacement drops.
907 const acked = Promise.withResolvers<void>();
908 yield* Effect.addFinalizer(() => Effect.sync(() => acked.resolve()));
909 stallViewer(viewer, {
910 _tag: "frame",
911 data: Buffer.alloc(0),
912 ack: Effect.promise(() => acked.promise),
913 });
914 yield* viewer.input({ type: "takeControl" });
915 yield* viewer.input({ type: "releaseControl" });
916 const controls = (yield* Queue.clear(viewer.output)).flatMap((item) =>
917 item._tag === "control" ? [item.controller] : [],
918 );
919 expect(controls[0]).toBe("you");
920 expect(controls.at(-1)).not.toBe("you");
921 }),
922 ).pipe(Effect.provide(layer)),
923);
924
925it.live("an agent's tabs stop at the limit until an unwatched idle tab closes", () =>
926 Effect.scoped(
927 Effect.gen(function* () {
928 const { broker, tabId } = yield* ready;
929 const open = broker.invoke<PreviewAutomationStatus>({
930 scope,
931 operation: "open",
932 input: { reuseExistingTab: false, show: false },
933 });
934 for (let count = 1; count < 8; count += 1) yield* open;
935 expect(yield* Effect.flip(open)).toMatchObject({
936 _tag: "PreviewAutomationControlInterruptedError",
937 reason: "tabLimit",
938 });
939 // Another agent session keeps its own budget.
940 yield* broker.invoke({
941 scope: asSession("agent-b"),
942 operation: "open",
943 input: { reuseExistingTab: false, show: false },
944 });
945 const later = (yield* Clock.currentTimeMillis) + 31 * 60 * 1000;
946 const clock = vi.spyOn(Date, "now").mockReturnValue(later);
947 yield* Effect.addFinalizer(() => Effect.sync(() => clock.mockRestore()));
948 // The first tab is watched, so it stays open while the agent's other idle tabs close.
949 const browser = yield* ServerBrowser.ServerBrowser;
950 yield* browser.attachViewer(viewerInput(tabId, false));
951 const reopened = yield* open;
952 const manager = yield* Manager.PreviewManager;
953 const { sessions } = yield* manager.list({ threadId: scope.thread.threadId });
954 expect(sessions.map((session) => session.tabId).toSorted()).toEqual(
955 [tabId, reopened.tabId].toSorted(),
956 );
957 }),
958 ).pipe(Effect.provide(layer)),
959);
960
961it.live("an agent answers the page's file picker or sets files on a file input", () =>
962 Effect.scoped(
963 Effect.gen(function* () {
964 const { broker, tabId } = yield* ready;
965 const page = contexts[0]!.page;
966 const upload = (input: Record<string, unknown>) =>
967 broker.invoke<void>({ scope, tabId, operation: "upload", input });
968 expect(yield* Effect.flip(upload({ paths: ["/tmp/a.csv"] }))).toMatchObject({
969 _tag: "PreviewAutomationExecutionError",
970 });
971 const setFiles = vi.fn(async (_files: unknown) => {});
972 page.emit("filechooser", {
973 isMultiple: () => false,
974 element: () => ({ getAttribute: async () => ".csv" }),
975 setFiles,
976 });
977 const status = broker.invoke<PreviewAutomationStatus>({
978 scope,
979 tabId,
980 operation: "status",
981 input: {},
982 });
983 let opened = yield* status;
984 while (!opened.fileChooser) {
985 yield* Effect.sleep("5 millis");
986 opened = yield* status;
987 }
988 expect(opened.fileChooser).toEqual({ multiple: false, accept: ".csv" });
989 // A single-file picker rejects several files instead of dropping some.
990 yield* Effect.flip(upload({ paths: ["/tmp/a.csv", "/tmp/b.csv"] }));
991 yield* upload({ paths: ["/tmp/a.csv"] });
992 expect(setFiles).toHaveBeenCalledExactlyOnceWith(["/tmp/a.csv"], expect.anything());
993 expect((yield* status).fileChooser).toBeNull();
994
995 const setInputFiles = vi.fn(async () => {});
996 page.locator.mockReturnValue({ setInputFiles } as never);
997 yield* upload({ paths: ["/tmp/b.csv"], locator: "input[type=file]" });
998 expect(page.locator).toHaveBeenLastCalledWith("input[type=file]");
999 expect(setInputFiles).toHaveBeenCalledWith(["/tmp/b.csv"], expect.anything());
1000 }),
1001 ).pipe(Effect.provide(layer)),
1002);
1003
1004it.live("viewers see the agent's pointer move to its target and click there", () =>
1005 Effect.scoped(
1006 Effect.gen(function* () {
1007 const { browser, broker, tabId } = yield* ready;
1008 const page = contexts[0]!.page;
1009 const viewer = yield* browser.attachViewer(viewerInput(tabId, false));
1010 const click = vi.fn(async () => {});
1011 page.locator.mockReturnValue({
1012 scrollIntoViewIfNeeded: async () => {},
1013 boundingBox: async () => ({ x: 100, y: 40, width: 80, height: 20 }),
1014 click,
1015 } as never);
1016 yield* broker.invoke({ scope, tabId, operation: "click", input: { locator: "#go" } });
1017 const pointers = (yield* Queue.clear(viewer.output)).filter(
1018 (item) => item._tag === "pointer",
1019 );
1020 expect(pointers).toEqual([
1021 expect.objectContaining({ phase: "move", x: 140, y: 50 }),
1022 expect.objectContaining({ phase: "click", x: 140, y: 50 }),
1023 ]);
1024 expect(click).toHaveBeenCalledOnce();
1025 }),
1026 ).pipe(Effect.provide(layer)),
1027);
1028
1029it.live("drives the desktop's own page for a tab the desktop renders", () =>
1030 Effect.scoped(
1031 Effect.gen(function* () {
1032 const browser = yield* ServerBrowser.ServerBrowser;
1033 const broker = yield* Broker.PreviewAutomationBroker;
1034 const manager = yield* Manager.PreviewManager;
1035 yield* Effect.yieldNow;
1036 desktopRendersNext = true;
1037 const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" });
1038 const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, true));
1039 expect(desktopConnections.map((connection) => connection.endpoint)).toEqual([
1040 `ws://desktop/${opened.tabId}`,
1041 ]);
1042 // No headless context was launched for it.
1043 expect(contexts).toEqual([]);
1044 const page = desktopConnections[0]!.context.page;
1045 // The desktop panel sizes its page, so a viewer resize leaves it alone.
1046 yield* viewer.input({ type: "takeControl" });
1047 yield* viewer.input({ type: "resize", width: 390, height: 844 });
1048 expect(page.setViewportSize).not.toHaveBeenCalled();
1049 yield* viewer.input({ type: "releaseControl" });
1050 // Agents reach it through the same engine as a headless tab.
1051 const evaluated = yield* broker.invoke({
1052 scope: asSession("agent-desktop"),
1053 operation: "status",
1054 input: {},
1055 tabId: PreviewTabId.make(opened.tabId),
1056 });
1057 expect(evaluated).toMatchObject({ tabId: opened.tabId });
1058 // Closing the session lets go of the desktop's page without closing it.
1059 yield* manager.close({ threadId: scope.thread.threadId, tabId: opened.tabId });
1060 while (releasedDesktopTabs.length === 0) yield* Effect.yieldNow;
1061 expect(releasedDesktopTabs).toEqual([opened.tabId]);
1062 expect(page.close).not.toHaveBeenCalled();
1063 }),
1064 ).pipe(Effect.provide(layer)),
1065);
1066
1067it.live("a desktop page the desktop takes back reconnects instead of closing", () =>
1068 Effect.scoped(
1069 Effect.gen(function* () {
1070 const browser = yield* ServerBrowser.ServerBrowser;
1071 const manager = yield* Manager.PreviewManager;
1072 yield* Effect.yieldNow;
1073 desktopRendersNext = true;
1074 const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" });
1075 const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, false));
1076 // Devtools opened on the desktop, so it withdrew the page's debugger.
1077 // The server's listener subscribes in its own fiber; detach once it is there.
1078 while (desktopDetaches.listenerCount("detach") === 0) yield* Effect.yieldNow;
1079 desktopDetaches.emit("detach", { threadId: scope.thread.threadId, tabId: opened.tabId });
1080 let end = yield* Queue.take(viewer.output);
1081 while (end._tag !== "reconnect" && end._tag !== "gone")
1082 end = yield* Queue.take(viewer.output);
1083 expect(end._tag).toBe("reconnect");
1084 expect(releasedDesktopTabs).toEqual([opened.tabId]);
1085 // The session survives, and the next viewer reaches the page again.
1086 const { sessions } = yield* manager.list({ threadId: scope.thread.threadId });
1087 expect(sessions.map((session) => session.tabId)).toContain(opened.tabId);
1088 yield* browser.attachViewer(viewerInput(opened.tabId, false));
1089 expect(desktopConnections).toHaveLength(2);
1090 }),
1091 ).pipe(Effect.provide(layer)),
1092);
apps/server/src/preview/ServerBrowser.ts created+2202
...@@ -0,0 +1,2202 @@
1// @effect-diagnostics nodeBuiltinImport:off globalDate:off globalTimers:off - Playwright callbacks run outside the Effect runtime.
2// Screencasts ignore emulated device scale; real 2x keeps captures sharp.
3// --disable-gpu uses cheaper software compositing while preserving SwiftShader WebGL.
4import {
5 FILL_PREVIEW_VIEWPORT,
6 INCOGNITO_BROWSER_PROFILE_ID,
7 PREVIEW_AUTOMATION_SERVER_OPERATIONS,
8 PreviewViewportSetting as PreviewViewportSettingSchema,
9 PROVIDER_SEND_TURN_MAX_FILE_BYTES,
10 type PreviewAutomationActionEvent,
11 type PreviewAutomationClickInput,
12 type PreviewAutomationDialogInput,
13 type PreviewAutomationConsoleEntry,
14 type PreviewAutomationDragInput,
15 type PreviewAutomationEvaluateInput,
16 type PreviewAutomationHoverInput,
17 type PreviewAutomationNavigateInput,
18 type PreviewAutomationNetworkEntry,
19 type PreviewAutomationOpenInput,
20 type PreviewAutomationPressInput,
21 type PreviewAutomationRequest,
22 type PreviewAutomationResizeInput,
23 type PreviewAutomationScrollInput,
24 type PreviewAutomationSelectInput,
25 type PreviewAutomationSetColorSchemeInput,
26 type PreviewAutomationStatus,
27 type PreviewAutomationTypeInput,
28 type PreviewAutomationUploadInput,
29 type PreviewAutomationWaitForInput,
30 PreviewClearProfileError,
31 type PreviewEvent,
32 type PreviewNavStatus,
33 type PreviewSessionSnapshot,
34 type PreviewViewportSetting,
35 ThreadId,
36 SERVER_BROWSER_AUTOMATION_CLIENT_ID,
37 type PreviewAppearancePreference,
38} from "@t3tools/contracts";
39import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
40import { normalizePreviewUrl } from "@t3tools/shared/preview";
41import { resolvePreviewViewport } from "@t3tools/shared/previewViewport";
42import * as NodeCrypto from "node:crypto";
43import * as NodeFSP from "node:fs/promises";
44import * as NodePath from "node:path";
45import * as Context from "effect/Context";
46import * as Effect from "effect/Effect";
47import { constVoid } from "effect/Function";
48import * as Layer from "effect/Layer";
49import * as Option from "effect/Option";
50import * as Queue from "effect/Queue";
51import * as Schedule from "effect/Schedule";
52import * as Schema from "effect/Schema";
53import * as Exit from "effect/Exit";
54import * as Scope from "effect/Scope";
55import * as Stream from "effect/Stream";
56import type * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
57import type {
58 BrowserContext,
59 CDPSession,
60 Dialog,
61 Download,
62 FileChooser,
63 Page,
64} from "playwright-core";
65
66import { PENDING_ATTACHMENT_THREAD_SEGMENT } from "../attachmentStore.ts";
67import * as ServerConfig from "../config.ts";
68import { resolveRootCliCommand } from "../cli/invocation.ts";
69import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
70import * as PreviewAutomationBroker from "../mcp/PreviewAutomationBroker.ts";
71import * as DesktopBrowserChannel from "./DesktopBrowserChannel.ts";
72import * as PreviewManager from "./Manager.ts";
73import * as ServerBrowserPage from "./ServerBrowserPage.ts";
74import * as PreviewBrowser from "./PreviewBrowser.ts";
75import * as PreviewBrowserHost from "./PreviewBrowserHost.ts";
76import { ServerBrowserContexts } from "./ServerBrowserContexts.ts";
77import { BrowserControlInterrupted, SessionControl } from "./SessionControl.ts";
78
79const SERVER_HOST_CLIENT_ID = SERVER_BROWSER_AUTOMATION_CLIENT_ID;
80const RENDER_SCALE = 2;
81// Chromium allows three unacked frames, so 100 ms pacing caps viewers near 30 fps.
82const SCREENCAST_ACK_PACE_MS = 100;
83const SCREENCAST_SETTLE_MS = 200;
84// Only scrolling triggers reduced-quality motion frames; animations stay sharp.
85const SCREENCAST_MOTION_FRAMES = 4;
86const SCREENCAST_MOTION_WINDOW_MS = 300;
87const SCREENCAST_MOTION_QUALITY = 50;
88const HOST_RECONNECT_DELAY = "1 second";
89/** How long a new tab waits for the desktop app to mount it before running headless. */
90const DESKTOP_ATTACH_TIMEOUT = "10 seconds";
91const VIEWER_OUTPUT_LIMIT = 64;
92const RECORDING_SCREENCAST = { format: "jpeg", quality: 90, everyNthFrame: 1 } as const;
93const decodeViewportSetting = Schema.decodeUnknownSync(PreviewViewportSettingSchema);
94/** The agent cursor glides to its target, then pulses just before the press, like desktop tabs. */
95const AGENT_CURSOR_MOVE_MS = 160;
96const AGENT_CURSOR_CLICK_LEAD_MS = 40;
97
98const sleepUntil = (deadline: number) =>
99 new Promise<void>((resolve) => setTimeout(resolve, Math.max(0, deadline - Date.now())));
100
101// Touch viewers raise the keyboard for editable targets, including opaque frames.
102const EDITABLE_AT_POINT_SCRIPT = `(x, y) => {
103 let element = document.elementFromPoint(x, y);
104 while (element && element.shadowRoot) {
105 const inner = element.shadowRoot.elementFromPoint(x, y);
106 if (!inner || inner === element) break;
107 element = inner;
108 }
109 if (element && element.tagName === "LABEL" && element.control) element = element.control;
110 if (!element) return false;
111 if (element.tagName === "IFRAME" || element.tagName === "FRAME") return true;
112 if (element.isContentEditable) return true;
113 if (element.tagName === "TEXTAREA") return !element.disabled && !element.readOnly;
114 if (element.tagName !== "INPUT") return false;
115 const nonText = ["button", "checkbox", "color", "file", "hidden", "image", "radio", "range", "reset", "submit"];
116 return !nonText.includes(element.type) && !element.disabled && !element.readOnly;
117}`;
118const UNATTACHED_FILL_VIEWPORT = { width: 1280, height: 800 } as const;
119const NAVIGATION_TIMEOUT_MS = 15_000;
120const VIEWER_NAVIGATION_OPTIONS = { waitUntil: "commit", timeout: NAVIGATION_TIMEOUT_MS } as const;
121const ACTION_TIMELINE_LIMIT = 50;
122
123export class ServerBrowserTabNotFoundError extends Schema.TaggedError<ServerBrowserTabNotFoundError>()(
124 "ServerBrowserTabNotFoundError",
125 { threadId: Schema.String, tabId: Schema.String },
126) {
127 override get message(): string {
128 return "The server preview tab does not exist.";
129 }
130}
131
132const isTabNotFound = Schema.is(ServerBrowserTabNotFoundError);
133const isHostSetupError = Schema.is(
134 Schema.Union([
135 PreviewBrowserHost.PreviewBrowserSandboxError,
136 PreviewBrowserHost.PreviewBrowserLibrariesError,
137 ]),
138);
139
140export class ServerBrowserLaunchError extends Schema.TaggedError<ServerBrowserLaunchError>()(
141 "ServerBrowserLaunchError",
142 { cause: Schema.Defect() },
143) {
144 override get message(): string {
145 return "The server preview browser could not start.";
146 }
147}
148
149export type ServerBrowserViewerOutput =
150 | {
151 readonly _tag: "frame";
152 readonly data: Uint8Array;
153 readonly ack: Effect.Effect<void>;
154 }
155 | { readonly _tag: "viewport"; readonly width: number; readonly height: number }
156 | {
157 readonly _tag: "control";
158 readonly canOperate: boolean;
159 readonly controller: "agent" | "you" | "another-viewer" | "unclaimed";
160 readonly generation: number;
161 readonly dialog: {
162 readonly type: string;
163 readonly message: string;
164 readonly defaultValue: string;
165 } | null;
166 }
167 | {
168 readonly _tag: "probe";
169 readonly x: number;
170 readonly y: number;
171 readonly editable: boolean;
172 }
173 | { readonly _tag: "clipboard"; readonly text: string }
174 | {
175 readonly _tag: "pointer";
176 readonly phase: "move" | "click";
177 readonly x: number;
178 readonly y: number;
179 readonly sequence: number;
180 }
181 | {
182 readonly _tag: "fileChooser";
183 readonly id: string;
184 readonly multiple: boolean;
185 readonly accept: string;
186 }
187 | { readonly _tag: "fileChooserClosed"; readonly id: string }
188 | {
189 readonly _tag: "download";
190 readonly id: string;
191 readonly fileName: string;
192 readonly sizeBytes: number;
193 }
194 | { readonly _tag: "gone" }
195 /** The page is still open, but this connection to it ended; the viewer reconnects. */
196 | { readonly _tag: "reconnect" };
197
198export interface ServerBrowserViewer {
199 readonly output: Queue.Dequeue<ServerBrowserViewerOutput>;
200 readonly input: (message: unknown) => Effect.Effect<void>;
201}
202
203export class ServerBrowser extends Context.Service<
204 ServerBrowser,
205 {
206 readonly attachViewer: (input: {
207 readonly threadId: string;
208 readonly tabId: string;
209 readonly maxWidth: number;
210 readonly maxHeight: number;
211 readonly quality: number;
212 readonly canOperate: boolean;
213 }) => Effect.Effect<
214 ServerBrowserViewer,
215 ServerBrowserTabNotFoundError | ServerBrowserLaunchError,
216 Scope.Scope
217 >;
218 /**
219 * Gives a page's open file picker the files a viewer uploaded, by path, and
220 * closes it. No files cancels the pick. False when that picker is gone.
221 */
222 readonly answerFileChooser: (input: {
223 readonly threadId: string;
224 readonly tabId: string;
225 readonly chooserId: string;
226 readonly files: ReadonlyArray<{
227 readonly name: string;
228 readonly mimeType: string;
229 readonly buffer: Buffer;
230 }>;
231 }) => Effect.Effect<boolean>;
232 /** A finished download of a live tab, for the authenticated download route. */
233 readonly openDownload: (input: {
234 readonly threadId: string;
235 readonly tabId: string;
236 readonly downloadId: string;
237 }) => Effect.Effect<Option.Option<{ readonly path: string; readonly fileName: string }>>;
238 /** Deletes a human profile's server-side storage, closing its open tabs first. */
239 readonly clearProfile: (profileId: string) => Effect.Effect<void, PreviewClearProfileError>;
240 }
241>()("t3/preview/ServerBrowser") {}
242
243interface ViewerState {
244 readonly id: string;
245 readonly canOperate: boolean;
246 readonly pressedKeys: Map<string, { key: string; code: string }>;
247 readonly pressedButtons: Map<"left" | "middle" | "right", { x: number; y: number }>;
248 readonly push: (output: ServerBrowserViewerOutput) => void;
249 readonly pause: () => Promise<void>;
250 readonly resume: () => Promise<void>;
251 scrolledAt: number;
252 /** Last input from this viewer; page copies reach its clipboard only right after. */
253 inputAt: number;
254 /** Panel bounds, retained in fixed mode; passive viewers never request a size. */
255 requestedSize: { width: number; height: number; order: number } | null;
256}
257
258interface EncoderWindow {
259 __t3Recorder: {
260 cursor(x: number, y: number, click: boolean): void;
261 frame(data: string, width: number): Promise<boolean>;
262 stop(): Promise<{ mimeType: string | null; count: number; bytes: number }>;
263 chunk(index: number): Promise<string>;
264 };
265}
266
267interface Recording {
268 readonly encoder: Page;
269 readonly session: CDPSession;
270 readonly startedAt: string;
271 /** Frames still being handed to the encoder; stopping waits for them. */
272 readonly framesInFlight: Set<Promise<void>>;
273}
274
275interface ServerTab {
276 readonly threadId: ThreadId;
277 readonly tabId: string;
278 readonly page: Page;
279 readonly cdp: CDPSession;
280 readonly createdAt: number;
281 /** The agent's latest request on this tab; idle agent tabs close. */
282 usedAt: number;
283 readonly viewers: Set<ViewerState>;
284 readonly consoleEntries: Array<PreviewAutomationConsoleEntry>;
285 readonly networkEntries: Array<PreviewAutomationNetworkEntry>;
286 readonly actionTimeline: Array<PreviewAutomationActionEvent>;
287 readonly control: SessionControl;
288 /** The tab's own storage context, closed with it. Popups share their opener's. */
289 readonly isolatedContext: boolean;
290 /**
291 * Set when the desktop app renders this tab. The server drives the desktop's
292 * page; the desktop owns its size, storage, and window.
293 */
294 readonly desktop: { readonly close: () => Promise<void> } | null;
295 readonly profileId: string | undefined;
296 /** Set when a page in another tab opened this one with `window.open` or a link. */
297 readonly openerTabId: string | undefined;
298 /** Finished downloads, newest last; files live until the tab closes. */
299 readonly downloads: Array<ServerDownload>;
300 /** A page's open file picker, waiting for the controlling viewer's files. */
301 fileChooser: {
302 readonly id: string;
303 readonly chooser: FileChooser;
304 readonly accept: string;
305 } | null;
306 dialog: Dialog | null;
307 setting: PreviewViewportSetting;
308 colorScheme: PreviewAppearancePreference;
309 zoomFactor: number;
310 loading: boolean;
311 closing: boolean;
312 recording: Recording | null;
313 initialNavigation: Promise<void> | null;
314 /** The latest queued start, so a stop can find a recording still starting. */
315 recordingStart: Promise<Recording> | null;
316 /** Serializes captures and recording start/stop. */
317 captureLock: Promise<void>;
318 /** Scaled captures rendering now; screencasts stay stopped meanwhile. */
319 capturing: number;
320}
321
322interface ServerDownload {
323 readonly id: string;
324 readonly fileName: string;
325 readonly path: string;
326 readonly sizeBytes: number;
327 readonly url: string;
328 readonly completedAt: string;
329}
330
331/** One agent session and the whole server; each tab holds a renderer process. */
332const AGENT_TAB_LIMIT = 8;
333const SERVER_TAB_LIMIT = 32;
334/** Agent tabs nobody watches close after this long without an agent request. */
335const AGENT_TAB_IDLE_MS = 30 * 60 * 1000;
336const IDLE_SWEEP_INTERVAL = "1 minute";
337
338const DOWNLOAD_LIMIT = 20;
339const DOWNLOAD_MAX_BYTES = 1024 * 1024 * 1024;
340
341const tabKey = (threadId: string, tabId: string) => `${threadId}\u0000${tabId}`;
342
343const pushBounded = <A>(
344 buffer: Array<A>,
345 entry: A,
346 limit = ServerBrowserPage.DIAGNOSTIC_BUFFER_LIMIT,
347) => {
348 buffer.push(entry);
349 if (buffer.length > limit) buffer.splice(0, buffer.length - limit);
350};
351
352const viewportSettingsEqual = (left: PreviewViewportSetting, right: PreviewViewportSetting) =>
353 left._tag === right._tag &&
354 (left._tag === "fill" ||
355 (right._tag !== "fill" && left.width === right.width && left.height === right.height));
356
357const fixedViewportSize = (setting: PreviewViewportSetting) =>
358 setting._tag === "fill" ? null : { width: setting.width, height: setting.height };
359
360const asRecord = (value: unknown): Record<string, unknown> | null =>
361 typeof value === "object" && value !== null ? (value as Record<string, unknown>) : null;
362
363const num = (value: unknown, fallback = 0) =>
364 typeof value === "number" && Number.isFinite(value) ? value : fallback;
365
366const modifiersOf = (message: Record<string, unknown>) => {
367 const value = num(message.modifiers);
368 return Number.isInteger(value) && value >= 0 && value < 16 ? value : 0;
369};
370
371// Cmd shortcuts from Apple viewers are no editing shortcut for Linux or headless
372// Chromium, so they carry the command. Ctrl already works natively on Linux,
373// except copy and cut, which headless Chromium only runs as commands.
374const editingCommand = (key: string, modifiers: number) => {
375 const modifier = modifiers & 0b0111;
376 if (modifier !== 2 && modifier !== 4) return null;
377 const lower = key.toLowerCase();
378 const command =
379 lower === "c"
380 ? "copy"
381 : lower === "x"
382 ? "cut"
383 : modifier !== 4
384 ? null
385 : lower === "a"
386 ? "selectAll"
387 : lower === "z"
388 ? modifiers & 8
389 ? "redo"
390 : "undo"
391 : null;
392 return command ? { commands: [command] } : null;
393};
394
395const CLIPBOARD_TEXT_LIMIT = 1024 * 1024;
396/** Copies reach the viewer only this soon after it last touched the page. */
397const CLIPBOARD_GESTURE_MS = 5_000;
398const CLIPBOARD_BINDING = "__t3PreviewClipboard";
399// Headless Chromium shares one clipboard between every context, so pages
400// report their own copies instead of the clipboard being read back.
401const CLIPBOARD_SCRIPT = `(() => {
402 const send = (text) => {
403 if (typeof text !== "string" || text.length === 0) return;
404 try { globalThis.${CLIPBOARD_BINDING}?.(text.slice(0, ${CLIPBOARD_TEXT_LIMIT})); } catch {}
405 };
406 const selection = () => {
407 const field = document.activeElement;
408 if ((field instanceof HTMLInputElement || field instanceof HTMLTextAreaElement) && field.selectionStart !== null)
409 return field.value.slice(field.selectionStart, field.selectionEnd ?? field.selectionStart);
410 return String(document.getSelection() ?? "");
411 };
412 // Bubbling to window runs after page handlers, before cut removes the selection.
413 const copied = (event) =>
414 send(event.defaultPrevented ? event.clipboardData?.getData("text/plain") : selection());
415 addEventListener("copy", copied);
416 addEventListener("cut", copied);
417 const proto = globalThis.Clipboard?.prototype;
418 if (!proto) return;
419 const writeText = proto.writeText;
420 proto.writeText = function (text) {
421 const result = writeText.call(this, text);
422 result.then(() => send(String(text)), () => {});
423 return result;
424 };
425 const write = proto.write;
426 proto.write = function (items) {
427 const result = write.call(this, items);
428 result.then(async () => {
429 const item = [...items].find((candidate) => candidate.types.includes("text/plain"));
430 if (item) send(await (await item.getType("text/plain")).text());
431 }, () => {});
432 return result;
433 };
434})();`;
435
436const make = Effect.gen(function* () {
437 const config = yield* ServerConfig.ServerConfig;
438 const manager = yield* PreviewManager.PreviewManager;
439 const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
440 const environment = yield* ServerEnvironment.ServerEnvironment;
441 const previewBrowser = yield* PreviewBrowser.PreviewBrowser;
442 const desktopChannel = yield* DesktopBrowserChannel.DesktopBrowserChannel;
443 const runFork = Effect.runForkWith(yield* Effect.context<never>());
444 const launchServices = yield* Effect.context<ChildProcessSpawner.ChildProcessSpawner>();
445 // The fix every host error names, rendered for how this server was launched.
446 const setupCommand = yield* resolveRootCliCommand(PreviewBrowserHost.SETUP_SUBCOMMAND);
447
448 const tabs = new Map<string, ServerTab>();
449 const pendingTabs = new Map<string, Promise<ServerTab>>();
450 /** Sessions closed while their tab was still opening; the open discards its page. */
451 const closedPendingTabs = new Set<string>();
452 /** Popup pages waiting for the tab their `opened` event creates. */
453 const adoptedPages = new Map<string, { readonly page: Page; readonly openerTabId: string }>();
454 /** Sessions the manager closed, so their tabs end for good. Pruned once dropped. */
455 const closedSessions = new Set<string>();
456 let hostConnectionId: string | null = null;
457 let viewerResizeOrder = 0;
458
459 const contexts = new ServerBrowserContexts({
460 profilesDir: NodePath.join(config.stateDir, "server-browser", "profiles"),
461 executable: () => Effect.runPromise(previewBrowser.executable),
462 // Playwright's launch error drops Chrome's own output; its sandbox note survives.
463 diagnose: (executable, cause) =>
464 Effect.runPromiseWith(launchServices)(
465 PreviewBrowserHost.diagnoseLaunchFailure({
466 executable,
467 setupCommand,
468 output: /sandboxing failed/i.test(String(cause))
469 ? PreviewBrowserHost.NO_SANDBOX_SIGNATURE
470 : "",
471 }),
472 ),
473 onContextClose: (context) => {
474 for (const tab of tabs.values()) {
475 if (tab.page.context() === context) dropTab(tab, true);
476 }
477 },
478 });
479
480 const dialogStatus = (tab: ServerTab) =>
481 tab.dialog
482 ? {
483 type: tab.dialog.type(),
484 message: tab.dialog.message().slice(0, 4000),
485 defaultValue: tab.dialog.defaultValue().slice(0, 4000),
486 }
487 : null;
488
489 const broadcastControl = (tab: ServerTab) => {
490 for (const viewer of tab.viewers)
491 viewer.push({
492 _tag: "control",
493 canOperate: viewer.canOperate,
494 controller:
495 tab.control.controller === viewer.id
496 ? "you"
497 : tab.control.controller !== null
498 ? "another-viewer"
499 : tab.control.agentId !== null
500 ? "agent"
501 : "unclaimed",
502 generation: tab.control.generation,
503 dialog: dialogStatus(tab),
504 });
505 };
506
507 const resolveDialog = async (tab: ServerTab, input: PreviewAutomationDialogInput) => {
508 const dialog = tab.dialog;
509 if (!dialog) throw new Error("No dialog is pending.");
510 if (input.accept) await dialog.accept(input.promptText);
511 else await dialog.dismiss();
512 if (tab.dialog === dialog) tab.dialog = null;
513 ServerBrowserPage.invalidateRefs(tab.page);
514 broadcastControl(tab);
515 };
516
517 const report = (tab: ServerTab, navStatus: PreviewNavStatus) => {
518 void tab.cdp
519 .send("Page.getNavigationHistory")
520 .catch(() => null)
521 .then((history) => {
522 const index = history?.currentIndex ?? 0;
523 const count = history?.entries.length ?? 0;
524 runFork(
525 manager
526 .reportStatus({
527 threadId: tab.threadId,
528 tabId: tab.tabId,
529 serverControlled: true,
530 navStatus,
531 canGoBack: index > 0,
532 canGoForward: index < count - 1,
533 })
534 .pipe(Effect.ignore),
535 );
536 });
537 };
538
539 const reportLoaded = async (tab: ServerTab) => {
540 const url = tab.page.url();
541 // Chromium's error page loads after `requestfailed` and must not clear LoadFailed.
542 if (url === "about:blank" || url.startsWith("chrome-error://")) return;
543 const title = (await tab.page.title().catch(() => "")).slice(0, 512);
544 // A navigation that started while reading the title owns the status now.
545 if (tab.loading || tab.page.url() !== url) return;
546 report(tab, { _tag: "Success", url: url.slice(0, 2048), title });
547 };
548
549 const reportLiveTabs = () => {
550 const connectionId = hostConnectionId;
551 if (connectionId === null) return;
552 runFork(
553 environment.getEnvironmentId.pipe(
554 Effect.flatMap((environmentId) =>
555 broker.focusHost({
556 clientId: SERVER_HOST_CLIENT_ID,
557 environmentId,
558 connectionId,
559 focused: true,
560 liveTabs: [...tabs.values()].map((tab) => ({
561 threadId: tab.threadId,
562 tabId: tab.tabId,
563 visible: tab.viewers.size > 0,
564 })),
565 }),
566 ),
567 ),
568 );
569 };
570
571 /** The CSS size the page lays out in: the viewport shrunk by the tab's zoom, as Chrome zooms. */
572 const layoutSize = (tab: ServerTab) => {
573 const size = tab.page.viewportSize();
574 if (!size || tab.desktop) return size;
575 return {
576 width: Math.max(1, Math.round(size.width / tab.zoomFactor)),
577 height: Math.max(1, Math.round(size.height / tab.zoomFactor)),
578 };
579 };
580
581 /**
582 * Zooms a headless page as Chrome's zoom does: it lays out in fewer CSS
583 * pixels and draws each one larger, so frames keep their size. The desktop
584 * zooms the pages it renders itself.
585 */
586 const applyZoom = async (tab: ServerTab) => {
587 if (tab.desktop) return;
588 const size = tab.page.viewportSize();
589 if (!size) return;
590 if (tab.zoomFactor === 1) {
591 // Playwright's own override carries the plain viewport.
592 await tab.page.setViewportSize(size);
593 return;
594 }
595 const layout = layoutSize(tab)!;
596 await tab.cdp.send("Emulation.setDeviceMetricsOverride", {
597 width: layout.width,
598 height: layout.height,
599 deviceScaleFactor: RENDER_SCALE * tab.zoomFactor,
600 mobile: false,
601 });
602 };
603
604 const broadcastViewport = (tab: ServerTab) => {
605 const size = layoutSize(tab);
606 if (!size) return;
607 for (const viewer of tab.viewers) viewer.push({ _tag: "viewport", ...size });
608 };
609
610 const applySetting = async (tab: ServerTab, setting: PreviewViewportSetting) => {
611 tab.setting = setting;
612 // The desktop lays its webview out at the published setting itself.
613 if (tab.desktop) return;
614 const size =
615 fixedViewportSize(setting) ??
616 [...tab.viewers]
617 .map((viewer) => viewer.requestedSize)
618 .filter((requested) => requested !== null)
619 .sort((left, right) => right.order - left.order)[0] ??
620 UNATTACHED_FILL_VIEWPORT;
621 await tab.page.setViewportSize({ width: size.width, height: size.height });
622 await applyZoom(tab);
623 broadcastViewport(tab);
624 };
625
626 /** Applies a tab's published appearance and, for headless tabs, zoom. */
627 const applyRendering = async (tab: ServerTab, snapshot: PreviewSessionSnapshot) => {
628 const colorScheme = snapshot.colorScheme ?? "system";
629 if (colorScheme !== tab.colorScheme) {
630 tab.colorScheme = colorScheme;
631 await tab.page.emulateMedia({ colorScheme: colorScheme === "system" ? null : colorScheme });
632 }
633 const zoomFactor = snapshot.zoomFactor ?? 1;
634 if (zoomFactor !== tab.zoomFactor && !tab.desktop) {
635 tab.zoomFactor = zoomFactor;
636 await applyZoom(tab);
637 broadcastViewport(tab);
638 }
639 };
640
641 /** Whether the preview session for a tab still exists. */
642 const sessionOpen = (tab: ServerTab) => !closedSessions.has(tabKey(tab.threadId, tab.tabId));
643
644 const dropTab = (tab: ServerTab, closeSession: boolean) => {
645 const key = tabKey(tab.threadId, tab.tabId);
646 if (tabs.get(key) !== tab) return;
647 tabs.delete(key);
648 tab.closing = true;
649 // A desktop page outlives the connection unless its session closed with it.
650 const end = tab.desktop && !closeSession && sessionOpen(tab) ? "reconnect" : "gone";
651 for (const viewer of tab.viewers) viewer.push({ _tag: end });
652 void tab.control.close().catch(constVoid);
653 // The desktop owns its page; letting go only ends this connection.
654 if (tab.desktop) void tab.desktop.close().catch(constVoid);
655 else void tab.page.close().catch(constVoid);
656 if (tab.isolatedContext) void tab.page.context().close().catch(constVoid);
657 void tab.recording?.encoder.close().catch(constVoid);
658 void NodeFSP.rm(downloadDir(tab), { recursive: true, force: true }).catch(constVoid);
659 reportLiveTabs();
660 if (closeSession) {
661 runFork(manager.close({ threadId: tab.threadId, tabId: tab.tabId }).pipe(Effect.ignore));
662 }
663 };
664
665 /**
666 * With a desktop app attached, every tab of this server renders there, so a
667 * new tab waits for its `<webview>` instead of launching headless.
668 */
669 const desktopRenders = (snapshot: PreviewSessionSnapshot) =>
670 desktopChannel.available
671 ? Effect.runPromise(
672 desktopChannel.awaitAttached(
673 { threadId: snapshot.threadId, tabId: snapshot.tabId },
674 DESKTOP_ATTACH_TIMEOUT,
675 ),
676 )
677 : Promise.resolve(false);
678
679 /** Connects to the desktop's page for a tab, which it renders and the server drives. */
680 const connectDesktop = async (snapshot: PreviewSessionSnapshot) => {
681 const scope = await Effect.runPromise(Scope.make());
682 try {
683 const endpoint = await Effect.runPromise(
684 desktopChannel
685 .endpoint({ threadId: snapshot.threadId, tabId: snapshot.tabId })
686 .pipe(Scope.provide(scope)),
687 );
688 const connected = await contexts.connectDesktopPage(endpoint);
689 return {
690 page: connected.page,
691 close: async () => {
692 await connected.browser.close().catch(constVoid);
693 await Effect.runPromise(Scope.close(scope, Exit.void));
694 },
695 };
696 } catch (cause) {
697 await Effect.runPromise(Scope.close(scope, Exit.void));
698 throw cause;
699 }
700 };
701
702 const createTab = async (snapshot: PreviewSessionSnapshot): Promise<ServerTab> => {
703 const adopted = adoptedPages.get(tabKey(snapshot.threadId, snapshot.tabId));
704 adoptedPages.delete(tabKey(snapshot.threadId, snapshot.tabId));
705 const desktop =
706 adopted === undefined && (await desktopRenders(snapshot))
707 ? await connectDesktop(snapshot)
708 : null;
709 const isolatedContext =
710 adopted === undefined &&
711 desktop === null &&
712 (snapshot.automationOwner !== undefined ||
713 snapshot.profileId === INCOGNITO_BROWSER_PROFILE_ID);
714 const context =
715 adopted?.page.context() ??
716 desktop?.page.context() ??
717 (await contexts.contextFor(
718 snapshot.profileId ?? "default",
719 isolatedContext ? tabKey(snapshot.threadId, snapshot.tabId) : undefined,
720 ));
721 if (adopted?.page.isClosed()) throw new Error("The popup closed before it opened.");
722 // The desktop page already has its own clipboard; the bridge script is for headless tabs.
723 if (!desktop) await prepareContext(context);
724 const page = adopted?.page ?? desktop?.page ?? (await context.newPage());
725 const cdp = await context.newCDPSession(page);
726 page.setDefaultTimeout(NAVIGATION_TIMEOUT_MS);
727 page.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS);
728 const control = new SessionControl(snapshot.automationOwner ?? null, () =>
729 ServerBrowserPage.invalidateRefs(page),
730 );
731 const tab: ServerTab = {
732 threadId: ThreadId.make(snapshot.threadId),
733 tabId: snapshot.tabId,
734 page,
735 cdp,
736 createdAt: Date.now(),
737 usedAt: Date.now(),
738 viewers: new Set(),
739 consoleEntries: [],
740 networkEntries: [],
741 actionTimeline: [],
742 control,
743 isolatedContext,
744 desktop: desktop === null ? null : { close: desktop.close },
745 profileId: snapshot.profileId,
746 openerTabId: adopted?.openerTabId,
747 downloads: [],
748 fileChooser: null,
749 dialog: null,
750 setting: snapshot.viewport ?? FILL_PREVIEW_VIEWPORT,
751 colorScheme: "system",
752 zoomFactor: 1,
753 loading: false,
754 closing: false,
755 recording: null,
756 recordingStart: null,
757 initialNavigation: null,
758 captureLock: Promise.resolve(),
759 capturing: 0,
760 };
761 if (!desktop) {
762 await page.setViewportSize(fixedViewportSize(tab.setting) ?? UNATTACHED_FILL_VIEWPORT);
763 }
764 await applyRendering(tab, snapshot);
765 const isMainNavigation = (request: { isNavigationRequest(): boolean; frame(): unknown }) =>
766 request.isNavigationRequest() && request.frame() === page.mainFrame();
767 page.on("request", (request) => {
768 if (!isMainNavigation(request)) return;
769 tab.loading = true;
770 report(tab, { _tag: "Loading", url: request.url().slice(0, 2048), title: "" });
771 });
772 page.on("load", () => {
773 tab.loading = false;
774 void reportLoaded(tab);
775 });
776 page.on("framenavigated", (frame) => {
777 // Same-document navigations (SPA routes) fire no load event.
778 if (frame === page.mainFrame() && !tab.loading) void reportLoaded(tab);
779 });
780 page.on("requestfailed", (request) => {
781 const errorText = request.failure()?.errorText ?? "";
782 pushBounded(tab.networkEntries, {
783 url: request.url(),
784 method: request.method(),
785 status: null,
786 failed: true,
787 errorText,
788 timestamp: new Date().toISOString(),
789 });
790 if (!isMainNavigation(request) || errorText.includes("ERR_ABORTED")) return;
791 tab.loading = false;
792 const { code, description } = ServerBrowserPage.parseNetError(errorText);
793 report(tab, {
794 _tag: "LoadFailed",
795 url: request.url().slice(0, 2048),
796 title: "",
797 code,
798 description,
799 });
800 });
801 page.on("response", (response) => {
802 pushBounded(tab.networkEntries, {
803 url: response.url(),
804 method: response.request().method(),
805 status: response.status(),
806 failed: false,
807 timestamp: new Date().toISOString(),
808 });
809 });
810 page.on("console", (message) => {
811 pushBounded(tab.consoleEntries, {
812 level: message.type(),
813 text: message.text().slice(0, 2_000),
814 timestamp: new Date().toISOString(),
815 });
816 });
817 page.on("dialog", (dialog) => {
818 tab.dialog = dialog;
819 broadcastControl(tab);
820 });
821 page.on("download", (download) => void saveDownload(tab, download));
822 page.on("filechooser", (chooser) => void offerFileChooser(tab, chooser));
823 // Popups become tabs and keep `window.opener`, so sign-in popups can report back.
824 page.on("popup", (popup) => void adoptPopup(tab, popup));
825 // Playwright cannot reload a crashed page, so it must leave the tab list.
826 const key = tabKey(tab.threadId, tab.tabId);
827 if (closedPendingTabs.delete(key)) {
828 await control.close().catch(constVoid);
829 if (desktop) await desktop.close().catch(constVoid);
830 else await page.close().catch(constVoid);
831 if (isolatedContext) await context.close().catch(constVoid);
832 throw new ServerBrowserTabNotFoundError({ threadId: tab.threadId, tabId: tab.tabId });
833 }
834 page.on("close", () => dropTab(tab, true));
835 page.on("crash", () => dropTab(tab, true));
836 tabs.set(key, tab);
837 reportLiveTabs();
838 // A popup is already loading its own URL, and the desktop loads its tab's.
839 if (!adopted && !desktop && snapshot.navStatus._tag === "Loading") {
840 tab.initialNavigation = page
841 .goto(snapshot.navStatus.url, { waitUntil: "commit", timeout: NAVIGATION_TIMEOUT_MS })
842 .then(constVoid);
843 // Background creation keeps the failed tab; automation awaits the original error.
844 void tab.initialNavigation.catch(constVoid);
845 }
846 return tab;
847 };
848
849 const downloadsRoot = NodePath.join(config.stateDir, "server-browser", "downloads");
850 // Tab ids are opaque server strings; hashing keeps them out of the path.
851 const downloadDir = (tab: ServerTab) =>
852 NodePath.join(
853 downloadsRoot,
854 NodeCrypto.createHash("sha256").update(tabKey(tab.threadId, tab.tabId)).digest("hex"),
855 );
856
857 const saveDownload = async (tab: ServerTab, download: Download) => {
858 const id = NodeCrypto.randomUUID();
859 const path = NodePath.join(downloadDir(tab), id);
860 try {
861 if (await download.failure()) return;
862 await NodeFSP.mkdir(downloadDir(tab), { recursive: true });
863 await download.saveAs(path);
864 const { size } = await NodeFSP.stat(path);
865 if (tab.closing || size > DOWNLOAD_MAX_BYTES) {
866 await NodeFSP.rm(path, { force: true });
867 return;
868 }
869 const saved: ServerDownload = {
870 id,
871 fileName: download.suggestedFilename().slice(0, 255) || "download",
872 path,
873 sizeBytes: size,
874 url: download.url().slice(0, 2048),
875 completedAt: new Date().toISOString(),
876 };
877 tab.downloads.push(saved);
878 for (const evicted of tab.downloads.splice(0, tab.downloads.length - DOWNLOAD_LIMIT)) {
879 await NodeFSP.rm(evicted.path, { force: true }).catch(constVoid);
880 }
881 // Only the person driving the page gets the file offered; agents read it from status.
882 const controller = [...tab.viewers].find((viewer) => viewer.id === tab.control.controller);
883 controller?.push({
884 _tag: "download",
885 id,
886 fileName: saved.fileName,
887 sizeBytes: saved.sizeBytes,
888 });
889 } catch (cause) {
890 await NodeFSP.rm(path, { force: true }).catch(constVoid);
891 runFork(Effect.logWarning("server preview download failed", { cause }));
892 }
893 };
894
895 const fileChooserMessage = (tab: ServerTab): ServerBrowserViewerOutput | null =>
896 tab.fileChooser
897 ? {
898 _tag: "fileChooser",
899 id: tab.fileChooser.id,
900 multiple: tab.fileChooser.chooser.isMultiple(),
901 accept: tab.fileChooser.accept,
902 }
903 : null;
904
905 const offerFileChooser = async (tab: ServerTab, chooser: FileChooser) => {
906 const previous = tab.fileChooser;
907 const accept =
908 (await chooser
909 .element()
910 .getAttribute("accept")
911 .catch(() => null)) ?? "";
912 // A newer picker replaces an unanswered one, as a real browser allows only one.
913 if (previous) closeFileChooser(tab);
914 tab.fileChooser = { id: NodeCrypto.randomUUID(), chooser, accept: accept.slice(0, 1024) };
915 pushFileChooser(tab);
916 };
917
918 const pushFileChooser = (tab: ServerTab) => {
919 const message = fileChooserMessage(tab);
920 const controller = [...tab.viewers].find((viewer) => viewer.id === tab.control.controller);
921 if (message) controller?.push(message);
922 };
923
924 const closeFileChooser = (tab: ServerTab) => {
925 const open = tab.fileChooser;
926 if (!open) return;
927 tab.fileChooser = null;
928 for (const viewer of tab.viewers) viewer.push({ _tag: "fileChooserClosed", id: open.id });
929 };
930
931 /** Hands uploaded files to the page's open picker; an empty list cancels it. */
932 const setChooserFiles = async (
933 input: Parameters<ServerBrowser["Service"]["answerFileChooser"]>[0],
934 ) => {
935 const tab = tabs.get(tabKey(input.threadId, input.tabId));
936 const open = tab?.fileChooser;
937 if (!tab || !open || open.id !== input.chooserId) return false;
938 if (input.files.length > 0) {
939 await open.chooser.setFiles(
940 open.chooser.isMultiple() ? [...input.files] : input.files.slice(0, 1),
941 );
942 }
943 if (tab.fileChooser === open) closeFileChooser(tab);
944 return true;
945 };
946 /** The agent's files go to a named file input, or else to the page's open picker. */
947 const uploadFiles = async (tab: ServerTab, input: PreviewAutomationUploadInput) => {
948 const relative = input.paths.find((path) => !NodePath.isAbsolute(path));
949 if (relative !== undefined)
950 throw new ServerBrowserPage.ServerBrowserOperationError(
951 "PreviewAutomationExecutionError",
952 `Upload paths must be absolute: ${relative}`,
953 );
954 if (await ServerBrowserPage.setInputFiles(tab.page, input)) return undefined;
955 const open = tab.fileChooser;
956 if (!open)
957 throw new ServerBrowserPage.ServerBrowserOperationError(
958 "PreviewAutomationExecutionError",
959 "No file picker is open. Click the page's upload control first, or pass the file input's locator.",
960 );
961 if (input.paths.length > 1 && !open.chooser.isMultiple())
962 throw new ServerBrowserPage.ServerBrowserOperationError(
963 "PreviewAutomationExecutionError",
964 "This file picker accepts one file.",
965 );
966 if (input.paths.length > 0)
967 await open.chooser.setFiles([...input.paths], {
968 timeout: input.timeoutMs ?? NAVIGATION_TIMEOUT_MS,
969 });
970 if (tab.fileChooser === open) closeFileChooser(tab);
971 return undefined;
972 };
973
974 const answerFileChooser: ServerBrowser["Service"]["answerFileChooser"] = (input) =>
975 Effect.promise(() => setChooserFiles(input).catch(() => false));
976
977 const openDownload: ServerBrowser["Service"]["openDownload"] = (input) =>
978 Effect.sync(() => {
979 const download = tabs
980 .get(tabKey(input.threadId, input.tabId))
981 ?.downloads.find((candidate) => candidate.id === input.downloadId);
982 return download === undefined
983 ? Option.none()
984 : Option.some({ path: download.path, fileName: download.fileName });
985 });
986
987 const preparedContexts = new WeakSet<BrowserContext>();
988 const prepareContext = async (context: BrowserContext) => {
989 if (preparedContexts.has(context)) return;
990 preparedContexts.add(context);
991 await context.grantPermissions(["clipboard-write"]);
992 await context.exposeBinding(CLIPBOARD_BINDING, ({ page }, text: unknown) => {
993 if (typeof text !== "string") return;
994 const tab = [...tabs.values()].find((candidate) => candidate.page === page);
995 const controller = tab
996 ? [...tab.viewers].find((viewer) => viewer.id === tab.control.controller)
997 : undefined;
998 if (!controller || Date.now() - controller.inputAt > CLIPBOARD_GESTURE_MS) return;
999 controller.push({ _tag: "clipboard", text: text.slice(0, CLIPBOARD_TEXT_LIMIT) });
1000 });
1001 await context.addInitScript(CLIPBOARD_SCRIPT);
1002 };
1003
1004 const adoptPopup = async (opener: ServerTab, popup: Page) => {
1005 const agentId = opener.control.agentId;
1006 // A popup past an agent's limit closes; its page sees window.open return a closed window.
1007 if (opener.closing || (agentId !== null && atTabLimit(agentId))) {
1008 await popup.close().catch(constVoid);
1009 return;
1010 }
1011 const url = popup.url();
1012 await Effect.runPromise(
1013 manager.open({
1014 threadId: opener.threadId,
1015 ...(/^https?:/i.test(url) ? { url } : {}),
1016 runtime: "server",
1017 ...(opener.profileId === undefined ? {} : { profileId: opener.profileId }),
1018 // Agent popups stay with the agent and only float when it asks, like its own opens.
1019 ...(opener.control.agentId === null
1020 ? {}
1021 : { automationOwner: opener.control.agentId, reveal: false }),
1022 beforePublish: (snapshot) =>
1023 adoptedPages.set(tabKey(snapshot.threadId, snapshot.tabId), {
1024 page: popup,
1025 openerTabId: opener.tabId,
1026 }),
1027 }),
1028 ).catch(async () => {
1029 await popup.close().catch(constVoid);
1030 });
1031 };
1032
1033 const ensureTab = (snapshot: PreviewSessionSnapshot): Promise<ServerTab> => {
1034 const key = tabKey(snapshot.threadId, snapshot.tabId);
1035 const pending = pendingTabs.get(key);
1036 if (pending) return pending;
1037 const existing = tabs.get(key);
1038 if (existing) return Promise.resolve(existing);
1039 const opening = createTab(snapshot)
1040 .catch((cause: unknown) => {
1041 runFork(
1042 Effect.logWarning(
1043 isHostSetupError(cause) ? cause.message : "server preview tab failed to start",
1044 { cause },
1045 ),
1046 );
1047 throw cause;
1048 })
1049 .finally(() => {
1050 pendingTabs.delete(key);
1051 closedPendingTabs.delete(key);
1052 });
1053 pendingTabs.set(key, opening);
1054 return opening;
1055 };
1056
1057 const findTab = (threadId: string, tabId: string) =>
1058 Effect.gen(function* () {
1059 const existing = tabs.get(tabKey(threadId, tabId));
1060 if (existing) return existing;
1061 const { sessions } = yield* manager.list({ threadId: ThreadId.make(threadId) });
1062 const snapshot = sessions.find(
1063 (session) => session.tabId === tabId && session.runtime === "server",
1064 );
1065 if (!snapshot) return yield* new ServerBrowserTabNotFoundError({ threadId, tabId });
1066 return yield* Effect.tryPromise({
1067 try: () => ensureTab(snapshot),
1068 catch: (cause) => (isTabNotFound(cause) ? cause : new ServerBrowserLaunchError({ cause })),
1069 });
1070 });
1071
1072 const atTabLimit = (agentSessionId: string) =>
1073 tabs.size + pendingTabs.size >= SERVER_TAB_LIMIT ||
1074 [...tabs.values()].filter((tab) => tab.control.agentId === agentSessionId).length >=
1075 AGENT_TAB_LIMIT;
1076
1077 const assertTabCapacity = (agentSessionId: string) => {
1078 if (atTabLimit(agentSessionId))
1079 throw new BrowserControlInterrupted(
1080 `Too many server browser tabs are open (${AGENT_TAB_LIMIT} per agent session, ${SERVER_TAB_LIMIT} per server). Close one with t3_preview_close or reuse one from preview_status tabs.`,
1081 "tabLimit",
1082 );
1083 };
1084
1085 /** Closes agent tabs that no one watches and the agent stopped using. */
1086 const closeIdleAgentTabs = () => {
1087 const cutoff = Date.now() - AGENT_TAB_IDLE_MS;
1088 for (const tab of tabs.values()) {
1089 if (tab.control.agentId !== null && tab.viewers.size === 0 && tab.usedAt < cutoff)
1090 dropTab(tab, true);
1091 }
1092 };
1093
1094 const latestThreadTab = (threadId: string, agentSessionId?: string) =>
1095 [...tabs.values()]
1096 .filter((tab) => tab.threadId === threadId && tab.control.agentId === agentSessionId)
1097 .sort((left, right) => right.createdAt - left.createdAt)[0];
1098
1099 const statusWithTitle = async (
1100 tab: ServerTab | undefined,
1101 agentSessionId?: string,
1102 ): Promise<PreviewAutomationStatus> => {
1103 if (!tab) {
1104 return {
1105 available: false,
1106 visible: false,
1107 tabId: null,
1108 url: null,
1109 title: null,
1110 loading: false,
1111 };
1112 }
1113 const url = tab.page.url();
1114 const viewport = tab.page.viewportSize();
1115 const status = {
1116 available: true,
1117 visible: tab.viewers.size > 0,
1118 tabId: tab.tabId,
1119 url: url === "about:blank" ? null : url,
1120 title: null,
1121 loading: tab.loading,
1122 control: {
1123 owner:
1124 tab.control.controller !== null
1125 ? ("human" as const)
1126 : tab.control.agentId !== null
1127 ? ("agent" as const)
1128 : ("unclaimed" as const),
1129 ownedByCaller: tab.control.agentId === agentSessionId,
1130 generation: tab.control.generation,
1131 },
1132 dialog: dialogStatus(tab),
1133 fileChooser: tab.fileChooser
1134 ? { multiple: tab.fileChooser.chooser.isMultiple(), accept: tab.fileChooser.accept }
1135 : null,
1136 viewportSetting: tab.setting,
1137 ...(viewport ? { viewport } : {}),
1138 tabs: [...tabs.values()]
1139 .filter(
1140 (candidate) =>
1141 candidate.threadId === tab.threadId && candidate.control.agentId === agentSessionId,
1142 )
1143 .map((candidate) => ({
1144 tabId: candidate.tabId,
1145 url: candidate.page.url() === "about:blank" ? null : candidate.page.url(),
1146 ...(candidate.openerTabId === undefined ? {} : { openerTabId: candidate.openerTabId }),
1147 })),
1148 downloads: tab.downloads.map(({ fileName, path, sizeBytes, url, completedAt }) => ({
1149 fileName,
1150 path,
1151 sizeBytes,
1152 url,
1153 completedAt,
1154 })),
1155 };
1156 if (status.url === null || tab.dialog) return status;
1157 return { ...status, title: (await tab.page.title().catch(() => "")) || null };
1158 };
1159
1160 const resolveNavigationUrl = (input: PreviewAutomationNavigateInput) => {
1161 if (input.url !== undefined) return normalizePreviewUrl(input.url);
1162 const target = input.target!;
1163 if (target.kind === "url") return normalizePreviewUrl(target.url);
1164 // The browser runs inside the environment, so its ports are loopback.
1165 const path = target.path ?? "";
1166 return `${target.protocol ?? "http"}://localhost:${target.port}${path.startsWith("/") || path === "" ? path : `/${path}`}`;
1167 };
1168
1169 const navigate = async (
1170 tab: ServerTab,
1171 url: string,
1172 readiness: "load" | "domContentLoaded" | "none",
1173 timeout: number,
1174 ) => {
1175 const navigation = tab.page.goto(url, {
1176 timeout,
1177 waitUntil:
1178 readiness === "domContentLoaded"
1179 ? "domcontentloaded"
1180 : readiness === "none"
1181 ? "commit"
1182 : "load",
1183 });
1184 if (readiness === "none") {
1185 tab.control.track(navigation);
1186 return;
1187 }
1188 await navigation.catch((cause: unknown) => {
1189 const message = cause instanceof Error ? cause.message : String(cause);
1190 if (/ERR_[A-Z_]+/.test(message)) {
1191 throw new ServerBrowserPage.ServerBrowserOperationError(
1192 "PreviewAutomationExecutionError",
1193 `Navigation to ${url} failed: ${ServerBrowserPage.parseNetError(message).description}`,
1194 );
1195 }
1196 throw cause;
1197 });
1198 };
1199
1200 const withCaptureLock = <A>(tab: ServerTab, operation: () => Promise<A>): Promise<A> => {
1201 const run = tab.captureLock.then(() => {
1202 if (tab.closing) throw new Error("The preview tab closed.");
1203 return operation();
1204 });
1205 tab.captureLock = run.then(constVoid, constVoid);
1206 return run;
1207 };
1208
1209 const startRecording = (tab: ServerTab): Promise<Recording> => {
1210 const started = withCaptureLock(tab, async () => {
1211 if (tab.recording) return tab.recording;
1212 const encoder = await contexts.scratchPage();
1213 let session: CDPSession | null = null;
1214 try {
1215 await encoder.evaluate(ServerBrowserPage.RECORDING_ENCODER_SCRIPT);
1216 // Seed idle pages before returning so an immediate stop has a frame to encode.
1217 const firstFrame = await ServerBrowserPage.captureViewport(tab.page, tab.cdp, {
1218 format: "jpeg",
1219 quality: RECORDING_SCREENCAST.quality,
1220 scale: 1,
1221 });
1222 await encoder.evaluate(
1223 ([data, width]) =>
1224 (globalThis as unknown as EncoderWindow).__t3Recorder.frame(data, width),
1225 [firstFrame, tab.page.viewportSize()?.width ?? UNATTACHED_FILL_VIEWPORT.width] as const,
1226 );
1227 const opened = await tab.page.context().newCDPSession(tab.page);
1228 session = opened;
1229 const framesInFlight = new Set<Promise<void>>();
1230 opened.on("Page.screencastFrame", (frame) => {
1231 const cssWidth = tab.page.viewportSize()?.width ?? frame.metadata.deviceWidth;
1232 const delivered: Promise<void> = encoder
1233 .evaluate(
1234 ([data, width]) =>
1235 (globalThis as unknown as EncoderWindow).__t3Recorder.frame(data, width),
1236 [frame.data, cssWidth] as const,
1237 )
1238 .then(async (accepted) => {
1239 if (!accepted) await opened.send("Page.stopScreencast");
1240 })
1241 .catch(constVoid)
1242 .finally(() => {
1243 framesInFlight.delete(delivered);
1244 void opened
1245 .send("Page.screencastFrameAck", { sessionId: frame.sessionId })
1246 .catch(constVoid);
1247 });
1248 framesInFlight.add(delivered);
1249 });
1250 await opened.send("Page.startScreencast", RECORDING_SCREENCAST);
1251 if (tab.closing) throw new Error("The tab closed while the recording started.");
1252 const recording: Recording = {
1253 encoder,
1254 session: opened,
1255 startedAt: new Date().toISOString(),
1256 framesInFlight,
1257 };
1258 tab.recording = recording;
1259 return recording;
1260 } catch (cause) {
1261 // A start that fails partway must not leave its encoder page behind.
1262 await encoder.close().catch(constVoid);
1263 await session?.detach().catch(constVoid);
1264 throw cause;
1265 }
1266 }).finally(() => {
1267 if (tab.recordingStart === started) tab.recordingStart = null;
1268 });
1269 tab.recordingStart = started;
1270 return started;
1271 };
1272
1273 // Scaled captures repaint every screencast; pause them to avoid leaking that frame.
1274 const withScreencastsPaused = <A>(tab: ServerTab, capture: () => Promise<A>): Promise<A> =>
1275 withCaptureLock(tab, async () => {
1276 tab.capturing += 1;
1277 const recording = tab.recording;
1278 try {
1279 await Promise.all([
1280 ...[...tab.viewers].map((viewer) => viewer.pause()),
1281 recording?.session.send("Page.stopScreencast").catch(constVoid),
1282 ]);
1283 return await capture();
1284 } finally {
1285 tab.capturing -= 1;
1286 // Viewers that attached during the capture start here too.
1287 await Promise.all([
1288 ...[...tab.viewers].map((viewer) => viewer.resume()),
1289 recording && tab.recording === recording
1290 ? recording.session.send("Page.startScreencast", RECORDING_SCREENCAST).catch(constVoid)
1291 : undefined,
1292 ]);
1293 }
1294 });
1295
1296 const stopRecording = (tab: ServerTab) =>
1297 withCaptureLock(tab, async () => {
1298 const recording = tab.recording;
1299 if (!recording) {
1300 throw new ServerBrowserPage.ServerBrowserOperationError(
1301 "PreviewAutomationRecordingNotActiveError",
1302 "No recording is active for this tab.",
1303 );
1304 }
1305 let mimeType: string | null;
1306 const chunks: Array<Buffer> = [];
1307 try {
1308 await recording.session.send("Page.stopScreencast").catch(constVoid);
1309 // The last frames may still be on their way into the encoder.
1310 await Promise.all(recording.framesInFlight);
1311 await recording.session.detach().catch(constVoid);
1312 const stopped = await recording.encoder.evaluate(() =>
1313 (globalThis as unknown as EncoderWindow).__t3Recorder.stop(),
1314 );
1315 mimeType = stopped.mimeType;
1316 // Checked before the transfer so an oversized video never lands in this process.
1317 if (stopped.bytes > PROVIDER_SEND_TURN_MAX_FILE_BYTES) {
1318 throw new ServerBrowserPage.ServerBrowserOperationError(
1319 "PreviewAutomationRecordingTooLargeError",
1320 "The recording is larger than the attachment limit.",
1321 );
1322 }
1323 for (let index = 0; index < stopped.count; index += 1) {
1324 const chunk = await recording.encoder.evaluate(
1325 (chunkIndex) => (globalThis as unknown as EncoderWindow).__t3Recorder.chunk(chunkIndex),
1326 index,
1327 );
1328 chunks.push(Buffer.from(chunk, "base64"));
1329 }
1330 } finally {
1331 await recording.encoder.close().catch(constVoid);
1332 tab.recording = null;
1333 }
1334 const data = Buffer.concat(chunks);
1335 if (!mimeType || data.byteLength === 0) {
1336 throw new ServerBrowserPage.ServerBrowserOperationError(
1337 "PreviewAutomationExecutionError",
1338 "The recording captured no frames.",
1339 );
1340 }
1341 // Use the desktop upload location so the MCP handler can claim the recording.
1342 const extension = mimeType.startsWith("video/mp4") ? "mp4" : "webm";
1343 const pendingId = `${PENDING_ATTACHMENT_THREAD_SEGMENT}-${NodeCrypto.randomUUID()}-${extension}`;
1344 const path = NodePath.join(config.attachmentsDir, `${pendingId}.${extension}`);
1345 await NodeFSP.mkdir(config.attachmentsDir, { recursive: true });
1346 await NodeFSP.writeFile(path, data);
1347 return {
1348 id: pendingId,
1349 tabId: tab.tabId,
1350 path,
1351 mimeType: mimeType.split(";")[0]!,
1352 sizeBytes: data.byteLength,
1353 createdAt: new Date().toISOString(),
1354 uploadedAttachmentId: pendingId,
1355 };
1356 });
1357
1358 let pointerSequence = 0;
1359 /**
1360 * Shows viewers and recordings where the agent is about to act. Nobody is
1361 * watching a headless agent tab, so it pays no glide delay.
1362 */
1363 const pointerFor =
1364 (tab: ServerTab): ServerBrowserPage.PointerReporter =>
1365 async ({ x, y }, phase) => {
1366 const encoder = tab.recording?.encoder;
1367 // A desktop-rendered tab is always on screen in the desktop app.
1368 if (tab.viewers.size === 0 && !encoder && !tab.desktop) return;
1369 const show = (next: "move" | "click") => {
1370 const sequence = ++pointerSequence;
1371 for (const viewer of tab.viewers)
1372 viewer.push({ _tag: "pointer", phase: next, x, y, sequence });
1373 if (tab.desktop)
1374 runFork(
1375 desktopChannel.pointer(
1376 { threadId: tab.threadId, tabId: tab.tabId },
1377 { phase: next, x, y },
1378 ),
1379 );
1380 void encoder
1381 ?.evaluate(
1382 ([px, py, click]) =>
1383 (globalThis as unknown as EncoderWindow).__t3Recorder?.cursor(px, py, click),
1384 [x, y, next === "click"] as const,
1385 )
1386 .catch(constVoid);
1387 };
1388 show("move");
1389 await sleepUntil(Date.now() + AGENT_CURSOR_MOVE_MS);
1390 if (phase !== "click") return;
1391 show("click");
1392 await sleepUntil(Date.now() + AGENT_CURSOR_CLICK_LEAD_MS);
1393 };
1394
1395 const recordAction = <A>(tab: ServerTab, action: string, run: () => Promise<A>): Promise<A> => {
1396 const event: {
1397 -readonly [K in keyof PreviewAutomationActionEvent]: PreviewAutomationActionEvent[K];
1398 } = {
1399 id: NodeCrypto.randomUUID(),
1400 action,
1401 status: "running",
1402 startedAt: new Date().toISOString(),
1403 };
1404 pushBounded(tab.actionTimeline, event, ACTION_TIMELINE_LIMIT);
1405 return run().then(
1406 (result) => {
1407 event.status = "succeeded";
1408 event.completedAt = new Date().toISOString();
1409 return result;
1410 },
1411 (cause: unknown) => {
1412 event.status = "failed";
1413 event.completedAt = new Date().toISOString();
1414 event.error = cause instanceof Error ? cause.message.split("\n")[0] : String(cause);
1415 throw cause;
1416 },
1417 );
1418 };
1419
1420 const requireTab = async (request: PreviewAutomationRequest) => {
1421 if (!request.agentSessionId)
1422 throw new BrowserControlInterrupted("The agent session is missing. Reconnect the provider.");
1423 const owned = [...tabs.values()].filter(
1424 (tab) => tab.threadId === request.threadId && tab.control.agentId === request.agentSessionId,
1425 );
1426 if (!request.tabIdExplicit && owned.length > 1)
1427 throw new BrowserControlInterrupted(
1428 "Multiple tabs belong to this agent session. Pass a tabId from preview_open or preview_status tabs.",
1429 "tabRequired",
1430 );
1431 const tab =
1432 request.tabId === undefined
1433 ? latestThreadTab(request.threadId, request.agentSessionId)
1434 : await Effect.runPromise(
1435 findTab(request.threadId, request.tabId).pipe(Effect.orElseSucceed(constVoid)),
1436 );
1437 if (!tab) {
1438 throw new ServerBrowserPage.ServerBrowserOperationError(
1439 "PreviewAutomationTabNotFoundError",
1440 "No server preview tab is open for this thread. Call preview_open first.",
1441 );
1442 }
1443 if (tab.control.agentId !== request.agentSessionId)
1444 throw new BrowserControlInterrupted(
1445 "This tab belongs to another agent session or a human. Open your own tab.",
1446 "agentMismatch",
1447 );
1448 return tab;
1449 };
1450
1451 /** Any request, even a failed one, keeps the agent's tabs on the thread from idling out. */
1452 const markUsed = (request: PreviewAutomationRequest) => {
1453 const now = Date.now();
1454 for (const tab of tabs.values()) {
1455 if (tab.threadId === request.threadId && tab.control.agentId === request.agentSessionId)
1456 tab.usedAt = now;
1457 }
1458 };
1459
1460 const runOperation = async (request: PreviewAutomationRequest): Promise<unknown> => {
1461 const input = request.input;
1462 switch (request.operation) {
1463 case "status":
1464 return statusWithTitle(
1465 request.tabId === undefined
1466 ? latestThreadTab(request.threadId, request.agentSessionId)
1467 : tabs.get(tabKey(request.threadId, request.tabId)),
1468 request.agentSessionId,
1469 );
1470 case "open": {
1471 if (!request.agentSessionId)
1472 throw new BrowserControlInterrupted(
1473 "The agent session is missing. Reconnect the provider.",
1474 );
1475 const open = input as PreviewAutomationOpenInput;
1476 const url = open.url === undefined ? undefined : normalizePreviewUrl(open.url);
1477 const reuse = open.reuseExistingTab ?? true;
1478 if (
1479 reuse &&
1480 !request.tabIdExplicit &&
1481 [...tabs.values()].filter(
1482 (tab) =>
1483 tab.threadId === request.threadId && tab.control.agentId === request.agentSessionId,
1484 ).length > 1
1485 )
1486 throw new BrowserControlInterrupted(
1487 "Multiple tabs are open. Pass tabId or reuseExistingTab=false.",
1488 "tabRequired",
1489 );
1490 // A tab still launching exists only as a session, so resolve it like a viewer would.
1491 const existing =
1492 reuse && request.tabId !== undefined
1493 ? await Effect.runPromise(
1494 findTab(request.threadId, request.tabId).pipe(
1495 Effect.catchTags({
1496 ServerBrowserTabNotFoundError: () => Effect.succeed(undefined),
1497 }),
1498 ),
1499 )
1500 : undefined;
1501 const navigationTimeout = Math.min(request.timeoutMs, NAVIGATION_TIMEOUT_MS);
1502 if (!existing) {
1503 closeIdleAgentTabs();
1504 assertTabCapacity(request.agentSessionId);
1505 }
1506 const tab =
1507 existing ??
1508 (await ensureTab(
1509 await Effect.runPromise(
1510 manager.open({
1511 threadId: request.threadId,
1512 ...(url ? { url } : {}),
1513 runtime: "server",
1514 reveal: false,
1515 automationOwner: request.agentSessionId,
1516 }),
1517 ),
1518 ));
1519 if (existing?.dialog)
1520 throw new BrowserControlInterrupted(
1521 "A browser dialog is pending. Read preview_status and use preview_dialog first.",
1522 "dialogPending",
1523 );
1524 return tab.control.agent(request.agentSessionId, async () => {
1525 if (tab.dialog)
1526 throw new BrowserControlInterrupted(
1527 "A browser dialog is pending. Read preview_status and use preview_dialog first.",
1528 "dialogPending",
1529 );
1530 if (existing) {
1531 if (url) await navigate(tab, url, "load", navigationTimeout);
1532 } else {
1533 // Await the original navigation failure even though background creation keeps the tab.
1534 await tab.initialNavigation;
1535 }
1536 const reveal = open.open ?? open.show;
1537 if (reveal !== false) {
1538 await Effect.runPromise(
1539 manager.requestReveal({
1540 threadId: tab.threadId,
1541 tabId: tab.tabId,
1542 force: reveal === true,
1543 }),
1544 );
1545 }
1546 if (!existing && url) {
1547 await tab.page
1548 .waitForLoadState("load", { timeout: navigationTimeout })
1549 .catch(constVoid);
1550 }
1551 return statusWithTitle(tab, request.agentSessionId);
1552 });
1553 }
1554 case "recordingStop": {
1555 if (
1556 !request.tabIdExplicit &&
1557 [...tabs.values()].filter(
1558 (candidate) =>
1559 candidate.threadId === request.threadId &&
1560 candidate.control.agentId === request.agentSessionId,
1561 ).length > 1
1562 )
1563 throw new BrowserControlInterrupted(
1564 "Multiple tabs belong to this agent session. Pass a tabId from preview_open or preview_status tabs.",
1565 "tabRequired",
1566 );
1567 const recordings = [...tabs.values()].filter(
1568 (candidate) =>
1569 candidate.threadId === request.threadId &&
1570 candidate.control.agentId === request.agentSessionId &&
1571 (candidate.recording || candidate.recordingStart),
1572 );
1573 const targetTabId =
1574 request.tabId ?? latestThreadTab(request.threadId, request.agentSessionId)?.tabId;
1575 const tab =
1576 recordings.find((candidate) => candidate.tabId === targetTabId) ??
1577 (!request.tabIdExplicit && recordings.length === 1 ? recordings[0] : undefined);
1578 if (!tab) {
1579 throw new ServerBrowserPage.ServerBrowserOperationError(
1580 "PreviewAutomationRecordingNotActiveError",
1581 "No recording is active for this thread.",
1582 );
1583 }
1584 return tab.control.agent(request.agentSessionId ?? "", () => stopRecording(tab));
1585 }
1586 }
1587 const tab = await requireTab(request);
1588 // Closing must unblock an action waiting on a dialog, without queueing behind it.
1589 if (request.operation === "close") {
1590 if (tab.control.controller !== null)
1591 throw new BrowserControlInterrupted("A human controls this tab.", "humanControl");
1592 void tab.control.close().catch(constVoid);
1593 await Effect.runPromise(manager.close({ threadId: tab.threadId, tabId: tab.tabId }));
1594 dropTab(tab, false);
1595 return {};
1596 }
1597 // A click can be waiting for its dialog. Resolve it outside the serial queue,
1598 // with the same owner check, so the operation can finish and control can drain.
1599 if (request.operation === "dialog") {
1600 if (tab.control.controller !== null)
1601 throw new BrowserControlInterrupted("A human controls this tab.", "humanControl");
1602 await resolveDialog(tab, input as PreviewAutomationDialogInput);
1603 return statusWithTitle(tab, request.agentSessionId);
1604 }
1605 return tab.control.agent(request.agentSessionId!, async () => {
1606 if (tab.dialog)
1607 throw new BrowserControlInterrupted(
1608 "A browser dialog is pending. Read preview_status and use preview_dialog first.",
1609 "dialogPending",
1610 );
1611 const generation = tab.control.generation;
1612 try {
1613 return await executeTabOperation(tab, request);
1614 } finally {
1615 if (generation !== tab.control.generation) ServerBrowserPage.invalidateRefs(tab.page);
1616 }
1617 });
1618 };
1619
1620 const executeTabOperation = async (tab: ServerTab, request: PreviewAutomationRequest) => {
1621 const input = request.input;
1622 switch (request.operation) {
1623 case "navigate": {
1624 const navigateInput = input as PreviewAutomationNavigateInput;
1625 await recordAction(tab, "navigate", () =>
1626 navigate(
1627 tab,
1628 resolveNavigationUrl(navigateInput),
1629 navigateInput.readiness ?? "load",
1630 navigateInput.timeoutMs ?? request.timeoutMs,
1631 ),
1632 );
1633 return statusWithTitle(tab, request.agentSessionId);
1634 }
1635 case "resize": {
1636 const setting = resolvePreviewViewport(input as PreviewAutomationResizeInput);
1637 await Effect.runPromise(
1638 manager.resize({ threadId: tab.threadId, tabId: tab.tabId, viewport: setting }),
1639 );
1640 await applySetting(tab, setting);
1641 return {
1642 tabId: tab.tabId,
1643 setting,
1644 viewport: tab.page.viewportSize() ?? UNATTACHED_FILL_VIEWPORT,
1645 };
1646 }
1647 case "setColorScheme": {
1648 const { colorScheme } = input as PreviewAutomationSetColorSchemeInput;
1649 const snapshot = await Effect.runPromise(
1650 manager.adjust({ threadId: tab.threadId, tabId: tab.tabId, colorScheme }),
1651 );
1652 await applyRendering(tab, snapshot);
1653 return { tabId: tab.tabId, colorScheme };
1654 }
1655 case "snapshot": {
1656 return withScreencastsPaused(tab, () =>
1657 ServerBrowserPage.snapshot({ ...tab, renderScale: RENDER_SCALE }),
1658 );
1659 }
1660 case "click": {
1661 const clickInput = input as PreviewAutomationClickInput;
1662 await recordAction(tab, "click", () =>
1663 ServerBrowserPage.click(tab.page, clickInput, pointerFor(tab)),
1664 );
1665 return undefined;
1666 }
1667 case "hover":
1668 return recordAction(tab, "hover", () =>
1669 ServerBrowserPage.hover(tab.page, input as PreviewAutomationHoverInput, pointerFor(tab)),
1670 );
1671 case "select":
1672 return recordAction(tab, "select", () =>
1673 ServerBrowserPage.select(tab.page, input as PreviewAutomationSelectInput),
1674 );
1675 case "drag":
1676 return recordAction(tab, "drag", () =>
1677 ServerBrowserPage.drag(tab.page, input as PreviewAutomationDragInput, pointerFor(tab)),
1678 );
1679 case "upload":
1680 return recordAction(tab, "upload", () =>
1681 uploadFiles(tab, input as PreviewAutomationUploadInput),
1682 );
1683 case "type":
1684 return recordAction(tab, "type", () =>
1685 ServerBrowserPage.type(tab.page, input as PreviewAutomationTypeInput),
1686 );
1687 case "press":
1688 return recordAction(tab, "press", () =>
1689 ServerBrowserPage.press(tab.page, input as PreviewAutomationPressInput),
1690 );
1691 case "scroll":
1692 return recordAction(tab, "scroll", () =>
1693 ServerBrowserPage.scroll(tab.page, input as PreviewAutomationScrollInput),
1694 );
1695 case "evaluate":
1696 return ServerBrowserPage.evaluate(tab.cdp, input as PreviewAutomationEvaluateInput);
1697 case "waitFor":
1698 return ServerBrowserPage.waitFor(tab.page, input as PreviewAutomationWaitForInput);
1699 case "recordingStart": {
1700 const recording = await startRecording(tab);
1701 return { tabId: tab.tabId, recording: true, startedAt: recording.startedAt };
1702 }
1703 }
1704 };
1705
1706 const handleRequest = (connectionId: string, request: PreviewAutomationRequest) =>
1707 Effect.tryPromise({
1708 try: () => runOperation(request).finally(() => markUsed(request)),
1709 catch: ServerBrowserPage.toOperationError,
1710 }).pipe(
1711 Effect.match({
1712 onSuccess: (result) => ({ ok: true as const, result }),
1713 onFailure: (error) => ({
1714 ok: false as const,
1715 error: {
1716 _tag: error.tag,
1717 message: error.message,
1718 ...(error.detail === undefined ? {} : { detail: error.detail }),
1719 },
1720 }),
1721 }),
1722 Effect.flatMap((outcome) =>
1723 broker.respond({
1724 clientId: SERVER_HOST_CLIENT_ID,
1725 connectionId,
1726 requestId: request.requestId,
1727 ...outcome,
1728 }),
1729 ),
1730 Effect.ignore,
1731 );
1732
1733 const mirrorManagerEvent = (event: PreviewEvent) =>
1734 Effect.promise(async () => {
1735 if (event.type === "opened" && event.snapshot.runtime === "server") {
1736 await ensureTab(event.snapshot).catch(constVoid);
1737 return;
1738 }
1739 const key = tabKey(event.threadId, event.tabId);
1740 const tab = tabs.get(key);
1741 if (event.type === "closed" && !tab && pendingTabs.has(key)) closedPendingTabs.add(key);
1742 if (!tab) return;
1743 if (event.type === "closed") {
1744 closedSessions.add(key);
1745 dropTab(tab, false);
1746 closedSessions.delete(key);
1747 return;
1748 }
1749 // Any client or agent may change these; the page follows what was published.
1750 if (event.type === "resized") {
1751 await tab.control
1752 .system(async () => {
1753 if (
1754 !viewportSettingsEqual(event.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT, tab.setting)
1755 ) {
1756 await applySetting(tab, event.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT);
1757 }
1758 await applyRendering(tab, event.snapshot);
1759 const request = event.request;
1760 if (request?.clear === "cookies") await tab.cdp.send("Network.clearBrowserCookies");
1761 if (request?.clear === "cache") await tab.cdp.send("Network.clearBrowserCache");
1762 if (request?.hardReload) await tab.cdp.send("Page.reload", { ignoreCache: true });
1763 })
1764 .catch((cause: unknown) =>
1765 runFork(Effect.logWarning("server preview could not apply a tab setting", { cause })),
1766 );
1767 }
1768 });
1769
1770 const releaseViewerInput = async (viewer: ViewerState, session: CDPSession) => {
1771 for (const { key, code } of viewer.pressedKeys.values()) {
1772 await session.send("Input.dispatchKeyEvent", { type: "keyUp", key, code }).catch(constVoid);
1773 }
1774 viewer.pressedKeys.clear();
1775 for (const [button, point] of viewer.pressedButtons) {
1776 await session
1777 .send("Input.dispatchMouseEvent", {
1778 type: "mouseReleased",
1779 button,
1780 ...point,
1781 buttons: 0,
1782 clickCount: 1,
1783 })
1784 .catch(constVoid);
1785 }
1786 viewer.pressedButtons.clear();
1787 viewer.requestedSize = null;
1788 };
1789
1790 const dispatchViewerInput = async (
1791 tab: ServerTab,
1792 session: CDPSession,
1793 viewer: ViewerState,
1794 raw: unknown,
1795 ) => {
1796 const message = asRecord(raw);
1797 if (!message) return;
1798 viewer.inputAt = Date.now();
1799 const modifiers = modifiersOf(message);
1800 switch (message.type) {
1801 case "mouse": {
1802 const action = message.action;
1803 const type =
1804 action === "down" ? "mousePressed" : action === "up" ? "mouseReleased" : "mouseMoved";
1805 const button = ["none", "left", "middle", "right"].includes(String(message.button))
1806 ? (message.button as "none" | "left" | "middle" | "right")
1807 : "none";
1808 await session.send("Input.dispatchMouseEvent", {
1809 type,
1810 x: num(message.x),
1811 y: num(message.y),
1812 button,
1813 buttons: num(message.buttons),
1814 clickCount: num(message.clickCount, type === "mouseMoved" ? 0 : 1),
1815 modifiers,
1816 });
1817 if (button !== "none") {
1818 if (action === "down")
1819 viewer.pressedButtons.set(button, { x: num(message.x), y: num(message.y) });
1820 else if (action === "up") viewer.pressedButtons.delete(button);
1821 }
1822 return;
1823 }
1824 case "wheel":
1825 viewer.scrolledAt = Date.now();
1826 await session.send("Input.dispatchMouseEvent", {
1827 type: "mouseWheel",
1828 x: num(message.x),
1829 y: num(message.y),
1830 deltaX: num(message.deltaX),
1831 deltaY: num(message.deltaY),
1832 modifiers,
1833 });
1834 return;
1835 case "key": {
1836 const key = typeof message.key === "string" ? message.key : "";
1837 const code = typeof message.code === "string" ? message.code : "";
1838 const text = typeof message.text === "string" ? message.text : undefined;
1839 if (message.action === "up") {
1840 await session.send("Input.dispatchKeyEvent", { type: "keyUp", key, code, modifiers });
1841 viewer.pressedKeys.delete(code || key);
1842 return;
1843 }
1844 await session.send("Input.dispatchKeyEvent", {
1845 type: text ? "keyDown" : "rawKeyDown",
1846 key,
1847 code,
1848 modifiers,
1849 ...(text ? { text, unmodifiedText: text } : {}),
1850 ...editingCommand(key, modifiers),
1851 windowsVirtualKeyCode: num(
1852 message.keyCode,
1853 key.length === 1 ? key.toUpperCase().charCodeAt(0) : 0,
1854 ),
1855 });
1856 viewer.pressedKeys.set(code || key, { key, code });
1857 return;
1858 }
1859 case "text":
1860 if (typeof message.text === "string" && message.text.length > 0) {
1861 await session.send("Input.insertText", { text: message.text.slice(0, 10_000) });
1862 }
1863 return;
1864 case "resize": {
1865 const width = Math.min(Math.round(num(message.width)), 3840);
1866 const height = Math.min(Math.round(num(message.height)), 2160);
1867 if (width < 100 || height < 100) return;
1868 viewer.requestedSize = { width, height, order: ++viewerResizeOrder };
1869 if (tab.setting._tag !== "fill" || tab.desktop) return;
1870 const current = tab.page.viewportSize();
1871 if (current?.width === width && current.height === height) return;
1872 await tab.page.setViewportSize({ width, height });
1873 broadcastViewport(tab);
1874 return;
1875 }
1876 case "viewport": {
1877 const setting = decodeViewportSetting(message.setting);
1878 await Effect.runPromise(
1879 manager.resize({ threadId: tab.threadId, tabId: tab.tabId, viewport: setting }),
1880 );
1881 await applySetting(tab, setting);
1882 return;
1883 }
1884 case "navigate":
1885 if (typeof message.url === "string") {
1886 const url = normalizePreviewUrl(message.url);
1887 await tab.page.goto(url, VIEWER_NAVIGATION_OPTIONS);
1888 }
1889 return;
1890 case "history":
1891 await (num(message.delta) < 0
1892 ? tab.page.goBack(VIEWER_NAVIGATION_OPTIONS)
1893 : tab.page.goForward(VIEWER_NAVIGATION_OPTIONS));
1894 return;
1895 case "reload":
1896 // A hard reload fetches everything again, as Chrome's Shift+Reload does.
1897 if (message.ignoreCache === true) {
1898 await session.send("Page.reload", { ignoreCache: true });
1899 return;
1900 }
1901 await tab.page.reload(VIEWER_NAVIGATION_OPTIONS);
1902 return;
1903 case "probe": {
1904 const x = num(message.x);
1905 const y = num(message.y);
1906 const result = await session.send("Runtime.evaluate", {
1907 expression: `(${EDITABLE_AT_POINT_SCRIPT})(${x}, ${y})`,
1908 returnByValue: true,
1909 });
1910 viewer.push({ _tag: "probe", x, y, editable: result.result.value === true });
1911 return;
1912 }
1913 }
1914 };
1915
1916 const attachViewer: ServerBrowser["Service"]["attachViewer"] = (input) =>
1917 Effect.gen(function* () {
1918 const tab = yield* findTab(input.threadId, input.tabId);
1919 const output = yield* Queue.make<ServerBrowserViewerOutput>({
1920 capacity: VIEWER_OUTPUT_LIMIT,
1921 strategy: "dropping",
1922 });
1923 const session = yield* Effect.acquireRelease(
1924 Effect.tryPromise({
1925 try: () => tab.page.context().newCDPSession(tab.page),
1926 catch: (cause) => new ServerBrowserLaunchError({ cause }),
1927 }),
1928 (session) =>
1929 Effect.promise(() =>
1930 session
1931 .send("Page.stopScreencast")
1932 .catch(constVoid)
1933 .then(() => session.detach())
1934 .catch(constVoid),
1935 ),
1936 );
1937 const quality = Math.min(100, Math.max(1, Math.round(input.quality)));
1938 // Chromium may drop the final frame during a burst; send a still when it settles.
1939 let framesInFlight = 0;
1940 let mayHaveDropped = false;
1941 let motion = false;
1942 const recentFrames: Array<number> = [];
1943 let screencastParams = Promise.resolve();
1944 let screencastScale = 1;
1945 const startScreencast = (scale: number) => {
1946 screencastScale = scale;
1947 screencastParams = screencastParams.then(async () => {
1948 // A scaled capture is rendering; its resume starts the stream.
1949 if (tab.capturing > 0) return;
1950 await session.send("Page.stopScreencast").catch(constVoid);
1951 await session
1952 .send("Page.startScreencast", {
1953 format: "jpeg",
1954 quality: screencastScale < 1 ? Math.min(quality, SCREENCAST_MOTION_QUALITY) : quality,
1955 maxWidth: Math.max(1, Math.round(input.maxWidth * screencastScale)),
1956 maxHeight: Math.max(1, Math.round(input.maxHeight * screencastScale)),
1957 })
1958 .catch(constVoid);
1959 });
1960 return screencastParams;
1961 };
1962 const viewer: ViewerState = {
1963 id: NodeCrypto.randomUUID(),
1964 canOperate: input.canOperate,
1965 pressedKeys: new Map(),
1966 pressedButtons: new Map(),
1967 push: (next) => {
1968 // Dropped frames must still release Chromium.
1969 if (Queue.offerUnsafe(output, next)) return;
1970 if (next._tag === "frame") runFork(next.ack);
1971 // State a stalled viewer cannot miss replaces its backlog. It runs
1972 // synchronously so an older replacement can never land after a newer one.
1973 else if (next._tag === "gone" || next._tag === "control" || next._tag === "fileChooser") {
1974 const dropped = Effect.runSyncExit(Queue.clear(output));
1975 if (dropped._tag === "Failure") return;
1976 Queue.offerUnsafe(output, next);
1977 // The controller's open picker may have been in the dropped backlog.
1978 const chooser = next._tag === "control" ? fileChooserMessage(tab) : null;
1979 if (chooser && tab.control.controller === viewer.id) Queue.offerUnsafe(output, chooser);
1980 for (const item of dropped.value) if (item._tag === "frame") runFork(item.ack);
1981 }
1982 },
1983 pause: () => {
1984 screencastParams = screencastParams.then(() =>
1985 session.send("Page.stopScreencast").then(constVoid, constVoid),
1986 );
1987 return screencastParams;
1988 },
1989 resume: () => startScreencast(screencastScale),
1990 scrolledAt: 0,
1991 inputAt: 0,
1992 requestedSize: null,
1993 };
1994 yield* Effect.acquireRelease(
1995 Effect.sync(() => {
1996 tab.viewers.add(viewer);
1997 reportLiveTabs();
1998 }),
1999 () =>
2000 Effect.promise(async () => {
2001 await tab.control.disconnect(viewer.id, () => releaseViewerInput(viewer, session));
2002 tab.viewers.delete(viewer);
2003 broadcastControl(tab);
2004 reportLiveTabs();
2005 }),
2006 );
2007 if (input.canOperate && tab.control.agentId === null && tab.control.controller === null) {
2008 yield* Effect.promise(() => tab.control.take(viewer.id));
2009 }
2010 broadcastControl(tab);
2011 pushFileChooser(tab);
2012 // Full scale: a scaled capture would flash in every other viewer.
2013 const pushStill = async () => {
2014 const data = await withCaptureLock(tab, () =>
2015 ServerBrowserPage.captureViewport(tab.page, session, {
2016 format: "jpeg",
2017 quality,
2018 scale: 1,
2019 }),
2020 ).catch(() => null);
2021 if (data)
2022 viewer.push({ _tag: "frame", data: Buffer.from(data, "base64"), ack: Effect.void });
2023 };
2024 let settleTimer: ReturnType<typeof setTimeout> | null = null;
2025 yield* Effect.addFinalizer(() =>
2026 Effect.sync(() => {
2027 if (settleTimer !== null) clearTimeout(settleTimer);
2028 }),
2029 );
2030 let screencastStarted = false;
2031 session.on("Page.screencastFrame", (frame) => {
2032 screencastStarted = true;
2033 if (framesInFlight > 0) mayHaveDropped = true;
2034 framesInFlight += 1;
2035 const arrivedAt = Date.now();
2036 recentFrames.push(arrivedAt);
2037 while (recentFrames[0]! < arrivedAt - SCREENCAST_MOTION_WINDOW_MS) recentFrames.shift();
2038 if (
2039 !motion &&
2040 recentFrames.length >= SCREENCAST_MOTION_FRAMES &&
2041 arrivedAt - viewer.scrolledAt < SCREENCAST_MOTION_WINDOW_MS
2042 ) {
2043 motion = true;
2044 void startScreencast(0.5);
2045 } else if (motion && arrivedAt - viewer.scrolledAt > SCREENCAST_SETTLE_MS) {
2046 // The page keeps animating after the scroll; its own frames are sharp again.
2047 motion = false;
2048 recentFrames.length = 0;
2049 void startScreencast(1);
2050 }
2051 if (settleTimer !== null) clearTimeout(settleTimer);
2052 settleTimer = setTimeout(() => {
2053 settleTimer = null;
2054 if (!motion && !mayHaveDropped) return;
2055 mayHaveDropped = false;
2056 if (motion) {
2057 motion = false;
2058 recentFrames.length = 0;
2059 void startScreencast(1);
2060 }
2061 void pushStill();
2062 }, SCREENCAST_SETTLE_MS);
2063 viewer.push({
2064 _tag: "frame",
2065 data: Buffer.from(frame.data, "base64"),
2066 ack: Effect.promise(() =>
2067 sleepUntil(arrivedAt + SCREENCAST_ACK_PACE_MS).then(() => {
2068 framesInFlight -= 1;
2069 return session
2070 .send("Page.screencastFrameAck", { sessionId: frame.sessionId })
2071 .catch(constVoid);
2072 }),
2073 ),
2074 });
2075 });
2076 broadcastViewport(tab);
2077 yield* Effect.promise(() => startScreencast(1));
2078 // An idle page does not repaint for a new screencast, so the viewer
2079 // starts from a still unless a live frame beat it.
2080 if (!screencastStarted) yield* Effect.promise(pushStill);
2081 return {
2082 output,
2083 input: (raw: unknown) =>
2084 Effect.promise(async () => {
2085 if (!viewer.canOperate) return;
2086 const message = asRecord(raw);
2087 if (!message) return;
2088 try {
2089 if (message.type === "takeControl") {
2090 const taking = tab.control.take(viewer.id);
2091 broadcastControl(tab);
2092 await taking;
2093 pushFileChooser(tab);
2094 } else if (message.type === "releaseControl") {
2095 const releasing = tab.control.release(viewer.id, () =>
2096 releaseViewerInput(viewer, session),
2097 );
2098 broadcastControl(tab);
2099 await releasing;
2100 } else if (
2101 message.type === "dialog" &&
2102 tab.control.controller === viewer.id &&
2103 typeof message.accept === "boolean"
2104 ) {
2105 await resolveDialog(tab, {
2106 accept: message.accept,
2107 ...(typeof message.promptText === "string"
2108 ? { promptText: message.promptText }
2109 : {}),
2110 });
2111 } else {
2112 await tab.control.human(viewer.id, () =>
2113 dispatchViewerInput(tab, session, viewer, message),
2114 );
2115 }
2116 } catch {
2117 // Rejected ownership cannot mutate the page; refresh the viewer's controls.
2118 broadcastControl(tab);
2119 }
2120 }),
2121 } satisfies ServerBrowserViewer;
2122 });
2123
2124 yield* manager.events.pipe(Stream.runForEach(mirrorManagerEvent), Effect.forkScoped);
2125 // Whoever runs the server learns the fix before anyone opens a tab.
2126 if (
2127 !PreviewBrowserHost.sandboxDisabled(yield* HostProcessEnvironment) &&
2128 (yield* PreviewBrowserHost.sandboxBlocked)
2129 ) {
2130 yield* Effect.logWarning(
2131 `This host blocks the sandbox T3's browser runs in, so browser tabs and HTML previews will not start. Run \`${setupCommand}\` once to allow it.`,
2132 );
2133 }
2134 // The desktop took its page back (closed, swapped, crashed, or devtools opened).
2135 // The session stays; the next viewer or agent reconnects when it re-attaches.
2136 yield* desktopChannel.detached.pipe(
2137 Stream.runForEach((key) =>
2138 Effect.sync(() => {
2139 const tab = tabs.get(tabKey(key.threadId, key.tabId));
2140 if (tab?.desktop) dropTab(tab, false);
2141 }),
2142 ),
2143 Effect.forkScoped,
2144 );
2145 yield* Effect.sync(closeIdleAgentTabs).pipe(
2146 Effect.repeat(Schedule.spaced(IDLE_SWEEP_INTERVAL)),
2147 Effect.forkScoped,
2148 );
2149 const environmentId = yield* environment.getEnvironmentId;
2150 const hostSession = broker
2151 .connect(
2152 {
2153 clientId: SERVER_HOST_CLIENT_ID,
2154 environmentId,
2155 supportedOperations: [...PREVIEW_AUTOMATION_SERVER_OPERATIONS],
2156 },
2157 { preferred: true },
2158 )
2159 .pipe(
2160 Effect.flatMap((events) =>
2161 events.pipe(
2162 Stream.runForEach((event) => {
2163 if (event.type === "connected") {
2164 hostConnectionId = event.connectionId;
2165 return Effect.sync(reportLiveTabs);
2166 }
2167 return handleRequest(event.connectionId, event.request).pipe(
2168 Effect.forkScoped,
2169 Effect.asVoid,
2170 );
2171 }),
2172 ),
2173 ),
2174 );
2175 // The broker disconnects timed-out hosts, including slow first installs. Reconnect.
2176 yield* hostSession.pipe(
2177 Effect.exit,
2178 Effect.andThen(Effect.sleep(HOST_RECONNECT_DELAY)),
2179 Effect.forever,
2180 Effect.forkScoped,
2181 );
2182 yield* Effect.addFinalizer(() =>
2183 Effect.promise(async () => {
2184 await contexts.close();
2185 }),
2186 );
2187
2188 const clearProfile = (profileId: string) =>
2189 Effect.tryPromise({
2190 try: () => contexts.clearProfile(profileId),
2191 catch: (cause) => new PreviewClearProfileError({ profileId, cause }),
2192 });
2193
2194 return ServerBrowser.of({
2195 attachViewer,
2196 clearProfile,
2197 openDownload,
2198 answerFileChooser,
2199 });
2200});
2201
2202export const layer = Layer.effect(ServerBrowser, make);
apps/server/src/preview/ServerBrowserContexts.test.ts created+245
...@@ -0,0 +1,245 @@
1import * as NodeEvents from "node:events";
2
3import { INCOGNITO_BROWSER_PROFILE_ID } from "@t3tools/contracts";
4import type { Browser, BrowserContext, BrowserType } from "playwright-core";
5import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
6
7import { ServerBrowserContexts } from "./ServerBrowserContexts.ts";
8
9const launches = vi.hoisted(() => ({
10 launch: vi.fn<BrowserType["launch"]>(),
11 persistent: vi.fn<BrowserType["launchPersistentContext"]>(),
12 mkdir: vi.fn().mockResolvedValue(undefined),
13 rm: vi.fn().mockResolvedValue(undefined),
14}));
15vi.mock("playwright-core", () => ({
16 chromium: { launch: launches.launch, launchPersistentContext: launches.persistent },
17}));
18vi.mock("node:fs/promises", () => ({ mkdir: launches.mkdir, rm: launches.rm }));
19
20const makeContext = () => {
21 const events = new NodeEvents.EventEmitter();
22 let closed = false;
23 return {
24 on: vi.fn((event: string, listener: () => void) => events.on(event, listener)),
25 pages: vi.fn(() => []),
26 close: vi.fn(async () => {
27 if (!closed) {
28 closed = true;
29 events.emit("close");
30 }
31 }),
32 };
33};
34
35const makeBrowser = () => {
36 const events = new NodeEvents.EventEmitter();
37 const contexts: ReturnType<typeof makeContext>[] = [];
38 return {
39 contexts,
40 on: vi.fn((event: string, listener: () => void) => events.on(event, listener)),
41 newContext: vi.fn(async () => {
42 const context = makeContext();
43 contexts.push(context);
44 return context as unknown as BrowserContext;
45 }),
46 close: vi.fn(async () => {
47 for (const context of contexts) await context.close();
48 events.emit("disconnected");
49 }),
50 };
51};
52
53const options = () => ({
54 profilesDir: "/test/profiles",
55 executable: vi.fn(async () => "/test/chromium"),
56 env: {},
57});
58
59beforeEach(() => {
60 launches.launch.mockReset();
61 launches.persistent.mockReset();
62 launches.mkdir.mockClear();
63 launches.rm.mockClear();
64});
65
66describe("ServerBrowserContexts", () => {
67 it("lazily shares one browser across concurrent isolated sessions and reuses each context", async () => {
68 const browser = makeBrowser();
69 const launch = Promise.withResolvers<Browser>();
70 launches.launch.mockReturnValue(launch.promise);
71 const configuration = options();
72 const pool = new ServerBrowserContexts(configuration);
73 expect(configuration.executable).not.toHaveBeenCalled();
74 const first = pool.contextFor("default", "agent-a");
75 const again = pool.contextFor("default", "agent-a");
76 const second = pool.contextFor("default", "agent-b");
77 const incognito = pool.contextFor(INCOGNITO_BROWSER_PROFILE_ID);
78 launch.resolve(browser as unknown as Browser);
79 const [a, reused, b, privateContext] = await Promise.all([first, again, second, incognito]);
80 expect(a).toBe(reused);
81 expect(new Set([a, b, privateContext]).size).toBe(3);
82 expect(launches.launch).toHaveBeenCalledTimes(1);
83 expect(launches.persistent).not.toHaveBeenCalled();
84 expect(browser.newContext).toHaveBeenCalledWith({
85 viewport: { width: 1280, height: 800 },
86 deviceScaleFactor: 2,
87 });
88 await pool.close();
89 expect(browser.close).toHaveBeenCalledTimes(1);
90 for (const context of browser.contexts) expect(context.close).toHaveBeenCalled();
91 });
92
93 it("keeps human profiles persistent without sharing their storage with agents", async () => {
94 const human = makeContext();
95 const browser = makeBrowser();
96 launches.persistent.mockResolvedValue(human as unknown as BrowserContext);
97 launches.launch.mockResolvedValue(browser as unknown as Browser);
98 const pool = new ServerBrowserContexts(options());
99 const persistent = await pool.contextFor("work/team");
100 expect(await pool.contextFor("work/team")).toBe(persistent);
101 expect(await pool.contextFor("work/team", "agent")).not.toBe(persistent);
102 expect(launches.persistent).toHaveBeenCalledExactlyOnceWith(
103 "/test/profiles/work%2Fteam",
104 expect.objectContaining({ chromiumSandbox: true, executablePath: "/test/chromium" }),
105 );
106 expect(launches.mkdir).toHaveBeenCalledWith("/test/profiles/work%2Fteam", { recursive: true });
107 await pool.close();
108 expect(human.close).toHaveBeenCalledTimes(1);
109 });
110
111 it("clears a profile by closing its open context before deleting its storage", async () => {
112 const human = makeContext();
113 const browser = makeBrowser();
114 launches.persistent.mockResolvedValue(human as unknown as BrowserContext);
115 launches.launch.mockResolvedValue(browser as unknown as Browser);
116 const onContextClose = vi.fn();
117 const pool = new ServerBrowserContexts({ ...options(), onContextClose });
118 const persistent = await pool.contextFor("work/team");
119 const agent = await pool.contextFor("work/team", "agent");
120 launches.rm.mockImplementationOnce(async () => {
121 expect(human.close).toHaveBeenCalledTimes(1);
122 });
123 await pool.clearProfile("work/team");
124 expect(onContextClose).toHaveBeenCalledExactlyOnceWith(persistent);
125 expect(launches.rm).toHaveBeenCalledExactlyOnceWith("/test/profiles/work%2Fteam", {
126 recursive: true,
127 force: true,
128 });
129 expect(await pool.contextFor("work/team", "agent")).toBe(agent);
130 expect(browser.contexts[0]?.close).not.toHaveBeenCalled();
131 launches.persistent.mockResolvedValue(makeContext() as unknown as BrowserContext);
132 expect(await pool.contextFor("work/team")).not.toBe(persistent);
133 await pool.clearProfile("never-opened");
134 expect(launches.rm).toHaveBeenLastCalledWith("/test/profiles/never-opened", {
135 recursive: true,
136 force: true,
137 });
138 await pool.close();
139 });
140
141 it("opens a profile requested during its clear only after the storage is deleted", async () => {
142 launches.persistent.mockImplementation(async () => makeContext() as unknown as BrowserContext);
143 const configuration = options();
144 const pool = new ServerBrowserContexts(configuration);
145 await pool.contextFor("work/team");
146 const removing = Promise.withResolvers<void>();
147 const removed = Promise.withResolvers<void>();
148 let deleted = false;
149 launches.rm.mockImplementationOnce(async () => {
150 removing.resolve();
151 await removed.promise;
152 deleted = true;
153 });
154 // Launching starts by resolving the executable; record whether storage was gone by then.
155 const launchedAfterDelete: Array<boolean> = [];
156 configuration.executable.mockImplementation(async () => {
157 launchedAfterDelete.push(deleted);
158 return "/test/chromium";
159 });
160 const clearing = pool.clearProfile("work/team");
161 const reopening = pool.contextFor("work/team");
162 await removing.promise;
163 removed.resolve();
164 await Promise.all([clearing, reopening]);
165 expect(launchedAfterDelete).toEqual([true]);
166 await pool.close();
167 });
168
169 it.each([undefined, "default"])(
170 "never retries a failed %s launch with the sandbox disabled",
171 async (profile) => {
172 const launch = profile ? launches.persistent : launches.launch;
173 launch.mockRejectedValue(new Error("sandbox unavailable"));
174 const pool = new ServerBrowserContexts(options());
175 await expect(pool.contextFor(profile ?? INCOGNITO_BROWSER_PROFILE_ID)).rejects.toThrow(
176 "sandbox unavailable",
177 );
178 expect(launch).toHaveBeenCalledTimes(1);
179 expect(
180 profile ? launches.persistent.mock.calls[0]?.[1] : launches.launch.mock.calls[0]?.[0],
181 ).toMatchObject({ chromiumSandbox: true });
182 await pool.close();
183 },
184 );
185
186 it("allows only an explicit sandbox opt-out", async () => {
187 const browser = makeBrowser();
188 launches.launch.mockResolvedValue(browser as unknown as Browser);
189 const pool = new ServerBrowserContexts({
190 ...options(),
191 env: { T3CODE_SERVER_BROWSER_SANDBOX: "0" },
192 });
193 await pool.contextFor(INCOGNITO_BROWSER_PROFILE_ID);
194 expect(launches.launch).toHaveBeenCalledWith(
195 expect.objectContaining({ chromiumSandbox: false }),
196 );
197 await pool.close();
198 });
199
200 it("retries a failed startup on a later request with sandboxing still enabled", async () => {
201 const browser = makeBrowser();
202 launches.launch
203 .mockRejectedValueOnce(new Error("executable unavailable"))
204 .mockResolvedValue(browser as unknown as Browser);
205 const pool = new ServerBrowserContexts(options());
206 await expect(pool.contextFor("default", "agent")).rejects.toThrow("executable unavailable");
207 await pool.contextFor("default", "agent");
208 expect(launches.launch).toHaveBeenCalledTimes(2);
209 expect(launches.launch).toHaveBeenLastCalledWith(
210 expect.objectContaining({ chromiumSandbox: true }),
211 );
212 await pool.close();
213 });
214
215 it("evicts a closed context without closing other agents or relaunching chromium", async () => {
216 const browser = makeBrowser();
217 launches.launch.mockResolvedValue(browser as unknown as Browser);
218 const onContextClose = vi.fn();
219 const pool = new ServerBrowserContexts({ ...options(), onContextClose });
220 const a = await pool.contextFor("default", "agent-a");
221 const b = await pool.contextFor("default", "agent-b");
222 await a.close();
223 expect(onContextClose).toHaveBeenCalledWith(a);
224 expect(await pool.contextFor("default", "agent-a")).not.toBe(a);
225 expect(await pool.contextFor("default", "agent-b")).toBe(b);
226 expect(browser.close).not.toHaveBeenCalled();
227 expect(launches.launch).toHaveBeenCalledTimes(1);
228 await pool.close();
229 });
230
231 it("cleans up a browser whose launch finishes during shutdown and rejects future work", async () => {
232 const browser = makeBrowser();
233 const launch = Promise.withResolvers<Browser>();
234 launches.launch.mockReturnValue(launch.promise);
235 const pool = new ServerBrowserContexts(options());
236 const opening = expect(pool.contextFor("default", "agent")).rejects.toThrow("closed");
237 const closing = pool.close();
238 launch.resolve(browser as unknown as Browser);
239 await Promise.all([opening, closing]);
240 await expect(pool.contextFor("default", "agent")).rejects.toThrow("closed");
241 await pool.close();
242 expect(browser.close).toHaveBeenCalledTimes(1);
243 expect(browser.contexts[0]?.close).toHaveBeenCalled();
244 });
245});
apps/server/src/preview/ServerBrowserContexts.ts created+180
...@@ -0,0 +1,180 @@
1// @effect-diagnostics nodeBuiltinImport:off - Owns Playwright resources outside the Effect runtime.
2import { INCOGNITO_BROWSER_PROFILE_ID } from "@t3tools/contracts";
3import { constVoid } from "effect/Function";
4import * as NodeFSP from "node:fs/promises";
5import * as NodePath from "node:path";
6import type { Browser, BrowserContext } from "playwright-core";
7
8import { sandboxDisabled } from "./PreviewBrowserHost.ts";
9
10interface Options {
11 readonly profilesDir: string;
12 /** The shared headless browser's executable, installing it on first use. */
13 readonly executable: () => Promise<string>;
14 /** Replaces a failed launch's error with the host setup it is missing, if any. */
15 readonly diagnose?: (executable: string, cause: unknown) => Promise<unknown>;
16 readonly env?: NodeJS.ProcessEnv;
17 readonly onContextClose?: (context: BrowserContext) => void;
18}
19
20/** Persistent human profiles keep their storage; isolated agents share a browser, never a context. */
21export class ServerBrowserContexts {
22 private readonly options: Options;
23 private readonly contexts = new Map<string, Promise<BrowserContext>>();
24 /** Profile clears in progress; a context for that profile opens after its storage is gone. */
25 private readonly clearing = new Map<string, Promise<void>>();
26 private browser: Promise<Browser> | undefined;
27 private closing: Promise<void> | undefined;
28
29 constructor(options: Options) {
30 this.options = options;
31 }
32
33 private async launchOptions() {
34 const executablePath = await this.options.executable();
35 const env = this.options.env ?? process.env;
36 return {
37 executablePath,
38 env,
39 args: ["--disable-gpu", "--force-device-scale-factor=2"],
40 headless: true,
41 // Only an explicit operator opt-out disables sandboxing. Launch errors never do.
42 chromiumSandbox: !sandboxDisabled(env),
43 };
44 }
45
46 private async launch<A>(options: { readonly executablePath: string }, start: () => Promise<A>) {
47 try {
48 return await start();
49 } catch (cause) {
50 throw (await this.options.diagnose?.(options.executablePath, cause)) ?? cause;
51 }
52 }
53
54 private sharedBrowser() {
55 if (!this.browser) {
56 const launched = this.launchOptions().then(async (options) => {
57 const { chromium } = await import("playwright-core");
58 const browser = await this.launch(options, () => chromium.launch(options));
59 browser.on("disconnected", () => {
60 if (this.browser === launched) this.browser = undefined;
61 });
62 return browser;
63 });
64 this.browser = launched;
65 void launched.catch(() => {
66 if (this.browser === launched) this.browser = undefined;
67 });
68 }
69 return this.browser;
70 }
71
72 contextFor(profileId: string, isolationKey?: string): Promise<BrowserContext> {
73 if (this.closing) return Promise.reject(new Error("The preview browser is closed."));
74 const key = JSON.stringify([profileId, isolationKey ?? null]);
75 const cached = this.contexts.get(key);
76 if (cached) return cached;
77 const cleared = this.clearing.get(key)?.catch(constVoid) ?? Promise.resolve();
78 const pending = cleared
79 .then(() => this.createContext(profileId, isolationKey))
80 .then(async (context) => {
81 context.on("close", () => {
82 if (this.contexts.get(key) === pending) this.contexts.delete(key);
83 this.options.onContextClose?.(context);
84 });
85 for (const page of context.pages()) await page.close().catch(constVoid);
86 if (this.closing) {
87 await context.close().catch(constVoid);
88 throw new Error("The preview browser is closed.");
89 }
90 return context;
91 });
92 this.contexts.set(key, pending);
93 void pending.catch(() => {
94 if (this.contexts.get(key) === pending) this.contexts.delete(key);
95 });
96 return pending;
97 }
98
99 private async createContext(profileId: string, isolationKey?: string) {
100 const contextOptions = { viewport: { width: 1280, height: 800 }, deviceScaleFactor: 2 };
101 if (isolationKey !== undefined || profileId === INCOGNITO_BROWSER_PROFILE_ID) {
102 const browser = await this.sharedBrowser();
103 return browser.newContext(contextOptions);
104 }
105 const directory = this.profileDirectory(profileId);
106 const options = await this.launchOptions();
107 const { chromium } = await import("playwright-core");
108 await NodeFSP.mkdir(directory, { recursive: true });
109 return this.launch(options, () =>
110 chromium.launchPersistentContext(directory, { ...options, ...contextOptions }),
111 );
112 }
113
114 private profileDirectory(profileId: string) {
115 const encoded = encodeURIComponent(profileId);
116 return NodePath.join(
117 this.options.profilesDir,
118 profileId === "." || profileId === ".." ? encoded.replaceAll(".", "%2E") : encoded,
119 );
120 }
121
122 /** A throwaway page in the shared browser, for work that must not touch a tab's own storage. */
123 async scratchPage() {
124 if (this.closing) throw new Error("The preview browser is closed.");
125 const context = await (await this.sharedBrowser()).newContext();
126 const page = await context.newPage();
127 page.once("close", () => void context.close().catch(constVoid));
128 return page;
129 }
130
131 /**
132 * Drives a page the desktop app renders, through the CDP endpoint its relay
133 * serves. The page keeps the desktop's storage, size, and window.
134 */
135 async connectDesktopPage(endpoint: string) {
136 const { chromium } = await import("playwright-core");
137 const browser = await chromium.connectOverCDP(endpoint, { timeout: 15_000 });
138 const page = browser.contexts().flatMap((context) => context.pages())[0];
139 if (!page) {
140 await browser.close().catch(constVoid);
141 throw new Error("The desktop tab has no page.");
142 }
143 return { browser, page };
144 }
145
146 /** Closes a human profile's persistent context, ending its tabs, then deletes its storage. */
147 async clearProfile(profileId: string) {
148 if (profileId === INCOGNITO_BROWSER_PROFILE_ID) return;
149 const key = JSON.stringify([profileId, null]);
150 const clearing = (async () => {
151 const pending = this.contexts.get(key);
152 if (pending) {
153 this.contexts.delete(key);
154 const context = await pending.catch(() => undefined);
155 await context?.close();
156 }
157 await NodeFSP.rm(this.profileDirectory(profileId), { recursive: true, force: true });
158 })();
159 this.clearing.set(key, clearing);
160 try {
161 await clearing;
162 } finally {
163 if (this.clearing.get(key) === clearing) this.clearing.delete(key);
164 }
165 }
166
167 close() {
168 this.closing ??= this.dispose();
169 return this.closing;
170 }
171
172 private async dispose() {
173 await Promise.allSettled(
174 [...this.contexts.values()].map(async (pending) => (await pending).close()),
175 );
176 const browser = await this.browser?.catch(() => undefined);
177 await browser?.close().catch(constVoid);
178 this.contexts.clear();
179 }
180}
apps/server/src/preview/ServerBrowserPage.test.ts created+248
...@@ -0,0 +1,248 @@
1import {
2 chromium,
3 type Browser,
4 type BrowserContext,
5 type CDPSession,
6 type Page,
7} from "playwright-core";
8import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vite-plus/test";
9
10import * as ServerBrowserPage from "./ServerBrowserPage.ts";
11
12describe("server browser element refs", () => {
13 let browser: Browser;
14 let context: BrowserContext;
15 let page: Page;
16 let cdp: CDPSession;
17
18 beforeAll(async () => {
19 browser = await chromium.launch({ headless: true });
20 });
21 afterAll(async () => {
22 await browser?.close();
23 });
24 beforeEach(async () => {
25 context = await browser.newContext();
26 page = await context.newPage();
27 cdp = await context.newCDPSession(page);
28 });
29 afterEach(async () => {
30 await context.close();
31 });
32
33 const takeSnapshot = () =>
34 ServerBrowserPage.snapshot({
35 page,
36 cdp,
37 renderScale: 1,
38 consoleEntries: [],
39 networkEntries: [],
40 actionTimeline: [],
41 });
42 const locators = (tree: unknown) => {
43 expect(typeof tree).toBe("string");
44 return Array.from(
45 String(tree).matchAll(/\[ref=([^\]]+)\]/g),
46 (match) => `aria-ref=${match[1]}`,
47 );
48 };
49 const buttonLocator = (tree: unknown, name: string) => {
50 const line = String(tree)
51 .split("\n")
52 .find((line) => line.includes(`button "${name}"`));
53 const locator = locators(line)[0];
54 expect(locator).toBeDefined();
55 return locator!;
56 };
57 const repeatedRows = `<ul>${Array.from({ length: 5 }, (_, i) => `<li>row ${i + 1}<button data-testid="delete-row" onclick="this.parentElement.remove()">delete</button></li>`).join("")}</ul>`;
58
59 it("clicks the fifth repeated delete control without touching row one", async () => {
60 await page.setContent(repeatedRows);
61 const result = await takeSnapshot();
62 const buttons = String(result.accessibilityTree)
63 .split("\n")
64 .filter((line) => line.includes('button "delete"'));
65 expect(buttons).toHaveLength(5);
66 await ServerBrowserPage.click(page, { locator: locators(buttons[4])[0]!, timeoutMs: 1_000 });
67 expect(await page.locator("li").allTextContents()).toEqual([
68 "row 1delete",
69 "row 2delete",
70 "row 3delete",
71 "row 4delete",
72 ]);
73 });
74
75 it("returns from a click once it opens a dialog", async () => {
76 await page.setContent(
77 `<button onclick="document.body.dataset.answer = String(confirm('sure?'))">Confirm</button>`,
78 );
79 const dialog = new Promise<import("playwright-core").Dialog>((resolve) =>
80 page.once("dialog", resolve),
81 );
82 await ServerBrowserPage.click(page, {
83 locator: buttonLocator((await takeSnapshot()).accessibilityTree, "Confirm"),
84 timeoutMs: 1_000,
85 });
86 await (await dialog).accept();
87 await expect.poll(() => page.locator("body").getAttribute("data-answer")).toBe("true");
88 });
89
90 it("rejects ambiguous CSS controls without clicking any row", async () => {
91 await page.setContent(repeatedRows);
92 await expect(
93 ServerBrowserPage.click(page, {
94 selector: 'button[data-testid="delete-row"]',
95 timeoutMs: 1_000,
96 }),
97 ).rejects.toThrow(/strict mode violation/);
98 expect(await page.locator("li").count()).toBe(5);
99 });
100
101 it("does not retarget a removed ref to a replacement node", async () => {
102 await page.setContent("<button onclick=\"this.textContent='clicked'\">original</button>");
103 const locator = buttonLocator((await takeSnapshot()).accessibilityTree, "original");
104 await page.setContent("<button onclick=\"this.textContent='clicked'\">replacement</button>");
105 await expect(ServerBrowserPage.click(page, { locator, timeoutMs: 100 })).rejects.toThrow();
106 expect(await page.locator("button").textContent()).toBe("replacement");
107 });
108
109 it("targets iframe input refs and preserves the parent form", async () => {
110 await page.setContent(
111 '<input aria-label="parent"><iframe srcdoc="<input aria-label=child>"></iframe>',
112 );
113 await page.frameLocator("iframe").getByRole("textbox").waitFor();
114 const result = await takeSnapshot();
115 const line = String(result.accessibilityTree)
116 .split("\n")
117 .find((line) => line.includes('textbox "child"'));
118 const locator = locators(line)[0]!;
119 await ServerBrowserPage.type(page, { locator, text: "inside iframe", clear: true });
120 expect(await page.frameLocator("iframe").getByRole("textbox").inputValue()).toBe(
121 "inside iframe",
122 );
123 expect(await page.getByRole("textbox", { name: "parent" }).inputValue()).toBe("");
124 });
125
126 it("rejects refs from another tab", async () => {
127 await page.setContent("<button>same label</button>");
128 const locator = buttonLocator((await takeSnapshot()).accessibilityTree, "same label");
129 const other = await context.newPage();
130 await other.setContent("<button>same label</button>");
131 await expect(ServerBrowserPage.click(other, { locator })).rejects.toThrow(/another tab/);
132 });
133
134 it("revokes refs on takeover and issues usable refs in the next snapshot", async () => {
135 await page.setContent("<button onclick=\"this.textContent='clicked'\">continue</button>");
136 const locator = buttonLocator((await takeSnapshot()).accessibilityTree, "continue");
137 ServerBrowserPage.invalidateRefs(page);
138 await expect(ServerBrowserPage.click(page, { locator })).rejects.toThrow(/stale/);
139 const fresh = buttonLocator((await takeSnapshot()).accessibilityTree, "continue");
140 await ServerBrowserPage.click(page, { locator: fresh });
141 expect(await page.locator("button").textContent()).toBe("clicked");
142 });
143
144 it("revokes refs after navigation even when labels are identical", async () => {
145 await page.goto("data:text/html,<button>continue</button>");
146 const locator = buttonLocator((await takeSnapshot()).accessibilityTree, "continue");
147 await page.goto("data:text/html,<button>continue</button><p>new document</p>");
148 await expect(ServerBrowserPage.click(page, { locator })).rejects.toThrow(/stale/);
149 });
150
151 it("only accepts refs from the most recent snapshot", async () => {
152 await page.setContent("<button>continue</button>");
153 const locator = buttonLocator((await takeSnapshot()).accessibilityTree, "continue");
154 await takeSnapshot();
155 await expect(ServerBrowserPage.click(page, { locator })).rejects.toThrow(/stale/);
156 });
157
158 it.each(["aria-ref=e1", " aria-ref=e1", "css=body >> aria-ref=e1"])(
159 "does not allow native refs to bypass generation validation (%s)",
160 async (locator) => {
161 await page.setContent("<button>continue</button>");
162 await takeSnapshot();
163 await expect(ServerBrowserPage.click(page, { locator })).rejects.toThrow(/stale/);
164 },
165 );
166
167 it("preserves CSS selectors containing an aria-ref attribute", async () => {
168 await page.setContent(
169 '<button aria-ref="save" onclick="this.textContent=\'saved\'">save</button>',
170 );
171 await ServerBrowserPage.click(page, { selector: 'button[aria-ref="save"]' });
172 expect(await page.locator("button").textContent()).toBe("saved");
173 });
174
175 it("preserves quoted attribute values containing ref engine text", async () => {
176 await page.setContent(
177 '<button data-example=" >> aria-ref=e1" onclick="this.textContent=\'saved\'">save</button>',
178 );
179 await ServerBrowserPage.click(page, { selector: 'button[data-example=" >> aria-ref=e1"]' });
180 expect(await page.locator("button").textContent()).toBe("saved");
181 });
182
183 it("right-clicks, double-clicks, hovers, selects, and drags like a pointer user", async () => {
184 await page.setContent(`
185 <style>#menu { position: absolute; display: none } #hover:hover + #menu { display: block }</style>
186 <button id="target">target</button>
187 <div id="hover">hover me</div><div id="menu">menu item</div>
188 <select id="size"><option value="s">Small</option><option value="l">Large</option></select>
189 <div id="card" draggable="true">card</div><div id="lane" style="height:40px">lane</div>
190 <p id="log"></p>
191 <script>
192 const log = (text) => (document.getElementById("log").textContent += text + ";");
193 const target = document.getElementById("target");
194 target.addEventListener("contextmenu", (event) => { event.preventDefault(); log("context"); });
195 target.addEventListener("dblclick", () => log("dblclick"));
196 document.getElementById("card").addEventListener("dragstart", (event) =>
197 event.dataTransfer.setData("text/plain", "card"),
198 );
199 for (const type of ["dragenter", "dragover"])
200 document.getElementById("lane").addEventListener(type, (event) => event.preventDefault());
201 document.getElementById("lane").addEventListener("drop", () => log("drop"));
202 </script>`);
203 await ServerBrowserPage.click(page, { locator: "#target", button: "right" });
204 await ServerBrowserPage.click(page, { locator: "#target", clickCount: 2 });
205 await ServerBrowserPage.hover(page, { locator: "#hover" });
206 expect(await page.isVisible("#menu")).toBe(true);
207 // A visible label selects the same option as its value.
208 expect(await ServerBrowserPage.select(page, { locator: "#size", values: ["Large"] })).toEqual({
209 selected: ["l"],
210 });
211 await ServerBrowserPage.drag(page, { source: "#card", target: "#lane" });
212 expect(await page.textContent("#log")).toBe("context;dblclick;drop;");
213 await expect(
214 ServerBrowserPage.select(page, { locator: "#target", values: ["s"] }),
215 ).rejects.toMatchObject({ tag: "PreviewAutomationTargetNotEditableError" });
216 });
217
218 it("shows the agent's pointer at each target before the action reaches the page", async () => {
219 await page.setContent(`
220 <button id="go" style="position:absolute;left:100px;top:40px;width:80px;height:20px">go</button>
221 <div id="tip" style="position:absolute;left:300px;top:40px;width:60px;height:20px">tip</div>
222 <div id="card" draggable="true" style="position:absolute;left:20px;top:120px;width:40px;height:40px">card</div>
223 <div id="lane" style="position:absolute;left:220px;top:120px;width:100px;height:40px">lane</div>
224 <script>
225 window.seen = [];
226 go.onclick = () => seen.push("click");
227 tip.onmouseenter = () => seen.push("hover");
228 card.ondragstart = (event) => event.dataTransfer.setData("text/plain", "card");
229 lane.ondragover = (event) => event.preventDefault();
230 lane.ondrop = () => seen.push("drop");
231 </script>`);
232 const shown: Array<string> = [];
233 const pointer: ServerBrowserPage.PointerReporter = async ({ x, y }, phase) => {
234 const seen = await page.evaluate("window.seen.length");
235 shown.push(`${phase}@${Math.round(x)},${Math.round(y)} after ${seen}`);
236 };
237 await ServerBrowserPage.click(page, { locator: "#go" }, pointer);
238 await ServerBrowserPage.hover(page, { locator: "#tip" }, pointer);
239 await ServerBrowserPage.drag(page, { source: "#card", target: "#lane" }, pointer);
240 expect(shown).toEqual([
241 "click@140,50 after 0",
242 "move@330,50 after 1",
243 "move@40,140 after 2",
244 "move@270,140 after 2",
245 ]);
246 expect(await page.evaluate("window.seen")).toEqual(["click", "hover", "drop"]);
247 });
248});
apps/server/src/preview/ServerBrowserPage.ts created+640
...@@ -0,0 +1,640 @@
1// @effect-diagnostics nodeBuiltinImport:off globalDate:off globalTimers:off - Playwright callbacks run outside the Effect runtime.
2
3import {
4 PROVIDER_SEND_TURN_MAX_FILE_BYTES,
5 type PreviewAutomationClickInput,
6 type PreviewAutomationConsoleEntry,
7 type PreviewAutomationDragInput,
8 type PreviewAutomationEvaluateInput,
9 type PreviewAutomationHoverInput,
10 type PreviewAutomationNetworkEntry,
11 type PreviewAutomationPressInput,
12 type PreviewAutomationScrollInput,
13 type PreviewAutomationSelectInput,
14 type PreviewAutomationSelectResult,
15 type PreviewAutomationSnapshot,
16 type PreviewAutomationTypeInput,
17 type PreviewAutomationUploadInput,
18 type PreviewAutomationWaitForInput,
19} from "@t3tools/contracts";
20import { constVoid } from "effect/Function";
21import type { CDPSession, Locator, Page } from "playwright-core";
22import * as NodeCrypto from "node:crypto";
23import { BrowserControlInterrupted } from "./SessionControl.ts";
24
25const MAX_EVALUATION_BYTES = 64_000;
26const MAX_VISIBLE_TEXT_LENGTH = 20_000;
27const MAX_SCREENSHOT_WIDTH = 1280;
28const WAIT_POLL_MS = 100;
29export const DIAGNOSTIC_BUFFER_LIMIT = 200;
30
31export class ServerBrowserOperationError extends Error {
32 readonly tag: string;
33 readonly detail: unknown;
34
35 constructor(tag: string, message: string, detail?: unknown) {
36 super(message);
37 this.tag = tag;
38 this.detail = detail;
39 }
40}
41
42export const toOperationError = (cause: unknown): ServerBrowserOperationError => {
43 if (cause instanceof BrowserControlInterrupted)
44 return new ServerBrowserOperationError(
45 "PreviewAutomationControlInterruptedError",
46 cause.message,
47 cause.reason,
48 );
49 if (cause instanceof ServerBrowserOperationError) return cause;
50 const message = cause instanceof Error ? cause.message : String(cause);
51 const firstLine = message.split("\n")[0] ?? message;
52 if (cause instanceof Error && cause.name === "TimeoutError") {
53 return new ServerBrowserOperationError("PreviewAutomationTimeoutError", firstLine);
54 }
55 if (
56 /while parsing selector|Unknown engine|Unexpected token|strict mode violation/i.test(message)
57 ) {
58 return new ServerBrowserOperationError("PreviewAutomationInvalidSelectorError", firstLine);
59 }
60 if (/not an <input>|not editable|not an editable/i.test(message)) {
61 return new ServerBrowserOperationError("PreviewAutomationTargetNotEditableError", firstLine);
62 }
63 return new ServerBrowserOperationError("PreviewAutomationExecutionError", firstLine);
64};
65
66const DEFAULT_TIMEOUT_MS = 15_000;
67
68const pageRefs = new WeakMap<Page, { generation: string; refs: Map<string, string> }>();
69// A compact runtime namespace prevents old refs from aliasing after a server restart.
70const refNamespace = NodeCrypto.randomUUID().slice(0, 8);
71let snapshotSequence = 0;
72const nextRefGeneration = () => `${refNamespace}-${(++snapshotSequence).toString(36)}`;
73
74/** A takeover revokes previously issued refs even when the document stays unchanged. */
75export const invalidateRefs = (page: Page) => {
76 const state = pageRefs.get(page);
77 if (state) {
78 state.generation = nextRefGeneration();
79 state.refs.clear();
80 }
81};
82
83const refsFor = (page: Page) => {
84 let state = pageRefs.get(page);
85 if (!state) {
86 state = { generation: nextRefGeneration(), refs: new Map<string, string>() };
87 pageRefs.set(page, state);
88 page.on("framenavigated", () => invalidateRefs(page));
89 page.on("framedetached", () => invalidateRefs(page));
90 }
91 return state;
92};
93
94const targetLocator = (
95 page: Page,
96 input: { readonly locator?: string | undefined; readonly selector?: string | undefined },
97): Locator | null => {
98 const selector = input.locator ?? input.selector;
99 if (selector === undefined) return null;
100 // Ignore quoted/escaped CSS values when looking for a selector-engine boundary.
101 const engines = selector.replace(
102 /\\.|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`/g,
103 " ",
104 );
105 if (/(?:^|>>)\s*\*?aria-ref\s*=/.test(engines)) {
106 const ref = /^\s*aria-ref\s*=\s*(\S+)\s*$/.exec(selector)?.[1];
107 const nativeRef = ref === undefined ? undefined : pageRefs.get(page)?.refs.get(ref);
108 if (nativeRef === undefined) {
109 throw new ServerBrowserOperationError(
110 "PreviewAutomationInvalidSelectorError",
111 "This element ref is stale or belongs to another tab. Take a fresh snapshot and use its locator.",
112 { staleRef: true },
113 );
114 }
115 return page.locator(`aria-ref=${nativeRef}`);
116 }
117 // Playwright's strict locators reject ambiguous controls rather than acting on row one.
118 return page.locator(selector);
119};
120
121/** Shows the agent's pointer to viewers before an action lands at that point. */
122export type PointerReporter = (
123 point: { readonly x: number; readonly y: number },
124 phase: "move" | "click",
125) => Promise<void>;
126const noPointer: PointerReporter = async () => {};
127
128/** The viewport point an action will hit: the target's center after scrolling it into view. */
129const targetPoint = async (
130 page: Page,
131 locator: Locator | null,
132 input: { readonly x?: number | undefined; readonly y?: number | undefined },
133 timeout: number,
134) => {
135 if (locator === null) return { x: input.x ?? 0, y: input.y ?? 0 };
136 await locator.scrollIntoViewIfNeeded({ timeout });
137 const box = await locator.boundingBox({ timeout });
138 if (box) return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
139 const viewport = page.viewportSize();
140 return { x: (viewport?.width ?? 0) / 2, y: (viewport?.height ?? 0) / 2 };
141};
142
143const SNAPSHOT_SCRIPT = `(() => {
144 return {
145 url: location.href,
146 title: document.title,
147 loading: document.readyState !== "complete",
148 visibleText: (document.body?.innerText || "").slice(0, ${MAX_VISIBLE_TEXT_LENGTH}),
149 interactiveElements: [],
150 };
151})()`;
152
153// Scaled captures repaint live screencasts, so callers pause them. Clips use document offsets.
154export const captureViewport = async (
155 page: Page,
156 cdp: CDPSession,
157 options: { readonly format: "png" | "jpeg"; readonly quality?: number; readonly scale: number },
158) => {
159 let clip;
160 if (options.scale < 1) {
161 const viewport = page.viewportSize() ?? { width: 1280, height: 800 };
162 const { cssVisualViewport } = await cdp.send("Page.getLayoutMetrics");
163 clip = {
164 x: cssVisualViewport.pageX,
165 y: cssVisualViewport.pageY,
166 ...viewport,
167 scale: options.scale,
168 };
169 }
170 const { data } = await cdp.send("Page.captureScreenshot", {
171 format: options.format,
172 ...(options.quality === undefined ? {} : { quality: options.quality }),
173 ...(clip ? { clip } : {}),
174 });
175 return data;
176};
177
178export const snapshot = async (input: {
179 readonly page: Page;
180 readonly cdp: CDPSession;
181 readonly renderScale: number;
182 readonly consoleEntries: ReadonlyArray<PreviewAutomationConsoleEntry>;
183 readonly networkEntries: ReadonlyArray<PreviewAutomationNetworkEntry>;
184 readonly actionTimeline: PreviewAutomationSnapshot["actionTimeline"];
185}): Promise<PreviewAutomationSnapshot> => {
186 const viewport = input.page.viewportSize() ?? { width: 1280, height: 800 };
187 const scale = Math.min(1, MAX_SCREENSHOT_WIDTH / (viewport.width * input.renderScale));
188 const state = refsFor(input.page);
189 invalidateRefs(input.page);
190 const generation = state.generation;
191 const [page, tree, data] = await Promise.all([
192 input.page.evaluate(SNAPSHOT_SCRIPT) as Promise<
193 Pick<
194 PreviewAutomationSnapshot,
195 "url" | "title" | "loading" | "visibleText" | "interactiveElements"
196 >
197 >,
198 input.page.ariaSnapshot({ mode: "ai", boxes: true, timeout: DEFAULT_TIMEOUT_MS }),
199 captureViewport(input.page, input.cdp, { format: "png", scale }),
200 ]);
201 if (state.generation !== generation) {
202 throw new ServerBrowserOperationError(
203 "PreviewAutomationExecutionError",
204 "The page changed while capturing its snapshot. Take another snapshot.",
205 );
206 }
207 const accessibilityTree = tree
208 .slice(0, MAX_VISIBLE_TEXT_LENGTH)
209 .replace(/\[ref=((?:f\d+)?e\d+)\]/g, (_match, nativeRef: string) => {
210 const ref = `t3-${generation}-${nativeRef}`;
211 state.refs.set(ref, nativeRef);
212 return `[ref=${ref}]`;
213 });
214 return {
215 ...page,
216 accessibilityTree,
217 consoleEntries: [...input.consoleEntries],
218 networkEntries: [...input.networkEntries],
219 actionTimeline: [...input.actionTimeline],
220 screenshot: {
221 mimeType: "image/png",
222 data,
223 width: Math.round(viewport.width * input.renderScale * scale),
224 height: Math.round(viewport.height * input.renderScale * scale),
225 },
226 };
227};
228
229/**
230 * A click whose handler opens a dialog does not finish until the dialog is
231 * resolved, so it returns as soon as the dialog opens; status then reports it.
232 */
233export const click = async (
234 page: Page,
235 input: PreviewAutomationClickInput,
236 pointer: PointerReporter = noPointer,
237): Promise<{ readonly x: number; readonly y: number }> => {
238 const timeout = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
239 const locator = targetLocator(page, input);
240 const point = await targetPoint(page, locator, input, timeout);
241 await pointer(point, "click");
242 const options = { button: input.button ?? "left", clickCount: input.clickCount ?? 1 } as const;
243 const clicked =
244 locator === null
245 ? page.mouse.click(point.x, point.y, options)
246 : locator.click({ ...options, timeout });
247 let onDialog = constVoid;
248 const dialogOpened = new Promise<"dialog">((resolve) => {
249 onDialog = () => resolve("dialog");
250 page.once("dialog", onDialog);
251 });
252 try {
253 if ((await Promise.race([clicked, dialogOpened])) === "dialog") void clicked.catch(constVoid);
254 } finally {
255 page.off("dialog", onDialog);
256 }
257 return point;
258};
259
260export const type = async (page: Page, input: PreviewAutomationTypeInput) => {
261 const timeout = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
262 const locator = targetLocator(page, input);
263 if (locator !== null && input.clear) {
264 await locator.fill(input.text, { timeout });
265 return;
266 }
267 // A shadow host also matches :focus; use its innermost focused descendant.
268 const target = locator ?? page.locator("*:focus").last();
269 const focused =
270 (locator !== null || (await target.count()) > 0) &&
271 (await target.evaluate(
272 (element) => {
273 // Like the desktop host: an enabled text control or contenteditable
274 // that actually takes focus. Anything else would swallow the text or
275 // send it to whichever field had focus before.
276 const control = element as unknown as {
277 readonly type?: string;
278 readonly disabled?: boolean;
279 readonly readOnly?: boolean;
280 readonly isContentEditable?: boolean;
281 readonly focus?: () => void;
282 };
283 const nonText = [
284 "button",
285 "checkbox",
286 "color",
287 "file",
288 "hidden",
289 "image",
290 "radio",
291 "range",
292 "reset",
293 "submit",
294 ];
295 const textControl =
296 element.tagName === "TEXTAREA" ||
297 (element.tagName === "INPUT" && !nonText.includes(control.type ?? "text"));
298 if (!(textControl || control.isContentEditable) || control.disabled || control.readOnly) {
299 return false;
300 }
301 control.focus?.();
302 const root = element.getRootNode() as { readonly activeElement?: typeof element | null };
303 const active = root.activeElement;
304 return active != null && (active === element || element.contains(active));
305 },
306 undefined,
307 { timeout },
308 ));
309 if (focused !== true) {
310 throw new ServerBrowserOperationError(
311 "PreviewAutomationTargetNotEditableError",
312 "The target is not an enabled text field, so no text was typed.",
313 );
314 }
315 if (input.clear) {
316 await page.keyboard.press("ControlOrMeta+A");
317 await page.keyboard.press("Delete");
318 }
319 await page.keyboard.insertText(input.text);
320};
321
322/** Hovering by locator moves the real mouse, so CSS :hover and pointer events both apply. */
323export const hover = async (
324 page: Page,
325 input: PreviewAutomationHoverInput,
326 pointer: PointerReporter = noPointer,
327) => {
328 const timeout = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
329 const locator = targetLocator(page, input);
330 await pointer(await targetPoint(page, locator, input, timeout), "move");
331 if (locator === null) {
332 await page.mouse.move(input.x ?? 0, input.y ?? 0);
333 return;
334 }
335 await locator.hover({ timeout });
336};
337
338export const select = async (
339 page: Page,
340 input: PreviewAutomationSelectInput,
341): Promise<PreviewAutomationSelectResult> => {
342 const locator = targetLocator(page, input)!;
343 const timeout = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
344 // Each entry matches an option by value first, then by its visible label.
345 const options = await locator.evaluate(
346 (element) =>
347 element.tagName === "SELECT"
348 ? Array.from(
349 (element as unknown as { options: ArrayLike<{ value: string; label: string }> })
350 .options,
351 ({ value, label }) => ({ value, label: label.trim() }),
352 )
353 : null,
354 undefined,
355 { timeout },
356 );
357 const values =
358 options &&
359 input.values.map(
360 (entry) =>
361 (
362 options.find((option) => option.value === entry) ??
363 options.find((option) => option.label === entry.trim())
364 )?.value ?? entry,
365 );
366 if (values === null)
367 throw new ServerBrowserOperationError(
368 "PreviewAutomationTargetNotEditableError",
369 "This element is not a <select>. Click a custom dropdown, then click its option.",
370 );
371 return { selected: await locator.selectOption(values, { timeout }) };
372};
373
374export const drag = async (
375 page: Page,
376 input: PreviewAutomationDragInput,
377 pointer: PointerReporter = noPointer,
378) => {
379 const timeout = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
380 const source = targetLocator(page, { locator: input.source })!;
381 const target = targetLocator(page, { locator: input.target })!;
382 await pointer(await targetPoint(page, source, {}, timeout), "move");
383 // The cursor travels with the drag; the page sees one continuous gesture.
384 const dropped = source.dragTo(target, { timeout });
385 const end = await target.boundingBox({ timeout }).catch(() => null);
386 if (end) await pointer({ x: end.x + end.width / 2, y: end.y + end.height / 2 }, "move");
387 await dropped;
388};
389
390/** Sets files on one file input; false when no locator or selector names one. */
391export const setInputFiles = async (page: Page, input: PreviewAutomationUploadInput) => {
392 const locator = targetLocator(page, input);
393 if (locator === null) return false;
394 await locator.setInputFiles([...input.paths], { timeout: input.timeoutMs ?? DEFAULT_TIMEOUT_MS });
395 return true;
396};
397
398export const press = async (page: Page, input: PreviewAutomationPressInput) => {
399 await page.keyboard.press([...(input.modifiers ?? []), input.key].join("+"));
400};
401
402export const scroll = async (page: Page, input: PreviewAutomationScrollInput) => {
403 const delta = [input.deltaX ?? 0, input.deltaY ?? 0] as const;
404 const locator = targetLocator(page, input);
405 if (locator === null) {
406 // Page-side code is passed as source: the server compiles without DOM types.
407 await page.evaluate(`scrollBy(${delta[0]}, ${delta[1]})`);
408 return;
409 }
410 await locator.evaluate((element, [x, y]) => element.scrollBy(x, y), delta);
411};
412
413export const evaluate = async (cdp: CDPSession, input: PreviewAutomationEvaluateInput) => {
414 const result = await cdp.send("Runtime.evaluate", {
415 expression: input.expression,
416 awaitPromise: input.awaitPromise ?? true,
417 returnByValue: input.returnByValue ?? true,
418 });
419 if (result.exceptionDetails) {
420 throw new ServerBrowserOperationError(
421 "PreviewAutomationExecutionError",
422 result.exceptionDetails.exception?.description ?? result.exceptionDetails.text,
423 );
424 }
425 const value =
426 "value" in result.result ? result.result.value : (result.result.description ?? null);
427 const actualBytes = Buffer.byteLength(JSON.stringify(value ?? null), "utf8");
428 if (actualBytes > MAX_EVALUATION_BYTES) {
429 throw new ServerBrowserOperationError(
430 "PreviewAutomationResultTooLargeError",
431 `Evaluation result is ${actualBytes} bytes; the limit is ${MAX_EVALUATION_BYTES}.`,
432 { maximumBytes: MAX_EVALUATION_BYTES },
433 );
434 }
435 return value;
436};
437
438export const waitFor = async (page: Page, input: PreviewAutomationWaitForInput) => {
439 const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
440 const deadline = Date.now() + timeoutMs;
441 const locator = targetLocator(page, input);
442 const { text, urlIncludes } = input;
443 const checks: Array<() => Promise<boolean>> = [];
444 // Like the desktop host: the selector must match an element, visible or not.
445 if (locator !== null) checks.push(async () => (await locator.count()) > 0);
446 if (text !== undefined) {
447 // A navigation can destroy the context mid-check; that counts as not yet.
448 checks.push(() =>
449 page
450 .evaluate(`(document.body?.innerText ?? "").includes(${JSON.stringify(text)})`)
451 .then((found) => found === true)
452 .catch(() => false),
453 );
454 }
455 if (urlIncludes !== undefined) checks.push(async () => page.url().includes(urlIncludes));
456 for (;;) {
457 const results = await Promise.all(checks.map((check) => check()));
458 if (results.every(Boolean)) return;
459 if (Date.now() >= deadline) {
460 throw new ServerBrowserOperationError(
461 "PreviewAutomationTimeoutError",
462 `Waited ${timeoutMs}ms without every condition holding.`,
463 );
464 }
465 await new Promise((resolve) => setTimeout(resolve, WAIT_POLL_MS));
466 }
467};
468
469const NET_ERROR_CODES: Readonly<Record<string, number>> = {
470 ERR_FAILED: -2,
471 ERR_TIMED_OUT: -7,
472 ERR_CONNECTION_CLOSED: -100,
473 ERR_CONNECTION_RESET: -101,
474 ERR_CONNECTION_REFUSED: -102,
475 ERR_NAME_NOT_RESOLVED: -105,
476 ERR_INTERNET_DISCONNECTED: -106,
477 ERR_ADDRESS_UNREACHABLE: -109,
478 ERR_CERT_AUTHORITY_INVALID: -202,
479 ERR_EMPTY_RESPONSE: -324,
480};
481
482export const parseNetError = (errorText: string) => {
483 const description = /ERR_[A-Z_]+/.exec(errorText)?.[0] ?? errorText;
484 return { description, code: NET_ERROR_CODES[description] ?? -2 };
485};
486
487// Browser-side encoding avoids ffmpeg. Resizes are letterboxed into the first frame;
488// the agent cursor is drawn here so it never changes the page under test.
489export const RECORDING_ENCODER_SCRIPT = `(() => {
490 const canvas = document.createElement("canvas");
491 const context = canvas.getContext("2d");
492 const chunks = [];
493 let recorder = null;
494 let started = null;
495 let stopped = null;
496 let sizeBytes = 0;
497 let tooLarge = false;
498 let frame = null;
499 let cursor = null;
500 let ring = null;
501 const AGENT_CURSOR_PATH = new Path2D("M4.037 4.688a.495.495 0 0 1 .651-.651l16 6.5a.5.5 0 0 1-.063.947l-6.124 1.58a2 2 0 0 0-1.438 1.435l-1.579 6.126a.5.5 0 0 1-.947.063z");
502 let ringTimer = null;
503 let scale = 1;
504 let offsetX = 0;
505 let offsetY = 0;
506 const paint = () => {
507 if (!frame) return;
508 const fit = Math.min(canvas.width / frame.width, canvas.height / frame.height);
509 const width = frame.width * fit;
510 const height = frame.height * fit;
511 offsetX = (canvas.width - width) / 2;
512 offsetY = (canvas.height - height) / 2;
513 if (width < canvas.width || height < canvas.height) {
514 context.fillStyle = "#000";
515 context.fillRect(0, 0, canvas.width, canvas.height);
516 }
517 context.drawImage(frame, offsetX, offsetY, width, height);
518 scale = (width / frame.width) * frame.cssScale;
519 if (ring) {
520 const age = (performance.now() - ring.at) / 400;
521 if (age < 1) {
522 context.beginPath();
523 context.arc(offsetX + ring.x * scale, offsetY + ring.y * scale, (8 + 18 * age) * scale, 0, Math.PI * 2);
524 context.strokeStyle = "rgba(59,130,246," + (0.9 * (1 - age)) + ")";
525 context.lineWidth = 3 * scale;
526 context.stroke();
527 }
528 }
529 if (cursor) {
530 // The agent cursor (lucide MousePointer2, as in the live panel), never the human arrow.
531 const s = 20 / 24 * scale;
532 context.save();
533 context.translate(offsetX + cursor.x * scale - 2 * scale, offsetY + cursor.y * scale - 2 * scale);
534 context.scale(s, s);
535 context.lineWidth = 2;
536 context.lineJoin = "round";
537 context.lineCap = "round";
538 context.fillStyle = "#fff";
539 context.strokeStyle = "#2563eb";
540 context.shadowColor = "rgba(0,0,0,0.25)";
541 context.shadowBlur = 2;
542 context.fill(AGENT_CURSOR_PATH);
543 context.shadowColor = "transparent";
544 context.stroke(AGENT_CURSOR_PATH);
545 context.restore();
546 }
547 };
548 window.__t3Recorder = {
549 async frame(base64, cssWidth) {
550 if (tooLarge) return false;
551 const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
552 const bitmap = await createImageBitmap(new Blob([bytes], { type: "image/jpeg" }));
553 if (tooLarge) { bitmap.close(); return false; }
554 if (!recorder) {
555 canvas.width = bitmap.width;
556 canvas.height = bitmap.height;
557 const mimeType = ["video/mp4;codecs=avc1.640033", "video/webm;codecs=vp9", "video/webm"].find((type) => MediaRecorder.isTypeSupported(type));
558 const bitsPerSecond = Math.min(50e6, Math.max(2.5e6, bitmap.width * bitmap.height * 30 * 0.05));
559 recorder = new MediaRecorder(canvas.captureStream(30), { mimeType, videoBitsPerSecond: bitsPerSecond });
560 started = new Promise((resolve, reject) => {
561 recorder.onstart = resolve;
562 recorder.onerror = (event) => reject(event.error);
563 });
564 stopped = new Promise((resolve) => { recorder.onstop = resolve; });
565 recorder.ondataavailable = (event) => {
566 if (tooLarge || event.data.size === 0) return;
567 sizeBytes += event.data.size;
568 if (sizeBytes > ${PROVIDER_SEND_TURN_MAX_FILE_BYTES}) {
569 tooLarge = true;
570 chunks.length = 0;
571 clearInterval(ringTimer);
572 frame?.close();
573 frame = null;
574 if (recorder.state !== "inactive") recorder.stop();
575 for (const track of recorder.stream.getTracks()) track.stop();
576 return;
577 }
578 chunks.push(event.data);
579 };
580 recorder.start(1000);
581 }
582 // Frame px per page CSS px, before fitting into the canvas.
583 bitmap.cssScale = bitmap.width / cssWidth;
584 frame?.close();
585 frame = bitmap;
586 paint();
587 await started;
588 return true;
589 },
590 cursor(x, y, click) {
591 if (tooLarge) return;
592 cursor = { x, y };
593 if (click) {
594 ring = { x, y, at: performance.now() };
595 clearInterval(ringTimer);
596 ringTimer = setInterval(() => {
597 paint();
598 if (performance.now() - ring.at > 400) { ring = null; clearInterval(ringTimer); paint(); }
599 }, 33);
600 }
601 paint();
602 },
603 async stop() {
604 if (!recorder) return { mimeType: null, count: 0, bytes: 0 };
605 if (recorder.state !== "inactive") {
606 // Consume the cached frame, then wait for the final paint to reach the
607 // capture stream. Animation ticks alone can stop before it is delivered.
608 const video = document.createElement("video");
609 video.muted = true;
610 video.srcObject = recorder.stream;
611 const nextFrame = () => Promise.race([
612 new Promise(resolve => video.requestVideoFrameCallback(resolve)),
613 stopped,
614 ]);
615 const initial = nextFrame();
616 void video.play().catch(() => {});
617 await initial;
618 if (recorder.state !== "inactive") {
619 const delivered = nextFrame();
620 recorder.stream.getVideoTracks()[0].requestFrame();
621 paint();
622 await delivered;
623 }
624 video.pause();
625 video.srcObject = null;
626 if (recorder.state !== "inactive") recorder.stop();
627 }
628 await stopped;
629 return { mimeType: recorder.mimeType, count: chunks.length, bytes: sizeBytes };
630 },
631 async chunk(index) {
632 const bytes = new Uint8Array(await chunks[index].arrayBuffer());
633 let binary = "";
634 for (let offset = 0; offset < bytes.length; offset += 0x8000) {
635 binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
636 }
637 return btoa(binary);
638 },
639 };
640})()`;
apps/server/src/preview/ServerBrowserStream.test.ts created+358
...@@ -0,0 +1,358 @@
1import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform";
2import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { expect, it } from "@effect/vitest";
5import {
6 AuthOrchestrationOperateScope,
7 AuthOrchestrationReadScope,
8 AuthSessionId,
9 PREVIEW_STREAM_HOST_SETUP_CLOSE_CODE,
10 PreviewStreamHostSetup,
11 type AuthEnvironmentScope,
12} from "@t3tools/contracts";
13import * as Context from "effect/Context";
14import * as Effect from "effect/Effect";
15import * as FileSystem from "effect/FileSystem";
16import * as Layer from "effect/Layer";
17import * as Option from "effect/Option";
18import * as Queue from "effect/Queue";
19import * as Schema from "effect/Schema";
20import { HttpRouter, HttpServer } from "effect/http";
21
22import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
23import * as PreviewBrowserHost from "./PreviewBrowserHost.ts";
24import * as ServerBrowser from "./ServerBrowser.ts";
25import { routeLayer } from "./ServerBrowserStream.ts";
26
27const platformLayer = NodeHttpPlatform.layer.pipe(Layer.provideMerge(NodeServices.layer));
28
29const makeAuth = (
30 scopes: ReadonlyArray<AuthEnvironmentScope>,
31 error?: EnvironmentAuth.ServerAuthCredentialError,
32) => {
33 const requests: string[] = [];
34 const layer = Layer.mock(EnvironmentAuth.EnvironmentAuth, {
35 authenticateWebSocketUpgrade: (request) => {
36 requests.push(request.originalUrl);
37 return error
38 ? Effect.fail(error)
39 : Effect.succeed({
40 sessionId: AuthSessionId.make("stream-test"),
41 subject: "stream-test",
42 method: "bearer-access-token",
43 scopes,
44 });
45 },
46 });
47 return { layer, requests };
48};
49
50const mutations = [
51 { type: "resize", width: 390, height: 844 },
52 { type: "mouse", action: "down", x: 10, y: 10, button: "left", buttons: 1 },
53 { type: "key", action: "down", key: "Enter" },
54 { type: "text", text: "reader must not type" },
55 { type: "wheel", deltaY: 100 },
56 { type: "navigate", url: "https://example.com" },
57 { type: "history", delta: -1 },
58 { type: "reload" },
59 { type: "probe", x: 10, y: 10 },
60 { type: "takeControl" },
61 { type: "releaseControl" },
62 { type: "dialog", accept: true },
63 { type: "viewport", setting: { _tag: "fill" } },
64];
65
66it.effect.each([
67 { hasOperateScope: false, interactive: true },
68 { hasOperateScope: true, interactive: true },
69 { hasOperateScope: true, interactive: false },
70])("streams frames and acks while gating page mutations (%s)", ({ hasOperateScope, interactive }) =>
71 Effect.gen(function* () {
72 const canOperate = hasOperateScope && interactive;
73 const scopes = hasOperateScope
74 ? [AuthOrchestrationReadScope, AuthOrchestrationOperateScope]
75 : [AuthOrchestrationReadScope];
76 const auth = makeAuth(scopes);
77 const inputs: unknown[] = [];
78 const attachments: Parameters<ServerBrowser.ServerBrowser["Service"]["attachViewer"]>[0][] = [];
79 const acked = Promise.withResolvers<void>();
80 const frame = new Uint8Array([255, 216, 255, 217]);
81 const output = yield* Queue.make<ServerBrowser.ServerBrowserViewerOutput>();
82 yield* Queue.offer(output, { _tag: "viewport", width: 1280, height: 800 });
83 yield* Queue.offer(output, {
84 _tag: "frame",
85 data: frame,
86 ack: Effect.sync(() => acked.resolve()),
87 });
88 const browser = ServerBrowser.ServerBrowser.of({
89 clearProfile: () => Effect.void,
90 openDownload: () => Effect.succeedNone,
91 answerFileChooser: () => Effect.succeed(false),
92 attachViewer: (input) =>
93 Effect.sync(() => {
94 attachments.push(input);
95 return {
96 output,
97 input: (message) => Effect.sync(() => void inputs.push(message)),
98 };
99 }),
100 });
101 const services = yield* Layer.build(
102 HttpRouter.serve(
103 routeLayer.pipe(
104 Layer.provide(Layer.succeed(ServerBrowser.ServerBrowser, browser)),
105 Layer.provide(platformLayer),
106 ),
107 { disableListenLog: true },
108 ).pipe(Layer.provideMerge(NodeHttpServer.layerTest), Layer.provide(auth.layer)),
109 );
110 const server = Context.get(services, HttpServer.HttpServer);
111 const origin = HttpServer.formatAddress(server.address).replace(/^http/, "ws");
112 const resource = `/api/preview-stream/ws?threadId=thread&tabId=tab&wsTicket=one-use-ticket${interactive ? "" : "&interactive=false"}`;
113 const received = Promise.withResolvers<void>();
114 const socket = yield* Effect.acquireRelease(
115 Effect.sync(() => new WebSocket(`${origin}${resource}`)),
116 (socket) => Effect.sync(() => socket.close()),
117 );
118 socket.binaryType = "arraybuffer";
119 const viewports: unknown[] = [];
120 const frames: Uint8Array[] = [];
121 socket.addEventListener("error", () => received.reject(new Error("stream failed")));
122 socket.addEventListener("message", (event) => {
123 if (typeof event.data === "string") {
124 viewports.push(JSON.parse(event.data));
125 return;
126 }
127 frames.push(new Uint8Array(event.data as ArrayBuffer));
128 for (const message of mutations) socket.send(JSON.stringify(message));
129 socket.send("malformed input");
130 socket.send(JSON.stringify({ type: "ack" }));
131 received.resolve();
132 });
133 yield* Effect.promise(() => received.promise);
134 // The ack follows every mutation on the socket, so this is also a barrier
135 // proving all preceding inputs were processed, without a timing sleep.
136 yield* Effect.promise(() => acked.promise);
137 expect(frames).toEqual([frame]);
138 expect(viewports).toEqual([{ type: "viewport", width: 1280, height: 800 }]);
139 expect(inputs).toEqual(canOperate ? [...mutations, null] : []);
140 expect(attachments).toEqual([
141 {
142 threadId: "thread",
143 tabId: "tab",
144 maxWidth: 1280,
145 maxHeight: 800,
146 quality: 70,
147 canOperate,
148 },
149 ]);
150 // In particular, a one-use ticket must never be authenticated a second
151 // time to discover whether this read session also has operate scope.
152 expect(auth.requests).toEqual([resource]);
153 }).pipe(Effect.scoped),
154);
155
156it.effect.each([
157 { scopes: [], error: undefined, status: 403 },
158 { scopes: [AuthOrchestrationOperateScope], error: undefined, status: 403 },
159 { scopes: [], error: new EnvironmentAuth.ServerAuthMissingCredentialError({}), status: 401 },
160])("rejects unauthorized stream connections before attaching a viewer (%s)", (testCase) =>
161 Effect.gen(function* () {
162 const auth = makeAuth(testCase.scopes, testCase.error);
163 let attachments = 0;
164 const browser = ServerBrowser.ServerBrowser.of({
165 clearProfile: () => Effect.void,
166 openDownload: () => Effect.succeedNone,
167 answerFileChooser: () => Effect.succeed(false),
168 attachViewer: () => {
169 attachments++;
170 return Effect.die("unauthorized viewer must not attach");
171 },
172 });
173 const handler = yield* Effect.acquireRelease(
174 Effect.sync(() =>
175 HttpRouter.toWebHandler(
176 routeLayer.pipe(
177 Layer.provide(Layer.succeed(ServerBrowser.ServerBrowser, browser)),
178 Layer.provide(platformLayer),
179 Layer.provideMerge(auth.layer),
180 ),
181 { disableLogger: true },
182 ),
183 ),
184 ({ dispose }) => Effect.promise(dispose),
185 );
186 const response = yield* Effect.promise(() =>
187 handler.handler(
188 new Request("http://t3.test/api/preview-stream/ws?threadId=thread&tabId=tab", {
189 headers: { upgrade: "websocket" },
190 }),
191 ),
192 );
193 expect(response.status).toBe(testCase.status);
194 expect(attachments).toBe(0);
195 }).pipe(Effect.scoped),
196);
197
198it.effect("serves a tab's download only to an authorized session", () =>
199 Effect.gen(function* () {
200 const fs = yield* FileSystem.FileSystem;
201 const path = `${yield* fs.makeTempDirectoryScoped({ prefix: "t3-download-" })}/file`;
202 yield* fs.writeFileString(path, "report contents");
203 const requests: Array<unknown> = [];
204 const browser = ServerBrowser.ServerBrowser.of({
205 clearProfile: () => Effect.void,
206 openDownload: (input) =>
207 Effect.sync(() => {
208 requests.push(input);
209 return input.downloadId === "download-1"
210 ? Option.some({ path, fileName: "Q3 report.csv" })
211 : Option.none();
212 }),
213 answerFileChooser: () => Effect.succeed(false),
214 attachViewer: () => Effect.die("unused"),
215 });
216 const serve = (scopes: ReadonlyArray<AuthEnvironmentScope>, url: string) =>
217 Effect.gen(function* () {
218 const handler = yield* Effect.acquireRelease(
219 Effect.sync(() =>
220 HttpRouter.toWebHandler(
221 routeLayer.pipe(
222 Layer.provide(Layer.succeed(ServerBrowser.ServerBrowser, browser)),
223 Layer.provide(platformLayer),
224 Layer.provideMerge(makeAuth(scopes, undefined).layer),
225 ),
226 { disableLogger: true },
227 ),
228 ),
229 ({ dispose }) => Effect.promise(dispose),
230 );
231 return yield* Effect.promise(() => handler.handler(new Request(url)));
232 });
233 const base = "http://t3.test/api/preview-stream/download?threadId=thread&tabId=tab";
234 const denied = yield* serve([], `${base}&id=download-1`);
235 expect(denied.status).toBe(403);
236 expect(requests).toEqual([]);
237 const response = yield* serve([AuthOrchestrationReadScope], `${base}&id=download-1`);
238 expect(response.status).toBe(200);
239 expect(response.headers.get("content-disposition")).toBe(
240 'attachment; filename="Q3 report.csv"',
241 );
242 expect(yield* Effect.promise(() => response.text())).toBe("report contents");
243 const missing = yield* serve([AuthOrchestrationReadScope], `${base}&id=other`);
244 expect(missing.status).toBe(404);
245 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
246);
247
248it.effect("passes uploaded files to the page's open picker and needs operate scope", () =>
249 Effect.gen(function* () {
250 const answers: Array<{ chooserId: string; files: Array<{ name: string; text: string }> }> = [];
251 const browser = ServerBrowser.ServerBrowser.of({
252 clearProfile: () => Effect.void,
253 openDownload: () => Effect.succeedNone,
254 answerFileChooser: (input) =>
255 Effect.sync(() => {
256 answers.push({
257 chooserId: input.chooserId,
258 files: input.files.map((file) => ({ name: file.name, text: file.buffer.toString() })),
259 });
260 return input.chooserId === "chooser-1";
261 }),
262 attachViewer: () => Effect.die("unused"),
263 });
264 const upload = (scopes: ReadonlyArray<AuthEnvironmentScope>, chooser: string) =>
265 Effect.gen(function* () {
266 const handler = yield* Effect.acquireRelease(
267 Effect.sync(() =>
268 HttpRouter.toWebHandler(
269 routeLayer.pipe(
270 Layer.provide(Layer.succeed(ServerBrowser.ServerBrowser, browser)),
271 Layer.provide(platformLayer),
272 Layer.provideMerge(makeAuth(scopes, undefined).layer),
273 ),
274 { disableLogger: true },
275 ),
276 ),
277 ({ dispose }) => Effect.promise(dispose),
278 );
279 const body = new FormData();
280 body.append("file", new File(["hello"], "notes.txt", { type: "text/plain" }));
281 return yield* Effect.promise(() =>
282 handler.handler(
283 new Request(
284 `http://t3.test/api/preview-stream/upload?threadId=thread&tabId=tab&chooser=${chooser}`,
285 { method: "POST", body },
286 ),
287 ),
288 );
289 });
290 expect((yield* upload([AuthOrchestrationReadScope], "chooser-1")).status).toBe(403);
291 expect(answers).toEqual([]);
292 expect((yield* upload([AuthOrchestrationOperateScope], "chooser-1")).status).toBe(204);
293 expect(answers).toEqual([
294 { chooserId: "chooser-1", files: [{ name: "notes.txt", text: "hello" }] },
295 ]);
296 expect((yield* upload([AuthOrchestrationOperateScope], "stale")).status).toBe(409);
297 }).pipe(Effect.scoped),
298);
299
300const decodeHostSetup = Schema.decodeUnknownEffect(Schema.fromJsonString(PreviewStreamHostSetup));
301
302it.effect.each([
303 {
304 error: new PreviewBrowserHost.PreviewBrowserSandboxError({
305 setupCommand: "sudo t3 browser setup",
306 }),
307 need: "sandbox",
308 },
309 {
310 error: new PreviewBrowserHost.PreviewBrowserLibrariesError({
311 setupCommand: "sudo t3 browser setup",
312 libraries: ["libnss3.so"],
313 }),
314 need: "libraries",
315 },
316])("tells viewers the command that sets up the host ($need)", ({ error, need }) =>
317 Effect.gen(function* () {
318 const browser = ServerBrowser.ServerBrowser.of({
319 clearProfile: () => Effect.void,
320 openDownload: () => Effect.succeedNone,
321 answerFileChooser: () => Effect.succeed(false),
322 attachViewer: () => Effect.fail(new ServerBrowser.ServerBrowserLaunchError({ cause: error })),
323 });
324 const services = yield* Layer.build(
325 HttpRouter.serve(
326 routeLayer.pipe(
327 Layer.provide(Layer.succeed(ServerBrowser.ServerBrowser, browser)),
328 Layer.provide(platformLayer),
329 ),
330 { disableListenLog: true },
331 ).pipe(
332 Layer.provideMerge(NodeHttpServer.layerTest),
333 Layer.provide(makeAuth([AuthOrchestrationReadScope]).layer),
334 ),
335 );
336 const server = Context.get(services, HttpServer.HttpServer);
337 const origin = HttpServer.formatAddress(server.address).replace(/^http/, "ws");
338 const closed = Promise.withResolvers<{ code: number; reason: string }>();
339 yield* Effect.acquireRelease(
340 Effect.sync(() => {
341 const socket = new WebSocket(
342 `${origin}/api/preview-stream/ws?threadId=thread&tabId=tab&wsTicket=one-use-ticket`,
343 );
344 socket.addEventListener("close", (event) =>
345 closed.resolve({ code: event.code, reason: event.reason }),
346 );
347 return socket;
348 }),
349 (socket) => Effect.sync(() => socket.close()),
350 );
351 const { code, reason } = yield* Effect.promise(() => closed.promise);
352 expect(code).toBe(PREVIEW_STREAM_HOST_SETUP_CLOSE_CODE);
353 expect(yield* decodeHostSetup(reason)).toEqual({
354 need,
355 command: "sudo t3 browser setup",
356 });
357 }).pipe(Effect.scoped),
358);
apps/server/src/preview/ServerBrowserStream.ts created+287
...@@ -0,0 +1,287 @@
1import * as NodeHttpServerRequest from "@effect/platform-node/NodeHttpServerRequest";
2import {
3 AuthOrchestrationOperateScope,
4 AuthOrchestrationReadScope,
5 PREVIEW_STREAM_HOST_SETUP_CLOSE_CODE,
6 PreviewStreamHostSetup,
7} from "@t3tools/contracts";
8import * as Effect from "effect/Effect";
9import * as Option from "effect/Option";
10import * as Queue from "effect/Queue";
11import * as Schema from "effect/Schema";
12import * as FileSystem from "effect/FileSystem";
13import * as Path from "effect/Path";
14import {
15 HttpPlatform,
16 HttpRouter,
17 HttpServerRequest,
18 HttpServerResponse,
19 Multipart,
20} from "effect/http";
21import * as Socket from "effect/socket/Socket";
22
23import { authenticateMediaRequest } from "../auth/http.ts";
24import { assetResponseHeaders } from "../http.ts";
25import * as PreviewBrowserHost from "./PreviewBrowserHost.ts";
26import * as ServerBrowser from "./ServerBrowser.ts";
27
28const PREVIEW_STREAM_ROUTE_PREFIX = "/api/preview-stream";
29/** Matches `PREVIEW_STREAM_TAB_GONE_CODE` in the client. */
30const TAB_GONE_CODE = 4404;
31
32const DEFAULT_QUALITY = 70;
33const MAX_SOCKET_BUFFER_BYTES = 8 * 1024 * 1024;
34const MAX_UNACKNOWLEDGED_FRAMES = 64;
35const textDecoder = new TextDecoder();
36
37const intParam = (params: URLSearchParams, name: string, fallback: number, max: number) => {
38 const value = Number(params.get(name));
39 return Number.isFinite(value) && value > 0 ? Math.min(Math.round(value), max) : fallback;
40};
41
42const isAck = (message: unknown) =>
43 typeof message === "object" && message !== null && "type" in message && message.type === "ack";
44
45const parseMessage = (chunk: Uint8Array | string): unknown => {
46 try {
47 return JSON.parse(typeof chunk === "string" ? chunk : textDecoder.decode(chunk));
48 } catch {
49 return null;
50 }
51};
52
53const makeHandler = (browser: ServerBrowser.ServerBrowser["Service"]) =>
54 Effect.gen(function* () {
55 const request = yield* HttpServerRequest.HttpServerRequest;
56 const url = HttpServerRequest.toURL(request);
57 if (Option.isNone(url)) return HttpServerResponse.text("Bad Request", { status: 400 });
58 if (url.value.pathname === `${PREVIEW_STREAM_ROUTE_PREFIX}/download`) {
59 return yield* serveDownload(browser, url.value.searchParams);
60 }
61 if (url.value.pathname === `${PREVIEW_STREAM_ROUTE_PREFIX}/upload`) {
62 return request.method === "POST"
63 ? yield* receiveUpload(browser, url.value.searchParams)
64 : HttpServerResponse.text("Method Not Allowed", { status: 405 });
65 }
66 if (
67 url.value.pathname !== `${PREVIEW_STREAM_ROUTE_PREFIX}/ws` ||
68 request.headers.upgrade?.toLowerCase() !== "websocket"
69 ) {
70 return HttpServerResponse.text("Not Found", { status: 404 });
71 }
72 const session = yield* authenticateMediaRequest(AuthOrchestrationReadScope);
73 const params = url.value.searchParams;
74 const canOperate =
75 session.scopes.includes(AuthOrchestrationOperateScope) &&
76 params.get("interactive") !== "false";
77 const threadId = params.get("threadId") ?? "";
78 const tabId = params.get("tabId") ?? "";
79 if (threadId.length === 0 || tabId.length === 0) {
80 return HttpServerResponse.text("Not Found", { status: 404 });
81 }
82 return yield* Effect.scoped(
83 Effect.gen(function* () {
84 const attached = yield* browser
85 .attachViewer({
86 threadId,
87 tabId,
88 canOperate,
89 maxWidth: intParam(params, "maxWidth", 1280, 7680),
90 maxHeight: intParam(params, "maxHeight", 800, 4320),
91 quality: intParam(params, "quality", DEFAULT_QUALITY, 100),
92 })
93 .pipe(
94 Effect.map((viewer) => ({ _tag: "attached" as const, viewer })),
95 Effect.catchTags({
96 ServerBrowserTabNotFoundError: () => Effect.succeed({ _tag: "gone" as const }),
97 ServerBrowserLaunchError: (error) => {
98 const setup = hostSetup(error.cause);
99 return setup === undefined
100 ? Effect.fail(error)
101 : Effect.succeed({ _tag: "hostSetup" as const, reason: encodeHostSetup(setup) });
102 },
103 }),
104 );
105 const incoming = NodeHttpServerRequest.toIncomingMessage(request);
106 // JPEGs are already compressed. Disabling deflate also keeps all
107 // pending writes in the socket buffer we bound below, not a zlib queue.
108 delete incoming.headers["sec-websocket-extensions"];
109 const transport = incoming.socket;
110 const socket = yield* request.upgrade;
111 // The reader performs the upgrade; writes wait for it.
112 const reader = yield* socket.reader;
113 const writer = yield* socket.writer;
114 // A refused upgrade reads as an auth failure to ticket clients, so a
115 // missing tab is a close code they stop on.
116 const gone = writer.write(new Socket.CloseEvent(TAB_GONE_CODE, "tab closed"));
117 if (attached._tag === "gone") {
118 yield* gone;
119 return HttpServerResponse.empty();
120 }
121 if (attached._tag === "hostSetup") {
122 yield* writer.write(
123 new Socket.CloseEvent(PREVIEW_STREAM_HOST_SETUP_CLOSE_CODE, attached.reason),
124 );
125 return HttpServerResponse.empty();
126 }
127 const viewer = attached.viewer;
128 // `write` returns once the frame is queued, so Chromium's ack for each
129 // frame waits for the viewer's `ack` message instead.
130 const unacknowledged: Array<Effect.Effect<void>> = [];
131 const disconnectSlowViewer = Effect.sync(() => transport.destroy()).pipe(
132 Effect.andThen(Effect.interrupt),
133 );
134 const write = (data: Uint8Array | string) =>
135 Effect.suspend(() => {
136 const bytes = typeof data === "string" ? Buffer.byteLength(data) : data.byteLength;
137 // Include the WebSocket frame header in the budget.
138 if (transport.writableLength + bytes + 14 > MAX_SOCKET_BUFFER_BYTES) {
139 return disconnectSlowViewer;
140 }
141 return writer.write(data);
142 });
143 const sendOutput = Queue.take(viewer.output).pipe(
144 Effect.flatMap((output) => {
145 switch (output._tag) {
146 case "frame":
147 if (unacknowledged.length >= MAX_UNACKNOWLEDGED_FRAMES) {
148 return disconnectSlowViewer;
149 }
150 return write(output.data).pipe(
151 Effect.andThen(Effect.sync(() => unacknowledged.push(output.ack))),
152 );
153 case "viewport":
154 case "control":
155 case "clipboard":
156 case "download":
157 case "fileChooser":
158 case "fileChooserClosed":
159 case "pointer":
160 case "probe": {
161 const { _tag: type, ...data } = output;
162 return write(JSON.stringify({ type, ...data }));
163 }
164 case "gone":
165 return gone.pipe(Effect.andThen(Effect.interrupt));
166 case "reconnect":
167 return writer
168 .write(new Socket.CloseEvent(1012, "reconnect"))
169 .pipe(Effect.andThen(Effect.interrupt));
170 }
171 }),
172 );
173 const receive = (chunk: Uint8Array | string) => {
174 const message = parseMessage(chunk);
175 // Ownership serializes actions in the service. Keep reading so dialog
176 // replies and takeover can unblock an action already waiting on the page.
177 if (!isAck(message))
178 return canOperate
179 ? viewer.input(message).pipe(Effect.forkScoped, Effect.asVoid)
180 : Effect.void;
181 const ack = unacknowledged.shift();
182 // Forked: Chromium acks are paced and must not hold up input.
183 return ack ? Effect.forkScoped(ack).pipe(Effect.asVoid) : Effect.void;
184 };
185 const receiveInput = reader.pull.pipe(
186 Effect.flatMap((chunks) => Effect.forEach(chunks, receive, { discard: true })),
187 );
188 return yield* Effect.raceFirst(Effect.forever(sendOutput), Effect.forever(receiveInput));
189 }),
190 ).pipe(
191 Effect.catchTags({
192 ServerBrowserLaunchError: (error) =>
193 Effect.logWarning("server preview browser failed to start", { cause: error.cause }).pipe(
194 Effect.as(HttpServerResponse.text("Service Unavailable", { status: 503 })),
195 ),
196 }),
197 // A dropped socket is a normal end of viewing.
198 Effect.catch(() => Effect.succeed(HttpServerResponse.empty())),
199 );
200 });
201
202/** Which host setup a launch failure is missing, and the command that fixes it. */
203const hostSetup = (cause: unknown): PreviewStreamHostSetup | undefined =>
204 isSandboxError(cause)
205 ? { need: "sandbox", command: cause.setupCommand }
206 : isLibrariesError(cause)
207 ? { need: "libraries", command: cause.setupCommand }
208 : undefined;
209const isSandboxError = Schema.is(PreviewBrowserHost.PreviewBrowserSandboxError);
210const isLibrariesError = Schema.is(PreviewBrowserHost.PreviewBrowserLibrariesError);
211const encodeHostSetup = Schema.encodeSync(Schema.fromJsonString(PreviewStreamHostSetup));
212
213/** `GET /api/preview-stream/download?threadId&tabId&id`: a file a server tab downloaded. */
214const serveDownload = (browser: ServerBrowser.ServerBrowser["Service"], params: URLSearchParams) =>
215 Effect.gen(function* () {
216 yield* authenticateMediaRequest(AuthOrchestrationReadScope);
217 const download = yield* browser.openDownload({
218 threadId: params.get("threadId") ?? "",
219 tabId: params.get("tabId") ?? "",
220 downloadId: params.get("id") ?? "",
221 });
222 if (Option.isNone(download)) return HttpServerResponse.text("Not Found", { status: 404 });
223 return yield* HttpServerResponse.file(download.value.path, {
224 headers: assetResponseHeaders(download.value.path, {
225 download: true,
226 fileName: download.value.fileName,
227 }),
228 });
229 });
230
231const UPLOAD_MAX_FILE_BYTES = 100 * 1024 * 1024;
232const UPLOAD_MAX_FILES = 20;
233
234/**
235 * `POST /api/preview-stream/upload?threadId&tabId&chooser` with multipart `file`
236 * parts: answers the page's open file picker. No parts cancels it.
237 */
238const receiveUpload = (browser: ServerBrowser.ServerBrowser["Service"], params: URLSearchParams) =>
239 Effect.gen(function* () {
240 yield* authenticateMediaRequest(AuthOrchestrationOperateScope);
241 const request = yield* HttpServerRequest.HttpServerRequest;
242 const fs = yield* FileSystem.FileSystem;
243 const parts = yield* request.multipart.pipe(
244 Effect.provideContext(
245 Multipart.limitsServices({
246 maxParts: UPLOAD_MAX_FILES,
247 maxFileSize: UPLOAD_MAX_FILE_BYTES,
248 maxTotalSize: UPLOAD_MAX_FILE_BYTES,
249 }),
250 ),
251 );
252 const files = yield* Effect.forEach(
253 Object.values(parts).flatMap((value) =>
254 Array.isArray(value) ? value.filter(Multipart.isPersistedFile) : [],
255 ),
256 (file) =>
257 fs.readFile(file.path).pipe(
258 Effect.map((bytes) => ({
259 name: file.name,
260 mimeType: file.contentType,
261 buffer: Buffer.from(bytes),
262 })),
263 ),
264 );
265 const answered = yield* browser.answerFileChooser({
266 threadId: params.get("threadId") ?? "",
267 tabId: params.get("tabId") ?? "",
268 chooserId: params.get("chooser") ?? "",
269 files,
270 });
271 return answered
272 ? HttpServerResponse.empty({ status: 204 })
273 : HttpServerResponse.text("The page's file picker is no longer open.", { status: 409 });
274 }).pipe(Effect.scoped);
275
276// Capture the browser because handlers only see request-scoped services.
277export const routeLayer = HttpRouter.use((router) =>
278 Effect.gen(function* () {
279 const browser = yield* ServerBrowser.ServerBrowser;
280 const platform = yield* Effect.context<
281 HttpPlatform.HttpPlatform | FileSystem.FileSystem | Path.Path
282 >();
283 const handler = makeHandler(browser).pipe(Effect.provideContext(platform));
284 yield* router.add("GET", `${PREVIEW_STREAM_ROUTE_PREFIX}/*`, handler);
285 yield* router.add("POST", `${PREVIEW_STREAM_ROUTE_PREFIX}/upload`, handler);
286 }),
287);
apps/server/src/preview/SessionControl.test.ts created+169
...@@ -0,0 +1,169 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { BrowserControlInterrupted, SessionControl } from "./SessionControl.ts";
4
5describe("SessionControl", () => {
6 it("returns an action result before tracked navigation but drains it before already queued actions", async () => {
7 const control = new SessionControl("agent");
8 const committed = Promise.withResolvers<void>();
9 const events: string[] = [];
10 const navigation = control.agent("agent", async () => {
11 control.track(committed.promise.then(() => events.push("navigation committed")));
12 return "started";
13 });
14 const next = control.agent("agent", async () => events.push("next action"));
15 await expect(navigation).resolves.toBe("started");
16 expect(events).toEqual([]);
17 committed.resolve();
18 await next;
19 expect(events).toEqual(["navigation committed", "next action"]);
20 });
21
22 it("handles tracked navigation failures without poisoning the next action", async () => {
23 const control = new SessionControl("agent");
24 const committed = Promise.withResolvers<void>();
25 await control.agent("agent", async () => control.track(committed.promise));
26 committed.reject(new Error("navigation failed"));
27 await expect(control.agent("agent", async () => "resumed")).resolves.toBe("resumed");
28 });
29
30 it("only lets the assigned agent act and restores that agent after human control", async () => {
31 const control = new SessionControl("agent-a");
32 await expect(control.agent("agent-b", async () => "wrong agent")).rejects.toThrow(
33 BrowserControlInterrupted,
34 );
35 await expect(control.agent("agent-a", async () => "first")).resolves.toBe("first");
36 await control.take("viewer-a");
37 await expect(control.agent("agent-a", async () => "racing")).rejects.toThrow(
38 BrowserControlInterrupted,
39 );
40 await expect(control.human("viewer-a", async () => "typed")).resolves.toBe("typed");
41 await control.release("viewer-a");
42 await expect(control.agent("agent-a", async () => "resumed")).resolves.toBe("resumed");
43 await expect(control.agent("agent-b", async () => "wrong agent")).rejects.toThrow(
44 BrowserControlInterrupted,
45 );
46 });
47
48 it("drains running work before takeover and cancels previously queued agent actions", async () => {
49 const control = new SessionControl("agent");
50 const started = Promise.withResolvers<void>();
51 const finish = Promise.withResolvers<void>();
52 const events: string[] = [];
53 const running = control.agent("agent", async () => {
54 events.push("agent started");
55 started.resolve();
56 await finish.promise;
57 events.push("agent finished");
58 });
59 await started.promise;
60 const queued = expect(
61 control.agent("agent", async () => events.push("stale agent action")),
62 ).rejects.toThrow(BrowserControlInterrupted);
63 const takeover = control.take("viewer");
64 const typing = control.human("viewer", async () => events.push("human typed"));
65 await expect(control.agent("agent", async () => "late action")).rejects.toThrow(
66 BrowserControlInterrupted,
67 );
68 expect(events).toEqual(["agent started"]);
69 finish.resolve();
70 await Promise.all([running, queued, takeover, typing]);
71 expect(events).toEqual(["agent started", "agent finished", "human typed"]);
72 });
73
74 it("does not let a second viewer steal, type, release, or disconnect the owner", async () => {
75 const control = new SessionControl("agent");
76 const takeover = control.take("viewer-a");
77 await expect(control.take("viewer-b")).rejects.toThrow("Another viewer");
78 await takeover;
79 await expect(control.human("viewer-b", async () => "wrong viewer")).rejects.toThrow(
80 BrowserControlInterrupted,
81 );
82 await expect(control.release("viewer-b")).rejects.toThrow("Only the controlling viewer");
83 await control.disconnect("viewer-b");
84 expect(control.controller).toBe("viewer-a");
85 await expect(control.human("viewer-a", async () => "still controlled")).resolves.toBe(
86 "still controlled",
87 );
88 });
89
90 it("invalidates queued human actions and refs when a viewer disconnects", async () => {
91 let invalidations = 0;
92 const control = new SessionControl("agent", () => invalidations++);
93 await control.take("viewer");
94 const started = Promise.withResolvers<void>();
95 const finish = Promise.withResolvers<void>();
96 const events: string[] = [];
97 const running = control.human("viewer", async () => {
98 started.resolve();
99 await finish.promise;
100 events.push("human finished");
101 });
102 await started.promise;
103 const queued = expect(
104 control.human("viewer", async () => events.push("stale human action")),
105 ).rejects.toThrow(BrowserControlInterrupted);
106 const disconnected = control.disconnect("viewer");
107 const resumed = control.agent("agent", async () => events.push("agent resumed"));
108 expect(control.generation).toBe(2);
109 expect(invalidations).toBe(2);
110 finish.resolve();
111 await Promise.all([running, queued, disconnected, resumed]);
112 expect(events).toEqual(["human finished", "agent resumed"]);
113 });
114
115 it("does not revive stale actions when takeover is released before running work finishes", async () => {
116 const control = new SessionControl("agent");
117 const started = Promise.withResolvers<void>();
118 const finish = Promise.withResolvers<void>();
119 const running = control.agent("agent", async () => {
120 started.resolve();
121 await finish.promise;
122 });
123 await started.promise;
124 const staleAgent = expect(control.agent("agent", async () => "stale")).rejects.toThrow(
125 BrowserControlInterrupted,
126 );
127 const takeover = expect(control.take("viewer")).rejects.toThrow(BrowserControlInterrupted);
128 const staleHuman = expect(control.human("viewer", async () => "stale")).rejects.toThrow(
129 BrowserControlInterrupted,
130 );
131 const release = control.release("viewer");
132 const resumed = control.agent("agent", async () => "fresh");
133 finish.resolve();
134 await Promise.all([running, staleAgent, takeover, staleHuman, release]);
135 await expect(resumed).resolves.toBe("fresh");
136 });
137
138 it("closes after running work finishes and never runs queued or new actions", async () => {
139 const control = new SessionControl("agent");
140 const started = Promise.withResolvers<void>();
141 const finish = Promise.withResolvers<void>();
142 const running = control.agent("agent", async () => {
143 started.resolve();
144 await finish.promise;
145 return "finished";
146 });
147 await started.promise;
148 const queued = expect(control.agent("agent", async () => "stale")).rejects.toThrow("closed");
149 const closed = control.close();
150 await expect(control.take("viewer")).rejects.toThrow("closed");
151 await expect(control.agent("agent", async () => "new")).rejects.toThrow("closed");
152 finish.resolve();
153 await expect(running).resolves.toBe("finished");
154 await Promise.all([queued, closed]);
155 await control.disconnect("viewer");
156 await control.close();
157 expect(control.generation).toBe(1);
158 });
159
160 it("does not let a failed action poison the serial queue", async () => {
161 const control = new SessionControl("agent");
162 await expect(
163 control.agent("agent", async () => {
164 throw new Error("navigation failed");
165 }),
166 ).rejects.toThrow("navigation failed");
167 await expect(control.agent("agent", async () => "recovered")).resolves.toBe("recovered");
168 });
169});
apps/server/src/preview/SessionControl.ts created+135
...@@ -0,0 +1,135 @@
1import type { PreviewAutomationControlReason } from "@t3tools/contracts";
2
3export class BrowserControlInterrupted extends Error {
4 readonly reason: PreviewAutomationControlReason;
5 constructor(
6 message = "Browser control changed. Refresh the snapshot before trying again.",
7 reason: PreviewAutomationControlReason = "interrupted",
8 ) {
9 super(message);
10 this.reason = reason;
11 }
12}
13
14/** Reserves control immediately, but drains running work before the new owner can act. */
15export class SessionControl {
16 readonly agentId: string | null;
17 private readonly onGenerationChange: () => void;
18 private tail: Promise<unknown> = Promise.resolve();
19 private pending: Promise<void> = Promise.resolve();
20 private owner: string | null = null;
21 private epoch = 0;
22 private closed = false;
23
24 constructor(agentId: string | null, onGenerationChange: () => void = () => {}) {
25 this.agentId = agentId;
26 this.onGenerationChange = onGenerationChange;
27 }
28
29 get controller() {
30 return this.owner;
31 }
32
33 get generation() {
34 return this.epoch;
35 }
36
37 private enqueue<A>(run: () => Promise<A>): Promise<A> {
38 const result = this.tail.then(run);
39 this.tail = result.then(
40 () => this.pending,
41 () => this.pending,
42 );
43 return result;
44 }
45
46 /** An action can respond before its navigation commits, while later actions still wait. */
47 track(work: Promise<unknown>) {
48 const settled = work.then(
49 () => undefined,
50 () => undefined,
51 );
52 this.pending = Promise.all([this.pending, settled]).then(() => undefined);
53 }
54
55 private assertOpen() {
56 if (this.closed) throw new BrowserControlInterrupted("This browser tab is closed.", "closed");
57 }
58
59 private changeOwner(owner: string | null) {
60 this.owner = owner;
61 this.epoch++;
62 this.onGenerationChange();
63 }
64
65 private async action<A>(allowed: () => boolean, run: () => Promise<A>) {
66 this.assertOpen();
67 if (!allowed()) throw new BrowserControlInterrupted("You do not control this browser tab.");
68 const epoch = this.epoch;
69 return this.enqueue(async () => {
70 this.assertOpen();
71 if (epoch !== this.epoch || !allowed()) throw new BrowserControlInterrupted();
72 return run();
73 });
74 }
75
76 agent<A>(agentId: string, run: () => Promise<A>) {
77 if (this.agentId !== agentId)
78 return Promise.reject(
79 new BrowserControlInterrupted("This tab belongs to another agent.", "agentMismatch"),
80 );
81 if (this.owner !== null)
82 return Promise.reject(
83 new BrowserControlInterrupted("A human controls this tab.", "humanControl"),
84 );
85 return this.action(() => this.agentId === agentId && this.owner === null, run);
86 }
87
88 /**
89 * Work any client may ask for, such as a viewport or appearance change. It
90 * waits its turn behind running actions but needs no control.
91 */
92 system<A>(run: () => Promise<A>) {
93 this.assertOpen();
94 return this.enqueue(async () => {
95 this.assertOpen();
96 return run();
97 });
98 }
99
100 human<A>(viewerId: string, run: () => Promise<A>) {
101 return this.action(() => this.owner === viewerId, run);
102 }
103
104 async take(viewerId: string) {
105 this.assertOpen();
106 if (this.owner !== null && this.owner !== viewerId) {
107 throw new BrowserControlInterrupted("Another viewer controls this browser tab.");
108 }
109 if (this.owner !== viewerId) this.changeOwner(viewerId);
110 return this.human(viewerId, async () => {});
111 }
112
113 async release(viewerId: string, afterDrain: () => Promise<void> = async () => {}) {
114 this.assertOpen();
115 if (this.owner !== viewerId) {
116 throw new BrowserControlInterrupted(
117 "Only the controlling viewer can release this browser tab.",
118 );
119 }
120 this.changeOwner(null);
121 await this.enqueue(afterDrain);
122 }
123
124 async disconnect(viewerId: string, afterDrain?: () => Promise<void>) {
125 if (!this.closed && this.owner === viewerId) await this.release(viewerId, afterDrain);
126 }
127
128 async close() {
129 if (!this.closed) {
130 this.closed = true;
131 this.changeOwner(null);
132 }
133 await this.tail;
134 }
135}
apps/server/src/server.ts+9
...@@ -63,6 +63,10 @@ import * as DeviceService from "./device/DeviceService.ts";...@@ -63,6 +63,10 @@ import * as DeviceService from "./device/DeviceService.ts";
63import * as DeviceHubProxy from "./device/DeviceHubProxy.ts";63import * as DeviceHubProxy from "./device/DeviceHubProxy.ts";
64import * as PreviewManager from "./preview/Manager.ts";64import * as PreviewManager from "./preview/Manager.ts";
65import * as PortScanner from "./preview/PortScanner.ts";65import * as PortScanner from "./preview/PortScanner.ts";
66import * as ServerBrowser from "./preview/ServerBrowser.ts";
67import * as DesktopBrowserChannel from "./preview/DesktopBrowserChannel.ts";
68import * as ServerBrowserStream from "./preview/ServerBrowserStream.ts";
69import * as PreviewBrowser from "./preview/PreviewBrowser.ts";
66import * as ProcessRunner from "./processRunner.ts";70import * as ProcessRunner from "./processRunner.ts";
67import * as GitManager from "./git/GitManager.ts";71import * as GitManager from "./git/GitManager.ts";
68import * as EnvironmentTheme from "./environmentTheme.ts";72import * as EnvironmentTheme from "./environmentTheme.ts";
...@@ -661,6 +665,7 @@ const layerMakeRoutes = Layer.mergeAll(...@@ -661,6 +665,7 @@ const layerMakeRoutes = Layer.mergeAll(
661 ServerHttp.layerAssetRoute,665 ServerHttp.layerAssetRoute,
662 ServerHttp.layerAttachmentUploadRoute,666 ServerHttp.layerAttachmentUploadRoute,
663 DeviceHubProxy.layer,667 DeviceHubProxy.layer,
668 ServerBrowserStream.routeLayer,
664 ServerHttp.layerStaticAndDevRoute,669 ServerHttp.layerStaticAndDevRoute,
665 Ws.layer,670 Ws.layer,
666 ),671 ),
...@@ -676,6 +681,10 @@ const layerMakeRoutes = Layer.mergeAll(...@@ -676,6 +681,10 @@ const layerMakeRoutes = Layer.mergeAll(
676 // Both transports consume the same service instance, so caches single-flight across clients681 // Both transports consume the same service instance, so caches single-flight across clients
677 // and mutations observed on WebSocket invalidate patches subsequently read over HTTP.682 // and mutations observed on WebSocket invalidate patches subsequently read over HTTP.
678 Layer.provide(layerPullRequestService),683 Layer.provide(layerPullRequestService),
684 // The stream route and the WebSocket RPCs share one browser.
685 Layer.provide(ServerBrowser.layer.pipe(Layer.provide(DesktopBrowserChannel.layer))),
686 // Server browser tabs and HTML render previews install and run the same headless browser.
687 Layer.provide(PreviewBrowser.layer),
679 Layer.provide(PreviewAutomationBroker.layer),688 Layer.provide(PreviewAutomationBroker.layer),
680 Layer.provide(ServerSelfUpdate.layer.pipe(Layer.provide(layerDesktopAppUpdate))),689 Layer.provide(ServerSelfUpdate.layer.pipe(Layer.provide(layerDesktopAppUpdate))),
681 Layer.provide(layerCommandReadiness),690 Layer.provide(layerCommandReadiness),
apps/server/src/ws.ts+20-19
...@@ -180,6 +180,7 @@ import * as ServerSettings from "./serverSettings.ts";...@@ -180,6 +180,7 @@ import * as ServerSettings from "./serverSettings.ts";
180import * as TerminalManager from "./terminal/Manager.ts";180import * as TerminalManager from "./terminal/Manager.ts";
181import { withTerminalOutputWindow } from "./terminal/OutputProtocol.ts";181import { withTerminalOutputWindow } from "./terminal/OutputProtocol.ts";
182import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts";182import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts";
183import * as ServerBrowser from "./preview/ServerBrowser.ts";
183import * as DeviceService from "./device/DeviceService.ts";184import * as DeviceService from "./device/DeviceService.ts";
184import { remoteSshDeviceHosts } from "./device/localSshDeviceHost.ts";185import { remoteSshDeviceHosts } from "./device/localSshDeviceHost.ts";
185import * as PreviewManager from "./preview/Manager.ts";186import * as PreviewManager from "./preview/Manager.ts";
...@@ -1182,6 +1183,7 @@ const layerWsRpc = (...@@ -1182,6 +1183,7 @@ const layerWsRpc = (
1182 clientOrigin: OrchestrationClientOrigin,1183 clientOrigin: OrchestrationClientOrigin,
1183 clientAnalyticsProps: Readonly<Record<string, unknown>>,1184 clientAnalyticsProps: Readonly<Record<string, unknown>>,
1184 previewAutomationBroker: PreviewAutomationBroker.PreviewAutomationBroker["Service"],1185 previewAutomationBroker: PreviewAutomationBroker.PreviewAutomationBroker["Service"],
1186 serverBrowser: ServerBrowser.ServerBrowser["Service"],
1185) =>1187) =>
1186 ServerWsRpcGroup.toLayer(1188 ServerWsRpcGroup.toLayer(
1187 Effect.gen(function* () {1189 Effect.gen(function* () {
...@@ -3489,6 +3491,10 @@ const layerWsRpc = (...@@ -3489,6 +3491,10 @@ const layerWsRpc = (
3489 observeRpcEffect(WS_METHODS.previewResize, previewManager.resize(input), {3491 observeRpcEffect(WS_METHODS.previewResize, previewManager.resize(input), {
3490 "rpc.aggregate": "preview",3492 "rpc.aggregate": "preview",
3491 }),3493 }),
3494 [WS_METHODS.previewAdjust]: (input) =>
3495 observeRpcEffect(WS_METHODS.previewAdjust, previewManager.adjust(input), {
3496 "rpc.aggregate": "preview",
3497 }),
3492 [WS_METHODS.previewRefresh]: (input) =>3498 [WS_METHODS.previewRefresh]: (input) =>
3493 observeRpcEffect(WS_METHODS.previewRefresh, previewManager.refresh(input), {3499 observeRpcEffect(WS_METHODS.previewRefresh, previewManager.refresh(input), {
3494 "rpc.aggregate": "preview",3500 "rpc.aggregate": "preview",
...@@ -3501,28 +3507,16 @@ const layerWsRpc = (...@@ -3501,28 +3507,16 @@ const layerWsRpc = (
3501 observeRpcEffect(WS_METHODS.previewList, previewManager.list(input), {3507 observeRpcEffect(WS_METHODS.previewList, previewManager.list(input), {
3502 "rpc.aggregate": "preview",3508 "rpc.aggregate": "preview",
3503 }),3509 }),
3510 [WS_METHODS.previewClearProfile]: (input) =>
3511 observeRpcEffect(
3512 WS_METHODS.previewClearProfile,
3513 serverBrowser.clearProfile(input.profileId),
3514 { "rpc.aggregate": "preview" },
3515 ),
3504 [WS_METHODS.previewReportStatus]: (input) =>3516 [WS_METHODS.previewReportStatus]: (input) =>
3505 observeRpcEffect(WS_METHODS.previewReportStatus, previewManager.reportStatus(input), {3517 observeRpcEffect(WS_METHODS.previewReportStatus, previewManager.reportStatus(input), {
3506 "rpc.aggregate": "preview",3518 "rpc.aggregate": "preview",
3507 }),3519 }),
3508 [WS_METHODS.previewAutomationConnect]: (input) =>
3509 observeRpcStreamEffect(
3510 WS_METHODS.previewAutomationConnect,
3511 previewAutomationBroker.connect(input),
3512 { "rpc.aggregate": "preview-automation" },
3513 ),
3514 [WS_METHODS.previewAutomationRespond]: (input) =>
3515 observeRpcEffect(
3516 WS_METHODS.previewAutomationRespond,
3517 previewAutomationBroker.respond(input),
3518 { "rpc.aggregate": "preview-automation" },
3519 ),
3520 [WS_METHODS.previewAutomationFocusHost]: (input) =>
3521 observeRpcEffect(
3522 WS_METHODS.previewAutomationFocusHost,
3523 previewAutomationBroker.focusHost(input),
3524 { "rpc.aggregate": "preview-automation" },
3525 ),
3526 [WS_METHODS.subscribePreviewEvents]: (_input) =>3520 [WS_METHODS.subscribePreviewEvents]: (_input) =>
3527 observeRpcStream(WS_METHODS.subscribePreviewEvents, previewManager.events, {3521 observeRpcStream(WS_METHODS.subscribePreviewEvents, previewManager.events, {
3528 "rpc.aggregate": "preview",3522 "rpc.aggregate": "preview",
...@@ -3796,6 +3790,7 @@ const layerWsRpc = (...@@ -3796,6 +3790,7 @@ const layerWsRpc = (
3796export const layer = Layer.unwrap(3790export const layer = Layer.unwrap(
3797 Effect.gen(function* () {3791 Effect.gen(function* () {
3798 const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker;3792 const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
3793 const serverBrowser = yield* ServerBrowser.ServerBrowser;
3799 const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate;3794 const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate;
3800 const pullRequests = yield* PullRequestService.PullRequestService;3795 const pullRequests = yield* PullRequestService.PullRequestService;
3801 const sql = yield* SqlClient.SqlClient;3796 const sql = yield* SqlClient.SqlClient;
...@@ -3844,7 +3839,13 @@ export const layer = Layer.unwrap(...@@ -3844,7 +3839,13 @@ export const layer = Layer.unwrap(
3844 return httpEffect;3839 return httpEffect;
3845 }).pipe(3840 }).pipe(
3846 Effect.provide(3841 Effect.provide(
3847 layerWsRpc(session, clientOrigin, clientAnalyticsProps, previewAutomationBroker).pipe(3842 layerWsRpc(
3843 session,
3844 clientOrigin,
3845 clientAnalyticsProps,
3846 previewAutomationBroker,
3847 serverBrowser,
3848 ).pipe(
3848 Layer.provideMerge(RpcSerialization.layerJson),3849 Layer.provideMerge(RpcSerialization.layerJson),
3849 Layer.provide(Layer.succeed(SqlClient.SqlClient, sql)),3850 Layer.provide(Layer.succeed(SqlClient.SqlClient, sql)),
3850 Layer.provide(AgentSessionScanner.layer),3851 Layer.provide(AgentSessionScanner.layer),
apps/web/src/AppRoot.tsx-2
...@@ -1,7 +1,6 @@...@@ -1,7 +1,6 @@
1import { RouterProvider } from "@tanstack/react-router";1import { RouterProvider } from "@tanstack/react-router";
22
3import { ElectronBrowserHost } from "./browser/ElectronBrowserHost";3import { ElectronBrowserHost } from "./browser/ElectronBrowserHost";
4import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts";
5import { QuitHoldOverlay } from "./components/QuitHoldOverlay";4import { QuitHoldOverlay } from "./components/QuitHoldOverlay";
6import { AppAtomRegistryProvider } from "./rpc/atomRegistry";5import { AppAtomRegistryProvider } from "./rpc/atomRegistry";
7import type { AppRouter } from "./router";6import type { AppRouter } from "./router";
...@@ -15,7 +14,6 @@ export function AppRoot({ router }: { readonly router: AppRouter }) {...@@ -15,7 +14,6 @@ export function AppRoot({ router }: { readonly router: AppRouter }) {
15 return (14 return (
16 <AppAtomRegistryProvider>15 <AppAtomRegistryProvider>
17 <RouterProvider router={router} />16 <RouterProvider router={router} />
18 <PreviewAutomationHosts />
19 <ElectronBrowserHost />17 <ElectronBrowserHost />
20 <QuitHoldOverlay />18 <QuitHoldOverlay />
21 </AppAtomRegistryProvider>19 </AppAtomRegistryProvider>
apps/web/src/browser/ElectronBrowserHost.tsx+33-12
...@@ -2,8 +2,11 @@...@@ -2,8 +2,11 @@
22
3import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";3import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";
4import { FILL_PREVIEW_VIEWPORT } from "@t3tools/contracts";4import { FILL_PREVIEW_VIEWPORT } from "@t3tools/contracts";
5import { useAtomValue } from "@effect/atom-react";
5import { useEffect, useMemo } from "react";6import { useEffect, useMemo } from "react";
67
8import { primaryEnvironmentIdAtom } from "~/state/primaryEnvironment";
9
7import { isElectron } from "~/env";10import { isElectron } from "~/env";
8import { useTheme } from "~/hooks/useTheme";11import { useTheme } from "~/hooks/useTheme";
9import { useActivePreviewSessions } from "~/previewStateStore";12import { useActivePreviewSessions } from "~/previewStateStore";
...@@ -11,31 +14,40 @@ import { useActivePreviewSessions } from "~/previewStateStore";...@@ -11,31 +14,40 @@ import { useActivePreviewSessions } from "~/previewStateStore";
11import { readPreviewAnnotationTheme } from "./annotationTheme";14import { readPreviewAnnotationTheme } from "./annotationTheme";
12import { useBrowserPointerStore } from "./browserPointerStore";15import { useBrowserPointerStore } from "./browserPointerStore";
13import { HostedBrowserWebview } from "./HostedBrowserWebview";16import { HostedBrowserWebview } from "./HostedBrowserWebview";
17import { rendersServerTabNatively } from "./previewRuntime";
14import { previewRuntimeTabId } from "./previewRuntimeTabId";18import { previewRuntimeTabId } from "./previewRuntimeTabId";
1519
16export function ElectronBrowserHost() {20export function ElectronBrowserHost() {
17 const { resolvedTheme } = useTheme();21 const { resolvedTheme } = useTheme();
18 const previewByThreadKey = useActivePreviewSessions();22 const previewByThreadKey = useActivePreviewSessions();
23 const primaryEnvironmentId = useAtomValue(primaryEnvironmentIdAtom);
19 const sessions = useMemo(24 const sessions = useMemo(
20 () =>25 () =>
21 Object.entries(previewByThreadKey).flatMap(([threadKey, previewState]) => {26 Object.entries(previewByThreadKey).flatMap(([threadKey, previewState]) => {
22 const threadRef = parseScopedThreadKey(threadKey);27 const threadRef = parseScopedThreadKey(threadKey);
28 // Server tabs of other environments stream; this desktop's own server tabs render here.
23 return threadRef29 return threadRef
24 ? Object.values(previewState.sessions).map((snapshot) => ({30 ? Object.values(previewState.sessions)
25 threadRef,31 .filter(
26 snapshot,32 (snapshot) =>
27 runtimeTabId: previewRuntimeTabId(33 snapshot.runtime !== "server" ||
34 rendersServerTabNatively(threadRef.environmentId, primaryEnvironmentId, snapshot),
35 )
36 .map((snapshot) => ({
28 threadRef,37 threadRef,
29 previewState.serverEpoch,38 snapshot,
30 snapshot.tabId,39 runtimeTabId: previewRuntimeTabId(
31 ),40 threadRef,
32 pictureInPicture:41 previewState.serverEpoch,
33 previewState.desktopByTabId[snapshot.tabId]?.pictureInPicture ?? false,42 snapshot.tabId,
34 zoomFactor: previewState.desktopByTabId[snapshot.tabId]?.zoomFactor ?? 1,43 ),
35 }))44 pictureInPicture:
45 previewState.desktopByTabId[snapshot.tabId]?.pictureInPicture ?? false,
46 zoomFactor: previewState.desktopByTabId[snapshot.tabId]?.zoomFactor ?? 1,
47 }))
36 : [];48 : [];
37 }),49 }),
38 [previewByThreadKey],50 [previewByThreadKey, primaryEnvironmentId],
39 );51 );
4052
41 useEffect(() => {53 useEffect(() => {
...@@ -95,6 +107,15 @@ export function ElectronBrowserHost() {...@@ -95,6 +107,15 @@ export function ElectronBrowserHost() {
95 pictureInPicture={pictureInPicture}107 pictureInPicture={pictureInPicture}
96 profileId={snapshot.profileId}108 profileId={snapshot.profileId}
97 zoomFactor={zoomFactor}109 zoomFactor={zoomFactor}
110 serverDriven={snapshot.runtime === "server"}
111 {...(snapshot.runtime === "server"
112 ? {
113 serverRendering: {
114 colorScheme: snapshot.colorScheme ?? "system",
115 zoomFactor: snapshot.zoomFactor ?? 1,
116 },
117 }
118 : {})}
98 />119 />
99 );120 );
100 })}121 })}
apps/web/src/browser/HostedBrowserWebview.test.tsx+1-1
...@@ -174,7 +174,7 @@ describe("HostedBrowserWebview settings hydration", () => {...@@ -174,7 +174,7 @@ describe("HostedBrowserWebview settings hydration", () => {
174 await retry;174 await retry;
175 });175 });
176176
177 expect(acquire).toHaveBeenCalledExactlyOnceWith(runtimeTabId);177 expect(acquire).toHaveBeenCalledExactlyOnceWith(runtimeTabId, undefined);
178 expect(mocks.getPreviewConfig).toHaveBeenCalledExactlyOnceWith(threadRef.environmentId, "work");178 expect(mocks.getPreviewConfig).toHaveBeenCalledExactlyOnceWith(threadRef.environmentId, "work");
179 expect(createGuest).toHaveBeenCalledOnce();179 expect(createGuest).toHaveBeenCalledOnce();
180 expect(createGuest).toHaveBeenCalledWith(180 expect(createGuest).toHaveBeenCalledWith(
apps/web/src/browser/HostedBrowserWebview.tsx+39-5
...@@ -1,6 +1,10 @@...@@ -1,6 +1,10 @@
1"use client";1"use client";
22
3import type { PreviewViewportSetting, ScopedThreadRef } from "@t3tools/contracts";3import type {
4 DesktopPreviewColorScheme,
5 PreviewViewportSetting,
6 ScopedThreadRef,
7} from "@t3tools/contracts";
4import { useShallow } from "zustand/react/shallow";8import { useShallow } from "zustand/react/shallow";
5import { useCallback, useEffect, useRef, useState } from "react";9import { useCallback, useEffect, useRef, useState } from "react";
610
...@@ -18,7 +22,7 @@ import {...@@ -18,7 +22,7 @@ import {
18} from "./browserViewportLayout";22} from "./browserViewportLayout";
19import { BrowserDeviceToolbar } from "./BrowserDeviceToolbar";23import { BrowserDeviceToolbar } from "./BrowserDeviceToolbar";
20import { BrowserViewportResizeHandles } from "./BrowserViewportResizeHandles";24import { BrowserViewportResizeHandles } from "./BrowserViewportResizeHandles";
21import { acquireDesktopTab, type AcquiredDesktopTab } from "./desktopTabLifetime";25import { acquireDesktopTab, type AcquiredDesktopTab, withDesktopTab } from "./desktopTabLifetime";
22import { resolveHostedBrowserWebviewWrapperStyle } from "./hostedBrowserWebviewStyle";26import { resolveHostedBrowserWebviewWrapperStyle } from "./hostedBrowserWebviewStyle";
23import { usePreviewWebviewConfig } from "./previewWebviewConfigState";27import { usePreviewWebviewConfig } from "./previewWebviewConfigState";
24import { useBrowserViewportResize } from "./useBrowserViewportResize";28import { useBrowserViewportResize } from "./useBrowserViewportResize";
...@@ -56,6 +60,16 @@ export function HostedBrowserWebview(props: {...@@ -56,6 +60,16 @@ export function HostedBrowserWebview(props: {
56 */60 */
57 readonly profileId: string | undefined;61 readonly profileId: string | undefined;
58 readonly zoomFactor: number;62 readonly zoomFactor: number;
63 /** A tab of the desktop's own server; the server drives this webview's page. */
64 readonly serverDriven?: boolean;
65 /**
66 * For a server-driven tab, the appearance and zoom its environment published,
67 * which this webview follows so every client and agent sees one state.
68 */
69 readonly serverRendering?: {
70 readonly colorScheme: DesktopPreviewColorScheme;
71 readonly zoomFactor: number;
72 };
59}) {73}) {
60 const {74 const {
61 threadRef,75 threadRef,
...@@ -66,6 +80,8 @@ export function HostedBrowserWebview(props: {...@@ -66,6 +80,8 @@ export function HostedBrowserWebview(props: {
66 pictureInPicture,80 pictureInPicture,
67 zoomFactor,81 zoomFactor,
68 profileId,82 profileId,
83 serverDriven = false,
84 serverRendering,
69 } = props;85 } = props;
70 const clientSettingsHydrated = useClientSettingsHydrated();86 const clientSettingsHydrated = useClientSettingsHydrated();
71 const config = usePreviewWebviewConfig(threadRef.environmentId, profileId);87 const config = usePreviewWebviewConfig(threadRef.environmentId, profileId);
...@@ -93,18 +109,36 @@ export function HostedBrowserWebview(props: {...@@ -93,18 +109,36 @@ export function HostedBrowserWebview(props: {
93 (state) => (state.activityByTabId[runtimeTabId] ?? 0) > 0,109 (state) => (state.activityByTabId[runtimeTabId] ?? 0) > 0,
94 );110 );
95 const recordingActive = useActiveBrowserRecordingTabIds().has(runtimeTabId);111 const recordingActive = useActiveBrowserRecordingTabIds().has(runtimeTabId);
96 usePreviewBridge({ threadRef, tabId, runtimeTabId });112 usePreviewBridge({ threadRef, tabId, runtimeTabId, serverDriven });
113
114 const serverColorScheme = serverRendering?.colorScheme;
115 const serverZoomFactor = serverRendering?.zoomFactor;
97116
98 useEffect(() => {117 useEffect(() => {
99 if (!clientSettingsHydrated) return;118 if (!clientSettingsHydrated) return;
100 crashRecoveryRef.current = INITIAL_WEBVIEW_CRASH_RECOVERY_STATE;119 crashRecoveryRef.current = INITIAL_WEBVIEW_CRASH_RECOVERY_STATE;
101 const lease = acquireDesktopTab(runtimeTabId);120 const lease = acquireDesktopTab(
121 runtimeTabId,
122 serverDriven ? { threadId: threadRef.threadId, tabId } : undefined,
123 );
102 tabLeaseRef.current = lease;124 tabLeaseRef.current = lease;
103 return () => {125 return () => {
104 if (tabLeaseRef.current === lease) tabLeaseRef.current = null;126 if (tabLeaseRef.current === lease) tabLeaseRef.current = null;
105 lease.release();127 lease.release();
106 };128 };
107 }, [clientSettingsHydrated, runtimeTabId]);129 }, [clientSettingsHydrated, runtimeTabId, serverDriven, tabId, threadRef.threadId]);
130
131 // A server tab looks the way its environment published, once the desktop tab exists.
132 useEffect(() => {
133 const bridge = window.desktopBridge?.preview;
134 if (!bridge || serverColorScheme === undefined) return;
135 withDesktopTab(runtimeTabId, () => bridge.setColorScheme(runtimeTabId, serverColorScheme));
136 }, [runtimeTabId, serverColorScheme]);
137 useEffect(() => {
138 const bridge = window.desktopBridge?.preview;
139 if (!bridge || serverZoomFactor === undefined) return;
140 withDesktopTab(runtimeTabId, () => bridge.setZoomFactor(runtimeTabId, serverZoomFactor));
141 }, [runtimeTabId, serverZoomFactor]);
108142
109 const [webviewGeneration, setWebviewGeneration] = useState(0);143 const [webviewGeneration, setWebviewGeneration] = useState(0);
110 const [recoverySrc, setRecoverySrc] = useState(initialSrc);144 const [recoverySrc, setRecoverySrc] = useState(initialSrc);
apps/web/src/browser/ServerBrowserSurface.tsx created+930
...@@ -0,0 +1,930 @@
1"use client";
2
3import {
4 createPreviewFramePainter,
5 createPreviewStreamClient,
6 previewStreamControlLabel,
7 previewStreamModifiers,
8 type PreviewStreamClient,
9 type PreviewStreamControl,
10 type PreviewStreamDownload,
11 type PreviewStreamFileChooser,
12 uploadPreviewStreamFiles,
13 type PreviewStreamInput,
14 type PreviewStreamMouseButton,
15 type PreviewStreamPointer,
16 type PreviewStreamViewport,
17 previewStreamHostSetupMessage,
18} from "@t3tools/client-runtime/preview/server-browser-stream";
19import type {
20 EnvironmentId,
21 PreviewStreamHostSetup,
22 PreviewViewportSetting,
23} from "@t3tools/contracts";
24import {
25 type FormEvent,
26 type KeyboardEvent,
27 type PointerEvent,
28 type Ref,
29 useCallback,
30 useEffect,
31 useEffectEvent,
32 useImperativeHandle,
33 useRef,
34 useState,
35} from "react";
36
37import { AgentCursorMark } from "~/components/preview/AgentBrowserCursor";
38import { CommandBlock } from "~/components/CommandBlock";
39import { Button } from "~/components/ui/button";
40import { Input } from "~/components/ui/input";
41import { toastManager } from "~/components/ui/toast";
42import { cn } from "~/lib/utils";
43import { refreshPreviewStreamAccess, usePreviewStreamAccess } from "~/state/previewStream";
44
45/** Chrome-row controls for a server tab; commands require current ownership. */
46export interface ServerBrowserHandle {
47 readonly navigate: (url: string) => void;
48 readonly history: (delta: -1 | 1) => void;
49 readonly reload: (options?: { readonly ignoreCache?: boolean }) => void;
50 readonly viewport: (setting: PreviewViewportSetting) => void;
51 readonly canvas: () => HTMLCanvasElement | null;
52}
53
54const RESIZE_DEBOUNCE_MS = 150;
55const ACCESS_RETRY_MS = 10_000;
56// A recent probe answer near a new tap stands in for that tap's own answer,
57// which on a slow link arrives after the tap ends.
58const PROBE_REUSE_PX = 24;
59const PROBE_REUSE_MS = 10_000;
60// Kept in the input so soft keyboards have something to delete: Android
61// reports Backspace only as a deletion of text, not as a key.
62const INPUT_SENTINEL = "\u200b";
63const BACKSPACE = { key: "Backspace", code: "Backspace", keyCode: 8 } as const;
64const DELETE = { key: "Delete", code: "Delete", keyCode: 46 } as const;
65const MAX_UNAUTHORIZED_REFUSALS = 3;
66const TAP_SLOP_PX = 8;
67const MULTI_CLICK_MS = 500;
68const MULTI_CLICK_SLOP_PX = 4;
69const WHEEL_LINE_PX = 16;
70
71type MouseInput = Extract<PreviewStreamInput, { type: "mouse" }>;
72type WheelInput = Extract<PreviewStreamInput, { type: "wheel" }>;
73
74interface PagePoint {
75 readonly x: number;
76 readonly y: number;
77 /** Page CSS px per client px. */
78 readonly scale: number;
79}
80
81interface TouchGesture {
82 readonly pointerId: number;
83 readonly startX: number;
84 readonly startY: number;
85 lastX: number;
86 lastY: number;
87 panning: boolean;
88}
89
90/** Whether the page point under a touch takes text; null until the server answers. */
91interface TouchProbe {
92 readonly x: number;
93 readonly y: number;
94 editable: boolean | null;
95 /** True only for this tap's own reply, never for a cached answer. */
96 answered: boolean;
97 /** The tap ended before the answer arrived. */
98 tapped: boolean;
99}
100
101const buttonOf = (button: number): PreviewStreamMouseButton =>
102 button === 0 ? "left" : button === 1 ? "middle" : button === 2 ? "right" : "none";
103
104const pressedButtonOf = (buttons: number): PreviewStreamMouseButton =>
105 buttons & 1 ? "left" : buttons & 2 ? "right" : buttons & 4 ? "middle" : "none";
106
107/** Browsers may refuse a write that arrives after the key press; a toast button is a fresh gesture. */
108async function copyPageText(text: string) {
109 try {
110 await navigator.clipboard.writeText(text);
111 } catch {
112 const id = toastManager.add({
113 type: "info",
114 title: "The page copied text",
115 actionProps: {
116 children: "Copy",
117 onClick: () => {
118 toastManager.close(id);
119 void navigator.clipboard.writeText(text).catch(() => undefined);
120 },
121 },
122 });
123 }
124}
125
126/** The download happens on the environment; this hands the finished file to this device. */
127function offerDownload(download: PreviewStreamDownload) {
128 const id = toastManager.add({
129 type: "info",
130 title: `Downloaded ${download.fileName}`,
131 actionProps: {
132 children: "Save",
133 onClick: () => {
134 toastManager.close(id);
135 const anchor = document.createElement("a");
136 anchor.href = download.url;
137 anchor.download = download.fileName;
138 anchor.click();
139 },
140 },
141 });
142}
143
144export function ServerBrowserSurface(props: {
145 readonly environmentId: EnvironmentId;
146 readonly threadId: string;
147 readonly tabId: string;
148 readonly visible: boolean;
149 /** Sends the surface size so fill-mode tabs follow it. The floating player scales the page instead. */
150 readonly followSize?: boolean;
151 /** Floating previews reserve the top edge for their existing hover controls. */
152 readonly controlPosition?: "top" | "bottom";
153 readonly onFirstFrame?: () => void;
154 readonly onViewport?: (viewport: PreviewStreamViewport) => void;
155 readonly onControl?: (control: PreviewStreamControl | null) => void;
156 readonly className?: string;
157 readonly ref?: Ref<ServerBrowserHandle>;
158}) {
159 const {
160 environmentId,
161 threadId,
162 tabId,
163 visible,
164 followSize = true,
165 controlPosition = "top",
166 onFirstFrame,
167 onViewport,
168 onControl,
169 className,
170 ref,
171 } = props;
172 const access = usePreviewStreamAccess(environmentId);
173 const canvasRef = useRef<HTMLCanvasElement | null>(null);
174 const inputRef = useRef<HTMLTextAreaElement | null>(null);
175 const keySentRef = useRef(false);
176 const clientRef = useRef<PreviewStreamClient | null>(null);
177 const viewportRef = useRef<PreviewStreamViewport | null>(null);
178 const sizeRef = useRef<{ width: number; height: number } | null>(null);
179 const hasFrameRef = useRef(false);
180 const controlRef = useRef<PreviewStreamControl | null>(null);
181 const [control, setControl] = useState<PreviewStreamControl | null>(null);
182 const [promptText, setPromptText] = useState("");
183 const [fileChooser, setFileChooser] = useState<PreviewStreamFileChooser | null>(null);
184 const [agentCursor, setAgentCursor] = useState<AgentCursorPlacement | null>(null);
185 const fileInputRef = useRef<HTMLInputElement | null>(null);
186 const answerFileChooser = (files: ReadonlyArray<File>) => {
187 const chooser = fileChooser;
188 if (!chooser) return;
189 setFileChooser(null);
190 void uploadPreviewStreamFiles(chooser, files).catch((cause: unknown) =>
191 toastManager.add({
192 type: "error",
193 title: "Could not send the files to the page",
194 description: cause instanceof Error ? cause.message : undefined,
195 }),
196 );
197 };
198 const unauthorizedRef = useRef(0);
199 const [accessDenied, setAccessDenied] = useState(false);
200 const [hostSetup, setHostSetup] = useState<PreviewStreamHostSetup | null>(null);
201 const pendingMoveRef = useRef<MouseInput | null>(null);
202 const pendingWheelRef = useRef<WheelInput | null>(null);
203 const inputFrameRef = useRef<number | null>(null);
204 const mouseButtonsRef = useRef(0);
205 const mouseClicksRef = useRef({ left: 1, middle: 1, right: 1, none: 1 });
206 const lastClickRef = useRef<{
207 button: PreviewStreamMouseButton;
208 time: number;
209 x: number;
210 y: number;
211 count: number;
212 } | null>(null);
213 const touchRef = useRef<TouchGesture | null>(null);
214 const probeRef = useRef<TouchProbe | null>(null);
215 const lastProbeRef = useRef<{ x: number; y: number; editable: boolean; at: number } | null>(null);
216 const firstFrame = useEffectEvent(() => onFirstFrame?.());
217 const viewportChanged = useEffectEvent((viewport: PreviewStreamViewport) =>
218 onViewport?.(viewport),
219 );
220 const controlChanged = useEffectEvent((next: PreviewStreamControl | null) => onControl?.(next));
221 // Frame cap in device px, fixed per socket. It grows with the surface and
222 // never shrinks, so only outgrowing it reconnects.
223 const [cap, setCap] = useState<{ width: number; height: number } | null>(null);
224
225 const send = useCallback((input: PreviewStreamInput) => {
226 clientRef.current?.send(input);
227 }, []);
228
229 const clearInput = useCallback(() => {
230 if (inputFrameRef.current !== null) cancelAnimationFrame(inputFrameRef.current);
231 inputFrameRef.current = null;
232 pendingMoveRef.current = pendingWheelRef.current = null;
233 mouseButtonsRef.current = 0;
234 mouseClicksRef.current = { left: 1, middle: 1, right: 1, none: 1 };
235 lastClickRef.current = null;
236 touchRef.current = probeRef.current = lastProbeRef.current = null;
237 keySentRef.current = false;
238 if (inputRef.current) inputRef.current.value = INPUT_SENTINEL;
239 inputRef.current?.blur();
240 }, []);
241
242 const flushInput = useCallback(() => {
243 if (inputFrameRef.current !== null) cancelAnimationFrame(inputFrameRef.current);
244 inputFrameRef.current = null;
245 const move = pendingMoveRef.current;
246 const wheel = pendingWheelRef.current;
247 pendingMoveRef.current = null;
248 pendingWheelRef.current = null;
249 if (move) send(move);
250 if (wheel) send(wheel);
251 }, [send]);
252
253 // Coalesce moves and wheel deltas to one message each per animation frame.
254 const scheduleFlush = useCallback(() => {
255 inputFrameRef.current ??= requestAnimationFrame(flushInput);
256 }, [flushInput]);
257
258 const queueWheel = useCallback(
259 (point: PagePoint, deltaX: number, deltaY: number, modifiers: number) => {
260 const pending = pendingWheelRef.current;
261 pendingWheelRef.current = {
262 type: "wheel",
263 x: point.x,
264 y: point.y,
265 deltaX: (pending?.deltaX ?? 0) + deltaX,
266 deltaY: (pending?.deltaY ?? 0) + deltaY,
267 modifiers,
268 };
269 scheduleFlush();
270 },
271 [scheduleFlush],
272 );
273
274 const pagePoint = useCallback(
275 (clientX: number, clientY: number, clamp: boolean): PagePoint | null => {
276 const canvas = canvasRef.current;
277 const viewport = viewportRef.current;
278 if (!canvas || !viewport || !hasFrameRef.current || controlRef.current?.controller !== "you")
279 return null;
280 const rect = canvas.getBoundingClientRect();
281 // `object-contain` letterboxes the frame inside the canvas box.
282 const fit = Math.min(rect.width / canvas.width, rect.height / canvas.height);
283 const width = canvas.width * fit;
284 const height = canvas.height * fit;
285 if (!(width > 0 && height > 0)) return null;
286 const scale = viewport.width / width;
287 const x = (clientX - rect.left - (rect.width - width) / 2) * scale;
288 const y = (clientY - rect.top - (rect.height - height) / 2) * (viewport.height / height);
289 const inside = x >= 0 && y >= 0 && x <= viewport.width && y <= viewport.height;
290 if (!inside && !clamp) return null;
291 return {
292 x: Math.min(Math.max(x, 0), viewport.width),
293 y: Math.min(Math.max(y, 0), viewport.height),
294 scale,
295 };
296 },
297 [],
298 );
299
300 const countClick = (button: PreviewStreamMouseButton, x: number, y: number, time: number) => {
301 const last = lastClickRef.current;
302 const count =
303 last &&
304 last.button === button &&
305 time - last.time < MULTI_CLICK_MS &&
306 Math.hypot(x - last.x, y - last.y) < MULTI_CLICK_SLOP_PX
307 ? last.count + 1
308 : 1;
309 lastClickRef.current = { button, time, x, y, count };
310 return count;
311 };
312
313 const focusInput = () => inputRef.current?.focus({ preventScroll: true });
314
315 useImperativeHandle(
316 ref,
317 () => ({
318 navigate: (url) => send({ type: "navigate", url }),
319 history: (delta) => send({ type: "history", delta }),
320 reload: (options) =>
321 send({ type: "reload", ...(options?.ignoreCache ? { ignoreCache: true } : {}) }),
322 viewport: (setting) => send({ type: "viewport", setting }),
323 canvas: () => (hasFrameRef.current ? canvasRef.current : null),
324 }),
325 [send],
326 );
327
328 useEffect(() => {
329 const element = canvasRef.current?.parentElement;
330 if (!element) return;
331 let timer: ReturnType<typeof setTimeout> | null = null;
332 const measure = () => {
333 timer = null;
334 const rect = element.getBoundingClientRect();
335 if (rect.width < 1 || rect.height < 1) return;
336 const size = { width: Math.round(rect.width), height: Math.round(rect.height) };
337 const previous = sizeRef.current;
338 if (previous?.width === size.width && previous.height === size.height) return;
339 sizeRef.current = size;
340 if (followSize) clientRef.current?.send({ type: "resize", ...size });
341 const ratio = window.devicePixelRatio || 1;
342 const width = Math.round(size.width * ratio);
343 const height = Math.round(size.height * ratio);
344 setCap((current) =>
345 current !== null && current.width >= width && current.height >= height
346 ? current
347 : {
348 width: Math.max(width, current?.width ?? 0),
349 height: Math.max(height, current?.height ?? 0),
350 },
351 );
352 };
353 const observer = new ResizeObserver(() => {
354 if (timer !== null) clearTimeout(timer);
355 // The first size connects right away; later ones settle before resizing the page.
356 timer = setTimeout(measure, sizeRef.current === null ? 0 : RESIZE_DEBOUNCE_MS);
357 });
358 observer.observe(element);
359 return () => {
360 observer.disconnect();
361 if (timer !== null) clearTimeout(timer);
362 };
363 }, [followSize]);
364
365 useEffect(() => {
366 const canvas = canvasRef.current;
367 if (!visible || accessDenied || hostSetup || !access || !cap || !canvas) return;
368 let refreshTimer: ReturnType<typeof setTimeout> | null = null;
369 const painter = createPreviewFramePainter(canvas, () => {
370 if (hasFrameRef.current) return;
371 hasFrameRef.current = true;
372 firstFrame();
373 });
374 const client = createPreviewStreamClient(
375 { access, threadId, tabId, maxWidth: cap.width, maxHeight: cap.height },
376 {
377 onFrame: (jpeg) => {
378 unauthorizedRef.current = 0;
379 painter.paint(jpeg);
380 },
381 onProbe: (result) => {
382 if (controlRef.current?.controller !== "you") return;
383 lastProbeRef.current = { ...result, at: performance.now() };
384 const probe = probeRef.current;
385 if (!probe || probe.x !== result.x || probe.y !== result.y) return;
386 probe.editable = result.editable;
387 probe.answered = true;
388 if (!probe.tapped) return;
389 // Late answer: Android still raises the keyboard; iOS waits for the next tap.
390 probeRef.current = null;
391 if (result.editable) inputRef.current?.focus({ preventScroll: true });
392 else inputRef.current?.blur();
393 },
394 onPointer: (pointer) => {
395 const placed = placeAgentCursor(pointer, canvasRef.current, viewportRef.current);
396 if (placed) setAgentCursor(placed);
397 },
398 onClipboard: (text) => void copyPageText(text),
399 onDownload: offerDownload,
400 onFileChooser: setFileChooser,
401 onViewport: (viewport) => {
402 viewportRef.current = viewport;
403 viewportChanged(viewport);
404 },
405 onControl: (next) => {
406 const previous = controlRef.current;
407 controlRef.current = next;
408 setControl(next);
409 controlChanged(next);
410 if (
411 next.dialog?.message !== previous?.dialog?.message ||
412 next.dialog?.defaultValue !== previous?.dialog?.defaultValue
413 )
414 setPromptText(next.dialog?.defaultValue ?? "");
415 if (next.controller !== "you") {
416 clearInput();
417 } else if (previous?.controller !== "you") {
418 const size = sizeRef.current;
419 if (followSize && size) client.send({ type: "resize", ...size });
420 }
421 },
422 onConnectedChange: (connected) => {
423 if (connected) return;
424 setFileChooser(null);
425 controlRef.current = null;
426 setControl(null);
427 controlChanged(null);
428 clearInput();
429 },
430 onHostSetup: setHostSetup,
431 onUnauthorized: () => {
432 // Fresh tickets re-run this effect. Repeated refusals need an explicit retry.
433 const refusals = ++unauthorizedRef.current;
434 if (refusals >= MAX_UNAUTHORIZED_REFUSALS) {
435 inputRef.current?.blur();
436 setAccessDenied(true);
437 return;
438 }
439 refreshTimer = setTimeout(
440 () => refreshPreviewStreamAccess(environmentId),
441 refusals === 1 ? 0 : 1_000 * 2 ** (refusals - 1),
442 );
443 },
444 },
445 );
446 clientRef.current = client;
447 return () => {
448 painter.stop();
449 if (refreshTimer !== null) clearTimeout(refreshTimer);
450 client.stop();
451 if (clientRef.current === client) clientRef.current = null;
452 controlRef.current = null;
453 setControl(null);
454 controlChanged(null);
455 clearInput();
456 };
457 }, [
458 access,
459 accessDenied,
460 cap,
461 clearInput,
462 environmentId,
463 followSize,
464 hostSetup,
465 tabId,
466 threadId,
467 visible,
468 ]);
469
470 useEffect(() => {
471 if (!visible || accessDenied || access !== null) return;
472 // A failed ticket mint, e.g. while the server restarts, never retries on its own.
473 const timer = setInterval(() => refreshPreviewStreamAccess(environmentId), ACCESS_RETRY_MS);
474 return () => clearInterval(timer);
475 }, [access, accessDenied, environmentId, visible]);
476
477 useEffect(() => {
478 const canvas = canvasRef.current;
479 if (!canvas) return;
480 // Registered natively: React's wheel listener is passive and cannot stop the panel scrolling.
481 const onWheel = (event: WheelEvent) => {
482 const point = pagePoint(event.clientX, event.clientY, false);
483 if (!point) return;
484 event.preventDefault();
485 const unit =
486 event.deltaMode === WheelEvent.DOM_DELTA_LINE
487 ? WHEEL_LINE_PX
488 : event.deltaMode === WheelEvent.DOM_DELTA_PAGE
489 ? (viewportRef.current?.height ?? 0)
490 : 1;
491 queueWheel(point, event.deltaX * unit, event.deltaY * unit, previewStreamModifiers(event));
492 };
493 canvas.addEventListener("wheel", onWheel, { passive: false });
494 return () => canvas.removeEventListener("wheel", onWheel);
495 }, [pagePoint, queueWheel]);
496
497 useEffect(
498 () => () => {
499 if (inputFrameRef.current !== null) cancelAnimationFrame(inputFrameRef.current);
500 },
501 [],
502 );
503
504 const handlePointer = (event: PointerEvent<HTMLCanvasElement>) => {
505 if (controlRef.current?.controller !== "you") return;
506 const { type, pointerId, clientX, clientY } = event;
507 const cancelled = type === "pointercancel";
508 const touch = touchRef.current;
509 if (cancelled && touch?.pointerId === pointerId) {
510 touchRef.current = probeRef.current = null;
511 return;
512 }
513 if (event.pointerType === "touch" && !cancelled) {
514 const point = pagePoint(clientX, clientY, type === "pointermove");
515 if (type === "pointerdown") {
516 if (!event.isPrimary) return;
517 touchRef.current = {
518 pointerId,
519 startX: clientX,
520 startY: clientY,
521 lastX: clientX,
522 lastY: clientY,
523 panning: false,
524 };
525 const last = lastProbeRef.current;
526 const reusable =
527 point &&
528 last &&
529 performance.now() - last.at < PROBE_REUSE_MS &&
530 Math.hypot(point.x - last.x, point.y - last.y) < PROBE_REUSE_PX;
531 probeRef.current = point
532 ? {
533 x: point.x,
534 y: point.y,
535 editable: reusable ? last.editable : null,
536 answered: false,
537 tapped: false,
538 }
539 : null;
540 if (point) send({ type: "probe", x: point.x, y: point.y });
541 return;
542 }
543 if (!touch || touch.pointerId !== pointerId) return;
544 if (type === "pointermove") {
545 if (
546 !touch.panning &&
547 Math.hypot(clientX - touch.startX, clientY - touch.startY) < TAP_SLOP_PX
548 )
549 return;
550 touch.panning = true;
551 if (point)
552 queueWheel(
553 point,
554 (touch.lastX - clientX) * point.scale,
555 (touch.lastY - clientY) * point.scale,
556 0,
557 );
558 touch.lastX = clientX;
559 touch.lastY = clientY;
560 return;
561 }
562 touchRef.current = null;
563 const probe = probeRef.current;
564 if (touch.panning) {
565 probeRef.current = null;
566 return;
567 }
568 if (!point) return;
569 // iOS requires focus during the tap; late probe replies only raise Android's keyboard.
570 if (probe?.editable === true) focusInput();
571 else if (probe?.editable === false) inputRef.current?.blur();
572 if (probe && !probe.answered) probe.tapped = true;
573 else probeRef.current = null;
574 flushInput();
575 const clickCount = countClick("left", clientX, clientY, event.timeStamp);
576 const at = { x: point.x, y: point.y, modifiers: 0 };
577 send({ type: "mouse", action: "move", ...at, button: "none", buttons: 0, clickCount: 0 });
578 send({ type: "mouse", action: "down", ...at, button: "left", buttons: 1, clickCount });
579 send({ type: "mouse", action: "up", ...at, button: "left", buttons: 0, clickCount });
580 return;
581 }
582 const previous = mouseButtonsRef.current;
583 if ((cancelled || type === "pointerup") && previous === 0) return;
584 if (cancelled || type === "pointerup")
585 mouseButtonsRef.current = cancelled ? 0 : event.buttons & 7;
586 const point = pagePoint(
587 clientX,
588 clientY,
589 type !== "pointerdown" &&
590 (cancelled || type === "pointerup" || previous !== 0 || event.buttons !== 0),
591 );
592 if (!point) return;
593 const at = { x: point.x, y: point.y, modifiers: previewStreamModifiers(event) };
594 const mouse = (action: "down" | "up", button: PreviewStreamMouseButton, buttons: number) => {
595 if (action === "down")
596 mouseClicksRef.current[button] = countClick(button, clientX, clientY, event.timeStamp);
597 send({
598 type: "mouse",
599 action,
600 ...at,
601 button,
602 buttons,
603 clickCount: mouseClicksRef.current[button],
604 });
605 };
606 if (type === "pointerdown") {
607 focusInput();
608 event.currentTarget.setPointerCapture(pointerId);
609 flushInput();
610 mouseButtonsRef.current = event.buttons & 7;
611 mouse("down", buttonOf(event.button), event.buttons);
612 } else if (type === "pointerup") {
613 flushInput();
614 mouse("up", buttonOf(event.button), event.buttons);
615 } else {
616 // Chords arrive as pointermove. Cancellation releases every held button.
617 const next = cancelled ? 0 : event.buttons & 7;
618 const changed = previous ^ next;
619 if (previous !== 0 && changed !== 0) {
620 flushInput();
621 let buttons = previous;
622 for (const bit of [1, 2, 4]) {
623 if (!(changed & bit)) continue;
624 buttons ^= bit;
625 mouse(next & bit ? "down" : "up", pressedButtonOf(bit), buttons);
626 }
627 mouseButtonsRef.current = next;
628 }
629 if (cancelled) return;
630 pendingMoveRef.current = {
631 type: "mouse",
632 action: "move",
633 ...at,
634 button: pressedButtonOf(event.buttons),
635 buttons: event.buttons,
636 clickCount: 0,
637 };
638 scheduleFlush();
639 }
640 };
641
642 const resetInput = (textarea: HTMLTextAreaElement) => {
643 keySentRef.current = false;
644 textarea.value = INPUT_SENTINEL;
645 textarea.setSelectionRange(INPUT_SENTINEL.length, INPUT_SENTINEL.length);
646 };
647
648 const sendKeyPress = (key: typeof BACKSPACE | typeof DELETE) => {
649 send({ type: "key", action: "down", ...key, modifiers: 0 });
650 send({ type: "key", action: "up", ...key, modifiers: 0 });
651 };
652
653 const handleKey = (action: "down" | "up", event: KeyboardEvent<HTMLTextAreaElement>) => {
654 if (controlRef.current?.controller !== "you") return;
655 keySentRef.current = false;
656 // IME and soft keyboards deliver text through composition and input events.
657 if (
658 event.nativeEvent.isComposing ||
659 event.keyCode === 229 ||
660 event.key === "Process" ||
661 event.key === "Unidentified"
662 ) {
663 return;
664 }
665 // Keep plain Escape and Tab in the page; Shift+Escape returns to the app.
666 if (event.key === "Escape" && event.shiftKey) {
667 event.preventDefault();
668 event.stopPropagation();
669 if (action === "down") event.currentTarget.blur();
670 return;
671 }
672 const shortcut = event.ctrlKey || event.metaKey;
673 // Paste arrives as a paste event carrying this device's clipboard. Copy and cut run
674 // in the page, which sends the copied text back. Shift+Insert is the same paste.
675 if (shortcut && event.key.toLowerCase() === "v") return;
676 if (event.shiftKey && event.key === "Insert") return;
677 // Enter carries "\r" like Puppeteer's key table, so forms submit and textareas break lines.
678 const text = shortcut
679 ? undefined
680 : [...event.key].length === 1
681 ? event.key
682 : event.key === "Enter"
683 ? "\r"
684 : undefined;
685 send({
686 type: "key",
687 action,
688 key: event.key,
689 code: event.code,
690 keyCode: event.keyCode,
691 ...(action === "down" && text !== undefined ? { text } : {}),
692 modifiers: previewStreamModifiers(event),
693 });
694 // Some Android keyboards edit the textarea even when keydown is prevented.
695 keySentRef.current =
696 action === "down" &&
697 !shortcut &&
698 (text !== undefined || event.key === "Backspace" || event.key === "Delete");
699 // Shortcuts stay with the app (copy, paste, keybindings); other keys belong to the page.
700 if (shortcut) return;
701 event.preventDefault();
702 event.stopPropagation();
703 };
704
705 const handleInput = (event: FormEvent<HTMLTextAreaElement>) => {
706 const native = event.nativeEvent;
707 if (native instanceof InputEvent && native.isComposing) return;
708 const textarea = event.currentTarget;
709 const inputType = native instanceof InputEvent ? native.inputType : "";
710 if (keySentRef.current) keySentRef.current = false;
711 else if (inputType === "deleteContentBackward") sendKeyPress(BACKSPACE);
712 else if (inputType === "deleteContentForward") sendKeyPress(DELETE);
713 else {
714 const text = textarea.value.replace(/^\u200b/, "");
715 if (text) send({ type: "text", text });
716 }
717 resetInput(textarea);
718 };
719
720 return (
721 <div
722 className={cn("relative flex flex-col overflow-hidden", className)}
723 data-server-browser-surface={tabId}
724 >
725 <div
726 className={cn(
727 "visible relative z-20 flex shrink-0 items-center justify-between gap-2 border-border bg-background px-2 py-1",
728 controlPosition === "bottom" ? "order-last border-t" : "border-b",
729 )}
730 >
731 <span role="status" className="text-xs text-muted-foreground">
732 {previewStreamControlLabel(control)}
733 </span>
734 {control?.canOperate ? (
735 <Button
736 variant="outline"
737 size="xs"
738 disabled={control.controller === "another-viewer"}
739 onClick={() =>
740 send({ type: control.controller === "you" ? "releaseControl" : "takeControl" })
741 }
742 >
743 {control.controller === "you" ? "Release control" : "Take control"}
744 </Button>
745 ) : null}
746 </div>
747 <div className="relative min-h-0 flex-1">
748 <canvas
749 ref={canvasRef}
750 className="block size-full touch-none object-contain"
751 onPointerDown={handlePointer}
752 onPointerMove={handlePointer}
753 onPointerUp={handlePointer}
754 onPointerCancel={handlePointer}
755 // Keeps focus in the page input below and stops native text selection.
756 onMouseDown={(event) => event.preventDefault()}
757 onContextMenu={(event) => event.preventDefault()}
758 />
759 {agentCursor && control?.controller !== "you" ? (
760 <AgentCursorMark
761 {...agentCursor}
762 controller={control?.controller === "agent" ? "agent" : "none"}
763 />
764 ) : null}
765 {/* Focus target for page keyboard input. Pinned top-left so focusing it never
766 scrolls the surface; 16px keeps iOS from zooming the app on focus. */}
767 <textarea
768 ref={inputRef}
769 aria-label="Browser page"
770 aria-description="Press Shift+Escape to leave the browser page."
771 autoCapitalize="off"
772 autoComplete="off"
773 autoCorrect="off"
774 spellCheck={false}
775 disabled={control?.controller !== "you"}
776 defaultValue={INPUT_SENTINEL}
777 // The caret must sit after the sentinel for a deletion to have something to delete.
778 onFocus={(event) => resetInput(event.currentTarget)}
779 className="sr-only top-0 left-0 text-base"
780 onKeyDown={(event) => handleKey("down", event)}
781 onKeyUp={(event) => handleKey("up", event)}
782 onInput={handleInput}
783 onCompositionEnd={(event) => {
784 const text = event.data.replace(/^\u200b/, "");
785 if (text) send({ type: "text", text });
786 resetInput(event.currentTarget);
787 }}
788 // Copying the input would put its sentinel on this device's clipboard.
789 onCopy={(event) => event.preventDefault()}
790 onCut={(event) => event.preventDefault()}
791 onPaste={(event) => {
792 event.preventDefault();
793 const text = event.clipboardData.getData("text/plain");
794 if (text) send({ type: "text", text });
795 }}
796 />
797 {fileChooser && control?.controller === "you" ? (
798 <div
799 className="absolute inset-x-2 top-2 z-10 flex flex-col gap-2 rounded-lg border border-border bg-background p-3 shadow-lg"
800 role="dialog"
801 aria-label="Choose files for the page"
802 >
803 <p className="text-sm">
804 The page asks for {fileChooser.multiple ? "files" : "a file"}.
805 </p>
806 <input
807 ref={fileInputRef}
808 type="file"
809 className="hidden"
810 multiple={fileChooser.multiple}
811 accept={fileChooser.accept}
812 onChange={(event) => {
813 const files = [...(event.currentTarget.files ?? [])];
814 event.currentTarget.value = "";
815 if (files.length > 0) answerFileChooser(files);
816 }}
817 />
818 <div className="flex justify-end gap-2">
819 <Button variant="outline" size="sm" onClick={() => answerFileChooser([])}>
820 Cancel
821 </Button>
822 <Button size="sm" onClick={() => fileInputRef.current?.click()}>
823 Choose {fileChooser.multiple ? "files" : "file"}
824 </Button>
825 </div>
826 </div>
827 ) : null}
828 {control?.dialog ? (
829 <div
830 className="absolute inset-x-2 top-2 z-10 flex flex-col gap-2 rounded-lg border border-border bg-background p-3 shadow-lg"
831 role="dialog"
832 aria-label="Browser dialog"
833 >
834 <p className="break-words text-sm">{control.dialog.message}</p>
835 {control.controller === "you" ? (
836 <>
837 {control.dialog.type === "prompt" ? (
838 <Input
839 aria-label="Dialog response"
840 value={promptText}
841 onChange={(event) => setPromptText(event.target.value)}
842 />
843 ) : null}
844 <div className="flex justify-end gap-2">
845 <Button
846 variant="outline"
847 size="sm"
848 onClick={() => send({ type: "dialog", accept: false })}
849 >
850 Dismiss
851 </Button>
852 <Button
853 size="sm"
854 onClick={() =>
855 send({
856 type: "dialog",
857 accept: true,
858 ...(control.dialog?.type === "prompt" ? { promptText } : {}),
859 })
860 }
861 >
862 Accept
863 </Button>
864 </div>
865 </>
866 ) : (
867 <p className="text-xs text-muted-foreground">Take control to respond.</p>
868 )}
869 </div>
870 ) : null}
871 </div>
872 {visible && hostSetup ? (
873 <div className="visible absolute inset-0 z-20 flex flex-col items-center justify-center gap-3 bg-background p-4 text-center">
874 <p role="alert" className="max-w-sm text-sm text-muted-foreground">
875 {previewStreamHostSetupMessage(hostSetup)}
876 </p>
877 <CommandBlock command={hostSetup.command} className="w-full max-w-md text-left" />
878 <Button variant="outline" size="sm" onClick={() => setHostSetup(null)}>
879 Try again
880 </Button>
881 </div>
882 ) : null}
883 {visible && accessDenied ? (
884 // The page can be invisible beneath an empty or unreachable state; reconnect must remain reachable.
885 <div className="visible absolute inset-0 z-20 flex flex-col items-center justify-center gap-2 bg-background p-3 text-center">
886 <p role="alert" className="text-xs text-muted-foreground">
887 Browser connection was refused.
888 </p>
889 <Button
890 variant="outline"
891 size="sm"
892 onClick={() => {
893 unauthorizedRef.current = 0;
894 setAccessDenied(false);
895 refreshPreviewStreamAccess(environmentId);
896 }}
897 >
898 Reconnect
899 </Button>
900 </div>
901 ) : null}
902 </div>
903 );
904}
905
906interface AgentCursorPlacement {
907 readonly phase: PreviewStreamPointer["phase"];
908 readonly sequence: number;
909 readonly left: number;
910 readonly top: number;
911}
912
913/** Places the agent cursor over the letterboxed frame, the inverse of the viewer's own pointer mapping. */
914function placeAgentCursor(
915 pointer: PreviewStreamPointer,
916 canvas: HTMLCanvasElement | null,
917 viewport: PreviewStreamViewport | null,
918): AgentCursorPlacement | null {
919 if (!canvas || !viewport || canvas.width < 1 || canvas.height < 1) return null;
920 const box = { width: canvas.clientWidth, height: canvas.clientHeight };
921 const fit = Math.min(box.width / canvas.width, box.height / canvas.height);
922 const width = canvas.width * fit;
923 const height = canvas.height * fit;
924 return {
925 phase: pointer.phase,
926 sequence: pointer.sequence,
927 left: (box.width - width) / 2 + (pointer.x * width) / viewport.width,
928 top: (box.height - height) / 2 + (pointer.y * height) / viewport.height,
929 };
930}
apps/web/src/browser/browserLinkTarget.ts+5-5
...@@ -8,10 +8,10 @@...@@ -8,10 +8,10 @@
8 *8 *
9 * @module browserLinkTarget9 * @module browserLinkTarget
10 */10 */
11import type { BrowserLinkTarget } from "@t3tools/contracts";11import type { BrowserLinkTarget, ScopedThreadRef } from "@t3tools/contracts";
1212
13import { ensureClientSettingsHydrated, getClientSettings } from "~/hooks/useSettings";13import { ensureClientSettingsHydrated, getClientSettings } from "~/hooks/useSettings";
14import { isPreviewSupportedInRuntime } from "~/previewStateStore";14import { isPreviewAvailableFor } from "./previewRuntime";
1515
16export interface ResolveLinkTargetInput {16export interface ResolveLinkTargetInput {
17 readonly url: string;17 readonly url: string;
...@@ -62,7 +62,7 @@ export async function resolveBrowserLinkTargetPreference(): Promise<BrowserLinkT...@@ -62,7 +62,7 @@ export async function resolveBrowserLinkTargetPreference(): Promise<BrowserLinkT
62 return getClientSettings().browserLinkTarget;62 return getClientSettings().browserLinkTarget;
63}63}
6464
65/** Whether the in-app target is available at all in this client. */65/** Whether this thread's environment has an in-app browser available to this client. */
66export function canOpenLinksInApp(hasThread: boolean): boolean {66export function canOpenLinksInApp(threadRef: ScopedThreadRef | null | undefined): boolean {
67 return hasThread && isPreviewSupportedInRuntime();67 return threadRef != null && isPreviewAvailableFor(threadRef.environmentId);
68}68}
apps/web/src/browser/browserRecordingUpload.ts deleted-84
...@@ -1,84 +0,0 @@
1import {
2 PROVIDER_SEND_TURN_MAX_FILE_BYTES,
3 PreviewAutomationRecordingTransferError,
4 PreviewAutomationRecordingTooLargeError,
5 PreviewAutomationRecordingDeadlineExpiredError,
6 type DesktopPreviewRecordingArtifact,
7 type ScopedThreadRef,
8} from "@t3tools/contracts";
9import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
10import {
11 deletePendingAttachmentUpload,
12 runAttachmentUploadCycle,
13} from "@t3tools/client-runtime/state/attachments";
14
15import { appAtomRegistry } from "~/rpc/atomRegistry";
16import { attachmentEnvironment } from "~/state/attachments";
17import { readPreparedConnection } from "~/state/session";
18
19/** Sends the finished encoded file once; capture frames never cross the environment connection. */
20export async function uploadBrowserRecording(
21 { environmentId, threadId }: ScopedThreadRef,
22 artifact: DesktopPreviewRecordingArtifact,
23 blob: Blob,
24 deadlineMs: number,
25): Promise<string> {
26 if (blob.size > PROVIDER_SEND_TURN_MAX_FILE_BYTES) {
27 throw new PreviewAutomationRecordingTooLargeError({ threadId });
28 }
29 const result = await runAttachmentUploadCycle({
30 registry: appAtomRegistry,
31 createUploadUrl: attachmentEnvironment.createUploadUrl,
32 remove: attachmentEnvironment.remove,
33 environmentId,
34 upload: {
35 type: "file",
36 name: artifact.path.split(/[\\/]/).at(-1) ?? artifact.id,
37 mimeType: artifact.mimeType,
38 sizeBytes: blob.size,
39 },
40 resolveUploadUrl: (relativeUrl) => {
41 const connection = readPreparedConnection(environmentId);
42 return connection ? resolveAssetUrl(connection.httpBaseUrl, relativeUrl) : null;
43 },
44 transport: (url) => {
45 const controller = new AbortController();
46 // Encoding, saving and minting consume the same request budget. Leave time to reply.
47 const remainingMs = deadlineMs - Date.now() - 1_000;
48 return {
49 abort: () => controller.abort(),
50 done:
51 remainingMs <= 0
52 ? Promise.reject(new Error("Recording transfer deadline expired."))
53 : fetch(url, {
54 method: "POST",
55 headers: { "Content-Type": artifact.mimeType },
56 body: blob,
57 signal: AbortSignal.any([controller.signal, AbortSignal.timeout(remainingMs)]),
58 }).then((response) => {
59 if (!response.ok)
60 throw new Error(`Recording upload rejected (${response.status}).`);
61 }),
62 };
63 },
64 });
65 if (result.status !== "uploaded") {
66 if (result.attachmentId) {
67 deletePendingAttachmentUpload({
68 registry: appAtomRegistry,
69 remove: attachmentEnvironment.remove,
70 environmentId,
71 attachmentId: result.attachmentId,
72 });
73 }
74 const cause = result.status === "failed" ? result.error : undefined;
75 if (Date.now() >= deadlineMs - 1_000) {
76 throw new PreviewAutomationRecordingDeadlineExpiredError({ threadId, cause });
77 }
78 throw new PreviewAutomationRecordingTransferError({
79 threadId,
80 cause,
81 });
82 }
83 return result.attachmentId;
84}
apps/web/src/browser/desktopTabLifetime.ts+17-2
...@@ -31,12 +31,24 @@ const enqueueDesktopTabOperation = (...@@ -31,12 +31,24 @@ const enqueueDesktopTabOperation = (
31 return pending;31 return pending;
32};32};
3333
34/**
35 * Runs a call against a desktop tab after the tab's create and earlier calls,
36 * so a setting sent while the tab is being created still lands on it.
37 */
38export function withDesktopTab(tabId: string, operation: () => Promise<void>): void {
39 void enqueueDesktopTabOperation(tabId, operation).catch(() => undefined);
40}
41
34export interface AcquiredDesktopTab {42export interface AcquiredDesktopTab {
35 readonly ready: Promise<void>;43 readonly ready: Promise<void>;
36 readonly release: () => void;44 readonly release: () => void;
37}45}
3846
39export function acquireDesktopTab(tabId: string): AcquiredDesktopTab {47/** `serverTab` names the server tab this desktop tab renders, so the server can drive it. */
48export function acquireDesktopTab(
49 tabId: string,
50 serverTab?: { readonly threadId: string; readonly tabId: string },
51): AcquiredDesktopTab {
40 const current =52 const current =
41 leases.get(tabId) ??53 leases.get(tabId) ??
42 ({54 ({
...@@ -45,7 +57,10 @@ export function acquireDesktopTab(tabId: string): AcquiredDesktopTab {...@@ -45,7 +57,10 @@ export function acquireDesktopTab(tabId: string): AcquiredDesktopTab {
45 // Zoom/appearance defaults travel with creation so the guest never57 // Zoom/appearance defaults travel with creation so the guest never
46 // paints a frame at 100%/system before the preference is applied.58 // paints a frame at 100%/system before the preference is applied.
47 ready: enqueueDesktopTabOperation(tabId, async () =>59 ready: enqueueDesktopTabOperation(tabId, async () =>
48 previewBridge?.createTab(tabId, browserDefaultTabState(await resolveBrowserDefaults())),60 previewBridge?.createTab(tabId, {
61 ...browserDefaultTabState(await resolveBrowserDefaults()),
62 ...(serverTab === undefined ? {} : { serverTab }),
63 }),
49 ),64 ),
50 } satisfies DesktopTabLease);65 } satisfies DesktopTabLease);
51 if (current.closeTimer !== null) window.clearTimeout(current.closeTimer);66 if (current.closeTimer !== null) window.clearTimeout(current.closeTimer);
apps/web/src/browser/openFileInPreview.ts+5-6
...@@ -16,11 +16,8 @@ import * as Data from "effect/Data";...@@ -16,11 +16,8 @@ import * as Data from "effect/Data";
16import { AsyncResult } from "effect/reactivity";16import { AsyncResult } from "effect/reactivity";
1717
18import { resolveAssetUrl } from "~/assets/assetUrls";18import { resolveAssetUrl } from "~/assets/assetUrls";
19import {19import { isPreviewAvailableFor, previewRuntimeFor } from "~/browser/previewRuntime";
20 applyPreviewServerSnapshot,20import { applyPreviewServerSnapshot, rememberPreviewUrl } from "~/previewStateStore";
21 isPreviewSupportedInRuntime,
22 rememberPreviewUrl,
23} from "~/previewStateStore";
24import { useRightPanelStore } from "~/rightPanelStore";21import { useRightPanelStore } from "~/rightPanelStore";
2522
26import {23import {
...@@ -62,6 +59,7 @@ export async function openUrlInPreview<E>(input: {...@@ -62,6 +59,7 @@ export async function openUrlInPreview<E>(input: {
62 if (defaults instanceof BrowserSettingsReadError) {59 if (defaults instanceof BrowserSettingsReadError) {
63 return AsyncResult.failure(Cause.fail(defaults));60 return AsyncResult.failure(Cause.fail(defaults));
64 }61 }
62 const runtime = previewRuntimeFor(input.threadRef.environmentId);
65 const result = await input.openPreview({63 const result = await input.openPreview({
66 environmentId: input.threadRef.environmentId,64 environmentId: input.threadRef.environmentId,
67 input: {65 input: {
...@@ -72,6 +70,7 @@ export async function openUrlInPreview<E>(input: {...@@ -72,6 +70,7 @@ export async function openUrlInPreview<E>(input: {
72 // applied explicitly or file/link opens would ignore them.70 // applied explicitly or file/link opens would ignore them.
73 viewport: browserDefaultOpenViewport(defaults),71 viewport: browserDefaultOpenViewport(defaults),
74 profileId: browserDefaultOpenProfileId(defaults),72 profileId: browserDefaultOpenProfileId(defaults),
73 ...(runtime === undefined ? {} : { runtime }),
75 },74 },
76 });75 });
77 return mapAtomCommandResult(result, (snapshot) => {76 return mapAtomCommandResult(result, (snapshot) => {
...@@ -101,7 +100,7 @@ export async function openFileInPreview<AssetError, PreviewError>(input: {...@@ -101,7 +100,7 @@ export async function openFileInPreview<AssetError, PreviewError>(input: {
101 AssetError | PreviewError | BrowserPreviewUnavailableError | BrowserSettingsReadError100 AssetError | PreviewError | BrowserPreviewUnavailableError | BrowserSettingsReadError
102 >101 >
103> {102> {
104 if (!isPreviewSupportedInRuntime()) {103 if (!isPreviewAvailableFor(input.threadRef.environmentId)) {
105 return AsyncResult.failure(104 return AsyncResult.failure(
106 Cause.fail(105 Cause.fail(
107 new BrowserPreviewUnavailableError({106 new BrowserPreviewUnavailableError({
apps/web/src/browser/previewRuntime.ts created+49
...@@ -0,0 +1,49 @@
1import { useAtomValue } from "@effect/atom-react";
2import type { EnvironmentId, PreviewRuntime, PreviewSessionSnapshot } from "@t3tools/contracts";
3
4import { isElectron } from "~/env";
5import { isPreviewSupportedInRuntime } from "~/previewStateStore";
6import { primaryEnvironmentIdAtom } from "~/state/primaryEnvironment";
7import {
8 readEnvironmentSupportsServerBrowser,
9 useEnvironmentSupportsServerBrowser,
10} from "~/state/entities";
11
12export function previewRuntimeFor(environmentId: EnvironmentId): PreviewRuntime | undefined {
13 return readEnvironmentSupportsServerBrowser(environmentId) ? "server" : undefined;
14}
15
16/** Electron hosts its own browser tabs; other clients need the environment to host them. */
17export function isPreviewAvailableFor(environmentId: EnvironmentId): boolean {
18 return isPreviewSupportedInRuntime() || readEnvironmentSupportsServerBrowser(environmentId);
19}
20
21export function usePreviewAvailable(environmentId: EnvironmentId | null): boolean {
22 const serverBrowser = useEnvironmentSupportsServerBrowser(environmentId);
23 return isPreviewSupportedInRuntime() || serverBrowser;
24}
25
26/**
27 * Whether this client draws a server tab with its own `<webview>`. The desktop
28 * app renders tabs of the server it launched, which drives them over the
29 * desktop browser channel; every other client and environment streams them.
30 */
31export function rendersServerTabNatively(
32 environmentId: EnvironmentId,
33 primaryEnvironmentId: EnvironmentId | null,
34 snapshot: Pick<PreviewSessionSnapshot, "runtime"> | null | undefined,
35): boolean {
36 return (
37 isElectron &&
38 snapshot?.runtime === "server" &&
39 primaryEnvironmentId !== null &&
40 environmentId === primaryEnvironmentId
41 );
42}
43
44export function useRendersServerTabNatively(
45 environmentId: EnvironmentId,
46 snapshot: Pick<PreviewSessionSnapshot, "runtime"> | null | undefined,
47): boolean {
48 return rendersServerTabNatively(environmentId, useAtomValue(primaryEnvironmentIdAtom), snapshot);
49}
apps/web/src/browser/serverPictureInPicture.ts created+177
...@@ -0,0 +1,177 @@
1import {
2 createPreviewFramePainter,
3 createPreviewStreamClient,
4 type PreviewStreamClient,
5} from "@t3tools/client-runtime/preview/server-browser-stream";
6import type { EnvironmentId } from "@t3tools/contracts";
7import { useSyncExternalStore } from "react";
8
9import { readPreviewStreamAccess } from "~/state/previewStream";
10
11interface WebKitVideo {
12 webkitSetPresentationMode?: (mode: string) => void;
13 webkitPresentationMode?: string;
14}
15
16interface ActivePictureInPicture {
17 readonly key: string;
18 readonly stop: () => void;
19}
20
21// Owns its stream outside React so the floating window survives panel unmounts.
22let active: ActivePictureInPicture | null = null;
23const listeners = new Set<() => void>();
24
25const emit = () => {
26 for (const listener of listeners) listener();
27};
28
29const subscribe = (listener: () => void) => {
30 listeners.add(listener);
31 return () => listeners.delete(listener);
32};
33
34export const serverPictureInPictureKey = (threadId: string, tabId: string) =>
35 JSON.stringify([threadId, tabId]);
36
37/** Chromium and Safari float a canvas-captured video; Firefox has no API for it. */
38export function supportsServerPictureInPicture(): boolean {
39 if (typeof document === "undefined") return false;
40 if (typeof HTMLCanvasElement.prototype.captureStream !== "function") return false;
41 const video = HTMLVideoElement.prototype as HTMLVideoElement & WebKitVideo;
42 return (
43 (document.pictureInPictureEnabled && typeof video.requestPictureInPicture === "function") ||
44 typeof video.webkitSetPresentationMode === "function"
45 );
46}
47
48export function useServerPictureInPictureKey(): string | null {
49 return useSyncExternalStore(
50 subscribe,
51 () => active?.key ?? null,
52 () => null,
53 );
54}
55
56export function closeServerPictureInPicture(): void {
57 const current = active;
58 active = null;
59 current?.stop();
60 emit();
61}
62
63/** Open during user activation, seeding the video with the currently visible frame. */
64export async function openServerPictureInPicture(input: {
65 readonly environmentId: EnvironmentId;
66 readonly threadId: string;
67 readonly tabId: string;
68 readonly seed: HTMLCanvasElement | null;
69}): Promise<void> {
70 closeServerPictureInPicture();
71 const canvas = document.createElement("canvas");
72 const context = canvas.getContext("2d");
73 const seed = input.seed && input.seed.width > 0 && input.seed.height > 0 ? input.seed : null;
74 canvas.width = seed?.width ?? 640;
75 canvas.height = seed?.height ?? 400;
76 // The capture emits a frame per draw, so the seed is drawn after it starts.
77 const stream = canvas.captureStream();
78 if (seed) context?.drawImage(seed, 0, 0);
79 else context?.fillRect(0, 0, canvas.width, canvas.height);
80 const video = document.createElement("video") as HTMLVideoElement & WebKitVideo;
81 video.muted = true;
82 video.playsInline = true;
83 video.srcObject = stream;
84 // Safari only floats a video that is in the document.
85 video.style.cssText =
86 "position:fixed;right:0;bottom:0;width:1px;height:1px;opacity:0;pointer-events:none";
87 document.body.append(video);
88
89 const painter = createPreviewFramePainter(canvas);
90 let client: PreviewStreamClient | null = null;
91 let stopped = false;
92 let refusals = 0;
93 let retryTimer: ReturnType<typeof setTimeout> | null = null;
94 const onLeave = () => {
95 if (active === current) closeServerPictureInPicture();
96 };
97 const connect = async (refresh: boolean) => {
98 const access = await readPreviewStreamAccess(input.environmentId, refresh);
99 if (stopped) return;
100 if (!access) {
101 closeServerPictureInPicture();
102 return;
103 }
104 client = createPreviewStreamClient(
105 {
106 access,
107 threadId: input.threadId,
108 tabId: input.tabId,
109 maxWidth: 1280,
110 maxHeight: 1280,
111 interactive: false,
112 },
113 {
114 onFrame: (jpeg) => {
115 refusals = 0;
116 painter.paint(jpeg);
117 },
118 onViewport: () => undefined,
119 onConnectedChange: () => undefined,
120 onUnauthorized: () => {
121 if (stopped) return;
122 if (refusals >= 5) {
123 onLeave();
124 return;
125 }
126 retryTimer = setTimeout(() => void connect(true), 1_000 * 2 ** refusals++);
127 },
128 onGone: onLeave,
129 },
130 );
131 };
132
133 const onPresentationMode = () => {
134 if (video.webkitPresentationMode !== "picture-in-picture") onLeave();
135 };
136 const current: ActivePictureInPicture = {
137 key: serverPictureInPictureKey(input.threadId, input.tabId),
138 stop: () => {
139 stopped = true;
140 if (retryTimer !== null) clearTimeout(retryTimer);
141 client?.stop();
142 painter.stop();
143 video.removeEventListener("leavepictureinpicture", onLeave);
144 video.removeEventListener("webkitpresentationmodechanged", onPresentationMode);
145 if (document.pictureInPictureElement === video) {
146 void document.exitPictureInPicture().catch(() => undefined);
147 } else if (video.webkitPresentationMode === "picture-in-picture") {
148 video.webkitSetPresentationMode?.("inline");
149 }
150 for (const track of stream.getTracks()) track.stop();
151 video.srcObject = null;
152 video.remove();
153 },
154 };
155 active = current;
156 emit();
157
158 try {
159 await video.play();
160 if (video.readyState < HTMLMediaElement.HAVE_METADATA) {
161 await new Promise((resolve) =>
162 video.addEventListener("loadedmetadata", resolve, { once: true }),
163 );
164 }
165 if (document.pictureInPictureEnabled && typeof video.requestPictureInPicture === "function") {
166 video.addEventListener("leavepictureinpicture", onLeave);
167 await video.requestPictureInPicture();
168 } else {
169 video.addEventListener("webkitpresentationmodechanged", onPresentationMode);
170 video.webkitSetPresentationMode?.("picture-in-picture");
171 }
172 } catch (error) {
173 onLeave();
174 throw error;
175 }
176 if (active === current) void connect(false);
177}
apps/web/src/browser/useOpenLink.ts+1-1
...@@ -46,7 +46,7 @@ export function useOpenLink(threadRef: ScopedThreadRef | null | undefined): (...@@ -46,7 +46,7 @@ export function useOpenLink(threadRef: ScopedThreadRef | null | undefined): (
46 url,46 url,
47 event: options.event ?? NO_MODIFIER,47 event: options.event ?? NO_MODIFIER,
48 preference: await resolveBrowserLinkTargetPreference(),48 preference: await resolveBrowserLinkTargetPreference(),
49 canOpenInApp: canOpenLinksInApp(Boolean(targetThreadRef)),49 canOpenInApp: canOpenLinksInApp(targetThreadRef),
50 });50 });
51 if (target === "app" && targetThreadRef) {51 if (target === "app" && targetThreadRef) {
52 const result = await openUrlInPreview({ threadRef: targetThreadRef, url, openPreview });52 const result = await openUrlInPreview({ threadRef: targetThreadRef, url, openPreview });
apps/web/src/components/ChatMarkdown.tsx+3-3
...@@ -190,7 +190,7 @@ import {...@@ -190,7 +190,7 @@ import {
190} from "~/lib/openPullRequestLink";190} from "~/lib/openPullRequestLink";
191import { useOpenLink } from "../browser/useOpenLink";191import { useOpenLink } from "../browser/useOpenLink";
192import { writeTextToClipboard } from "../hooks/useCopyToClipboard";192import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
193import { isPreviewSupportedInRuntime } from "../previewStateStore";193import { isPreviewAvailableFor } from "../browser/previewRuntime";
194import { isAbsolutePath, resolvePathLinkTarget } from "../terminal-links";194import { isAbsolutePath, resolvePathLinkTarget } from "../terminal-links";
195import {195import {
196 isBrowserPreviewFile,196 isBrowserPreviewFile,
...@@ -2803,7 +2803,7 @@ function useChatMarkdownState({...@@ -2803,7 +2803,7 @@ function useChatMarkdownState({
2803 revealLabel={revealInFileManagerLabel}2803 revealLabel={revealInFileManagerLabel}
2804 onOpenInBrowser={2804 onOpenInBrowser={
2805 threadRef &&2805 threadRef &&
2806 isPreviewSupportedInRuntime() &&2806 isPreviewAvailableFor(threadRef.environmentId) &&
2807 isBrowserPreviewFile(fileLinkMeta.filePath)2807 isBrowserPreviewFile(fileLinkMeta.filePath)
2808 ? () => openMarkdownFileInPreview(fileLinkMeta.filePath)2808 ? () => openMarkdownFileInPreview(fileLinkMeta.filePath)
2809 : undefined2809 : undefined
...@@ -3073,7 +3073,7 @@ const CHAT_MARKDOWN_COMPONENTS = {...@@ -3073,7 +3073,7 @@ const CHAT_MARKDOWN_COMPONENTS = {
3073 : null;3073 : null;
3074 const isSameDocumentLink = href?.startsWith("#") ?? false;3074 const isSameDocumentLink = href?.startsWith("#") ?? false;
3075 const onClick = props.onClick;3075 const onClick = props.onClick;
3076 const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime();3076 const canOpenInPreview = Boolean(threadRef && isPreviewAvailableFor(threadRef.environmentId));
3077 const linkChildren = <MarkdownLinkContext value>{children}</MarkdownLinkContext>;3077 const linkChildren = <MarkdownLinkContext value>{children}</MarkdownLinkContext>;
3078 const link = (3078 const link = (
3079 <a3079 <a
apps/web/src/components/ChatView.tsx+83-13
...@@ -259,6 +259,7 @@ import { previewRuntimeTabId } from "../browser/previewRuntimeTabId";...@@ -259,6 +259,7 @@ import { previewRuntimeTabId } from "../browser/previewRuntimeTabId";
259import { addBrowserSurface } from "./preview/addBrowserSurface";259import { addBrowserSurface } from "./preview/addBrowserSurface";
260import { closePreviewSession } from "./preview/closePreviewSession";260import { closePreviewSession } from "./preview/closePreviewSession";
261import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer";261import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer";
262import { usePreviewSession } from "./preview/usePreviewSession";
262import { subscribePreviewAction } from "./preview/previewActionBus";263import { subscribePreviewAction } from "./preview/previewActionBus";
263import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic";264import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic";
264265
...@@ -408,6 +409,7 @@ import { projectCloneDisplayName, projectCloneProgressSummary } from "@t3tools/c...@@ -408,6 +409,7 @@ import { projectCloneDisplayName, projectCloneProgressSummary } from "@t3tools/c
408import { useEnvironments, usePrimaryEnvironment } from "../state/environments";409import { useEnvironments, usePrimaryEnvironment } from "../state/environments";
409import {410import {
410 resolveThreadDetailRef,411 resolveThreadDetailRef,
412 useEnvironmentSupportsServerBrowser,
411 useProject,413 useProject,
412 useProjects,414 useProjects,
413 useThreadProjection,415 useThreadProjection,
...@@ -2195,6 +2197,11 @@ export default function ChatView(props: ChatViewProps) {...@@ -2195,6 +2197,11 @@ export default function ChatView(props: ChatViewProps) {
2195 [activeThread],2197 [activeThread],
2196 );2198 );
2197 const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null;2199 const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null;
2200 const activeEnvironmentServerBrowser = useEnvironmentSupportsServerBrowser(
2201 activeThreadRef?.environmentId ?? null,
2202 );
2203 // Electron hosts its own browser tabs; other clients need the environment to host them.
2204 const browserAvailable = isPreviewSupportedInRuntime() || activeEnvironmentServerBrowser;
2198 const previewPanelInlineSize = usePreviewPanelInlineSize(undefined, {2205 const previewPanelInlineSize = usePreviewPanelInlineSize(undefined, {
2199 containerWidth: workspaceLayoutWidth ?? undefined,2206 containerWidth: workspaceLayoutWidth ?? undefined,
2200 widthStorageKey: `t3code:preview-panel-width:${activeThreadKey}`,2207 widthStorageKey: `t3code:preview-panel-width:${activeThreadKey}`,
...@@ -2260,6 +2267,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -2260,6 +2267,7 @@ export default function ChatView(props: ChatViewProps) {
2260 );2267 );
2261 const activePreviewState = useThreadPreviewState(activeThreadRef);2268 const activePreviewState = useThreadPreviewState(activeThreadRef);
2262 const activePreviewServerEpoch = activePreviewState.serverEpoch;2269 const activePreviewServerEpoch = activePreviewState.serverEpoch;
2270 const previewSessionsReady = !activeEnvironmentServerBrowser || activePreviewState.listLoaded;
2263 const resolvePreviewRuntimeTabId = useMemo(2271 const resolvePreviewRuntimeTabId = useMemo(
2264 () =>2272 () =>
2265 activeThreadRef2273 activeThreadRef
...@@ -2283,7 +2291,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -2283,7 +2291,7 @@ export default function ChatView(props: ChatViewProps) {
2283 () => [...new Set([...activeKnownTerminalIds, ...panelTerminalIds])],2291 () => [...new Set([...activeKnownTerminalIds, ...panelTerminalIds])],
2284 [activeKnownTerminalIds, panelTerminalIds],2292 [activeKnownTerminalIds, panelTerminalIds],
2285 );2293 );
2286 const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime();2294 const previewPanelOpen = activeRightPanelKind === "preview" && browserAvailable;
2287 const rightPanelOpen = rightPanelState.isOpen;2295 const rightPanelOpen = rightPanelState.isOpen;
2288 const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } =2296 const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } =
2289 usePanelAnimationSettings();2297 usePanelAnimationSettings();
...@@ -2334,21 +2342,32 @@ export default function ChatView(props: ChatViewProps) {...@@ -2334,21 +2342,32 @@ export default function ChatView(props: ChatViewProps) {
2334 );2342 );
23352343
2336 useEffect(() => {2344 useEffect(() => {
2337 if (!activeThreadRef) return;2345 if (!activeThreadRef || !previewSessionsReady) return;
2346 const hiddenTabIds = new Set(
2347 Object.values(activePreviewState.sessions)
2348 .filter((session) => session.runtime === "server" && session.reveal === false)
2349 .map((session) => session.tabId),
2350 );
2338 useRightPanelStore2351 useRightPanelStore
2339 .getState()2352 .getState()
2340 .reconcileBrowserSurfaces(activeThreadRef, Object.keys(activePreviewState.sessions));2353 .reconcileBrowserSurfaces(
2341 }, [activePreviewState.sessions, activeThreadRef]);2354 activeThreadRef,
2355 Object.keys(activePreviewState.sessions),
2356 hiddenTabIds,
2357 );
2358 }, [activePreviewState.sessions, activeThreadRef, previewSessionsReady]);
23422359
2343 useEffect(() => {2360 useEffect(() => {
2344 if (!activeThreadRef || activePreviewMiniPlayer?.source.kind !== "browser") return;2361 const source = activePreviewMiniPlayer?.source;
2345 const miniTabStillExists = Boolean(2362 if (
2346 activePreviewState.sessions[activePreviewMiniPlayer.source.tabId],2363 activeThreadRef &&
2347 );2364 previewSessionsReady &&
2348 if (!miniTabStillExists) {2365 source?.kind === "browser" &&
2366 !activePreviewState.sessions[source.tabId]
2367 ) {
2349 usePreviewMiniPlayerStore.getState().close(activeThreadRef);2368 usePreviewMiniPlayerStore.getState().close(activeThreadRef);
2350 }2369 }
2351 }, [activePreviewMiniPlayer, activePreviewState.sessions, activeThreadRef]);2370 }, [activePreviewMiniPlayer, activePreviewState.sessions, activeThreadRef, previewSessionsReady]);
23522371
2353 const existingOpenTerminalThreadKeys = useMemo(() => {2372 const existingOpenTerminalThreadKeys = useMemo(() => {
2354 const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]);2373 const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]);
...@@ -5349,6 +5368,47 @@ export default function ChatView(props: ChatViewProps) {...@@ -5349,6 +5368,47 @@ export default function ChatView(props: ChatViewProps) {
5349 deviceState.sessions,5368 deviceState.sessions,
5350 deviceState.devices,5369 deviceState.devices,
5351 ]);5370 ]);
5371 // Baseline loaded tabs so reloads never reopen previews the user dismissed.
5372 const previousServerPreviewTabs = useRef(new Map<string, Map<string, string | undefined>>());
5373 useEffect(() => {
5374 if (!activeThreadRef || !activeEnvironmentServerBrowser || !activePreviewState.listLoaded)
5375 return;
5376 const threadKey = scopedThreadKey(activeThreadRef);
5377 const serverSessions = Object.values(activePreviewState.sessions).filter(
5378 (session) => session.runtime === "server",
5379 );
5380 const previous = previousServerPreviewTabs.current.get(threadKey);
5381 previousServerPreviewTabs.current.set(
5382 threadKey,
5383 new Map(serverSessions.map((session) => [session.tabId, session.revealRequest?.id])),
5384 );
5385 if (!previous) return;
5386 for (const session of serverSessions) {
5387 const requested = session.revealRequest;
5388 const fresh = requested
5389 ? previous.get(session.tabId) !== requested.id
5390 : !previous.has(session.tabId);
5391 if (!fresh || session.reveal !== true) continue;
5392 if (!autoShowFloatingPreview && requested?.force !== true) continue;
5393 const surface = rightPanelState.surfaces.find(
5394 (surface) => surface.kind === "preview" && surface.resourceId === session.tabId,
5395 );
5396 if (surface && requested?.force === true) {
5397 useRightPanelStore.getState().activateSurface(activeThreadRef, surface.id);
5398 } else if (!surface) {
5399 usePreviewMiniPlayerStore
5400 .getState()
5401 .open(activeThreadRef, browserMiniPlayerSource(session.tabId));
5402 }
5403 }
5404 }, [
5405 activeEnvironmentServerBrowser,
5406 activePreviewState.listLoaded,
5407 activePreviewState.sessions,
5408 activeThreadRef,
5409 autoShowFloatingPreview,
5410 rightPanelState.surfaces,
5411 ]);
5352 // A floating device follows its session: once the agent or another client5412 // A floating device follows its session: once the agent or another client
5353 // closes the device there is nothing left to stream.5413 // closes the device there is nothing left to stream.
5354 useEffect(() => {5414 useEffect(() => {
...@@ -5653,7 +5713,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -5653,7 +5713,7 @@ export default function ChatView(props: ChatViewProps) {
5653 }5713 }
5654 }, [activeRightPanelSurface, activeThreadRef]);5714 }, [activeRightPanelSurface, activeThreadRef]);
5655 const togglePreviewPanel = useCallback(() => {5715 const togglePreviewPanel = useCallback(() => {
5656 if (!activeThreadRef || !isPreviewSupportedInRuntime()) return;5716 if (!activeThreadRef || !browserAvailable) return;
5657 if (previewPanelOpen) {5717 if (previewPanelOpen) {
5658 closePreviewPanel();5718 closePreviewPanel();
5659 return;5719 return;
...@@ -5667,6 +5727,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -5667,6 +5727,7 @@ export default function ChatView(props: ChatViewProps) {
5667 }, [5727 }, [
5668 activePreviewState.activeTabId,5728 activePreviewState.activeTabId,
5669 activeThreadRef,5729 activeThreadRef,
5730 browserAvailable,
5670 closePreviewPanel,5731 closePreviewPanel,
5671 createBrowserSurface,5732 createBrowserSurface,
5672 previewPanelOpen,5733 previewPanelOpen,
...@@ -11449,6 +11510,9 @@ export default function ChatView(props: ChatViewProps) {...@@ -11449,6 +11510,9 @@ export default function ChatView(props: ChatViewProps) {
11449 </div>11510 </div>
11450 </div>11511 </div>
1145111512
11513 {activeThreadRef && activeEnvironmentServerBrowser ? (
11514 <PreviewSessionSync threadRef={activeThreadRef} />
11515 ) : null}
11452 {activeThreadRef && activePreviewMiniPlayer && previewMiniPlayerVisible ? (11516 {activeThreadRef && activePreviewMiniPlayer && previewMiniPlayerVisible ? (
11453 <ThreadPreviewMiniPlayer11517 <ThreadPreviewMiniPlayer
11454 key={`${activeThreadKey}:${previewMiniPlayerSourceKey(activePreviewMiniPlayer.source)}`}11518 key={`${activeThreadKey}:${previewMiniPlayerSourceKey(activePreviewMiniPlayer.source)}`}
...@@ -11562,7 +11626,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -11562,7 +11626,7 @@ export default function ChatView(props: ChatViewProps) {
11562 onAddPullRequest={addPullRequestSurface}11626 onAddPullRequest={addPullRequestSurface}
11563 onAddPullRequests={addPullRequestsSurface}11627 onAddPullRequests={addPullRequestsSurface}
11564 onAddDevice={addDeviceSurface}11628 onAddDevice={addDeviceSurface}
11565 browserAvailable={isPreviewSupportedInRuntime()}11629 browserAvailable={browserAvailable}
11566 terminalAvailable={activeProject !== null}11630 terminalAvailable={activeProject !== null}
11567 diffAvailable={isServerThread && isGitRepo}11631 diffAvailable={isServerThread && isGitRepo}
11568 filesAvailable={activeProject !== null}11632 filesAvailable={activeProject !== null}
...@@ -11617,7 +11681,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -11617,7 +11681,7 @@ export default function ChatView(props: ChatViewProps) {
11617 onAddPullRequest={addPullRequestSurface}11681 onAddPullRequest={addPullRequestSurface}
11618 onAddPullRequests={addPullRequestsSurface}11682 onAddPullRequests={addPullRequestsSurface}
11619 onAddDevice={addDeviceSurface}11683 onAddDevice={addDeviceSurface}
11620 browserAvailable={isPreviewSupportedInRuntime()}11684 browserAvailable={browserAvailable}
11621 terminalAvailable={activeProject !== null}11685 terminalAvailable={activeProject !== null}
11622 diffAvailable={isServerThread && isGitRepo}11686 diffAvailable={isServerThread && isGitRepo}
11623 filesAvailable={activeProject !== null}11687 filesAvailable={activeProject !== null}
...@@ -11679,3 +11743,9 @@ export default function ChatView(props: ChatViewProps) {...@@ -11679,3 +11743,9 @@ export default function ChatView(props: ChatViewProps) {
11679 </div>11743 </div>
11680 );11744 );
11681}11745}
11746
11747/** Keeps the thread's preview tabs synced while no browser panel is mounted. */
11748function PreviewSessionSync(props: { readonly threadRef: ScopedThreadRef }) {
11749 usePreviewSession(props.threadRef);
11750 return null;
11751}
apps/web/src/components/CommandBlock.tsx created+45
...@@ -0,0 +1,45 @@
1import { Check, Copy } from "lucide";
2
3import { MorphIcon } from "~/components/MorphIcon";
4import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
5import { cn } from "~/lib/utils";
6
7import { Button } from "./ui/button";
8
9/** A shell command to copy and run elsewhere, such as on the environment's host. */
10export function CommandBlock({
11 command,
12 className,
13 prominent = false,
14}: {
15 readonly command: string;
16 readonly className?: string;
17 readonly prominent?: boolean;
18}) {
19 const { copyToClipboard, isCopied } = useCopyToClipboard({
20 timeout: 1500,
21 target: "command",
22 });
23 return (
24 <div
25 className={cn(
26 "flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/50 font-mono",
27 prominent ? "px-4 py-3.5 text-base" : "px-3 py-2.5 text-sm",
28 className,
29 )}
30 >
31 <span className="min-w-0 truncate">
32 <span className="mr-2 text-muted-foreground">$</span>
33 {command}
34 </span>
35 <Button
36 size="icon-xs"
37 variant="ghost"
38 aria-label="Copy command"
39 onClick={() => copyToClipboard(command, undefined)}
40 >
41 <MorphIcon className="size-3.5" icon={isCopied ? Check : Copy} />
42 </Button>
43 </div>
44 );
45}
apps/web/src/components/files/FilePreviewPanel.tsx+3-2
...@@ -40,13 +40,13 @@ import { resolveDiffThemeName } from "~/lib/diffRendering";...@@ -40,13 +40,13 @@ import { resolveDiffThemeName } from "~/lib/diffRendering";
40import { PREFERRED_HIGHLIGHTER } from "~/lib/syntaxHighlighting";40import { PREFERRED_HIGHLIGHTER } from "~/lib/syntaxHighlighting";
41import { cn } from "~/lib/utils";41import { cn } from "~/lib/utils";
42import type { ChatFileAttachment } from "~/types";42import type { ChatFileAttachment } from "~/types";
43import { isPreviewSupportedInRuntime } from "~/previewStateStore";
44import { isAbsolutePath, resolvePathLinkTarget } from "~/terminal-links";43import { isAbsolutePath, resolvePathLinkTarget } from "~/terminal-links";
45import { ScrollArea } from "~/components/ui/scroll-area";44import { ScrollArea } from "~/components/ui/scroll-area";
46import { stackedThreadToast, toastManager } from "~/components/ui/toast";45import { stackedThreadToast, toastManager } from "~/components/ui/toast";
47import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";46import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";
48import { buildFileReviewComment } from "~/reviewCommentContext";47import { buildFileReviewComment } from "~/reviewCommentContext";
49import { assetEnvironment } from "~/state/assets";48import { assetEnvironment } from "~/state/assets";
49import { usePreviewAvailable } from "~/browser/previewRuntime";
50import { useEnvironmentHttpBaseUrl, usePrimaryEnvironmentId } from "~/state/environments";50import { useEnvironmentHttpBaseUrl, usePrimaryEnvironmentId } from "~/state/environments";
51import { previewEnvironment } from "~/state/preview";51import { previewEnvironment } from "~/state/preview";
52import { useAtomCommand } from "~/state/use-atom-command";52import { useAtomCommand } from "~/state/use-atom-command";
...@@ -930,6 +930,7 @@ export default function FilePreviewPanel({...@@ -930,6 +930,7 @@ export default function FilePreviewPanel({
930 const primaryEnvironmentId = usePrimaryEnvironmentId();930 const primaryEnvironmentId = usePrimaryEnvironmentId();
931 const remoteOpenState = useRemoteOpenState(environmentId);931 const remoteOpenState = useRemoteOpenState(environmentId);
932 const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(environmentId);932 const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(environmentId);
933 const previewAvailable = usePreviewAvailable(environmentId);
933 const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {934 const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
934 reportFailure: false,935 reportFailure: false,
935 });936 });
...@@ -1032,7 +1033,7 @@ export default function FilePreviewPanel({...@@ -1032,7 +1033,7 @@ export default function FilePreviewPanel({
1032 previewPath !== null &&1033 previewPath !== null &&
1033 attachment === undefined &&1034 attachment === undefined &&
1034 !isVideo &&1035 !isVideo &&
1035 isPreviewSupportedInRuntime() &&1036 previewAvailable &&
1036 isBrowserPreviewFile(previewPath);1037 isBrowserPreviewFile(previewPath);
1037 const absolutePath =1038 const absolutePath =
1038 relativePath && attachment === undefined ? resolvePathLinkTarget(relativePath, cwd) : null;1039 relativePath && attachment === undefined ? resolvePathLinkTarget(relativePath, cwd) : null;
apps/web/src/components/onboarding/WelcomeWizard.tsx+1-40
...@@ -30,7 +30,6 @@ import {...@@ -30,7 +30,6 @@ import {
30 MonitorIcon,30 MonitorIcon,
31 TerminalIcon,31 TerminalIcon,
32} from "lucide-react";32} from "lucide-react";
33import { Check, Copy } from "lucide";
34import { useCallback, useEffect, useMemo, useRef, useState } from "react";33import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3534
36import { TYPOGRAPHY_ADVANCED_STORAGE_KEY } from "../../appearanceFonts";35import { TYPOGRAPHY_ADVANCED_STORAGE_KEY } from "../../appearanceFonts";
...@@ -52,7 +51,6 @@ import {...@@ -52,7 +51,6 @@ import {
52 resolveOnboardingProviderLoginCommand,51 resolveOnboardingProviderLoginCommand,
53 selectOnboardingProvidersByDriver,52 selectOnboardingProvidersByDriver,
54} from "../../onboarding/providerReadiness.logic";53} from "../../onboarding/providerReadiness.logic";
55import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
56import { newProjectId, randomUUID } from "../../lib/utils";54import { newProjectId, randomUUID } from "../../lib/utils";
57import { agentSessionImport } from "../../state/agentSessions";55import { agentSessionImport } from "../../state/agentSessions";
58import { readProjects, useProjects } from "../../state/entities";56import { readProjects, useProjects } from "../../state/entities";
...@@ -76,8 +74,8 @@ import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";...@@ -76,8 +74,8 @@ import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
76import { T3Wordmark } from "../T3Wordmark";74import { T3Wordmark } from "../T3Wordmark";
77import { Alert, AlertDescription } from "../ui/alert";75import { Alert, AlertDescription } from "../ui/alert";
78import { Button } from "../ui/button";76import { Button } from "../ui/button";
77import { CommandBlock } from "../CommandBlock";
79import { Checkbox } from "../ui/checkbox";78import { Checkbox } from "../ui/checkbox";
80import { MorphIcon } from "~/components/MorphIcon";
81import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible";79import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible";
82import { Input } from "../ui/input";80import { Input } from "../ui/input";
83import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";81import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";
...@@ -1713,40 +1711,3 @@ function StepShell({...@@ -1713,40 +1711,3 @@ function StepShell({
1713 </>1711 </>
1714 );1712 );
1715}1713}
1716
1717function CommandBlock({
1718 command,
1719 className,
1720 prominent = false,
1721}: {
1722 readonly command: string;
1723 readonly className?: string;
1724 readonly prominent?: boolean;
1725}) {
1726 const { copyToClipboard, isCopied } = useCopyToClipboard({
1727 timeout: 1500,
1728 target: "command",
1729 });
1730 return (
1731 <div
1732 className={cn(
1733 "flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/50 font-mono",
1734 prominent ? "px-4 py-3.5 text-base" : "px-3 py-2.5 text-sm",
1735 className,
1736 )}
1737 >
1738 <span className="min-w-0 truncate">
1739 <span className="mr-2 text-muted-foreground">$</span>
1740 {command}
1741 </span>
1742 <Button
1743 size="icon-xs"
1744 variant="ghost"
1745 aria-label="Copy command"
1746 onClick={() => copyToClipboard(command, undefined)}
1747 >
1748 <MorphIcon className="size-3.5" icon={isCopied ? Check : Copy} />
1749 </Button>
1750 </div>
1751 );
1752}
apps/web/src/components/preview/AgentBrowserCursor.tsx+27-6
...@@ -45,27 +45,48 @@ function AgentBrowserCursorEvent(props: {...@@ -45,27 +45,48 @@ function AgentBrowserCursorEvent(props: {
45 readonly controller: BrowserController;45 readonly controller: BrowserController;
46}) {46}) {
47 const { event, content, zoomFactor, controller } = props;47 const { event, content, zoomFactor, controller } = props;
48 const scale = zoomFactor * (content?.scale ?? 1);
49 return (
50 <AgentCursorMark
51 phase={event.phase}
52 sequence={event.sequence}
53 left={event.x * scale + (content?.x ?? 0) - (content?.scrollLeft ?? 0)}
54 top={event.y * scale + (content?.y ?? 0) - (content?.scrollTop ?? 0)}
55 controller={controller}
56 />
57 );
58}
59
60/** The agent's pointer at a surface position; it fades once the agent stops acting. */
61export function AgentCursorMark(props: {
62 readonly phase: "move" | "click";
63 readonly sequence: number;
64 readonly left: number;
65 readonly top: number;
66 readonly controller: BrowserController;
67}) {
68 const { phase, sequence, left, top, controller } = props;
48 const [inactiveSequence, setInactiveSequence] = useState<number | null>(null);69 const [inactiveSequence, setInactiveSequence] = useState<number | null>(null);
49 const active = inactiveSequence !== event.sequence;70 const active = inactiveSequence !== sequence;
5071
51 useEffect(() => {72 useEffect(() => {
52 const timeout = window.setTimeout(() => setInactiveSequence(event.sequence), CURSOR_ACTIVE_MS);73 const timeout = window.setTimeout(() => setInactiveSequence(sequence), CURSOR_ACTIVE_MS);
53 return () => window.clearTimeout(timeout);74 return () => window.clearTimeout(timeout);
54 }, [event.sequence]);75 }, [sequence]);
5576
56 return (77 return (
57 <div78 <div
58 className="pointer-events-none absolute left-0 top-0 z-40 transition-[opacity,transform] duration-150 ease-out motion-reduce:transition-none"79 className="pointer-events-none absolute left-0 top-0 z-40 transition-[opacity,transform] duration-150 ease-out motion-reduce:transition-none"
59 style={{80 style={{
60 opacity: agentBrowserCursorOpacity(active, controller),81 opacity: agentBrowserCursorOpacity(active, controller),
61 transform: `translate3d(${event.x * zoomFactor * (content?.scale ?? 1) + (content?.x ?? 0) - (content?.scrollLeft ?? 0)}px, ${event.y * zoomFactor * (content?.scale ?? 1) + (content?.y ?? 0) - (content?.scrollTop ?? 0)}px, 0)`,82 transform: `translate3d(${left}px, ${top}px, 0)`,
62 }}83 }}
63 aria-hidden="true"84 aria-hidden="true"
64 data-agent-browser-cursor85 data-agent-browser-cursor
65 >86 >
66 {event.phase === "click" ? (87 {phase === "click" ? (
67 <span88 <span
68 key={event.sequence}89 key={sequence}
69 className="absolute left-0.5 top-0.5 size-4 animate-status-ping rounded-full bg-primary/25 motion-reduce:animate-none"90 className="absolute left-0.5 top-0.5 size-4 animate-status-ping rounded-full bg-primary/25 motion-reduce:animate-none"
70 />91 />
71 ) : null}92 ) : null}
apps/web/src/components/preview/PreviewAutomationHosts.test.tsx deleted-363
...@@ -1,363 +0,0 @@
1import {
2 DEFAULT_CLIENT_SETTINGS,
3 EnvironmentId,
4 ThreadId,
5 type ClientSettings,
6 type PreviewAutomationResponse,
7 type PreviewAutomationStreamEvent,
8 type PreviewOpenInput,
9 type PreviewSessionSnapshot,
10} from "@t3tools/contracts";
11import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
12import * as Cause from "effect/Cause";
13import { AsyncResult, Atom } from "effect/reactivity";
14import { act } from "react";
15import { create, type ReactTestRenderer } from "react-test-renderer";
16import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
17
18import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore";
19import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";
20import { __resetClientSettingsPersistenceForTests } from "~/hooks/useSettings";
21import {
22 applyPreviewServerSnapshot,
23 readThreadPreviewState,
24 resetPreviewStateForTests,
25 applyPreviewDesktopState,
26} from "~/previewStateStore";
27import { appAtomRegistry, AppAtomRegistryProvider } from "~/rpc/atomRegistry";
28
29import { PreviewAutomationHosts } from "./PreviewAutomationHosts";
30
31const mocks = vi.hoisted(() => ({
32 getClientSettings: vi.fn<() => Promise<ClientSettings | null>>(),
33 setClientSettings: vi.fn(),
34 open: vi.fn(async (_target: { environmentId: EnvironmentId; input: PreviewOpenInput }) =>
35 AsyncResult.success(snapshot),
36 ),
37 list: vi.fn(async () => AsyncResult.success(emptyList)),
38 resize: vi.fn(),
39 respond:
40 vi.fn<
41 (target: { environmentId: EnvironmentId; input: PreviewAutomationResponse }) => Promise<void>
42 >(),
43 focus: vi.fn<() => Promise<AtomCommandResult<void, Error>>>(),
44}));
45
46vi.mock("~/localApi", () => ({
47 ensureLocalApi: () => ({ persistence: mocks }),
48}));
49vi.mock("~/env", () => ({ isElectron: true }));
50vi.mock("~/state/environments", () => ({
51 useEnvironments: () => ({ environments: [{ environmentId }] }),
52}));
53vi.mock("~/state/preview", () => ({
54 previewEnvironment: {
55 automationRequests: () => requestsAtom,
56 list: () => listAtom,
57 open: mocks.open,
58 resize: mocks.resize,
59 respondToAutomation: mocks.respond,
60 focusAutomationHost: mocks.focus,
61 },
62}));
63vi.mock("~/state/use-atom-command", () => ({
64 useAtomCommand: (command: unknown) => command,
65}));
66vi.mock("~/state/use-atom-query-runner", () => ({
67 useAtomQueryRunner: () => mocks.list,
68}));
69vi.mock("./previewBridge", () => ({ previewBridge: { automation: {} } }));
70
71const environmentId = EnvironmentId.make("automation-environment");
72const threadId = ThreadId.make("automation-thread");
73const threadRef = { environmentId, threadId };
74const viewport = { _tag: "freeform", width: 1440, height: 900 } as const;
75const savedSettings: ClientSettings = {
76 ...DEFAULT_CLIENT_SETTINGS,
77 browserDefaultViewport: viewport,
78 browserDefaultProfileId: "work",
79 browserProfiles: [{ id: "work", name: "Work", kind: "persistent" }],
80};
81const snapshot: PreviewSessionSnapshot = {
82 threadId,
83 tabId: "automation-tab",
84 navStatus: { _tag: "Idle" },
85 canGoBack: false,
86 canGoForward: false,
87 viewport,
88 profileId: "work",
89 updatedAt: "2026-09-05T00:00:00.000Z",
90};
91const emptyList = { sessions: [], serverEpoch: "test-server", revision: 0 };
92const listAtom = Atom.make(AsyncResult.success(emptyList));
93const requestsAtom = Atom.make<AsyncResult.AsyncResult<PreviewAutomationStreamEvent, Error>>(
94 AsyncResult.initial(false),
95);
96const requestEvent: PreviewAutomationStreamEvent = {
97 type: "request",
98 connectionId: "automation-connection",
99 request: {
100 requestId: "open-request",
101 threadId,
102 operation: "open",
103 input: { open: false, reuseExistingTab: false },
104 timeoutMs: 15_000,
105 },
106};
107
108function deferred<A>() {
109 let resolve!: (value: A) => void;
110 const promise = new Promise<A>((complete) => {
111 resolve = complete;
112 });
113 return { promise, resolve };
114}
115
116let renderer: ReactTestRenderer | null = null;
117
118beforeEach(async () => {
119 vi.clearAllMocks();
120 mocks.getClientSettings.mockReset().mockResolvedValue(savedSettings);
121 mocks.respond.mockReset();
122 mocks.focus.mockReset().mockResolvedValue(AsyncResult.success(undefined));
123 __resetClientSettingsPersistenceForTests();
124 resetPreviewStateForTests();
125 useBrowserSurfaceStore.setState({ byTabId: {} });
126 appAtomRegistry.set(requestsAtom, AsyncResult.initial(false));
127 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
128 vi.stubGlobal("window", { addEventListener: vi.fn(), removeEventListener: vi.fn() });
129 vi.stubGlobal(
130 "document",
131 Object.assign(new EventTarget(), {
132 hasFocus: () => false,
133 visibilityState: "visible",
134 querySelectorAll: () => [],
135 }),
136 );
137 await act(() => {
138 renderer = create(
139 <AppAtomRegistryProvider>
140 <PreviewAutomationHosts />
141 </AppAtomRegistryProvider>,
142 );
143 });
144});
145
146afterEach(async () => {
147 await act(() => renderer?.unmount());
148 renderer = null;
149 resetPreviewStateForTests();
150 __resetClientSettingsPersistenceForTests();
151 vi.unstubAllGlobals();
152 vi.restoreAllMocks();
153});
154
155describe("PreviewAutomationHosts open", () => {
156 it("waits for saved settings before opening a tab with the configured profile and viewport", async () => {
157 const readStarted = deferred<void>();
158 const read = deferred<ClientSettings>();
159 const response = deferred<PreviewAutomationResponse>();
160 mocks.getClientSettings.mockImplementationOnce(() => {
161 readStarted.resolve();
162 return read.promise;
163 });
164 mocks.respond.mockImplementationOnce(async ({ input }) => response.resolve(input));
165
166 await act(async () => {
167 appAtomRegistry.set(requestsAtom, AsyncResult.success(requestEvent));
168 await readStarted.promise;
169 });
170 expect(mocks.open).not.toHaveBeenCalled();
171
172 await act(async () => {
173 read.resolve(savedSettings);
174 await response.promise;
175 });
176
177 expect(mocks.open).toHaveBeenCalledExactlyOnceWith({
178 environmentId,
179 input: { threadId, viewport, profileId: "work" },
180 });
181 expect(mocks.getClientSettings).toHaveBeenCalledOnce();
182 await expect(response.promise).resolves.toMatchObject({
183 requestId: "open-request",
184 ok: true,
185 result: { available: false, tabId: snapshot.tabId },
186 });
187 expect(readThreadPreviewState(threadRef).snapshot).toEqual(snapshot);
188 expect(mocks.setClientSettings).not.toHaveBeenCalled();
189 });
190
191 it("reports a settings read failure without opening a tab", async () => {
192 vi.spyOn(console, "error").mockImplementation(() => undefined);
193 mocks.getClientSettings.mockRejectedValueOnce(new Error("Settings read failed"));
194 const response = deferred<PreviewAutomationResponse>();
195 mocks.respond.mockImplementationOnce(async ({ input }) => response.resolve(input));
196
197 await act(async () => {
198 appAtomRegistry.set(requestsAtom, AsyncResult.success(requestEvent));
199 await response.promise;
200 });
201
202 await expect(response.promise).resolves.toMatchObject({
203 requestId: "open-request",
204 ok: false,
205 error: { _tag: "PreviewAutomationExecutionError" },
206 });
207 expect(mocks.getClientSettings).toHaveBeenCalledOnce();
208 expect(mocks.open).not.toHaveBeenCalled();
209 expect(readThreadPreviewState(threadRef).snapshot).toBeNull();
210 expect(mocks.setClientSettings).not.toHaveBeenCalled();
211 });
212});
213
214describe("PreviewAutomationHosts ownership", () => {
215 it("reports only local live tabs and removes ownership when their web contents close", async () => {
216 await act(() => {
217 appAtomRegistry.set(
218 requestsAtom,
219 AsyncResult.success({ type: "connected", connectionId: "automation-connection" }),
220 );
221 applyPreviewServerSnapshot(threadRef, snapshot);
222 });
223 expect(mocks.focus).toHaveBeenLastCalledWith(
224 expect.objectContaining({ input: expect.objectContaining({ liveTabs: [] }) }),
225 );
226 const overlay = {
227 hasWebContents: true,
228 canGoBack: false,
229 canGoForward: false,
230 loading: false,
231 zoomFactor: 1,
232 pictureInPicture: false,
233 colorScheme: "system" as const,
234 audioMuted: false,
235 audible: false,
236 controller: "none" as const,
237 favicon: null,
238 };
239 await act(() => applyPreviewDesktopState(threadRef, snapshot.tabId, overlay));
240 expect(mocks.focus).toHaveBeenLastCalledWith(
241 expect.objectContaining({
242 input: expect.objectContaining({
243 liveTabs: [{ threadId, tabId: snapshot.tabId, visible: false }],
244 }),
245 }),
246 );
247 const reportCount = mocks.focus.mock.calls.length;
248 await act(() =>
249 applyPreviewDesktopState(threadRef, snapshot.tabId, { ...overlay, loading: true }),
250 );
251 expect(mocks.focus).toHaveBeenCalledTimes(reportCount);
252 const runtimeTabId = previewRuntimeTabId(threadRef, null, snapshot.tabId);
253 const owner = Symbol();
254 await act(() => {
255 useBrowserSurfaceStore.getState().claim(runtimeTabId, owner, false);
256 useBrowserSurfaceStore
257 .getState()
258 .present(runtimeTabId, owner, { x: 0, y: 0, width: 800, height: 600 }, true, 0, 1);
259 });
260 expect(mocks.focus).toHaveBeenLastCalledWith(
261 expect.objectContaining({
262 input: expect.objectContaining({
263 liveTabs: [{ threadId, tabId: snapshot.tabId, visible: true }],
264 }),
265 }),
266 );
267 for (const visibilityState of ["hidden", "visible"]) {
268 await act(() => {
269 Object.assign(document, { visibilityState });
270 document.dispatchEvent(new Event("visibilitychange"));
271 });
272 expect(mocks.focus).toHaveBeenLastCalledWith(
273 expect.objectContaining({
274 input: expect.objectContaining({
275 focused: false,
276 liveTabs: [{ threadId, tabId: snapshot.tabId, visible: visibilityState === "visible" }],
277 }),
278 }),
279 );
280 }
281 await act(() => {
282 appAtomRegistry.set(
283 requestsAtom,
284 AsyncResult.success({ type: "connected", connectionId: "reconnected" }),
285 );
286 });
287 expect(mocks.focus).toHaveBeenLastCalledWith(
288 expect.objectContaining({
289 input: expect.objectContaining({
290 connectionId: "reconnected",
291 liveTabs: [{ threadId, tabId: snapshot.tabId, visible: true }],
292 }),
293 }),
294 );
295 await act(() =>
296 applyPreviewDesktopState(threadRef, snapshot.tabId, { ...overlay, hasWebContents: false }),
297 );
298 expect(mocks.focus).toHaveBeenLastCalledWith(
299 expect.objectContaining({ input: expect.objectContaining({ liveTabs: [] }) }),
300 );
301 });
302
303 it.each([false, true])(
304 "retries failed reports without clearing newer connection reports (reconnect: %s)",
305 async (reconnect) => {
306 const report = deferred<Awaited<ReturnType<typeof mocks.focus>>>();
307 mocks.focus.mockReturnValueOnce(report.promise);
308 await act(() => {
309 appAtomRegistry.set(
310 requestsAtom,
311 AsyncResult.success({ type: "connected", connectionId: "first" }),
312 );
313 });
314 if (reconnect) {
315 await act(() => {
316 appAtomRegistry.set(
317 requestsAtom,
318 AsyncResult.success({ type: "connected", connectionId: "second" }),
319 );
320 });
321 }
322 await act(async () => {
323 report.resolve(AsyncResult.failure(Cause.fail(new Error("Focus report failed"))));
324 await report.promise;
325 });
326 expect(mocks.focus).toHaveBeenCalledTimes(reconnect ? 2 : 1);
327 await act(() => {
328 document.dispatchEvent(new Event("visibilitychange"));
329 });
330 expect(mocks.focus).toHaveBeenCalledTimes(2);
331 expect(mocks.focus).toHaveBeenLastCalledWith(
332 expect.objectContaining({
333 input: expect.objectContaining({ connectionId: reconnect ? "second" : "first" }),
334 }),
335 );
336 },
337 );
338
339 it("does not claim an available runtime from a server snapshot alone", async () => {
340 const response = deferred<PreviewAutomationResponse>();
341 mocks.respond.mockImplementationOnce(async ({ input }) => response.resolve(input));
342 await act(async () => {
343 applyPreviewServerSnapshot(threadRef, snapshot);
344 appAtomRegistry.set(
345 requestsAtom,
346 AsyncResult.success({
347 ...requestEvent,
348 request: {
349 ...requestEvent.request,
350 operation: "status",
351 tabId: snapshot.tabId,
352 input: {},
353 },
354 }),
355 );
356 await response.promise;
357 });
358 await expect(response.promise).resolves.toMatchObject({
359 ok: true,
360 result: { available: false, tabId: snapshot.tabId },
361 });
362 });
363});
apps/web/src/components/preview/PreviewAutomationHosts.tsx deleted-868
...@@ -1,868 +0,0 @@
1"use client";
2
3import { RegistryContext, useAtomSet, useAtomValue } from "@effect/atom-react";
4import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";
5import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
6import {
7 FILL_PREVIEW_VIEWPORT,
8 PREVIEW_AUTOMATION_OPERATIONS,
9 type EnvironmentId,
10 type PreviewAutomationNavigateInput,
11 type PreviewAutomationOpenInput,
12 type PreviewAutomationResizeInput,
13 type PreviewAutomationResizeResult,
14 type PreviewAutomationSetColorSchemeInput,
15 type PreviewAutomationSetColorSchemeResult,
16 type PreviewAutomationHost as PreviewAutomationHostState,
17 type PreviewAutomationRequest,
18 type PreviewAutomationStatus,
19 type PreviewRenderedViewportSize,
20 type PreviewViewportSetting,
21 type ScopedThreadRef,
22} from "@t3tools/contracts";
23import { resolvePreviewViewport } from "@t3tools/shared/previewViewport";
24import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
25import { Atom } from "effect/reactivity";
26import { useShallow } from "zustand/react/shallow";
27
28import {
29 applyPreviewServerSnapshot,
30 readThreadPreviewState,
31 reconcilePreviewServerSessions,
32 updatePreviewServerSnapshot,
33 useActivePreviewSessions,
34} from "~/previewStateStore";
35import {
36 browserMiniPlayerSource,
37 selectThreadPreviewMiniPlayerTabId,
38 usePreviewMiniPlayerStore,
39} from "~/previewMiniPlayerStore";
40import { resolveBrowserNavigationTarget } from "~/browser/browserTargetResolver";
41import {
42 readActiveBrowserRecordingTargets,
43 startBrowserRecording,
44 stopBrowserRecording,
45 stopBrowserRecordingForUpload,
46} from "~/browser/browserRecording";
47import { resolveBrowserRecordingStopTarget } from "~/browser/browserRecordingScope";
48import { uploadBrowserRecording } from "~/browser/browserRecordingUpload";
49import {
50 acquireBrowserSurfaceActivity,
51 useBrowserSurfaceStore,
52} from "~/browser/browserSurfaceStore";
53import {
54 browserDefaultOpenProfileId,
55 browserDefaultOpenViewport,
56 resolveBrowserDefaults,
57} from "~/browser/browserDefaults";
58import { runBrowserViewportMutation } from "~/browser/browserViewportActions";
59import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";
60import { isElectron } from "~/env";
61import { useEnvironments } from "~/state/environments";
62import { previewEnvironment } from "~/state/preview";
63import { useAtomQueryRunner } from "~/state/use-atom-query-runner";
64import { useAtomCommand } from "~/state/use-atom-command";
65
66import { previewBridge } from "./previewBridge";
67import {
68 PreviewAutomationOperationError,
69 PreviewAutomationOverlayTimeoutError,
70 PreviewAutomationRecordingNotActiveError,
71 PreviewAutomationTargetUnavailableError,
72 PreviewAutomationViewportTimeoutError,
73} from "./previewAutomationErrors";
74import {
75 explicitlySuppressesPreviewMiniPlayer,
76 previewAutomationDefaultViewport,
77 previewAutomationOpenNeedsOverlay,
78 shouldAutoShowPreviewForAutomationUse,
79 shouldOpenPreviewMiniPlayer,
80} from "./previewAutomationOpenReadiness";
81import {
82 assertPreviewRuntimeCurrent,
83 waitForNavigationReadiness,
84} from "./previewNavigationReadiness";
85import { createPreviewAutomationRequestConsumerAtom } from "./previewAutomationRequestConsumer";
86import { createPreviewAutomationClientId } from "./previewAutomationClientId";
87import {
88 needsPreviewAutomationSessionSync,
89 resolvePreviewAutomationOpenTab,
90 resolvePreviewAutomationTarget,
91} from "./previewAutomationTarget";
92import { resolveHostWaitBudgetMs, waitForHostReadiness } from "./previewAutomationHostBudget";
93import { runPreviewClickKeepingHostFocus } from "./previewClickFocus";
94import { isPreviewViewportReady } from "./previewViewportReadiness";
95import { shouldRollbackPreviewViewport } from "./previewViewportRollback";
96
97const PREVIEW_PRESENTATION_SETTLE_TIMEOUT_MS = 500;
98
99const waitForPreviewPresentation = async (runtimeTabId: string): Promise<void> => {
100 const deadline = Date.now() + PREVIEW_PRESENTATION_SETTLE_TIMEOUT_MS;
101 while (Date.now() <= deadline) {
102 if (useBrowserSurfaceStore.getState().byTabId[runtimeTabId]?.visible) return;
103 await new Promise<void>((resolve) => window.setTimeout(resolve, 16));
104 }
105};
106
107const waitForDesktopOverlay = async (
108 threadRef: ScopedThreadRef,
109 requestId: string,
110 tabId: string,
111 runtimeTabId: string,
112 operation: PreviewAutomationRequest["operation"],
113 deadlineMs: number,
114): Promise<void> => {
115 const waitBudgetMs = Math.max(0, deadlineMs - Date.now());
116 const ready = await waitForHostReadiness(deadlineMs, async () => {
117 const state = assertPreviewRuntimeCurrent(threadRef, tabId, runtimeTabId, {
118 operation,
119 requestId,
120 });
121 if (state.desktopByTabId[tabId] && previewBridge && isPreviewWebviewRendering(runtimeTabId)) {
122 const status = await previewBridge.automation.status(runtimeTabId);
123 return status.available;
124 }
125 return false;
126 });
127 if (ready) return;
128 throw new PreviewAutomationOverlayTimeoutError({
129 requestId,
130 environmentId: threadRef.environmentId,
131 threadId: threadRef.threadId,
132 timeoutMs: waitBudgetMs,
133 });
134};
135
136interface ExecutablePreviewWebview extends Element {
137 readonly executeJavaScript: (code: string, userGesture?: boolean) => Promise<unknown>;
138}
139
140const findPreviewWebview = (tabId: string): ExecutablePreviewWebview | null =>
141 Array.from(document.querySelectorAll<ExecutablePreviewWebview>("webview[data-preview-tab]")).find(
142 (candidate) => candidate.getAttribute("data-preview-tab") === tabId,
143 ) ?? null;
144
145const isPreviewWebviewRendering = (runtimeTabId: string): boolean => {
146 const wrapper = findPreviewWebview(runtimeTabId)?.closest<HTMLElement>("[data-preview-viewport]");
147 return wrapper?.getAttribute("data-preview-rendering") === "active";
148};
149
150const readWebviewViewport = async (
151 webview: ExecutablePreviewWebview,
152): Promise<PreviewRenderedViewportSize | null> => {
153 const value = await webview.executeJavaScript(
154 "({ width: window.innerWidth, height: window.innerHeight })",
155 );
156 if (typeof value !== "object" || value === null) return null;
157 const { width, height } = value as { readonly width?: unknown; readonly height?: unknown };
158 return typeof width === "number" &&
159 Number.isInteger(width) &&
160 width > 0 &&
161 typeof height === "number" &&
162 Number.isInteger(height) &&
163 height > 0
164 ? { width, height }
165 : null;
166};
167
168const readRenderedViewport = async (
169 runtimeTabId: string,
170): Promise<PreviewRenderedViewportSize | null> => {
171 const webview = findPreviewWebview(runtimeTabId);
172 if (!webview) return null;
173 return await readWebviewViewport(webview);
174};
175
176const readDeclaredViewport = (
177 webview: ExecutablePreviewWebview | null,
178): PreviewRenderedViewportSize | null => {
179 const width = Number(webview?.getAttribute("data-preview-css-width"));
180 const height = Number(webview?.getAttribute("data-preview-css-height"));
181 return Number.isInteger(width) && width > 0 && Number.isInteger(height) && height > 0
182 ? { width, height }
183 : null;
184};
185
186const waitForRenderedViewport = async (
187 threadRef: ScopedThreadRef,
188 tabId: string,
189 runtimeTabId: string,
190 setting: PreviewViewportSetting,
191 timeoutMs: number,
192 context: {
193 readonly requestId: PreviewAutomationRequest["requestId"];
194 readonly operation: PreviewAutomationRequest["operation"];
195 readonly environmentId: EnvironmentId;
196 readonly threadId: PreviewAutomationRequest["threadId"];
197 },
198): Promise<PreviewRenderedViewportSize> => {
199 const deadline = Date.now() + timeoutMs;
200 while (Date.now() <= deadline) {
201 assertPreviewRuntimeCurrent(threadRef, tabId, runtimeTabId, context);
202 try {
203 const webview = findPreviewWebview(runtimeTabId);
204 const appliedSettingKey = webview?.getAttribute("data-preview-viewport-key") ?? null;
205 const declaredViewport = readDeclaredViewport(webview);
206 const renderedViewport = webview ? await readWebviewViewport(webview) : null;
207 if (
208 renderedViewport &&
209 isPreviewViewportReady({
210 setting,
211 appliedSettingKey,
212 declaredViewport,
213 renderedViewport,
214 })
215 ) {
216 return renderedViewport;
217 }
218 } catch {
219 // Registration and navigation can transiently replace the guest while
220 // React applies the server snapshot. Retry until the operation deadline.
221 }
222 await new Promise<void>((resolve) => window.setTimeout(resolve, 50));
223 }
224 throw new PreviewAutomationViewportTimeoutError({
225 ...context,
226 tabId,
227 timeoutMs,
228 });
229};
230
231const currentStatus = async (
232 threadRef: ScopedThreadRef,
233 requestedTabId: string | null,
234): Promise<PreviewAutomationStatus> => {
235 const state = readThreadPreviewState(threadRef);
236 const { snapshot, tabId } = resolvePreviewAutomationTarget(state, requestedTabId);
237 const runtimeTabId = tabId ? previewRuntimeTabId(threadRef, state.serverEpoch, tabId) : null;
238 const visible = runtimeTabId
239 ? (useBrowserSurfaceStore.getState().byTabId[runtimeTabId]?.visible ?? false)
240 : false;
241 const renderingActive = runtimeTabId ? isPreviewWebviewRendering(runtimeTabId) : false;
242 const viewportSetting = snapshot ? (snapshot.viewport ?? FILL_PREVIEW_VIEWPORT) : undefined;
243 const viewport =
244 runtimeTabId && renderingActive
245 ? await readRenderedViewport(runtimeTabId).catch(() => null)
246 : null;
247 const viewportStatus = {
248 ...(viewportSetting === undefined ? {} : { viewportSetting }),
249 ...(viewport === null ? {} : { viewport }),
250 };
251 if (runtimeTabId && tabId && previewBridge && state.desktopByTabId[tabId]) {
252 const status = await previewBridge.automation.status(runtimeTabId);
253 return { ...status, tabId, visible, ...viewportStatus };
254 }
255 const navStatus = snapshot?.navStatus;
256 return {
257 available: false,
258 visible,
259 tabId,
260 url: navStatus && navStatus._tag !== "Idle" ? navStatus.url : null,
261 title: navStatus && navStatus._tag !== "Idle" ? navStatus.title : null,
262 loading: navStatus?._tag === "Loading",
263 ...viewportStatus,
264 };
265};
266
267const raiseAtomCommandFailure = (result: Parameters<typeof squashAtomCommandFailure>[0]): never => {
268 throw squashAtomCommandFailure(result);
269};
270
271const raisePreviewAutomationHostError = (
272 error: PreviewAutomationRecordingNotActiveError,
273): never => {
274 throw error;
275};
276
277export function PreviewAutomationHosts() {
278 const { environments } = useEnvironments();
279 if (!isElectron || !previewBridge?.automation) return null;
280 return (
281 <>
282 {/*
283 * Host lifetime follows the desktop runtime's environment connections,
284 * not the routed thread. This keeps background threads automatable and
285 * lets the subscription runtime own reconnects for every saved target.
286 */}
287 {environments.map((environment) => (
288 <PreviewAutomationHost
289 key={environment.environmentId}
290 environmentId={environment.environmentId}
291 />
292 ))}
293 </>
294 );
295}
296
297function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) {
298 const { environmentId } = props;
299 const previewSessions = useActivePreviewSessions();
300 const visibleRuntimeTabIds = useBrowserSurfaceStore(
301 useShallow((state) =>
302 Object.keys(state.byTabId).filter((tabId) => state.byTabId[tabId]?.visible),
303 ),
304 );
305 const liveTabs = useMemo(
306 () =>
307 Object.entries(previewSessions).flatMap(([key, state]) => {
308 const ref = parseScopedThreadKey(key);
309 if (ref?.environmentId !== environmentId) return [];
310 return Object.values(state.sessions)
311 .filter((tab) => state.desktopByTabId[tab.tabId]?.hasWebContents)
312 .map((tab) => ({
313 threadId: ref.threadId,
314 tabId: tab.tabId,
315 visible: visibleRuntimeTabIds.includes(
316 previewRuntimeTabId(ref, state.serverEpoch, tab.tabId),
317 ),
318 }));
319 }),
320 [environmentId, previewSessions, visibleRuntimeTabIds],
321 );
322 const lastFocusReportRef = useRef<string | null>(null);
323 const registry = useContext(RegistryContext);
324 const [automationClientId] = useState(createPreviewAutomationClientId);
325 const initialAutomationHost = useMemo<PreviewAutomationHostState>(
326 () => ({
327 clientId: automationClientId,
328 environmentId,
329 supportedOperations: [...PREVIEW_AUTOMATION_OPERATIONS],
330 }),
331 [automationClientId, environmentId],
332 );
333 const automationRequestsAtom = previewEnvironment.automationRequests({
334 environmentId,
335 input: initialAutomationHost,
336 });
337 const listPreviews = useAtomQueryRunner(previewEnvironment.list, {
338 reportFailure: false,
339 });
340 const open = useAtomCommand(previewEnvironment.open, {
341 reportFailure: false,
342 });
343 const resize = useAtomCommand(previewEnvironment.resize, {
344 reportFailure: false,
345 });
346 const respondToAutomation = useAtomCommand(
347 previewEnvironment.respondToAutomation,
348 "preview automation response",
349 );
350 const focusAutomationHost = useAtomCommand(
351 previewEnvironment.focusAutomationHost,
352 "preview automation host focus",
353 );
354 const [automationConnectionAtom] = useState(() => Atom.make<string | null>(null));
355 const automationConnectionId = useAtomValue(automationConnectionAtom);
356 const presentationSuppressedRuntimeTabsRef = useRef(new Map<string, Set<string>>());
357
358 const handleRequest = useCallback(
359 async (request: PreviewAutomationRequest): Promise<unknown> => {
360 // Session sync and tab creation consume the same budget as overlay registration.
361 const hostDeadlineMs = Date.now() + resolveHostWaitBudgetMs(request.timeoutMs);
362 const threadRef: ScopedThreadRef = {
363 environmentId,
364 threadId: request.threadId,
365 };
366 let tabId = request.tabId ?? null;
367 const browserActivity = { release: null as (() => void) | null };
368 try {
369 let state = readThreadPreviewState(threadRef);
370 const needsSessionSync = needsPreviewAutomationSessionSync(state, request.tabId);
371 if (needsSessionSync) {
372 const listTarget = {
373 environmentId,
374 input: { threadId: request.threadId },
375 } as const;
376 registry.refresh(previewEnvironment.list(listTarget));
377 const result = await listPreviews(listTarget);
378 if (result._tag === "Failure") {
379 return raiseAtomCommandFailure(result);
380 }
381 reconcilePreviewServerSessions(threadRef, result.value);
382 state = readThreadPreviewState(threadRef);
383 }
384 tabId = request.tabId ?? state.snapshot?.tabId ?? null;
385 const unavailableTarget = {
386 requestId: request.requestId,
387 operation: request.operation,
388 environmentId,
389 threadId: request.threadId,
390 tabId,
391 bridgeAvailable: Boolean(previewBridge),
392 };
393 const requireReadyTab = async () => {
394 const bridge = previewBridge;
395 const readyTabId = tabId;
396 if (!bridge || !readyTabId) {
397 throw new PreviewAutomationTargetUnavailableError(unavailableTarget);
398 }
399 const readyState = readThreadPreviewState(threadRef);
400 const runtimeTabId = previewRuntimeTabId(threadRef, readyState.serverEpoch, readyTabId);
401 if (request.operation !== "open") {
402 const { autoShowFloatingPreview } = await resolveBrowserDefaults();
403 if (
404 shouldAutoShowPreviewForAutomationUse({
405 operation: request.operation,
406 autoShowFloatingPreview,
407 presentationSuppressed:
408 presentationSuppressedRuntimeTabsRef.current
409 .get(request.threadId)
410 ?.has(runtimeTabId) ?? false,
411 })
412 ) {
413 usePreviewMiniPlayerStore
414 .getState()
415 .open(threadRef, browserMiniPlayerSource(readyTabId));
416 }
417 }
418 browserActivity.release ??= acquireBrowserSurfaceActivity(runtimeTabId);
419 await waitForDesktopOverlay(
420 threadRef,
421 request.requestId,
422 readyTabId,
423 runtimeTabId,
424 request.operation,
425 hostDeadlineMs,
426 );
427 return {
428 bridge,
429 tabId: readyTabId,
430 runtimeTabId,
431 };
432 };
433 switch (request.operation) {
434 case "status":
435 return await currentStatus(threadRef, tabId);
436 case "open": {
437 const input = request.input as PreviewAutomationOpenInput;
438 const resolvedInputUrl = input.url
439 ? resolveBrowserNavigationTarget(environmentId, {
440 kind: "url",
441 url: input.url,
442 }).resolvedUrl
443 : undefined;
444 let activeTabId = resolvePreviewAutomationOpenTab(
445 state,
446 request.tabId,
447 input.reuseExistingTab ?? true,
448 );
449 let activeSnapshot = activeTabId
450 ? (state.sessions[activeTabId] ?? state.snapshot ?? undefined)
451 : undefined;
452 const reusedExistingTab = activeTabId !== null;
453 tabId = activeTabId;
454 if (!activeTabId) {
455 const defaults = await resolveBrowserDefaults();
456 const result = await open({
457 environmentId,
458 input: {
459 threadId: request.threadId,
460 ...(resolvedInputUrl ? { url: resolvedInputUrl } : {}),
461 // An agent that didn't state a size gets the user's
462 // configured default, same as a hand-opened tab.
463 viewport: browserDefaultOpenViewport(defaults),
464 profileId: browserDefaultOpenProfileId(defaults),
465 },
466 });
467 if (result._tag === "Failure") {
468 return raiseAtomCommandFailure(result);
469 }
470 const snapshot = result.value;
471 applyPreviewServerSnapshot(threadRef, snapshot);
472 activeTabId = snapshot.tabId;
473 activeSnapshot = snapshot;
474 tabId = activeTabId;
475 }
476 const activeRuntimeTabId = previewRuntimeTabId(
477 threadRef,
478 readThreadPreviewState(threadRef).serverEpoch,
479 activeTabId,
480 );
481 if (activeSnapshot) {
482 const defaultViewport = previewAutomationDefaultViewport(
483 reusedExistingTab,
484 activeSnapshot,
485 );
486 if (defaultViewport) {
487 const resizeResult = await runBrowserViewportMutation(
488 activeRuntimeTabId,
489 async () => {
490 assertPreviewRuntimeCurrent(
491 threadRef,
492 activeTabId,
493 activeRuntimeTabId,
494 request,
495 );
496 return await resize({
497 environmentId,
498 input: {
499 threadId: request.threadId,
500 tabId: activeTabId,
501 viewport: defaultViewport,
502 },
503 });
504 },
505 );
506 if (resizeResult._tag === "Failure") {
507 return raiseAtomCommandFailure(resizeResult);
508 }
509 activeSnapshot = resizeResult.value;
510 updatePreviewServerSnapshot(threadRef, resizeResult.value);
511 }
512 }
513 const shouldPresentPreview = shouldOpenPreviewMiniPlayer(
514 input,
515 (await resolveBrowserDefaults()).autoShowFloatingPreview,
516 );
517 const explicitlySuppressed = explicitlySuppressesPreviewMiniPlayer(input);
518 const suppressedTabs = presentationSuppressedRuntimeTabsRef.current.get(
519 request.threadId,
520 );
521 if (explicitlySuppressed) {
522 if (suppressedTabs) {
523 suppressedTabs.add(activeRuntimeTabId);
524 } else {
525 presentationSuppressedRuntimeTabsRef.current.set(
526 request.threadId,
527 new Set([activeRuntimeTabId]),
528 );
529 }
530 const miniPlayerTabId = selectThreadPreviewMiniPlayerTabId(
531 usePreviewMiniPlayerStore.getState().byThreadKey,
532 threadRef,
533 );
534 if (miniPlayerTabId === activeTabId) {
535 usePreviewMiniPlayerStore.getState().close(threadRef);
536 }
537 } else if (shouldPresentPreview) {
538 suppressedTabs?.delete(activeRuntimeTabId);
539 if (suppressedTabs?.size === 0) {
540 presentationSuppressedRuntimeTabsRef.current.delete(request.threadId);
541 }
542 }
543 if (shouldPresentPreview) {
544 usePreviewMiniPlayerStore
545 .getState()
546 .open(threadRef, browserMiniPlayerSource(activeTabId));
547 }
548 if (activeSnapshot && previewAutomationOpenNeedsOverlay(input, activeSnapshot)) {
549 await requireReadyTab();
550 }
551 if (shouldPresentPreview) {
552 // React commits the thread-bound surface asynchronously. Settle
553 // briefly so active-thread opens report visible=true, without
554 // turning a background thread's offscreen mini player into an
555 // operation failure.
556 await waitForPreviewPresentation(activeRuntimeTabId);
557 }
558 if (reusedExistingTab && resolvedInputUrl && previewBridge) {
559 assertPreviewRuntimeCurrent(threadRef, activeTabId, activeRuntimeTabId, request);
560 await previewBridge.navigate(activeRuntimeTabId, resolvedInputUrl);
561 await waitForNavigationReadiness(
562 threadRef,
563 request.requestId,
564 activeTabId,
565 activeRuntimeTabId,
566 request.operation,
567 "load",
568 request.timeoutMs,
569 );
570 }
571 return await currentStatus(threadRef, activeTabId);
572 }
573 case "navigate": {
574 const ready = await requireReadyTab();
575 const input = request.input as PreviewAutomationNavigateInput;
576 const resolution = resolveBrowserNavigationTarget(
577 environmentId,
578 input.target ?? {
579 kind: "url",
580 url: input.url!,
581 },
582 );
583 await ready.bridge.navigate(ready.runtimeTabId, resolution.resolvedUrl);
584 await waitForNavigationReadiness(
585 threadRef,
586 request.requestId,
587 ready.tabId,
588 ready.runtimeTabId,
589 request.operation,
590 input.readiness ?? "load",
591 input.timeoutMs ?? request.timeoutMs,
592 );
593 return await currentStatus(threadRef, ready.tabId);
594 }
595 case "resize": {
596 const ready = await requireReadyTab();
597 const input = request.input as PreviewAutomationResizeInput;
598 const setting = resolvePreviewViewport(input);
599 const applied = await runBrowserViewportMutation(ready.runtimeTabId, async () => {
600 const operationState = assertPreviewRuntimeCurrent(
601 threadRef,
602 ready.tabId,
603 ready.runtimeTabId,
604 request,
605 );
606 const previousSetting =
607 operationState.sessions[ready.tabId]?.viewport ?? FILL_PREVIEW_VIEWPORT;
608 const result = await resize({
609 environmentId,
610 input: {
611 threadId: request.threadId,
612 tabId: ready.tabId,
613 viewport: setting,
614 },
615 });
616 if (result._tag === "Failure") {
617 return raiseAtomCommandFailure(result);
618 }
619 updatePreviewServerSnapshot(threadRef, result.value);
620 return {
621 previousSetting,
622 serverEpoch: operationState.serverEpoch,
623 };
624 });
625 let viewport: PreviewRenderedViewportSize;
626 try {
627 viewport = await waitForRenderedViewport(
628 threadRef,
629 ready.tabId,
630 ready.runtimeTabId,
631 setting,
632 input.timeoutMs ?? request.timeoutMs,
633 {
634 requestId: request.requestId,
635 operation: request.operation,
636 environmentId,
637 threadId: request.threadId,
638 },
639 );
640 } catch (cause) {
641 await runBrowserViewportMutation(ready.runtimeTabId, async () => {
642 const latestState = readThreadPreviewState(threadRef);
643 const latestSetting =
644 latestState.sessions[ready.tabId]?.viewport ?? FILL_PREVIEW_VIEWPORT;
645 if (
646 shouldRollbackPreviewViewport(
647 applied.previousSetting,
648 setting,
649 latestSetting,
650 applied.serverEpoch,
651 latestState.serverEpoch,
652 )
653 ) {
654 const rollback = await resize({
655 environmentId,
656 input: {
657 threadId: request.threadId,
658 tabId: ready.tabId,
659 viewport: applied.previousSetting,
660 },
661 });
662 if (rollback._tag !== "Failure") {
663 updatePreviewServerSnapshot(threadRef, rollback.value);
664 }
665 }
666 });
667 throw cause;
668 }
669 return {
670 tabId: ready.tabId,
671 setting,
672 viewport,
673 } satisfies PreviewAutomationResizeResult;
674 }
675 case "setColorScheme": {
676 const ready = await requireReadyTab();
677 const input = request.input as PreviewAutomationSetColorSchemeInput;
678 await ready.bridge.setColorScheme(ready.runtimeTabId, input.colorScheme);
679 return {
680 tabId: ready.tabId,
681 colorScheme: input.colorScheme,
682 } satisfies PreviewAutomationSetColorSchemeResult;
683 }
684 case "snapshot": {
685 const ready = await requireReadyTab();
686 return await ready.bridge.automation.snapshot(ready.runtimeTabId);
687 }
688 case "click": {
689 const ready = await requireReadyTab();
690 return await runPreviewClickKeepingHostFocus(ready.runtimeTabId, () =>
691 ready.bridge.automation.click(
692 ready.runtimeTabId,
693 request.input as Parameters<typeof ready.bridge.automation.click>[1],
694 ),
695 );
696 }
697 case "type": {
698 const ready = await requireReadyTab();
699 return await ready.bridge.automation.type(
700 ready.runtimeTabId,
701 request.input as Parameters<typeof ready.bridge.automation.type>[1],
702 );
703 }
704 case "press": {
705 const ready = await requireReadyTab();
706 return await ready.bridge.automation.press(
707 ready.runtimeTabId,
708 request.input as Parameters<typeof ready.bridge.automation.press>[1],
709 );
710 }
711 case "scroll": {
712 const ready = await requireReadyTab();
713 return await ready.bridge.automation.scroll(
714 ready.runtimeTabId,
715 request.input as Parameters<typeof ready.bridge.automation.scroll>[1],
716 );
717 }
718 case "evaluate": {
719 const ready = await requireReadyTab();
720 return await ready.bridge.automation.evaluate(
721 ready.runtimeTabId,
722 request.input as Parameters<typeof ready.bridge.automation.evaluate>[1],
723 );
724 }
725 case "waitFor": {
726 const ready = await requireReadyTab();
727 return await ready.bridge.automation.waitFor(
728 ready.runtimeTabId,
729 request.input as Parameters<typeof ready.bridge.automation.waitFor>[1],
730 );
731 }
732 case "recordingStart": {
733 const ready = await requireReadyTab();
734 const startedAt = await startBrowserRecording(
735 ready.runtimeTabId,
736 threadRef,
737 ready.tabId,
738 );
739 return {
740 tabId: ready.tabId,
741 recording: true,
742 startedAt,
743 };
744 }
745 case "recordingStop": {
746 const activeRecordings = readActiveBrowserRecordingTargets(threadRef);
747 const activeTabIds = new Set(
748 activeRecordings.map((recording) => recording.serverTabId),
749 );
750 const stopTabId = resolveBrowserRecordingStopTarget(
751 activeTabIds,
752 tabId,
753 request.tabIdExplicit ? request.tabId : undefined,
754 );
755 tabId = stopTabId ?? tabId;
756 const stopRuntimeTabId =
757 activeRecordings.find((recording) => recording.serverTabId === stopTabId)
758 ?.runtimeTabId ?? null;
759 const transferToEnvironment =
760 typeof request.input === "object" &&
761 request.input !== null &&
762 "transferToEnvironment" in request.input &&
763 request.input.transferToEnvironment === true;
764 const artifact = stopRuntimeTabId
765 ? transferToEnvironment
766 ? await stopBrowserRecordingForUpload(stopRuntimeTabId, (saved, blob) =>
767 uploadBrowserRecording(threadRef, saved, blob, hostDeadlineMs),
768 )
769 : await stopBrowserRecording(stopRuntimeTabId)
770 : null;
771 if (!artifact || !stopTabId) {
772 return raisePreviewAutomationHostError(
773 new PreviewAutomationRecordingNotActiveError({
774 requestId: request.requestId,
775 environmentId,
776 threadId: request.threadId,
777 tabId,
778 }),
779 );
780 }
781 return {
782 ...artifact,
783 tabId: stopTabId,
784 };
785 }
786 }
787 } catch (cause) {
788 throw PreviewAutomationOperationError.fromCause({
789 requestId: request.requestId,
790 operation: request.operation,
791 environmentId,
792 threadId: request.threadId,
793 tabId,
794 cause,
795 });
796 } finally {
797 browserActivity.release?.();
798 }
799 },
800 [environmentId, listPreviews, open, registry, resize],
801 );
802 const [requestHandlerAtom] = useState(() => Atom.make({ handle: handleRequest }));
803 const setRequestHandler = useAtomSet(requestHandlerAtom);
804 useEffect(() => {
805 setRequestHandler({ handle: handleRequest });
806 }, [handleRequest, setRequestHandler]);
807
808 const automationRequestConsumerAtom = useMemo(
809 () =>
810 createPreviewAutomationRequestConsumerAtom({
811 requestsAtom: automationRequestsAtom,
812 clientId: automationClientId,
813 connectionAtom: automationConnectionAtom,
814 environmentId,
815 requestHandlerAtom,
816 respond: (response) =>
817 respondToAutomation({
818 environmentId,
819 input: response,
820 }),
821 label: `preview:automation-host:${environmentId}:${automationClientId}`,
822 }),
823 [
824 automationClientId,
825 automationConnectionAtom,
826 automationRequestsAtom,
827 requestHandlerAtom,
828 respondToAutomation,
829 environmentId,
830 ],
831 );
832 useAtomValue(automationRequestConsumerAtom);
833
834 useEffect(() => {
835 const report = () => {
836 if (!automationConnectionId) return;
837 const input = {
838 clientId: automationClientId,
839 environmentId,
840 connectionId: automationConnectionId,
841 focused: document.hasFocus() && document.visibilityState === "visible",
842 liveTabs: liveTabs.map((tab) => ({
843 ...tab,
844 visible: tab.visible && document.visibilityState === "visible",
845 })),
846 };
847 const reportKey = JSON.stringify(input);
848 if (lastFocusReportRef.current === reportKey) return;
849 lastFocusReportRef.current = reportKey;
850 void focusAutomationHost({ environmentId, input }).then((result) => {
851 if (result._tag === "Failure" && lastFocusReportRef.current === reportKey) {
852 lastFocusReportRef.current = null;
853 }
854 });
855 };
856 report();
857 window.addEventListener("focus", report);
858 window.addEventListener("blur", report);
859 document.addEventListener("visibilitychange", report);
860 return () => {
861 window.removeEventListener("focus", report);
862 window.removeEventListener("blur", report);
863 document.removeEventListener("visibilitychange", report);
864 };
865 }, [automationClientId, automationConnectionId, environmentId, focusAutomationHost, liveTabs]);
866
867 return null;
868}
apps/web/src/components/preview/PreviewMoreMenu.tsx+69-96
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1"use client";1"use client";
22
3import type { DesktopPreviewColorScheme, EnvironmentId } from "@t3tools/contracts";3import type { DesktopPreviewColorScheme } from "@t3tools/contracts";
4import { Minus, MoreVertical, Plus as PlusIcon, RotateCcw } from "lucide-react";4import { Minus, MoreVertical, Plus as PlusIcon, RotateCcw } from "lucide-react";
55
6import { Button } from "~/components/ui/button";6import { Button } from "~/components/ui/button";
...@@ -20,8 +20,6 @@ import {...@@ -20,8 +20,6 @@ import {
20} from "~/components/ui/menu";20} from "~/components/ui/menu";
21import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";21import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
2222
23import { previewBridge } from "./previewBridge";
24
25const COLOR_SCHEME_OPTIONS: ReadonlyArray<{23const COLOR_SCHEME_OPTIONS: ReadonlyArray<{
26 value: DesktopPreviewColorScheme;24 value: DesktopPreviewColorScheme;
27 label: string;25 label: string;
...@@ -31,18 +29,32 @@ const COLOR_SCHEME_OPTIONS: ReadonlyArray<{...@@ -31,18 +29,32 @@ const COLOR_SCHEME_OPTIONS: ReadonlyArray<{
31 { value: "dark", label: "Dark" },29 { value: "dark", label: "Dark" },
32];30];
3331
32/**
33 * What the menu's items do for the active tab. Desktop tabs run them through
34 * the desktop bridge; server tabs, whether streamed or rendered natively by
35 * the desktop app, run them through the environment server.
36 */
37export interface PreviewMoreMenuActions {
38 readonly hardReload: () => void;
39 readonly setColorScheme: (colorScheme: DesktopPreviewColorScheme) => void;
40 readonly zoomIn: () => void;
41 readonly zoomOut: () => void;
42 readonly resetZoom: () => void;
43 readonly clearCookies: () => void;
44 readonly clearCache: () => void;
45 /** Absent where there is no inspector to open, such as a streamed server tab. */
46 readonly openDevTools?: () => void;
47 /** Absent where the tab cannot get its own window. */
48 readonly toggleNativePictureInPicture?: () => void;
49}
50
34interface Props {51interface Props {
35 /** Active preview tab id. Tab-targeting actions are disabled without it. */52 /** False until the tab can take these actions, such as before its page attaches. */
36 tabId: string | null;53 enabled: boolean;
37 /**54 actions: PreviewMoreMenuActions;
38 * True only after the desktop bridge has registered a `webContentsId` for
39 * the active tab. Tab-targeting actions throw on the desktop side until
40 * then; we disable those items so the menu doesn't fire silent no-ops.
41 */
42 hasWebContents: boolean;
43 /** Current zoom factor as a number (1.0 = 100%). */55 /** Current zoom factor as a number (1.0 = 100%). */
44 zoomFactor: number;56 zoomFactor: number;
45 /** Emulated `prefers-color-scheme` for the guest page. */57 /** Emulated `prefers-color-scheme` for the page. */
46 colorScheme: DesktopPreviewColorScheme;58 colorScheme: DesktopPreviewColorScheme;
47 /** Fixed viewport modes expose the device toolbar and resize rails. */59 /** Fixed viewport modes expose the device toolbar and resize rails. */
48 deviceToolbarVisible: boolean;60 deviceToolbarVisible: boolean;
...@@ -50,90 +62,66 @@ interface Props {...@@ -50,90 +62,66 @@ interface Props {
50 onToggleDeviceToolbar: () => void;62 onToggleDeviceToolbar: () => void;
51 /** Whether the separate native always-on-top preview window is open. */63 /** Whether the separate native always-on-top preview window is open. */
52 nativePictureInPicture: boolean;64 nativePictureInPicture: boolean;
53 /** Toggles the optional native always-on-top preview window. */
54 onNativePictureInPicture: () => void;
55 /** Environment the tab belongs to; scopes storage clearing to its partitions. */
56 environmentId: EnvironmentId;
57 /** Profile the tab was opened under, if the server recorded one. */
58 /**
59 * Required: the IPC layer reads an absent profile as "every profile", so a
60 * tab whose own profile is unknown must resolve the default before it gets
61 * here rather than passing the gap along.
62 */
63 profileId: string;
64 /** Profile display name, shown so the menu says which data is being cleared. */65 /** Profile display name, shown so the menu says which data is being cleared. */
65 profileName: string | undefined;66 profileName: string | undefined;
66}67}
6768
68/**69const MenuTriggerButton = () => (
69 * Three-dot menu in the chrome row. Wires Hard reload, DevTools, zoom70 <Tooltip>
70 * controls, and storage-clearing actions. Only mounted by `PreviewView`71 <TooltipTrigger
71 * when the desktop bridge is present, so we can call it unconditionally.72 render={
72 */73 <MenuTrigger
74 render={<Button variant="ghost" size="icon-xs" type="button" aria-label="Preview menu" />}
75 />
76 }
77 >
78 <MoreVertical />
79 </TooltipTrigger>
80 <TooltipPopup>More</TooltipPopup>
81 </Tooltip>
82);
83
84/** Three-dot menu in the chrome row; the same items for every kind of tab. */
73export function PreviewMoreMenu({85export function PreviewMoreMenu({
74 tabId,86 enabled,
75 hasWebContents,87 actions,
76 zoomFactor,88 zoomFactor,
77 colorScheme,89 colorScheme,
78 deviceToolbarVisible,90 deviceToolbarVisible,
79 onToggleDeviceToolbar,91 onToggleDeviceToolbar,
80 nativePictureInPicture,92 nativePictureInPicture,
81 onNativePictureInPicture,
82 environmentId,
83 profileId,
84 profileName,93 profileName,
85}: Props) {94}: Props) {
86 if (!previewBridge) return null;
87 const bridge = previewBridge;
88 const tabDisabled = !tabId || !hasWebContents;
89 const callTab = (op: (tabId: string) => Promise<void>) => () => {
90 if (!tabId) return;
91 void op(tabId).catch(() => undefined);
92 };
93
94 const zoomLabel = `${Math.round(zoomFactor * 100)}%`;95 const zoomLabel = `${Math.round(zoomFactor * 100)}%`;
96 const disabled = !enabled;
95 return (97 return (
96 <Menu>98 <Menu>
97 <Tooltip>99 <MenuTriggerButton />
98 <TooltipTrigger
99 render={
100 <MenuTrigger
101 render={
102 <Button variant="ghost" size="icon-xs" type="button" aria-label="Preview menu" />
103 }
104 />
105 }
106 >
107 <MoreVertical />
108 </TooltipTrigger>
109 <TooltipPopup>More</TooltipPopup>
110 </Tooltip>
111 <MenuPopup align="end" sideOffset={6}>100 <MenuPopup align="end" sideOffset={6}>
112 <MenuItem onClick={callTab(bridge.hardReload)} disabled={tabDisabled}>101 <MenuItem onClick={actions.hardReload} disabled={disabled}>
113 Hard reload102 Hard reload
114 </MenuItem>103 </MenuItem>
115 <MenuItem onClick={callTab(bridge.openDevTools)} disabled={tabDisabled}>104 {actions.openDevTools ? (
116 Open DevTools105 <MenuItem onClick={actions.openDevTools} disabled={disabled}>
117 </MenuItem>106 Open DevTools
118 <MenuItem onClick={onNativePictureInPicture} disabled={tabDisabled}>107 </MenuItem>
119 {nativePictureInPicture108 ) : null}
120 ? "Close separate preview window"109 {actions.toggleNativePictureInPicture ? (
121 : "Open separate preview window"}110 <MenuItem onClick={actions.toggleNativePictureInPicture} disabled={disabled}>
122 </MenuItem>111 {nativePictureInPicture
123 <MenuItem onClick={onToggleDeviceToolbar} disabled={tabDisabled}>112 ? "Close separate preview window"
113 : "Open separate preview window"}
114 </MenuItem>
115 ) : null}
116 <MenuItem onClick={onToggleDeviceToolbar} disabled={disabled}>
124 {deviceToolbarVisible ? "Hide device toolbar" : "Show device toolbar"}117 {deviceToolbarVisible ? "Hide device toolbar" : "Show device toolbar"}
125 </MenuItem>118 </MenuItem>
126 <MenuSub>119 <MenuSub>
127 <MenuSubTrigger disabled={tabDisabled}>Appearance</MenuSubTrigger>120 <MenuSubTrigger disabled={disabled}>Appearance</MenuSubTrigger>
128 <MenuSubPopup>121 <MenuSubPopup>
129 <MenuRadioGroup122 <MenuRadioGroup
130 value={colorScheme}123 value={colorScheme}
131 onValueChange={(value) => {124 onValueChange={(value) => actions.setColorScheme(value as DesktopPreviewColorScheme)}
132 if (!tabId) return;
133 void bridge
134 .setColorScheme(tabId, value as DesktopPreviewColorScheme)
135 .catch(() => undefined);
136 }}
137 >125 >
138 {COLOR_SCHEME_OPTIONS.map((option) => (126 {COLOR_SCHEME_OPTIONS.map((option) => (
139 <MenuRadioItem key={option.value} value={option.value}>127 <MenuRadioItem key={option.value} value={option.value}>
...@@ -152,7 +140,7 @@ export function PreviewMoreMenu({...@@ -152,7 +140,7 @@ export function PreviewMoreMenu({
152 closeOnClick={false}140 closeOnClick={false}
153 onClick={(event: React.MouseEvent) => event.preventDefault()}141 onClick={(event: React.MouseEvent) => event.preventDefault()}
154 className="justify-between"142 className="justify-between"
155 disabled={tabDisabled}143 disabled={disabled}
156 >144 >
157 <span>Zoom</span>145 <span>Zoom</span>
158 <span className="flex items-center gap-1">146 <span className="flex items-center gap-1">
...@@ -160,9 +148,9 @@ export function PreviewMoreMenu({...@@ -160,9 +148,9 @@ export function PreviewMoreMenu({
160 variant="outline"148 variant="outline"
161 size="icon-xs"149 size="icon-xs"
162 type="button"150 type="button"
163 onClick={callTab(bridge.zoomOut)}151 onClick={actions.zoomOut}
164 aria-label="Zoom out"152 aria-label="Zoom out"
165 disabled={tabDisabled}153 disabled={disabled}
166 >154 >
167 <Minus />155 <Minus />
168 </Button>156 </Button>
...@@ -173,9 +161,9 @@ export function PreviewMoreMenu({...@@ -173,9 +161,9 @@ export function PreviewMoreMenu({
173 variant="outline"161 variant="outline"
174 size="icon-xs"162 size="icon-xs"
175 type="button"163 type="button"
176 onClick={callTab(bridge.zoomIn)}164 onClick={actions.zoomIn}
177 aria-label="Zoom in"165 aria-label="Zoom in"
178 disabled={tabDisabled}166 disabled={disabled}
179 >167 >
180 <PlusIcon />168 <PlusIcon />
181 </Button>169 </Button>
...@@ -183,9 +171,9 @@ export function PreviewMoreMenu({...@@ -183,9 +171,9 @@ export function PreviewMoreMenu({
183 variant="ghost"171 variant="ghost"
184 size="icon-xs"172 size="icon-xs"
185 type="button"173 type="button"
186 onClick={callTab(bridge.resetZoom)}174 onClick={actions.resetZoom}
187 aria-label="Reset zoom"175 aria-label="Reset zoom"
188 disabled={tabDisabled}176 disabled={disabled}
189 >177 >
190 <RotateCcw />178 <RotateCcw />
191 </Button>179 </Button>
...@@ -199,11 +187,6 @@ export function PreviewMoreMenu({...@@ -199,11 +187,6 @@ export function PreviewMoreMenu({
199 at open and nothing else in the chrome shows it.187 at open and nothing else in the chrome shows it.
200 */}188 */}
201 <MenuGroup>189 <MenuGroup>
202 {/*
203 The heading carries the profile so the actions below can keep
204 fixed-length labels: repeating a name of up to 48 characters in
205 each one drove the popup far past its width.
206 */}
207 {profileName ? (190 {profileName ? (
208 // Truncation needs a block box: `text-overflow` on an inline child191 // Truncation needs a block box: `text-overflow` on an inline child
209 // never applies and a long name would push the popup past its width.192 // never applies and a long name would push the popup past its width.
...@@ -211,18 +194,8 @@ export function PreviewMoreMenu({...@@ -211,18 +194,8 @@ export function PreviewMoreMenu({
211 <span className="block truncate">Profile: {profileName}</span>194 <span className="block truncate">Profile: {profileName}</span>
212 </MenuGroupLabel>195 </MenuGroupLabel>
213 ) : null}196 ) : null}
214 <MenuItem197 <MenuItem onClick={actions.clearCookies}>Clear cookies</MenuItem>
215 onClick={() =>198 <MenuItem onClick={actions.clearCache}>Clear cache</MenuItem>
216 void bridge.clearCookies(environmentId, profileId).catch(() => undefined)
217 }
218 >
219 Clear cookies
220 </MenuItem>
221 <MenuItem
222 onClick={() => void bridge.clearCache(environmentId, profileId).catch(() => undefined)}
223 >
224 Clear cache
225 </MenuItem>
226 </MenuGroup>199 </MenuGroup>
227 </MenuPopup>200 </MenuPopup>
228 </Menu>201 </Menu>
apps/web/src/components/preview/PreviewPanel.tsx+2-2
...@@ -3,7 +3,7 @@...@@ -3,7 +3,7 @@
3import type { PreviewAnnotationPayload, ScopedThreadRef } from "@t3tools/contracts";3import type { PreviewAnnotationPayload, ScopedThreadRef } from "@t3tools/contracts";
44
5import type { ComposerImageAttachment } from "~/composerDraftStore";5import type { ComposerImageAttachment } from "~/composerDraftStore";
6import { isPreviewSupportedInRuntime } from "~/previewStateStore";6import { usePreviewAvailable } from "~/browser/previewRuntime";
77
8import { PreviewPanelShell, type PreviewPanelMode } from "./PreviewPanelShell";8import { PreviewPanelShell, type PreviewPanelMode } from "./PreviewPanelShell";
9import { PreviewView } from "./PreviewView";9import { PreviewView } from "./PreviewView";
...@@ -28,7 +28,7 @@ export function PreviewPanel({...@@ -28,7 +28,7 @@ export function PreviewPanel({
28 visible,28 visible,
29 onSendAnnotation,29 onSendAnnotation,
30}: Props) {30}: Props) {
31 if (!isPreviewSupportedInRuntime()) {31 if (!usePreviewAvailable(threadRef.environmentId)) {
32 return (32 return (
33 <PreviewPanelShell mode={mode}>33 <PreviewPanelShell mode={mode}>
34 <div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 p-8 text-center">34 <div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 p-8 text-center">
apps/web/src/components/preview/PreviewView.test.tsx+4-4
...@@ -221,14 +221,14 @@ vi.mock("./PreviewChromeRow", () => ({...@@ -221,14 +221,14 @@ vi.mock("./PreviewChromeRow", () => ({
221 onPictureInPicture?: () => void;221 onPictureInPicture?: () => void;
222 pictureInPicture?: boolean;222 pictureInPicture?: boolean;
223 trailingActions?: {223 trailingActions?: {
224 props: { onNativePictureInPicture?: () => void };224 props: { actions?: { toggleNativePictureInPicture?: () => void } };
225 };225 };
226 }) => {226 }) => {
227 mocks.submittedUrl = props.onSubmit;227 mocks.submittedUrl = props.onSubmit;
228 mocks.toggleAnnotation = props.onPickElement ?? null;228 mocks.toggleAnnotation = props.onPickElement ?? null;
229 mocks.togglePictureInPicture = props.onPictureInPicture ?? null;229 mocks.togglePictureInPicture = props.onPictureInPicture ?? null;
230 mocks.toggleNativePictureInPicture =230 mocks.toggleNativePictureInPicture =
231 props.trailingActions?.props.onNativePictureInPicture ?? null;231 props.trailingActions?.props.actions?.toggleNativePictureInPicture ?? null;
232 mocks.pictureInPicturePressed = props.pictureInPicture ?? false;232 mocks.pictureInPicturePressed = props.pictureInPicture ?? false;
233 return null;233 return null;
234 },234 },
...@@ -241,8 +241,8 @@ vi.mock("./PreviewEmptyState", () => ({...@@ -241,8 +241,8 @@ vi.mock("./PreviewEmptyState", () => ({
241 },241 },
242}));242}));
243vi.mock("./PreviewMoreMenu", () => ({243vi.mock("./PreviewMoreMenu", () => ({
244 PreviewMoreMenu: (props: { onNativePictureInPicture: () => void }) => {244 PreviewMoreMenu: (props: { actions: { toggleNativePictureInPicture?: () => void } }) => {
245 mocks.toggleNativePictureInPicture = props.onNativePictureInPicture;245 mocks.toggleNativePictureInPicture = props.actions.toggleNativePictureInPicture ?? null;
246 return null;246 return null;
247 },247 },
248}));248}));
apps/web/src/components/preview/PreviewView.tsx+237-41
...@@ -11,6 +11,9 @@ import {...@@ -11,6 +11,9 @@ import {
11 type PreviewAnnotationPayload,11 type PreviewAnnotationPayload,
12 type PreviewViewportSetting,12 type PreviewViewportSetting,
13 type ScopedThreadRef,13 type ScopedThreadRef,
14 DEFAULT_PREVIEW_ZOOM_FACTOR,
15 PREVIEW_ZOOM_LEVELS,
16 type PreviewAdjustInput,
14} from "@t3tools/contracts";17} from "@t3tools/contracts";
15import { normalizePreviewUrl } from "@t3tools/shared/preview";18import { normalizePreviewUrl } from "@t3tools/shared/preview";
16import { useCallback, useEffect, useRef, useState } from "react";19import { useCallback, useEffect, useRef, useState } from "react";
...@@ -46,12 +49,14 @@ import { subscribePreviewAction } from "./previewActionBus";...@@ -46,12 +49,14 @@ import { subscribePreviewAction } from "./previewActionBus";
46import { openPreviewSession } from "./openPreviewSession";49import { openPreviewSession } from "./openPreviewSession";
47import { PreviewChromeRow } from "./PreviewChromeRow";50import { PreviewChromeRow } from "./PreviewChromeRow";
48import { PreviewEmptyState } from "./PreviewEmptyState";51import { PreviewEmptyState } from "./PreviewEmptyState";
49import { PreviewMoreMenu } from "./PreviewMoreMenu";52import { PreviewMoreMenu, type PreviewMoreMenuActions } from "./PreviewMoreMenu";
50import {53import {
51 commitBrowserViewportChange,54 commitBrowserViewportChange,
52 subscribeBrowserViewportChange,55 subscribeBrowserViewportChange,
53} from "~/browser/browserViewportActions";56} from "~/browser/browserViewportActions";
54import { browserResponsiveViewportForToggle, useBrowserDefaults } from "~/browser/browserDefaults";57import { browserResponsiveViewportForToggle, useBrowserDefaults } from "~/browser/browserDefaults";
58import { BrowserDeviceToolbar } from "~/browser/BrowserDeviceToolbar";
59import { BROWSER_DEVICE_TOOLBAR_HEIGHT } from "~/browser/browserViewportLayout";
55import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";60import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";
56import { BrowserSettingsReadError } from "~/browser/openFileInPreview";61import { BrowserSettingsReadError } from "~/browser/openFileInPreview";
57import { PreviewUnreachable } from "./PreviewUnreachable";62import { PreviewUnreachable } from "./PreviewUnreachable";
...@@ -59,6 +64,9 @@ import { revealInFileExplorerLabel } from "./fileExplorerLabel";...@@ -59,6 +64,9 @@ import { revealInFileExplorerLabel } from "./fileExplorerLabel";
59import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic";64import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic";
60import { Badge } from "~/components/ui/badge";65import { Badge } from "~/components/ui/badge";
61import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot";66import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot";
67import { useRendersServerTabNatively } from "~/browser/previewRuntime";
68import { ServerBrowserSurface, type ServerBrowserHandle } from "~/browser/ServerBrowserSurface";
69import { cn } from "~/lib/utils";
62import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore";70import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore";
63import { usePreviewSession } from "./usePreviewSession";71import { usePreviewSession } from "./usePreviewSession";
64import { ZoomIndicator } from "./ZoomIndicator";72import { ZoomIndicator } from "./ZoomIndicator";
...@@ -129,6 +137,7 @@ export function PreviewView({...@@ -129,6 +137,7 @@ export function PreviewView({
129 : null;137 : null;
130 const open = useAtomCommand(previewEnvironment.open);138 const open = useAtomCommand(previewEnvironment.open);
131 const resize = useAtomCommand(previewEnvironment.resize, "preview viewport resize");139 const resize = useAtomCommand(previewEnvironment.resize, "preview viewport resize");
140 const adjust = useAtomCommand(previewEnvironment.adjust, "preview appearance or zoom");
132141
133 usePreviewSession(threadRef);142 usePreviewSession(threadRef);
134143
...@@ -150,6 +159,31 @@ export function PreviewView({...@@ -150,6 +159,31 @@ export function PreviewView({
150 : findActiveBrowserRecordingRuntimeTabId(threadRef, tabId)159 : findActiveBrowserRecordingRuntimeTabId(threadRef, tabId)
151 : null;160 : null;
152 const snapshot = tabId ? (previewState.sessions[tabId] ?? null) : null;161 const snapshot = tabId ? (previewState.sessions[tabId] ?? null) : null;
162 // Server tabs run in the environment's browser and stream to any client, except the
163 // desktop app's own server's tabs, which render here natively while the server drives them.
164 const nativeServerTab = useRendersServerTabNatively(threadRef.environmentId, snapshot);
165 const isServerTab = snapshot?.runtime === "server" && !nativeServerTab;
166 /** The server owns this tab's appearance, zoom, and size, whoever renders it. */
167 const serverOwnsRendering = snapshot?.runtime === "server";
168 const serverSurfaceRef = useRef<ServerBrowserHandle | null>(null);
169 const [serverAspectRatioLocked, setServerAspectRatioLocked] = useState(false);
170 const serverBodyRef = useRef<HTMLDivElement | null>(null);
171 const [serverToolbarWidth, setServerToolbarWidth] = useState(0);
172 // The streamed tab's device toolbar spans the panel; only measured while it shows.
173 const serverToolbarShown =
174 snapshot?.runtime === "server" && (snapshot.viewport ?? FILL_PREVIEW_VIEWPORT)._tag !== "fill";
175 useEffect(() => {
176 const element = serverBodyRef.current;
177 if (!element || !serverToolbarShown) return;
178 const observer = new ResizeObserver(([entry]) =>
179 setServerToolbarWidth(Math.max(1, Math.round(entry?.contentRect.width ?? 0))),
180 );
181 observer.observe(element);
182 return () => observer.disconnect();
183 }, [serverToolbarShown]);
184 const [serverControlledTabId, setServerControlledTabId] = useState<string | null>(null);
185 const serverInputDisabled = isServerTab && serverControlledTabId !== runtimeTabId;
186 const [serverFrameTabId, setServerFrameTabId] = useState<string | null>(null);
153 const desktopOverlay = tabId ? (previewState.desktopByTabId[tabId] ?? null) : null;187 const desktopOverlay = tabId ? (previewState.desktopByTabId[tabId] ?? null) : null;
154 const navStatus = snapshot?.navStatus ?? { _tag: "Idle" as const };188 const navStatus = snapshot?.navStatus ?? { _tag: "Idle" as const };
155 const url = navStatus._tag === "Idle" ? "" : navStatus.url;189 const url = navStatus._tag === "Idle" ? "" : navStatus.url;
...@@ -159,6 +193,8 @@ export function PreviewView({...@@ -159,6 +193,8 @@ export function PreviewView({
159 const refreshDisabled = navStatus._tag === "Idle";193 const refreshDisabled = navStatus._tag === "Idle";
160 const isUnreachable = navStatus._tag === "LoadFailed";194 const isUnreachable = navStatus._tag === "LoadFailed";
161 const showEmptyState = shouldShowPreviewEmptyState(snapshot);195 const showEmptyState = shouldShowPreviewEmptyState(snapshot);
196 const serverStreamPending =
197 isServerTab && !showEmptyState && !isUnreachable && serverFrameTabId !== runtimeTabId;
162 const controller = desktopOverlay?.controller ?? "none";198 const controller = desktopOverlay?.controller ?? "none";
163 const viewport = snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT;199 const viewport = snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT;
164 const browserDefaults = useBrowserDefaults();200 const browserDefaults = useBrowserDefaults();
...@@ -187,6 +223,12 @@ export function PreviewView({...@@ -187,6 +223,12 @@ export function PreviewView({
187223
188 const navigateToResolvedUrl = useCallback(224 const navigateToResolvedUrl = useCallback(
189 async (resolvedUrl: string) => {225 async (resolvedUrl: string) => {
226 if (isServerTab && serverSurfaceRef.current) {
227 if (serverInputDisabled) return false;
228 serverSurfaceRef.current.navigate(resolvedUrl);
229 rememberPreviewUrl(threadRef, resolvedUrl);
230 return true;
231 }
190 if (runtimeTabId && previewBridge) {232 if (runtimeTabId && previewBridge) {
191 // The bridge mirrors the resolved URL back to the server.233 // The bridge mirrors the resolved URL back to the server.
192 await previewBridge.navigate(runtimeTabId, resolvedUrl);234 await previewBridge.navigate(runtimeTabId, resolvedUrl);
...@@ -206,7 +248,7 @@ export function PreviewView({...@@ -206,7 +248,7 @@ export function PreviewView({
206 }248 }
207 return result._tag === "Success";249 return result._tag === "Success";
208 },250 },
209 [open, runtimeTabId, threadRef],251 [isServerTab, open, runtimeTabId, serverInputDisabled, threadRef],
210 );252 );
211253
212 const handleSubmitUrl = useCallback(254 const handleSubmitUrl = useCallback(
...@@ -226,7 +268,11 @@ export function PreviewView({...@@ -226,7 +268,11 @@ export function PreviewView({
226 const handleOpenServerUrl = useCallback(268 const handleOpenServerUrl = useCallback(
227 async (next: string) => {269 async (next: string) => {
228 try {270 try {
229 const resolved = resolveDiscoveredServerUrl(threadRef.environmentId, next);271 // A server tab's browser runs on the environment, where loopback is already right.
272 const resolved =
273 isServerTab || !previewBridge
274 ? normalizePreviewUrl(next)
275 : resolveDiscoveredServerUrl(threadRef.environmentId, next);
230 if (await navigateToResolvedUrl(resolved)) {276 if (await navigateToResolvedUrl(resolved)) {
231 recordVisitForThread(threadRef, next);277 recordVisitForThread(threadRef, next);
232 }278 }
...@@ -234,24 +280,68 @@ export function PreviewView({...@@ -234,24 +280,68 @@ export function PreviewView({
234 // Server-side `failed` event renders the unreachable view.280 // Server-side `failed` event renders the unreachable view.
235 }281 }
236 },282 },
237 [navigateToResolvedUrl, threadRef],283 [isServerTab, navigateToResolvedUrl, threadRef],
238 );284 );
239285
240 const handleRefresh = useCallback(() => {286 const handleRefresh = useCallback(() => {
241 if (previewBridge && runtimeTabId) void previewBridge.refresh(runtimeTabId);287 if (isServerTab) serverSurfaceRef.current?.reload();
242 }, [runtimeTabId]);288 else if (previewBridge && runtimeTabId) void previewBridge.refresh(runtimeTabId);
289 }, [isServerTab, runtimeTabId]);
290
291 /** Appearance and zoom of a server tab, through the server for every client. */
292 const adjustServerTab = useCallback(
293 async (change: Omit<PreviewAdjustInput, "threadId" | "tabId">) => {
294 if (!tabId) return;
295 const result = await adjust({
296 environmentId: threadRef.environmentId,
297 input: { threadId: threadRef.threadId, tabId, ...change },
298 });
299 if (result._tag === "Failure") {
300 const error = squashAtomCommandFailure(result);
301 toastManager.add({
302 type: "error",
303 title: "Unable to change the browser tab",
304 description: error instanceof Error ? error.message : "An error occurred.",
305 });
306 return;
307 }
308 updatePreviewServerSnapshot(threadRef, result.value);
309 },
310 [adjust, tabId, threadRef],
311 );
312
313 const serverZoomFactor = snapshot?.zoomFactor ?? DEFAULT_PREVIEW_ZOOM_FACTOR;
314 const stepServerZoom = useCallback(
315 (direction: -1 | 0 | 1) => {
316 const index = PREVIEW_ZOOM_LEVELS.indexOf(serverZoomFactor);
317 const next =
318 direction === 0
319 ? DEFAULT_PREVIEW_ZOOM_FACTOR
320 : PREVIEW_ZOOM_LEVELS[
321 Math.min(
322 Math.max((index < 0 ? 7 : index) + direction, 0),
323 PREVIEW_ZOOM_LEVELS.length - 1,
324 )
325 ]!;
326 if (next !== serverZoomFactor) void adjustServerTab({ zoomFactor: next });
327 },
328 [adjustServerTab, serverZoomFactor],
329 );
243330
244 const handleZoomIn = useCallback(() => {331 const handleZoomIn = useCallback(() => {
245 if (previewBridge && runtimeTabId) void previewBridge.zoomIn(runtimeTabId);332 if (serverOwnsRendering) stepServerZoom(1);
246 }, [runtimeTabId]);333 else if (previewBridge && runtimeTabId) void previewBridge.zoomIn(runtimeTabId);
334 }, [runtimeTabId, serverOwnsRendering, stepServerZoom]);
247335
248 const handleZoomOut = useCallback(() => {336 const handleZoomOut = useCallback(() => {
249 if (previewBridge && runtimeTabId) void previewBridge.zoomOut(runtimeTabId);337 if (serverOwnsRendering) stepServerZoom(-1);
250 }, [runtimeTabId]);338 else if (previewBridge && runtimeTabId) void previewBridge.zoomOut(runtimeTabId);
339 }, [runtimeTabId, serverOwnsRendering, stepServerZoom]);
251340
252 const handleResetZoom = useCallback(() => {341 const handleResetZoom = useCallback(() => {
253 if (previewBridge && runtimeTabId) void previewBridge.resetZoom(runtimeTabId);342 if (serverOwnsRendering) stepServerZoom(0);
254 }, [runtimeTabId]);343 else if (previewBridge && runtimeTabId) void previewBridge.resetZoom(runtimeTabId);
344 }, [runtimeTabId, serverOwnsRendering, stepServerZoom]);
255345
256 const handleViewportChange = useCallback(346 const handleViewportChange = useCallback(
257 async (nextViewport: PreviewViewportSetting) => {347 async (nextViewport: PreviewViewportSetting) => {
...@@ -290,7 +380,7 @@ export function PreviewView({...@@ -290,7 +380,7 @@ export function PreviewView({
290 browserResponsiveViewportForToggle({380 browserResponsiveViewportForToggle({
291 defaults: browserDefaults,381 defaults: browserDefaults,
292 panelRect,382 panelRect,
293 zoomFactor: desktopOverlay?.zoomFactor,383 zoomFactor: serverOwnsRendering ? serverZoomFactor : desktopOverlay?.zoomFactor,
294 }),384 }),
295 ).catch(() => undefined);385 ).catch(() => undefined);
296 };386 };
...@@ -301,12 +391,14 @@ export function PreviewView({...@@ -301,12 +391,14 @@ export function PreviewView({
301 }, [handleViewportChange, runtimeTabId]);391 }, [handleViewportChange, runtimeTabId]);
302392
303 const handleBack = useCallback(() => {393 const handleBack = useCallback(() => {
304 if (previewBridge && runtimeTabId) void previewBridge.goBack(runtimeTabId);394 if (isServerTab) serverSurfaceRef.current?.history(-1);
305 }, [runtimeTabId]);395 else if (previewBridge && runtimeTabId) void previewBridge.goBack(runtimeTabId);
396 }, [isServerTab, runtimeTabId]);
306397
307 const handleForward = useCallback(() => {398 const handleForward = useCallback(() => {
308 if (previewBridge && runtimeTabId) void previewBridge.goForward(runtimeTabId);399 if (isServerTab) serverSurfaceRef.current?.history(1);
309 }, [runtimeTabId]);400 else if (previewBridge && runtimeTabId) void previewBridge.goForward(runtimeTabId);
401 }, [isServerTab, runtimeTabId]);
310402
311 const handleOpenInBrowser = useCallback(() => {403 const handleOpenInBrowser = useCallback(() => {
312 if (!localApi || !url) return;404 if (!localApi || !url) return;
...@@ -323,6 +415,53 @@ export function PreviewView({...@@ -323,6 +415,53 @@ export function PreviewView({
323 useRightPanelStore.getState().close(threadRef);415 useRightPanelStore.getState().close(threadRef);
324 }, [miniPlayerTabId, tabId, threadRef]);416 }, [miniPlayerTabId, tabId, threadRef]);
325417
418 /**
419 * The menu's actions. A server tab, streamed or rendered natively here, runs
420 * them through its environment, so every client and agent sees one state.
421 * Opening DevTools and a separate window need the desktop's own page.
422 */
423 const desktopCall = (op: ((tabId: string) => Promise<void>) | undefined) => () => {
424 if (op && runtimeTabId) void op(runtimeTabId).catch(() => undefined);
425 };
426 const moreMenuActions: PreviewMoreMenuActions | null = serverOwnsRendering
427 ? {
428 hardReload: () => void adjustServerTab({ hardReload: true }),
429 setColorScheme: (colorScheme) => void adjustServerTab({ colorScheme }),
430 zoomIn: handleZoomIn,
431 zoomOut: handleZoomOut,
432 resetZoom: handleResetZoom,
433 clearCookies: () => void adjustServerTab({ clear: "cookies" }),
434 clearCache: () => void adjustServerTab({ clear: "cache" }),
435 ...(nativeServerTab && previewBridge
436 ? {
437 openDevTools: desktopCall(previewBridge.openDevTools),
438 toggleNativePictureInPicture: () => handleNativePictureInPicture(),
439 }
440 : {}),
441 }
442 : previewBridge
443 ? {
444 hardReload: desktopCall(previewBridge.hardReload),
445 setColorScheme: (colorScheme) => {
446 if (runtimeTabId)
447 void previewBridge?.setColorScheme(runtimeTabId, colorScheme).catch(() => undefined);
448 },
449 zoomIn: handleZoomIn,
450 zoomOut: handleZoomOut,
451 resetZoom: handleResetZoom,
452 clearCookies: () =>
453 void previewBridge
454 ?.clearCookies(threadRef.environmentId, activeProfileId)
455 .catch(() => undefined),
456 clearCache: () =>
457 void previewBridge
458 ?.clearCache(threadRef.environmentId, activeProfileId)
459 .catch(() => undefined),
460 openDevTools: desktopCall(previewBridge.openDevTools),
461 toggleNativePictureInPicture: () => handleNativePictureInPicture(),
462 }
463 : null;
464
326 const handleNativePictureInPicture = useCallback(() => {465 const handleNativePictureInPicture = useCallback(() => {
327 if (!previewBridge || !runtimeTabId) return;466 if (!previewBridge || !runtimeTabId) return;
328 const operation = desktopOverlay?.pictureInPicture467 const operation = desktopOverlay?.pictureInPicture
...@@ -712,23 +851,30 @@ export function PreviewView({...@@ -712,23 +851,30 @@ export function PreviewView({
712 >851 >
713 <PreviewChromeRow852 <PreviewChromeRow
714 url={url}853 url={url}
715 loading={loading}854 loading={loading || serverStreamPending}
716 canGoBack={canGoBack}855 canGoBack={canGoBack && !serverInputDisabled}
717 canGoForward={canGoForward}856 canGoForward={canGoForward && !serverInputDisabled}
718 refreshDisabled={refreshDisabled}857 refreshDisabled={refreshDisabled || serverInputDisabled}
858 inputDisabled={serverInputDisabled}
719 focusUrlNonce={focusUrlNonce}859 focusUrlNonce={focusUrlNonce}
720 onBack={handleBack}860 onBack={handleBack}
721 onForward={handleForward}861 onForward={handleForward}
722 onRefresh={handleRefresh}862 onRefresh={handleRefresh}
723 onSubmit={(next) => void handleSubmitUrl(next)}863 onSubmit={(next) => void handleSubmitUrl(next)}
724 onOpenInBrowser={tabId ? handleOpenInBrowser : undefined}864 onOpenInBrowser={tabId ? handleOpenInBrowser : undefined}
725 onCapture={previewBridge && tabId ? handleCapture : undefined}865 // Capture, annotation, and the more menu drive the desktop webview, so
866 // server tabs leave them out. Floating works for both.
867 onCapture={previewBridge && tabId && !isServerTab ? handleCapture : undefined}
726 captureDisabled={!desktopOverlay || isUnreachable}868 captureDisabled={!desktopOverlay || isUnreachable}
727 recording={recordingRuntimeTabId !== null}869 recording={recordingRuntimeTabId !== null}
728 onPictureInPicture={previewBridge && tabId ? handlePictureInPicture : undefined}870 onPictureInPicture={
871 tabId && (isServerTab || previewBridge) ? handlePictureInPicture : undefined
872 }
729 pictureInPicture={miniPlayerTabId === tabId}873 pictureInPicture={miniPlayerTabId === tabId}
730 pictureInPictureDisabled={!desktopOverlay?.hasWebContents || isUnreachable}874 pictureInPictureDisabled={
731 onPickElement={previewBridge && tabId ? handlePickElement : undefined}875 isUnreachable || (!isServerTab && !desktopOverlay?.hasWebContents)
876 }
877 onPickElement={previewBridge && tabId && !isServerTab ? handlePickElement : undefined}
732 pickActive={pickActive}878 pickActive={pickActive}
733 // Disable when there's no tab (nothing to pick on) OR the page879 // Disable when there's no tab (nothing to pick on) OR the page
734 // failed to load (a React overlay covers the webview, so the880 // failed to load (a React overlay covers the webview, so the
...@@ -758,32 +904,82 @@ export function PreviewView({...@@ -758,32 +904,82 @@ export function PreviewView({
758 ) : null904 ) : null
759 }905 }
760 trailingActions={906 trailingActions={
761 previewBridge ? (907 moreMenuActions ? (
762 <PreviewMoreMenu908 <PreviewMoreMenu
763 environmentId={threadRef.environmentId}909 enabled={
764 profileId={activeProfileId}910 runtimeTabId !== null &&
911 (serverOwnsRendering || (desktopOverlay?.hasWebContents ?? false))
912 }
913 actions={moreMenuActions}
765 profileName={activeProfileName}914 profileName={activeProfileName}
766 tabId={runtimeTabId}915 zoomFactor={
767 hasWebContents={desktopOverlay?.hasWebContents ?? false}916 serverOwnsRendering ? serverZoomFactor : (desktopOverlay?.zoomFactor ?? 1)
768 zoomFactor={desktopOverlay?.zoomFactor ?? 1}917 }
769 colorScheme={desktopOverlay?.colorScheme ?? "system"}918 colorScheme={
919 serverOwnsRendering
920 ? (snapshot?.colorScheme ?? "system")
921 : (desktopOverlay?.colorScheme ?? "system")
922 }
770 deviceToolbarVisible={viewport._tag !== "fill"}923 deviceToolbarVisible={viewport._tag !== "fill"}
771 onToggleDeviceToolbar={handleToggleDeviceToolbar}924 onToggleDeviceToolbar={handleToggleDeviceToolbar}
772 nativePictureInPicture={desktopOverlay?.pictureInPicture ?? false}925 nativePictureInPicture={desktopOverlay?.pictureInPicture ?? false}
773 onNativePictureInPicture={handleNativePictureInPicture}
774 />926 />
775 ) : null927 ) : null
776 }928 }
777 />929 />
778930
779 <div className="relative min-h-0 flex-1 overflow-hidden">931 <div ref={serverBodyRef} className="relative min-h-0 flex-1 overflow-hidden">
780 {runtimeTabId && snapshot && !showEmptyState ? (932 {runtimeTabId && snapshot && isServerTab ? (
781 <BrowserSurfaceSlot933 <>
782 key={runtimeTabId}934 {viewport._tag !== "fill" && !showEmptyState && !isUnreachable ? (
783 tabId={runtimeTabId}935 <div className="absolute inset-x-0 top-0 z-10">
784 visible={visible && !isUnreachable}936 <BrowserDeviceToolbar
785 className="absolute inset-0 h-full w-full"937 setting={viewport}
786 />938 width={serverToolbarWidth}
939 aspectRatio={serverAspectRatioLocked ? viewport.width / viewport.height : null}
940 onAspectRatioChange={(ratio) => setServerAspectRatioLocked(ratio !== null)}
941 onChange={(setting) => commitBrowserViewportChange(runtimeTabId, setting)}
942 />
943 </div>
944 ) : null}
945 <div
946 className="absolute inset-x-0 bottom-0"
947 style={{ top: viewport._tag !== "fill" ? BROWSER_DEVICE_TOOLBAR_HEIGHT : 0 }}
948 >
949 <ServerBrowserSurface
950 key={runtimeTabId}
951 ref={serverSurfaceRef}
952 environmentId={threadRef.environmentId}
953 threadId={threadRef.threadId}
954 tabId={snapshot.tabId}
955 visible={visible}
956 onFirstFrame={() => setServerFrameTabId(runtimeTabId)}
957 onControl={(control) =>
958 setServerControlledTabId(control?.controller === "you" ? runtimeTabId : null)
959 }
960 // Stays connected under the empty state so a URL picked there reaches the page.
961 className={cn(
962 "absolute inset-0 h-full w-full",
963 (showEmptyState || isUnreachable) && "invisible",
964 )}
965 />
966 </div>
967 </>
968 ) : runtimeTabId && snapshot && !showEmptyState ? (
969 previewBridge ? (
970 <BrowserSurfaceSlot
971 key={runtimeTabId}
972 tabId={runtimeTabId}
973 visible={visible && !isUnreachable}
974 className="absolute inset-0 h-full w-full"
975 />
976 ) : (
977 <div className="flex h-full items-center justify-center p-8 text-center">
978 <p className="max-w-sm text-sm text-muted-foreground">
979 This tab is open in the T3 Code desktop app.
980 </p>
981 </div>
982 )
787 ) : null}983 ) : null}
788 {showEmptyState ? (984 {showEmptyState ? (
789 <PreviewEmptyState985 <PreviewEmptyState
apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx+147-60
...@@ -6,6 +6,7 @@ import {...@@ -6,6 +6,7 @@ import {
6 type PointerEvent as ReactPointerEvent,6 type PointerEvent as ReactPointerEvent,
7 type ReactNode,7 type ReactNode,
8 useCallback,8 useCallback,
9 useEffect,
9 useLayoutEffect,10 useLayoutEffect,
10 useRef,11 useRef,
11 useState,12 useState,
...@@ -20,6 +21,15 @@ import {...@@ -20,6 +21,15 @@ import {
20import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore";21import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore";
21import type { BrowserViewportResizeDirection } from "~/browser/browserViewportLayout";22import type { BrowserViewportResizeDirection } from "~/browser/browserViewportLayout";
22import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";23import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";
24import { useRendersServerTabNatively } from "~/browser/previewRuntime";
25import { type ServerBrowserHandle, ServerBrowserSurface } from "~/browser/ServerBrowserSurface";
26import {
27 closeServerPictureInPicture,
28 openServerPictureInPicture,
29 serverPictureInPictureKey,
30 supportsServerPictureInPicture,
31 useServerPictureInPictureKey,
32} from "~/browser/serverPictureInPicture";
23import { Button } from "~/components/ui/button";33import { Button } from "~/components/ui/button";
24import { toastManager } from "~/components/ui/toast";34import { toastManager } from "~/components/ui/toast";
25import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";35import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
...@@ -37,6 +47,7 @@ import { useDeviceState } from "~/state/device";...@@ -37,6 +47,7 @@ import { useDeviceState } from "~/state/device";
3747
38import { DeviceStreamView } from "../device/DeviceStreamView";48import { DeviceStreamView } from "../device/DeviceStreamView";
39import type { DeviceScreenSize } from "@t3tools/client-runtime/device/stream";49import type { DeviceScreenSize } from "@t3tools/client-runtime/device/stream";
50import type { PreviewStreamViewport } from "@t3tools/client-runtime/preview/server-browser-stream";
40import { previewBridge } from "./previewBridge";51import { previewBridge } from "./previewBridge";
41import {52import {
42 clampPreviewMiniPlayerPosition,53 clampPreviewMiniPlayerPosition,
...@@ -52,12 +63,17 @@ import {...@@ -52,12 +63,17 @@ import {
5263
53interface PointerGesture {64interface PointerGesture {
54 readonly pointerId: number;65 readonly pointerId: number;
66 readonly pointerType: string;
55 readonly pointerX: number;67 readonly pointerX: number;
56 readonly pointerY: number;68 readonly pointerY: number;
57 readonly frame: PreviewMiniPlayerFrame;69 readonly frame: PreviewMiniPlayerFrame;
58 readonly direction: BrowserViewportResizeDirection | null;70 readonly direction: BrowserViewportResizeDirection | null;
71 moved: boolean;
59}72}
6073
74// A touch that travels less than this is a tap on the handle, not a drag.
75const HANDLE_TAP_SLOP_PX = 6;
76
61interface Props {77interface Props {
62 readonly threadRef: ScopedThreadRef;78 readonly threadRef: ScopedThreadRef;
63 readonly miniPlayer: PreviewMiniPlayerState;79 readonly miniPlayer: PreviewMiniPlayerState;
...@@ -112,32 +128,54 @@ function BrowserMiniPlayer({ threadRef, tabId, miniPlayer }: Props & { readonly...@@ -112,32 +128,54 @@ function BrowserMiniPlayer({ threadRef, tabId, miniPlayer }: Props & { readonly
112 const fittedSourceContent = useBrowserSurfaceStore(128 const fittedSourceContent = useBrowserSurfaceStore(
113 (state) => state.byTabId[runtimeTabId]?.fittedSourceContent ?? null,129 (state) => state.byTabId[runtimeTabId]?.fittedSourceContent ?? null,
114 );130 );
115 const sourceSize = resolvePreviewMiniPlayerSourceSize(131 const nativeServerTab = useRendersServerTabNatively(threadRef.environmentId, snapshot);
116 snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT,132 const serverTab = snapshot?.runtime === "server" && !nativeServerTab;
117 fittedSourceContent,133 const [streamViewport, setStreamViewport] = useState<PreviewStreamViewport | null>(null);
118 desktopOverlay?.zoomFactor ?? 1,134 const serverSurfaceRef = useRef<ServerBrowserHandle | null>(null);
119 );135 const serverPictureInPicture =
136 useServerPictureInPictureKey() === serverPictureInPictureKey(threadRef.threadId, tabId);
137 const sourceSize =
138 serverTab && streamViewport
139 ? streamViewport
140 : resolvePreviewMiniPlayerSourceSize(
141 snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT,
142 fittedSourceContent,
143 desktopOverlay?.zoomFactor ?? 1,
144 );
120145
121 const openInPanel = () => {146 const openInPanel = () => {
122 usePreviewMiniPlayerStore.getState().close(threadRef);147 usePreviewMiniPlayerStore.getState().close(threadRef);
123 useRightPanelStore.getState().openBrowser(threadRef, tabId);148 useRightPanelStore.getState().openBrowser(threadRef, tabId);
124 };149 };
125150
126 const toggleNativePictureInPicture = () => {151 const toggleNativePictureInPicture = async () => {
127 if (!previewBridge) return;152 try {
128 const operation = desktopOverlay?.pictureInPicture153 if (serverTab) {
129 ? previewBridge.pictureInPicture.close154 if (serverPictureInPicture) closeServerPictureInPicture();
130 : previewBridge.pictureInPicture.open;155 else
131 void operation(runtimeTabId).catch((error) => {156 await openServerPictureInPicture({
157 ...threadRef,
158 tabId,
159 seed: serverSurfaceRef.current?.canvas() ?? null,
160 });
161 } else if (previewBridge) {
162 const operation = desktopOverlay?.pictureInPicture
163 ? previewBridge.pictureInPicture.close
164 : previewBridge.pictureInPicture.open;
165 await operation(runtimeTabId);
166 }
167 } catch (error) {
132 toastManager.add({168 toastManager.add({
133 type: "error",169 type: "error",
134 title: "Unable to update popped-out preview",170 title: serverTab ? "Unable to pop out preview" : "Unable to update popped-out preview",
135 description: error instanceof Error ? error.message : "An error occurred.",171 description: error instanceof Error ? error.message : "An error occurred.",
136 });172 });
137 });173 }
138 };174 };
139175
140 if (!snapshot) return null;176 if (!snapshot) return null;
177 const poppedOut = serverTab ? serverPictureInPicture : Boolean(desktopOverlay?.pictureInPicture);
178 const canPopOut = serverTab ? supportsServerPictureInPicture() : true;
141179
142 return (180 return (
143 <MiniPlayerShell181 <MiniPlayerShell
...@@ -148,51 +186,68 @@ function BrowserMiniPlayer({ threadRef, tabId, miniPlayer }: Props & { readonly...@@ -148,51 +186,68 @@ function BrowserMiniPlayer({ threadRef, tabId, miniPlayer }: Props & { readonly
148 recording={recording}186 recording={recording}
149 onOpenInPanel={openInPanel}187 onOpenInPanel={openInPanel}
150 pillActions={188 pillActions={
151 <Tooltip>189 canPopOut ? (
152 <TooltipTrigger190 <Tooltip>
153 render={191 <TooltipTrigger
154 <Button192 render={
155 variant={desktopOverlay?.pictureInPicture ? "secondary" : "ghost"}193 <Button
156 size="icon-xs"194 variant={poppedOut ? "secondary" : "ghost"}
157 aria-label={195 size="icon-xs"
158 desktopOverlay?.pictureInPicture196 aria-label={
159 ? "Close popped-out preview"197 poppedOut ? "Close popped-out preview" : "Pop preview into separate window"
160 : "Pop preview into separate window"198 }
161 }199 disabled={!serverTab && !desktopOverlay?.hasWebContents}
162 disabled={!desktopOverlay?.hasWebContents}200 onPointerDown={(event) => event.stopPropagation()}
163 onPointerDown={(event) => event.stopPropagation()}201 onClick={toggleNativePictureInPicture}
164 onClick={toggleNativePictureInPicture}202 />
165 />203 }
166 }204 >
167 >205 <PictureInPicture2 />
168 <PictureInPicture2 />206 </TooltipTrigger>
169 </TooltipTrigger>207 <TooltipPopup side="top">
170 <TooltipPopup side="top">208 {poppedOut ? "Close separate window" : "Pop into separate window"}
171 {desktopOverlay?.pictureInPicture209 </TooltipPopup>
172 ? "Close separate window"210 </Tooltip>
173 : "Pop into separate window"}211 ) : null
174 </TooltipPopup>
175 </Tooltip>
176 }212 }
177 >213 >
178 {(frame) => (214 {(frame) =>
179 <>215 serverTab ? (
180 <BrowserSurfaceSlot216 <div
181 tabId={runtimeTabId}217 className="pointer-events-auto absolute inset-0 overflow-hidden rounded-[inherit]"
182 visible={Boolean(desktopOverlay?.hasWebContents)}218 style={{ zIndex: PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX }}
183 cornerRadius={PREVIEW_MINI_PLAYER_CORNER_RADIUS}219 >
184 zIndex={PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX}220 <ServerBrowserSurface
185 fitSourceContent221 ref={serverSurfaceRef}
186 layoutVersion={`${frame.x}:${frame.y}`}222 environmentId={threadRef.environmentId}
187 className="absolute inset-0"223 threadId={threadRef.threadId}
188 />224 tabId={tabId}
189 {!desktopOverlay?.hasWebContents ? (225 visible
190 <div className="pointer-events-none absolute inset-0 z-[49] flex items-center justify-center rounded-[inherit] bg-muted text-xs text-muted-foreground">226 followSize={false}
191 Reconnecting preview…227 controlPosition="bottom"
192 </div>228 onViewport={setStreamViewport}
193 ) : null}229 className="size-full"
194 </>230 />
195 )}231 </div>
232 ) : (
233 <>
234 <BrowserSurfaceSlot
235 tabId={runtimeTabId}
236 visible={Boolean(desktopOverlay?.hasWebContents)}
237 cornerRadius={PREVIEW_MINI_PLAYER_CORNER_RADIUS}
238 zIndex={PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX}
239 fitSourceContent
240 layoutVersion={`${frame.x}:${frame.y}`}
241 className="absolute inset-0"
242 />
243 {!desktopOverlay?.hasWebContents ? (
244 <div className="pointer-events-none absolute inset-0 z-[49] flex items-center justify-center rounded-[inherit] bg-muted text-xs text-muted-foreground">
245 Reconnecting preview…
246 </div>
247 ) : null}
248 </>
249 )
250 }
196 </MiniPlayerShell>251 </MiniPlayerShell>
197 );252 );
198}253}
...@@ -285,6 +340,21 @@ function MiniPlayerShell({...@@ -285,6 +340,21 @@ function MiniPlayerShell({
285}) {340}) {
286 const canvas = useChatCanvas();341 const canvas = useChatCanvas();
287 const gestureRef = useRef<PointerGesture | null>(null);342 const gestureRef = useRef<PointerGesture | null>(null);
343 // Touch has no hover, so tapping the handle toggles the pill instead. The
344 // toggle waits for the tap's click, which would otherwise land on the pill.
345 const [pillOpen, setPillOpen] = useState(false);
346 const handleTappedRef = useRef(false);
347 const handleRef = useRef<HTMLDivElement | null>(null);
348 // The pill covers the handle, so a tap anywhere else dismisses it.
349 useEffect(() => {
350 if (!pillOpen) return;
351 const dismiss = (event: PointerEvent) => {
352 if (event.target instanceof Node && handleRef.current?.contains(event.target)) return;
353 setPillOpen(false);
354 };
355 document.addEventListener("pointerdown", dismiss, true);
356 return () => document.removeEventListener("pointerdown", dismiss, true);
357 }, [pillOpen]);
288 const container = canvas?.container ?? null;358 const container = canvas?.container ?? null;
289 const obstacles = NO_PREVIEW_MINI_PLAYER_OBSTACLES;359 const obstacles = NO_PREVIEW_MINI_PLAYER_OBSTACLES;
290 const sourceKey = previewMiniPlayerSourceKey(miniPlayer.source);360 const sourceKey = previewMiniPlayerSourceKey(miniPlayer.source);
...@@ -326,10 +396,12 @@ function MiniPlayerShell({...@@ -326,10 +396,12 @@ function MiniPlayerShell({
326 if (event.button !== 0 || !frame) return;396 if (event.button !== 0 || !frame) return;
327 gestureRef.current = {397 gestureRef.current = {
328 pointerId: event.pointerId,398 pointerId: event.pointerId,
399 pointerType: event.pointerType,
329 pointerX: event.clientX,400 pointerX: event.clientX,
330 pointerY: event.clientY,401 pointerY: event.clientY,
331 frame,402 frame,
332 direction,403 direction,
404 moved: false,
333 };405 };
334 event.currentTarget.setPointerCapture(event.pointerId);406 event.currentTarget.setPointerCapture(event.pointerId);
335 event.preventDefault();407 event.preventDefault();
...@@ -340,6 +412,8 @@ function MiniPlayerShell({...@@ -340,6 +412,8 @@ function MiniPlayerShell({
340 const gesture = gestureRef.current;412 const gesture = gestureRef.current;
341 if (!gesture || gesture.pointerId !== event.pointerId || !container) return;413 if (!gesture || gesture.pointerId !== event.pointerId || !container) return;
342 const delta = { x: event.clientX - gesture.pointerX, y: event.clientY - gesture.pointerY };414 const delta = { x: event.clientX - gesture.pointerX, y: event.clientY - gesture.pointerY };
415 if (!gesture.moved && Math.hypot(delta.x, delta.y) < HANDLE_TAP_SLOP_PX) return;
416 gesture.moved = true;
343 const store = usePreviewMiniPlayerStore.getState();417 const store = usePreviewMiniPlayerStore.getState();
344 if (gesture.direction === null) {418 if (gesture.direction === null) {
345 store.move(419 store.move(
...@@ -366,8 +440,14 @@ function MiniPlayerShell({...@@ -366,8 +440,14 @@ function MiniPlayerShell({
366 };440 };
367441
368 const endGesture = (event: ReactPointerEvent<HTMLElement>) => {442 const endGesture = (event: ReactPointerEvent<HTMLElement>) => {
369 if (gestureRef.current?.pointerId !== event.pointerId) return;443 const gesture = gestureRef.current;
444 if (gesture?.pointerId !== event.pointerId) return;
370 gestureRef.current = null;445 gestureRef.current = null;
446 handleTappedRef.current =
447 event.type === "pointerup" &&
448 gesture.direction === null &&
449 gesture.pointerType === "touch" &&
450 !gesture.moved;
371 if (event.currentTarget.hasPointerCapture(event.pointerId)) {451 if (event.currentTarget.hasPointerCapture(event.pointerId)) {
372 event.currentTarget.releasePointerCapture(event.pointerId);452 event.currentTarget.releasePointerCapture(event.pointerId);
373 }453 }
...@@ -389,18 +469,25 @@ function MiniPlayerShell({...@@ -389,18 +469,25 @@ function MiniPlayerShell({
389 }}469 }}
390 >470 >
391 <div471 <div
392 className="group pointer-events-auto absolute z-[49] size-3 touch-none cursor-grab active:cursor-grabbing"472 ref={handleRef}
473 data-pill-open={pillOpen ? "" : undefined}
474 className="group pointer-events-auto absolute z-[49] size-3 touch-none cursor-grab active:cursor-grabbing pointer-coarse:-m-2.5 pointer-coarse:size-8"
393 style={{ right: pillInset, top: pillInset }}475 style={{ right: pillInset, top: pillInset }}
394 onPointerDown={(event) => beginGesture(event, null)}476 onPointerDown={(event) => beginGesture(event, null)}
395 onPointerMove={handlePointerMove}477 onPointerMove={handlePointerMove}
396 onPointerUp={endGesture}478 onPointerUp={endGesture}
397 onPointerCancel={endGesture}479 onPointerCancel={endGesture}
480 onClick={() => {
481 if (!handleTappedRef.current) return;
482 handleTappedRef.current = false;
483 setPillOpen((open) => !open);
484 }}
398 >485 >
399 <div486 <div
400 role={recording ? "status" : undefined}487 role={recording ? "status" : undefined}
401 aria-label={recording ? "Recording preview" : undefined}488 aria-label={recording ? "Recording preview" : undefined}
402 aria-hidden={!recording}489 aria-hidden={!recording}
403 className="absolute right-0 top-0 size-2 transition-opacity group-hover:opacity-0 group-focus-within:opacity-0"490 className="absolute right-0 top-0 size-2 transition-opacity group-hover:opacity-0 group-focus-within:opacity-0 group-data-pill-open:opacity-0 pointer-coarse:right-2.5 pointer-coarse:top-2.5"
404 >491 >
405 <span492 <span
406 className={cn(493 className={cn(
...@@ -411,7 +498,7 @@ function MiniPlayerShell({...@@ -411,7 +498,7 @@ function MiniPlayerShell({
411 )}498 )}
412 />499 />
413 </div>500 </div>
414 <div className="pointer-events-none absolute right-0 top-0 flex h-8 cursor-grab items-center gap-0.5 rounded-lg border border-border/80 bg-popover/92 p-0.5 opacity-0 shadow-lg/20 backdrop-blur-xl transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100 active:cursor-grabbing">501 <div className="pointer-events-none absolute right-0 top-0 flex h-8 cursor-grab items-center gap-0.5 rounded-lg border border-border/80 bg-popover/92 p-0.5 opacity-0 shadow-lg/20 backdrop-blur-xl transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-data-pill-open:pointer-events-auto group-data-pill-open:opacity-100 active:cursor-grabbing pointer-coarse:right-2.5 pointer-coarse:top-2.5">
415 {recording ? (502 {recording ? (
416 <span aria-hidden className="flex size-6 shrink-0 items-center justify-center">503 <span aria-hidden className="flex size-6 shrink-0 items-center justify-center">
417 <span className="size-2 rounded-full bg-destructive motion-safe:animate-status-pulse" />504 <span className="size-2 rounded-full bg-destructive motion-safe:animate-status-pulse" />
apps/web/src/components/preview/openDiscoveredPort.ts+6-1
...@@ -9,13 +9,18 @@ import type { BrowserSettingsReadError, OpenPreviewMutation } from "~/browser/op...@@ -9,13 +9,18 @@ import type { BrowserSettingsReadError, OpenPreviewMutation } from "~/browser/op
9import { recordVisitForThread } from "~/browserHistoryStore";9import { recordVisitForThread } from "~/browserHistoryStore";
10import { useRightPanelStore } from "~/rightPanelStore";10import { useRightPanelStore } from "~/rightPanelStore";
11import { openPreviewSession } from "./openPreviewSession";11import { openPreviewSession } from "./openPreviewSession";
12import { previewRuntimeFor } from "~/browser/previewRuntime";
1213
13export async function openDiscoveredPort<E>(input: {14export async function openDiscoveredPort<E>(input: {
14 readonly threadRef: ScopedThreadRef;15 readonly threadRef: ScopedThreadRef;
15 readonly port: DiscoveredLocalServer;16 readonly port: DiscoveredLocalServer;
16 readonly openPreview: OpenPreviewMutation<E>;17 readonly openPreview: OpenPreviewMutation<E>;
17}): Promise<AtomCommandResult<void, E | BrowserSettingsReadError>> {18}): Promise<AtomCommandResult<void, E | BrowserSettingsReadError>> {
18 const resolvedUrl = resolveDiscoveredServerUrl(input.threadRef.environmentId, input.port.url);19 // A server tab runs on the environment, where loopback is already right.
20 const resolvedUrl =
21 previewRuntimeFor(input.threadRef.environmentId) === "server"
22 ? input.port.url
23 : resolveDiscoveredServerUrl(input.threadRef.environmentId, input.port.url);
19 const result = await openPreviewSession({24 const result = await openPreviewSession({
20 openPreview: input.openPreview,25 openPreview: input.openPreview,
21 threadRef: input.threadRef,26 threadRef: input.threadRef,
apps/web/src/components/preview/openPreviewSession.ts+3
...@@ -15,6 +15,7 @@ import {...@@ -15,6 +15,7 @@ import {
15 resolveBrowserDefaults,15 resolveBrowserDefaults,
16} from "~/browser/browserDefaults";16} from "~/browser/browserDefaults";
17import { BrowserSettingsReadError } from "~/browser/openFileInPreview";17import { BrowserSettingsReadError } from "~/browser/openFileInPreview";
18import { previewRuntimeFor } from "~/browser/previewRuntime";
18import { applyPreviewServerSnapshot, rememberPreviewUrl } from "~/previewStateStore";19import { applyPreviewServerSnapshot, rememberPreviewUrl } from "~/previewStateStore";
1920
20interface OpenPreviewSessionInput<E> {21interface OpenPreviewSessionInput<E> {
...@@ -41,6 +42,7 @@ export async function openPreviewSession<E>(...@@ -41,6 +42,7 @@ export async function openPreviewSession<E>(
41 if (defaults instanceof BrowserSettingsReadError) {42 if (defaults instanceof BrowserSettingsReadError) {
42 return AsyncResult.failure(Cause.fail(defaults));43 return AsyncResult.failure(Cause.fail(defaults));
43 }44 }
45 const runtime = previewRuntimeFor(input.threadRef.environmentId);
44 const result = await input.openPreview({46 const result = await input.openPreview({
45 environmentId: input.threadRef.environmentId,47 environmentId: input.threadRef.environmentId,
46 input: {48 input: {
...@@ -48,6 +50,7 @@ export async function openPreviewSession<E>(...@@ -48,6 +50,7 @@ export async function openPreviewSession<E>(
48 ...(input.url === undefined ? {} : { url: input.url }),50 ...(input.url === undefined ? {} : { url: input.url }),
49 viewport: input.viewport ?? browserDefaultOpenViewport(defaults),51 viewport: input.viewport ?? browserDefaultOpenViewport(defaults),
50 profileId: input.profileId ?? browserDefaultOpenProfileId(defaults),52 profileId: input.profileId ?? browserDefaultOpenProfileId(defaults),
53 ...(runtime === undefined ? {} : { runtime }),
51 },54 },
52 });55 });
53 if (result._tag === "Failure") {56 if (result._tag === "Failure") {
apps/web/src/components/preview/openTerminalLinkInPreview.ts+5-2
...@@ -9,8 +9,9 @@ import {...@@ -9,8 +9,9 @@ import {
9} from "~/browser/browserDefaults";9} from "~/browser/browserDefaults";
10import { isWebUrl, resolveBrowserLinkTargetPreference } from "~/browser/browserLinkTarget";10import { isWebUrl, resolveBrowserLinkTargetPreference } from "~/browser/browserLinkTarget";
11import type { OpenPreviewMutation } from "~/browser/openFileInPreview";11import type { OpenPreviewMutation } from "~/browser/openFileInPreview";
12import { isPreviewAvailableFor, previewRuntimeFor } from "~/browser/previewRuntime";
12import { recordVisitForThread } from "~/browserHistoryStore";13import { recordVisitForThread } from "~/browserHistoryStore";
13import { applyPreviewServerSnapshot, isPreviewSupportedInRuntime } from "~/previewStateStore";14import { applyPreviewServerSnapshot } from "~/previewStateStore";
14import { useRightPanelStore } from "~/rightPanelStore";15import { useRightPanelStore } from "~/rightPanelStore";
1516
16const terminalLinkErrorContext = {17const terminalLinkErrorContext = {
...@@ -48,7 +49,7 @@ export async function openTerminalLinkInPreview<E>(...@@ -48,7 +49,7 @@ export async function openTerminalLinkInPreview<E>(
48 const supportsPreview =49 const supportsPreview =
49 !input.forceBrowser &&50 !input.forceBrowser &&
50 isWebUrl(input.url) &&51 isWebUrl(input.url) &&
51 isPreviewSupportedInRuntime() &&52 isPreviewAvailableFor(input.threadRef.environmentId) &&
52 input.threadRef.threadId.length > 0 &&53 input.threadRef.threadId.length > 0 &&
53 (await resolveBrowserLinkTargetPreference()) === "app";54 (await resolveBrowserLinkTargetPreference()) === "app";
5455
...@@ -64,6 +65,7 @@ export async function openTerminalLinkInPreview<E>(...@@ -64,6 +65,7 @@ export async function openTerminalLinkInPreview<E>(
64 };65 };
6566
66 const defaults = await resolveBrowserDefaults();67 const defaults = await resolveBrowserDefaults();
68 const runtime = previewRuntimeFor(input.threadRef.environmentId);
67 const result = await input.openPreview({69 const result = await input.openPreview({
68 environmentId: input.threadRef.environmentId,70 environmentId: input.threadRef.environmentId,
69 input: {71 input: {
...@@ -73,6 +75,7 @@ export async function openTerminalLinkInPreview<E>(...@@ -73,6 +75,7 @@ export async function openTerminalLinkInPreview<E>(
73 // mapping, so the configured defaults are applied explicitly.75 // mapping, so the configured defaults are applied explicitly.
74 viewport: browserDefaultOpenViewport(defaults),76 viewport: browserDefaultOpenViewport(defaults),
75 profileId: browserDefaultOpenProfileId(defaults),77 profileId: browserDefaultOpenProfileId(defaults),
78 ...(runtime === undefined ? {} : { runtime }),
76 },79 },
77 });80 });
78 if (result._tag === "Failure") {81 if (result._tag === "Failure") {
apps/web/src/components/preview/previewAutomationClientId.ts deleted-4
...@@ -1,4 +0,0 @@
1export function createPreviewAutomationClientId(): string {
2 const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
3 return `preview-${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
4}
apps/web/src/components/preview/previewAutomationErrors.ts deleted-243
...@@ -1,243 +0,0 @@
1import {
2 EnvironmentId,
3 type PreviewAutomationHost,
4 PreviewAutomationOperation,
5 PreviewAutomationRecordingTransferError,
6 PreviewAutomationRecordingDesktopUpdateRequiredError,
7 PreviewAutomationRecordingTooLargeError,
8 PreviewAutomationRecordingDeadlineExpiredError,
9 type PreviewAutomationRequest,
10 type PreviewAutomationResponse,
11 PreviewTabId,
12 ThreadId,
13 TrimmedNonEmptyString,
14} from "@t3tools/contracts";
15import * as Schema from "effect/Schema";
16
17export interface PreviewAutomationOperationContext {
18 readonly requestId: PreviewAutomationRequest["requestId"];
19 readonly operation: PreviewAutomationRequest["operation"];
20 readonly environmentId: PreviewAutomationHost["environmentId"];
21 readonly threadId: PreviewAutomationRequest["threadId"];
22 readonly tabId: Exclude<PreviewAutomationRequest["tabId"], undefined> | null;
23}
24
25export class PreviewAutomationOverlayTimeoutError extends Schema.TaggedError<PreviewAutomationOverlayTimeoutError>()(
26 "PreviewAutomationOverlayTimeoutError",
27 {
28 requestId: TrimmedNonEmptyString,
29 environmentId: EnvironmentId,
30 threadId: ThreadId,
31 timeoutMs: Schema.Int,
32 },
33) {
34 get responseTag() {
35 return "PreviewAutomationTimeoutError" as const;
36 }
37
38 override get message(): string {
39 return `Preview webview for request ${this.requestId} on environment ${this.environmentId} thread ${this.threadId} did not register within ${this.timeoutMs}ms.`;
40 }
41}
42
43export class PreviewAutomationNavigationTimeoutError extends Schema.TaggedError<PreviewAutomationNavigationTimeoutError>()(
44 "PreviewAutomationNavigationTimeoutError",
45 {
46 requestId: TrimmedNonEmptyString,
47 environmentId: EnvironmentId,
48 threadId: ThreadId,
49 tabId: PreviewTabId,
50 readiness: Schema.Literals(["domContentLoaded", "load"]),
51 timeoutMs: Schema.Int,
52 },
53) {
54 get responseTag() {
55 return "PreviewAutomationTimeoutError" as const;
56 }
57
58 override get message(): string {
59 return `Preview navigation for request ${this.requestId} on environment ${this.environmentId} thread ${this.threadId} tab ${this.tabId} did not reach ${this.readiness} readiness within ${this.timeoutMs}ms.`;
60 }
61}
62
63export class PreviewAutomationViewportTimeoutError extends Schema.TaggedError<PreviewAutomationViewportTimeoutError>()(
64 "PreviewAutomationViewportTimeoutError",
65 {
66 requestId: TrimmedNonEmptyString,
67 environmentId: EnvironmentId,
68 threadId: ThreadId,
69 tabId: PreviewTabId,
70 timeoutMs: Schema.Int,
71 },
72) {
73 get responseTag() {
74 return "PreviewAutomationTimeoutError" as const;
75 }
76
77 override get message(): string {
78 return `Preview viewport for request ${this.requestId} on environment ${this.environmentId} thread ${this.threadId} tab ${this.tabId} was not rendered within ${this.timeoutMs}ms.`;
79 }
80}
81
82export class PreviewAutomationTargetUnavailableError extends Schema.TaggedError<PreviewAutomationTargetUnavailableError>()(
83 "PreviewAutomationTargetUnavailableError",
84 {
85 requestId: TrimmedNonEmptyString,
86 operation: PreviewAutomationOperation,
87 environmentId: EnvironmentId,
88 threadId: ThreadId,
89 tabId: Schema.NullOr(PreviewTabId),
90 bridgeAvailable: Schema.Boolean,
91 },
92) {
93 get responseTag() {
94 return "PreviewAutomationTabNotFoundError" as const;
95 }
96
97 override get message(): string {
98 return `Preview automation target for ${this.operation} request ${this.requestId} is unavailable on environment ${this.environmentId} thread ${this.threadId} (tab ${this.tabId ?? "unassigned"}, bridge ${this.bridgeAvailable ? "available" : "unavailable"}).`;
99 }
100}
101
102export class PreviewAutomationRecordingNotActiveError extends Schema.TaggedError<PreviewAutomationRecordingNotActiveError>()(
103 "PreviewAutomationRecordingNotActiveError",
104 {
105 requestId: TrimmedNonEmptyString,
106 environmentId: EnvironmentId,
107 threadId: ThreadId,
108 tabId: Schema.NullOr(PreviewTabId),
109 },
110) {
111 get responseTag() {
112 return "PreviewAutomationExecutionError" as const;
113 }
114
115 override get message(): string {
116 return `Preview automation request ${this.requestId} found no active recording for tab ${this.tabId ?? "unassigned"} on environment ${this.environmentId} thread ${this.threadId}.`;
117 }
118}
119
120export class PreviewAutomationTargetNotEditableHostError extends Schema.TaggedError<PreviewAutomationTargetNotEditableHostError>()(
121 "PreviewAutomationTargetNotEditableHostError",
122 {
123 requestId: TrimmedNonEmptyString,
124 operation: PreviewAutomationOperation,
125 environmentId: EnvironmentId,
126 threadId: ThreadId,
127 tabId: Schema.NullOr(PreviewTabId),
128 selectorKind: Schema.optional(Schema.Literals(["focused-element", "locator", "selector"])),
129 selectorLength: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))),
130 },
131) {
132 get responseTag() {
133 return "PreviewAutomationTargetNotEditableError" as const;
134 }
135
136 override get message(): string {
137 return `Preview automation ${this.operation} request ${this.requestId} requires an editable target in tab ${this.tabId ?? "unassigned"}.`;
138 }
139}
140
141const targetNotEditableDiagnostics = (
142 cause: unknown,
143): {
144 readonly selectorKind?: "focused-element" | "locator" | "selector";
145 readonly selectorLength?: number;
146} | null => {
147 if (
148 typeof cause !== "object" ||
149 cause === null ||
150 !("_tag" in cause) ||
151 cause._tag !== "PreviewAutomationTargetNotEditableError"
152 ) {
153 return null;
154 }
155 const selectorKind =
156 "selectorKind" in cause &&
157 (cause.selectorKind === "focused-element" ||
158 cause.selectorKind === "locator" ||
159 cause.selectorKind === "selector")
160 ? cause.selectorKind
161 : undefined;
162 const selectorLength =
163 "selectorLength" in cause &&
164 typeof cause.selectorLength === "number" &&
165 Number.isInteger(cause.selectorLength) &&
166 cause.selectorLength >= 0
167 ? cause.selectorLength
168 : undefined;
169 return {
170 ...(selectorKind === undefined ? {} : { selectorKind }),
171 ...(selectorLength === undefined ? {} : { selectorLength }),
172 };
173};
174
175export class PreviewAutomationOperationError extends Schema.TaggedError<PreviewAutomationOperationError>()(
176 "PreviewAutomationOperationError",
177 {
178 requestId: TrimmedNonEmptyString,
179 operation: PreviewAutomationOperation,
180 environmentId: EnvironmentId,
181 threadId: ThreadId,
182 tabId: Schema.NullOr(PreviewTabId),
183 cause: Schema.Defect(),
184 },
185) {
186 static fromCause(
187 input: PreviewAutomationOperationContext & { readonly cause: unknown },
188 ): PreviewAutomationHostError {
189 if (isPreviewAutomationHostError(input.cause)) return input.cause;
190 const diagnostics = targetNotEditableDiagnostics(input.cause);
191 return diagnostics
192 ? new PreviewAutomationTargetNotEditableHostError({
193 requestId: input.requestId,
194 operation: input.operation,
195 environmentId: input.environmentId,
196 threadId: input.threadId,
197 tabId: input.tabId,
198 ...diagnostics,
199 })
200 : new PreviewAutomationOperationError(input);
201 }
202
203 get responseTag() {
204 return "PreviewAutomationExecutionError" as const;
205 }
206
207 override get message(): string {
208 return `Preview automation ${this.operation} request ${this.requestId} failed on environment ${this.environmentId} thread ${this.threadId} (tab ${this.tabId ?? "unassigned"}).`;
209 }
210}
211
212export const PreviewAutomationHostError = Schema.Union([
213 PreviewAutomationRecordingTransferError,
214 PreviewAutomationRecordingDesktopUpdateRequiredError,
215 PreviewAutomationRecordingTooLargeError,
216 PreviewAutomationRecordingDeadlineExpiredError,
217 PreviewAutomationOverlayTimeoutError,
218 PreviewAutomationNavigationTimeoutError,
219 PreviewAutomationViewportTimeoutError,
220 PreviewAutomationTargetUnavailableError,
221 PreviewAutomationRecordingNotActiveError,
222 PreviewAutomationTargetNotEditableHostError,
223 PreviewAutomationOperationError,
224]);
225export type PreviewAutomationHostError = typeof PreviewAutomationHostError.Type;
226
227const isPreviewAutomationHostError = Schema.is(PreviewAutomationHostError);
228
229export function serializePreviewAutomationHostError(
230 error: PreviewAutomationHostError,
231): NonNullable<PreviewAutomationResponse["error"]> {
232 const detail = Object.fromEntries(
233 Object.entries(error).filter(
234 ([key]) =>
235 key !== "_tag" && key !== "cause" && key !== "name" && key !== "message" && key !== "stack",
236 ),
237 );
238 return {
239 _tag: "responseTag" in error ? error.responseTag : error._tag,
240 message: error.message,
241 ...(Object.keys(detail).length === 0 ? {} : { detail }),
242 };
243}
apps/web/src/components/preview/previewAutomationHostBudget.test.ts deleted-136
...@@ -1,136 +0,0 @@
1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
2
3import {
4 PREVIEW_HOST_RESPONSE_MARGIN_MS,
5 resolveHostWaitBudgetMs,
6 waitForHostReadiness,
7} from "./previewAutomationHostBudget";
8
9describe("resolveHostWaitBudgetMs", () => {
10 it("reserves the full response margin once the request budget allows it", () => {
11 expect(resolveHostWaitBudgetMs(15_000)).toBe(15_000 - PREVIEW_HOST_RESPONSE_MARGIN_MS);
12 expect(resolveHostWaitBudgetMs(60_000)).toBe(60_000 - PREVIEW_HOST_RESPONSE_MARGIN_MS);
13 });
14
15 it("keeps most of a short request budget instead of collapsing it", () => {
16 expect(resolveHostWaitBudgetMs(1_000)).toBe(800);
17 expect(resolveHostWaitBudgetMs(100)).toBe(80);
18 });
19
20 it("returns a non-negative budget for invalid input", () => {
21 for (const invalid of [0, -5, Number.NaN, Number.POSITIVE_INFINITY]) {
22 expect(resolveHostWaitBudgetMs(invalid)).toBeGreaterThanOrEqual(0);
23 }
24 });
25});
26
27describe("waitForHostReadiness", () => {
28 beforeEach(() => {
29 vi.useFakeTimers();
30 vi.setSystemTime(0);
31 });
32
33 afterEach(() => {
34 vi.useRealTimers();
35 });
36
37 it.each([1, 10, 100, 250, 1_000, 15_000])(
38 "stops unavailable-overlay polling before a %ims broker timeout",
39 async (requestTimeoutMs) => {
40 const deadlineMs = Date.now() + resolveHostWaitBudgetMs(requestTimeoutMs);
41 let finishedAt: number | undefined;
42 const result = waitForHostReadiness(deadlineMs, async () => false).then((ready) => {
43 finishedAt = Date.now();
44 return ready;
45 });
46
47 await vi.advanceTimersByTimeAsync(requestTimeoutMs);
48
49 expect(await result).toBe(false);
50 expect(finishedAt).toBeLessThan(requestTimeoutMs);
51 expect(vi.getTimerCount()).toBe(0);
52 },
53 );
54
55 it("includes session setup in the deadline instead of starting a fresh wait budget", async () => {
56 const deadlineMs = Date.now() + resolveHostWaitBudgetMs(15_000);
57 await vi.advanceTimersByTimeAsync(2_000);
58 let finished = false;
59 const result = waitForHostReadiness(deadlineMs, async () => false).then((ready) => {
60 finished = true;
61 return ready;
62 });
63
64 await vi.advanceTimersByTimeAsync(11_499);
65 expect(finished).toBe(false);
66 await vi.advanceTimersByTimeAsync(1);
67 expect(await result).toBe(false);
68 expect(Date.now()).toBe(deadlineMs);
69 expect(vi.getTimerCount()).toBe(0);
70 });
71
72 it("skips readiness probes when setup has already exhausted the deadline", async () => {
73 const deadlineMs = Date.now() + resolveHostWaitBudgetMs(100);
74 await vi.advanceTimersByTimeAsync(100);
75 const isReady = vi.fn(async () => false);
76
77 expect(await waitForHostReadiness(deadlineMs, isReady)).toBe(false);
78 expect(isReady).not.toHaveBeenCalled();
79 expect(vi.getTimerCount()).toBe(0);
80 });
81
82 it("returns as soon as the overlay is ready and clears its timeout", async () => {
83 const isReady = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true);
84 const result = waitForHostReadiness(800, isReady);
85
86 await vi.advanceTimersByTimeAsync(50);
87
88 expect(await result).toBe(true);
89 expect(isReady).toHaveBeenCalledTimes(2);
90 expect(vi.getTimerCount()).toBe(0);
91 });
92
93 it.each([false, true])(
94 "keeps a probe result of %s that wins the deadline race",
95 async (ready) => {
96 const isReady = vi.fn(
97 () => new Promise<boolean>((resolve) => setTimeout(() => resolve(ready), 80)),
98 );
99 const result = waitForHostReadiness(80, isReady);
100
101 await vi.advanceTimersByTimeAsync(80);
102
103 expect(await result).toBe(ready);
104 expect(isReady).toHaveBeenCalledTimes(1);
105 expect(vi.getTimerCount()).toBe(0);
106 },
107 );
108
109 it("bounds a stalled status probe and ignores a late ready result", async () => {
110 let completeProbe!: (ready: boolean) => void;
111 const isReady = vi.fn(
112 () =>
113 new Promise<boolean>((resolve) => {
114 completeProbe = resolve;
115 }),
116 );
117 const result = waitForHostReadiness(80, isReady);
118
119 await vi.advanceTimersByTimeAsync(80);
120 expect(await result).toBe(false);
121 expect(vi.getTimerCount()).toBe(0);
122
123 completeProbe(true);
124 await vi.runAllTimersAsync();
125 expect(isReady).toHaveBeenCalledTimes(1);
126 expect(vi.getTimerCount()).toBe(0);
127 });
128
129 it("preserves probe failures and clears the pending timeout", async () => {
130 const error = new Error("Preview target was replaced");
131 const isReady = vi.fn().mockRejectedValue(error);
132
133 await expect(waitForHostReadiness(800, isReady)).rejects.toBe(error);
134 expect(vi.getTimerCount()).toBe(0);
135 });
136});
apps/web/src/components/preview/previewAutomationHostBudget.ts deleted-42
...@@ -1,42 +0,0 @@
1/** Allow time to deliver an overlay failure before the broker times out. */
2export const PREVIEW_HOST_RESPONSE_MARGIN_MS = 1_500;
3
4const HOST_RESPONSE_MARGIN_FRACTION = 0.2;
5
6export function resolveHostWaitBudgetMs(requestTimeoutMs: number): number {
7 if (!Number.isFinite(requestTimeoutMs) || requestTimeoutMs <= 0) {
8 return 0;
9 }
10 const reservedMs = Math.min(
11 PREVIEW_HOST_RESPONSE_MARGIN_MS,
12 Math.ceil(requestTimeoutMs * HOST_RESPONSE_MARGIN_FRACTION),
13 );
14 return Math.max(0, requestTimeoutMs - reservedMs);
15}
16
17/** Both readiness probes and polling delays share the request's host deadline. */
18export async function waitForHostReadiness(
19 deadlineMs: number,
20 isReady: () => Promise<boolean>,
21): Promise<boolean> {
22 while (Date.now() < deadlineMs) {
23 let timeout: ReturnType<typeof setTimeout> | undefined;
24 let ready: boolean | null;
25 try {
26 ready = await Promise.race([
27 isReady(),
28 new Promise<null>((resolve) => {
29 timeout = setTimeout(() => resolve(null), Math.max(0, deadlineMs - Date.now()));
30 }),
31 ]);
32 } finally {
33 clearTimeout(timeout);
34 }
35 if (ready) return true;
36 if (ready === null || Date.now() >= deadlineMs) return false;
37 await new Promise<void>((resolve) =>
38 setTimeout(resolve, Math.min(50, deadlineMs - Date.now())),
39 );
40 }
41 return false;
42}
apps/web/src/components/preview/previewAutomationOpenReadiness.test.ts deleted-141
...@@ -1,141 +0,0 @@
1import type { PreviewAutomationOpenInput, PreviewSessionSnapshot } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 DEFAULT_PREVIEW_AUTOMATION_VIEWPORT,
6 explicitlySuppressesPreviewMiniPlayer,
7 previewAutomationDefaultViewport,
8 previewAutomationOpenNeedsOverlay,
9 shouldAutoShowPreviewForAutomationUse,
10 shouldOpenPreviewMiniPlayer,
11} from "./previewAutomationOpenReadiness";
12
13const snapshot = (navStatus: PreviewSessionSnapshot["navStatus"]): PreviewSessionSnapshot => ({
14 threadId: "thread-1",
15 tabId: "tab-1",
16 navStatus,
17 canGoBack: false,
18 canGoForward: false,
19 updatedAt: "2026-06-26T00:00:00.000Z",
20});
21
22describe("preview automation open readiness", () => {
23 it("opens the inline preview by default", () => {
24 expect(shouldOpenPreviewMiniPlayer({} as PreviewAutomationOpenInput)).toBe(true);
25 });
26
27 it("supports explicit opt-out and the legacy show alias", () => {
28 expect(shouldOpenPreviewMiniPlayer({ open: false } as PreviewAutomationOpenInput)).toBe(false);
29 expect(shouldOpenPreviewMiniPlayer({ show: false } as PreviewAutomationOpenInput)).toBe(false);
30 expect(
31 shouldOpenPreviewMiniPlayer({ open: true, show: false } as PreviewAutomationOpenInput),
32 ).toBe(true);
33 });
34
35 it("does not wait for a desktop overlay when opening an empty tab", () => {
36 expect(
37 previewAutomationOpenNeedsOverlay(
38 {} as PreviewAutomationOpenInput,
39 snapshot({ _tag: "Idle" }),
40 ),
41 ).toBe(false);
42 });
43
44 it("waits when an empty tab is immediately given a URL", () => {
45 expect(
46 previewAutomationOpenNeedsOverlay(
47 { url: "https://example.com" } as PreviewAutomationOpenInput,
48 snapshot({ _tag: "Idle" }),
49 ),
50 ).toBe(true);
51 });
52
53 it("waits for existing tabs that already have rendered content", () => {
54 expect(
55 previewAutomationOpenNeedsOverlay(
56 {} as PreviewAutomationOpenInput,
57 snapshot({
58 _tag: "Success",
59 url: "https://example.com/",
60 title: "Example",
61 }),
62 ),
63 ).toBe(true);
64 });
65
66 it("gives newly-created automation tabs a stable desktop viewport", () => {
67 expect(previewAutomationDefaultViewport(false, snapshot({ _tag: "Idle" }))).toEqual(
68 DEFAULT_PREVIEW_AUTOMATION_VIEWPORT,
69 );
70 });
71
72 it("preserves reused and already-fixed browser viewports", () => {
73 expect(previewAutomationDefaultViewport(true, snapshot({ _tag: "Idle" }))).toBeNull();
74 expect(
75 previewAutomationDefaultViewport(false, {
76 ...snapshot({ _tag: "Idle" }),
77 viewport: { _tag: "freeform", width: 900, height: 600 },
78 }),
79 ).toBeNull();
80 });
81});
82
83describe("shouldOpenPreviewMiniPlayer with the floating-preview preference", () => {
84 it("honours the preference when the agent said nothing either way", () => {
85 // `preview_open` no longer arrives with `open` pre-filled, so an agent
86 // that omitted it leaves the decision to the user's setting.
87 expect(shouldOpenPreviewMiniPlayer({}, false)).toBe(false);
88 expect(shouldOpenPreviewMiniPlayer({}, true)).toBe(true);
89 });
90
91 it("lets an explicit request outrank the preference in both directions", () => {
92 expect(shouldOpenPreviewMiniPlayer({ open: true }, false)).toBe(true);
93 expect(shouldOpenPreviewMiniPlayer({ open: false }, true)).toBe(false);
94 expect(shouldOpenPreviewMiniPlayer({ show: true }, false)).toBe(true);
95 });
96
97 it("distinguishes an explicit background request from a disabled preference", () => {
98 expect(explicitlySuppressesPreviewMiniPlayer({ open: false })).toBe(true);
99 expect(explicitlySuppressesPreviewMiniPlayer({ show: false })).toBe(true);
100 expect(explicitlySuppressesPreviewMiniPlayer({})).toBe(false);
101 });
102});
103
104describe("auto-show for existing automation tabs", () => {
105 it("records floating-preview intent whenever an agent uses the tab", () => {
106 expect(
107 shouldAutoShowPreviewForAutomationUse({
108 operation: "navigate",
109 autoShowFloatingPreview: true,
110 presentationSuppressed: false,
111 }),
112 ).toBe(true);
113 });
114
115 it("leaves explicit opens and background-only tabs alone", () => {
116 expect(
117 shouldAutoShowPreviewForAutomationUse({
118 operation: "open",
119 autoShowFloatingPreview: true,
120 presentationSuppressed: false,
121 }),
122 ).toBe(false);
123 expect(
124 shouldAutoShowPreviewForAutomationUse({
125 operation: "click",
126 autoShowFloatingPreview: true,
127 presentationSuppressed: true,
128 }),
129 ).toBe(false);
130 });
131
132 it("honours the auto-show preference for reused tabs", () => {
133 expect(
134 shouldAutoShowPreviewForAutomationUse({
135 operation: "snapshot",
136 autoShowFloatingPreview: false,
137 presentationSuppressed: false,
138 }),
139 ).toBe(false);
140 });
141});
apps/web/src/components/preview/previewAutomationOpenReadiness.ts deleted-69
...@@ -1,69 +0,0 @@
1import {
2 FILL_PREVIEW_VIEWPORT,
3 type PreviewAutomationOperation,
4 type PreviewAutomationOpenInput,
5 type PreviewSessionSnapshot,
6 type PreviewViewportSetting,
7} from "@t3tools/contracts";
8
9/**
10 * Viewport an agent-opened tab falls back to when the user has no configured
11 * browser default. Agents screenshot and assert against what they open, so a
12 * brand-new tab left in fill mode would be sized by whatever the panel happens
13 * to be — deterministic beats incidental here.
14 */
15export const DEFAULT_PREVIEW_AUTOMATION_VIEWPORT = {
16 _tag: "freeform",
17 width: 1280,
18 height: 800,
19} as const satisfies PreviewViewportSetting;
20
21/**
22 * An explicit `open`/`show` is the agent deliberately surfacing or suppressing
23 * its work, so it outranks the preference; the setting only decides what
24 * happens when the agent said nothing either way.
25 */
26export function shouldOpenPreviewMiniPlayer(
27 input: PreviewAutomationOpenInput,
28 autoShowFloatingPreview = true,
29): boolean {
30 return input.open ?? input.show ?? autoShowFloatingPreview;
31}
32
33export function shouldAutoShowPreviewForAutomationUse(input: {
34 readonly operation: PreviewAutomationOperation;
35 readonly autoShowFloatingPreview: boolean;
36 readonly presentationSuppressed: boolean;
37}): boolean {
38 return (
39 input.operation !== "open" && input.autoShowFloatingPreview && !input.presentationSuppressed
40 );
41}
42
43export function explicitlySuppressesPreviewMiniPlayer(input: PreviewAutomationOpenInput): boolean {
44 return (input.open ?? input.show) === false;
45}
46
47export function previewAutomationOpenNeedsOverlay(
48 input: PreviewAutomationOpenInput,
49 snapshot: PreviewSessionSnapshot,
50): boolean {
51 return input.url !== undefined || snapshot.navStatus._tag !== "Idle";
52}
53
54/**
55 * Whether a freshly opened automation tab still needs a viewport applied.
56 *
57 * A configured browser default is sent with `preview.open`, so the snapshot
58 * already carries it and nothing is needed here. Fill means the user has no
59 * stated preference, which is where the agent fallback applies.
60 */
61export function previewAutomationDefaultViewport(
62 reusedExistingTab: boolean,
63 snapshot: PreviewSessionSnapshot,
64): PreviewViewportSetting | null {
65 const viewport = snapshot.viewport ?? FILL_PREVIEW_VIEWPORT;
66 return !reusedExistingTab && viewport._tag === "fill"
67 ? DEFAULT_PREVIEW_AUTOMATION_VIEWPORT
68 : null;
69}
apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts deleted-407
...@@ -1,407 +0,0 @@
1import {
2 EnvironmentId,
3 type PreviewAutomationRequest,
4 type PreviewAutomationResponse,
5 type PreviewAutomationStreamEvent,
6 PreviewTabId,
7 ThreadId,
8} from "@t3tools/contracts";
9import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
10import { describe, expect, it, vi } from "vite-plus/test";
11
12import {
13 PreviewAutomationRecordingNotActiveError,
14 PreviewAutomationTargetUnavailableError,
15 PreviewAutomationViewportTimeoutError,
16} from "./previewAutomationErrors";
17import {
18 createPreviewAutomationRequestConsumerAtom,
19 serializePreviewAutomationError,
20} from "./previewAutomationRequestConsumer";
21
22const environmentId = EnvironmentId.make("environment-1");
23const threadId = ThreadId.make("thread-1");
24const tabId = PreviewTabId.make("tab-1");
25const clientId = "client-1";
26const connectionId = "connection-1";
27
28const request = (
29 requestId: string,
30 overrides: Partial<PreviewAutomationRequest> = {},
31): PreviewAutomationRequest => ({
32 requestId,
33 threadId,
34 operation: "status",
35 input: {},
36 timeoutMs: 15_000,
37 ...overrides,
38});
39
40const requestEvent = (
41 requestId: string,
42 overrides: Partial<PreviewAutomationRequest> = {},
43 eventConnectionId = connectionId,
44): PreviewAutomationStreamEvent => ({
45 type: "request",
46 connectionId: eventConnectionId,
47 request: request(requestId, overrides),
48});
49
50const consumerState = (handleRequest: (request: PreviewAutomationRequest) => Promise<unknown>) => ({
51 connectionAtom: Atom.make<string | null>(null),
52 requestHandlerAtom: Atom.make({ handle: handleRequest }),
53});
54
55describe("previewAutomationRequestConsumer", () => {
56 it("acknowledges a replacement stream before consuming requests from it", async () => {
57 const requestsAtom = Atom.make(
58 AsyncResult.success<PreviewAutomationStreamEvent, Error>({
59 type: "connected",
60 connectionId,
61 }),
62 );
63 const handleRequest = vi.fn(async () => undefined);
64 const respond = vi.fn(async () => undefined);
65 const state = consumerState(handleRequest);
66 const consumerAtom = createPreviewAutomationRequestConsumerAtom({
67 requestsAtom,
68 clientId,
69 connectionAtom: state.connectionAtom,
70 environmentId,
71 requestHandlerAtom: state.requestHandlerAtom,
72 respond,
73 label: "test:preview-automation-connected",
74 });
75 const registry = AtomRegistry.make();
76
77 registry.mount(consumerAtom);
78 registry.set(requestsAtom, AsyncResult.success(requestEvent("request-after-connect")));
79
80 await vi.waitFor(() => expect(registry.get(state.connectionAtom)).toBe(connectionId));
81 await vi.waitFor(() => expect(respond).toHaveBeenCalledTimes(1));
82 expect(handleRequest).toHaveBeenCalledTimes(1);
83 registry.dispose();
84 });
85
86 it("drops late requests from an older stream generation", async () => {
87 const requestsAtom = Atom.make(
88 AsyncResult.success<PreviewAutomationStreamEvent, Error>({
89 type: "connected",
90 connectionId: "connection-2",
91 }),
92 );
93 const handleRequest = vi.fn(async () => undefined);
94 const respond = vi.fn(async () => undefined);
95 const state = consumerState(handleRequest);
96 const consumerAtom = createPreviewAutomationRequestConsumerAtom({
97 requestsAtom,
98 clientId,
99 connectionAtom: state.connectionAtom,
100 environmentId,
101 requestHandlerAtom: state.requestHandlerAtom,
102 respond,
103 label: "test:preview-automation-stale-generation",
104 });
105 const registry = AtomRegistry.make();
106
107 registry.mount(consumerAtom);
108 registry.set(
109 requestsAtom,
110 AsyncResult.success(requestEvent("request-stale", {}, "connection-1")),
111 );
112
113 await vi.waitFor(() => expect(registry.get(state.connectionAtom)).toBe("connection-2"));
114 expect(handleRequest).not.toHaveBeenCalled();
115 expect(respond).not.toHaveBeenCalled();
116 registry.dispose();
117 });
118
119 it("consumes every request emitted before React can render", async () => {
120 const requestsAtom = Atom.make<AsyncResult.AsyncResult<PreviewAutomationStreamEvent, Error>>(
121 AsyncResult.initial<PreviewAutomationStreamEvent, Error>(false),
122 );
123 const handleRequest = vi.fn(async (value: PreviewAutomationRequest) => ({
124 requestId: value.requestId,
125 }));
126 const responses: PreviewAutomationResponse[] = [];
127 const respond = vi.fn(async (response: PreviewAutomationResponse) => {
128 responses.push(response);
129 });
130 const state = consumerState(handleRequest);
131 const consumerAtom = createPreviewAutomationRequestConsumerAtom({
132 requestsAtom,
133 clientId,
134 connectionAtom: state.connectionAtom,
135 environmentId,
136 requestHandlerAtom: state.requestHandlerAtom,
137 respond,
138 label: "test:preview-automation-consumer",
139 });
140 const registry = AtomRegistry.make();
141 registry.mount(consumerAtom);
142
143 registry.set(requestsAtom, AsyncResult.success(requestEvent("request-1")));
144 registry.set(requestsAtom, AsyncResult.success(requestEvent("request-2")));
145
146 await vi.waitFor(() => expect(respond).toHaveBeenCalledTimes(2));
147 expect(handleRequest.mock.calls.map(([value]) => value.requestId)).toEqual([
148 "request-1",
149 "request-2",
150 ]);
151 expect(responses.map((response) => response.requestId)).toEqual(["request-1", "request-2"]);
152 registry.dispose();
153 });
154
155 it("uses the latest request handler without rebuilding the stream consumer", async () => {
156 const requestsAtom = Atom.make<AsyncResult.AsyncResult<PreviewAutomationStreamEvent, Error>>(
157 AsyncResult.initial<PreviewAutomationStreamEvent, Error>(false),
158 );
159 const firstHandler = vi.fn(async () => "first");
160 const secondHandler = vi.fn(async () => "second");
161 const respond = vi.fn(async (_response: PreviewAutomationResponse) => undefined);
162 const state = consumerState(firstHandler);
163 const consumerAtom = createPreviewAutomationRequestConsumerAtom({
164 requestsAtom,
165 clientId,
166 connectionAtom: state.connectionAtom,
167 environmentId,
168 requestHandlerAtom: state.requestHandlerAtom,
169 respond,
170 label: "test:preview-automation-latest-handler",
171 });
172 const registry = AtomRegistry.make();
173 registry.mount(consumerAtom);
174
175 registry.set(requestsAtom, AsyncResult.success(requestEvent("request-first")));
176 await vi.waitFor(() => expect(respond).toHaveBeenCalledTimes(1));
177 registry.set(state.requestHandlerAtom, { handle: secondHandler });
178 registry.set(requestsAtom, AsyncResult.success(requestEvent("request-second")));
179
180 await vi.waitFor(() => expect(respond).toHaveBeenCalledTimes(2));
181 expect(firstHandler).toHaveBeenCalledTimes(1);
182 expect(secondHandler).toHaveBeenCalledTimes(1);
183 expect(respond.mock.calls.map(([response]) => response.result)).toEqual(["first", "second"]);
184 registry.dispose();
185 });
186
187 it("consumes a request that arrived immediately before the consumer mounted", async () => {
188 const requestsAtom = Atom.make(
189 AsyncResult.success<PreviewAutomationStreamEvent, Error>(requestEvent("request-ready")),
190 );
191 const respond = vi.fn(async (_response: PreviewAutomationResponse) => undefined);
192 const state = consumerState(async () => undefined);
193 const consumerAtom = createPreviewAutomationRequestConsumerAtom({
194 requestsAtom,
195 clientId,
196 connectionAtom: state.connectionAtom,
197 environmentId,
198 requestHandlerAtom: state.requestHandlerAtom,
199 respond,
200 label: "test:preview-automation-initial-request",
201 });
202 const registry = AtomRegistry.make();
203
204 registry.mount(consumerAtom);
205
206 await vi.waitFor(() => expect(respond).toHaveBeenCalledTimes(1));
207 expect(respond).toHaveBeenCalledWith({
208 clientId,
209 connectionId,
210 requestId: "request-ready",
211 ok: true,
212 });
213 registry.dispose();
214 });
215
216 it("preserves tagged automation errors and their structured diagnostics", () => {
217 const error = new PreviewAutomationTargetUnavailableError({
218 requestId: "request-1",
219 operation: "click",
220 environmentId,
221 threadId,
222 tabId,
223 bridgeAvailable: false,
224 });
225
226 expect(
227 serializePreviewAutomationError(error, {
228 requestId: "request-1",
229 operation: "click",
230 environmentId,
231 threadId,
232 tabId,
233 }),
234 ).toEqual({
235 _tag: "PreviewAutomationTabNotFoundError",
236 message:
237 "Preview automation target for click request request-1 is unavailable on environment environment-1 thread thread-1 (tab tab-1, bridge unavailable).",
238 detail: {
239 requestId: "request-1",
240 operation: "click",
241 environmentId: "environment-1",
242 threadId: "thread-1",
243 tabId: "tab-1",
244 bridgeAvailable: false,
245 },
246 });
247 });
248
249 it("reports a missing recording even when no preview tab remains", () => {
250 const error = new PreviewAutomationRecordingNotActiveError({
251 requestId: "request-recording-stop",
252 environmentId,
253 threadId,
254 tabId: null,
255 });
256
257 expect(
258 serializePreviewAutomationError(error, {
259 requestId: "request-recording-stop",
260 operation: "recordingStop",
261 environmentId,
262 threadId,
263 tabId: null,
264 }),
265 ).toMatchObject({
266 _tag: "PreviewAutomationExecutionError",
267 detail: { tabId: null },
268 });
269 });
270
271 it("preserves viewport render timeouts as timeout responses", () => {
272 const error = new PreviewAutomationViewportTimeoutError({
273 requestId: "request-resize",
274 environmentId,
275 threadId,
276 tabId,
277 timeoutMs: 2_500,
278 });
279
280 expect(
281 serializePreviewAutomationError(error, {
282 requestId: "request-resize",
283 operation: "resize",
284 environmentId,
285 threadId,
286 tabId,
287 }),
288 ).toMatchObject({
289 _tag: "PreviewAutomationTimeoutError",
290 detail: { tabId: "tab-1", timeoutMs: 2_500 },
291 });
292 });
293
294 it("maps desktop non-editable targets to the public typed response", () => {
295 expect(
296 serializePreviewAutomationError(
297 {
298 _tag: "PreviewAutomationTargetNotEditableError",
299 tabId: "tab-1",
300 selectorKind: "selector",
301 selectorLength: 6,
302 },
303 {
304 requestId: "request-type",
305 operation: "type",
306 environmentId,
307 threadId,
308 tabId,
309 },
310 ),
311 ).toEqual({
312 _tag: "PreviewAutomationTargetNotEditableError",
313 message:
314 "Preview automation type request request-type requires an editable target in tab tab-1.",
315 detail: {
316 requestId: "request-type",
317 operation: "type",
318 environmentId: "environment-1",
319 threadId: "thread-1",
320 tabId: "tab-1",
321 selectorKind: "selector",
322 selectorLength: 6,
323 },
324 });
325 });
326
327 it("correlates unexpected failures without exposing cause details", () => {
328 const cause = new Error("private bridge token: preview-secret");
329 const context = {
330 requestId: "request-2",
331 operation: "snapshot" as const,
332 environmentId,
333 threadId,
334 tabId,
335 };
336 const response = serializePreviewAutomationError(cause, context);
337
338 expect(response).toEqual({
339 _tag: "PreviewAutomationExecutionError",
340 message:
341 "Preview automation snapshot request request-2 failed on environment environment-1 thread thread-1 (tab tab-1).",
342 detail: {
343 requestId: "request-2",
344 operation: "snapshot",
345 environmentId: "environment-1",
346 threadId: "thread-1",
347 tabId: "tab-1",
348 },
349 });
350 expect(JSON.stringify(response)).not.toContain("preview-secret");
351 });
352
353 it("sanitizes unexpected handler failures at the response boundary", async () => {
354 const requestsAtom = Atom.make<AsyncResult.AsyncResult<PreviewAutomationStreamEvent, Error>>(
355 AsyncResult.initial<PreviewAutomationStreamEvent, Error>(false),
356 );
357 const responses: PreviewAutomationResponse[] = [];
358 const state = consumerState(async () => {
359 throw new Error("desktop IPC secret: do-not-return");
360 });
361 const consumerAtom = createPreviewAutomationRequestConsumerAtom({
362 requestsAtom,
363 clientId,
364 connectionAtom: state.connectionAtom,
365 environmentId,
366 requestHandlerAtom: state.requestHandlerAtom,
367 respond: async (response) => {
368 responses.push(response);
369 },
370 label: "test:preview-automation-failure-boundary",
371 });
372 const registry = AtomRegistry.make();
373 registry.mount(consumerAtom);
374
375 registry.set(
376 requestsAtom,
377 AsyncResult.success(
378 requestEvent("request-failed", {
379 operation: "click",
380 tabId,
381 }),
382 ),
383 );
384
385 await vi.waitFor(() => expect(responses).toHaveLength(1));
386 expect(responses[0]).toEqual({
387 clientId,
388 connectionId,
389 requestId: "request-failed",
390 ok: false,
391 error: {
392 _tag: "PreviewAutomationExecutionError",
393 message:
394 "Preview automation click request request-failed failed on environment environment-1 thread thread-1 (tab tab-1).",
395 detail: {
396 requestId: "request-failed",
397 operation: "click",
398 environmentId: "environment-1",
399 threadId: "thread-1",
400 tabId: "tab-1",
401 },
402 },
403 });
404 expect(JSON.stringify(responses[0])).not.toContain("do-not-return");
405 registry.dispose();
406 });
407});
apps/web/src/components/preview/previewAutomationRequestConsumer.ts deleted-121
...@@ -1,121 +0,0 @@
1import type {
2 PreviewAutomationHost,
3 PreviewAutomationRequest,
4 PreviewAutomationResponse,
5 PreviewAutomationStreamEvent,
6} from "@t3tools/contracts";
7import { AsyncResult, Atom } from "effect/reactivity";
8
9import {
10 PreviewAutomationOperationError,
11 type PreviewAutomationOperationContext,
12 serializePreviewAutomationHostError,
13} from "./previewAutomationErrors";
14
15type AutomationStreamResult<E> = AsyncResult.AsyncResult<PreviewAutomationStreamEvent, E>;
16
17export function serializePreviewAutomationError(
18 error: unknown,
19 context: PreviewAutomationOperationContext,
20): NonNullable<PreviewAutomationResponse["error"]> {
21 return serializePreviewAutomationHostError(
22 PreviewAutomationOperationError.fromCause({ ...context, cause: error }),
23 );
24}
25
26export function createPreviewAutomationRequestConsumerAtom<E>(options: {
27 readonly requestsAtom: Atom.Atom<AutomationStreamResult<E>>;
28 readonly clientId: PreviewAutomationHost["clientId"];
29 readonly connectionAtom: Atom.Writable<PreviewAutomationStreamEvent["connectionId"] | null>;
30 readonly environmentId: PreviewAutomationHost["environmentId"];
31 readonly requestHandlerAtom: Atom.Atom<{
32 readonly handle: (request: PreviewAutomationRequest) => Promise<unknown>;
33 }>;
34 readonly respond: (response: PreviewAutomationResponse) => Promise<unknown>;
35 readonly label: string;
36}): Atom.Atom<void> {
37 return Atom.make((get) => {
38 get.mount(options.connectionAtom);
39 get.mount(options.requestHandlerAtom);
40 let disposed = false;
41 let activeConnectionId: PreviewAutomationStreamEvent["connectionId"] | null = null;
42 let connectionExplicitlyAnnounced = false;
43 let reportedConnectionId: PreviewAutomationStreamEvent["connectionId"] | null = null;
44 let requestsVersion = 0;
45
46 const consume = (result: AutomationStreamResult<E>) => {
47 if (!AsyncResult.isSuccess(result)) return;
48 const event = result.value;
49 if (event.type === "connected") {
50 activeConnectionId = event.connectionId;
51 connectionExplicitlyAnnounced = true;
52 } else if (activeConnectionId === null) {
53 activeConnectionId = event.connectionId;
54 } else if (activeConnectionId !== event.connectionId) {
55 if (connectionExplicitlyAnnounced) return;
56 activeConnectionId = event.connectionId;
57 }
58 if (reportedConnectionId !== event.connectionId) {
59 reportedConnectionId = event.connectionId;
60 get.set(options.connectionAtom, event.connectionId);
61 }
62 if (event.type === "connected") {
63 return;
64 }
65 const request = event.request;
66 void get
67 .once(options.requestHandlerAtom)
68 .handle(request)
69 .then(
70 (value) =>
71 options.respond({
72 clientId: options.clientId,
73 connectionId: event.connectionId,
74 requestId: request.requestId,
75 ok: true,
76 ...(value === undefined ? {} : { result: value }),
77 }),
78 (error) =>
79 options.respond({
80 clientId: options.clientId,
81 connectionId: event.connectionId,
82 requestId: request.requestId,
83 ok: false,
84 error: serializePreviewAutomationError(error, {
85 requestId: request.requestId,
86 operation: request.operation,
87 environmentId: options.environmentId,
88 threadId: request.threadId,
89 tabId: request.tabId ?? null,
90 }),
91 }),
92 );
93 };
94
95 get.addFinalizer(() => {
96 disposed = true;
97 });
98 const initialRequest = get.once(options.requestsAtom);
99 if (AsyncResult.isSuccess(initialRequest)) {
100 activeConnectionId = initialRequest.value.connectionId;
101 connectionExplicitlyAnnounced = initialRequest.value.type === "connected";
102 if (initialRequest.value.type === "connected") {
103 reportedConnectionId = initialRequest.value.connectionId;
104 get.set(options.connectionAtom, initialRequest.value.connectionId);
105 }
106 }
107 get.subscribe(options.requestsAtom, (result) => {
108 requestsVersion += 1;
109 consume(result);
110 });
111 queueMicrotask(() => {
112 const initialConnectionWasSkipped =
113 AsyncResult.isSuccess(initialRequest) &&
114 initialRequest.value.connectionId === activeConnectionId &&
115 initialRequest.value.connectionId !== reportedConnectionId;
116 if (!disposed && (requestsVersion === 0 || initialConnectionWasSkipped)) {
117 consume(initialRequest);
118 }
119 });
120 }).pipe(Atom.setIdleTTL(0), Atom.withLabel(options.label));
121}
apps/web/src/components/preview/previewAutomationTarget.test.ts deleted-59
...@@ -1,59 +0,0 @@
1import type { PreviewSessionSnapshot } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 needsPreviewAutomationSessionSync,
6 resolvePreviewAutomationOpenTab,
7 resolvePreviewAutomationTarget,
8} from "./previewAutomationTarget";
9
10const snapshot = (tabId: string): PreviewSessionSnapshot => ({
11 threadId: "thread-1",
12 tabId,
13 navStatus: { _tag: "Idle" },
14 canGoBack: false,
15 canGoForward: false,
16 updatedAt: "2026-01-01T00:00:00.000Z",
17});
18
19describe("preview automation target selection", () => {
20 it("refreshes authoritative sessions whenever the caller relies on the active tab", () => {
21 const active = snapshot("tab-active");
22 expect(
23 needsPreviewAutomationSessionSync(
24 { snapshot: active, sessions: { [active.tabId]: active } },
25 undefined,
26 ),
27 ).toBe(true);
28 });
29
30 it("refreshes an explicit tab only when it is absent locally", () => {
31 const active = snapshot("tab-active");
32 const state = { snapshot: active, sessions: { [active.tabId]: active } };
33 expect(needsPreviewAutomationSessionSync(state, active.tabId)).toBe(false);
34 expect(needsPreviewAutomationSessionSync(state, "tab-missing")).toBe(true);
35 });
36
37 it("does not report the active tab under an unknown requested tab id", () => {
38 const active = snapshot("tab-active");
39 expect(
40 resolvePreviewAutomationTarget(
41 { snapshot: active, sessions: { [active.tabId]: active } },
42 "tab-missing",
43 ),
44 ).toEqual({ tabId: null, snapshot: null });
45 });
46
47 it("reuses the provider session's pinned tab instead of the mutable UI tab", () => {
48 const uiActive = snapshot("tab-ui-active");
49 const agentTab = snapshot("tab-opened-by-agent");
50 const state = {
51 snapshot: uiActive,
52 sessions: { [uiActive.tabId]: uiActive, [agentTab.tabId]: agentTab },
53 };
54
55 expect(resolvePreviewAutomationOpenTab(state, agentTab.tabId, true)).toBe(agentTab.tabId);
56 expect(resolvePreviewAutomationOpenTab(state, undefined, true)).toBe(uiActive.tabId);
57 expect(resolvePreviewAutomationOpenTab(state, agentTab.tabId, false)).toBeNull();
58 });
59});
apps/web/src/components/preview/previewAutomationTarget.ts deleted-37
...@@ -1,37 +0,0 @@
1import type { PreviewSessionSnapshot } from "@t3tools/contracts";
2
3interface PreviewAutomationSessionIndex {
4 readonly snapshot: PreviewSessionSnapshot | null;
5 readonly sessions: Readonly<Record<string, PreviewSessionSnapshot>>;
6}
7
8export function needsPreviewAutomationSessionSync(
9 state: PreviewAutomationSessionIndex,
10 requestedTabId: string | undefined,
11): boolean {
12 return (
13 Object.keys(state.sessions).length === 0 ||
14 requestedTabId === undefined ||
15 state.sessions[requestedTabId] === undefined
16 );
17}
18
19export function resolvePreviewAutomationTarget(
20 state: PreviewAutomationSessionIndex,
21 requestedTabId: string | null,
22): { readonly tabId: string | null; readonly snapshot: PreviewSessionSnapshot | null } {
23 const snapshot = requestedTabId ? (state.sessions[requestedTabId] ?? null) : state.snapshot;
24 return { tabId: snapshot?.tabId ?? null, snapshot };
25}
26
27export function resolvePreviewAutomationOpenTab(
28 state: PreviewAutomationSessionIndex,
29 requestedTabId: string | undefined,
30 reuseExistingTab: boolean,
31): string | null {
32 if (!reuseExistingTab) return null;
33 if (requestedTabId !== undefined) {
34 return state.sessions[requestedTabId]?.tabId ?? null;
35 }
36 return state.snapshot?.tabId ?? null;
37}
apps/web/src/components/preview/previewClickFocus.test.ts deleted-152
...@@ -1,152 +0,0 @@
1// @vitest-environment jsdom
2
3import { afterEach, describe, expect, it } from "vite-plus/test";
4
5import { runPreviewClickKeepingHostFocus } from "./previewClickFocus";
6
7const TAB = "runtime-tab";
8
9const mount = (tagName: string, previewTab?: string) => {
10 const element = document.createElement(tagName);
11 element.tabIndex = -1;
12 if (previewTab) element.setAttribute("data-preview-tab", previewTab);
13 document.body.append(element);
14 return element;
15};
16
17afterEach(() => {
18 document.body.replaceChildren();
19});
20
21describe("runPreviewClickKeepingHostFocus", () => {
22 it("gives focus back to the composer after the click focuses the page", async () => {
23 const composer = mount("textarea");
24 const webview = mount("webview", TAB);
25 composer.focus();
26
27 const result = await runPreviewClickKeepingHostFocus(TAB, async () => {
28 webview.focus();
29 return "clicked";
30 });
31
32 expect(result).toBe("clicked");
33 expect(document.activeElement).toBe(composer);
34 });
35
36 it("gives focus back when the user's typing interrupts the click", async () => {
37 const composer = mount("textarea");
38 const webview = mount("webview", TAB);
39 composer.focus();
40 const interrupted = new Error("PreviewAutomationControlInterruptedError");
41
42 await expect(
43 runPreviewClickKeepingHostFocus(TAB, async () => {
44 webview.focus();
45 throw interrupted;
46 }),
47 ).rejects.toBe(interrupted);
48
49 expect(document.activeElement).toBe(composer);
50 });
51
52 it("gives focus back once overlapping clicks in two tabs finish", async () => {
53 const composer = mount("textarea");
54 const first = mount("webview", TAB);
55 const second = mount("webview", "other-tab");
56 composer.focus();
57 let finishSecond = () => {};
58
59 const firstClick = runPreviewClickKeepingHostFocus(TAB, async () => {
60 first.focus();
61 });
62 // Starts while the first click holds focus in its page, and finishes last.
63 const secondClick = runPreviewClickKeepingHostFocus("other-tab", async () => {
64 second.focus();
65 await new Promise<void>((resolve) => {
66 finishSecond = resolve;
67 });
68 });
69 await firstClick;
70 finishSecond();
71 await secondClick;
72
73 expect(document.activeElement).toBe(composer);
74 });
75
76 it("keeps the user's page when overlapping clicks finish in reverse order", async () => {
77 const first = mount("webview", TAB);
78 const second = mount("webview", "other-tab");
79 second.focus();
80 let finishFirst = () => {};
81
82 const firstClick = runPreviewClickKeepingHostFocus(TAB, async () => {
83 first.focus();
84 await new Promise<void>((resolve) => {
85 finishFirst = resolve;
86 });
87 });
88 // Starts while the first click holds focus in its page, and finishes first.
89 await runPreviewClickKeepingHostFocus("other-tab", async () => {
90 second.focus();
91 });
92 finishFirst();
93 await firstClick;
94
95 expect(document.activeElement).toBe(second);
96 });
97
98 it("ignores a click that never finishes", async () => {
99 const composer = mount("textarea");
100 const stuck = mount("webview", "stuck-tab");
101 const webview = mount("webview", TAB);
102 const other = mount("webview", "other-tab");
103 composer.focus();
104
105 // A click queued behind a page promise that never settles.
106 void runPreviewClickKeepingHostFocus("stuck-tab", () => new Promise<never>(() => {}));
107 await runPreviewClickKeepingHostFocus(TAB, async () => {
108 webview.focus();
109 });
110 expect(document.activeElement).toBe(composer);
111
112 // The user opens the stuck tab's page themselves.
113 stuck.focus();
114 await runPreviewClickKeepingHostFocus("other-tab", async () => {
115 other.focus();
116 });
117 expect(document.activeElement).toBe(stuck);
118 });
119
120 it("leaves no focus in the page when the composer can no longer take focus", async () => {
121 const composer = mount("div");
122 const webview = mount("webview", TAB);
123 composer.focus();
124
125 await runPreviewClickKeepingHostFocus(TAB, async () => {
126 webview.focus();
127 // Like the editor going read-only for an approval request.
128 composer.removeAttribute("tabindex");
129 });
130
131 expect(document.activeElement).toBe(document.body);
132 });
133
134 it("leaves no focus in the page when nothing in the app had focus", async () => {
135 const webview = mount("webview", TAB);
136
137 await runPreviewClickKeepingHostFocus(TAB, async () => {
138 webview.focus();
139 });
140
141 expect(document.activeElement).toBe(document.body);
142 });
143
144 it("keeps focus in the page when the user was already typing there", async () => {
145 const webview = mount("webview", TAB);
146 webview.focus();
147
148 await runPreviewClickKeepingHostFocus(TAB, async () => undefined);
149
150 expect(document.activeElement).toBe(webview);
151 });
152});
apps/web/src/components/preview/previewClickFocus.ts deleted-67
...@@ -1,67 +0,0 @@
1interface PreviewClick {
2 readonly runtimeTabId: string;
3 readonly order: number;
4 previous: Element | null;
5}
6
7// Agent clicks can overlap across tabs and threads.
8const inFlightClicks = new Set<PreviewClick>();
9let nextClickOrder = 0;
10
11const focusedPreviewTab = (element: Element | null): string | null =>
12 element?.localName === "webview" ? element.getAttribute("data-preview-tab") : null;
13
14const restoreHostFocus = ({ runtimeTabId, previous }: PreviewClick): void => {
15 const current = document.activeElement;
16 if (
17 !(current instanceof HTMLElement) ||
18 current === previous ||
19 focusedPreviewTab(current) !== runtimeTabId
20 ) {
21 return;
22 }
23 if (previous instanceof HTMLElement && previous.isConnected && previous !== document.body) {
24 previous.focus({ preventScroll: true });
25 }
26 // The previous element may no longer take focus, like a composer that was
27 // disabled during the click. Never leave focus in the page.
28 if (document.activeElement === current) current.blur();
29};
30
31/**
32 * Runs an agent click in a preview tab and hands keyboard focus back to the
33 * host page afterwards.
34 *
35 * The desktop dispatches the click as a CDP mouse press, which focuses the
36 * guest page, and nothing gives that focus back. The user's next keystrokes
37 * then go into the page, even when its tab is hidden or belongs to another
38 * thread. Only the host document knows what had focus before the click, so
39 * the restore happens here and not in the desktop main process.
40 */
41export async function runPreviewClickKeepingHostFocus<A>(
42 runtimeTabId: string,
43 click: () => Promise<A>,
44): Promise<A> {
45 const entry: PreviewClick = {
46 runtimeTabId,
47 order: nextClickOrder++,
48 previous: document.activeElement,
49 };
50 inFlightClicks.add(entry);
51 try {
52 return await click();
53 } finally {
54 // Also runs when the click fails: a keystroke that reaches the page after
55 // the press counts as human input and fails the click as interrupted.
56 inFlightClicks.delete(entry);
57 // A click that started while this one held focus in its page saw that page
58 // as what had focus. Hand it this click's target instead. A click that never
59 // finishes never gets here, so it cannot override where the user goes later.
60 for (const other of inFlightClicks) {
61 if (other.order > entry.order && focusedPreviewTab(other.previous) === runtimeTabId) {
62 other.previous = entry.previous;
63 }
64 }
65 restoreHostFocus(entry);
66 }
67}
apps/web/src/components/preview/previewNavigationReadiness.test.ts deleted-56
...@@ -1,56 +0,0 @@
1import { EnvironmentId, ThreadId } from "@t3tools/contracts";
2import { describe, expect, it, vi } from "vite-plus/test";
3
4const mocks = vi.hoisted(() => ({
5 readThreadPreviewState: vi.fn(),
6}));
7
8vi.mock("~/previewStateStore", () => ({
9 applyPreviewServerSnapshot: vi.fn(),
10 readThreadPreviewState: mocks.readThreadPreviewState,
11 reconcilePreviewServerSessions: vi.fn(),
12 updatePreviewServerSnapshot: vi.fn(),
13}));
14
15vi.mock("./previewBridge", () => ({
16 previewBridge: {
17 automation: {
18 evaluate: vi.fn(),
19 status: vi.fn(),
20 },
21 },
22}));
23
24import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";
25
26import { PreviewAutomationTargetUnavailableError } from "./previewAutomationErrors";
27import { waitForNavigationReadiness } from "./previewNavigationReadiness";
28
29describe("waitForNavigationReadiness", () => {
30 it("rejects a replaced runtime target even when readiness polling is disabled", async () => {
31 const threadRef = {
32 environmentId: EnvironmentId.make("environment-2"),
33 threadId: ThreadId.make("thread-1"),
34 };
35 const tabId = "tab_1";
36 const staleRuntimeTabId = previewRuntimeTabId(threadRef, "epoch-1", tabId);
37 mocks.readThreadPreviewState.mockReturnValue({
38 serverEpoch: "epoch-2",
39 sessions: {
40 [tabId]: { tabId },
41 },
42 });
43
44 await expect(
45 waitForNavigationReadiness(
46 threadRef,
47 "request-1",
48 tabId,
49 staleRuntimeTabId,
50 "navigate",
51 "none",
52 100,
53 ),
54 ).rejects.toBeInstanceOf(PreviewAutomationTargetUnavailableError);
55 });
56});
apps/web/src/components/preview/previewNavigationReadiness.ts deleted-74
...@@ -1,74 +0,0 @@
1import {
2 type PreviewAutomationNavigateInput,
3 type PreviewAutomationRequest,
4 type ScopedThreadRef,
5} from "@t3tools/contracts";
6
7import { isCurrentPreviewRuntimeTab } from "~/browser/previewRuntimeTabId";
8import { readThreadPreviewState } from "~/previewStateStore";
9
10import { previewBridge } from "./previewBridge";
11import {
12 PreviewAutomationNavigationTimeoutError,
13 PreviewAutomationTargetUnavailableError,
14} from "./previewAutomationErrors";
15
16export function assertPreviewRuntimeCurrent(
17 threadRef: ScopedThreadRef,
18 tabId: string,
19 runtimeTabId: string,
20 request: Pick<PreviewAutomationRequest, "operation" | "requestId">,
21) {
22 const state = readThreadPreviewState(threadRef);
23 if (
24 state.sessions[tabId] &&
25 isCurrentPreviewRuntimeTab(threadRef, state.serverEpoch, tabId, runtimeTabId)
26 ) {
27 return state;
28 }
29 throw new PreviewAutomationTargetUnavailableError({
30 requestId: request.requestId,
31 operation: request.operation,
32 environmentId: threadRef.environmentId,
33 threadId: threadRef.threadId,
34 tabId,
35 bridgeAvailable: Boolean(previewBridge),
36 });
37}
38
39export async function waitForNavigationReadiness(
40 threadRef: ScopedThreadRef,
41 requestId: string,
42 tabId: string,
43 runtimeTabId: string,
44 operation: PreviewAutomationRequest["operation"],
45 readiness: PreviewAutomationNavigateInput["readiness"],
46 timeoutMs: number,
47): Promise<void> {
48 const targetReadiness = readiness ?? "load";
49 if (!previewBridge) return;
50 assertPreviewRuntimeCurrent(threadRef, tabId, runtimeTabId, { operation, requestId });
51 if (targetReadiness === "none") return;
52 const deadline = Date.now() + timeoutMs;
53 while (Date.now() <= deadline) {
54 assertPreviewRuntimeCurrent(threadRef, tabId, runtimeTabId, { operation, requestId });
55 if (targetReadiness === "domContentLoaded") {
56 const readyState = await previewBridge.automation.evaluate(runtimeTabId, {
57 expression: "document.readyState",
58 });
59 if (readyState === "interactive" || readyState === "complete") return;
60 } else {
61 const status = await previewBridge.automation.status(runtimeTabId);
62 if (status.available && !status.loading) return;
63 }
64 await new Promise<void>((resolve) => window.setTimeout(resolve, 50));
65 }
66 throw new PreviewAutomationNavigationTimeoutError({
67 requestId,
68 environmentId: threadRef.environmentId,
69 threadId: threadRef.threadId,
70 tabId,
71 readiness: targetReadiness,
72 timeoutMs,
73 });
74}
apps/web/src/components/preview/previewViewportReadiness.test.ts deleted-72
...@@ -1,72 +0,0 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { browserViewportSettingKey } from "~/browser/browserViewportLayout";
4
5import { isPreviewViewportReady } from "./previewViewportReadiness";
6
7describe("isPreviewViewportReady", () => {
8 const landscape = {
9 _tag: "preset",
10 width: 844,
11 height: 390,
12 presetId: "iphone-12-pro",
13 } as const;
14
15 it("rejects a stale same-mode preset while React applies the requested orientation", () => {
16 expect(
17 isPreviewViewportReady({
18 setting: landscape,
19 appliedSettingKey: "preset:390:844:iphone-12-pro",
20 declaredViewport: { width: 390, height: 844 },
21 renderedViewport: { width: 390, height: 844 },
22 }),
23 ).toBe(false);
24 });
25
26 it("requires both the declaration and guest viewport to match a fixed request", () => {
27 const appliedSettingKey = browserViewportSettingKey(landscape);
28 expect(
29 isPreviewViewportReady({
30 setting: landscape,
31 appliedSettingKey,
32 declaredViewport: { width: 390, height: 844 },
33 renderedViewport: { width: 844, height: 390 },
34 }),
35 ).toBe(false);
36 expect(
37 isPreviewViewportReady({
38 setting: landscape,
39 appliedSettingKey,
40 declaredViewport: { width: 844, height: 390 },
41 renderedViewport: { width: 844, height: 390 },
42 }),
43 ).toBe(true);
44 });
45
46 it("allows one pixel of Electron rounding tolerance in every mode", () => {
47 expect(
48 isPreviewViewportReady({
49 setting: { _tag: "fill" },
50 appliedSettingKey: "fill",
51 declaredViewport: { width: 500, height: 700 },
52 renderedViewport: { width: 501, height: 699 },
53 }),
54 ).toBe(true);
55 expect(
56 isPreviewViewportReady({
57 setting: landscape,
58 appliedSettingKey: browserViewportSettingKey(landscape),
59 declaredViewport: { width: 844, height: 390 },
60 renderedViewport: { width: 845, height: 389 },
61 }),
62 ).toBe(true);
63 expect(
64 isPreviewViewportReady({
65 setting: landscape,
66 appliedSettingKey: browserViewportSettingKey(landscape),
67 declaredViewport: { width: 844, height: 390 },
68 renderedViewport: { width: 846, height: 390 },
69 }),
70 ).toBe(false);
71 });
72});
apps/web/src/components/preview/previewViewportReadiness.ts deleted-37
...@@ -1,37 +0,0 @@
1import type { PreviewRenderedViewportSize, PreviewViewportSetting } from "@t3tools/contracts";
2
3import { browserViewportSettingKey } from "~/browser/browserViewportLayout";
4
5export function isPreviewViewportReady(input: {
6 readonly setting: PreviewViewportSetting;
7 readonly appliedSettingKey: string | null;
8 readonly declaredViewport: PreviewRenderedViewportSize | null;
9 readonly renderedViewport: PreviewRenderedViewportSize | null;
10}): boolean {
11 const { setting, appliedSettingKey, declaredViewport, renderedViewport } = input;
12 if (
13 appliedSettingKey !== browserViewportSettingKey(setting) ||
14 declaredViewport === null ||
15 renderedViewport === null
16 ) {
17 return false;
18 }
19
20 const expectedViewport =
21 setting._tag === "fill" ? declaredViewport : { width: setting.width, height: setting.height };
22 if (
23 setting._tag !== "fill" &&
24 (declaredViewport.width !== expectedViewport.width ||
25 declaredViewport.height !== expectedViewport.height)
26 ) {
27 return false;
28 }
29
30 // Electron rounds CSS pixels through the guest's fractional zoom/device scale,
31 // so a successfully applied fixed viewport can measure one pixel either way.
32 const tolerance = 1;
33 return (
34 Math.abs(renderedViewport.width - expectedViewport.width) <= tolerance &&
35 Math.abs(renderedViewport.height - expectedViewport.height) <= tolerance
36 );
37}
apps/web/src/components/preview/usePreviewBridge.ts+4-2
...@@ -39,8 +39,10 @@ export function usePreviewBridge(input: {...@@ -39,8 +39,10 @@ export function usePreviewBridge(input: {
39 threadRef: ScopedThreadRef;39 threadRef: ScopedThreadRef;
40 tabId: string;40 tabId: string;
41 runtimeTabId: string;41 runtimeTabId: string;
42 /** The server drives this tab and reports its navigation itself. */
43 serverDriven?: boolean;
42}): void {44}): void {
43 const { threadRef, tabId, runtimeTabId } = input;45 const { threadRef, tabId, runtimeTabId, serverDriven = false } = input;
44 const clearBrowserPointer = useBrowserPointerStore((state) => state.clear);46 const clearBrowserPointer = useBrowserPointerStore((state) => state.clear);
45 const reportStatus = useAtomCommand(previewEnvironment.reportStatus, "preview status report");47 const reportStatus = useAtomCommand(previewEnvironment.reportStatus, "preview status report");
46 const bridge = previewBridge;48 const bridge = previewBridge;
...@@ -79,7 +81,7 @@ export function usePreviewBridge(input: {...@@ -79,7 +81,7 @@ export function usePreviewBridge(input: {
79 lastReportedUrl: lastReportedUrl.current,81 lastReportedUrl: lastReportedUrl.current,
80 lastReportedKind: lastReportedKind.current,82 lastReportedKind: lastReportedKind.current,
81 });83 });
82 if (!reported) return;84 if (!reported || serverDriven) return;
83 lastReportedUrl.current = reported.lastReportedUrl;85 lastReportedUrl.current = reported.lastReportedUrl;
84 lastReportedKind.current = reported.lastReportedKind;86 lastReportedKind.current = reported.lastReportedKind;
85 void reportStatus({87 void reportStatus({
apps/web/src/components/preview/usePreviewSession.ts+9
...@@ -44,6 +44,13 @@ const previewSessionSyncAtom = Atom.family((threadKey: string) => {...@@ -44,6 +44,13 @@ const previewSessionSyncAtom = Atom.family((threadKey: string) => {
44 const reconcileSessions = (result: Atom.Type<typeof sessionsAtom>) => {44 const reconcileSessions = (result: Atom.Type<typeof sessionsAtom>) => {
45 if (!AsyncResult.isSuccess(result)) return;45 if (!AsyncResult.isSuccess(result)) return;
46 reconcilePreviewServerSessions(threadRef, result.value);46 reconcilePreviewServerSessions(threadRef, result.value);
47 if (!result.waiting && !readThreadPreviewState(threadRef).listLoaded) {
48 // An event overtook the first list. Retry the authoritative baseline;
49 // do not declare a partial event-only index ready for surface cleanup.
50 queueMicrotask(() => {
51 if (!disposed) get.refresh(sessionsAtom);
52 });
53 }
47 };54 };
4855
49 const applyLatestEvent = (result: Atom.Type<typeof eventsAtom>) => {56 const applyLatestEvent = (result: Atom.Type<typeof eventsAtom>) => {
...@@ -67,6 +74,8 @@ const previewSessionSyncAtom = Atom.family((threadKey: string) => {...@@ -67,6 +74,8 @@ const previewSessionSyncAtom = Atom.family((threadKey: string) => {
67 eventsVersion += 1;74 eventsVersion += 1;
68 applyLatestEvent(result);75 applyLatestEvent(result);
69 });76 });
77 get.mount(sessionsAtom);
78 get.mount(eventsAtom);
70 queueMicrotask(() => {79 queueMicrotask(() => {
71 if (disposed) return;80 if (disposed) return;
72 // The cached list can predate an automation-created tab. Keep the local81 // The cached list can predate an automation-created tab. Keep the local
apps/web/src/components/settings/IntegrationsSettings.logic.test.ts+30
...@@ -68,7 +68,37 @@ describe("clearBrowserProfileData", () => {...@@ -68,7 +68,37 @@ describe("clearBrowserProfileData", () => {
68 });68 });
69});69});
7070
71describe("clearBrowserProfileData with server browsers", () => {
72 it("clears server-hosted profiles without a desktop bridge", async () => {
73 const clear = vi.fn().mockResolvedValue(undefined);
74 await clearBrowserProfileData(null, [environmentId, secondEnvironmentId], "profile-a", {
75 environmentIds: [secondEnvironmentId],
76 clear,
77 });
78 expect(clear).toHaveBeenCalledExactlyOnceWith(secondEnvironmentId, "profile-a");
79 });
80
81 it("clears both copies and fails when the server copy fails", async () => {
82 const bridge = {
83 clearCookies: vi.fn().mockResolvedValue(undefined),
84 clearCache: vi.fn().mockResolvedValue(undefined),
85 };
86 const failure = new Error("server failed");
87 await expect(
88 clearBrowserProfileData(bridge, [environmentId], "profile-a", {
89 environmentIds: [environmentId],
90 clear: vi.fn().mockRejectedValue(failure),
91 }),
92 ).rejects.toBe(failure);
93 expect(bridge.clearCookies).toHaveBeenCalledWith(environmentId, "profile-a");
94 });
95});
96
71describe("browserProfileRemovalAvailable", () => {97describe("browserProfileRemovalAvailable", () => {
98 it("accepts a server browser in place of the desktop bridge", () => {
99 expect(browserProfileRemovalAvailable(false, true, 1, 1)).toBe(true);
100 });
101
72 it("requires a ready non-empty catalog and desktop bridge", () => {102 it("requires a ready non-empty catalog and desktop bridge", () => {
73 expect(browserProfileRemovalAvailable(true, true, 1)).toBe(true);103 expect(browserProfileRemovalAvailable(true, true, 1)).toBe(true);
74 expect(browserProfileRemovalAvailable(true, true, 0)).toBe(false);104 expect(browserProfileRemovalAvailable(true, true, 0)).toBe(false);
apps/web/src/components/settings/IntegrationsSettings.tsx+80-11
...@@ -50,6 +50,9 @@ import { cn, randomUUID } from "~/lib/utils";...@@ -50,6 +50,9 @@ import { cn, randomUUID } from "~/lib/utils";
50import { useEnvironments, usePrimaryEnvironment } from "~/state/environments";50import { useEnvironments, usePrimaryEnvironment } from "~/state/environments";
51import { deviceEnvironment, useDeviceState } from "~/state/device";51import { deviceEnvironment, useDeviceState } from "~/state/device";
52import { useAtomCommand } from "~/state/use-atom-command";52import { useAtomCommand } from "~/state/use-atom-command";
53import { previewEnvironment } from "~/state/preview";
54import { useServerConfigs } from "~/state/entities";
55import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
53import {56import {
54 AgentDeviceSetupStatus,57 AgentDeviceSetupStatus,
55 DeviceHubSetupStatus,58 DeviceHubSetupStatus,
...@@ -125,28 +128,39 @@ type BrowserProfileDataBridge = Pick<...@@ -125,28 +128,39 @@ type BrowserProfileDataBridge = Pick<
125 "clearCookies" | "clearCache"128 "clearCookies" | "clearCache"
126>;129>;
127130
131/** Environments that host server-runtime tabs keep their own copy of each profile. */
132export interface ServerProfileData {
133 readonly environmentIds: ReadonlyArray<EnvironmentId>;
134 readonly clear: (environmentId: EnvironmentId, profileId: string) => Promise<void>;
135}
136
128export async function clearBrowserProfileData(137export async function clearBrowserProfileData(
129 bridge: BrowserProfileDataBridge | null,138 bridge: BrowserProfileDataBridge | null,
130 environmentIds: ReadonlyArray<EnvironmentId>,139 environmentIds: ReadonlyArray<EnvironmentId>,
131 profileId: string,140 profileId: string,
141 server?: ServerProfileData,
132): Promise<void> {142): Promise<void> {
133 if (bridge === null || environmentIds.length === 0) {143 const desktopEnvironmentIds = bridge === null ? [] : environmentIds;
144 const serverEnvironmentIds = server?.environmentIds ?? [];
145 if (desktopEnvironmentIds.length === 0 && serverEnvironmentIds.length === 0) {
134 throw new Error("Browser profile data is not available to clear.");146 throw new Error("Browser profile data is not available to clear.");
135 }147 }
136 await Promise.all(148 await Promise.all([
137 environmentIds.flatMap((environmentId) => [149 ...desktopEnvironmentIds.flatMap((environmentId) => [
138 bridge.clearCookies(environmentId, profileId),150 bridge!.clearCookies(environmentId, profileId),
139 bridge.clearCache(environmentId, profileId),151 bridge!.clearCache(environmentId, profileId),
140 ]),152 ]),
141 );153 ...serverEnvironmentIds.map((environmentId) => server!.clear(environmentId, profileId)),
154 ]);
142}155}
143156
144export function browserProfileRemovalAvailable(157export function browserProfileRemovalAvailable(
145 bridgeAvailable: boolean,158 bridgeAvailable: boolean,
146 environmentsReady: boolean,159 environmentsReady: boolean,
147 environmentCount: number,160 environmentCount: number,
161 serverBrowserCount = 0,
148): boolean {162): boolean {
149 return bridgeAvailable && environmentsReady && environmentCount > 0;163 return (bridgeAvailable || serverBrowserCount > 0) && environmentsReady && environmentCount > 0;
150}164}
151165
152/**166/**
...@@ -926,12 +940,30 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {...@@ -926,12 +940,30 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
926 readonly environmentName: string;940 readonly environmentName: string;
927 } | null>(null);941 } | null>(null);
928 const [profilePendingRemoval, setProfilePendingRemoval] = useState<BrowserProfile | null>(null);942 const [profilePendingRemoval, setProfilePendingRemoval] = useState<BrowserProfile | null>(null);
943 const [profilePendingClear, setProfilePendingClear] = useState<BrowserProfile | null>(null);
929 const [profileRemovalError, setProfileRemovalError] = useState<string | null>(null);944 const [profileRemovalError, setProfileRemovalError] = useState<string | null>(null);
930 const [profileRemovalInFlight, setProfileRemovalInFlight] = useState(false);945 const [profileRemovalInFlight, setProfileRemovalInFlight] = useState(false);
946 const serverConfigs = useServerConfigs();
947 const runClearServerProfile = useAtomCommand(previewEnvironment.clearProfile, {
948 reportFailure: false,
949 });
950 const serverProfileData: ServerProfileData = {
951 environmentIds: environments
952 .map((environment) => environment.environmentId)
953 .filter(
954 (environmentId) =>
955 serverConfigs.get(environmentId)?.environment.capabilities.serverBrowser === true,
956 ),
957 clear: async (environmentId, profileId) => {
958 const result = await runClearServerProfile({ environmentId, input: { profileId } });
959 if (result._tag === "Failure") throw squashAtomCommandFailure(result);
960 },
961 };
931 const removalAvailable = browserProfileRemovalAvailable(962 const removalAvailable = browserProfileRemovalAvailable(
932 previewBridge !== null,963 previewBridge !== null,
933 environmentsReady,964 environmentsReady,
934 environments.length,965 environments.length,
966 serverProfileData.environmentIds.length,
935 );967 );
936 const importInFlightRef = useRef(false);968 const importInFlightRef = useRef(false);
937 const [importInFlight, setImportInFlight] = useState(false);969 const [importInFlight, setImportInFlight] = useState(false);
...@@ -975,7 +1007,7 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {...@@ -975,7 +1007,7 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
9751007
976 const clearProfileData = (id: string, name: string) => {1008 const clearProfileData = (id: string, name: string) => {
977 if (!settingsHydrated || importInFlightRef.current) return;1009 if (!settingsHydrated || importInFlightRef.current) return;
978 if (!previewBridge || !environmentsReady || environments.length === 0) {1010 if (!removalAvailable) {
979 toastManager.add({1011 toastManager.add({
980 type: "error",1012 type: "error",
981 title: `Could not clear ${name}'s data`,1013 title: `Could not clear ${name}'s data`,
...@@ -987,6 +1019,7 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {...@@ -987,6 +1019,7 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
987 previewBridge,1019 previewBridge,
988 environments.map((environment) => environment.environmentId),1020 environments.map((environment) => environment.environmentId),
989 id,1021 id,
1022 serverProfileData,
990 )1023 )
991 .then(() => {1024 .then(() => {
992 toastManager.add({ type: "success", title: `Cleared ${name}'s cookies and cache` });1025 toastManager.add({ type: "success", title: `Cleared ${name}'s cookies and cache` });
...@@ -1011,6 +1044,7 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {...@@ -1011,6 +1044,7 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
1011 previewBridge,1044 previewBridge,
1012 environmentsReady ? environments.map((environment) => environment.environmentId) : [],1045 environmentsReady ? environments.map((environment) => environment.environmentId) : [],
1013 id,1046 id,
1047 serverProfileData,
1014 );1048 );
1015 } catch {1049 } catch {
1016 setProfileRemovalError("Profile data could not be deleted. Try again.");1050 setProfileRemovalError("Profile data could not be deleted. Try again.");
...@@ -1319,7 +1353,12 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {...@@ -1319,7 +1353,12 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
1319 </MenuItem>1353 </MenuItem>
1320 <MenuItem1354 <MenuItem
1321 disabled={!settingsHydrated || !removalAvailable}1355 disabled={!settingsHydrated || !removalAvailable}
1322 onClick={() => clearProfileData(profile.id, profile.name)}1356 onClick={() => {
1357 // Clearing a server copy closes its open tabs, so it asks first.
1358 if (serverProfileData.environmentIds.length > 0) {
1359 setProfilePendingClear(profile);
1360 } else clearProfileData(profile.id, profile.name);
1361 }}
1323 >1362 >
1324 Clear cookies and cache1363 Clear cookies and cache
1325 </MenuItem>1364 </MenuItem>
...@@ -1363,8 +1402,8 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {...@@ -1363,8 +1402,8 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
1363 <AlertDialogHeader>1402 <AlertDialogHeader>
1364 <AlertDialogTitle>Remove “{profilePendingRemoval?.name}”?</AlertDialogTitle>1403 <AlertDialogTitle>Remove “{profilePendingRemoval?.name}”?</AlertDialogTitle>
1365 <AlertDialogDescription>1404 <AlertDialogDescription>
1366 Its cookies and logins are deleted. Tabs already open in this profile stay open until1405 Its cookies and logins are deleted. Desktop tabs already open in this profile stay
1367 you close them.1406 open until you close them; server browser tabs close now.
1368 </AlertDialogDescription>1407 </AlertDialogDescription>
1369 {profileRemovalError ? (1408 {profileRemovalError ? (
1370 <p aria-live="polite" className="text-sm text-destructive">1409 <p aria-live="polite" className="text-sm text-destructive">
...@@ -1398,6 +1437,36 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {...@@ -1398,6 +1437,36 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
1398 </AlertDialogFooter>1437 </AlertDialogFooter>
1399 </AlertDialogPopup>1438 </AlertDialogPopup>
1400 </AlertDialog>1439 </AlertDialog>
1440 <AlertDialog
1441 open={profilePendingClear !== null}
1442 onOpenChange={(open) => {
1443 if (!open) setProfilePendingClear(null);
1444 }}
1445 >
1446 <AlertDialogPopup>
1447 <AlertDialogHeader>
1448 <AlertDialogTitle>
1449 Clear “{profilePendingClear?.name}”’s cookies and cache?
1450 </AlertDialogTitle>
1451 <AlertDialogDescription>
1452 You are signed out of its sites. Server browser tabs open in this profile close now.
1453 </AlertDialogDescription>
1454 </AlertDialogHeader>
1455 <AlertDialogFooter>
1456 <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>
1457 <Button
1458 variant="destructive"
1459 onClick={() => {
1460 if (profilePendingClear)
1461 clearProfileData(profilePendingClear.id, profilePendingClear.name);
1462 setProfilePendingClear(null);
1463 }}
1464 >
1465 Clear data
1466 </Button>
1467 </AlertDialogFooter>
1468 </AlertDialogPopup>
1469 </AlertDialog>
1401 {importSession ? (1470 {importSession ? (
1402 <BrowserImportWizard1471 <BrowserImportWizard
1403 source={importSession.source}1472 source={importSession.source}
apps/web/src/previewStateStore.test.ts+13
...@@ -466,6 +466,19 @@ describe("previewStateStore (single-tab)", () => {...@@ -466,6 +466,19 @@ describe("previewStateStore (single-tab)", () => {
466 reconcilePreviewServerSessions(ref, { sessions: [], serverEpoch, revision: 0 });466 reconcilePreviewServerSessions(ref, { sessions: [], serverEpoch, revision: 0 });
467467
468 expect(readThreadPreviewState(ref).sessions).toEqual({ [snapshot.tabId]: snapshot });468 expect(readThreadPreviewState(ref).sessions).toEqual({ [snapshot.tabId]: snapshot });
469 expect(readThreadPreviewState(ref).listLoaded).toBe(false);
470
471 const olderTab = makeSnapshot({ tabId: "tab_before_event" });
472 reconcilePreviewServerSessions(ref, {
473 sessions: [olderTab, snapshot],
474 serverEpoch,
475 revision: 2,
476 });
477 expect(readThreadPreviewState(ref).listLoaded).toBe(true);
478 expect(readThreadPreviewState(ref).sessions).toEqual({
479 [olderTab.tabId]: olderTab,
480 [snapshot.tabId]: snapshot,
481 });
469 });482 });
470483
471 it("does not resurrect a tab from an event older than its close", () => {484 it("does not resurrect a tab from an event older than its close", () => {
apps/web/src/previewStateStore.ts+7-1
...@@ -43,6 +43,8 @@ export interface ThreadPreviewState {...@@ -43,6 +43,8 @@ export interface ThreadPreviewState {
43 desktopOverlay: DesktopPreviewOverlay | null;43 desktopOverlay: DesktopPreviewOverlay | null;
44 desktopByTabId: Record<string, DesktopPreviewOverlay>;44 desktopByTabId: Record<string, DesktopPreviewOverlay>;
45 recentlySeenUrls: string[];45 recentlySeenUrls: string[];
46 /** Whether the first authoritative tab list has arrived. */
47 listLoaded: boolean;
46 /** Server process currently authoritative for revision ordering. */48 /** Server process currently authoritative for revision ordering. */
47 serverEpoch: string | null;49 serverEpoch: string | null;
48 /** Latest ordered server revision applied from a list response or event. */50 /** Latest ordered server revision applied from a list response or event. */
...@@ -57,6 +59,7 @@ const EMPTY_THREAD_PREVIEW_STATE: ThreadPreviewState = Object.freeze({...@@ -57,6 +59,7 @@ const EMPTY_THREAD_PREVIEW_STATE: ThreadPreviewState = Object.freeze({
57 desktopOverlay: null,59 desktopOverlay: null,
58 desktopByTabId: {},60 desktopByTabId: {},
59 recentlySeenUrls: [] as string[],61 recentlySeenUrls: [] as string[],
62 listLoaded: false,
60 serverEpoch: null,63 serverEpoch: null,
61 serverRevision: 0,64 serverRevision: 0,
62});65});
...@@ -311,7 +314,9 @@ export function reconcilePreviewServerSessions(...@@ -311,7 +314,9 @@ export function reconcilePreviewServerSessions(
311): void {314): void {
312 updateThreadPreviewState(ref, (current) => {315 updateThreadPreviewState(ref, (current) => {
313 const sameServer = current.serverEpoch === result.serverEpoch;316 const sameServer = current.serverEpoch === result.serverEpoch;
314 if (sameServer && result.revision < current.serverRevision) return current;317 if (sameServer && result.revision < current.serverRevision) {
318 return current;
319 }
315 const snapshots = result.sessions;320 const snapshots = result.sessions;
316 const sessions: Record<string, PreviewSessionSnapshot> = {};321 const sessions: Record<string, PreviewSessionSnapshot> = {};
317 const currentSuppressedTabIds = sameServer ? current.suppressedTabIds : new Set<string>();322 const currentSuppressedTabIds = sameServer ? current.suppressedTabIds : new Set<string>();
...@@ -349,6 +354,7 @@ export function reconcilePreviewServerSessions(...@@ -349,6 +354,7 @@ export function reconcilePreviewServerSessions(
349 desktopByTabId,354 desktopByTabId,
350 desktopOverlay: activeTabId ? (desktopByTabId[activeTabId] ?? null) : null,355 desktopOverlay: activeTabId ? (desktopByTabId[activeTabId] ?? null) : null,
351 recentlySeenUrls,356 recentlySeenUrls,
357 listLoaded: true,
352 serverEpoch: result.serverEpoch,358 serverEpoch: result.serverEpoch,
353 serverRevision: result.revision,359 serverRevision: result.revision,
354 };360 };
apps/web/src/rightPanelStore.test.ts+26
...@@ -1049,6 +1049,32 @@ describe("rightPanelStore", () => {...@@ -1049,6 +1049,32 @@ describe("rightPanelStore", () => {
1049 });1049 });
1050 });1050 });
10511051
1052 it("does not replace a stale surface with a hidden tab, but preserves explicit opens", () => {
1053 const store = useRightPanelStore.getState();
1054 store.openBrowser(refA, "stale-tab");
1055 store.reconcileBrowserSurfaces(refA, ["hidden-tab"], new Set(["hidden-tab"]));
1056 expect(
1057 selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA),
1058 ).toMatchObject({
1059 activeSurfaceId: null,
1060 surfaces: [],
1061 });
1062
1063 store.openBrowser(refA, "hidden-tab");
1064 store.reconcileBrowserSurfaces(
1065 refA,
1066 ["hidden-tab", "other-tab"],
1067 new Set(["hidden-tab", "other-tab"]),
1068 );
1069 expect(
1070 selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA),
1071 ).toMatchObject({
1072 isOpen: true,
1073 activeSurfaceId: "browser:hidden-tab",
1074 surfaces: [{ id: "browser:hidden-tab", kind: "preview", resourceId: "hidden-tab" }],
1075 });
1076 });
1077
1052 it("reconciles browser surfaces without deleting other surface kinds", () => {1078 it("reconciles browser surfaces without deleting other surface kinds", () => {
1053 useRightPanelStore.getState().openTerminal(refA, "term-1");1079 useRightPanelStore.getState().openTerminal(refA, "term-1");
1054 useRightPanelStore.getState().openBrowser(refA, "tab-a");1080 useRightPanelStore.getState().openBrowser(refA, "tab-a");
apps/web/src/rightPanelStore.ts+7-3
...@@ -163,7 +163,11 @@ interface RightPanelStoreState {...@@ -163,7 +163,11 @@ interface RightPanelStoreState {
163 closeOtherSurfaces: (ref: ScopedThreadRef, surfaceId: string) => void;163 closeOtherSurfaces: (ref: ScopedThreadRef, surfaceId: string) => void;
164 closeSurfacesToRight: (ref: ScopedThreadRef, surfaceId: string) => void;164 closeSurfacesToRight: (ref: ScopedThreadRef, surfaceId: string) => void;
165 closeAllSurfaces: (ref: ScopedThreadRef) => void;165 closeAllSurfaces: (ref: ScopedThreadRef) => void;
166 reconcileBrowserSurfaces: (ref: ScopedThreadRef, tabIds: readonly string[]) => void;166 reconcileBrowserSurfaces: (
167 ref: ScopedThreadRef,
168 tabIds: readonly string[],
169 hiddenTabIds?: ReadonlySet<string>,
170 ) => void;
167 reconcileFileSurfaces: (ref: ScopedThreadRef, workspaceAvailable: boolean) => void;171 reconcileFileSurfaces: (ref: ScopedThreadRef, workspaceAvailable: boolean) => void;
168 show: (ref: ScopedThreadRef) => void;172 show: (ref: ScopedThreadRef) => void;
169 close: (ref: ScopedThreadRef) => void;173 close: (ref: ScopedThreadRef) => void;
...@@ -850,7 +854,7 @@ export const useRightPanelStore = create<RightPanelStoreState>()(...@@ -850,7 +854,7 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
850 : { ...current, isOpen: false, surfaces: [], activeSurfaceId: null },854 : { ...current, isOpen: false, surfaces: [], activeSurfaceId: null },
851 ),855 ),
852 ),856 ),
853 reconcileBrowserSurfaces: (ref, tabIds) =>857 reconcileBrowserSurfaces: (ref, tabIds, hiddenTabIds) =>
854 set((state) =>858 set((state) =>
855 automaticUpdate(state, scopedThreadKey(ref), (current) => {859 automaticUpdate(state, scopedThreadKey(ref), (current) => {
856 const validIds = new Set(tabIds.map((tabId) => `browser:${tabId}`));860 const validIds = new Set(tabIds.map((tabId) => `browser:${tabId}`));
...@@ -863,7 +867,7 @@ export const useRightPanelStore = create<RightPanelStoreState>()(...@@ -863,7 +867,7 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
863 );867 );
864 const knownIds = new Set(existingBrowser.map((surface) => surface.id));868 const knownIds = new Set(existingBrowser.map((surface) => surface.id));
865 const added = tabIds869 const added = tabIds
866 .filter((tabId) => !knownIds.has(`browser:${tabId}`))870 .filter((tabId) => !knownIds.has(`browser:${tabId}`) && !hiddenTabIds?.has(tabId))
867 .map((tabId) => browserSurface(tabId));871 .map((tabId) => browserSurface(tabId));
868 const surfaces = [...nonBrowser, ...existingBrowser, ...added];872 const surfaces = [...nonBrowser, ...existingBrowser, ...added];
869 const activeStillExists = surfaces.some(873 const activeStillExists = surfaces.some(
apps/web/src/routes/_chat.tsx+2-2
...@@ -8,6 +8,7 @@ import { resolveThreadRouteTarget } from "../threadRoutes";...@@ -8,6 +8,7 @@ import { resolveThreadRouteTarget } from "../threadRoutes";
8import { useClientSettings, useLegacySidebarEnabled } from "../hooks/useSettings";8import { useClientSettings, useLegacySidebarEnabled } from "../hooks/useSettings";
9import { openCommandPalette } from "../commandPaletteBus";9import { openCommandPalette } from "../commandPaletteBus";
10import { useProjects } from "../state/entities";10import { useProjects } from "../state/entities";
11import { isPreviewAvailableFor } from "../browser/previewRuntime";
11import { usePrimaryEnvironmentId } from "../state/environments";12import { usePrimaryEnvironmentId } from "../state/environments";
12import { selectProjectGroupingSettings } from "../logicalProject";13import { selectProjectGroupingSettings } from "../logicalProject";
13import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping";14import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping";
...@@ -22,7 +23,6 @@ import { isModelPickerOpen } from "../modelPickerVisibility";...@@ -22,7 +23,6 @@ import { isModelPickerOpen } from "../modelPickerVisibility";
22import { undoLatestThreadAction } from "../hooks/showThreadUndoNotice";23import { undoLatestThreadAction } from "../hooks/showThreadUndoNotice";
23import { resolveShortcutCommand } from "../keybindings";24import { resolveShortcutCommand } from "../keybindings";
24import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";25import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
25import { isPreviewSupportedInRuntime } from "../previewStateStore";
26import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore";26import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore";
27import { useThreadSelectionStore } from "../threadSelectionStore";27import { useThreadSelectionStore } from "../threadSelectionStore";
28import { stackedThreadToast, toastManager } from "~/components/ui/toast";28import { stackedThreadToast, toastManager } from "~/components/ui/toast";
...@@ -141,7 +141,7 @@ function ChatRouteGlobalShortcuts() {...@@ -141,7 +141,7 @@ function ChatRouteGlobalShortcuts() {
141 event.preventDefault();141 event.preventDefault();
142 event.stopPropagation();142 event.stopPropagation();
143 if (!routeThreadRef) return;143 if (!routeThreadRef) return;
144 if (!isPreviewSupportedInRuntime()) {144 if (!isPreviewAvailableFor(routeThreadRef.environmentId)) {
145 toastManager.add(145 toastManager.add(
146 stackedThreadToast({146 stackedThreadToast({
147 type: "info",147 type: "info",
apps/web/src/rpc/requestLatencyState.test.ts+2-9
...@@ -52,13 +52,6 @@ describe("requestLatencyState", () => {...@@ -52,13 +52,6 @@ describe("requestLatencyState", () => {
52 expect(getSlowRpcAckRequests()).toEqual([]);52 expect(getSlowRpcAckRequests()).toEqual([]);
53 });53 });
5454
55 it("ignores the long-lived preview automation connection", () => {
56 trackRpcRequestSent("1", WS_METHODS.previewAutomationConnect);
57 vi.advanceTimersByTime(SLOW_RPC_ACK_THRESHOLD_MS * 2);
58
59 expect(getSlowRpcAckRequests()).toEqual([]);
60 });
61
62 it("ignores usage summary requests", () => {55 it("ignores usage summary requests", () => {
63 trackRpcRequestSent("1", WS_METHODS.serverGetUsageSummary);56 trackRpcRequestSent("1", WS_METHODS.serverGetUsageSummary);
64 vi.advanceTimersByTime(SLOW_RPC_ACK_THRESHOLD_MS * 2);57 vi.advanceTimersByTime(SLOW_RPC_ACK_THRESHOLD_MS * 2);
...@@ -79,8 +72,8 @@ describe("requestLatencyState", () => {...@@ -79,8 +72,8 @@ describe("requestLatencyState", () => {
79 it("keeps ignoring untracked methods when a display tag is supplied", () => {72 it("keeps ignoring untracked methods when a display tag is supplied", () => {
80 trackRpcRequestSent(73 trackRpcRequestSent(
81 "1",74 "1",
82 WS_METHODS.previewAutomationConnect,75 WS_METHODS.serverGetUsageSummary,
83 `${WS_METHODS.previewAutomationConnect} · env-1`,76 `${WS_METHODS.serverGetUsageSummary} · env-1`,
84 );77 );
85 vi.advanceTimersByTime(SLOW_RPC_ACK_THRESHOLD_MS * 2);78 vi.advanceTimersByTime(SLOW_RPC_ACK_THRESHOLD_MS * 2);
8679
apps/web/src/rpc/requestLatencyState.ts+1-4
...@@ -28,10 +28,7 @@ interface PendingRpcAckRequest {...@@ -28,10 +28,7 @@ interface PendingRpcAckRequest {
28}28}
2929
30const pendingRpcAckRequests = new Map<string, PendingRpcAckRequest>();30const pendingRpcAckRequests = new Map<string, PendingRpcAckRequest>();
31const untrackedRpcAckMethods = new Set<string>([31const untrackedRpcAckMethods = new Set<string>([WS_METHODS.serverGetUsageSummary]);
32 WS_METHODS.previewAutomationConnect,
33 WS_METHODS.serverGetUsageSummary,
34]);
35const longRunningRpcAckMethods = new Set<string>([32const longRunningRpcAckMethods = new Set<string>([
36 WS_METHODS.serverUpdateProvider,33 WS_METHODS.serverUpdateProvider,
37 WS_METHODS.serverRefreshProviders,34 WS_METHODS.serverRefreshProviders,
apps/web/src/state/entities.ts+17
...@@ -201,6 +201,23 @@ export function waitForThreadShell(ref: ScopedThreadRef, timeoutMs = 5_000): Pro...@@ -201,6 +201,23 @@ export function waitForThreadShell(ref: ScopedThreadRef, timeoutMs = 5_000): Pro
201 });201 });
202}202}
203203
204/** Whether the environment hosts preview tabs in its own browser (`runtime: "server"`),
205 so clients without Electron can still use the Browser panel. */
206export function useEnvironmentSupportsServerBrowser(environmentId: EnvironmentId | null): boolean {
207 const configs = useServerConfigs();
208 return (
209 environmentId !== null &&
210 configs.get(environmentId)?.environment.capabilities.serverBrowser === true
211 );
212}
213
214export function readEnvironmentSupportsServerBrowser(environmentId: EnvironmentId): boolean {
215 return (
216 appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities
217 .serverBrowser === true
218 );
219}
220
204export function readEnvironmentSupportsTitleRegeneration(environmentId: EnvironmentId): boolean {221export function readEnvironmentSupportsTitleRegeneration(environmentId: EnvironmentId): boolean {
205 return (222 return (
206 appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities223 appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities
apps/web/src/state/previewStream.ts created+51
...@@ -0,0 +1,51 @@
1import { useAtomValue } from "@effect/atom-react";
2import { PREVIEW_STREAM_BASE_PATH } from "@t3tools/client-runtime/preview/server-browser-stream";
3import {
4 type DeviceHubAccess,
5 resolveDeviceHubAccess,
6} from "@t3tools/client-runtime/state/deviceHubAccess";
7import type { EnvironmentId } from "@t3tools/contracts";
8import * as Effect from "effect/Effect";
9import * as Option from "effect/Option";
10import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
11
12import { connectionAtomRuntime } from "../connection/runtime";
13import { appAtomRegistry } from "../rpc/atomRegistry";
14import { environmentSession } from "./session";
15
16// Re-pairing changes the prepared connection, invalidating its cached ticket.
17const previewStreamAccessAtom = Atom.family((environmentId: EnvironmentId) =>
18 connectionAtomRuntime
19 .atom((get) => {
20 const prepared = Option.getOrNull(
21 get(environmentSession.preparedConnectionValueAtom(environmentId)),
22 );
23 if (prepared === null) return Effect.never;
24 return resolveDeviceHubAccess({ prepared, hubBasePath: PREVIEW_STREAM_BASE_PATH });
25 })
26 .pipe(Atom.setIdleTTL(60_000), Atom.withLabel(`preview-stream-access:${environmentId}`)),
27);
28
29export function usePreviewStreamAccess(environmentId: EnvironmentId): DeviceHubAccess | null {
30 const result = useAtomValue(previewStreamAccessAtom(environmentId));
31 return AsyncResult.isSuccess(result) ? result.value : null;
32}
33
34export function refreshPreviewStreamAccess(environmentId: EnvironmentId): void {
35 appAtomRegistry.refresh(previewStreamAccessAtom(environmentId));
36}
37
38/** Fresh stream access for a long-lived viewer outside React, e.g. system picture in picture. */
39export function readPreviewStreamAccess(
40 environmentId: EnvironmentId,
41 refresh: boolean,
42): Promise<DeviceHubAccess | null> {
43 const atom = previewStreamAccessAtom(environmentId);
44 if (refresh) appAtomRegistry.refresh(atom);
45 return Effect.runPromise(
46 AtomRegistry.getResult(appAtomRegistry, atom, { suspendOnWaiting: true }).pipe(
47 Effect.timeout("10 seconds"),
48 Effect.orElseSucceed(() => null),
49 ),
50 );
51}
docs/user/html-renders.md+1-1
...@@ -6,4 +6,4 @@ Pages use your current theme, including custom themes, and follow light and dark...@@ -6,4 +6,4 @@ Pages use your current theme, including custom themes, and follow light and dark
66
7Agents can place local images in a page by file path. T3 Code embeds them when the page is published, so the page keeps working after the original files move or are deleted. Deleting the thread deletes its pages.7Agents can place local images in a page by file path. T3 Code embeds them when the page is published, so the page keeps working after the original files move or are deleted. Deleting the thread deletes its pages.
88
9Before publishing, agents check their work with screenshots from a small headless browser that T3 Code keeps for itself; it never uses a browser you installed. The first preview on a machine downloads it once (about 120 MB) into T3 Code's data folder, so that preview can take a minute. Pages publish without it.9Before publishing, agents check their work with screenshots from a small headless browser that T3 Code keeps for itself; it never uses a browser you installed. The first preview on a machine downloads it once (about 120 MB) into T3 Code's data folder, so that preview can take a minute. Browser tabs on a remote environment use the same browser, so whichever comes first downloads it. Some Linux hosts need [setup](remote-access.md#browser-host-setup) before it can start. Pages publish without it.
docs/user/remote-access.md+48
...@@ -172,6 +172,54 @@ running is left alone....@@ -172,6 +172,54 @@ running is left alone.
172For Antigravity's Google callback on a remote host, see172For Antigravity's Google callback on a remote host, see
173[remote sign-in](./providers-antigravity.md#sign-in-from-a-remote-device).173[remote sign-in](./providers-antigravity.md#sign-in-from-a-remote-device).
174174
175## Browser on a remote environment
176
177Browser tabs belong to the environment, so you and your agents see the same
178tabs from any device. The desktop app shows its own environment's tabs
179directly. Every other device, and the desktop app for other environments,
180streams them from the host. Agents keep using them while no device is
181connected, and `localhost` addresses reach servers on the host.
182
183The first tab downloads a headless Chrome, about 120 MB, into the T3 home. It
184is the same browser [HTML renders](html-renders.md) use, so a host downloads it
185only once. Some Linux hosts need [setup](#browser-host-setup) before it can
186start.
187
188Agent tabs have separate storage and share a Chromium process. Take control before
189typing into an agent's tab, then release control when you want the agent to
190continue. Read-only connections can watch without changing the page.
191
192While you have control, the tab works with your device: text the page copies or
193cuts goes to your clipboard, a file picker on the page opens your device's
194picker, and a finished download is offered for you to save. Popups such as
195sign-in windows open as their own tabs. Downloads stay on the host until the
196tab closes. Audio does not play on your device.
197
198On a phone, tap the floating preview's corner dot to show its controls, then
199**Pop into separate window** to keep watching in picture-in-picture over other
200apps.
201
202### Browser host setup
203
204macOS, Windows, and Linux desktops run the browser as is. Some Linux hosts need
205one-time setup: Ubuntu 23.10 and later block the sandbox the browser runs in,
206and minimal images and containers lack libraries it loads. When that happens,
207the server says so at startup, and browser tabs and HTML previews show the
208command to run on the host:
209
210```sh
211sudo t3 browser setup
212```
213
214The server shows the exact line for how you started it, such as
215`sudo npx t3 browser setup`, and keeps your `PATH` when Node is installed only
216for your user. It allows Chrome's sandbox with an AppArmor profile and installs
217any missing libraries with apt. It is safe to run again. Without `sudo`, it
218only reports what it would change.
219
220The browser always runs in Chrome's sandbox. Where you cannot change the host,
221set `T3CODE_SERVER_BROWSER_SANDBOX=0` for the environment to run without it.
222
175## Manage or revoke access223## Manage or revoke access
176224
177On the host, **Settings → Connections** lets authorized administrators create225On the host, **Settings → Connections** lets authorized administrators create
knip.jsonc+1
...@@ -77,6 +77,7 @@...@@ -77,6 +77,7 @@
77 "plugins/*.cjs",77 "plugins/*.cjs",
78 "scripts/generate-device-stream.mts",78 "scripts/generate-device-stream.mts",
79 "src/features/devices/device-stream.browser.ts!",79 "src/features/devices/device-stream.browser.ts!",
80 "src/features/browser/preview-stream.browser.ts!",
80 ],81 ],
81 // Fonts are configured as asset paths; Expo autolinks the native packages.82 // Fonts are configured as asset paths; Expo autolinks the native packages.
82 "ignoreDependencies": [83 "ignoreDependencies": [
packages/client-runtime/package.json+4
...@@ -19,6 +19,10 @@...@@ -19,6 +19,10 @@
19 "types": "./src/device/hubAccess.ts",19 "types": "./src/device/hubAccess.ts",
20 "default": "./src/device/hubAccess.ts"20 "default": "./src/device/hubAccess.ts"
21 },21 },
22 "./preview/server-browser-stream": {
23 "types": "./src/preview/serverBrowserStream.ts",
24 "default": "./src/preview/serverBrowserStream.ts"
25 },
22 "./worktree-setup": {26 "./worktree-setup": {
23 "types": "./src/worktreeSetup.ts",27 "types": "./src/worktreeSetup.ts",
24 "default": "./src/worktreeSetup.ts"28 "default": "./src/worktreeSetup.ts"
packages/client-runtime/src/preview/serverBrowserStream.test.ts created+201
...@@ -0,0 +1,201 @@
1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
2
3import { createPreviewStreamClient, type PreviewStreamControl } from "./serverBrowserStream.ts";
4
5class FakeSocket extends EventTarget {
6 static readonly OPEN = 1;
7 static current: FakeSocket;
8 readyState = 1;
9 binaryType = "";
10 readonly sent: string[] = [];
11 readonly url: string;
12 constructor(url: string) {
13 super();
14 this.url = url;
15 FakeSocket.current = this;
16 }
17 send(value: string) {
18 this.sent.push(value);
19 }
20 close() {
21 this.readyState = 3;
22 }
23 message(data: unknown) {
24 this.dispatchEvent(new MessageEvent("message", { data }));
25 }
26 control(value: PreviewStreamControl) {
27 this.message(JSON.stringify({ type: "control", ...value }));
28 }
29}
30
31const target = {
32 access: {
33 httpBase: "http://preview.test/api/preview-stream",
34 wsBase: "ws://preview.test/api/preview-stream",
35 query: {},
36 credentials: true,
37 },
38 threadId: "thread",
39 tabId: "tab",
40 maxWidth: 800,
41 maxHeight: 600,
42};
43
44describe("preview stream control", () => {
45 beforeEach(() => vi.stubGlobal("WebSocket", FakeSocket));
46 afterEach(() => vi.unstubAllGlobals());
47 const connect = () => {
48 const onFrame = vi.fn();
49 const onControl = vi.fn();
50 const client = createPreviewStreamClient(target, {
51 onFrame,
52 onControl,
53 onViewport: vi.fn(),
54 onConnectedChange: vi.fn(),
55 onUnauthorized: vi.fn(),
56 });
57 return { client, socket: FakeSocket.current, onFrame, onControl };
58 };
59 const agent: PreviewStreamControl = {
60 canOperate: true,
61 controller: "agent",
62 generation: 1,
63 dialog: null,
64 };
65
66 it("marks passive viewers without changing active viewer URLs", () => {
67 const { client, socket } = connect();
68 expect(new URL(socket.url).searchParams.has("interactive")).toBe(false);
69 client.stop();
70 const passive = createPreviewStreamClient(
71 { ...target, interactive: false },
72 {
73 onFrame: vi.fn(),
74 onViewport: vi.fn(),
75 onConnectedChange: vi.fn(),
76 onUnauthorized: vi.fn(),
77 },
78 );
79 expect(new URL(FakeSocket.current.url).searchParams.get("interactive")).toBe("false");
80 passive.stop();
81 });
82
83 it("does not resize or navigate before ownership is granted", () => {
84 const { client, socket } = connect();
85 expect(client.send({ type: "resize", width: 390, height: 844 })).toBe(false);
86 expect(client.send({ type: "navigate", url: "http://example.test" })).toBe(false);
87 socket.control(agent);
88 expect(client.send({ type: "takeControl" })).toBe(true);
89 expect(client.send({ type: "text", text: "premature" })).toBe(false);
90 socket.control({ ...agent, controller: "you", generation: 2 });
91 expect(client.send({ type: "text", text: "owned" })).toBe(true);
92 expect(socket.sent.map((entry) => JSON.parse(entry))).toEqual([
93 { type: "takeControl" },
94 { type: "text", text: "owned" },
95 ]);
96 client.stop();
97 });
98
99 it("does not grant control from malformed dialog metadata", () => {
100 const { client, socket, onControl } = connect();
101 socket.message(
102 JSON.stringify({
103 type: "control",
104 ...agent,
105 controller: "you",
106 dialog: { type: "prompt", message: 42, defaultValue: "" },
107 }),
108 );
109 expect(onControl).not.toHaveBeenCalled();
110 expect(client.send({ type: "reload" })).toBe(false);
111 client.stop();
112 });
113
114 it("keeps read-only frames flowing while refusing takeover and every mutation", () => {
115 const { client, socket, onFrame } = connect();
116 socket.control({ ...agent, canOperate: false });
117 const frame = new ArrayBuffer(3);
118 socket.message(frame);
119 expect(onFrame).toHaveBeenCalledWith(frame);
120 expect(client.send({ type: "takeControl" })).toBe(false);
121 expect(client.send({ type: "resize", width: 390, height: 844 })).toBe(false);
122 expect(client.send({ type: "reload" })).toBe(false);
123 expect(socket.sent).toEqual(['{"type":"ack"}']);
124 client.stop();
125 });
126
127 it("revokes input immediately on release and exposes pending dialog state", () => {
128 const { client, socket, onControl } = connect();
129 const owned = {
130 ...agent,
131 controller: "you" as const,
132 dialog: { type: "prompt", message: "Name?", defaultValue: "initial" },
133 };
134 socket.control(owned);
135 expect(onControl).toHaveBeenLastCalledWith(owned);
136 expect(client.send({ type: "dialog", accept: true, promptText: "answer" })).toBe(true);
137 expect(client.send({ type: "releaseControl" })).toBe(true);
138 socket.control({ ...agent, generation: 2 });
139 expect(client.send({ type: "text", text: "late input" })).toBe(false);
140 expect(client.send({ type: "dialog", accept: false })).toBe(false);
141 client.stop();
142 });
143});
144
145describe("preview stream downloads", () => {
146 beforeEach(() => vi.stubGlobal("WebSocket", FakeSocket));
147 afterEach(() => vi.unstubAllGlobals());
148
149 it("offers a download at a URL carrying the stream's ticket", () => {
150 const onDownload = vi.fn();
151 createPreviewStreamClient(
152 {
153 ...target,
154 access: { ...target.access, query: { wsTicket: "ticket" }, credentials: false },
155 },
156 {
157 onFrame: vi.fn(),
158 onDownload,
159 onViewport: vi.fn(),
160 onConnectedChange: vi.fn(),
161 onUnauthorized: vi.fn(),
162 },
163 );
164 FakeSocket.current.message(
165 JSON.stringify({ type: "download", id: "d1", fileName: "a b.csv", sizeBytes: 3 }),
166 );
167 expect(onDownload).toHaveBeenCalledExactlyOnceWith({
168 fileName: "a b.csv",
169 sizeBytes: 3,
170 url: "http://preview.test/api/preview-stream/download?threadId=thread&tabId=tab&id=d1&wsTicket=ticket",
171 });
172 });
173});
174
175describe("preview stream agent pointer", () => {
176 beforeEach(() => vi.stubGlobal("WebSocket", FakeSocket));
177 afterEach(() => vi.unstubAllGlobals());
178
179 it("reports where the agent moves and clicks, ignoring malformed points", () => {
180 const onPointer = vi.fn();
181 createPreviewStreamClient(target, {
182 onFrame: vi.fn(),
183 onPointer,
184 onViewport: vi.fn(),
185 onConnectedChange: vi.fn(),
186 onUnauthorized: vi.fn(),
187 });
188 FakeSocket.current.message(
189 JSON.stringify({ type: "pointer", phase: "drag", x: 1, y: 2, sequence: 1 }),
190 );
191 FakeSocket.current.message(
192 JSON.stringify({ type: "pointer", phase: "click", x: 140, y: 50, sequence: 2 }),
193 );
194 expect(onPointer).toHaveBeenCalledExactlyOnceWith({
195 phase: "click",
196 x: 140,
197 y: 50,
198 sequence: 2,
199 });
200 });
201});
packages/client-runtime/src/preview/serverBrowserStream.ts created+476
...@@ -0,0 +1,476 @@
1// @effect-diagnostics globalTimers:off globalFetch:off - This browser and WebView transport runs without an Effect runtime.
2import { type DeviceHubAccess, withDeviceHubQuery } from "../device/hubAccess.ts";
3import {
4 PREVIEW_STREAM_HOST_SETUP_CLOSE_CODE,
5 type PreviewStreamHostSetup,
6 type PreviewViewportSetting,
7} from "@t3tools/contracts";
8
9export const PREVIEW_STREAM_BASE_PATH = "/api/preview-stream";
10
11export interface PreviewStreamViewport {
12 readonly width: number;
13 readonly height: number;
14}
15
16export type PreviewStreamMouseButton = "none" | "left" | "middle" | "right";
17
18export interface PreviewStreamControl {
19 readonly canOperate: boolean;
20 readonly controller: "agent" | "you" | "another-viewer" | "unclaimed";
21 readonly generation: number;
22 readonly dialog: null | {
23 readonly type: string;
24 readonly message: string;
25 readonly defaultValue: string;
26 };
27}
28
29/** Status line shown above a viewer; `null` control means the socket is not connected. */
30export const previewStreamControlLabel = (control: PreviewStreamControl | null): string =>
31 !control
32 ? "Connecting..."
33 : !control.canOperate
34 ? "Read-only"
35 : control.controller === "you"
36 ? "You have control"
37 : control.controller === "agent"
38 ? "Agent has control"
39 : control.controller === "another-viewer"
40 ? "Another viewer has control"
41 : "Watching";
42
43const isPreviewStreamDialog = (value: unknown): value is PreviewStreamControl["dialog"] =>
44 value === null ||
45 (typeof value === "object" &&
46 "type" in value &&
47 typeof value.type === "string" &&
48 "message" in value &&
49 typeof value.message === "string" &&
50 "defaultValue" in value &&
51 typeof value.defaultValue === "string");
52
53/** Client-to-server messages. Coordinates are page CSS px. */
54export type PreviewStreamInput =
55 | { readonly type: "takeControl" }
56 | { readonly type: "releaseControl" }
57 | { readonly type: "dialog"; readonly accept: boolean; readonly promptText?: string }
58 | { readonly type: "viewport"; readonly setting: PreviewViewportSetting }
59 | {
60 readonly type: "mouse";
61 readonly action: "move" | "down" | "up";
62 readonly x: number;
63 readonly y: number;
64 readonly button: PreviewStreamMouseButton;
65 /** Pressed buttons: left 1, right 2, middle 4. */
66 readonly buttons: number;
67 readonly clickCount: number;
68 readonly modifiers: number;
69 }
70 | {
71 readonly type: "wheel";
72 readonly x: number;
73 readonly y: number;
74 readonly deltaX: number;
75 readonly deltaY: number;
76 readonly modifiers: number;
77 }
78 | {
79 readonly type: "key";
80 readonly action: "down" | "up";
81 readonly key: string;
82 readonly code: string;
83 /** Windows virtual key code (DOM `keyCode`); Enter, Backspace, and arrows need it. */
84 readonly keyCode?: number;
85 /** Only for printable keys without Ctrl or Meta. */
86 readonly text?: string;
87 readonly modifiers: number;
88 }
89 | { readonly type: "text"; readonly text: string }
90 | { readonly type: "resize"; readonly width: number; readonly height: number }
91 | { readonly type: "navigate"; readonly url: string }
92 | { readonly type: "history"; readonly delta: -1 | 1 }
93 | { readonly type: "reload"; readonly ignoreCache?: boolean }
94 /** Asks whether the page point takes text. Touch viewers send it on touch start. */
95 | { readonly type: "probe"; readonly x: number; readonly y: number };
96
97export interface PreviewStreamDownload {
98 readonly fileName: string;
99 readonly sizeBytes: number;
100 /** Authenticated with the stream's own access; cookie sessions must send credentials. */
101 readonly url: string;
102}
103
104export interface PreviewStreamFileChooser {
105 readonly multiple: boolean;
106 /** The input's `accept` attribute, ready for a local `<input type=file>`. */
107 readonly accept: string;
108 /** POST multipart `file` parts here; an empty form cancels the page's picker. */
109 readonly uploadUrl: string;
110 /** Cookie sessions must send credentials with the upload. */
111 readonly credentials: boolean;
112}
113
114const previewStreamUploadUrl = (
115 target: Pick<PreviewStreamTarget, "access" | "threadId" | "tabId">,
116 chooser: string,
117): string =>
118 withDeviceHubQuery(
119 `${target.access.httpBase}/upload?${new URLSearchParams({
120 threadId: target.threadId,
121 tabId: target.tabId,
122 chooser,
123 }).toString()}`,
124 target.access,
125 );
126
127/** Sends files to a page's open picker. Rejects when the server refuses them. */
128export async function uploadPreviewStreamFiles(
129 chooser: PreviewStreamFileChooser,
130 files: ReadonlyArray<Blob & { readonly name?: string }>,
131): Promise<void> {
132 const body = new FormData();
133 for (const file of files) body.append("file", file, file.name ?? "file");
134 const response = await fetch(chooser.uploadUrl, {
135 method: "POST",
136 body,
137 credentials: chooser.credentials ? "include" : "omit",
138 });
139 if (!response.ok) throw new Error((await response.text()) || "The upload was refused.");
140}
141
142const previewStreamDownloadUrl = (
143 target: Pick<PreviewStreamTarget, "access" | "threadId" | "tabId">,
144 id: string,
145): string =>
146 withDeviceHubQuery(
147 `${target.access.httpBase}/download?${new URLSearchParams({
148 threadId: target.threadId,
149 tabId: target.tabId,
150 id,
151 }).toString()}`,
152 target.access,
153 );
154
155/** Answer to a `probe`, echoing its point. */
156export interface PreviewStreamProbe {
157 readonly x: number;
158 readonly y: number;
159 readonly editable: boolean;
160}
161
162/** Where an agent action is about to land, in page CSS pixels. */
163export interface PreviewStreamPointer {
164 readonly phase: "move" | "click";
165 readonly x: number;
166 readonly y: number;
167 readonly sequence: number;
168}
169
170/** CDP modifier bitmask: Alt 1, Ctrl 2, Meta 4, Shift 8. */
171export const previewStreamModifiers = (event: {
172 readonly altKey: boolean;
173 readonly ctrlKey: boolean;
174 readonly metaKey: boolean;
175 readonly shiftKey: boolean;
176}): number =>
177 (event.altKey ? 1 : 0) |
178 (event.ctrlKey ? 2 : 0) |
179 (event.metaKey ? 4 : 0) |
180 (event.shiftKey ? 8 : 0);
181
182export interface PreviewStreamTarget {
183 readonly access: DeviceHubAccess;
184 readonly threadId: string;
185 readonly tabId: string;
186 /** Viewer backing-store size in device px. The server never sends larger frames. */
187 readonly maxWidth: number;
188 readonly maxHeight: number;
189 /** Passive viewers reduce their own access, including automatic control grants. */
190 readonly interactive?: boolean;
191}
192
193export interface PreviewStreamEvents {
194 /** One complete JPEG frame. */
195 readonly onFrame: (jpeg: ArrayBuffer) => void;
196 readonly onViewport: (viewport: PreviewStreamViewport) => void;
197 readonly onProbe?: (probe: PreviewStreamProbe) => void;
198 /** The agent's cursor moved to, or clicked at, a page point. */
199 readonly onPointer?: (pointer: PreviewStreamPointer) => void;
200 readonly onControl?: (control: PreviewStreamControl) => void;
201 /** Text the page just copied or cut while this viewer had control. */
202 readonly onClipboard?: (text: string) => void;
203 /** A file the page downloaded while this viewer had control. */
204 readonly onDownload?: (download: PreviewStreamDownload) => void;
205 /** The page opened a file picker (`null` once answered or replaced). */
206 readonly onFileChooser?: (chooser: PreviewStreamFileChooser | null) => void;
207 /** Input sent while disconnected is dropped. */
208 readonly onConnectedChange: (connected: boolean) => void;
209 /** The upgrade was refused; refresh access and start a new client. */
210 readonly onUnauthorized: () => void;
211 /** The tab was closed on the server. The client has stopped. */
212 readonly onGone?: () => void;
213 /** The server's browser cannot start until its host is set up. The client has stopped. */
214 readonly onHostSetup?: (setup: PreviewStreamHostSetup) => void;
215}
216
217export interface PreviewStreamClient {
218 /** False when the socket is not open and the message was dropped. */
219 readonly send: (input: PreviewStreamInput) => boolean;
220 readonly stop: () => void;
221}
222
223const ACK_MESSAGE = JSON.stringify({ type: "ack" });
224export function createPreviewStreamClient(
225 target: PreviewStreamTarget,
226 events: PreviewStreamEvents,
227): PreviewStreamClient {
228 const query = new URLSearchParams({
229 threadId: target.threadId,
230 tabId: target.tabId,
231 maxWidth: String(Math.max(1, Math.round(target.maxWidth))),
232 maxHeight: String(Math.max(1, Math.round(target.maxHeight))),
233 });
234 if (target.interactive === false) query.set("interactive", "false");
235 const url = withDeviceHubQuery(`${target.access.wsBase}/ws?${query.toString()}`, target.access);
236 let stopped = false;
237 let socket: WebSocket | null = null;
238 let retryTimer: ReturnType<typeof setTimeout> | null = null;
239 let failures = 0;
240 let control: PreviewStreamControl | null = null;
241 let fileChooser: string | null = null;
242
243 const connect = () => {
244 if (stopped) return;
245 const ws = new WebSocket(url);
246 ws.binaryType = "arraybuffer";
247 socket = ws;
248 let opened = false;
249 ws.addEventListener("open", () => {
250 if (socket !== ws) return;
251 opened = true;
252 events.onConnectedChange(true);
253 });
254 ws.addEventListener("message", (event) => {
255 if (socket !== ws) return;
256 if (event.data instanceof ArrayBuffer) {
257 failures = 0;
258 ws.send(ACK_MESSAGE);
259 events.onFrame(event.data);
260 return;
261 }
262 if (typeof event.data !== "string") return;
263 let message: unknown;
264 try {
265 message = JSON.parse(event.data);
266 } catch {
267 return;
268 }
269 if (typeof message !== "object" || message === null) return;
270 const {
271 type,
272 x,
273 y,
274 width,
275 height,
276 editable,
277 canOperate,
278 controller,
279 generation,
280 dialog,
281 text,
282 id,
283 fileName,
284 sizeBytes,
285 multiple,
286 accept,
287 phase,
288 sequence,
289 } = message as Record<string, unknown>;
290 if (
291 type === "fileChooser" &&
292 typeof id === "string" &&
293 typeof multiple === "boolean" &&
294 typeof accept === "string"
295 ) {
296 fileChooser = id;
297 events.onFileChooser?.({
298 multiple,
299 accept,
300 uploadUrl: previewStreamUploadUrl(target, id),
301 credentials: target.access.credentials,
302 });
303 } else if (type === "fileChooserClosed" && typeof id === "string") {
304 if (fileChooser !== id) return;
305 fileChooser = null;
306 events.onFileChooser?.(null);
307 } else if (
308 type === "pointer" &&
309 (phase === "move" || phase === "click") &&
310 typeof x === "number" &&
311 typeof y === "number" &&
312 typeof sequence === "number"
313 ) {
314 events.onPointer?.({ phase, x, y, sequence });
315 } else if (type === "clipboard" && typeof text === "string") {
316 events.onClipboard?.(text);
317 } else if (
318 type === "download" &&
319 typeof id === "string" &&
320 typeof fileName === "string" &&
321 typeof sizeBytes === "number"
322 ) {
323 events.onDownload?.({
324 fileName,
325 sizeBytes,
326 url: previewStreamDownloadUrl(target, id),
327 });
328 } else if (type === "viewport" && typeof width === "number" && typeof height === "number") {
329 failures = 0;
330 events.onViewport({ width, height });
331 } else if (
332 type === "probe" &&
333 typeof x === "number" &&
334 typeof y === "number" &&
335 typeof editable === "boolean"
336 ) {
337 events.onProbe?.({ x, y, editable });
338 } else if (
339 type === "control" &&
340 typeof canOperate === "boolean" &&
341 (controller === "agent" ||
342 controller === "you" ||
343 controller === "another-viewer" ||
344 controller === "unclaimed") &&
345 typeof generation === "number" &&
346 isPreviewStreamDialog(dialog)
347 ) {
348 const nextControl: PreviewStreamControl = {
349 canOperate,
350 controller,
351 generation,
352 dialog,
353 };
354 control = nextControl;
355 events.onControl?.(nextControl);
356 }
357 });
358 ws.addEventListener("close", (event) => {
359 if (socket !== ws) return;
360 socket = null;
361 control = null;
362 if (opened) events.onConnectedChange(false);
363 if (stopped) return;
364 if (event.code === 4404) {
365 stopped = true;
366 events.onGone?.();
367 return;
368 }
369 if (event.code === PREVIEW_STREAM_HOST_SETUP_CLOSE_CODE) {
370 stopped = true;
371 events.onHostSetup?.(decodeHostSetup(event.reason));
372 return;
373 }
374 // Rejected upgrades surface as 1006 before open for both cookies and tickets.
375 if (event.code === 1008 || event.code === 4401 || (!opened && event.code === 1006)) {
376 stopped = true;
377 events.onUnauthorized();
378 return;
379 }
380 retryTimer = setTimeout(connect, Math.min(500 * 2 ** failures++, 10_000));
381 });
382 ws.addEventListener("error", () => ws.close());
383 };
384
385 connect();
386
387 return {
388 send: (input) => {
389 if (socket?.readyState !== WebSocket.OPEN) return false;
390 if (!control?.canOperate) return false;
391 if (input.type !== "takeControl" && control.controller !== "you") return false;
392 socket.send(JSON.stringify(input));
393 return true;
394 },
395 stop: () => {
396 stopped = true;
397 if (retryTimer !== null) clearTimeout(retryTimer);
398 const ws = socket;
399 socket = null;
400 control = null;
401 ws?.close();
402 },
403 };
404}
405
406export interface PreviewFramePainter {
407 readonly paint: (jpeg: ArrayBuffer) => void;
408 readonly stop: () => void;
409}
410
411/** One decode at a time, keeping only the latest waiting frame for slow viewers. */
412export function createPreviewFramePainter(
413 canvas: HTMLCanvasElement,
414 onPainted?: () => void,
415): PreviewFramePainter {
416 const context = canvas.getContext("2d");
417 let stopped = false;
418 let decoding = false;
419 let waiting: ArrayBuffer | null = null;
420 const draw = (jpeg: ArrayBuffer) => {
421 decoding = true;
422 void createImageBitmap(new Blob([jpeg], { type: "image/jpeg" }))
423 .then((bitmap) => {
424 if (stopped || !context) {
425 bitmap.close();
426 return;
427 }
428 if (canvas.width !== bitmap.width || canvas.height !== bitmap.height) {
429 canvas.width = bitmap.width;
430 canvas.height = bitmap.height;
431 }
432 context.drawImage(bitmap, 0, 0);
433 bitmap.close();
434 onPainted?.();
435 })
436 // A frame that fails to decode is skipped; the next one repaints.
437 .catch(() => undefined)
438 .finally(() => {
439 decoding = false;
440 const next = waiting;
441 waiting = null;
442 if (next && !stopped) draw(next);
443 });
444 };
445 return {
446 paint: (jpeg) => {
447 if (stopped) return;
448 if (decoding) waiting = jpeg;
449 else draw(jpeg);
450 },
451 stop: () => {
452 stopped = true;
453 waiting = null;
454 },
455 };
456}
457
458/** A malformed reason still stops the viewer and offers the usual command. */
459const decodeHostSetup = (reason: string): PreviewStreamHostSetup => {
460 try {
461 const value = JSON.parse(reason) as Partial<PreviewStreamHostSetup>;
462 if (
463 (value.need === "sandbox" || value.need === "libraries") &&
464 typeof value.command === "string"
465 ) {
466 return { need: value.need, command: value.command };
467 }
468 } catch {}
469 return { need: "sandbox", command: "sudo npx t3 browser setup" };
470};
471
472/** What a viewer tells the person; `command` is shown beside it, ready to copy. */
473export const previewStreamHostSetupMessage = (setup: PreviewStreamHostSetup) =>
474 setup.need === "sandbox"
475 ? "This server's host blocks the sandbox its browser runs in. Run this once on the host, then try again:"
476 : "This server's host is missing libraries its browser needs. Run this once on the host, then try again:";
packages/client-runtime/src/rpc/client.test.ts-153
...@@ -2,9 +2,6 @@ import {...@@ -2,9 +2,6 @@ import {
2 DEFAULT_SERVER_SETTINGS,2 DEFAULT_SERVER_SETTINGS,
3 EnvironmentAuthorizationError,3 EnvironmentAuthorizationError,
4 EnvironmentId,4 EnvironmentId,
5 PreviewTabId,
6 ThreadId,
7 type PreviewAutomationStreamEvent,
8 type RelayClientInstallProgressEvent,5 type RelayClientInstallProgressEvent,
9 type ServerConfigStreamEvent,6 type ServerConfigStreamEvent,
10 type ServerLifecycleStreamEvent,7 type ServerLifecycleStreamEvent,
...@@ -92,156 +89,6 @@ const makeHarness = Effect.fn("TestEnvironmentRpc.makeHarness")(function* () {...@@ -92,156 +89,6 @@ const makeHarness = Effect.fn("TestEnvironmentRpc.makeHarness")(function* () {
92});89});
9390
94describe("environment RPC", () => {91describe("environment RPC", () => {
95 it.effect("registers a fresh preview host after completion without replaying requests", () =>
96 Effect.gen(function* () {
97 const firstCompleted = yield* Deferred.make<void>();
98 const reconnected = yield* Deferred.make<void>();
99 const requests: string[] = [];
100 const connections: string[] = [];
101 let attempts = 0;
102 const client = {
103 [WS_METHODS.previewAutomationConnect]: () =>
104 Stream.suspend(() => {
105 attempts += 1;
106 const connected: PreviewAutomationStreamEvent = {
107 type: "connected",
108 connectionId: `connection-${attempts}`,
109 };
110 return attempts === 1
111 ? Stream.make(connected, {
112 type: "request",
113 connectionId: connected.connectionId,
114 request: {
115 requestId: "timed-out-action",
116 operation: "click",
117 threadId: ThreadId.make("thread-1"),
118 tabId: PreviewTabId.make("tab-1"),
119 input: {},
120 timeoutMs: 1_000,
121 },
122 } satisfies PreviewAutomationStreamEvent).pipe(
123 Stream.ensuring(Deferred.succeed(firstCompleted, undefined)),
124 )
125 : Stream.succeed(connected).pipe(Stream.concat(Stream.never));
126 }),
127 } as unknown as WsRpcProtocolClient;
128 const { activeSession, supervisor } = yield* makeHarness();
129 yield* SubscriptionRef.set(activeSession, Option.some(session(client)));
130 const consumer = yield* subscribe(WS_METHODS.previewAutomationConnect, {
131 clientId: "preview-host",
132 environmentId: TARGET.environmentId,
133 }).pipe(
134 Stream.runForEach((event) => {
135 if (event.type === "request") {
136 requests.push(event.request.requestId);
137 return Effect.void;
138 }
139 connections.push(event.connectionId);
140 return connections.length === 2 ? Deferred.succeed(reconnected, undefined) : Effect.void;
141 }),
142 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
143 Effect.forkChild,
144 );
145 yield* Deferred.await(firstCompleted);
146 yield* TestClock.adjust(999);
147 expect(attempts).toBe(1);
148 yield* TestClock.adjust(1);
149 expect(attempts).toBe(2);
150 yield* Deferred.await(reconnected);
151 expect(connections).toEqual(["connection-1", "connection-2"]);
152 expect(requests).toEqual(["timed-out-action"]);
153 yield* Fiber.interrupt(consumer);
154 }),
155 );
156
157 it.effect("does not re-register an unmounted preview host during the recovery delay", () =>
158 Effect.gen(function* () {
159 const completed = yield* Deferred.make<void>();
160 let attempts = 0;
161 const client = {
162 [WS_METHODS.previewAutomationConnect]: () =>
163 Stream.suspend(() => {
164 attempts += 1;
165 return Stream.empty.pipe(Stream.ensuring(Deferred.succeed(completed, undefined)));
166 }),
167 } as unknown as WsRpcProtocolClient;
168 const { activeSession, supervisor } = yield* makeHarness();
169 yield* SubscriptionRef.set(activeSession, Option.some(session(client)));
170 const consumer = yield* subscribe(WS_METHODS.previewAutomationConnect, {
171 clientId: "preview-host",
172 environmentId: TARGET.environmentId,
173 }).pipe(
174 Stream.runDrain,
175 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
176 Effect.forkChild,
177 );
178 yield* Deferred.await(completed);
179 yield* Fiber.interrupt(consumer);
180 yield* TestClock.adjust(10_000);
181 expect(attempts).toBe(1);
182 }),
183 );
184
185 it.effect.each(["completion", "transport failure"] as const)(
186 "keeps preview recovery tied to the active session after %s",
187 (reason) =>
188 Effect.gen(function* () {
189 const completed = yield* Deferred.make<void>();
190 const nextConnected = yield* Deferred.make<void>();
191 let oldAttempts = 0;
192 let nextAttempts = 0;
193 const firstClient = {
194 [WS_METHODS.previewAutomationConnect]: () =>
195 Stream.suspend(() => {
196 oldAttempts += 1;
197 return (
198 reason === "completion"
199 ? Stream.empty
200 : Stream.fail(
201 new RpcClientError.RpcClientError({
202 reason: new RpcClientError.RpcClientDefect({
203 message: "socket closed",
204 cause: new Error("socket closed"),
205 }),
206 }),
207 )
208 ).pipe(Stream.ensuring(Deferred.succeed(completed, undefined)));
209 }),
210 } as unknown as WsRpcProtocolClient;
211 const nextClient = {
212 [WS_METHODS.previewAutomationConnect]: () =>
213 Stream.suspend(() => {
214 nextAttempts += 1;
215 return Stream.fromEffect(Deferred.succeed(nextConnected, undefined)).pipe(
216 Stream.drain,
217 Stream.concat(Stream.never),
218 );
219 }),
220 } as unknown as WsRpcProtocolClient;
221 const { activeSession, supervisor } = yield* makeHarness();
222 yield* SubscriptionRef.set(activeSession, Option.some(session(firstClient)));
223 const consumer = yield* subscribe(WS_METHODS.previewAutomationConnect, {
224 clientId: "preview-host",
225 environmentId: TARGET.environmentId,
226 }).pipe(
227 Stream.runDrain,
228 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
229 Effect.forkChild,
230 );
231 yield* Deferred.await(completed);
232 if (reason === "transport failure") {
233 yield* TestClock.adjust(10_000);
234 expect(oldAttempts).toBe(1);
235 }
236 yield* SubscriptionRef.set(activeSession, Option.some(session(nextClient)));
237 yield* Deferred.await(nextConnected);
238 yield* TestClock.adjust(10_000);
239 expect(oldAttempts).toBe(1);
240 expect(nextAttempts).toBe(1);
241 yield* Fiber.interrupt(consumer);
242 }),
243 );
244
245 it.effect("reuses the session config stream instead of opening a duplicate subscription", () =>92 it.effect("reuses the session config stream instead of opening a duplicate subscription", () =>
246 Effect.gen(function* () {93 Effect.gen(function* () {
247 const event: ServerConfigStreamEvent = {94 const event: ServerConfigStreamEvent = {
packages/client-runtime/src/rpc/client.ts+1-10
...@@ -9,7 +9,6 @@ import * as Duration from "effect/Duration";...@@ -9,7 +9,6 @@ import * as Duration from "effect/Duration";
9import * as Effect from "effect/Effect";9import * as Effect from "effect/Effect";
10import * as Option from "effect/Option";10import * as Option from "effect/Option";
11import * as Schema from "effect/Schema";11import * as Schema from "effect/Schema";
12import * as Schedule from "effect/Schedule";
13import * as Stream from "effect/Stream";12import * as Stream from "effect/Stream";
14import * as SubscriptionRef from "effect/SubscriptionRef";13import * as SubscriptionRef from "effect/SubscriptionRef";
15import { RpcClientError } from "effect/rpc";14import { RpcClientError } from "effect/rpc";
...@@ -61,7 +60,6 @@ export type EnvironmentSubscriptionRpcTag =...@@ -61,7 +60,6 @@ export type EnvironmentSubscriptionRpcTag =
61 | typeof WS_METHODS.subscribeDeviceState60 | typeof WS_METHODS.subscribeDeviceState
62 | typeof WS_METHODS.subscribeResourceTelemetry61 | typeof WS_METHODS.subscribeResourceTelemetry
63 | typeof WS_METHODS.pullRequestsSubscribeRefreshes62 | typeof WS_METHODS.pullRequestsSubscribeRefreshes
64 | typeof WS_METHODS.previewAutomationConnect
65 | typeof WS_METHODS.subscribeVcsStatus63 | typeof WS_METHODS.subscribeVcsStatus
66 | typeof WS_METHODS.subscribeWorktreeSetup64 | typeof WS_METHODS.subscribeWorktreeSetup
67 | typeof WS_METHODS.subscribeProjectClones65 | typeof WS_METHODS.subscribeProjectClones
...@@ -272,14 +270,7 @@ function subscribeDynamicMapped<TTag extends EnvironmentSubscriptionRpcTag, A>(...@@ -272,14 +270,7 @@ function subscribeDynamicMapped<TTag extends EnvironmentSubscriptionRpcTag, A>(
272 }),270 }),
273 ),271 ),
274 );272 );
275 // An evicted preview host completes its registration stream.273 return stream.pipe(Stream.ensuring(completeObservation));
276 // Re-register only after completion; failures still follow the
277 // session recovery policy and browser actions are never replayed.
278 return (
279 tag === WS_METHODS.previewAutomationConnect
280 ? stream.pipe(Stream.repeat(Schedule.spaced("1 second")))
281 : stream
282 ).pipe(Stream.ensuring(completeObservation));
283 }),274 }),
284 ).pipe(275 ).pipe(
285 Stream.tapCause((cause) =>276 Stream.tapCause((cause) =>
packages/client-runtime/src/state/preview.test.ts deleted-18
...@@ -1,18 +0,0 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { previewAutomationHostFocusConcurrencyKey } from "./preview.ts";
4
5describe("preview state commands", () => {
6 it("keeps focus updates from replacement host connections independent", () => {
7 const first = previewAutomationHostFocusConcurrencyKey({
8 environmentId: "environment-1",
9 input: { clientId: "client-1", connectionId: "connection-1" },
10 });
11 const replacement = previewAutomationHostFocusConcurrencyKey({
12 environmentId: "environment-1",
13 input: { clientId: "client-1", connectionId: "connection-2" },
14 });
15
16 expect(first).not.toBe(replacement);
17 });
18});
packages/client-runtime/src/state/preview.ts+10-36
...@@ -9,20 +9,11 @@ import {...@@ -9,20 +9,11 @@ import {
9 createEnvironmentRpcSubscriptionAtomFamily,9 createEnvironmentRpcSubscriptionAtomFamily,
10} from "./runtime.ts";10} from "./runtime.ts";
1111
12export const previewAutomationHostFocusConcurrencyKey = (value: {
13 readonly environmentId: string;
14 readonly input: {
15 readonly clientId: string;
16 readonly connectionId: string;
17 };
18}): string => JSON.stringify([value.environmentId, value.input.clientId, value.input.connectionId]);
19
20export function createPreviewEnvironmentAtoms<R, E>(12export function createPreviewEnvironmentAtoms<R, E>(
21 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,13 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,
22) {14) {
23 const lifecycleScheduler = createAtomCommandScheduler();15 const lifecycleScheduler = createAtomCommandScheduler();
24 const statusScheduler = createAtomCommandScheduler();16 const statusScheduler = createAtomCommandScheduler();
25 const automationScheduler = createAtomCommandScheduler();
26 const lifecycleConcurrency = {17 const lifecycleConcurrency = {
27 mode: "serial" as const,18 mode: "serial" as const,
28 key: ({ environmentId, input }: { environmentId: string; input: { threadId: string } }) =>19 key: ({ environmentId, input }: { environmentId: string; input: { threadId: string } }) =>
...@@ -45,14 +36,6 @@ export function createPreviewEnvironmentAtoms<R, E>(...@@ -45,14 +36,6 @@ export function createPreviewEnvironmentAtoms<R, E>(
45 // unmounted projects stop contributing probe candidates on the server.36 // unmounted projects stop contributing probe candidates on the server.
46 idleTtlMs: 0,37 idleTtlMs: 0,
47 }),38 }),
48 automationRequests: createEnvironmentRpcSubscriptionAtomFamily(runtime, {
49 label: "environment-data:preview:automation-requests",
50 tag: WS_METHODS.previewAutomationConnect,
51 // Automation requests are commands, not cached query data. Dispose the
52 // stream immediately with its owner so stale requests cannot replay when
53 // a thread remounts and the server can clear disconnected hosts promptly.
54 idleTtlMs: 0,
55 }),
56 open: createEnvironmentRpcCommand(runtime, {39 open: createEnvironmentRpcCommand(runtime, {
57 label: "environment-data:preview:open",40 label: "environment-data:preview:open",
58 tag: WS_METHODS.previewOpen,41 tag: WS_METHODS.previewOpen,
...@@ -71,6 +54,12 @@ export function createPreviewEnvironmentAtoms<R, E>(...@@ -71,6 +54,12 @@ export function createPreviewEnvironmentAtoms<R, E>(
71 scheduler: lifecycleScheduler,54 scheduler: lifecycleScheduler,
72 concurrency: lifecycleConcurrency,55 concurrency: lifecycleConcurrency,
73 }),56 }),
57 adjust: createEnvironmentRpcCommand(runtime, {
58 label: "environment-data:preview:adjust",
59 tag: WS_METHODS.previewAdjust,
60 scheduler: lifecycleScheduler,
61 concurrency: lifecycleConcurrency,
62 }),
74 refresh: createEnvironmentRpcCommand(runtime, {63 refresh: createEnvironmentRpcCommand(runtime, {
75 label: "environment-data:preview:refresh",64 label: "environment-data:preview:refresh",
76 tag: WS_METHODS.previewRefresh,65 tag: WS_METHODS.previewRefresh,
...@@ -83,6 +72,10 @@ export function createPreviewEnvironmentAtoms<R, E>(...@@ -83,6 +72,10 @@ export function createPreviewEnvironmentAtoms<R, E>(
83 scheduler: lifecycleScheduler,72 scheduler: lifecycleScheduler,
84 concurrency: lifecycleConcurrency,73 concurrency: lifecycleConcurrency,
85 }),74 }),
75 clearProfile: createEnvironmentRpcCommand(runtime, {
76 label: "environment-data:preview:clear-profile",
77 tag: WS_METHODS.previewClearProfile,
78 }),
86 reportStatus: createEnvironmentRpcCommand(runtime, {79 reportStatus: createEnvironmentRpcCommand(runtime, {
87 label: "environment-data:preview:report-status",80 label: "environment-data:preview:report-status",
88 tag: WS_METHODS.previewReportStatus,81 tag: WS_METHODS.previewReportStatus,
...@@ -93,24 +86,5 @@ export function createPreviewEnvironmentAtoms<R, E>(...@@ -93,24 +86,5 @@ export function createPreviewEnvironmentAtoms<R, E>(
93 JSON.stringify([environmentId, input.threadId, input.tabId]),86 JSON.stringify([environmentId, input.threadId, input.tabId]),
94 },87 },
95 }),88 }),
96 respondToAutomation: createEnvironmentRpcCommand(runtime, {
97 label: "environment-data:preview:automation-respond",
98 tag: WS_METHODS.previewAutomationRespond,
99 scheduler: automationScheduler,
100 concurrency: {
101 mode: "singleFlight",
102 key: ({ environmentId, input }) =>
103 JSON.stringify([environmentId, input.connectionId, input.requestId]),
104 },
105 }),
106 focusAutomationHost: createEnvironmentRpcCommand(runtime, {
107 label: "environment-data:preview:automation-focus-host",
108 tag: WS_METHODS.previewAutomationFocusHost,
109 scheduler: automationScheduler,
110 concurrency: {
111 mode: "latest",
112 key: previewAutomationHostFocusConcurrencyKey,
113 },
114 }),
115 };89 };
116}90}
packages/contracts/src/desktopBootstrap.ts+4
...@@ -24,6 +24,10 @@ export const DesktopBackendBootstrap = Schema.Struct({...@@ -24,6 +24,10 @@ export const DesktopBackendBootstrap = Schema.Struct({
24 otlpLogsUrl: Schema.optional(Schema.String),24 otlpLogsUrl: Schema.optional(Schema.String),
25 desktopTelemetryFd: Schema.optionalKey(PositiveInt),25 desktopTelemetryFd: Schema.optionalKey(PositiveInt),
26 desktopTelemetryControlFd: Schema.optionalKey(PositiveInt),26 desktopTelemetryControlFd: Schema.optionalKey(PositiveInt),
27 /** Desktop -> server: the desktop's browser tabs, as newline-delimited JSON. */
28 desktopBrowserFd: Schema.optionalKey(PositiveInt),
29 /** Server -> desktop: commands for those tabs. */
30 desktopBrowserControlFd: Schema.optionalKey(PositiveInt),
27 resourceMonitorPath: Schema.optionalKey(TrimmedNonEmptyString),31 resourceMonitorPath: Schema.optionalKey(TrimmedNonEmptyString),
28});32});
2933
packages/contracts/src/desktopBrowser.ts created+46
...@@ -0,0 +1,46 @@
1import * as Schema from "effect/Schema";
2
3import { TrimmedNonEmptyString } from "./baseSchemas.ts";
4
5/**
6 * The desktop app renders browser tabs for the server it launched, and the
7 * server drives them with the same engine as its headless tabs. Messages
8 * travel as newline-delimited JSON over two bootstrap file descriptors, so
9 * only that one server can reach the desktop's pages; nothing listens on a port.
10 *
11 * Each tab carries one CDP connection, multiplexed by `tabId`. CDP frames pass
12 * through untouched; the desktop answers them with `CdpRelay`.
13 */
14
15const TabKey = {
16 threadId: TrimmedNonEmptyString,
17 tabId: TrimmedNonEmptyString,
18};
19
20/** Desktop -> server. */
21export const DesktopBrowserEvent = Schema.Union([
22 /** A desktop `<webview>` for this server tab is attached and can be driven. */
23 Schema.Struct({ type: Schema.Literal("attached"), ...TabKey }),
24 /** Its `<webview>` went away: closed, crashed, swapped, or devtools took the debugger. */
25 Schema.Struct({ type: Schema.Literal("detached"), ...TabKey }),
26 /** One CDP message from the tab's relay. */
27 Schema.Struct({ type: Schema.Literal("cdp"), ...TabKey, message: Schema.String }),
28]);
29export type DesktopBrowserEvent = typeof DesktopBrowserEvent.Type;
30
31/** Server -> desktop. */
32export const DesktopBrowserCommand = Schema.Union([
33 /** One CDP message for the tab's relay. */
34 Schema.Struct({ type: Schema.Literal("cdp"), ...TabKey, message: Schema.String }),
35 /** The server stopped driving this tab, so the relay can drop its sessions. */
36 Schema.Struct({ type: Schema.Literal("release"), ...TabKey }),
37 /** Where an agent action is about to land, so the desktop draws its cursor there. */
38 Schema.Struct({
39 type: Schema.Literal("pointer"),
40 ...TabKey,
41 phase: Schema.Literals(["move", "click"]),
42 x: Schema.Finite,
43 y: Schema.Finite,
44 }),
45]);
46export type DesktopBrowserCommand = typeof DesktopBrowserCommand.Type;
packages/contracts/src/environment.ts+4
...@@ -208,6 +208,10 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({...@@ -208,6 +208,10 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
208 desktop servers whose app predates the remote trigger, where clients208 desktop servers whose app predates the remote trigger, where clients
209 must keep telling the user to update the app on that machine. */209 must keep telling the user to update the app on that machine. */
210 desktopAppUpdate: Schema.optionalKey(Schema.Boolean),210 desktopAppUpdate: Schema.optionalKey(Schema.Boolean),
211 /** Server hosts preview tabs in its own headless Chromium (`runtime:
212 "server"`) and streams them over `/api/preview-stream`. Clients
213 without a local browser runtime open server tabs here. */
214 serverBrowser: Schema.optionalKey(Schema.Boolean),
211});215});
212export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type;216export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type;
213217
packages/contracts/src/index.ts+1
...@@ -9,6 +9,7 @@ export * from "./environment.ts";...@@ -9,6 +9,7 @@ export * from "./environment.ts";
9export * from "./environmentHttp.ts";9export * from "./environmentHttp.ts";
10export * from "./relayClient.ts";10export * from "./relayClient.ts";
11export * from "./desktopBootstrap.ts";11export * from "./desktopBootstrap.ts";
12export * from "./desktopBrowser.ts";
12export * from "./desktopAppActivation.ts";13export * from "./desktopAppActivation.ts";
13export * from "./remoteAccess.ts";14export * from "./remoteAccess.ts";
14export * from "./ipc.ts";15export * from "./ipc.ts";
packages/contracts/src/ipc.ts+12-56
...@@ -1,15 +1,5 @@...@@ -1,15 +1,5 @@
1import * as Schema from "effect/Schema";1import * as Schema from "effect/Schema";
22
3import {
4 PreviewAutomationClickInput,
5 PreviewAutomationEvaluateInput,
6 PreviewAutomationPressInput,
7 PreviewAutomationScrollInput,
8 PreviewAutomationSnapshot,
9 PreviewAutomationStatus,
10 PreviewAutomationTypeInput,
11 PreviewAutomationWaitForInput,
12} from "./previewAutomation.ts";
13import { SnapShotSource } from "./chatAttachment.ts";3import { SnapShotSource } from "./chatAttachment.ts";
14import { EnvironmentId, TrimmedNonEmptyString } from "./baseSchemas.ts";4import { EnvironmentId, TrimmedNonEmptyString } from "./baseSchemas.ts";
15import { BrowserProfileId } from "./browserProfile.ts";5import { BrowserProfileId } from "./browserProfile.ts";
...@@ -647,12 +637,6 @@ export const DesktopPreviewTabIdSchema = Schema.String.check(Schema.isTrimmed())...@@ -647,12 +637,6 @@ export const DesktopPreviewTabIdSchema = Schema.String.check(Schema.isTrimmed())
647 Schema.isNonEmpty(),637 Schema.isNonEmpty(),
648);638);
649639
650export const DesktopPreviewAutomationStatusSchema = Schema.Struct({
651 ...PreviewAutomationStatus.fields,
652 tabId: Schema.NullOr(DesktopPreviewTabIdSchema),
653});
654export type DesktopPreviewAutomationStatus = typeof DesktopPreviewAutomationStatusSchema.Type;
655
656export interface DesktopPreviewPointerEvent {640export interface DesktopPreviewPointerEvent {
657 tabId: string;641 tabId: string;
658 phase: "move" | "click";642 phase: "move" | "click";
...@@ -1026,11 +1010,16 @@ export const DesktopPreviewCreateTabInputSchema = Schema.Struct({...@@ -1026,11 +1010,16 @@ export const DesktopPreviewCreateTabInputSchema = Schema.Struct({
1026 tabId: DesktopPreviewTabIdSchema,1010 tabId: DesktopPreviewTabIdSchema,
1027 zoomFactor: Schema.optional(Schema.Number.check(Schema.isGreaterThan(0))),1011 zoomFactor: Schema.optional(Schema.Number.check(Schema.isGreaterThan(0))),
1028 colorScheme: Schema.optional(DesktopPreviewColorSchemeSchema),1012 colorScheme: Schema.optional(DesktopPreviewColorSchemeSchema),
1013 serverTab: Schema.optional(
1014 Schema.Struct({ threadId: TrimmedNonEmptyString, tabId: TrimmedNonEmptyString }),
1015 ),
1029});1016});
10301017
1031export interface DesktopPreviewTabDefaults {1018export interface DesktopPreviewTabDefaults {
1032 readonly zoomFactor?: number | undefined;1019 readonly zoomFactor?: number | undefined;
1033 readonly colorScheme?: DesktopPreviewColorScheme | undefined;1020 readonly colorScheme?: DesktopPreviewColorScheme | undefined;
1021 /** A tab of the desktop's own server: the server drives it through the desktop browser channel. */
1022 readonly serverTab?: { readonly threadId: string; readonly tabId: string } | undefined;
1034}1023}
10351024
1036export const DesktopPreviewRegisterWebviewInputSchema = Schema.Struct({1025export const DesktopPreviewRegisterWebviewInputSchema = Schema.Struct({
...@@ -1065,6 +1054,11 @@ export const DesktopPreviewSetColorSchemeInputSchema = Schema.Struct({...@@ -1065,6 +1054,11 @@ export const DesktopPreviewSetColorSchemeInputSchema = Schema.Struct({
1065 colorScheme: DesktopPreviewColorSchemeSchema,1054 colorScheme: DesktopPreviewColorSchemeSchema,
1066});1055});
10671056
1057export const DesktopPreviewSetZoomFactorInputSchema = Schema.Struct({
1058 tabId: DesktopPreviewTabIdSchema,
1059 zoomFactor: Schema.Number.check(Schema.isGreaterThan(0)),
1060});
1061
1068export const DesktopPreviewSetAudioMutedInputSchema = Schema.Struct({1062export const DesktopPreviewSetAudioMutedInputSchema = Schema.Struct({
1069 tabId: DesktopPreviewTabIdSchema,1063 tabId: DesktopPreviewTabIdSchema,
1070 audioMuted: Schema.Boolean,1064 audioMuted: Schema.Boolean,
...@@ -1084,36 +1078,6 @@ export const DesktopPreviewRecordingSaveInputSchema = Schema.Struct({...@@ -1084,36 +1078,6 @@ export const DesktopPreviewRecordingSaveInputSchema = Schema.Struct({
1084 data: Schema.Uint8Array,1078 data: Schema.Uint8Array,
1085});1079});
10861080
1087export const DesktopPreviewAutomationClickInputSchema = Schema.Struct({
1088 tabId: DesktopPreviewTabIdSchema,
1089 input: PreviewAutomationClickInput,
1090});
1091
1092export const DesktopPreviewAutomationTypeInputSchema = Schema.Struct({
1093 tabId: DesktopPreviewTabIdSchema,
1094 input: PreviewAutomationTypeInput,
1095});
1096
1097export const DesktopPreviewAutomationPressInputSchema = Schema.Struct({
1098 tabId: DesktopPreviewTabIdSchema,
1099 input: PreviewAutomationPressInput,
1100});
1101
1102export const DesktopPreviewAutomationScrollInputSchema = Schema.Struct({
1103 tabId: DesktopPreviewTabIdSchema,
1104 input: PreviewAutomationScrollInput,
1105});
1106
1107export const DesktopPreviewAutomationEvaluateInputSchema = Schema.Struct({
1108 tabId: DesktopPreviewTabIdSchema,
1109 input: PreviewAutomationEvaluateInput,
1110});
1111
1112export const DesktopPreviewAutomationWaitForInputSchema = Schema.Struct({
1113 tabId: DesktopPreviewTabIdSchema,
1114 input: PreviewAutomationWaitForInput,
1115});
1116
1117/**1081/**
1118 * A System Settings pane the app can deep-link to. The identifier crosses IPC1082 * A System Settings pane the app can deep-link to. The identifier crosses IPC
1119 * rather than a URL, so the renderer can only reach these known destinations.1083 * rather than a URL, so the renderer can only reach these known destinations.
...@@ -1272,6 +1236,8 @@ export interface DesktopPreviewBridge {...@@ -1272,6 +1236,8 @@ export interface DesktopPreviewBridge {
1272 zoomIn: (tabId: string) => Promise<void>;1236 zoomIn: (tabId: string) => Promise<void>;
1273 zoomOut: (tabId: string) => Promise<void>;1237 zoomOut: (tabId: string) => Promise<void>;
1274 resetZoom: (tabId: string) => Promise<void>;1238 resetZoom: (tabId: string) => Promise<void>;
1239 /** Sets a tab's zoom to the factor its environment published, for tabs the server drives. */
1240 setZoomFactor: (tabId: string, zoomFactor: number) => Promise<void>;
1275 /** Reload bypassing the HTTP cache. */1241 /** Reload bypassing the HTTP cache. */
1276 hardReload: (tabId: string) => Promise<void>;1242 hardReload: (tabId: string) => Promise<void>;
1277 /**1243 /**
...@@ -1337,16 +1303,6 @@ export interface DesktopPreviewBridge {...@@ -1337,16 +1303,6 @@ export interface DesktopPreviewBridge {
1337 ) => Promise<DesktopPreviewRecordingArtifact>;1303 ) => Promise<DesktopPreviewRecordingArtifact>;
1338 onFrame: (listener: (frame: DesktopPreviewRecordingFrame) => void) => () => void;1304 onFrame: (listener: (frame: DesktopPreviewRecordingFrame) => void) => () => void;
1339 };1305 };
1340 automation: {
1341 status: (tabId: string) => Promise<DesktopPreviewAutomationStatus>;
1342 snapshot: (tabId: string) => Promise<PreviewAutomationSnapshot>;
1343 click: (tabId: string, input: PreviewAutomationClickInput) => Promise<void>;
1344 type: (tabId: string, input: PreviewAutomationTypeInput) => Promise<void>;
1345 press: (tabId: string, input: PreviewAutomationPressInput) => Promise<void>;
1346 scroll: (tabId: string, input: PreviewAutomationScrollInput) => Promise<void>;
1347 evaluate: (tabId: string, input: PreviewAutomationEvaluateInput) => Promise<unknown>;
1348 waitFor: (tabId: string, input: PreviewAutomationWaitForInput) => Promise<void>;
1349 };
1350 onStateChange: (listener: (tabId: string, state: DesktopPreviewTabState) => void) => () => void;1306 onStateChange: (listener: (tabId: string, state: DesktopPreviewTabState) => void) => () => void;
1351 onPointerEvent: (listener: (event: DesktopPreviewPointerEvent) => void) => () => void;1307 onPointerEvent: (listener: (event: DesktopPreviewPointerEvent) => void) => () => void;
1352}1308}
packages/contracts/src/preview.ts+89-1
...@@ -162,6 +162,29 @@ export const PreviewNavStatus = Schema.Union([...@@ -162,6 +162,29 @@ export const PreviewNavStatus = Schema.Union([
162]);162]);
163export type PreviewNavStatus = typeof PreviewNavStatus.Type;163export type PreviewNavStatus = typeof PreviewNavStatus.Type;
164164
165/**
166 * Where a tab's page runs. `desktop` is an Electron <webview> owned by one
167 * desktop client; `server` is headless Chromium owned by the environment
168 * server, viewed by any client through `/api/preview-stream` and driven by
169 * agents with no client attached. Absent means `desktop`.
170 */
171export const PreviewRuntime = Schema.Literals(["desktop", "server"]);
172export type PreviewRuntime = typeof PreviewRuntime.Type;
173
174/**
175 * Host setup the server's browser is missing, sent as JSON in the reason when
176 * the preview stream closes with code 4503. Fixing it needs the host's
177 * operator, so viewers stop retrying and show `command`, the one line that
178 * fixes it. Close reasons are capped at 123 bytes, which this fits.
179 */
180export const PreviewStreamHostSetup = Schema.Struct({
181 need: Schema.Literals(["sandbox", "libraries"]),
182 /** For example `sudo npx t3 browser setup`, matching how the server was launched. */
183 command: Schema.String,
184});
185export type PreviewStreamHostSetup = typeof PreviewStreamHostSetup.Type;
186export const PREVIEW_STREAM_HOST_SETUP_CLOSE_CODE = 4503;
187
165export const PreviewSessionSnapshot = Schema.Struct({188export const PreviewSessionSnapshot = Schema.Struct({
166 threadId: TrimmedNonEmptyString,189 threadId: TrimmedNonEmptyString,
167 tabId: PreviewTabId,190 tabId: PreviewTabId,
...@@ -170,12 +193,23 @@ export const PreviewSessionSnapshot = Schema.Struct({...@@ -170,12 +193,23 @@ export const PreviewSessionSnapshot = Schema.Struct({
170 canGoForward: Schema.Boolean,193 canGoForward: Schema.Boolean,
171 /** Missing snapshots from older servers are treated as fill-panel mode. */194 /** Missing snapshots from older servers are treated as fill-panel mode. */
172 viewport: Schema.optional(PreviewViewportSetting),195 viewport: Schema.optional(PreviewViewportSetting),
196 /** Server tabs only; the desktop keeps its own tabs' appearance. Absent means `system`. */
197 colorScheme: Schema.optional(PreviewAppearancePreference),
198 /** Server tabs only. Absent means 1. */
199 zoomFactor: Schema.optional(PreviewZoomFactor),
173 /**200 /**
174 * Browser profile the tab's Chromium partition is derived from. Fixed at201 * Browser profile the tab's Chromium partition is derived from. Fixed at
175 * open: Electron only honours a `<webview>`'s partition before attach, so202 * open: Electron only honours a `<webview>`'s partition before attach, so
176 * switching would require tearing the guest down and losing page state.203 * switching would require tearing the guest down and losing page state.
177 */204 */
178 profileId: Schema.optional(BrowserProfileId),205 profileId: Schema.optional(BrowserProfileId),
206 runtime: Schema.optional(PreviewRuntime),
207 /** Authenticated provider session owning an isolated server tab. */
208 automationOwner: Schema.optional(Schema.String),
209 /** An agent opened this tab and asked to show it, so viewers float it. */
210 reveal: Schema.optional(Schema.Boolean),
211 /** A fresh presentation request, including whether it overrides automatic-floating settings. */
212 revealRequest: Schema.optional(Schema.Struct({ id: Schema.String, force: Schema.Boolean })),
179 updatedAt: Schema.String,213 updatedAt: Schema.String,
180});214});
181export type PreviewSessionSnapshot = typeof PreviewSessionSnapshot.Type;215export type PreviewSessionSnapshot = typeof PreviewSessionSnapshot.Type;
...@@ -193,6 +227,10 @@ export const PreviewOpenInput = Schema.Struct({...@@ -193,6 +227,10 @@ export const PreviewOpenInput = Schema.Struct({
193 viewport: Schema.optional(PreviewViewportSetting),227 viewport: Schema.optional(PreviewViewportSetting),
194 /** Omit to open under the client's configured default profile. */228 /** Omit to open under the client's configured default profile. */
195 profileId: Schema.optional(BrowserProfileId),229 profileId: Schema.optional(BrowserProfileId),
230 /** Omit for a desktop tab. `server` requires the `serverBrowser` capability. */
231 runtime: Schema.optional(PreviewRuntime),
232 /** Set by agent opens that should float for viewers; see the snapshot field. */
233 reveal: Schema.optional(Schema.Boolean),
196});234});
197export type PreviewOpenInput = typeof PreviewOpenInput.Type;235export type PreviewOpenInput = typeof PreviewOpenInput.Type;
198236
...@@ -226,12 +264,33 @@ export const PreviewResizeInput = Schema.Struct({...@@ -226,12 +264,33 @@ export const PreviewResizeInput = Schema.Struct({
226});264});
227export type PreviewResizeInput = typeof PreviewResizeInput.Type;265export type PreviewResizeInput = typeof PreviewResizeInput.Type;
228266
267/**
268 * Changes how a server tab renders, or clears its profile's site data, for any
269 * client. Unlike page input, these need no control of the tab.
270 */
271export const PreviewAdjustInput = Schema.Struct({
272 threadId: ThreadId,
273 tabId: PreviewTabId,
274 colorScheme: Schema.optional(PreviewAppearancePreference),
275 zoomFactor: Schema.optional(PreviewZoomFactor),
276 /** Reloads the page past its cache, as Chrome's Shift+Reload does. */
277 hardReload: Schema.optional(Schema.Boolean),
278 /** Clears the tab's profile's cookies or HTTP cache; its other storage stays. */
279 clear: Schema.optional(Schema.Literals(["cookies", "cache"])),
280});
281export type PreviewAdjustInput = typeof PreviewAdjustInput.Type;
282
229export const PreviewCloseInput = Schema.Struct({283export const PreviewCloseInput = Schema.Struct({
230 threadId: ThreadId,284 threadId: ThreadId,
231 tabId: Schema.optional(PreviewTabId),285 tabId: Schema.optional(PreviewTabId),
232});286});
233export type PreviewCloseInput = typeof PreviewCloseInput.Type;287export type PreviewCloseInput = typeof PreviewCloseInput.Type;
234288
289export const PreviewClearProfileInput = Schema.Struct({
290 profileId: BrowserProfileId,
291});
292export type PreviewClearProfileInput = typeof PreviewClearProfileInput.Type;
293
235export const PreviewListInput = Schema.Struct({294export const PreviewListInput = Schema.Struct({
236 threadId: ThreadId,295 threadId: ThreadId,
237});296});
...@@ -272,6 +331,13 @@ const PreviewResizedEvent = Schema.Struct({...@@ -272,6 +331,13 @@ const PreviewResizedEvent = Schema.Struct({
272 ...PreviewEventBaseSchema.fields,331 ...PreviewEventBaseSchema.fields,
273 type: Schema.Literal("resized"),332 type: Schema.Literal("resized"),
274 snapshot: PreviewSessionSnapshot,333 snapshot: PreviewSessionSnapshot,
334 /** A one-off action for the server's browser, such as a hard reload. */
335 request: Schema.optional(
336 Schema.Struct({
337 hardReload: Schema.optional(Schema.Boolean),
338 clear: Schema.optional(Schema.Literals(["cookies", "cache"])),
339 }),
340 ),
275});341});
276342
277const PreviewFailedEvent = Schema.Struct({343const PreviewFailedEvent = Schema.Struct({
...@@ -350,5 +416,27 @@ export class PreviewInvalidUrlError extends Schema.TaggedError<PreviewInvalidUrl...@@ -350,5 +416,27 @@ export class PreviewInvalidUrlError extends Schema.TaggedError<PreviewInvalidUrl
350 }416 }
351}417}
352418
353export const PreviewError = Schema.Union([PreviewSessionLookupError, PreviewInvalidUrlError]);419export class PreviewControlRequiredError extends Schema.TaggedError<PreviewControlRequiredError>()(
420 "PreviewControlRequiredError",
421 { tabId: Schema.String },
422) {
423 override get message() {
424 return "Take control of the server browser and use its viewer controls.";
425 }
426}
427
428export class PreviewClearProfileError extends Schema.TaggedError<PreviewClearProfileError>()(
429 "PreviewClearProfileError",
430 { profileId: Schema.String, cause: Schema.Defect() },
431) {
432 override get message() {
433 return "The environment could not delete this browser profile's data.";
434 }
435}
436
437export const PreviewError = Schema.Union([
438 PreviewSessionLookupError,
439 PreviewInvalidUrlError,
440 PreviewControlRequiredError,
441]);
354export type PreviewError = typeof PreviewError.Type;442export type PreviewError = typeof PreviewError.Type;
packages/contracts/src/previewAutomation.ts+224-17
...@@ -39,13 +39,22 @@ export const PREVIEW_AUTOMATION_V1_OPERATIONS = [...@@ -39,13 +39,22 @@ export const PREVIEW_AUTOMATION_V1_OPERATIONS = [
39] as const;39] as const;
4040
41/** Advertised by current desktop hosts for mixed-version routing. */41/** Advertised by current desktop hosts for mixed-version routing. */
42export const PREVIEW_AUTOMATION_OPERATIONS = [42const PREVIEW_AUTOMATION_OPERATIONS = [
43 ...PREVIEW_AUTOMATION_V1_OPERATIONS,43 ...PREVIEW_AUTOMATION_V1_OPERATIONS,
44 "resize",44 "resize",
45 "setColorScheme",45 "setColorScheme",
46] as const;46] as const;
4747
48export const PreviewAutomationOperation = Schema.Literals(PREVIEW_AUTOMATION_OPERATIONS);48export const PREVIEW_AUTOMATION_SERVER_OPERATIONS = [
49 ...PREVIEW_AUTOMATION_OPERATIONS,
50 "dialog",
51 "close",
52 "upload",
53 "hover",
54 "select",
55 "drag",
56] as const;
57export const PreviewAutomationOperation = Schema.Literals(PREVIEW_AUTOMATION_SERVER_OPERATIONS);
49export type PreviewAutomationOperation = typeof PreviewAutomationOperation.Type;58export type PreviewAutomationOperation = typeof PreviewAutomationOperation.Type;
5059
51const PreviewAutomationTabTargetFields = {60const PreviewAutomationTabTargetFields = {
...@@ -70,13 +79,66 @@ export const PreviewAutomationStatus = Schema.Struct({...@@ -70,13 +79,66 @@ export const PreviewAutomationStatus = Schema.Struct({
70 url: Schema.NullOr(Schema.String),79 url: Schema.NullOr(Schema.String),
71 title: Schema.NullOr(Schema.String),80 title: Schema.NullOr(Schema.String),
72 loading: Schema.Boolean,81 loading: Schema.Boolean,
82 control: Schema.optional(
83 Schema.Struct({
84 owner: Schema.Literals(["agent", "human", "unclaimed"]),
85 ownedByCaller: Schema.Boolean,
86 generation: Schema.Number,
87 }),
88 ),
89 dialog: Schema.optional(
90 Schema.NullOr(
91 Schema.Struct({
92 type: Schema.String,
93 message: Schema.String,
94 defaultValue: Schema.String,
95 }),
96 ),
97 ),
73 /** Optional for compatibility with desktop hosts predating viewport sizing. */98 /** Optional for compatibility with desktop hosts predating viewport sizing. */
74 viewportSetting: Schema.optional(PreviewViewportSetting),99 viewportSetting: Schema.optional(PreviewViewportSetting),
75 /** Measured guest-page viewport in CSS pixels when a webview is ready. */100 /** Measured guest-page viewport in CSS pixels when a webview is ready. */
76 viewport: Schema.optional(PreviewRenderedViewportSize),101 viewport: Schema.optional(PreviewRenderedViewportSize),
102 /** Server hosts: a file picker the page opened, answered with preview_upload. */
103 fileChooser: Schema.optional(
104 Schema.NullOr(Schema.Struct({ multiple: Schema.Boolean, accept: Schema.String })),
105 ),
106 /** Server hosts: files this tab downloaded, saved on the environment until the tab closes. */
107 downloads: Schema.optional(
108 Schema.Array(
109 Schema.Struct({
110 fileName: Schema.String,
111 path: Schema.String,
112 sizeBytes: Schema.Number,
113 url: Schema.String,
114 completedAt: Schema.String,
115 }),
116 ),
117 ),
118 /** Server hosts: every tab this agent session owns, including popups its pages opened. */
119 tabs: Schema.optional(
120 Schema.Array(
121 Schema.Struct({
122 tabId: PreviewTabId,
123 url: Schema.NullOr(Schema.String),
124 openerTabId: Schema.optional(PreviewTabId),
125 }),
126 ),
127 ),
77});128});
78export type PreviewAutomationStatus = typeof PreviewAutomationStatus.Type;129export type PreviewAutomationStatus = typeof PreviewAutomationStatus.Type;
79130
131export const PreviewAutomationDialogInput = Schema.Struct({
132 ...PreviewAutomationTabTargetFields,
133 accept: Schema.Boolean.annotate({
134 description: "Accept the pending browser dialog when true, or dismiss it when false.",
135 }),
136 promptText: Schema.optional(Schema.String).annotate({
137 description: "Optional text to submit when accepting a prompt dialog.",
138 }),
139});
140export type PreviewAutomationDialogInput = typeof PreviewAutomationDialogInput.Type;
141
80export const PreviewAutomationOpenInput = Schema.Struct({142export const PreviewAutomationOpenInput = Schema.Struct({
81 ...PreviewAutomationTabTargetFields,143 ...PreviewAutomationTabTargetFields,
82 url: Schema.optional(BoundedUrl).annotate({144 url: Schema.optional(BoundedUrl).annotate({
...@@ -282,7 +344,7 @@ export type PreviewAutomationSetColorSchemeResult =...@@ -282,7 +344,7 @@ export type PreviewAutomationSetColorSchemeResult =
282344
283const Locator = TrimmedNonEmptyString.annotate({345const Locator = TrimmedNonEmptyString.annotate({
284 description:346 description:
285 "Playwright selector, preferably role/text based, for example role=button[name='Send'] or text=Continue. Use snapshot first to inspect the page.",347 "Use locator='aria-ref=<ref>' with a ref from the latest server snapshot (including iframe elements), or a unique Playwright role/text selector. Refs expire on navigation, a new snapshot, or control handoff; refresh the snapshot after a stale-ref error.",
286});348});
287349
288const LegacySelector = TrimmedNonEmptyString.annotate({350const LegacySelector = TrimmedNonEmptyString.annotate({
...@@ -290,7 +352,7 @@ const LegacySelector = TrimmedNonEmptyString.annotate({...@@ -290,7 +352,7 @@ const LegacySelector = TrimmedNonEmptyString.annotate({
290 "Legacy CSS selector such as button[type='submit']. Prefer locator for resilient role/text targeting.",352 "Legacy CSS selector such as button[type='submit']. Prefer locator for resilient role/text targeting.",
291});353});
292354
293export const PreviewAutomationClickInput = Schema.Struct({355const PointerTargetFields = {
294 ...PreviewAutomationTabTargetFields,356 ...PreviewAutomationTabTargetFields,
295 selector: Schema.optional(LegacySelector).annotate({357 selector: Schema.optional(LegacySelector).annotate({
296 description:358 description:
...@@ -298,7 +360,7 @@ export const PreviewAutomationClickInput = Schema.Struct({...@@ -298,7 +360,7 @@ export const PreviewAutomationClickInput = Schema.Struct({
298 }),360 }),
299 locator: Schema.optional(Locator).annotate({361 locator: Schema.optional(Locator).annotate({
300 description:362 description:
301 "Playwright selector, preferably role/text based, for example role=button[name='Send'] or text=Continue. Use snapshot first to inspect the page.",363 "Use locator='aria-ref=<ref>' with a ref from the latest server snapshot (including iframe elements), or a unique Playwright role/text selector. Refs expire on navigation, a new snapshot, or control handoff; refresh the snapshot after a stale-ref error.",
302 }),364 }),
303 x: Schema.optional(365 x: Schema.optional(
304 Schema.Finite.annotate({366 Schema.Finite.annotate({
...@@ -311,24 +373,128 @@ export const PreviewAutomationClickInput = Schema.Struct({...@@ -311,24 +373,128 @@ export const PreviewAutomationClickInput = Schema.Struct({
311 }),373 }),
312 ),374 ),
313 timeoutMs: OptionalTimeoutMs,375 timeoutMs: OptionalTimeoutMs,
314})376};
315 .check(377
316 Schema.makeFilter((input) => {378const singlePointerTarget = Schema.makeFilter(
317 const selectorModes =379 (input: {
318 Number(input.selector !== undefined) + Number(input.locator !== undefined);380 readonly selector?: string | undefined;
319 const hasX = input.x !== undefined;381 readonly locator?: string | undefined;
320 const hasY = input.y !== undefined;382 readonly x?: number | undefined;
321 if (hasX !== hasY) return "Coordinates require both x and y.";383 readonly y?: number | undefined;
322 const coordinateModes = hasX && hasY ? 1 : 0;384 }) => {
323 return selectorModes + coordinateModes === 1 || "Provide exactly one click target.";385 const selectorModes =
386 Number(input.selector !== undefined) + Number(input.locator !== undefined);
387 const hasX = input.x !== undefined;
388 const hasY = input.y !== undefined;
389 if (hasX !== hasY) return "Coordinates require both x and y.";
390 const coordinateModes = hasX && hasY ? 1 : 0;
391 return selectorModes + coordinateModes === 1 || "Provide exactly one target.";
392 },
393);
394
395export const PreviewAutomationClickInput = Schema.Struct({
396 ...PointerTargetFields,
397 button: Schema.optional(
398 Schema.Literals(["left", "right", "middle"]).annotate({
399 description: "Mouse button. Defaults to left; right opens the page's context menu.",
324 }),400 }),
325 )401 ),
402 clickCount: Schema.optional(
403 Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 3 })).annotate({
404 description: "1 for a click, 2 for a double-click, 3 for a triple-click. Defaults to 1.",
405 }),
406 ),
407})
408 .check(singlePointerTarget)
326 .annotate({409 .annotate({
327 description:410 description:
328 "Clicks one target. Provide exactly one of locator, selector, or the x/y coordinate pair.",411 "Clicks one target. Provide exactly one of locator, selector, or the x/y coordinate pair.",
329 });412 });
330export type PreviewAutomationClickInput = typeof PreviewAutomationClickInput.Type;413export type PreviewAutomationClickInput = typeof PreviewAutomationClickInput.Type;
331414
415export const PreviewAutomationHoverInput = Schema.Struct(PointerTargetFields)
416 .check(singlePointerTarget)
417 .annotate({
418 description:
419 "Moves the mouse over one target. Provide exactly one of locator, selector, or the x/y coordinate pair.",
420 });
421export type PreviewAutomationHoverInput = typeof PreviewAutomationHoverInput.Type;
422
423export const PreviewAutomationSelectInput = Schema.Struct({
424 ...PreviewAutomationTabTargetFields,
425 selector: Schema.optional(LegacySelector).annotate({
426 description: "Legacy CSS selector for a <select>. Prefer locator.",
427 }),
428 locator: Schema.optional(Locator).annotate({
429 description: "The <select> element, for example aria-ref=<ref> or role=combobox[name='Size'].",
430 }),
431 values: Schema.Array(Schema.String).check(Schema.isMaxLength(100)).annotate({
432 description:
433 "Option values or visible labels to select. Pass several for a multiple select, or an empty list to clear it.",
434 }),
435 timeoutMs: OptionalTimeoutMs,
436})
437 .check(
438 Schema.makeFilter(
439 (input) =>
440 Number(input.selector !== undefined) + Number(input.locator !== undefined) === 1 ||
441 "Provide exactly one of selector or locator.",
442 ),
443 )
444 .annotate({ description: "Chooses options in one native <select> element." });
445export type PreviewAutomationSelectInput = typeof PreviewAutomationSelectInput.Type;
446
447export const PreviewAutomationSelectResult = Schema.Struct({
448 selected: Schema.Array(Schema.String).annotate({
449 description: "The values of the options now selected.",
450 }),
451});
452export type PreviewAutomationSelectResult = typeof PreviewAutomationSelectResult.Type;
453
454/** A required locator field. Its JSON schema keeps only the description on its last check. */
455const DescribedLocator = (description: string) =>
456 Schema.String.check(Schema.isTrimmed())
457 .check(Schema.isNonEmpty({ description }))
458 .annotateKey({ description });
459
460export const PreviewAutomationDragInput = Schema.Struct({
461 ...PreviewAutomationTabTargetFields,
462 source: DescribedLocator(
463 "Locator of the element to drag, for example aria-ref=<ref> from the latest snapshot.",
464 ),
465 target: DescribedLocator("Locator of the element to drop onto, for example aria-ref=<ref>."),
466 timeoutMs: OptionalTimeoutMs,
467}).annotate({ description: "Drags one element and drops it onto another." });
468export type PreviewAutomationDragInput = typeof PreviewAutomationDragInput.Type;
469
470export const PreviewAutomationUploadInput = Schema.Struct({
471 ...PreviewAutomationTabTargetFields,
472 paths: Schema.Array(TrimmedNonEmptyString).check(Schema.isMaxLength(20)).annotate({
473 description:
474 "Absolute paths of files on the environment to give the page. An empty list cancels the open file picker.",
475 }),
476 selector: Schema.optional(LegacySelector).annotate({
477 description: "Legacy CSS selector for an <input type=file>. Prefer locator.",
478 }),
479 locator: Schema.optional(Locator).annotate({
480 description:
481 "Set files directly on this <input type=file> instead of answering the open file picker.",
482 }),
483 timeoutMs: OptionalTimeoutMs,
484})
485 .check(
486 Schema.makeFilter(
487 (input) =>
488 !(input.selector !== undefined && input.locator !== undefined) ||
489 "Provide at most one of selector or locator.",
490 ),
491 )
492 .annotate({
493 description:
494 "Answers the page's open file picker, or sets files on a file input when locator/selector is given.",
495 });
496export type PreviewAutomationUploadInput = typeof PreviewAutomationUploadInput.Type;
497
332export const PreviewAutomationTypeInput = Schema.Struct({498export const PreviewAutomationTypeInput = Schema.Struct({
333 ...PreviewAutomationTabTargetFields,499 ...PreviewAutomationTabTargetFields,
334 text: Schema.String.annotate({ description: "Literal text to insert." }),500 text: Schema.String.annotate({ description: "Literal text to insert." }),
...@@ -607,6 +773,8 @@ export const PreviewAutomationRequest = Schema.Struct({...@@ -607,6 +773,8 @@ export const PreviewAutomationRequest = Schema.Struct({
607 threadId: ThreadId,773 threadId: ThreadId,
608 tabId: Schema.optional(PreviewTabId),774 tabId: Schema.optional(PreviewTabId),
609 tabIdExplicit: Schema.optional(Schema.Boolean),775 tabIdExplicit: Schema.optional(Schema.Boolean),
776 /** Supplied by the broker from authenticated scope, never from tool input. */
777 agentSessionId: Schema.optional(Schema.String),
610 operation: PreviewAutomationOperation,778 operation: PreviewAutomationOperation,
611 input: Schema.Unknown,779 input: Schema.Unknown,
612 timeoutMs: Schema.Int.check(Schema.isGreaterThan(0)),780 timeoutMs: Schema.Int.check(Schema.isGreaterThan(0)),
...@@ -684,6 +852,9 @@ const PreviewAutomationScopeErrorFields = {...@@ -684,6 +852,9 @@ const PreviewAutomationScopeErrorFields = {
684 providerInstanceId: ProviderInstanceId,852 providerInstanceId: ProviderInstanceId,
685};853};
686854
855/** The automation host id of an environment's own server browser. */
856export const SERVER_BROWSER_AUTOMATION_CLIENT_ID = "server-browser";
857
687const PreviewAutomationRequestErrorFields = {858const PreviewAutomationRequestErrorFields = {
688 ...PreviewAutomationScopeErrorFields,859 ...PreviewAutomationScopeErrorFields,
689 clientId: TrimmedNonEmptyString,860 clientId: TrimmedNonEmptyString,
...@@ -767,14 +938,39 @@ export class PreviewAutomationTimeoutError extends Schema.TaggedError<PreviewAut...@@ -767,14 +938,39 @@ export class PreviewAutomationTimeoutError extends Schema.TaggedError<PreviewAut
767 }938 }
768}939}
769940
941export const PreviewAutomationControlReason = Schema.Literals([
942 "agentMismatch",
943 "humanControl",
944 "tabRequired",
945 "closed",
946 "interrupted",
947 "dialogPending",
948 "tabLimit",
949]);
950export type PreviewAutomationControlReason = typeof PreviewAutomationControlReason.Type;
951
770export class PreviewAutomationControlInterruptedError extends Schema.TaggedError<PreviewAutomationControlInterruptedError>()(952export class PreviewAutomationControlInterruptedError extends Schema.TaggedError<PreviewAutomationControlInterruptedError>()(
771 "PreviewAutomationControlInterruptedError",953 "PreviewAutomationControlInterruptedError",
772 {954 {
773 ...PreviewAutomationRequestErrorFields,955 ...PreviewAutomationRequestErrorFields,
774 ...PreviewAutomationRemoteDiagnosticFields,956 ...PreviewAutomationRemoteDiagnosticFields,
957 reason: Schema.optional(PreviewAutomationControlReason),
775 },958 },
776) {959) {
777 override get message(): string {960 override get message(): string {
961 if (this.reason === "agentMismatch")
962 return "This browser tab belongs to another agent session or a human. Open your own tab.";
963 if (this.reason === "humanControl")
964 return "A human controls this browser tab. Wait until they release control.";
965 if (this.reason === "tabRequired")
966 return "Multiple browser tabs belong to this session. Pass a tabId from preview_open or from the tabs listed by preview_status.";
967 if (this.reason === "dialogPending")
968 return "A browser dialog is pending. Read preview_status and resolve it with preview_dialog.";
969 if (this.reason === "tabLimit")
970 return "Too many server browser tabs are open. Close one with t3_preview_close, or reuse a tabId from preview_status tabs.";
971 if (this.reason === "closed") return "This browser tab is closed. Call preview_open.";
972 if (this.reason === "interrupted")
973 return "Browser control changed. Take a fresh snapshot before trying again.";
778 return `Preview automation ${this.operation} was interrupted on client ${this.clientId}.`;974 return `Preview automation ${this.operation} was interrupted on client ${this.clientId}.`;
779 }975 }
780}976}
...@@ -784,10 +980,17 @@ export class PreviewAutomationExecutionError extends Schema.TaggedError<PreviewA...@@ -784,10 +980,17 @@ export class PreviewAutomationExecutionError extends Schema.TaggedError<PreviewA
784 {980 {
785 ...PreviewAutomationRequestErrorFields,981 ...PreviewAutomationRequestErrorFields,
786 ...PreviewAutomationRemoteDiagnosticFields,982 ...PreviewAutomationRemoteDiagnosticFields,
983 /**
984 * What went wrong, as the server's own browser reported it, such as a
985 * page that refused the connection. Absent for other hosts.
986 */
987 reason: Schema.optional(Schema.String),
787 },988 },
788) {989) {
789 override get message(): string {990 override get message(): string {
790 return `Preview automation ${this.operation} failed on client ${this.clientId}.`;991 return this.reason === undefined
992 ? `Preview automation ${this.operation} failed on client ${this.clientId}.`
993 : `Preview automation ${this.operation} failed: ${this.reason}`;
791 }994 }
792}995}
793996
...@@ -798,9 +1001,13 @@ export class PreviewAutomationInvalidSelectorError extends Schema.TaggedError<Pr...@@ -798,9 +1001,13 @@ export class PreviewAutomationInvalidSelectorError extends Schema.TaggedError<Pr
798 ...PreviewAutomationRemoteDiagnosticFields,1001 ...PreviewAutomationRemoteDiagnosticFields,
799 selectorKind: Schema.optional(Schema.Literals(["locator", "selector"])),1002 selectorKind: Schema.optional(Schema.Literals(["locator", "selector"])),
800 selectorLength: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))),1003 selectorLength: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))),
1004 /** The selector was an `aria-ref` from an older snapshot. */
1005 staleRef: Schema.optional(Schema.Boolean),
801 },1006 },
802) {1007) {
803 override get message(): string {1008 override get message(): string {
1009 if (this.staleRef)
1010 return "This element ref is stale. Navigation, dialogs, and new snapshots replace refs; take a fresh snapshot and use its refs.";
804 if (this.selectorKind !== undefined && this.selectorLength !== undefined) {1011 if (this.selectorKind !== undefined && this.selectorLength !== undefined) {
805 return `Preview automation ${this.operation} received an invalid ${this.selectorKind} (${this.selectorLength} characters).`;1012 return `Preview automation ${this.operation} received an invalid ${this.selectorKind} (${this.selectorLength} characters).`;
806 }1013 }
packages/contracts/src/rpc.ts+19-30
...@@ -237,11 +237,14 @@ import {...@@ -237,11 +237,14 @@ import {
237 PreviewEvent,237 PreviewEvent,
238 PreviewListInput,238 PreviewListInput,
239 PreviewListResult,239 PreviewListResult,
240 PreviewClearProfileError,
241 PreviewClearProfileInput,
240 PreviewNavigateInput,242 PreviewNavigateInput,
241 PreviewOpenInput,243 PreviewOpenInput,
242 PreviewRefreshInput,244 PreviewRefreshInput,
243 PreviewReportStatusInput,245 PreviewReportStatusInput,
244 PreviewResizeInput,246 PreviewResizeInput,
247 PreviewAdjustInput,
245 PreviewSessionSnapshot,248 PreviewSessionSnapshot,
246} from "./preview.ts";249} from "./preview.ts";
247import {250import {
...@@ -259,13 +262,7 @@ import {...@@ -259,13 +262,7 @@ import {
259 DeviceSession,262 DeviceSession,
260 DeviceShutdownInput,263 DeviceShutdownInput,
261} from "./device.ts";264} from "./device.ts";
262import {265import {} from "./previewAutomation.ts";
263 PreviewAutomationError,
264 PreviewAutomationHost,
265 PreviewAutomationHostFocus,
266 PreviewAutomationResponse,
267 PreviewAutomationStreamEvent,
268} from "./previewAutomation.ts";
269import {266import {
270 ServerConfigStreamEvent,267 ServerConfigStreamEvent,
271 DesktopUpdateCommitInput,268 DesktopUpdateCommitInput,
...@@ -418,13 +415,12 @@ export const WS_METHODS = {...@@ -418,13 +415,12 @@ export const WS_METHODS = {
418 previewOpen: "preview.open",415 previewOpen: "preview.open",
419 previewNavigate: "preview.navigate",416 previewNavigate: "preview.navigate",
420 previewResize: "preview.resize",417 previewResize: "preview.resize",
418 previewAdjust: "preview.adjust",
421 previewRefresh: "preview.refresh",419 previewRefresh: "preview.refresh",
422 previewClose: "preview.close",420 previewClose: "preview.close",
423 previewList: "preview.list",421 previewList: "preview.list",
422 previewClearProfile: "preview.clearProfile",
424 previewReportStatus: "preview.reportStatus",423 previewReportStatus: "preview.reportStatus",
425 previewAutomationConnect: "previewAutomation.connect",
426 previewAutomationRespond: "previewAutomation.respond",
427 previewAutomationFocusHost: "previewAutomation.focusHost",
428424
429 // Device methods425 // Device methods
430 deviceConfigure: "device.configure",426 deviceConfigure: "device.configure",
...@@ -1398,6 +1394,12 @@ const WsPreviewResizeRpc = Rpc.make(WS_METHODS.previewResize, {...@@ -1398,6 +1394,12 @@ const WsPreviewResizeRpc = Rpc.make(WS_METHODS.previewResize, {
1398 error: Schema.Union([PreviewError, EnvironmentAuthorizationError]),1394 error: Schema.Union([PreviewError, EnvironmentAuthorizationError]),
1399});1395});
14001396
1397const WsPreviewAdjustRpc = Rpc.make(WS_METHODS.previewAdjust, {
1398 payload: PreviewAdjustInput,
1399 success: PreviewSessionSnapshot,
1400 error: Schema.Union([PreviewError, EnvironmentAuthorizationError]),
1401});
1402
1401const WsPreviewRefreshRpc = Rpc.make(WS_METHODS.previewRefresh, {1403const WsPreviewRefreshRpc = Rpc.make(WS_METHODS.previewRefresh, {
1402 payload: PreviewRefreshInput,1404 payload: PreviewRefreshInput,
1403 error: Schema.Union([PreviewError, EnvironmentAuthorizationError]),1405 error: Schema.Union([PreviewError, EnvironmentAuthorizationError]),
...@@ -1414,28 +1416,16 @@ const WsPreviewListRpc = Rpc.make(WS_METHODS.previewList, {...@@ -1414,28 +1416,16 @@ const WsPreviewListRpc = Rpc.make(WS_METHODS.previewList, {
1414 error: EnvironmentAuthorizationError,1416 error: EnvironmentAuthorizationError,
1415});1417});
14161418
1419const WsPreviewClearProfileRpc = Rpc.make(WS_METHODS.previewClearProfile, {
1420 payload: PreviewClearProfileInput,
1421 error: Schema.Union([PreviewClearProfileError, EnvironmentAuthorizationError]),
1422});
1423
1417const WsPreviewReportStatusRpc = Rpc.make(WS_METHODS.previewReportStatus, {1424const WsPreviewReportStatusRpc = Rpc.make(WS_METHODS.previewReportStatus, {
1418 payload: PreviewReportStatusInput,1425 payload: PreviewReportStatusInput,
1419 error: Schema.Union([PreviewError, EnvironmentAuthorizationError]),1426 error: Schema.Union([PreviewError, EnvironmentAuthorizationError]),
1420});1427});
14211428
1422const WsPreviewAutomationConnectRpc = Rpc.make(WS_METHODS.previewAutomationConnect, {
1423 payload: PreviewAutomationHost,
1424 success: PreviewAutomationStreamEvent,
1425 error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]),
1426 stream: true,
1427});
1428
1429const WsPreviewAutomationRespondRpc = Rpc.make(WS_METHODS.previewAutomationRespond, {
1430 payload: PreviewAutomationResponse,
1431 error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]),
1432});
1433
1434const WsPreviewAutomationFocusHostRpc = Rpc.make(WS_METHODS.previewAutomationFocusHost, {
1435 payload: PreviewAutomationHostFocus,
1436 error: EnvironmentAuthorizationError,
1437});
1438
1439const WsSubscribePreviewEventsRpc = Rpc.make(WS_METHODS.subscribePreviewEvents, {1429const WsSubscribePreviewEventsRpc = Rpc.make(WS_METHODS.subscribePreviewEvents, {
1440 payload: Schema.Struct({}),1430 payload: Schema.Struct({}),
1441 success: PreviewEvent,1431 success: PreviewEvent,
...@@ -1885,13 +1875,12 @@ export const WsRpcGroup = RpcGroup.make(...@@ -1885,13 +1875,12 @@ export const WsRpcGroup = RpcGroup.make(
1885 WsPreviewOpenRpc,1875 WsPreviewOpenRpc,
1886 WsPreviewNavigateRpc,1876 WsPreviewNavigateRpc,
1887 WsPreviewResizeRpc,1877 WsPreviewResizeRpc,
1878 WsPreviewAdjustRpc,
1888 WsPreviewRefreshRpc,1879 WsPreviewRefreshRpc,
1889 WsPreviewCloseRpc,1880 WsPreviewCloseRpc,
1890 WsPreviewListRpc,1881 WsPreviewListRpc,
1882 WsPreviewClearProfileRpc,
1891 WsPreviewReportStatusRpc,1883 WsPreviewReportStatusRpc,
1892 WsPreviewAutomationConnectRpc,
1893 WsPreviewAutomationRespondRpc,
1894 WsPreviewAutomationFocusHostRpc,
1895 WsSubscribePreviewEventsRpc,1884 WsSubscribePreviewEventsRpc,
1896 WsSubscribeDiscoveredLocalServersRpc,1885 WsSubscribeDiscoveredLocalServersRpc,
1897 WsDeviceConfigureRpc,1886 WsDeviceConfigureRpc,
packages/shared/src/t3McpToolPresentation.ts+25
...@@ -158,6 +158,11 @@ const T3_MCP_TOOLS: Readonly<Record<string, T3McpToolDefinition>> = {...@@ -158,6 +158,11 @@ const T3_MCP_TOOLS: Readonly<Record<string, T3McpToolDefinition>> = {
158 "browser",158 "browser",
159 "browser",159 "browser",
160 ),160 ),
161 preview_dialog: tool(
162 ["Respond", "Responding", "Responded", "to a preview browser dialog"],
163 "browser",
164 "browser",
165 ),
161 preview_snapshot: tool(166 preview_snapshot: tool(
162 ["Take a snapshot of", "Taking a snapshot of", "Took a snapshot of", "the preview page"],167 ["Take a snapshot of", "Taking a snapshot of", "Took a snapshot of", "the preview page"],
163 "browser",168 "browser",
...@@ -175,6 +180,26 @@ const T3_MCP_TOOLS: Readonly<Record<string, T3McpToolDefinition>> = {...@@ -175,6 +180,26 @@ const T3_MCP_TOOLS: Readonly<Record<string, T3McpToolDefinition>> = {
175 "browser",180 "browser",
176 ),181 ),
177 preview_type: tool(["Type", "Typing", "Typed", "in the preview browser"], "browser", "browser"),182 preview_type: tool(["Type", "Typing", "Typed", "in the preview browser"], "browser", "browser"),
183 preview_hover: tool(
184 ["Hover", "Hovering", "Hovered", "in the preview browser"],
185 "browser",
186 "browser",
187 ),
188 preview_select: tool(
189 ["Choose", "Choosing", "Chose", "an option in the preview browser"],
190 "browser",
191 "browser",
192 ),
193 preview_drag: tool(
194 ["Drag", "Dragging", "Dragged", "in the preview browser"],
195 "browser",
196 "browser",
197 ),
198 preview_upload: tool(
199 ["Upload", "Uploading", "Uploaded", "files to the preview browser"],
200 "browser",
201 "browser",
202 ),
178 preview_scroll: tool(203 preview_scroll: tool(
179 ["Scroll", "Scrolling", "Scrolled", "the preview browser"],204 ["Scroll", "Scrolling", "Scrolled", "the preview browser"],
180 "browser",205 "browser",
pnpm-lock.yaml+3
...@@ -564,6 +564,9 @@ importers:...@@ -564,6 +564,9 @@ importers:
564 node-pty:564 node-pty:
565 specifier: ^1.2.0-beta.15565 specifier: ^1.2.0-beta.15
566 version: 1.2.0-beta.15(patch_hash=f2fe901c61cde17986240002d05c172d5d0272d83ffaab8d0ebeb922763be414)566 version: 1.2.0-beta.15(patch_hash=f2fe901c61cde17986240002d05c172d5d0272d83ffaab8d0ebeb922763be414)
567 playwright-core:
568 specifier: 1.60.0
569 version: 1.60.0
567 proper-lockfile:570 proper-lockfile:
568 specifier: 4.1.2571 specifier: 4.1.2
569 version: 4.1.2572 version: 4.1.2
scripts/lib/cli-external-packages.test.ts+1-1
...@@ -83,7 +83,7 @@ describe("selectCliRuntimeExternalDependencies", () => {...@@ -83,7 +83,7 @@ describe("selectCliRuntimeExternalDependencies", () => {
83 it("selects every external root declared by the server", () => {83 it("selects every external root declared by the server", () => {
84 assert.deepStrictEqual(84 assert.deepStrictEqual(
85 Object.keys(selectCliRuntimeExternalDependencies(serverPackageJson.dependencies)).sort(),85 Object.keys(selectCliRuntimeExternalDependencies(serverPackageJson.dependencies)).sort(),
86 ["@cursor/sdk", "@ff-labs/fff-node", "@napi-rs/keyring", "node-pty"],86 ["@cursor/sdk", "@ff-labs/fff-node", "@napi-rs/keyring", "node-pty", "playwright-core"],
87 );87 );
88 });88 });
89});89});
scripts/lib/cli-external-packages.ts+2
...@@ -28,6 +28,8 @@...@@ -28,6 +28,8 @@
28export const CLI_RUNTIME_EXTERNAL_PREFIXES = [28export const CLI_RUNTIME_EXTERNAL_PREFIXES = [
29 // Cursor ships computed Webpack imports and platform helper packages.29 // Cursor ships computed Webpack imports and platform helper packages.
30 "@cursor/sdk",30 "@cursor/sdk",
31 // Playwright reads package.json and browsers.json beside its runtime modules.
32 "playwright-core",
31 "node-pty",33 "node-pty",
32 "ffi-rs",34 "ffi-rs",
33 "@yuuang/",35 "@yuuang/",