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:
255255 # No Electron setup here: `t3` (apps/server) has no Electron dependency
256256 # and none of its tests touch the runtime. Only the non-server `test`
257257 # 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
258261 - name: Test
259262 env:
260263 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
568568 tailscaleServePort: backendExposure.tailscaleServePort,
569569 desktopTelemetryFd: 4,
570570 desktopTelemetryControlFd: 5,
571 desktopBrowserFd: 6,
572 desktopBrowserControlFd: 7,
571573 ...Option.match(input.resourceMonitorPath, {
572574 onNone: () => ({}),
573575 onSome: (resourceMonitorPath) => ({ resourceMonitorPath }),
apps/desktop/src/backend/DesktopBackendManager.test.ts+2
......@@ -27,6 +27,7 @@ import * as DesktopApp from "../app/DesktopApp.ts";
2727import * as DesktopBackendPool from "./DesktopBackendPool.ts";
2828import * as DesktopObservability from "../app/DesktopObservability.ts";
2929import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";
30import * as DesktopBrowserHost from "../preview/DesktopBrowserHost.ts";
3031import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";
3132
3233const decodeDesktopBackendBootstrap = Schema.decodeEffect(
......@@ -173,6 +174,7 @@ function makeTestInstance(input: MakeInstanceInput) {
173174 updateCancellations: Stream.empty,
174175 ...input.desktopTelemetryPublisher,
175176 }),
177 DesktopBrowserHost.layer,
176178 DesktopWslEnvironment.layerTest(
177179 input.pruneRuntimes === undefined ? {} : { pruneRuntimes: input.pruneRuntimes },
178180 ),
apps/desktop/src/backend/DesktopBackendManager.ts+42
......@@ -52,6 +52,7 @@ import { waitForHttpReady as waitForHttpReadyShared } from "@t3tools/shared/http
5252
5353import * as DesktopObservability from "../app/DesktopObservability.ts";
5454import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";
55import * as DesktopBrowserHost from "../preview/DesktopBrowserHost.ts";
5556import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";
5657
5758const INITIAL_RESTART_DELAY = Duration.millis(500);
......@@ -222,6 +223,10 @@ export type BackendProcessError = typeof BackendProcessError.Type;
222223
223224interface RunBackendProcessOptions extends DesktopBackendStartConfig {
224225 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>;
225230 readonly onDesktopTelemetryControl?: (
226231 message: DesktopTelemetryControlMessageValue,
227232 ) => Effect.Effect<void>;
......@@ -472,6 +477,18 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* (
472477 type: "output",
473478 };
474479 }
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 }
475492 }
476493 const command = ChildProcess.make(options.executablePath, options.args, {
477494 cwd: options.cwd,
......@@ -542,6 +559,26 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* (
542559 Effect.forkScoped,
543560 );
544561 }
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 }
545582 if (options.captureOutput) {
546583 const outputContext = {
547584 executablePath: options.executablePath,
......@@ -642,6 +679,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (
642679 | HttpClient.HttpClient
643680 | DesktopObservability.DesktopBackendOutputLogFactory
644681 | DesktopTelemetryPublisher.DesktopTelemetryPublisher
682 | DesktopBrowserHost.DesktopBrowserHost
645683 | DesktopWslEnvironment.DesktopWslEnvironment
646684 | Scope.Scope
647685> {
......@@ -650,6 +688,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (
650688 const backendOutputLogFactory = yield* DesktopObservability.DesktopBackendOutputLogFactory;
651689 const backendOutputLog = yield* backendOutputLogFactory.forInstance(spec.id);
652690 const desktopTelemetryPublisher = yield* DesktopTelemetryPublisher.DesktopTelemetryPublisher;
691 const desktopBrowserHost = yield* DesktopBrowserHost.DesktopBrowserHost;
653692 const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment;
654693 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
655694 const httpClient = yield* HttpClient.HttpClient;
......@@ -907,6 +946,9 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (
907946 const program = runBackendProcess({
908947 ...config.value,
909948 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,
910952 onDesktopTelemetryControl: (message) =>
911953 desktopTelemetryPublisher.handleControlForSource(spec.id, message),
912954 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";
1212import * as DesktopObservability from "../app/DesktopObservability.ts";
1313import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
1414import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";
15import * as DesktopBrowserHost from "../preview/DesktopBrowserHost.ts";
1516import * as ElectronDialog from "../electron/ElectronDialog.ts";
1617import * as DesktopWindow from "../window/DesktopWindow.ts";
1718import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";
......@@ -75,6 +76,7 @@ function layerPool(labelRef: Ref.Ref<string>): Layer.Layer<DesktopBackendPool.De
7576 updateCommits: Stream.empty,
7677 updateCancellations: Stream.empty,
7778 }),
79 DesktopBrowserHost.layer,
7880 Layer.succeed(DesktopBackendConfiguration.DesktopBackendConfiguration, {
7981 resolvePrimary: Effect.die("unexpected primary config resolve"),
8082 resolvePrimaryLabel: Ref.get(labelRef),
apps/desktop/src/backend/DesktopBackendPool.ts+2
......@@ -98,6 +98,7 @@ import * as DesktopBackendManager from "./DesktopBackendManager.ts";
9898import * as DesktopObservability from "../app/DesktopObservability.ts";
9999import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
100100import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";
101import * as DesktopBrowserHost from "../preview/DesktopBrowserHost.ts";
101102import * as DesktopWindow from "../window/DesktopWindow.ts";
102103import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";
103104import * as ElectronDialog from "../electron/ElectronDialog.ts";
......@@ -180,6 +181,7 @@ export type BackendInstanceFactoryRequirements =
180181 | HttpClient.HttpClient
181182 | DesktopObservability.DesktopBackendOutputLogFactory
182183 | DesktopTelemetryPublisher.DesktopTelemetryPublisher
184 | DesktopBrowserHost.DesktopBrowserHost
183185 | DesktopWslEnvironment.DesktopWslEnvironment;
184186
185187interface ActiveRegisteredInstance {
apps/desktop/src/ipc/channels.ts+1-8
......@@ -78,6 +78,7 @@ export const PREVIEW_REFRESH_CHANNEL = "desktop:preview-refresh";
7878export const PREVIEW_ZOOM_IN_CHANNEL = "desktop:preview-zoom-in";
7979export const PREVIEW_ZOOM_OUT_CHANNEL = "desktop:preview-zoom-out";
8080export const PREVIEW_RESET_ZOOM_CHANNEL = "desktop:preview-reset-zoom";
81export const PREVIEW_SET_ZOOM_FACTOR_CHANNEL = "desktop:preview-set-zoom-factor";
8182export const PREVIEW_HARD_RELOAD_CHANNEL = "desktop:preview-hard-reload";
8283export const PREVIEW_SET_COLOR_SCHEME_CHANNEL = "desktop:preview-set-color-scheme";
8384export 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";
9697export const PREVIEW_PICTURE_IN_PICTURE_OPEN_CHANNEL = "desktop:preview-pip-open";
9798export const PREVIEW_PICTURE_IN_PICTURE_CLOSE_CHANNEL = "desktop:preview-pip-close";
9899export 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";
107100export const PREVIEW_RECORDING_START_CHANNEL = "desktop:preview-recording-start";
108101export const PREVIEW_RECORDING_STOP_CHANNEL = "desktop:preview-recording-stop";
109102export 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", () => {
128128 ),
129129 );
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
157131 it("keeps the public automation status tab id limit", () => {
158132 const encode = Schema.encodeUnknownSync(PreviewAutomationStatus);
159133 const tabId = "t".repeat(129);
apps/desktop/src/ipc/methods/preview.ts+13-97
......@@ -1,13 +1,6 @@
11import {
22 DesktopPreviewAnnotationThemeInputSchema,
33 DesktopPreviewArtifactInputSchema,
4 DesktopPreviewAutomationClickInputSchema,
5 DesktopPreviewAutomationEvaluateInputSchema,
6 DesktopPreviewAutomationPressInputSchema,
7 DesktopPreviewAutomationScrollInputSchema,
8 DesktopPreviewAutomationStatusSchema,
9 DesktopPreviewAutomationTypeInputSchema,
10 DesktopPreviewAutomationWaitForInputSchema,
114 DesktopPreviewConfigInputSchema,
125 DesktopPreviewNavigateInputSchema,
136 DesktopPreviewRecordingArtifactSchema,
......@@ -16,6 +9,7 @@ import {
169 DesktopPreviewScreenshotArtifactSchema,
1710 DesktopPreviewSetAudioMutedInputSchema,
1811 DesktopPreviewSetColorSchemeInputSchema,
12 DesktopPreviewSetZoomFactorInputSchema,
1913 BrowserImportResult,
2014 BrowserImportSource,
2115 DesktopPreviewClearDataInputSchema,
......@@ -24,7 +18,6 @@ import {
2418 DesktopPreviewTabInputSchema,
2519 DesktopPreviewWebviewConfigSchema,
2620 PreviewAnnotationSubmissionResultSchema,
27 PreviewAutomationSnapshot,
2821 DEFAULT_BROWSER_PROFILE_ID,
2922 INCOGNITO_BROWSER_PROFILE_ID,
3023} from "@t3tools/contracts";
......@@ -68,9 +61,10 @@ export const createTab = DesktopIpc.makeIpcMethod({
6861 tabId,
6962 zoomFactor,
7063 colorScheme,
64 serverTab,
7165 }) {
7266 const manager = yield* PreviewManager.PreviewManager;
73 yield* manager.createTab(tabId, { zoomFactor, colorScheme });
67 yield* manager.createTab(tabId, { zoomFactor, colorScheme, serverTab });
7468 }),
7569});
7670
......@@ -157,6 +151,15 @@ export const hardReload = tabMethod(
157151 "desktop.ipc.preview.hardReload",
158152 (manager, tabId) => manager.hardReload(tabId),
159153);
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});
160163export const setColorScheme = DesktopIpc.makeIpcMethod({
161164 channel: IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL,
162165 payload: DesktopPreviewSetColorSchemeInputSchema,
......@@ -392,86 +395,6 @@ export const copyArtifactToClipboard = DesktopIpc.makeIpcMethod({
392395 }),
393396});
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
475398export const saveRecording = DesktopIpc.makeIpcMethod({
476399 channel: IpcChannels.PREVIEW_RECORDING_SAVE_CHANNEL,
477400 payload: DesktopPreviewRecordingSaveInputSchema,
......@@ -495,6 +418,7 @@ export const methods = [
495418 resetZoom,
496419 hardReload,
497420 setColorScheme,
421 setZoomFactor,
498422 setAudioMuted,
499423 openDevTools,
500424 clearCookies,
......@@ -508,14 +432,6 @@ export const methods = [
508432 copyArtifactToClipboard,
509433 openPictureInPicture,
510434 closePictureInPicture,
511 automationStatus,
512 automationSnapshot,
513 automationClick,
514 automationType,
515 automationPress,
516 automationScroll,
517 automationEvaluate,
518 automationWaitFor,
519435 startRecording,
520436 stopRecording,
521437 saveRecording,
apps/desktop/src/main.ts+2
......@@ -67,6 +67,7 @@ import * as DesktopUpdates from "./updates/DesktopUpdates.ts";
6767import * as BrowserImport from "./preview/BrowserImport/BrowserImport.ts";
6868import * as LinuxBrowserSecret from "./preview/BrowserImport/LinuxBrowserSecret.ts";
6969import * as BrowserSession from "./preview/BrowserSession.ts";
70import * as DesktopBrowserHost from "./preview/DesktopBrowserHost.ts";
7071import * as PreviewManager from "./preview/Manager.ts";
7172import * as DesktopWindow from "./window/DesktopWindow.ts";
7273import * as DesktopWslBackend from "./wsl/DesktopWslBackend.ts";
......@@ -161,6 +162,7 @@ const layerDesktopServerExposure = DesktopServerExposure.layer.pipe(
161162);
162163
163164const layerDesktopPreview = PreviewManager.layer.pipe(
165 Layer.provideMerge(DesktopBrowserHost.layer),
164166 // Merged rather than provided so the IPC handlers can reach the import
165167 // service alongside the manager; both sit on the same BrowserSession.
166168 Layer.provideMerge(BrowserImport.layer.pipe(Layer.provide(LinuxBrowserSecret.layer))),
apps/desktop/src/preload.ts+3-18
......@@ -303,6 +303,7 @@ contextBridge.exposeInMainWorld("desktopBridge", {
303303 ipcRenderer.invoke(IpcChannels.PREVIEW_CREATE_TAB_CHANNEL, {
304304 tabId,
305305 zoomFactor: defaults?.zoomFactor,
306 serverTab: defaults?.serverTab,
306307 colorScheme: defaults?.colorScheme,
307308 }),
308309 closeTab: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_CLOSE_TAB_CHANNEL, { tabId }),
......@@ -316,6 +317,8 @@ contextBridge.exposeInMainWorld("desktopBridge", {
316317 zoomIn: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_ZOOM_IN_CHANNEL, { tabId }),
317318 zoomOut: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_ZOOM_OUT_CHANNEL, { tabId }),
318319 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 }),
319322 hardReload: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_HARD_RELOAD_CHANNEL, { tabId }),
320323 setColorScheme: (tabId, colorScheme) =>
321324 ipcRenderer.invoke(IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL, { tabId, colorScheme }),
......@@ -379,24 +382,6 @@ contextBridge.exposeInMainWorld("desktopBridge", {
379382 ipcRenderer.removeListener(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, wrappedListener);
380383 },
381384 },
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 },
400385 onStateChange: (listener) => {
401386 const wrappedListener = (
402387 _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 @@
1import * as NodeVM from "node:vm";
2import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
31import { it as effectIt } from "@effect/vitest";
42import { DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER } from "@t3tools/contracts";
53import type {
......@@ -13,11 +11,12 @@ import * as Effect from "effect/Effect";
1311import * as Exit from "effect/Exit";
1412import * as FileSystem from "effect/FileSystem";
1513import * as Fiber from "effect/Fiber";
14import * as Queue from "effect/Queue";
15import * as Stream from "effect/Stream";
1616import * as Layer from "effect/Layer";
1717import * as Logger from "effect/Logger";
1818import * as Option from "effect/Option";
1919import * as Path from "effect/Path";
20import * as Schema from "effect/Schema";
2120import type * as Scope from "effect/Scope";
2221import { TestClock } from "effect/testing";
2322import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
......@@ -26,6 +25,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
2625import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts";
2726import * as ElectronWindow from "../electron/ElectronWindow.ts";
2827import * as BrowserSession from "./BrowserSession.ts";
28import * as DesktopBrowserHost from "./DesktopBrowserHost.ts";
2929import * as PreviewManager from "./Manager.ts";
3030
3131describe("fitPictureInPictureContentSize", () => {
......@@ -276,14 +276,13 @@ const layer = PreviewManager.layer.pipe(
276276 shutdown: Effect.void,
277277 }),
278278 ),
279 Layer.provideMerge(DesktopBrowserHost.layer),
279280 Layer.provideMerge(layerBrowserSession),
280281 Layer.provideMerge(layerEnvironment),
281282 Layer.provideMerge(layerFileSystem),
282283 Layer.provideMerge(Path.layer),
283 Layer.provideMerge(NodeCrypto.layer),
284284 Layer.provideMerge(Layer.succeed(HostProcessPlatform, "darwin")),
285285);
286const encodePreviewManagerError = Schema.encodeSync(PreviewManager.PreviewManagerError);
287286
288287const withManager = <A>(
289288 use: (
......@@ -676,33 +675,6 @@ describe("PreviewManager", () => {
676675 ),
677676 );
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
706678 effectIt.effect("rejects a destroyed webview during registration", () =>
707679 withManager((manager) =>
708680 Effect.gen(function* () {
......@@ -832,15 +804,6 @@ describe("PreviewManager", () => {
832804
833805 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
844807 yield* manager.registerWebview("tab_pending", 42);
845808 yield* Effect.yieldNow;
846809
......@@ -2721,6 +2684,39 @@ describe("PreviewManager", () => {
27212684 ),
27222685 );
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
27242720 effectIt.effect("stops capture retries when the tab swaps during the retry delay", () =>
27252721 withManager((manager) =>
27262722 Effect.gen(function* () {
......@@ -2783,65 +2779,6 @@ describe("PreviewManager", () => {
27832779 ),
27842780 );
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
28452782 effectIt.effect("grants each concurrent preview recording its own tab frame", () =>
28462783 withManager((manager) =>
28472784 Effect.gen(function* () {
......@@ -4192,725 +4129,6 @@ describe("PreviewManager", () => {
41924129 }),
41934130 ),
41944131 );
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 );
49144132});
49154133
49164134describe("PreviewOperationError", () => {
......@@ -4927,89 +4145,3 @@ describe("PreviewOperationError", () => {
49274145 expect(PreviewManager.PreviewOperationError.toTimelineMessage(error)).toBe(cause.message);
49284146 });
49294147});
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 {
1111} from "@t3tools/contracts";
1212import type {
1313 DesktopPreviewAnnotationTheme,
14 DesktopPreviewAutomationStatus,
1514 DesktopPreviewColorScheme,
1615 DesktopPreviewFavicon,
1716 DesktopPreviewPointerEvent,
......@@ -23,16 +22,6 @@ import type {
2322 DesktopPreviewRecordingInputEvent,
2423 DesktopPreviewScreenshotArtifact,
2524 DesktopPreviewTabDefaults,
26 PreviewAutomationClickInput,
27 PreviewAutomationActionEvent,
28 PreviewAutomationConsoleEntry,
29 PreviewAutomationEvaluateInput,
30 PreviewAutomationPressInput,
31 PreviewAutomationNetworkEntry,
32 PreviewAutomationScrollInput,
33 PreviewAutomationSnapshot,
34 PreviewAutomationTypeInput,
35 PreviewAutomationWaitForInput,
3625} from "@t3tools/contracts";
3726import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
3827import { normalizePreviewUrl } from "@t3tools/shared/preview";
......@@ -48,7 +37,6 @@ import {
4837import * as Cause from "effect/Cause";
4938import * as Clock from "effect/Clock";
5039import * as Context from "effect/Context";
51import * as Crypto from "effect/Crypto";
5240import * as DateTime from "effect/DateTime";
5341import * as Deferred from "effect/Deferred";
5442import * as Effect from "effect/Effect";
......@@ -62,6 +50,7 @@ import * as Queue from "effect/Queue";
6250import * as Ref from "effect/Ref";
6351import * as Schema from "effect/Schema";
6452import * as Semaphore from "effect/Semaphore";
53import * as Stream from "effect/Stream";
6554import * as Schedule from "effect/Schedule";
6655import * as Scope from "effect/Scope";
6756import * as SynchronizedRef from "effect/SynchronizedRef";
......@@ -69,6 +58,7 @@ import * as SynchronizedRef from "effect/SynchronizedRef";
6958import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
7059import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts";
7160import { PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL } from "../ipc/channels.ts";
61import * as DesktopBrowserHost from "./DesktopBrowserHost.ts";
7262import * as BrowserSession from "./BrowserSession.ts";
7363import {
7464 ANNOTATION_CAPTURED_CHANNEL,
......@@ -78,19 +68,12 @@ import {
7868 HUMAN_INPUT_CHANNEL,
7969 MOUSE_NAVIGATE_CHANNEL,
8070 RECORDING_CURSOR_CHANNEL,
81 RECORDING_POINTER_CHANNEL,
82 RECORDING_KEY_CHANNEL,
8371 RECORDING_INPUT_CHANNEL,
8472 RECORDING_CONTROLLER_CHANNEL,
73 RECORDING_POINTER_CHANNEL,
8574 START_PICK_CHANNEL,
8675} from "./GuestProtocol.ts";
8776import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts";
88import { playwrightInjectedRuntimeInstallExpression } from "./PlaywrightInjectedRuntime.ts";
89import {
90 makePreviewAutomationKeySequence,
91 makePreviewAutomationNativeKeySequence,
92 previewAutomationEditingCommandExpression,
93} from "./PreviewKeyboard.ts";
9477import { captureFavicon, safeHttpOrigin, selectFaviconCandidates } from "./FaviconCapture.ts";
9578import { DEFAULT_RECORDING_INPUT_OPTIONS, type RecordingInputOptions } from "./RecordingInput.ts";
9679
......@@ -121,6 +104,8 @@ export interface PreviewTabState {
121104 audible: boolean;
122105 controller: "human" | "agent" | "none";
123106 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 };
124109 updatedAt: string;
125110}
126111
......@@ -131,16 +116,11 @@ const ZOOM_LEVELS: ReadonlyArray<number> = [
131116
132117const DEFAULT_ZOOM_FACTOR = 1.0;
133118const ZOOM_EPSILON = 0.001;
134const MAX_EVALUATION_BYTES = 64_000;
135const MAX_VISIBLE_TEXT_LENGTH = 20_000;
136const MAX_INTERACTIVE_ELEMENTS = 200;
137119/**
138120 * A `[role]` container's innerText is its whole subtree, which turned one
139121 * snapshot's element list into 60 KB of repeated page text. Names are labels,
140122 * not content, so cap them where they are read.
141123 */
142const MAX_INTERACTIVE_ELEMENT_NAME_LENGTH = 200;
143const MAX_SCREENSHOT_WIDTH = 1280;
144124/** How long an armed tab keeps the exclusive display-media slot before another tab may take it. */
145125const RECORDING_ARM_GRACE_MS = 10_000;
146126const PICTURE_IN_PICTURE_FRAME_INTERVAL_MS = Math.ceil(1_000 / 12);
......@@ -157,13 +137,9 @@ const PICTURE_IN_PICTURE_INITIAL_HEIGHT = 320;
157137const PICTURE_IN_PICTURE_MIN_WIDTH = 240;
158138const PICTURE_IN_PICTURE_MIN_HEIGHT = 160;
159139const PICTURE_IN_PICTURE_ASPECT_RATIO_EPSILON = 0.002;
160const DIAGNOSTIC_BUFFER_LIMIT = 200;
161140const MAX_ARTIFACT_SITE_SLUG_LENGTH = 80;
162const AGENT_CURSOR_MOVE_MS = 160;
163const AGENT_CURSOR_CLICK_LEAD_MS = 40;
164141const requestRecordingCaptureExpression = (tabId: string): string =>
165142 `globalThis[${JSON.stringify(DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER)}]?.(${JSON.stringify(tabId)}) === true`;
166const encodeUnknownJson = Schema.encodeUnknownEffect(Schema.fromJsonString(Schema.Unknown));
167143const DEFAULT_ANNOTATION_THEME: DesktopPreviewAnnotationTheme = {
168144 colorScheme: "light",
169145 radius: "0.625rem",
......@@ -253,17 +229,6 @@ const artifactSiteSlug = (rawUrl: string): string => {
253229 }
254230};
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
267232export const PreviewAutomationSelectorKind = Schema.Literals([
268233 "focused-element",
269234 "selector",
......@@ -279,36 +244,6 @@ export const PreviewAutomationEvaluationDetailKind = Schema.Literals([
279244export type PreviewAutomationEvaluationDetailKind =
280245 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
312247interface PreviewOperationContext {
313248 readonly operation: string;
314249 readonly tabId?: string;
......@@ -492,23 +427,12 @@ interface BrowserControlSession {
492427 ) => void;
493428}
494429
495interface BrowserDiagnostics {
496 readonly consoleEntries: ReadonlyArray<PreviewAutomationConsoleEntry>;
497 readonly networkEntries: ReadonlyArray<PreviewAutomationNetworkEntry>;
498 readonly requests: ReadonlyMap<string, { url: string; method: string }>;
499}
500
501430const isRecordingInput = Schema.is(DesktopPreviewRecordingInputSchema);
502431
503432type RecordingInputListener = (event: DesktopPreviewRecordingInputEvent) => Effect.Effect<void>;
504433
505434type PointerEventListener = (event: DesktopPreviewPointerEvent) => Effect.Effect<void>;
506435
507interface ExpectedAgentInput {
508 readonly signal: PreviewInputSignal;
509 readonly expiresAt: number;
510}
511
512436/**
513437 * Protocols a preview page may open in a real popup window.
514438 *
......@@ -522,9 +446,6 @@ interface ExpectedAgentInput {
522446 */
523447const 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
528449const isPopupUrl = (rawUrl: string): boolean => {
529450 try {
530451 return POPUP_PROTOCOLS.has(new URL(rawUrl).protocol);
......@@ -618,23 +539,6 @@ const isPreviewInputSignal = (value: unknown): value is PreviewInputSignal => {
618539 );
619540};
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
638542const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function* (
639543 artifactDirectory: string,
640544 pictureInPicturePreloadPath: string,
......@@ -643,14 +547,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
643547 const rendererHistory = yield* DesktopRendererHistory.DesktopRendererHistory;
644548 const hostPlatform = yield* HostProcessPlatform;
645549 const path = yield* Path.Path;
646 const crypto = yield* Crypto.Crypto;
647550 const parentScope = yield* Scope.Scope;
551 const browserHost = yield* DesktopBrowserHost.DesktopBrowserHost;
648552 const context = yield* Effect.context<never>();
649553 const runFork = Effect.runForkWith(context);
650554 const resolvedArtifactDirectory = path.resolve(artifactDirectory);
651 const playwrightInstallExpression = yield* Effect.cached(
652 playwrightInjectedRuntimeInstallExpression(),
653 );
654555
655556 const annotationThemeRef = yield* Ref.make(DEFAULT_ANNOTATION_THEME);
656557 const mainWindowRef = yield* Ref.make<Option.Option<BrowserWindow>>(Option.none());
......@@ -668,16 +569,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
668569 const controlSessionsRef = yield* SynchronizedRef.make<
669570 ReadonlyMap<number, BrowserControlSession>
670571 >(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);
681572 const frameCaptureSessionsRef = yield* SynchronizedRef.make<
682573 ReadonlyMap<string, FrameCaptureSession>
683574 >(new Map());
......@@ -692,10 +583,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
692583 // (electron#44618) and now always rejects with NotAllowedError.
693584 let pendingRecording: PendingRecording | null = null;
694585 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>();
699586 let frameCaptureWindowOpen = true;
700587 let currentMainWindow: BrowserWindow | undefined;
701588 let mainWindowCleanupFiber: Fiber.Fiber<void, never> | undefined;
......@@ -756,12 +643,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
756643 });
757644 const currentIso = DateTime.now.pipe(Effect.map(DateTime.formatIso));
758645 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);
765646 const replaceMap = <K, V>(
766647 source: ReadonlyMap<K, V>,
767648 update: (copy: Map<K, V>) => void,
......@@ -1143,139 +1024,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
11431024 );
11441025 });
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
12771027 const detachControlSession = Effect.fn("PreviewManager.detachControlSession")(function* (
12781028 webContentsId: number,
1029 /** The server tab it rendered, when the caller already took the tab out of `tabsRef`. */
1030 closedServerTab?: PreviewTabState["serverTab"],
12791031 ) {
12801032 const control = yield* SynchronizedRef.modify(controlSessionsRef, (sessions) => [
12811033 sessions.get(webContentsId),
......@@ -1284,14 +1036,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
12841036 }),
12851037 ]);
12861038 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 }
12871044 yield* Scope.close(control.scope, Exit.void).pipe(Effect.ignore);
1288 return;
12891045 }
1290 yield* Ref.update(diagnosticsRef, (diagnostics) =>
1291 replaceMap(diagnostics, (copy) => {
1292 copy.delete(webContentsId);
1293 }),
1294 );
12951046 });
12961047
12971048 const ensureControlSession = Effect.fn("PreviewManager.ensureControlSession")(function* (
......@@ -1400,7 +1151,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
14001151 }
14011152 }
14021153 }
1403 yield* captureDiagnosticMessage(wc.id, method, params);
14041154 if (method === "Runtime.consoleAPICalled" || method === "Runtime.exceptionThrown") {
14051155 yield* Queue.offer(consoleReleases, undefined);
14061156 }
......@@ -1410,20 +1160,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
14101160 };
14111161 yield* Scope.addFinalizer(
14121162 scope,
1413 Effect.all(
1414 [
1415 Ref.update(diagnosticsRef, (diagnostics) =>
1416 replaceMap(diagnostics, (copy) => {
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 ),
1163 attempt({ operation: "detachControlSession", webContentsId: wc.id }, () => {
1164 wcDebugger.off("message", onMessage);
1165 if (wcDebugger.isAttached()) wcDebugger.detach();
1166 }).pipe(Effect.ignore),
14271167 );
14281168 const control: BrowserControlSession = {
14291169 webContentsId: wc.id,
......@@ -1433,15 +1173,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
14331173 onMessage,
14341174 };
14351175 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 );
14451176 yield* attempt({ operation: "attachDebuggerListeners", webContentsId: wc.id }, () => {
14461177 wcDebugger.on("message", onMessage);
14471178 wcDebugger.attach("1.3");
......@@ -1461,15 +1192,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
14611192 color: { r: 255, g: 255, b: 255, a: 1 },
14621193 }),
14631194 );
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 );
14731195 return [
14741196 control,
14751197 replaceMap(sessions, (copy) => {
......@@ -1486,250 +1208,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
14861208 );
14871209 });
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
17331211 const cancelPickElement = Effect.fn("PreviewManager.cancelPickElement")(function* (
17341212 tabId: string,
17351213 ) {
......@@ -1760,44 +1238,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
17601238 return { kind: "Success", url, title };
17611239 };
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
18011241 const attachListeners = Effect.fn("PreviewManager.attachListeners")(function* (
18021242 tabId: string,
18031243 wc: Electron.WebContents,
......@@ -2007,15 +1447,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
20071447 const handleHumanInput = Effect.fn("PreviewManager.handleHumanInput")(function* (
20081448 rawSignal?: unknown,
20091449 ) {
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 );
20191450 yield* update(
20201451 tabId,
20211452 { controller: "human" },
......@@ -2214,6 +1645,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
22141645 zoomFactor: normalizeZoomFactor(defaults?.zoomFactor),
22151646 pictureInPicture: false,
22161647 colorScheme: defaults?.colorScheme ?? "system",
1648 ...(defaults?.serverTab === undefined ? {} : { serverTab: defaults.serverTab }),
22171649 audioMuted: false,
22181650 audible: false,
22191651 controller: "none",
......@@ -2269,7 +1701,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
22691701 const closedTab = tab.value;
22701702 if (closedTab.webContentsId != null) {
22711703 yield* Effect.all(
2272 [detachControlSession(closedTab.webContentsId), detachListeners(closedTab.webContentsId)],
1704 [
1705 detachControlSession(closedTab.webContentsId, closedTab.serverTab),
1706 detachListeners(closedTab.webContentsId),
1707 ],
22731708 { concurrency: 2, discard: true },
22741709 );
22751710 }
......@@ -2593,7 +2028,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
25932028 }
25942029 // URL-bar navigation hands the page back to the human. The agent's own
25952030 // navigation shares this path; its next action marks the page again.
2596 agentDrivenWebContents.delete(wc);
25972031 if (wc.getURL() === url) {
25982032 yield* attempt({ operation: "navigate.reload", tabId, webContentsId: wc.id }, () =>
25992033 wc.reload(),
......@@ -2887,6 +2321,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
28872321 yield* detachControlSession(wc.id);
28882322 return;
28892323 }
2324 if (afterAttach.serverTab) {
2325 yield* listenForAgentPointers;
2326 browserHost.attach(afterAttach.serverTab, { webContents: wc, debugger: control.debugger });
2327 }
28902328 if (afterAttach.colorScheme !== "system") {
28912329 yield* attemptPromise({ operation: "applyColorScheme", tabId, webContentsId: wc.id }, () =>
28922330 control.debugger.sendCommand("Emulation.setEmulatedMedia", {
......@@ -3623,24 +3061,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
36233061 });
36243062 };
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
36443064 const startRecording = Effect.fn("PreviewManager.startRecording")(function* (
36453065 tabId: string,
36463066 options: RecordingInputOptions = DEFAULT_RECORDING_INPUT_OPTIONS,
......@@ -3763,956 +3183,86 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
37633183 };
37643184 });
37653185
3766 const automationStatus = Effect.fn("PreviewManager.automationStatus")(function* (tabId: string) {
3767 const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
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,
3186 const revealArtifact = Effect.fn("PreviewManager.revealArtifact")(function* (
3187 artifactPath: string,
39063188 ) {
3907 const wc = yield* requireWebContents(tabId);
3908 return yield* withControlSession(tabId, wc, "snapshot", (send) =>
3909 captureAutomationSnapshot(tabId, wc, send),
3189 const resolvedPath = yield* resolveArtifactPath(artifactPath);
3190 yield* attempt({ operation: "revealArtifact", artifactPath: resolvedPath }, () =>
3191 shell.showItemInFolder(resolvedPath),
39103192 );
39113193 });
39123194
3913 const resolveClickPoint = Effect.fn("PreviewManager.resolveClickPoint")(function* (
3914 tabId: string,
3915 send: SendCommand,
3916 input: PreviewAutomationClickInput,
3195 const copyArtifactToClipboard = Effect.fn("PreviewManager.copyArtifactToClipboard")(function* (
3196 artifactPath: string,
39173197 ) {
3918 if (!("selector" in input) && !("locator" in input)) {
3919 return { x: input.x!, y: input.y! };
3920 }
3921 const locator = automationLocator(input)!;
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,
3198 const resolvedPath = yield* resolveArtifactPath(artifactPath);
3199 const image = yield* attempt(
3200 { operation: "copyArtifactToClipboard.load", artifactPath: resolvedPath },
3201 () => nativeImage.createFromPath(resolvedPath),
39493202 );
3950 if ("invalidSelector" in point) {
3951 return yield* new PreviewAutomationInvalidSelectorError({
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 });
3203 if (image.isEmpty()) {
3204 return yield* new PreviewArtifactImageLoadError({ artifactPath: resolvedPath });
39653205 }
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 );
39673215 });
39683216
3969 const emitPointerEvent = Effect.fn("PreviewManager.emitPointerEvent")(function* (
3970 event: DesktopPreviewPointerEvent,
3971 ) {
3972 const recording = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(event.tabId);
3973 const tab = (yield* SynchronizedRef.get(tabsRef)).get(event.tabId);
3974 const webContentsId = tab?.webContentsId;
3217 let pointerSequence = 0;
3218 /**
3219 * The server reports where an agent action is about to land on a tab it
3220 * drives here. The live cursor and desktop recordings draw it like any agent.
3221 */
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;
39753244 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 }, () => {
39773246 const contents = webContents.fromId(webContentsId);
39783247 if (contents && !contents.isDestroyed()) contents.send(RECORDING_POINTER_CHANNEL, event);
3979 });
3248 }).pipe(Effect.ignore);
39803249 }
39813250 const listeners = yield* Ref.get(pointerEventListenersRef);
39823251 yield* Effect.forEach(
39833252 listeners,
3984 (listener) => deliverEvent("pointer-event", event.tabId, () => listener(event)),
3253 (listener) => deliverEvent("pointer-event", tab.tabId, () => listener(event)),
39853254 { discard: true },
39863255 );
39873256 });
3988
3989 const performAutomationClick = Effect.fn("PreviewManager.performAutomationClick")(function* (
3990 tabId: string,
3991 input: PreviewAutomationClickInput,
3992 send: SendCommand,
3993 ) {
3994 yield* prepareAutomationInput(send, true);
3995 const point = yield* resolveClickPoint(tabId, send, input);
3996 const viewport = yield* evaluateWithDebugger<{ width: number; height: number }>(
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 ]),
3257 // Listening starts with the first tab of the desktop's own server.
3258 let pointersStarted = false;
3259 const listenForAgentPointers = Effect.suspend(() => {
3260 if (pointersStarted) return Effect.void;
3261 pointersStarted = true;
3262 return browserHost.pointers.pipe(
3263 Stream.runForEach(emitAgentPointer),
3264 Effect.forkIn(parentScope),
3265 Effect.asVoid,
47163266 );
47173267 });
47183268
......@@ -4736,7 +3286,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
47363286 yield* Effect.all(
47373287 [
47383288 Ref.set(listenersRef, new Set()),
4739 Ref.set(expectedAgentInputsRef, new Map()),
47403289 Ref.set(pointerEventListenersRef, new Set()),
47413290 Ref.set(recordingFrameListenersRef, new Set()),
47423291 Ref.set(recordingInputListenersRef, new Set()),
......@@ -4748,14 +3297,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
47483297 yield* Effect.addFinalizer(() => destroy().pipe(Effect.ignore));
47493298
47503299 return {
4751 automationClick,
4752 automationEvaluate,
4753 automationPress,
4754 automationScroll,
4755 automationSnapshot,
4756 automationStatus,
4757 automationType,
4758 automationWaitFor,
47593300 cancelPickElement,
47603301 captureScreenshot,
47613302 closeTab,
......@@ -4764,7 +3305,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
47643305 goBack,
47653306 goForward,
47663307 hardReload,
4767 installDownloadHandler,
47683308 navigate,
47693309 openPictureInPicture,
47703310 openDevTools,
......@@ -4774,6 +3314,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
47743314 refresh,
47753315 registerWebview,
47763316 resetZoom: (tabId: string) => applyZoom(tabId, () => DEFAULT_ZOOM_FACTOR),
3317 setZoomFactor: (tabId: string, zoomFactor: number) =>
3318 applyZoom(tabId, () => normalizeZoomFactor(zoomFactor)),
47773319 revealArtifact,
47783320 saveRecording,
47793321 setAnnotationTheme,
......@@ -4921,143 +3463,6 @@ export class PreviewAutomationDebuggerAttachedError extends Schema.TaggedError<P
49213463 }
49223464}
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
50613466export const PreviewManagerError = Schema.Union([
50623467 PreviewTabNotFoundError,
50633468 PreviewWebContentsNotFoundError,
......@@ -5070,23 +3475,9 @@ export const PreviewManagerError = Schema.Union([
50703475 PreviewArtifactImageLoadError,
50713476 PreviewAutomationDevToolsOpenError,
50723477 PreviewAutomationDebuggerAttachedError,
5073 PreviewAutomationEvaluationError,
5074 PreviewAutomationTargetNotFoundError,
5075 PreviewAutomationTargetNotEditableError,
5076 PreviewAutomationCoordinatesOutsideViewportError,
5077 PreviewAutomationInvalidSelectorError,
5078 PreviewAutomationResultTooLargeError,
5079 PreviewAutomationTimeoutError,
5080 PreviewAutomationControlInterruptedError,
50813478]);
50823479export type PreviewManagerError = typeof PreviewManagerError.Type;
50833480
5084const isPreviewAutomationControlInterruptedError = Schema.is(
5085 PreviewAutomationControlInterruptedError,
5086);
5087const isPreviewAutomationEvaluationError = Schema.is(PreviewAutomationEvaluationError);
5088const isPreviewAutomationInvalidSelectorError = Schema.is(PreviewAutomationInvalidSelectorError);
5089
50903481export class PreviewManager extends Context.Service<
50913482 PreviewManager,
50923483 {
......@@ -5122,6 +3513,10 @@ export class PreviewManager extends Context.Service<
51223513 tabId: string,
51233514 colorScheme: DesktopPreviewColorScheme,
51243515 ) => Effect.Effect<void, PreviewManagerError>;
3516 readonly setZoomFactor: (
3517 tabId: string,
3518 zoomFactor: number,
3519 ) => Effect.Effect<void, PreviewManagerError>;
51253520 readonly setAudioMuted: (
51263521 tabId: string,
51273522 audioMuted: boolean,
......@@ -5162,36 +3557,6 @@ export class PreviewManager extends Context.Service<
51623557 mimeType: string,
51633558 data: Uint8Array,
51643559 ) => 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>;
51953560 readonly subscribeStateChanges: (listener: Listener) => Effect.Effect<void, never, Scope.Scope>;
51963561 readonly subscribePointerEvents: (
51973562 listener: PointerEventListener,
......@@ -5209,6 +3574,18 @@ export class PreviewManager extends Context.Service<
52093574export const make = Effect.gen(function* PreviewManagerMake() {
52103575 const environment = yield* DesktopEnvironment.DesktopEnvironment;
52113576 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
52123589 const operations = yield* makeNativeOperations(
52133590 environment.browserArtifactsDir,
52143591 environment.path.join(environment.dirname, "preview-pip-preload.cjs"),
......@@ -5225,7 +3602,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {
52253602 (cause) => new PreviewOperationError({ operation: "getBrowserSession", cause }),
52263603 ),
52273604 );
5228 operations.installDownloadHandler(session);
3605 placeServerDownloads(session);
52293606 return session;
52303607 },
52313608 ),
......@@ -5244,6 +3621,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {
52443621 reapplyZoom: operations.reapplyZoom,
52453622 hardReload: operations.hardReload,
52463623 setColorScheme: operations.setColorScheme,
3624 setZoomFactor: operations.setZoomFactor,
52473625 setAudioMuted: operations.setAudioMuted,
52483626 openDevTools: operations.openDevTools,
52493627 clearCookies: Effect.fn("PreviewManager.clearCookies")(function* (partitions) {
......@@ -5284,14 +3662,6 @@ export const make = Effect.gen(function* PreviewManagerMake() {
52843662 startRecording: operations.startRecording,
52853663 stopRecording: operations.stopRecording,
52863664 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,
52953665 subscribeStateChanges: operations.subscribeStateChanges,
52963666 subscribePointerEvents: operations.subscribePointerEvents,
52973667 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 = {
270270 NSLocalNetworkUsageDescription:
271271 "Allow T3 Code to connect to T3 Code servers on your local network or tailnet.",
272272 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"],
273276 ITSAppUsesNonExemptEncryption: false,
274277 // The App Store screenshot harness rotates the iPad interface from
275278 // 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(
1818const escapedWorkspaceRoot = workspaceRoot.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1919const mobileShikiRoot = path.dirname(require.resolve("shiki/package.json", { paths: [__dirname] }));
2020const generatedDeviceStreamRoot = path.join(__dirname, ".generated", "device-stream");
21const generatedPreviewStreamRoot = path.join(__dirname, ".generated", "preview-stream");
2122const resolveShikiDependencyRoot = (packageName) => {
2223 const entryPath = require.resolve(packageName, { paths: [mobileShikiRoot] });
2324 let currentDir = path.dirname(entryPath);
......@@ -48,6 +49,7 @@ config.resolver = {
4849 ...config.resolver?.extraNodeModules,
4950 "@t3tools/mobile-third-party-licenses": generatedLicenseModuleRoot,
5051 "@t3tools/mobile-device-stream": generatedDeviceStreamRoot,
52 "@t3tools/mobile-preview-stream": generatedPreviewStreamRoot,
5153 shiki: mobileShikiRoot,
5254 "@shikijs/core": resolveShikiDependencyRoot("@shikijs/core"),
5355 "@shikijs/engine-javascript": resolveShikiDependencyRoot("@shikijs/engine-javascript"),
......@@ -95,35 +97,53 @@ async function generateMobileThirdPartyLicenses() {
9597 ]);
9698}
9799
98async function prepareDeviceStream() {
99 const { generateDeviceStreamScript } = await import(
100async function prepareStreamScripts() {
101 const { generateDeviceStreamScript, generatePreviewStreamScript } = await import(
100102 pathToFileURL(path.join(__dirname, "scripts", "generate-device-stream.mts")).href
101103 );
102 await generateDeviceStreamScript();
104 const generateAll = () =>
105 Promise.all([generateDeviceStreamScript(), generatePreviewStreamScript()]);
106 await generateAll();
103107 if (process.env.NODE_ENV !== "production") {
104108 let rebuild = Promise.resolve();
105 for (const [directory, files] of [
106 [path.join(__dirname, "src/features/devices"), ["device-stream.browser.ts"]],
109 for (const [directory, files, generate] of [
107110 [
108 path.join(workspaceRoot, "packages/client-runtime/src/device"),
109 ["stream.ts", "hubAccess.ts"],
111 "apps/mobile/src/features/devices",
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,
110126 ],
111127 ]) {
112 // The generated module participates in Metro's normal Fast Refresh.
113 fs.watch(directory, { persistent: false }, (_event, filename) => {
128 // The generated modules participate in Metro's normal Fast Refresh.
129 fs.watch(path.join(workspaceRoot, directory), { persistent: false }, (_event, filename) => {
114130 if (filename && !files.includes(String(filename))) return;
115 rebuild = rebuild.then(generateDeviceStreamScript).catch((error) => {
116 console.error("Could not rebuild the device stream:", error);
117 });
131 rebuild = rebuild
132 .then(generate)
133 .then(() => undefined)
134 .catch((error) => {
135 console.error("Could not rebuild a WebView stream script:", error);
136 });
118137 });
119138 }
120139 }
121140}
122141
123module.exports = Promise.all([generateMobileThirdPartyLicenses(), prepareDeviceStream()]).then(() =>
124 withUniwindConfig(config, {
125 cssEntryFile: "./global.css",
126 extraThemes,
127 polyfills: { rem: 14 },
128 }),
142module.exports = Promise.all([generateMobileThirdPartyLicenses(), prepareStreamScripts()]).then(
143 () =>
144 withUniwindConfig(config, {
145 cssEntryFile: "./global.css",
146 extraThemes,
147 polyfills: { rem: 14 },
148 }),
129149);
apps/mobile/scripts/generate-device-stream.mts+12-8
......@@ -5,8 +5,9 @@ import { build } from "vite-plus";
55
66const 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. */
9export async function generateDeviceStreamScript() {
8/** Metro embeds each shared browser transport as a small script in a native WebView. */
9async function generateWebViewScript(feature: string, name: string) {
10 const stem = `${name.toLowerCase()}-stream`;
1011 const result = await build({
1112 configFile: false,
1213 logLevel: "silent",
......@@ -15,8 +16,8 @@ export async function generateDeviceStreamScript() {
1516 target: "es2022",
1617 minify: true,
1718 lib: {
18 entry: NodePath.join(mobileRoot, "src/features/devices/device-stream.browser.ts"),
19 name: "T3DeviceStream",
19 entry: NodePath.join(mobileRoot, "src/features", feature, `${stem}.browser.ts`),
20 name: `T3${name}Stream`,
2021 formats: ["iife"],
2122 },
2223 },
......@@ -25,15 +26,18 @@ export async function generateDeviceStreamScript() {
2526 const chunk = bundles
2627 .flatMap((bundle) => ("output" in bundle ? bundle.output : []))
2728 .find((output) => output.type === "chunk");
28 if (!chunk) throw new Error("Device stream build did not emit a script.");
29 const root = NodePath.join(mobileRoot, ".generated/device-stream");
29 if (!chunk) throw new Error(`${name} build did not emit a script.`);
30 const root = NodePath.join(mobileRoot, ".generated", stem);
3031 await NodeFSP.mkdir(root, { recursive: true });
31 for (const [name, contents] of [
32 for (const [file, contents] of [
3233 ["index.js", `module.exports = ${JSON.stringify(chunk.code)};\n`],
3334 ["package.json", '{"main":"index.js"}\n'],
3435 ] as const) {
35 const destination = NodePath.join(root, name);
36 const destination = NodePath.join(root, file);
3637 const previous = await NodeFSP.readFile(destination, "utf8").catch(() => null);
3738 if (previous !== contents) await NodeFSP.writeFile(destination, contents);
3839 }
3940}
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
4343import { ReviewSheet } from "./features/review/ReviewSheet";
4444import { ThreadTerminalRouteScreen } from "./features/terminal/ThreadTerminalRouteScreen";
4545import { DevicePreviewRouteScreen } from "./features/devices/DevicePreviewRouteScreen";
46import { BrowserPreviewRouteScreen } from "./features/browser/BrowserPreviewRouteScreen";
4647import { GitBranchesSheet } from "./features/threads/git/GitBranchesSheet";
4748import { GitCommitSheet } from "./features/threads/git/GitCommitSheet";
4849import { GitConfirmSheet } from "./features/threads/git/GitConfirmSheet";
......@@ -548,6 +549,7 @@ const WORKSPACE_OVERLAY_ROUTES = new Set([
548549 "ThreadQueue",
549550 "ThreadReviewComment",
550551 "ThreadDevicePreview",
552 "ThreadBrowserPreview",
551553 "ThreadSettingsSheet",
552554]);
553555
......@@ -705,6 +707,15 @@ const RootStackConfig = createNativeStackNavigator({
705707 navigationBarHidden: true,
706708 },
707709 }),
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 }),
708719 ThreadReview: createNativeStackScreen({
709720 screen: ReviewSheet,
710721 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
9595import { deviceEnvironment } from "../../state/device";
9696import { useEnvironmentQuery } from "../../state/query";
9797import { threadDevicePreviews } from "../devices/threadDevicePreviews";
98import { ThreadBrowserFloat } from "../browser/ThreadBrowserFloat";
99import { useThreadServerBrowserTabs } from "../../state/preview";
98100import type { QueuedThreadMessage } from "../../state/thread-outbox-model";
99101import { scopedThreadKey } from "../../lib/scopedEntities";
100102import {
......@@ -333,6 +335,22 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
333335 threadId: props.selectedThread.id,
334336 });
335337 }, [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 );
336354 const insets = useSafeAreaInsets();
337355 const isKeyboardVisible = useKeyboardState((state) => state.isVisible);
338356 const liveKeyboardHeight = useKeyboardState((state) => state.height);
......@@ -512,6 +530,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
512530 queuedCount > 0 ||
513531 agentsSegment !== null ||
514532 devicePreviews.length > 0 ||
533 browserTabs.tabs.length > 0 ||
515534 props.connectionStateLabel !== "connected" ||
516535 props.queuedMessages.length > 0 ||
517536 props.selectedThreadFeed.some(
......@@ -1129,6 +1148,18 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
11291148 <View className="flex-1" />
11301149 )}
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
11321163 {/* Floating composer — sticks to keyboard via KeyboardStickyView */}
11331164 {showContent ? (
11341165 <KeyboardStickyView
......@@ -1161,6 +1192,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
11611192 ? { count: devicePreviews.length, onPress: openDevicePreview }
11621193 : null
11631194 }
1195 browserPreview={
1196 browserTabs.tabs.length > 0
1197 ? { count: browserTabs.tabs.length, onPress: () => openBrowserPreview() }
1198 : null
1199 }
11641200 showScrollToEnd={showScrollToEndButton}
11651201 onScrollToEnd={handleScrollToEnd}
11661202 agents={agentsSegment}
apps/mobile/src/features/threads/floating-working-control.tsx+23-13
......@@ -26,6 +26,7 @@ import { AppText as Text } from "../../components/AppText";
2626import { SymbolView } from "../../components/AppSymbol";
2727import { ControlPill } from "../../components/ControlPill";
2828import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
29import { BrowserPreviewButton } from "../browser/browser-preview-button";
2930import { DevicePreviewButton } from "../devices/device-preview-button";
3031import type { FloatingWorkingStatus } from "./floating-working-status";
3132import { ShimmeringWorkContent } from "./thread-work-log";
......@@ -67,6 +68,7 @@ export function FloatingWorkingControl(props: {
6768 readonly colorScheme: "light" | "dark";
6869 readonly status: FloatingWorkingStatus | null;
6970 readonly devicePreview: { readonly count: number; readonly onPress: () => void } | null;
71 readonly browserPreview: { readonly count: number; readonly onPress: () => void } | null;
7072 readonly showScrollToEnd: boolean;
7173 readonly onScrollToEnd: () => void;
7274 readonly agents: SubagentPillSegment | null;
......@@ -81,8 +83,8 @@ export function FloatingWorkingControl(props: {
8183 const [queueWidth, setQueueWidth] = useState(0);
8284 const [agentsWidth, setAgentsWidth] = useState(0);
8385 const hasQueue = props.queuedCount > 0;
84 const hasDevicePreview = props.devicePreview !== null;
85 const [deviceWidth, setDeviceWidth] = useState(0);
86 const hasPreview = props.devicePreview !== null || props.browserPreview !== null;
87 const [previewWidth, setPreviewWidth] = useState(0);
8688 const agents = props.agents;
8789 const hasAgents = agents !== null;
8890 // Segments keep their measured width; only the status label absorbs the
......@@ -94,7 +96,7 @@ export function FloatingWorkingControl(props: {
9496 32 -
9597 (hasQueue ? queueWidth : 0) -
9698 (hasAgents ? agentsWidth : 0) -
97 (hasDevicePreview ? deviceWidth : 0),
99 (hasPreview ? previewWidth : 0),
98100 );
99101 const separationProgress = useSharedValue(props.showScrollToEnd ? 1 : 0);
100102
......@@ -131,7 +133,7 @@ export function FloatingWorkingControl(props: {
131133 // Forget the width while no label is shown so the next one appears at its
132134 // own size instead of animating from the previous label's.
133135 const hasStatus = props.status !== null;
134 const hasCapsule = hasStatus || hasQueue || hasAgents || hasDevicePreview;
136 const hasCapsule = hasStatus || hasQueue || hasAgents || hasPreview;
135137 useEffect(() => {
136138 if (!hasStatus) {
137139 measuredWidthRef.current = null;
......@@ -151,9 +153,15 @@ export function FloatingWorkingControl(props: {
151153 return null;
152154 }
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);
154162 // The queue, agents, and reconnect labels have separate tap targets.
155163 const statusInteractive = props.status?.kind === "connection";
156 const capsuleInteractive = statusInteractive || hasQueue || hasAgents || hasDevicePreview;
164 const capsuleInteractive = statusInteractive || hasQueue || hasAgents || hasPreview;
157165 // The host stays centered on the capsule, but its measurement constraint
158166 // comes from the overlay, independent of the capsule's current width.
159167 const statusContent =
......@@ -184,16 +192,18 @@ export function FloatingWorkingControl(props: {
184192 const capsuleContent = (
185193 <View className="flex-row items-center">
186194 {statusContent}
187 {props.devicePreview !== null ? (
195 {hasPreview ? (
188196 <View
189197 className="h-11 flex-row items-center"
190 onLayout={(event) => setDeviceWidth(event.nativeEvent.layout.width)}
198 onLayout={(event) => setPreviewWidth(event.nativeEvent.layout.width)}
191199 >
192200 {hasStatus ? <View className="h-4 w-px bg-border" /> : null}
193 <DevicePreviewButton
194 {...props.devicePreview}
195 compact={hasStatus || hasAgents || hasQueue}
196 />
201 {props.devicePreview !== null ? (
202 <DevicePreviewButton {...props.devicePreview} compact={previewCompact} />
203 ) : null}
204 {props.browserPreview !== null ? (
205 <BrowserPreviewButton {...props.browserPreview} compact={previewCompact} />
206 ) : null}
197207 </View>
198208 ) : null}
199209 {agents !== null ? (
......@@ -205,7 +215,7 @@ export function FloatingWorkingControl(props: {
205215 onLayout={(event) => setAgentsWidth(event.nativeEvent.layout.width)}
206216 className="h-11 flex-row items-center gap-1.5 px-3 active:opacity-70"
207217 >
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}
209219 <SymbolView name="person.2" size={13} tintColorClassName="accent-foreground-muted" />
210220 <Text className="font-t3-medium text-xs tabular-nums" numberOfLines={1}>
211221 {agents.label}
......@@ -222,7 +232,7 @@ export function FloatingWorkingControl(props: {
222232 style={{ maxWidth: Math.min(overlayWidth, windowWidth) * 0.45 }}
223233 className="h-11 flex-row items-center gap-2 px-3 active:opacity-70"
224234 >
225 {hasStatus || hasDevicePreview || hasAgents ? (
235 {hasStatus || hasPreview || hasAgents ? (
226236 <View className="mr-1 h-4 w-px bg-border" />
227237 ) : null}
228238 <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 @@
4242 "effect": "catalog:",
4343 "jose": "catalog:",
4444 "node-pty": "^1.2.0-beta.15",
45 "playwright-core": "1.60.0",
4546 "proper-lockfile": "4.1.2",
4647 "stream-chain": "^4.2.5",
4748 "stream-json": "3.6.0",
apps/server/src/auth/RpcAuthorization.ts+2-3
......@@ -187,13 +187,12 @@ export const RPC_REQUIRED_SCOPES = {
187187 [WS_METHODS.previewOpen]: AuthOrchestrationOperateScope,
188188 [WS_METHODS.previewNavigate]: AuthOrchestrationOperateScope,
189189 [WS_METHODS.previewResize]: AuthOrchestrationOperateScope,
190 [WS_METHODS.previewAdjust]: AuthOrchestrationOperateScope,
190191 [WS_METHODS.previewRefresh]: AuthOrchestrationOperateScope,
191192 [WS_METHODS.previewClose]: AuthOrchestrationOperateScope,
192193 [WS_METHODS.previewList]: AuthOrchestrationReadScope,
194 [WS_METHODS.previewClearProfile]: AuthOrchestrationOperateScope,
193195 [WS_METHODS.previewReportStatus]: AuthOrchestrationOperateScope,
194 [WS_METHODS.previewAutomationConnect]: AuthOrchestrationOperateScope,
195 [WS_METHODS.previewAutomationRespond]: AuthOrchestrationOperateScope,
196 [WS_METHODS.previewAutomationFocusHost]: AuthOrchestrationOperateScope,
197196 [WS_METHODS.subscribePreviewEvents]: AuthOrchestrationReadScope,
198197 [WS_METHODS.subscribeDiscoveredLocalServers]: AuthOrchestrationReadScope,
199198 [WS_METHODS.deviceConfigure]: AuthOrchestrationOperateScope,
apps/server/src/auth/http.ts+30
......@@ -157,6 +157,36 @@ export function failEnvironmentNotFound(reason: EnvironmentResourceNotFoundReaso
157157 );
158158}
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
160190export function failEnvironmentInternal(reason: EnvironmentInternalErrorReason, error?: unknown) {
161191 return Effect.gen(function* () {
162192 const traceId = yield* currentEnvironmentTraceId;
apps/server/src/binCli.ts+2
......@@ -10,6 +10,7 @@ import packageJson from "../package.json" with { type: "json" };
1010import { acpMcpBridgeCommand, acpMcpCallCommand } from "./cli/acpMcpBridge.ts";
1111import { authCommand } from "./cli/auth.ts";
1212import { appCommand } from "./cli/app.ts";
13import { browserCommand } from "./cli/browser.ts";
1314import { connectCommand } from "./cli/connect.ts";
1415import { pairCommand } from "./cli/pair.ts";
1516import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";
......@@ -71,6 +72,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) =>
7172 appCommand,
7273 pairCommand,
7374 authCommand,
75 browserCommand,
7476 projectCommand,
7577 serviceCommand,
7678 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 = (
362362 const desktopBootstrapSecret = bootstrap?.desktopBootstrapSecret;
363363 const desktopTelemetryFd = bootstrap?.desktopTelemetryFd;
364364 const desktopTelemetryControlFd = bootstrap?.desktopTelemetryControlFd;
365 const desktopBrowserFd = bootstrap?.desktopBrowserFd;
366 const desktopBrowserControlFd = bootstrap?.desktopBrowserControlFd;
365367 const resourceMonitorPath = bootstrap?.resourceMonitorPath;
366368 const autoBootstrapProjectFromCwd = Option.getOrElse(
367369 resolveOptionPrecedence(
......@@ -468,6 +470,8 @@ export const resolveServerConfig = (
468470 ...(desktopBootstrapSecret === undefined ? {} : { desktopBootstrapSecret }),
469471 desktopTelemetryFd,
470472 desktopTelemetryControlFd,
473 desktopBrowserFd,
474 desktopBrowserControlFd,
471475 resourceMonitorPath,
472476 autoBootstrapProjectFromCwd,
473477 logWebSocketEvents,
apps/server/src/cli/invocation.test.ts+28-1
......@@ -10,7 +10,11 @@ import * as Effect from "effect/Effect";
1010import * as FileSystem from "effect/FileSystem";
1111import * as Path from "effect/Path";
1212
13import { formatCliCommand, resolveServerInstallation } from "./invocation.ts";
13import {
14 formatCliCommand,
15 resolveRootCliCommand,
16 resolveServerInstallation,
17} from "./invocation.ts";
1418
1519it("formats package runner commands from their cache entry paths", () => {
1620 for (const [entryPath, expected] of [
......@@ -98,6 +102,29 @@ it("formats serve suggestions to match the launching command", () => {
98102 );
99103});
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
101128it.layer(NodeServices.layer)("manual server installation ownership", (it) => {
102129 it.effect("recognizes runner caches for both script and executable packages", () =>
103130 Effect.gen(function* () {
apps/server/src/cli/invocation.ts+26
......@@ -145,3 +145,29 @@ export const resolveCliCommand = (subcommand: string) =>
145145 version: packageJson.version,
146146 }),
147147 );
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: {
335335 startupPresentation: "headless",
336336 desktopBootstrapToken: undefined,
337337 desktopTelemetryFd: undefined,
338 desktopBrowserFd: undefined,
339 desktopBrowserControlFd: undefined,
338340 desktopTelemetryControlFd: undefined,
339341 resourceMonitorPath: undefined,
340342 autoBootstrapProjectFromCwd: false,
apps/server/src/config.ts+4
......@@ -97,6 +97,8 @@ export class ServerConfig extends Context.Service<
9797 readonly desktopBootstrapSecret?: string | undefined;
9898 readonly desktopTelemetryFd?: number | undefined;
9999 readonly desktopTelemetryControlFd?: number | undefined;
100 readonly desktopBrowserFd?: number | undefined;
101 readonly desktopBrowserControlFd?: number | undefined;
100102 readonly resourceMonitorPath?: string | undefined;
101103 readonly autoBootstrapProjectFromCwd: boolean;
102104 readonly logWebSocketEvents: boolean;
......@@ -235,6 +237,8 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* (
235237 desktopBootstrapToken: undefined,
236238 desktopTelemetryFd: undefined,
237239 desktopTelemetryControlFd: undefined,
240 desktopBrowserFd: undefined,
241 desktopBrowserControlFd: undefined,
238242 resourceMonitorPath: undefined,
239243 staticDir: undefined,
240244 devUrl,
apps/server/src/device/DeviceHubProxy.ts+5-41
......@@ -11,11 +11,7 @@
1111 * Only the routes the Device panel needs are forwarded. Anything under the
1212 * hub's dashboard, exec, or WebRTC surface is rejected here.
1313 */
14import {
15 AuthOrchestrationReadScope,
16 AuthOrchestrationOperateScope,
17 type AuthEnvironmentScope,
18} from "@t3tools/contracts";
14import { AuthOrchestrationReadScope, AuthOrchestrationOperateScope } from "@t3tools/contracts";
1915import * as Effect from "effect/Effect";
2016import * as Option from "effect/Option";
2117import {
......@@ -28,12 +24,7 @@ import {
2824import * as Socket from "effect/socket/Socket";
2925import * as NodeSocket from "@effect/platform-node/NodeSocket";
3026
31import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
32import {
33 failEnvironmentAuthInvalid,
34 failEnvironmentInternal,
35 failEnvironmentScopeRequired,
36} from "../auth/http.ts";
27import { authenticateMediaRequest } from "../auth/http.ts";
3728import * as DeviceService from "./DeviceService.ts";
3829
3930const ALLOWED_PATHS: ReadonlyArray<RegExp> = [
......@@ -80,35 +71,6 @@ const DROPPED_REQUEST_HEADERS = new Set([
8071const isWebSocketUpgrade = (request: HttpServerRequest.HttpServerRequest) =>
8172 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
11274const forwardHeaders = (request: HttpServerRequest.HttpServerRequest, origin: string) => {
11375 const headers: Record<string, string> = {};
11476 for (const [name, value] of Object.entries(request.headers)) {
......@@ -206,7 +168,9 @@ const handler = Effect.gen(function* () {
206168 const controlsDevice =
207169 (upgrade && hubPath !== "/api/devices/ws") ||
208170 (!readOnly && /\/api\/(stream-(mode|settings)|fold)$/.test(hubPath));
209 yield* authenticate(controlsDevice ? AuthOrchestrationOperateScope : AuthOrchestrationReadScope);
171 yield* authenticateMediaRequest(
172 controlsDevice ? AuthOrchestrationOperateScope : AuthOrchestrationReadScope,
173 );
210174 const devices = yield* DeviceService.DeviceService;
211175 const ready = yield* devices.currentReadiness(url.value.searchParams.get("hostId") ?? undefined);
212176 if (!ready) {
apps/server/src/environment/ServerEnvironment.ts+1
......@@ -258,6 +258,7 @@ export const make = Effect.gen(function* () {
258258 ? { serverSelfUpdateProgress: true }
259259 : {}),
260260 ...(desktopAppUpdate ? { desktopAppUpdate: true } : {}),
261 serverBrowser: true,
261262 },
262263 };
263264
apps/server/src/htmlRender/HtmlRender.test.ts+1-1
......@@ -24,7 +24,7 @@ import * as NodeURL from "node:url";
2424import { resolveAttachmentPathById } from "../attachmentStore.ts";
2525import * as ServerConfig from "../config.ts";
2626import * as HtmlRender from "./HtmlRender.ts";
27import * as PreviewBrowser from "./PreviewBrowser.ts";
27import * as PreviewBrowser from "../preview/PreviewBrowser.ts";
2828
2929// Real-browser tests run only when this names a chrome-headless-shell, for
3030// example one T3 installed under <T3 home>/tools/chrome-headless-shell.
apps/server/src/htmlRender/HtmlRender.ts+26-35
......@@ -1,5 +1,5 @@
11import type { ThreadId } from "@t3tools/contracts";
2import { HostProcessUserId } from "@t3tools/shared/hostProcess";
2import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
33import {
44 clampHtmlRenderHeight,
55 HTML_RENDER_COLUMN_WIDTH,
......@@ -29,9 +29,11 @@ import type * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
2929
3030import { resolveAttachmentRelativePath } from "../attachmentPaths.ts";
3131import { createAttachmentId } from "../attachmentStore.ts";
32import { resolveRootCliCommand } from "../cli/invocation.ts";
3233import * as ServerConfig from "../config.ts";
3334import * 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
3638const MIB = 1024 * 1024;
3739const MAX_IMAGE_BYTES = 10 * MIB;
......@@ -118,6 +120,7 @@ export class HtmlRender extends Context.Service<
118120 | PreviewBrowser.PreviewBrowserInstallError
119121 | PreviewBrowser.PreviewBrowserInstallingError
120122 | PreviewBrowser.PreviewBrowserUnsupportedError
123 | PreviewBrowserHost.PreviewBrowserHostError
121124 | HeadlessChrome.HtmlRenderBrowserError
122125 >;
123126 }
......@@ -336,42 +339,30 @@ const make = Effect.gen(function* () {
336339 FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner
337340 >();
338341 const browsers = yield* Semaphore.make(MAX_CONCURRENT_BROWSERS);
339 // Chrome refuses its sandbox as root. Other hosts that cannot provide one
340 // (Ubuntu 23.10+ AppArmor) are learned from the first launch and remembered.
341 let noSandbox = (yield* HostProcessUserId) === 0;
342 // Chrome's sandbox stays on unless the operator explicitly turns it off.
343 // Chrome also refuses it as root, where that opt-out is the only way to run.
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. */
343348 const launching = <A>(
344 run: (
345 noSandbox: boolean,
346 ) => Effect.Effect<
347 A,
348 HeadlessChrome.HtmlRenderBrowserError | HeadlessChrome.HtmlRenderSandboxUnavailableError
349 >,
349 executable: string,
350 run: (noSandbox: boolean) => Effect.Effect<A, HeadlessChrome.HtmlRenderBrowserError>,
350351 ) =>
351352 browsers.withPermits(1)(
352 // Read once a permit is held, so a queued call sees a fallback learned meanwhile.
353 Effect.suspend(() => run(noSandbox)).pipe(
353 run(noSandbox).pipe(
354354 Effect.catchTags({
355 HtmlRenderSandboxUnavailableError: () =>
356 Effect.logInfo(
357 "Chrome's sandbox is unavailable on this host; launching it without one.",
358 ).pipe(
359 Effect.andThen(
360 Effect.sync(() => {
361 noSandbox = true;
362 }),
363 ),
364 Effect.andThen(run(true)),
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 ),
355 HtmlRenderBrowserError: (error) =>
356 error.output === undefined
357 ? Effect.fail(error)
358 : PreviewBrowserHost.diagnoseLaunchFailure({
359 executable,
360 setupCommand,
361 output: error.output,
362 }).pipe(
363 Effect.provideContext(services),
364 Effect.flatMap((hostError) => Effect.fail(hostError ?? error)),
365 ),
375366 }),
376367 ),
377368 );
......@@ -381,7 +372,7 @@ const make = Effect.gen(function* () {
381372 Effect.gen(function* () {
382373 const executable = yield* previewBrowser.installed;
383374 if (Option.isNone(executable)) return undefined;
384 const heights = yield* launching((noSandbox) =>
375 const heights = yield* launching(executable.value, (noSandbox) =>
385376 HeadlessChrome.measureHtmlHeights({
386377 executable: executable.value,
387378 noSandbox,
......@@ -473,7 +464,7 @@ const make = Effect.gen(function* () {
473464 appearance,
474465 HTML_RENDER_MEASURE_FONTS,
475466 );
476 const screenshot = yield* launching((noSandbox) =>
467 const screenshot = yield* launching(executable, (noSandbox) =>
477468 HeadlessChrome.captureHtmlScreenshot({
478469 executable,
479470 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";
1414
1515export class HtmlRenderBrowserError extends Schema.TaggedError<HtmlRenderBrowserError>()(
1616 "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 },
1823) {
1924 override get message(): string {
2025 return `Headless Chrome could not render the page: ${this.reason}.`;
2126 }
2227}
2328
24/** The browser died at startup because this host cannot give it a sandbox. */
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;
29type BrowserFailure = HtmlRenderBrowserError;
3530
3631export interface ConsoleMessage {
3732 readonly level: "log" | "info" | "warning" | "error";
......@@ -67,9 +62,6 @@ const textEncoder = new TextEncoder();
6762 */
6863const pageBody = (html: string) =>
6964 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
7466const CdpMessage = Schema.fromJsonString(
7567 Schema.Struct({
......@@ -239,13 +231,11 @@ const launchBrowser = Effect.fnUntraced(function* (input: {
239231 );
240232
241233 let stderrTail = "";
242 let sandboxUnavailable = false;
243234 const stderrReader = yield* child.stderr.pipe(
244235 Stream.decodeText(),
245236 Stream.runForEach((text) =>
246237 Effect.sync(() => {
247 stderrTail = (stderrTail + text).slice(-1_024);
248 if (stderrTail.includes(NO_SANDBOX_SIGNATURE)) sandboxUnavailable = true;
238 stderrTail = (stderrTail + text).slice(-2_048);
249239 }),
250240 ),
251241 Effect.ignore,
......@@ -340,13 +330,14 @@ const launchBrowser = Effect.fnUntraced(function* (input: {
340330 return Effect.void;
341331 };
342332
343 // The pipe closes when the browser exits. A sandbox abort says why on
344 // stderr, which may still be draining, so give it a moment.
333 // The pipe closes when the browser exits. A startup abort, such as a missing
334 // sandbox, says why on stderr, which may still be draining, so give it a moment.
345335 const disconnect = Effect.gen(function* () {
346336 yield* Fiber.await(stderrReader).pipe(Effect.timeout("1 second"), Effect.ignore);
347 const error = sandboxUnavailable
348 ? new HtmlRenderSandboxUnavailableError()
349 : new HtmlRenderBrowserError({ reason: "the browser exited unexpectedly" });
337 const error = new HtmlRenderBrowserError({
338 reason: "the browser exited unexpectedly",
339 output: stderrTail,
340 });
350341 disconnected = error;
351342 const waiters = [...pending.values()];
352343 pending.clear();
......@@ -396,8 +387,7 @@ const launchBrowser = Effect.fnUntraced(function* (input: {
396387 return yield* Deferred.await(reply).pipe(
397388 Effect.flatMap(Schema.decodeUnknownEffect(result)),
398389 Effect.mapError((error) =>
399 error._tag === "HtmlRenderBrowserError" ||
400 error._tag === "HtmlRenderSandboxUnavailableError"
390 error._tag === "HtmlRenderBrowserError"
401391 ? error
402392 : new HtmlRenderBrowserError({
403393 reason: `${method} returned an unexpected result`,
apps/server/src/mcp/McpHttpServer.test.ts+39
......@@ -468,6 +468,45 @@ it.effect(
468468 }).pipe(Effect.provide(layerPullRequestsTest)),
469469);
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
471510it.effect("keeps the snapshot text under the agent's output ceiling", () =>
472511 Effect.scoped(
473512 Effect.gen(function* () {
apps/server/src/mcp/McpHttpServer.ts+14-5
......@@ -19,7 +19,6 @@ import packageJson from "../../package.json" with { type: "json" };
1919import * as ServerConfig from "../config.ts";
2020import * as DeviceService from "../device/DeviceService.ts";
2121import * as HtmlRender from "../htmlRender/HtmlRender.ts";
22import * as PreviewBrowser from "../htmlRender/PreviewBrowser.ts";
2322import * as McpInvocationContext from "./McpInvocationContext.ts";
2423import * as OrchestratorMcpService from "./OrchestratorMcpService.ts";
2524import { PreviewControlsToolkit } from "./toolkits/previewControls/tools.ts";
......@@ -181,7 +180,7 @@ type SnapshotMetadata = {
181180};
182181
183182/**
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,
185184 * and log strings, keeps only the newest log entries, and finally sheds
186185 * interactive elements until the JSON fits. Returns the bounded value, its
187186 * text, and notes on what is missing so the agent can reach for
......@@ -190,7 +189,8 @@ type SnapshotMetadata = {
190189const boundSnapshotMetadata = (metadata: SnapshotMetadata) => {
191190 const omitted: Array<string> = [];
192191 const { accessibilityTree, ...withoutTree } = metadata;
193 if (accessibilityTree !== undefined) {
192 const ariaTree = typeof accessibilityTree === "string" ? accessibilityTree : undefined;
193 if (accessibilityTree !== undefined && ariaTree === undefined) {
194194 omitted.push("accessibilityTree (use interactiveElements locators or preview_evaluate)");
195195 }
196196 const tail = <A>(entries: ReadonlyArray<A>, label: string) => {
......@@ -253,8 +253,10 @@ const boundSnapshotMetadata = (metadata: SnapshotMetadata) => {
253253 actionTimeline: 0,
254254 };
255255 let visibleTextChars = Math.min(metadata.visibleText.length, MAX_SNAPSHOT_VISIBLE_TEXT_CHARS);
256 let ariaTreeChars = Math.min(ariaTree?.length ?? 0, 20_000);
256257 const value = () => ({
257258 ...bounded,
259 ...(ariaTree === undefined ? {} : { accessibilityTree: cutText(ariaTree, ariaTreeChars) }),
258260 visibleText: cutText(metadata.visibleText, visibleTextChars),
259261 ...lists,
260262 });
......@@ -269,10 +271,14 @@ const boundSnapshotMetadata = (metadata: SnapshotMetadata) => {
269271 ? "visibleText"
270272 : lists.interactiveElements.length > 0
271273 ? "interactiveElements"
272 : undefined);
274 : ariaTreeChars > 0
275 ? "accessibilityTree"
276 : undefined);
273277 if (key === undefined) break;
274278 if (key === "visibleText") {
275279 visibleTextChars = Math.floor(visibleTextChars / 2);
280 } else if (key === "accessibilityTree") {
281 ariaTreeChars = Math.floor(ariaTreeChars / 2);
276282 } else {
277283 const keep = Math.floor(lists[key].length / 2);
278284 dropped[key] += lists[key].length - keep;
......@@ -291,6 +297,9 @@ const boundSnapshotMetadata = (metadata: SnapshotMetadata) => {
291297 `visibleText after ${visibleTextChars} characters (use preview_evaluate for more)`,
292298 );
293299 }
300 if (ariaTree !== undefined && ariaTreeChars < ariaTree.length) {
301 omitted.push(`accessibilityTree after ${ariaTreeChars} characters`);
302 }
294303 for (const key of shedOrder) {
295304 if (dropped[key] > 0) {
296305 omitted.push(`${dropped[key]} of ${bounded[key].length} ${key}`);
......@@ -683,7 +692,7 @@ const registerHtmlPreview = Effect.fn("McpHttpServer.registerHtmlPreview")(funct
683692export const layerHtmlToolkit = Layer.mergeAll(
684693 McpServer.toolkit(HtmlRenderToolkit).pipe(Layer.provide(HtmlHandlers.layerRender)),
685694 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
688697const layerPreviewStandardToolkitRegistration = McpServer.toolkit(PreviewStandardToolkit).pipe(
689698 Layer.provide(PreviewHandlers.layerStandard),
apps/server/src/mcp/PreviewAutomationBroker.test.ts+108-112
......@@ -16,6 +16,7 @@ import {
1616 type PreviewAutomationHost,
1717 type PreviewAutomationRequest,
1818 type PreviewAutomationStreamEvent,
19 SERVER_BROWSER_AUTOMATION_CLIENT_ID,
1920} from "@t3tools/contracts";
2021import * as Effect from "effect/Effect";
2122import * as Layer from "effect/Layer";
......@@ -332,6 +333,44 @@ it.effect("announces a live replacement stream before delivering requests", () =
332333 ),
333334);
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
335374it.effect("preserves bounded request and remote selector diagnostics", () => {
336375 const locator = "role=button[name='request-secret']";
337376 const remoteMessage = "Unexpected token near remote-secret.";
......@@ -481,6 +520,37 @@ it.effect.each([
481520 ),
482521);
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
484554it.effect("distinguishes malformed remote failures", () =>
485555 Effect.scoped(
486556 Effect.gen(function* () {
......@@ -1256,118 +1326,6 @@ it.effect("accepts responses only from the host that received the request", () =
12561326 ),
12571327);
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
13711329it.effect("discards buffered actions before completing an evicted host stream", () =>
13721330 Effect.scoped(
13731331 Effect.gen(function* () {
......@@ -1505,3 +1463,41 @@ it.effect("keeps a host that responds with an operation timeout", () =>
15051463 }),
15061464 ),
15071465);
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 {
22 PREVIEW_AUTOMATION_V1_OPERATIONS,
33 PreviewAutomationClientDisconnectedError,
44 PreviewAutomationControlInterruptedError,
5 PreviewAutomationControlReason,
56 PreviewAutomationExecutionError,
7 SERVER_BROWSER_AUTOMATION_CLIENT_ID,
68 PreviewAutomationInvalidSelectorError,
79 PreviewAutomationMalformedResponseError,
810 PreviewAutomationNoAvailableHostError,
......@@ -52,11 +54,21 @@ export interface PreviewAutomationInvokeInput {
5254 readonly onTargetTab?: (tabId: PreviewTabId | undefined) => void;
5355}
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
5566export class PreviewAutomationBroker extends Context.Service<
5667 PreviewAutomationBroker,
5768 {
5869 readonly connect: (
5970 host: PreviewAutomationHost,
71 options?: PreviewAutomationConnectOptions,
6072 ) => Effect.Effect<Stream.Stream<PreviewAutomationStreamEvent>>;
6173 readonly focusHost: (host: PreviewAutomationHostFocus) => Effect.Effect<void>;
6274 readonly respond: (
......@@ -74,6 +86,7 @@ interface ClientConnection {
7486 readonly environmentId: PreviewAutomationHost["environmentId"];
7587 readonly supportedOperations: ReadonlySet<PreviewAutomationOperation>;
7688 readonly focused: boolean;
89 readonly preferred: boolean;
7790 readonly liveTabs: NonNullable<PreviewAutomationHostFocus["liveTabs"]>;
7891 readonly focusOrder: number;
7992 readonly queue: Queue.Queue<PreviewAutomationStreamEvent, Cause.Done>;
......@@ -165,6 +178,7 @@ const hostAssignmentKey = (scope: McpInvocationContext.McpThreadInvocationScope)
165178 `${scope.environmentId}\u0000${scope.thread.providerSessionId}`;
166179
167180const isPreviewTabId = Schema.is(PreviewTabId);
181const decodeControlReason = Schema.decodeUnknownOption(PreviewAutomationControlReason);
168182
169183const readResultTabId = (result: unknown): PreviewTabId | null | undefined => {
170184 if (typeof result !== "object" || result === null || !("tabId" in result)) return undefined;
......@@ -194,6 +208,9 @@ function remoteDetailKind(detail: unknown): RemoteDetailKind {
194208 }
195209}
196210
211/** Enough for a browser's own error line, such as a refused connection and its URL. */
212const MAX_REASON_CHARS = 500;
213
197214const classifyResponseError = (
198215 context: PreviewAutomationRequestErrorContext,
199216 error: NonNullable<PreviewAutomationResponse["error"]>,
......@@ -245,15 +262,24 @@ const classifyResponseError = (
245262 ...context,
246263 ...remoteDiagnostics,
247264 });
248 case "PreviewAutomationControlInterruptedError":
265 case "PreviewAutomationControlInterruptedError": {
266 const reason = decodeControlReason(error.detail);
249267 return new PreviewAutomationControlInterruptedError({
250268 ...context,
251269 ...remoteDiagnostics,
270 ...(Option.isSome(reason) ? { reason: reason.value } : {}),
252271 });
272 }
253273 case "PreviewAutomationInvalidSelectorError": {
274 const staleRef =
275 typeof error.detail === "object" &&
276 error.detail !== null &&
277 "staleRef" in error.detail &&
278 error.detail.staleRef === true;
254279 return new PreviewAutomationInvalidSelectorError({
255280 ...context,
256281 ...remoteDiagnostics,
282 ...(staleRef ? { staleRef } : {}),
257283 });
258284 }
259285 case "PreviewAutomationTargetNotEditableError": {
......@@ -312,6 +338,10 @@ const classifyResponseError = (
312338 return new PreviewAutomationExecutionError({
313339 ...context,
314340 ...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 : {}),
315345 });
316346 }
317347};
......@@ -367,6 +397,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
367397
368398 const acquireConnection = Effect.fn("PreviewAutomationBroker.acquireConnection")(function* (
369399 host: PreviewAutomationHost,
400 options: PreviewAutomationConnectOptions | undefined,
370401 ) {
371402 const clientId = host.clientId;
372403 const queue = yield* Queue.unbounded<PreviewAutomationStreamEvent, Cause.Done>();
......@@ -378,6 +409,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
378409 environmentId: host.environmentId,
379410 supportedOperations: new Set(host.supportedOperations ?? PREVIEW_AUTOMATION_V1_OPERATIONS),
380411 focused: false,
412 preferred: options?.preferred ?? false,
381413 liveTabs: [],
382414 focusOrder: 0,
383415 queue,
......@@ -408,10 +440,10 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
408440
409441 const connect: PreviewAutomationBroker["Service"]["connect"] = Effect.fn(
410442 "PreviewAutomationBroker.connect",
411 )((host) =>
443 )((host, options) =>
412444 Effect.succeed(
413445 Stream.unwrap(
414 Effect.acquireRelease(acquireConnection(host), (connection) =>
446 Effect.acquireRelease(acquireConnection(host, options), (connection) =>
415447 disconnect(connection.clientId, connection.queue),
416448 ).pipe(Effect.map((connection) => Stream.fromQueue(connection.queue))),
417449 ),
......@@ -474,7 +506,6 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
474506 const invoke = Effect.fn("PreviewAutomationBroker.invoke")(function* <A = unknown>(
475507 input: Parameters<PreviewAutomationBroker["Service"]["invoke"]>[0],
476508 ): Effect.fn.Return<A, PreviewAutomationError> {
477 const timeoutMs = input.timeoutMs ?? 15_000;
478509 const deferred = yield* Deferred.make<unknown, PreviewAutomationError>();
479510 const route = yield* SynchronizedRef.modify(state, (current) => {
480511 const assignments = new Map(
......@@ -516,6 +547,9 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
516547 )
517548 .sort(
518549 (left, right) =>
550 Number(input.tabId !== undefined && ownsTargetTab(right)) -
551 Number(input.tabId !== undefined && ownsTargetTab(left)) ||
552 Number(right.preferred) - Number(left.preferred) ||
519553 Number(ownsTargetTab(right, true)) - Number(ownsTargetTab(left, true)) ||
520554 Number(ownsTargetTab(right)) - Number(ownsTargetTab(left)) ||
521555 Number(right.focused) - Number(left.focused) ||
......@@ -525,6 +559,10 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
525559 if (!hasLiveAssignment) assignments.delete(assignmentKey);
526560 return [undefined, { ...current, assignments }] as const;
527561 }
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);
528566 const canReuseAssignedTab =
529567 assigned !== undefined &&
530568 assigned.connectionId === connection.connectionId &&
......@@ -573,6 +611,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
573611 });
574612 }
575613 const { connection, requestId, requestContext, requestSequence } = route;
614 const { timeoutMs } = requestContext;
576615 input.onTargetTab?.(requestContext.tabId);
577616 const removePending = SynchronizedRef.update(state, (next) => {
578617 if (!next.pending.has(requestId)) return next;
......@@ -598,6 +637,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
598637 threadId: input.scope.thread.threadId,
599638 tabId: requestContext.tabId,
600639 tabIdExplicit: input.tabId !== undefined,
640 agentSessionId: hostAssignmentKey(input.scope),
601641 operation: input.operation,
602642 input: input.input,
603643 timeoutMs,
......@@ -617,7 +657,11 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
617657 Effect.gen(function* () {
618658 // An unanswered request invalidates this connection. Do not replay
619659 // 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 }
621665 return yield* new PreviewAutomationTimeoutError(requestContext);
622666 }),
623667 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";
2121import { OrchestratorProjectionError } from "../../orchestration-v2/Orchestrator.ts";
2222import * as ProviderAdapterRegistry from "../../orchestration-v2/ProviderAdapterRegistry.ts";
2323import * as ThreadManagement from "../../orchestration-v2/ThreadManagementService.ts";
24import * as PreviewBrowser from "../../preview/PreviewBrowser.ts";
2425import * as ProjectService from "../../project/ProjectService.ts";
2526import * as ProviderRegistry from "../../provider/ProviderRegistry.ts";
2627import * as SecretRequests from "../../secrets/SecretRequests.ts";
......@@ -205,6 +206,7 @@ it.effect("returns an HTML render reference that Codex and Claude tool rows both
205206 Effect.provide(
206207 McpHttpServer.layerHtmlToolkit.pipe(
207208 Layer.provideMerge(McpServer.McpServer.layer),
209 Layer.provide(PreviewBrowser.layer),
208210 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-html-render-" })),
209211 Layer.provide(NodeServices.layer),
210212 // 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 {
1313 type PreviewAutomationOpenInput,
1414 type PreviewAutomationRecordingStatus,
1515 type PreviewAutomationResizeResult,
16 type PreviewAutomationSelectResult,
1617 type PreviewAutomationSetColorSchemeResult,
1718 type PreviewAutomationSnapshot,
1819 type PreviewAutomationStatus,
......@@ -188,6 +189,7 @@ export const claimPreviewRecording = Effect.fn("PreviewToolkit.claimRecording")(
188189});
189190
190191const handlers = {
192 preview_dialog: (input) => invokeTargeted<PreviewAutomationStatus>("dialog", input),
191193 preview_status: (input) => invokeTargeted<PreviewAutomationStatus>("status", input ?? {}),
192194 preview_open: (input) =>
193195 invokeTargeted<PreviewAutomationStatus>("open", normalizePreviewOpenInput(input)),
......@@ -204,6 +206,11 @@ const handlers = {
204206 },
205207 preview_click: (input) => invokeTargeted<object>("click", input, input.timeoutMs),
206208 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),
207214 preview_press: (input) => invokeTargeted<object>("press", input),
208215 preview_scroll: (input) => invokeTargeted<object>("scroll", input),
209216 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", () => {
6060 const actionNames = [
6161 "preview_click",
6262 "preview_type",
63 "preview_hover",
64 "preview_drag",
65 "preview_upload",
6366 "preview_press",
6467 "preview_scroll",
6568 "preview_wait_for",
apps/server/src/mcp/toolkits/preview/tools.ts+78-4
......@@ -1,8 +1,11 @@
11import {
22 ToolActivityIcon,
33 PreviewAutomationClickInput,
4 PreviewAutomationDialogInput,
5 PreviewAutomationDragInput,
46 PreviewAutomationError,
57 PreviewAutomationEvaluateInput,
8 PreviewAutomationHoverInput,
69 PreviewAutomationNavigateInput,
710 PreviewAutomationOpenInput,
811 PreviewAutomationPressInput,
......@@ -11,12 +14,15 @@ import {
1114 PreviewAutomationResizeInput,
1215 PreviewAutomationResizeResult,
1316 PreviewAutomationScrollInput,
17 PreviewAutomationSelectInput,
18 PreviewAutomationSelectResult,
1419 PreviewAutomationSetColorSchemeInput,
1520 PreviewAutomationSetColorSchemeResult,
1621 PreviewAutomationSnapshot,
1722 PreviewAutomationStatus,
1823 PreviewAutomationTabTargetInput,
1924 PreviewAutomationTypeInput,
25 PreviewAutomationUploadInput,
2026 PreviewAutomationWaitForInput,
2127} from "@t3tools/contracts";
2228import * as Schema from "effect/Schema";
......@@ -52,7 +58,7 @@ const readonlyBrowserTool = <T extends Tool.Any>(tool: T): T =>
5258
5359const PreviewStatusTool = Tool.make("preview_status", {
5460 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.",
5662 parameters: PreviewAutomationTabTargetInput,
5763 success: PreviewAutomationStatus,
5864 failure: PreviewAutomationError,
......@@ -66,7 +72,7 @@ const PreviewStatusTool = Tool.make("preview_status", {
6672const PreviewOpenTool = browserTool(
6773 Tool.make("preview_open", {
6874 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.",
7076 parameters: PreviewAutomationOpenInput,
7177 success: PreviewAutomationStatus,
7278 failure: PreviewAutomationError,
......@@ -76,6 +82,17 @@ const PreviewOpenTool = browserTool(
7682 .annotate(Tool.Destructive, false),
7783);
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
7996const PreviewNavigateTool = safeBrowserTool(
8097 Tool.make("preview_navigate", {
8198 description:
......@@ -119,7 +136,7 @@ const PreviewSetAppearanceTool = safeBrowserTool(
119136export const PreviewSnapshotTool = readonlyBrowserTool(
120137 Tool.make("preview_snapshot", {
121138 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.",
123140 parameters: Schema.Struct({
124141 ...PreviewAutomationTabTargetInput.fields,
125142 includeImage: Schema.optional(
......@@ -144,7 +161,7 @@ export const PreviewSnapshotTool = readonlyBrowserTool(
144161const PreviewClickTool = browserTool(
145162 Tool.make("preview_click", {
146163 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.",
148165 parameters: PreviewAutomationClickInput,
149166 success: PreviewActionResult,
150167 failure: PreviewAutomationError,
......@@ -163,6 +180,53 @@ const PreviewTypeTool = browserTool(
163180 }).annotate(Tool.Title, "Type into preview page"),
164181);
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
166230const PreviewPressTool = browserTool(
167231 Tool.make("preview_press", {
168232 description:
......@@ -242,6 +306,7 @@ const PreviewRecordingStopTool = safeBrowserTool(
242306);
243307
244308export const PreviewToolkit = Toolkit.make(
309 PreviewDialogTool,
245310 PreviewStatusTool,
246311 PreviewOpenTool,
247312 PreviewNavigateTool,
......@@ -250,6 +315,10 @@ export const PreviewToolkit = Toolkit.make(
250315 PreviewSnapshotTool,
251316 PreviewClickTool,
252317 PreviewTypeTool,
318 PreviewHoverTool,
319 PreviewSelectTool,
320 PreviewDragTool,
321 PreviewUploadTool,
253322 PreviewPressTool,
254323 PreviewScrollTool,
255324 PreviewEvaluateTool,
......@@ -259,6 +328,7 @@ export const PreviewToolkit = Toolkit.make(
259328);
260329
261330export const PreviewStandardToolkit = Toolkit.make(
331 PreviewDialogTool,
262332 PreviewStatusTool,
263333 PreviewOpenTool,
264334 PreviewNavigateTool,
......@@ -266,6 +336,10 @@ export const PreviewStandardToolkit = Toolkit.make(
266336 PreviewSetAppearanceTool,
267337 PreviewClickTool,
268338 PreviewTypeTool,
339 PreviewHoverTool,
340 PreviewSelectTool,
341 PreviewDragTool,
342 PreviewUploadTool,
269343 PreviewPressTool,
270344 PreviewScrollTool,
271345 PreviewEvaluateTool,
apps/server/src/mcp/toolkits/previewControls/handlers.test.ts+14-1
......@@ -1,4 +1,5 @@
11import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
2import * as NodeServices from "@effect/platform-node/NodeServices";
23import { expect, it } from "@effect/vitest";
34import {
45 DEFAULT_SERVER_SETTINGS,
......@@ -12,9 +13,11 @@ import * as Effect from "effect/Effect";
1213import * as Layer from "effect/Layer";
1314import * as Stream from "effect/Stream";
1415
16import * as ServerConfig from "../../../config.ts";
1517import * as Preview from "../../../preview/Manager.ts";
1618import * as ServerSettings from "../../../serverSettings.ts";
1719import * as McpInvocationContext from "../../McpInvocationContext.ts";
20import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts";
1821import * as PreviewControlsHandlers from "./handlers.ts";
1922import { PreviewControlsToolkit } from "./tools.ts";
2023
......@@ -46,9 +49,19 @@ it.effect.each([
4649 capabilities: new Set(effective.enableAgentBrowserAccess ? ["preview"] : []),
4750 issuedAt: 0,
4851 };
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 );
5062 const tab = yield* manager.open({ threadId, url: "http://localhost:3000" });
5163 const layerDependencies = Layer.mergeAll(
64 PreviewAutomationBroker.layer.pipe(Layer.provide(NodeServices.layer)),
5265 Layer.succeed(Preview.PreviewManager, manager),
5366 Layer.succeed(McpInvocationContext.McpInvocationContext, scope),
5467 Layer.mock(ServerSettings.ServerSettingsService)({
apps/server/src/mcp/toolkits/previewControls/handlers.ts+11
......@@ -3,6 +3,7 @@ import * as Preview from "../../../preview/Manager.ts";
33import { requireThreadMcpCapability } from "../../McpInvocationContext.ts";
44import { unavailable } from "../../threadAccess.ts";
55import { PreviewControlsToolkit } from "./tools.ts";
6import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts";
67
78const access = Effect.gen(function* () {
89 // The preview capability already reflects the calling project's access setting.
......@@ -25,6 +26,16 @@ export const layer = PreviewControlsToolkit.toLayer({
2526 t3_preview_close: (input) =>
2627 Effect.gen(function* () {
2728 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 }
2839 yield* manager
2940 .close({ threadId: scope.thread.threadId, tabId: input.tabId })
3041 .pipe(Effect.mapError(unavailable));
apps/server/src/mcp/toolkits/previewControls/tools.ts+6-1
......@@ -8,12 +8,17 @@ import {
88import * as Schema from "effect/Schema";
99import { Tool, Toolkit } from "effect/ai";
1010import * as PreviewManager from "../../../preview/Manager.ts";
11import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts";
1112import * as McpInvocationContext from "../../McpInvocationContext.ts";
1213
1314const shared = {
1415 failure: Schema.Union([OrchestratorMcpFailure, PreviewAutomationUnavailableError]),
1516 failureMode: "return" as const,
16 dependencies: [McpInvocationContext.McpInvocationContext, PreviewManager.PreviewManager],
17 dependencies: [
18 McpInvocationContext.McpInvocationContext,
19 PreviewManager.PreviewManager,
20 PreviewAutomationBroker.PreviewAutomationBroker,
21 ],
1722};
1823const PreviewListTool = Tool.make("t3_preview_list", {
1924 ...shared,
apps/server/src/mcp/toolkits/worktree/registration.test.ts+2
......@@ -25,6 +25,7 @@ import * as VcsStatusBroadcaster from "../../../vcs/VcsStatusBroadcaster.ts";
2525import * as McpHttpServer from "../../McpHttpServer.ts";
2626import * as McpSessionRegistry from "../../McpSessionRegistry.ts";
2727import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts";
28import * as PreviewBrowser from "../../../preview/PreviewBrowser.ts";
2829
2930const layerStubServices = Layer.mergeAll(
3031 Layer.mock(Orchestrator.OrchestratorV2)({}),
......@@ -77,6 +78,7 @@ it.effect("production mcp layer lists worktree tools over http", () =>
7778 }),
7879 ),
7980 Layer.provide(PreviewAutomationBroker.layer),
81 Layer.provide(PreviewBrowser.layer),
8082 Layer.provide(layerStubServices),
8183 Layer.build,
8284 );
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 @@
11import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
2import * as NodeServices from "@effect/platform-node/NodeServices";
23import { it } from "@effect/vitest";
34import { type PreviewEvent, ThreadId } from "@t3tools/contracts";
45import { PreviewUrlNormalizationError } from "@t3tools/shared/preview";
......@@ -7,6 +8,7 @@ import * as Layer from "effect/Layer";
78import * as PubSub from "effect/PubSub";
89import { expect } from "vite-plus/test";
910
11import * as ServerConfig from "../config.ts";
1012import * as PreviewManager from "./Manager.ts";
1113
1214const DRAIN_LIMIT = 100;
......@@ -38,7 +40,13 @@ const collectEvents = Effect.gen(function* () {
3840 return collector;
3941}).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) => {
4250 it.effect("opens a session and emits opened with normalized URL", () =>
4351 Effect.gen(function* () {
4452 const threadId = freshThreadId();
......@@ -92,6 +100,75 @@ it.layer(PreviewManager.layer.pipe(Layer.provide(NodeCrypto.layer)))("PreviewMan
92100 }),
93101 );
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
95172 it.effect("opens an Idle tab when no URL is supplied", () =>
96173 Effect.gen(function* () {
97174 const threadId = freshThreadId();
......@@ -266,6 +343,46 @@ it.layer(PreviewManager.layer.pipe(Layer.provide(NodeCrypto.layer)))("PreviewMan
266343 }),
267344 );
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
269386 it.effect("reportStatus emits failed for LoadFailed nav", () =>
270387 Effect.gen(function* () {
271388 const threadId = freshThreadId();
apps/server/src/preview/Manager.ts+125-73
......@@ -21,10 +21,10 @@ import {
2121 type PreviewRefreshInput,
2222 type PreviewReportStatusInput,
2323 type PreviewResizeInput,
24 type PreviewAdjustInput,
2425 FILL_PREVIEW_VIEWPORT,
2526 PreviewSessionLookupError,
2627 type PreviewSessionSnapshot,
27 type PreviewViewportSetting,
2828} from "@t3tools/contracts";
2929import {
3030 isPreviewUrlNormalizationError,
......@@ -40,18 +40,38 @@ import * as PubSub from "effect/PubSub";
4040import * as Scope from "effect/Scope";
4141import * as Stream from "effect/Stream";
4242import * as SynchronizedRef from "effect/SynchronizedRef";
43import { PreviewControlRequiredError } from "@t3tools/contracts";
4344
4445export class PreviewManager extends Context.Service<
4546 PreviewManager,
4647 {
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>;
4855 readonly navigate: (
4956 input: PreviewNavigateInput,
5057 ) => 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>;
5264 readonly resize: (
5365 input: PreviewResizeInput,
5466 ) => 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>;
5575 readonly refresh: (input: PreviewRefreshInput) => Effect.Effect<void, PreviewError>;
5676 readonly close: (input: PreviewCloseInput) => Effect.Effect<void, PreviewError>;
5777 readonly list: (input: PreviewListInput) => Effect.Effect<PreviewListResult>;
......@@ -117,42 +137,6 @@ const normalizeUrl = (rawUrl: string): Effect.Effect<string, PreviewInvalidUrlEr
117137
118138const 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
156140/** @public Service construction is part of the canonical Effect module API. */
157141export const make = Effect.gen(function* PreviewManagerMake() {
158142 const crypto = yield* Crypto.Crypto;
......@@ -222,29 +206,27 @@ export const make = Effect.gen(function* PreviewManagerMake() {
222206
223207 const open: PreviewManager["Service"]["open"] = Effect.fn("PreviewManager.open")(
224208 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}` : ""}`;
226212 const updatedAt = yield* currentIsoTimestamp;
227 // Clients with a configured default send the viewport up front so the
228 // session is born at the right size; older clients omit it and keep the
229 // historical fill-panel behaviour.
230 const viewport = input.viewport ?? FILL_PREVIEW_VIEWPORT;
231 const snapshot = input.url
232 ? buildLoadingSnapshot({
233 threadId: input.threadId,
234 tabId,
235 url: yield* normalizeUrl(input.url),
236 title: "",
237 viewport,
238 profileId: input.profileId,
239 updatedAt,
240 })
241 : buildIdleSnapshot({
242 threadId: input.threadId,
243 tabId,
244 viewport,
245 profileId: input.profileId,
246 updatedAt,
247 });
213 const snapshot: PreviewSessionSnapshot = {
214 threadId: input.threadId,
215 tabId,
216 navStatus: input.url
217 ? { _tag: "Loading", url: yield* normalizeUrl(input.url), title: "" }
218 : { _tag: "Idle" },
219 canGoBack: false,
220 canGoForward: false,
221 viewport: input.viewport ?? FILL_PREVIEW_VIEWPORT,
222 ...(input.profileId === undefined ? {} : { profileId: input.profileId }),
223 ...(runtime === undefined ? {} : { runtime }),
224 ...(runtime === "server" && input.automationOwner !== undefined
225 ? { automationOwner: input.automationOwner }
226 : {}),
227 ...(input.reveal === undefined ? {} : { reveal: input.reveal }),
228 updatedAt,
229 };
248230 yield* SynchronizedRef.modifyEffect(stateRef, (state) =>
249231 Effect.gen(function* () {
250232 const revision = state.revision + 1;
......@@ -254,6 +236,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {
254236 tabId,
255237 snapshot,
256238 });
239 input.beforePublish?.(snapshot);
257240 yield* PubSub.publish(eventsPubSub, {
258241 type: "opened",
259242 threadId: input.threadId,
......@@ -277,20 +260,16 @@ export const make = Effect.gen(function* PreviewManagerMake() {
277260 input.threadId,
278261 input.tabId,
279262 Effect.fn("PreviewManager.navigateSession")(function* (session) {
263 if (session.snapshot.runtime === "server")
264 return yield* new PreviewControlRequiredError({ tabId: input.tabId });
280265 const updatedAt = yield* currentIsoTimestamp;
281266 const previousTitle =
282267 session.snapshot.navStatus._tag === "Idle" ? "" : session.snapshot.navStatus.title;
283268 const resolvedTitle = input.resolvedTitle ?? previousTitle;
284269 const snapshot: PreviewSessionSnapshot = {
285 threadId: session.threadId,
286 tabId: session.tabId,
270 ...session.snapshot,
287271 navStatus: { _tag: "Success", url, title: resolvedTitle },
288 canGoBack: session.snapshot.canGoBack,
289 canGoForward: session.snapshot.canGoForward,
290272 viewport: session.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT,
291 ...(session.snapshot.profileId === undefined
292 ? {}
293 : { profileId: session.snapshot.profileId }),
294273 updatedAt,
295274 };
296275 return {
......@@ -316,17 +295,15 @@ export const make = Effect.gen(function* PreviewManagerMake() {
316295 input.threadId,
317296 input.tabId,
318297 Effect.fn("PreviewManager.reportSessionStatus")(function* (session) {
298 if (session.snapshot.runtime === "server" && !input.serverControlled)
299 return yield* new PreviewControlRequiredError({ tabId: input.tabId });
319300 const updatedAt = yield* currentIsoTimestamp;
320301 const snapshot: PreviewSessionSnapshot = {
321 threadId: session.threadId,
322 tabId: session.tabId,
302 ...session.snapshot,
323303 navStatus: input.navStatus,
324304 canGoBack: input.canGoBack,
325305 canGoForward: input.canGoForward,
326306 viewport: session.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT,
327 ...(session.snapshot.profileId === undefined
328 ? {}
329 : { profileId: session.snapshot.profileId }),
330307 updatedAt,
331308 };
332309 const emit: PreviewEventDraft =
......@@ -362,6 +339,8 @@ export const make = Effect.gen(function* PreviewManagerMake() {
362339 return yield* mutateExistingSession(
363340 input.threadId,
364341 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.
365344 Effect.fn("PreviewManager.resizeSession")(function* (session) {
366345 const updatedAt = yield* currentIsoTimestamp;
367346 const snapshot: PreviewSessionSnapshot = {
......@@ -385,6 +364,46 @@ export const make = Effect.gen(function* PreviewManagerMake() {
385364 },
386365 );
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
388407 const refresh: PreviewManager["Service"]["refresh"] = Effect.fn("PreviewManager.refresh")(
389408 function* (input) {
390409 // Verify the session exists; the desktop bridge handles the actual reload
......@@ -446,11 +465,44 @@ export const make = Effect.gen(function* PreviewManagerMake() {
446465 },
447466 );
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
449499 return PreviewManager.of({
450500 open,
501 requestReveal,
451502 navigate,
452503 reportStatus,
453504 resize,
505 adjust,
454506 refresh,
455507 close,
456508 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";
6363import * as DeviceHubProxy from "./device/DeviceHubProxy.ts";
6464import * as PreviewManager from "./preview/Manager.ts";
6565import * 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";
6670import * as ProcessRunner from "./processRunner.ts";
6771import * as GitManager from "./git/GitManager.ts";
6872import * as EnvironmentTheme from "./environmentTheme.ts";
......@@ -661,6 +665,7 @@ const layerMakeRoutes = Layer.mergeAll(
661665 ServerHttp.layerAssetRoute,
662666 ServerHttp.layerAttachmentUploadRoute,
663667 DeviceHubProxy.layer,
668 ServerBrowserStream.routeLayer,
664669 ServerHttp.layerStaticAndDevRoute,
665670 Ws.layer,
666671 ),
......@@ -676,6 +681,10 @@ const layerMakeRoutes = Layer.mergeAll(
676681 // Both transports consume the same service instance, so caches single-flight across clients
677682 // and mutations observed on WebSocket invalidate patches subsequently read over HTTP.
678683 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),
679688 Layer.provide(PreviewAutomationBroker.layer),
680689 Layer.provide(ServerSelfUpdate.layer.pipe(Layer.provide(layerDesktopAppUpdate))),
681690 Layer.provide(layerCommandReadiness),
apps/server/src/ws.ts+20-19
......@@ -180,6 +180,7 @@ import * as ServerSettings from "./serverSettings.ts";
180180import * as TerminalManager from "./terminal/Manager.ts";
181181import { withTerminalOutputWindow } from "./terminal/OutputProtocol.ts";
182182import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts";
183import * as ServerBrowser from "./preview/ServerBrowser.ts";
183184import * as DeviceService from "./device/DeviceService.ts";
184185import { remoteSshDeviceHosts } from "./device/localSshDeviceHost.ts";
185186import * as PreviewManager from "./preview/Manager.ts";
......@@ -1182,6 +1183,7 @@ const layerWsRpc = (
11821183 clientOrigin: OrchestrationClientOrigin,
11831184 clientAnalyticsProps: Readonly<Record<string, unknown>>,
11841185 previewAutomationBroker: PreviewAutomationBroker.PreviewAutomationBroker["Service"],
1186 serverBrowser: ServerBrowser.ServerBrowser["Service"],
11851187) =>
11861188 ServerWsRpcGroup.toLayer(
11871189 Effect.gen(function* () {
......@@ -3489,6 +3491,10 @@ const layerWsRpc = (
34893491 observeRpcEffect(WS_METHODS.previewResize, previewManager.resize(input), {
34903492 "rpc.aggregate": "preview",
34913493 }),
3494 [WS_METHODS.previewAdjust]: (input) =>
3495 observeRpcEffect(WS_METHODS.previewAdjust, previewManager.adjust(input), {
3496 "rpc.aggregate": "preview",
3497 }),
34923498 [WS_METHODS.previewRefresh]: (input) =>
34933499 observeRpcEffect(WS_METHODS.previewRefresh, previewManager.refresh(input), {
34943500 "rpc.aggregate": "preview",
......@@ -3501,28 +3507,16 @@ const layerWsRpc = (
35013507 observeRpcEffect(WS_METHODS.previewList, previewManager.list(input), {
35023508 "rpc.aggregate": "preview",
35033509 }),
3510 [WS_METHODS.previewClearProfile]: (input) =>
3511 observeRpcEffect(
3512 WS_METHODS.previewClearProfile,
3513 serverBrowser.clearProfile(input.profileId),
3514 { "rpc.aggregate": "preview" },
3515 ),
35043516 [WS_METHODS.previewReportStatus]: (input) =>
35053517 observeRpcEffect(WS_METHODS.previewReportStatus, previewManager.reportStatus(input), {
35063518 "rpc.aggregate": "preview",
35073519 }),
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 ),
35263520 [WS_METHODS.subscribePreviewEvents]: (_input) =>
35273521 observeRpcStream(WS_METHODS.subscribePreviewEvents, previewManager.events, {
35283522 "rpc.aggregate": "preview",
......@@ -3796,6 +3790,7 @@ const layerWsRpc = (
37963790export const layer = Layer.unwrap(
37973791 Effect.gen(function* () {
37983792 const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
3793 const serverBrowser = yield* ServerBrowser.ServerBrowser;
37993794 const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate;
38003795 const pullRequests = yield* PullRequestService.PullRequestService;
38013796 const sql = yield* SqlClient.SqlClient;
......@@ -3844,7 +3839,13 @@ export const layer = Layer.unwrap(
38443839 return httpEffect;
38453840 }).pipe(
38463841 Effect.provide(
3847 layerWsRpc(session, clientOrigin, clientAnalyticsProps, previewAutomationBroker).pipe(
3842 layerWsRpc(
3843 session,
3844 clientOrigin,
3845 clientAnalyticsProps,
3846 previewAutomationBroker,
3847 serverBrowser,
3848 ).pipe(
38483849 Layer.provideMerge(RpcSerialization.layerJson),
38493850 Layer.provide(Layer.succeed(SqlClient.SqlClient, sql)),
38503851 Layer.provide(AgentSessionScanner.layer),
apps/web/src/AppRoot.tsx-2
......@@ -1,7 +1,6 @@
11import { RouterProvider } from "@tanstack/react-router";
22
33import { ElectronBrowserHost } from "./browser/ElectronBrowserHost";
4import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts";
54import { QuitHoldOverlay } from "./components/QuitHoldOverlay";
65import { AppAtomRegistryProvider } from "./rpc/atomRegistry";
76import type { AppRouter } from "./router";
......@@ -15,7 +14,6 @@ export function AppRoot({ router }: { readonly router: AppRouter }) {
1514 return (
1615 <AppAtomRegistryProvider>
1716 <RouterProvider router={router} />
18 <PreviewAutomationHosts />
1917 <ElectronBrowserHost />
2018 <QuitHoldOverlay />
2119 </AppAtomRegistryProvider>
apps/web/src/browser/ElectronBrowserHost.tsx+33-12
......@@ -2,8 +2,11 @@
22
33import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";
44import { FILL_PREVIEW_VIEWPORT } from "@t3tools/contracts";
5import { useAtomValue } from "@effect/atom-react";
56import { useEffect, useMemo } from "react";
67
8import { primaryEnvironmentIdAtom } from "~/state/primaryEnvironment";
9
710import { isElectron } from "~/env";
811import { useTheme } from "~/hooks/useTheme";
912import { useActivePreviewSessions } from "~/previewStateStore";
......@@ -11,31 +14,40 @@ import { useActivePreviewSessions } from "~/previewStateStore";
1114import { readPreviewAnnotationTheme } from "./annotationTheme";
1215import { useBrowserPointerStore } from "./browserPointerStore";
1316import { HostedBrowserWebview } from "./HostedBrowserWebview";
17import { rendersServerTabNatively } from "./previewRuntime";
1418import { previewRuntimeTabId } from "./previewRuntimeTabId";
1519
1620export function ElectronBrowserHost() {
1721 const { resolvedTheme } = useTheme();
1822 const previewByThreadKey = useActivePreviewSessions();
23 const primaryEnvironmentId = useAtomValue(primaryEnvironmentIdAtom);
1924 const sessions = useMemo(
2025 () =>
2126 Object.entries(previewByThreadKey).flatMap(([threadKey, previewState]) => {
2227 const threadRef = parseScopedThreadKey(threadKey);
28 // Server tabs of other environments stream; this desktop's own server tabs render here.
2329 return threadRef
24 ? Object.values(previewState.sessions).map((snapshot) => ({
25 threadRef,
26 snapshot,
27 runtimeTabId: previewRuntimeTabId(
30 ? Object.values(previewState.sessions)
31 .filter(
32 (snapshot) =>
33 snapshot.runtime !== "server" ||
34 rendersServerTabNatively(threadRef.environmentId, primaryEnvironmentId, snapshot),
35 )
36 .map((snapshot) => ({
2837 threadRef,
29 previewState.serverEpoch,
30 snapshot.tabId,
31 ),
32 pictureInPicture:
33 previewState.desktopByTabId[snapshot.tabId]?.pictureInPicture ?? false,
34 zoomFactor: previewState.desktopByTabId[snapshot.tabId]?.zoomFactor ?? 1,
35 }))
38 snapshot,
39 runtimeTabId: previewRuntimeTabId(
40 threadRef,
41 previewState.serverEpoch,
42 snapshot.tabId,
43 ),
44 pictureInPicture:
45 previewState.desktopByTabId[snapshot.tabId]?.pictureInPicture ?? false,
46 zoomFactor: previewState.desktopByTabId[snapshot.tabId]?.zoomFactor ?? 1,
47 }))
3648 : [];
3749 }),
38 [previewByThreadKey],
50 [previewByThreadKey, primaryEnvironmentId],
3951 );
4052
4153 useEffect(() => {
......@@ -95,6 +107,15 @@ export function ElectronBrowserHost() {
95107 pictureInPicture={pictureInPicture}
96108 profileId={snapshot.profileId}
97109 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 : {})}
98119 />
99120 );
100121 })}
apps/web/src/browser/HostedBrowserWebview.test.tsx+1-1
......@@ -174,7 +174,7 @@ describe("HostedBrowserWebview settings hydration", () => {
174174 await retry;
175175 });
176176
177 expect(acquire).toHaveBeenCalledExactlyOnceWith(runtimeTabId);
177 expect(acquire).toHaveBeenCalledExactlyOnceWith(runtimeTabId, undefined);
178178 expect(mocks.getPreviewConfig).toHaveBeenCalledExactlyOnceWith(threadRef.environmentId, "work");
179179 expect(createGuest).toHaveBeenCalledOnce();
180180 expect(createGuest).toHaveBeenCalledWith(
apps/web/src/browser/HostedBrowserWebview.tsx+39-5
......@@ -1,6 +1,10 @@
11"use client";
22
3import type { PreviewViewportSetting, ScopedThreadRef } from "@t3tools/contracts";
3import type {
4 DesktopPreviewColorScheme,
5 PreviewViewportSetting,
6 ScopedThreadRef,
7} from "@t3tools/contracts";
48import { useShallow } from "zustand/react/shallow";
59import { useCallback, useEffect, useRef, useState } from "react";
610
......@@ -18,7 +22,7 @@ import {
1822} from "./browserViewportLayout";
1923import { BrowserDeviceToolbar } from "./BrowserDeviceToolbar";
2024import { BrowserViewportResizeHandles } from "./BrowserViewportResizeHandles";
21import { acquireDesktopTab, type AcquiredDesktopTab } from "./desktopTabLifetime";
25import { acquireDesktopTab, type AcquiredDesktopTab, withDesktopTab } from "./desktopTabLifetime";
2226import { resolveHostedBrowserWebviewWrapperStyle } from "./hostedBrowserWebviewStyle";
2327import { usePreviewWebviewConfig } from "./previewWebviewConfigState";
2428import { useBrowserViewportResize } from "./useBrowserViewportResize";
......@@ -56,6 +60,16 @@ export function HostedBrowserWebview(props: {
5660 */
5761 readonly profileId: string | undefined;
5862 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 };
5973}) {
6074 const {
6175 threadRef,
......@@ -66,6 +80,8 @@ export function HostedBrowserWebview(props: {
6680 pictureInPicture,
6781 zoomFactor,
6882 profileId,
83 serverDriven = false,
84 serverRendering,
6985 } = props;
7086 const clientSettingsHydrated = useClientSettingsHydrated();
7187 const config = usePreviewWebviewConfig(threadRef.environmentId, profileId);
......@@ -93,18 +109,36 @@ export function HostedBrowserWebview(props: {
93109 (state) => (state.activityByTabId[runtimeTabId] ?? 0) > 0,
94110 );
95111 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
98117 useEffect(() => {
99118 if (!clientSettingsHydrated) return;
100119 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 );
102124 tabLeaseRef.current = lease;
103125 return () => {
104126 if (tabLeaseRef.current === lease) tabLeaseRef.current = null;
105127 lease.release();
106128 };
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
109143 const [webviewGeneration, setWebviewGeneration] = useState(0);
110144 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 @@
88 *
99 * @module browserLinkTarget
1010 */
11import type { BrowserLinkTarget } from "@t3tools/contracts";
11import type { BrowserLinkTarget, ScopedThreadRef } from "@t3tools/contracts";
1212
1313import { ensureClientSettingsHydrated, getClientSettings } from "~/hooks/useSettings";
14import { isPreviewSupportedInRuntime } from "~/previewStateStore";
14import { isPreviewAvailableFor } from "./previewRuntime";
1515
1616export interface ResolveLinkTargetInput {
1717 readonly url: string;
......@@ -62,7 +62,7 @@ export async function resolveBrowserLinkTargetPreference(): Promise<BrowserLinkT
6262 return getClientSettings().browserLinkTarget;
6363}
6464
65/** Whether the in-app target is available at all in this client. */
66export function canOpenLinksInApp(hasThread: boolean): boolean {
67 return hasThread && isPreviewSupportedInRuntime();
65/** Whether this thread's environment has an in-app browser available to this client. */
66export function canOpenLinksInApp(threadRef: ScopedThreadRef | null | undefined): boolean {
67 return threadRef != null && isPreviewAvailableFor(threadRef.environmentId);
6868}
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 = (
3131 return pending;
3232};
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
3442export interface AcquiredDesktopTab {
3543 readonly ready: Promise<void>;
3644 readonly release: () => void;
3745}
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 {
4052 const current =
4153 leases.get(tabId) ??
4254 ({
......@@ -45,7 +57,10 @@ export function acquireDesktopTab(tabId: string): AcquiredDesktopTab {
4557 // Zoom/appearance defaults travel with creation so the guest never
4658 // paints a frame at 100%/system before the preference is applied.
4759 ready: enqueueDesktopTabOperation(tabId, async () =>
48 previewBridge?.createTab(tabId, browserDefaultTabState(await resolveBrowserDefaults())),
60 previewBridge?.createTab(tabId, {
61 ...browserDefaultTabState(await resolveBrowserDefaults()),
62 ...(serverTab === undefined ? {} : { serverTab }),
63 }),
4964 ),
5065 } satisfies DesktopTabLease);
5166 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";
1616import { AsyncResult } from "effect/reactivity";
1717
1818import { resolveAssetUrl } from "~/assets/assetUrls";
19import {
20 applyPreviewServerSnapshot,
21 isPreviewSupportedInRuntime,
22 rememberPreviewUrl,
23} from "~/previewStateStore";
19import { isPreviewAvailableFor, previewRuntimeFor } from "~/browser/previewRuntime";
20import { applyPreviewServerSnapshot, rememberPreviewUrl } from "~/previewStateStore";
2421import { useRightPanelStore } from "~/rightPanelStore";
2522
2623import {
......@@ -62,6 +59,7 @@ export async function openUrlInPreview<E>(input: {
6259 if (defaults instanceof BrowserSettingsReadError) {
6360 return AsyncResult.failure(Cause.fail(defaults));
6461 }
62 const runtime = previewRuntimeFor(input.threadRef.environmentId);
6563 const result = await input.openPreview({
6664 environmentId: input.threadRef.environmentId,
6765 input: {
......@@ -72,6 +70,7 @@ export async function openUrlInPreview<E>(input: {
7270 // applied explicitly or file/link opens would ignore them.
7371 viewport: browserDefaultOpenViewport(defaults),
7472 profileId: browserDefaultOpenProfileId(defaults),
73 ...(runtime === undefined ? {} : { runtime }),
7574 },
7675 });
7776 return mapAtomCommandResult(result, (snapshot) => {
......@@ -101,7 +100,7 @@ export async function openFileInPreview<AssetError, PreviewError>(input: {
101100 AssetError | PreviewError | BrowserPreviewUnavailableError | BrowserSettingsReadError
102101 >
103102> {
104 if (!isPreviewSupportedInRuntime()) {
103 if (!isPreviewAvailableFor(input.threadRef.environmentId)) {
105104 return AsyncResult.failure(
106105 Cause.fail(
107106 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): (
4646 url,
4747 event: options.event ?? NO_MODIFIER,
4848 preference: await resolveBrowserLinkTargetPreference(),
49 canOpenInApp: canOpenLinksInApp(Boolean(targetThreadRef)),
49 canOpenInApp: canOpenLinksInApp(targetThreadRef),
5050 });
5151 if (target === "app" && targetThreadRef) {
5252 const result = await openUrlInPreview({ threadRef: targetThreadRef, url, openPreview });
apps/web/src/components/ChatMarkdown.tsx+3-3
......@@ -190,7 +190,7 @@ import {
190190} from "~/lib/openPullRequestLink";
191191import { useOpenLink } from "../browser/useOpenLink";
192192import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
193import { isPreviewSupportedInRuntime } from "../previewStateStore";
193import { isPreviewAvailableFor } from "../browser/previewRuntime";
194194import { isAbsolutePath, resolvePathLinkTarget } from "../terminal-links";
195195import {
196196 isBrowserPreviewFile,
......@@ -2803,7 +2803,7 @@ function useChatMarkdownState({
28032803 revealLabel={revealInFileManagerLabel}
28042804 onOpenInBrowser={
28052805 threadRef &&
2806 isPreviewSupportedInRuntime() &&
2806 isPreviewAvailableFor(threadRef.environmentId) &&
28072807 isBrowserPreviewFile(fileLinkMeta.filePath)
28082808 ? () => openMarkdownFileInPreview(fileLinkMeta.filePath)
28092809 : undefined
......@@ -3073,7 +3073,7 @@ const CHAT_MARKDOWN_COMPONENTS = {
30733073 : null;
30743074 const isSameDocumentLink = href?.startsWith("#") ?? false;
30753075 const onClick = props.onClick;
3076 const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime();
3076 const canOpenInPreview = Boolean(threadRef && isPreviewAvailableFor(threadRef.environmentId));
30773077 const linkChildren = <MarkdownLinkContext value>{children}</MarkdownLinkContext>;
30783078 const link = (
30793079 <a
apps/web/src/components/ChatView.tsx+83-13
......@@ -259,6 +259,7 @@ import { previewRuntimeTabId } from "../browser/previewRuntimeTabId";
259259import { addBrowserSurface } from "./preview/addBrowserSurface";
260260import { closePreviewSession } from "./preview/closePreviewSession";
261261import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer";
262import { usePreviewSession } from "./preview/usePreviewSession";
262263import { subscribePreviewAction } from "./preview/previewActionBus";
263264import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic";
264265
......@@ -408,6 +409,7 @@ import { projectCloneDisplayName, projectCloneProgressSummary } from "@t3tools/c
408409import { useEnvironments, usePrimaryEnvironment } from "../state/environments";
409410import {
410411 resolveThreadDetailRef,
412 useEnvironmentSupportsServerBrowser,
411413 useProject,
412414 useProjects,
413415 useThreadProjection,
......@@ -2195,6 +2197,11 @@ export default function ChatView(props: ChatViewProps) {
21952197 [activeThread],
21962198 );
21972199 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;
21982205 const previewPanelInlineSize = usePreviewPanelInlineSize(undefined, {
21992206 containerWidth: workspaceLayoutWidth ?? undefined,
22002207 widthStorageKey: `t3code:preview-panel-width:${activeThreadKey}`,
......@@ -2260,6 +2267,7 @@ export default function ChatView(props: ChatViewProps) {
22602267 );
22612268 const activePreviewState = useThreadPreviewState(activeThreadRef);
22622269 const activePreviewServerEpoch = activePreviewState.serverEpoch;
2270 const previewSessionsReady = !activeEnvironmentServerBrowser || activePreviewState.listLoaded;
22632271 const resolvePreviewRuntimeTabId = useMemo(
22642272 () =>
22652273 activeThreadRef
......@@ -2283,7 +2291,7 @@ export default function ChatView(props: ChatViewProps) {
22832291 () => [...new Set([...activeKnownTerminalIds, ...panelTerminalIds])],
22842292 [activeKnownTerminalIds, panelTerminalIds],
22852293 );
2286 const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime();
2294 const previewPanelOpen = activeRightPanelKind === "preview" && browserAvailable;
22872295 const rightPanelOpen = rightPanelState.isOpen;
22882296 const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } =
22892297 usePanelAnimationSettings();
......@@ -2334,21 +2342,32 @@ export default function ChatView(props: ChatViewProps) {
23342342 );
23352343
23362344 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 );
23382351 useRightPanelStore
23392352 .getState()
2340 .reconcileBrowserSurfaces(activeThreadRef, Object.keys(activePreviewState.sessions));
2341 }, [activePreviewState.sessions, activeThreadRef]);
2353 .reconcileBrowserSurfaces(
2354 activeThreadRef,
2355 Object.keys(activePreviewState.sessions),
2356 hiddenTabIds,
2357 );
2358 }, [activePreviewState.sessions, activeThreadRef, previewSessionsReady]);
23422359
23432360 useEffect(() => {
2344 if (!activeThreadRef || activePreviewMiniPlayer?.source.kind !== "browser") return;
2345 const miniTabStillExists = Boolean(
2346 activePreviewState.sessions[activePreviewMiniPlayer.source.tabId],
2347 );
2348 if (!miniTabStillExists) {
2361 const source = activePreviewMiniPlayer?.source;
2362 if (
2363 activeThreadRef &&
2364 previewSessionsReady &&
2365 source?.kind === "browser" &&
2366 !activePreviewState.sessions[source.tabId]
2367 ) {
23492368 usePreviewMiniPlayerStore.getState().close(activeThreadRef);
23502369 }
2351 }, [activePreviewMiniPlayer, activePreviewState.sessions, activeThreadRef]);
2370 }, [activePreviewMiniPlayer, activePreviewState.sessions, activeThreadRef, previewSessionsReady]);
23522371
23532372 const existingOpenTerminalThreadKeys = useMemo(() => {
23542373 const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]);
......@@ -5349,6 +5368,47 @@ export default function ChatView(props: ChatViewProps) {
53495368 deviceState.sessions,
53505369 deviceState.devices,
53515370 ]);
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 ]);
53525412 // A floating device follows its session: once the agent or another client
53535413 // closes the device there is nothing left to stream.
53545414 useEffect(() => {
......@@ -5653,7 +5713,7 @@ export default function ChatView(props: ChatViewProps) {
56535713 }
56545714 }, [activeRightPanelSurface, activeThreadRef]);
56555715 const togglePreviewPanel = useCallback(() => {
5656 if (!activeThreadRef || !isPreviewSupportedInRuntime()) return;
5716 if (!activeThreadRef || !browserAvailable) return;
56575717 if (previewPanelOpen) {
56585718 closePreviewPanel();
56595719 return;
......@@ -5667,6 +5727,7 @@ export default function ChatView(props: ChatViewProps) {
56675727 }, [
56685728 activePreviewState.activeTabId,
56695729 activeThreadRef,
5730 browserAvailable,
56705731 closePreviewPanel,
56715732 createBrowserSurface,
56725733 previewPanelOpen,
......@@ -11449,6 +11510,9 @@ export default function ChatView(props: ChatViewProps) {
1144911510 </div>
1145011511 </div>
1145111512
11513 {activeThreadRef && activeEnvironmentServerBrowser ? (
11514 <PreviewSessionSync threadRef={activeThreadRef} />
11515 ) : null}
1145211516 {activeThreadRef && activePreviewMiniPlayer && previewMiniPlayerVisible ? (
1145311517 <ThreadPreviewMiniPlayer
1145411518 key={`${activeThreadKey}:${previewMiniPlayerSourceKey(activePreviewMiniPlayer.source)}`}
......@@ -11562,7 +11626,7 @@ export default function ChatView(props: ChatViewProps) {
1156211626 onAddPullRequest={addPullRequestSurface}
1156311627 onAddPullRequests={addPullRequestsSurface}
1156411628 onAddDevice={addDeviceSurface}
11565 browserAvailable={isPreviewSupportedInRuntime()}
11629 browserAvailable={browserAvailable}
1156611630 terminalAvailable={activeProject !== null}
1156711631 diffAvailable={isServerThread && isGitRepo}
1156811632 filesAvailable={activeProject !== null}
......@@ -11617,7 +11681,7 @@ export default function ChatView(props: ChatViewProps) {
1161711681 onAddPullRequest={addPullRequestSurface}
1161811682 onAddPullRequests={addPullRequestsSurface}
1161911683 onAddDevice={addDeviceSurface}
11620 browserAvailable={isPreviewSupportedInRuntime()}
11684 browserAvailable={browserAvailable}
1162111685 terminalAvailable={activeProject !== null}
1162211686 diffAvailable={isServerThread && isGitRepo}
1162311687 filesAvailable={activeProject !== null}
......@@ -11679,3 +11743,9 @@ export default function ChatView(props: ChatViewProps) {
1167911743 </div>
1168011744 );
1168111745}
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";
4040import { PREFERRED_HIGHLIGHTER } from "~/lib/syntaxHighlighting";
4141import { cn } from "~/lib/utils";
4242import type { ChatFileAttachment } from "~/types";
43import { isPreviewSupportedInRuntime } from "~/previewStateStore";
4443import { isAbsolutePath, resolvePathLinkTarget } from "~/terminal-links";
4544import { ScrollArea } from "~/components/ui/scroll-area";
4645import { stackedThreadToast, toastManager } from "~/components/ui/toast";
4746import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";
4847import { buildFileReviewComment } from "~/reviewCommentContext";
4948import { assetEnvironment } from "~/state/assets";
49import { usePreviewAvailable } from "~/browser/previewRuntime";
5050import { useEnvironmentHttpBaseUrl, usePrimaryEnvironmentId } from "~/state/environments";
5151import { previewEnvironment } from "~/state/preview";
5252import { useAtomCommand } from "~/state/use-atom-command";
......@@ -930,6 +930,7 @@ export default function FilePreviewPanel({
930930 const primaryEnvironmentId = usePrimaryEnvironmentId();
931931 const remoteOpenState = useRemoteOpenState(environmentId);
932932 const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(environmentId);
933 const previewAvailable = usePreviewAvailable(environmentId);
933934 const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
934935 reportFailure: false,
935936 });
......@@ -1032,7 +1033,7 @@ export default function FilePreviewPanel({
10321033 previewPath !== null &&
10331034 attachment === undefined &&
10341035 !isVideo &&
1035 isPreviewSupportedInRuntime() &&
1036 previewAvailable &&
10361037 isBrowserPreviewFile(previewPath);
10371038 const absolutePath =
10381039 relativePath && attachment === undefined ? resolvePathLinkTarget(relativePath, cwd) : null;
apps/web/src/components/onboarding/WelcomeWizard.tsx+1-40
......@@ -30,7 +30,6 @@ import {
3030 MonitorIcon,
3131 TerminalIcon,
3232} from "lucide-react";
33import { Check, Copy } from "lucide";
3433import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3534
3635import { TYPOGRAPHY_ADVANCED_STORAGE_KEY } from "../../appearanceFonts";
......@@ -52,7 +51,6 @@ import {
5251 resolveOnboardingProviderLoginCommand,
5352 selectOnboardingProvidersByDriver,
5453} from "../../onboarding/providerReadiness.logic";
55import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
5654import { newProjectId, randomUUID } from "../../lib/utils";
5755import { agentSessionImport } from "../../state/agentSessions";
5856import { readProjects, useProjects } from "../../state/entities";
......@@ -76,8 +74,8 @@ import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
7674import { T3Wordmark } from "../T3Wordmark";
7775import { Alert, AlertDescription } from "../ui/alert";
7876import { Button } from "../ui/button";
77import { CommandBlock } from "../CommandBlock";
7978import { Checkbox } from "../ui/checkbox";
80import { MorphIcon } from "~/components/MorphIcon";
8179import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible";
8280import { Input } from "../ui/input";
8381import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";
......@@ -1713,40 +1711,3 @@ function StepShell({
17131711 </>
17141712 );
17151713}
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: {
4545 readonly controller: BrowserController;
4646}) {
4747 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;
4869 const [inactiveSequence, setInactiveSequence] = useState<number | null>(null);
49 const active = inactiveSequence !== event.sequence;
70 const active = inactiveSequence !== sequence;
5071
5172 useEffect(() => {
52 const timeout = window.setTimeout(() => setInactiveSequence(event.sequence), CURSOR_ACTIVE_MS);
73 const timeout = window.setTimeout(() => setInactiveSequence(sequence), CURSOR_ACTIVE_MS);
5374 return () => window.clearTimeout(timeout);
54 }, [event.sequence]);
75 }, [sequence]);
5576
5677 return (
5778 <div
5879 className="pointer-events-none absolute left-0 top-0 z-40 transition-[opacity,transform] duration-150 ease-out motion-reduce:transition-none"
5980 style={{
6081 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)`,
6283 }}
6384 aria-hidden="true"
6485 data-agent-browser-cursor
6586 >
66 {event.phase === "click" ? (
87 {phase === "click" ? (
6788 <span
68 key={event.sequence}
89 key={sequence}
6990 className="absolute left-0.5 top-0.5 size-4 animate-status-ping rounded-full bg-primary/25 motion-reduce:animate-none"
7091 />
7192 ) : 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 @@
11"use client";
22
3import type { DesktopPreviewColorScheme, EnvironmentId } from "@t3tools/contracts";
3import type { DesktopPreviewColorScheme } from "@t3tools/contracts";
44import { Minus, MoreVertical, Plus as PlusIcon, RotateCcw } from "lucide-react";
55
66import { Button } from "~/components/ui/button";
......@@ -20,8 +20,6 @@ import {
2020} from "~/components/ui/menu";
2121import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
2222
23import { previewBridge } from "./previewBridge";
24
2523const COLOR_SCHEME_OPTIONS: ReadonlyArray<{
2624 value: DesktopPreviewColorScheme;
2725 label: string;
......@@ -31,18 +29,32 @@ const COLOR_SCHEME_OPTIONS: ReadonlyArray<{
3129 { value: "dark", label: "Dark" },
3230];
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
3451interface Props {
35 /** Active preview tab id. Tab-targeting actions are disabled without it. */
36 tabId: string | null;
37 /**
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;
52 /** False until the tab can take these actions, such as before its page attaches. */
53 enabled: boolean;
54 actions: PreviewMoreMenuActions;
4355 /** Current zoom factor as a number (1.0 = 100%). */
4456 zoomFactor: number;
45 /** Emulated `prefers-color-scheme` for the guest page. */
57 /** Emulated `prefers-color-scheme` for the page. */
4658 colorScheme: DesktopPreviewColorScheme;
4759 /** Fixed viewport modes expose the device toolbar and resize rails. */
4860 deviceToolbarVisible: boolean;
......@@ -50,90 +62,66 @@ interface Props {
5062 onToggleDeviceToolbar: () => void;
5163 /** Whether the separate native always-on-top preview window is open. */
5264 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;
6465 /** Profile display name, shown so the menu says which data is being cleared. */
6566 profileName: string | undefined;
6667}
6768
68/**
69 * Three-dot menu in the chrome row. Wires Hard reload, DevTools, zoom
70 * controls, and storage-clearing actions. Only mounted by `PreviewView`
71 * when the desktop bridge is present, so we can call it unconditionally.
72 */
69const MenuTriggerButton = () => (
70 <Tooltip>
71 <TooltipTrigger
72 render={
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. */
7385export function PreviewMoreMenu({
74 tabId,
75 hasWebContents,
86 enabled,
87 actions,
7688 zoomFactor,
7789 colorScheme,
7890 deviceToolbarVisible,
7991 onToggleDeviceToolbar,
8092 nativePictureInPicture,
81 onNativePictureInPicture,
82 environmentId,
83 profileId,
8493 profileName,
8594}: 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
9495 const zoomLabel = `${Math.round(zoomFactor * 100)}%`;
96 const disabled = !enabled;
9597 return (
9698 <Menu>
97 <Tooltip>
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>
99 <MenuTriggerButton />
111100 <MenuPopup align="end" sideOffset={6}>
112 <MenuItem onClick={callTab(bridge.hardReload)} disabled={tabDisabled}>
101 <MenuItem onClick={actions.hardReload} disabled={disabled}>
113102 Hard reload
114103 </MenuItem>
115 <MenuItem onClick={callTab(bridge.openDevTools)} disabled={tabDisabled}>
116 Open DevTools
117 </MenuItem>
118 <MenuItem onClick={onNativePictureInPicture} disabled={tabDisabled}>
119 {nativePictureInPicture
120 ? "Close separate preview window"
121 : "Open separate preview window"}
122 </MenuItem>
123 <MenuItem onClick={onToggleDeviceToolbar} disabled={tabDisabled}>
104 {actions.openDevTools ? (
105 <MenuItem onClick={actions.openDevTools} disabled={disabled}>
106 Open DevTools
107 </MenuItem>
108 ) : null}
109 {actions.toggleNativePictureInPicture ? (
110 <MenuItem onClick={actions.toggleNativePictureInPicture} disabled={disabled}>
111 {nativePictureInPicture
112 ? "Close separate preview window"
113 : "Open separate preview window"}
114 </MenuItem>
115 ) : null}
116 <MenuItem onClick={onToggleDeviceToolbar} disabled={disabled}>
124117 {deviceToolbarVisible ? "Hide device toolbar" : "Show device toolbar"}
125118 </MenuItem>
126119 <MenuSub>
127 <MenuSubTrigger disabled={tabDisabled}>Appearance</MenuSubTrigger>
120 <MenuSubTrigger disabled={disabled}>Appearance</MenuSubTrigger>
128121 <MenuSubPopup>
129122 <MenuRadioGroup
130123 value={colorScheme}
131 onValueChange={(value) => {
132 if (!tabId) return;
133 void bridge
134 .setColorScheme(tabId, value as DesktopPreviewColorScheme)
135 .catch(() => undefined);
136 }}
124 onValueChange={(value) => actions.setColorScheme(value as DesktopPreviewColorScheme)}
137125 >
138126 {COLOR_SCHEME_OPTIONS.map((option) => (
139127 <MenuRadioItem key={option.value} value={option.value}>
......@@ -152,7 +140,7 @@ export function PreviewMoreMenu({
152140 closeOnClick={false}
153141 onClick={(event: React.MouseEvent) => event.preventDefault()}
154142 className="justify-between"
155 disabled={tabDisabled}
143 disabled={disabled}
156144 >
157145 <span>Zoom</span>
158146 <span className="flex items-center gap-1">
......@@ -160,9 +148,9 @@ export function PreviewMoreMenu({
160148 variant="outline"
161149 size="icon-xs"
162150 type="button"
163 onClick={callTab(bridge.zoomOut)}
151 onClick={actions.zoomOut}
164152 aria-label="Zoom out"
165 disabled={tabDisabled}
153 disabled={disabled}
166154 >
167155 <Minus />
168156 </Button>
......@@ -173,9 +161,9 @@ export function PreviewMoreMenu({
173161 variant="outline"
174162 size="icon-xs"
175163 type="button"
176 onClick={callTab(bridge.zoomIn)}
164 onClick={actions.zoomIn}
177165 aria-label="Zoom in"
178 disabled={tabDisabled}
166 disabled={disabled}
179167 >
180168 <PlusIcon />
181169 </Button>
......@@ -183,9 +171,9 @@ export function PreviewMoreMenu({
183171 variant="ghost"
184172 size="icon-xs"
185173 type="button"
186 onClick={callTab(bridge.resetZoom)}
174 onClick={actions.resetZoom}
187175 aria-label="Reset zoom"
188 disabled={tabDisabled}
176 disabled={disabled}
189177 >
190178 <RotateCcw />
191179 </Button>
......@@ -199,11 +187,6 @@ export function PreviewMoreMenu({
199187 at open and nothing else in the chrome shows it.
200188 */}
201189 <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 */}
207190 {profileName ? (
208191 // Truncation needs a block box: `text-overflow` on an inline child
209192 // never applies and a long name would push the popup past its width.
......@@ -211,18 +194,8 @@ export function PreviewMoreMenu({
211194 <span className="block truncate">Profile: {profileName}</span>
212195 </MenuGroupLabel>
213196 ) : null}
214 <MenuItem
215 onClick={() =>
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>
197 <MenuItem onClick={actions.clearCookies}>Clear cookies</MenuItem>
198 <MenuItem onClick={actions.clearCache}>Clear cache</MenuItem>
226199 </MenuGroup>
227200 </MenuPopup>
228201 </Menu>
apps/web/src/components/preview/PreviewPanel.tsx+2-2
......@@ -3,7 +3,7 @@
33import type { PreviewAnnotationPayload, ScopedThreadRef } from "@t3tools/contracts";
44
55import type { ComposerImageAttachment } from "~/composerDraftStore";
6import { isPreviewSupportedInRuntime } from "~/previewStateStore";
6import { usePreviewAvailable } from "~/browser/previewRuntime";
77
88import { PreviewPanelShell, type PreviewPanelMode } from "./PreviewPanelShell";
99import { PreviewView } from "./PreviewView";
......@@ -28,7 +28,7 @@ export function PreviewPanel({
2828 visible,
2929 onSendAnnotation,
3030}: Props) {
31 if (!isPreviewSupportedInRuntime()) {
31 if (!usePreviewAvailable(threadRef.environmentId)) {
3232 return (
3333 <PreviewPanelShell mode={mode}>
3434 <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", () => ({
221221 onPictureInPicture?: () => void;
222222 pictureInPicture?: boolean;
223223 trailingActions?: {
224 props: { onNativePictureInPicture?: () => void };
224 props: { actions?: { toggleNativePictureInPicture?: () => void } };
225225 };
226226 }) => {
227227 mocks.submittedUrl = props.onSubmit;
228228 mocks.toggleAnnotation = props.onPickElement ?? null;
229229 mocks.togglePictureInPicture = props.onPictureInPicture ?? null;
230230 mocks.toggleNativePictureInPicture =
231 props.trailingActions?.props.onNativePictureInPicture ?? null;
231 props.trailingActions?.props.actions?.toggleNativePictureInPicture ?? null;
232232 mocks.pictureInPicturePressed = props.pictureInPicture ?? false;
233233 return null;
234234 },
......@@ -241,8 +241,8 @@ vi.mock("./PreviewEmptyState", () => ({
241241 },
242242}));
243243vi.mock("./PreviewMoreMenu", () => ({
244 PreviewMoreMenu: (props: { onNativePictureInPicture: () => void }) => {
245 mocks.toggleNativePictureInPicture = props.onNativePictureInPicture;
244 PreviewMoreMenu: (props: { actions: { toggleNativePictureInPicture?: () => void } }) => {
245 mocks.toggleNativePictureInPicture = props.actions.toggleNativePictureInPicture ?? null;
246246 return null;
247247 },
248248}));
apps/web/src/components/preview/PreviewView.tsx+237-41
......@@ -11,6 +11,9 @@ import {
1111 type PreviewAnnotationPayload,
1212 type PreviewViewportSetting,
1313 type ScopedThreadRef,
14 DEFAULT_PREVIEW_ZOOM_FACTOR,
15 PREVIEW_ZOOM_LEVELS,
16 type PreviewAdjustInput,
1417} from "@t3tools/contracts";
1518import { normalizePreviewUrl } from "@t3tools/shared/preview";
1619import { useCallback, useEffect, useRef, useState } from "react";
......@@ -46,12 +49,14 @@ import { subscribePreviewAction } from "./previewActionBus";
4649import { openPreviewSession } from "./openPreviewSession";
4750import { PreviewChromeRow } from "./PreviewChromeRow";
4851import { PreviewEmptyState } from "./PreviewEmptyState";
49import { PreviewMoreMenu } from "./PreviewMoreMenu";
52import { PreviewMoreMenu, type PreviewMoreMenuActions } from "./PreviewMoreMenu";
5053import {
5154 commitBrowserViewportChange,
5255 subscribeBrowserViewportChange,
5356} from "~/browser/browserViewportActions";
5457import { browserResponsiveViewportForToggle, useBrowserDefaults } from "~/browser/browserDefaults";
58import { BrowserDeviceToolbar } from "~/browser/BrowserDeviceToolbar";
59import { BROWSER_DEVICE_TOOLBAR_HEIGHT } from "~/browser/browserViewportLayout";
5560import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";
5661import { BrowserSettingsReadError } from "~/browser/openFileInPreview";
5762import { PreviewUnreachable } from "./PreviewUnreachable";
......@@ -59,6 +64,9 @@ import { revealInFileExplorerLabel } from "./fileExplorerLabel";
5964import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic";
6065import { Badge } from "~/components/ui/badge";
6166import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot";
67import { useRendersServerTabNatively } from "~/browser/previewRuntime";
68import { ServerBrowserSurface, type ServerBrowserHandle } from "~/browser/ServerBrowserSurface";
69import { cn } from "~/lib/utils";
6270import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore";
6371import { usePreviewSession } from "./usePreviewSession";
6472import { ZoomIndicator } from "./ZoomIndicator";
......@@ -129,6 +137,7 @@ export function PreviewView({
129137 : null;
130138 const open = useAtomCommand(previewEnvironment.open);
131139 const resize = useAtomCommand(previewEnvironment.resize, "preview viewport resize");
140 const adjust = useAtomCommand(previewEnvironment.adjust, "preview appearance or zoom");
132141
133142 usePreviewSession(threadRef);
134143
......@@ -150,6 +159,31 @@ export function PreviewView({
150159 : findActiveBrowserRecordingRuntimeTabId(threadRef, tabId)
151160 : null;
152161 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);
153187 const desktopOverlay = tabId ? (previewState.desktopByTabId[tabId] ?? null) : null;
154188 const navStatus = snapshot?.navStatus ?? { _tag: "Idle" as const };
155189 const url = navStatus._tag === "Idle" ? "" : navStatus.url;
......@@ -159,6 +193,8 @@ export function PreviewView({
159193 const refreshDisabled = navStatus._tag === "Idle";
160194 const isUnreachable = navStatus._tag === "LoadFailed";
161195 const showEmptyState = shouldShowPreviewEmptyState(snapshot);
196 const serverStreamPending =
197 isServerTab && !showEmptyState && !isUnreachable && serverFrameTabId !== runtimeTabId;
162198 const controller = desktopOverlay?.controller ?? "none";
163199 const viewport = snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT;
164200 const browserDefaults = useBrowserDefaults();
......@@ -187,6 +223,12 @@ export function PreviewView({
187223
188224 const navigateToResolvedUrl = useCallback(
189225 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 }
190232 if (runtimeTabId && previewBridge) {
191233 // The bridge mirrors the resolved URL back to the server.
192234 await previewBridge.navigate(runtimeTabId, resolvedUrl);
......@@ -206,7 +248,7 @@ export function PreviewView({
206248 }
207249 return result._tag === "Success";
208250 },
209 [open, runtimeTabId, threadRef],
251 [isServerTab, open, runtimeTabId, serverInputDisabled, threadRef],
210252 );
211253
212254 const handleSubmitUrl = useCallback(
......@@ -226,7 +268,11 @@ export function PreviewView({
226268 const handleOpenServerUrl = useCallback(
227269 async (next: string) => {
228270 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);
230276 if (await navigateToResolvedUrl(resolved)) {
231277 recordVisitForThread(threadRef, next);
232278 }
......@@ -234,24 +280,68 @@ export function PreviewView({
234280 // Server-side `failed` event renders the unreachable view.
235281 }
236282 },
237 [navigateToResolvedUrl, threadRef],
283 [isServerTab, navigateToResolvedUrl, threadRef],
238284 );
239285
240286 const handleRefresh = useCallback(() => {
241 if (previewBridge && runtimeTabId) void previewBridge.refresh(runtimeTabId);
242 }, [runtimeTabId]);
287 if (isServerTab) serverSurfaceRef.current?.reload();
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
244331 const handleZoomIn = useCallback(() => {
245 if (previewBridge && runtimeTabId) void previewBridge.zoomIn(runtimeTabId);
246 }, [runtimeTabId]);
332 if (serverOwnsRendering) stepServerZoom(1);
333 else if (previewBridge && runtimeTabId) void previewBridge.zoomIn(runtimeTabId);
334 }, [runtimeTabId, serverOwnsRendering, stepServerZoom]);
247335
248336 const handleZoomOut = useCallback(() => {
249 if (previewBridge && runtimeTabId) void previewBridge.zoomOut(runtimeTabId);
250 }, [runtimeTabId]);
337 if (serverOwnsRendering) stepServerZoom(-1);
338 else if (previewBridge && runtimeTabId) void previewBridge.zoomOut(runtimeTabId);
339 }, [runtimeTabId, serverOwnsRendering, stepServerZoom]);
251340
252341 const handleResetZoom = useCallback(() => {
253 if (previewBridge && runtimeTabId) void previewBridge.resetZoom(runtimeTabId);
254 }, [runtimeTabId]);
342 if (serverOwnsRendering) stepServerZoom(0);
343 else if (previewBridge && runtimeTabId) void previewBridge.resetZoom(runtimeTabId);
344 }, [runtimeTabId, serverOwnsRendering, stepServerZoom]);
255345
256346 const handleViewportChange = useCallback(
257347 async (nextViewport: PreviewViewportSetting) => {
......@@ -290,7 +380,7 @@ export function PreviewView({
290380 browserResponsiveViewportForToggle({
291381 defaults: browserDefaults,
292382 panelRect,
293 zoomFactor: desktopOverlay?.zoomFactor,
383 zoomFactor: serverOwnsRendering ? serverZoomFactor : desktopOverlay?.zoomFactor,
294384 }),
295385 ).catch(() => undefined);
296386 };
......@@ -301,12 +391,14 @@ export function PreviewView({
301391 }, [handleViewportChange, runtimeTabId]);
302392
303393 const handleBack = useCallback(() => {
304 if (previewBridge && runtimeTabId) void previewBridge.goBack(runtimeTabId);
305 }, [runtimeTabId]);
394 if (isServerTab) serverSurfaceRef.current?.history(-1);
395 else if (previewBridge && runtimeTabId) void previewBridge.goBack(runtimeTabId);
396 }, [isServerTab, runtimeTabId]);
306397
307398 const handleForward = useCallback(() => {
308 if (previewBridge && runtimeTabId) void previewBridge.goForward(runtimeTabId);
309 }, [runtimeTabId]);
399 if (isServerTab) serverSurfaceRef.current?.history(1);
400 else if (previewBridge && runtimeTabId) void previewBridge.goForward(runtimeTabId);
401 }, [isServerTab, runtimeTabId]);
310402
311403 const handleOpenInBrowser = useCallback(() => {
312404 if (!localApi || !url) return;
......@@ -323,6 +415,53 @@ export function PreviewView({
323415 useRightPanelStore.getState().close(threadRef);
324416 }, [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
326465 const handleNativePictureInPicture = useCallback(() => {
327466 if (!previewBridge || !runtimeTabId) return;
328467 const operation = desktopOverlay?.pictureInPicture
......@@ -712,23 +851,30 @@ export function PreviewView({
712851 >
713852 <PreviewChromeRow
714853 url={url}
715 loading={loading}
716 canGoBack={canGoBack}
717 canGoForward={canGoForward}
718 refreshDisabled={refreshDisabled}
854 loading={loading || serverStreamPending}
855 canGoBack={canGoBack && !serverInputDisabled}
856 canGoForward={canGoForward && !serverInputDisabled}
857 refreshDisabled={refreshDisabled || serverInputDisabled}
858 inputDisabled={serverInputDisabled}
719859 focusUrlNonce={focusUrlNonce}
720860 onBack={handleBack}
721861 onForward={handleForward}
722862 onRefresh={handleRefresh}
723863 onSubmit={(next) => void handleSubmitUrl(next)}
724864 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}
726868 captureDisabled={!desktopOverlay || isUnreachable}
727869 recording={recordingRuntimeTabId !== null}
728 onPictureInPicture={previewBridge && tabId ? handlePictureInPicture : undefined}
870 onPictureInPicture={
871 tabId && (isServerTab || previewBridge) ? handlePictureInPicture : undefined
872 }
729873 pictureInPicture={miniPlayerTabId === tabId}
730 pictureInPictureDisabled={!desktopOverlay?.hasWebContents || isUnreachable}
731 onPickElement={previewBridge && tabId ? handlePickElement : undefined}
874 pictureInPictureDisabled={
875 isUnreachable || (!isServerTab && !desktopOverlay?.hasWebContents)
876 }
877 onPickElement={previewBridge && tabId && !isServerTab ? handlePickElement : undefined}
732878 pickActive={pickActive}
733879 // Disable when there's no tab (nothing to pick on) OR the page
734880 // failed to load (a React overlay covers the webview, so the
......@@ -758,32 +904,82 @@ export function PreviewView({
758904 ) : null
759905 }
760906 trailingActions={
761 previewBridge ? (
907 moreMenuActions ? (
762908 <PreviewMoreMenu
763 environmentId={threadRef.environmentId}
764 profileId={activeProfileId}
909 enabled={
910 runtimeTabId !== null &&
911 (serverOwnsRendering || (desktopOverlay?.hasWebContents ?? false))
912 }
913 actions={moreMenuActions}
765914 profileName={activeProfileName}
766 tabId={runtimeTabId}
767 hasWebContents={desktopOverlay?.hasWebContents ?? false}
768 zoomFactor={desktopOverlay?.zoomFactor ?? 1}
769 colorScheme={desktopOverlay?.colorScheme ?? "system"}
915 zoomFactor={
916 serverOwnsRendering ? serverZoomFactor : (desktopOverlay?.zoomFactor ?? 1)
917 }
918 colorScheme={
919 serverOwnsRendering
920 ? (snapshot?.colorScheme ?? "system")
921 : (desktopOverlay?.colorScheme ?? "system")
922 }
770923 deviceToolbarVisible={viewport._tag !== "fill"}
771924 onToggleDeviceToolbar={handleToggleDeviceToolbar}
772925 nativePictureInPicture={desktopOverlay?.pictureInPicture ?? false}
773 onNativePictureInPicture={handleNativePictureInPicture}
774926 />
775927 ) : null
776928 }
777929 />
778930
779 <div className="relative min-h-0 flex-1 overflow-hidden">
780 {runtimeTabId && snapshot && !showEmptyState ? (
781 <BrowserSurfaceSlot
782 key={runtimeTabId}
783 tabId={runtimeTabId}
784 visible={visible && !isUnreachable}
785 className="absolute inset-0 h-full w-full"
786 />
931 <div ref={serverBodyRef} className="relative min-h-0 flex-1 overflow-hidden">
932 {runtimeTabId && snapshot && isServerTab ? (
933 <>
934 {viewport._tag !== "fill" && !showEmptyState && !isUnreachable ? (
935 <div className="absolute inset-x-0 top-0 z-10">
936 <BrowserDeviceToolbar
937 setting={viewport}
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 )
787983 ) : null}
788984 {showEmptyState ? (
789985 <PreviewEmptyState
apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx+147-60
......@@ -6,6 +6,7 @@ import {
66 type PointerEvent as ReactPointerEvent,
77 type ReactNode,
88 useCallback,
9 useEffect,
910 useLayoutEffect,
1011 useRef,
1112 useState,
......@@ -20,6 +21,15 @@ import {
2021import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore";
2122import type { BrowserViewportResizeDirection } from "~/browser/browserViewportLayout";
2223import { 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";
2333import { Button } from "~/components/ui/button";
2434import { toastManager } from "~/components/ui/toast";
2535import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
......@@ -37,6 +47,7 @@ import { useDeviceState } from "~/state/device";
3747
3848import { DeviceStreamView } from "../device/DeviceStreamView";
3949import type { DeviceScreenSize } from "@t3tools/client-runtime/device/stream";
50import type { PreviewStreamViewport } from "@t3tools/client-runtime/preview/server-browser-stream";
4051import { previewBridge } from "./previewBridge";
4152import {
4253 clampPreviewMiniPlayerPosition,
......@@ -52,12 +63,17 @@ import {
5263
5364interface PointerGesture {
5465 readonly pointerId: number;
66 readonly pointerType: string;
5567 readonly pointerX: number;
5668 readonly pointerY: number;
5769 readonly frame: PreviewMiniPlayerFrame;
5870 readonly direction: BrowserViewportResizeDirection | null;
71 moved: boolean;
5972}
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
6177interface Props {
6278 readonly threadRef: ScopedThreadRef;
6379 readonly miniPlayer: PreviewMiniPlayerState;
......@@ -112,32 +128,54 @@ function BrowserMiniPlayer({ threadRef, tabId, miniPlayer }: Props & { readonly
112128 const fittedSourceContent = useBrowserSurfaceStore(
113129 (state) => state.byTabId[runtimeTabId]?.fittedSourceContent ?? null,
114130 );
115 const sourceSize = resolvePreviewMiniPlayerSourceSize(
116 snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT,
117 fittedSourceContent,
118 desktopOverlay?.zoomFactor ?? 1,
119 );
131 const nativeServerTab = useRendersServerTabNatively(threadRef.environmentId, snapshot);
132 const serverTab = snapshot?.runtime === "server" && !nativeServerTab;
133 const [streamViewport, setStreamViewport] = useState<PreviewStreamViewport | null>(null);
134 const serverSurfaceRef = useRef<ServerBrowserHandle | null>(null);
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
121146 const openInPanel = () => {
122147 usePreviewMiniPlayerStore.getState().close(threadRef);
123148 useRightPanelStore.getState().openBrowser(threadRef, tabId);
124149 };
125150
126 const toggleNativePictureInPicture = () => {
127 if (!previewBridge) return;
128 const operation = desktopOverlay?.pictureInPicture
129 ? previewBridge.pictureInPicture.close
130 : previewBridge.pictureInPicture.open;
131 void operation(runtimeTabId).catch((error) => {
151 const toggleNativePictureInPicture = async () => {
152 try {
153 if (serverTab) {
154 if (serverPictureInPicture) closeServerPictureInPicture();
155 else
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) {
132168 toastManager.add({
133169 type: "error",
134 title: "Unable to update popped-out preview",
170 title: serverTab ? "Unable to pop out preview" : "Unable to update popped-out preview",
135171 description: error instanceof Error ? error.message : "An error occurred.",
136172 });
137 });
173 }
138174 };
139175
140176 if (!snapshot) return null;
177 const poppedOut = serverTab ? serverPictureInPicture : Boolean(desktopOverlay?.pictureInPicture);
178 const canPopOut = serverTab ? supportsServerPictureInPicture() : true;
141179
142180 return (
143181 <MiniPlayerShell
......@@ -148,51 +186,68 @@ function BrowserMiniPlayer({ threadRef, tabId, miniPlayer }: Props & { readonly
148186 recording={recording}
149187 onOpenInPanel={openInPanel}
150188 pillActions={
151 <Tooltip>
152 <TooltipTrigger
153 render={
154 <Button
155 variant={desktopOverlay?.pictureInPicture ? "secondary" : "ghost"}
156 size="icon-xs"
157 aria-label={
158 desktopOverlay?.pictureInPicture
159 ? "Close popped-out preview"
160 : "Pop preview into separate window"
161 }
162 disabled={!desktopOverlay?.hasWebContents}
163 onPointerDown={(event) => event.stopPropagation()}
164 onClick={toggleNativePictureInPicture}
165 />
166 }
167 >
168 <PictureInPicture2 />
169 </TooltipTrigger>
170 <TooltipPopup side="top">
171 {desktopOverlay?.pictureInPicture
172 ? "Close separate window"
173 : "Pop into separate window"}
174 </TooltipPopup>
175 </Tooltip>
189 canPopOut ? (
190 <Tooltip>
191 <TooltipTrigger
192 render={
193 <Button
194 variant={poppedOut ? "secondary" : "ghost"}
195 size="icon-xs"
196 aria-label={
197 poppedOut ? "Close popped-out preview" : "Pop preview into separate window"
198 }
199 disabled={!serverTab && !desktopOverlay?.hasWebContents}
200 onPointerDown={(event) => event.stopPropagation()}
201 onClick={toggleNativePictureInPicture}
202 />
203 }
204 >
205 <PictureInPicture2 />
206 </TooltipTrigger>
207 <TooltipPopup side="top">
208 {poppedOut ? "Close separate window" : "Pop into separate window"}
209 </TooltipPopup>
210 </Tooltip>
211 ) : null
176212 }
177213 >
178 {(frame) => (
179 <>
180 <BrowserSurfaceSlot
181 tabId={runtimeTabId}
182 visible={Boolean(desktopOverlay?.hasWebContents)}
183 cornerRadius={PREVIEW_MINI_PLAYER_CORNER_RADIUS}
184 zIndex={PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX}
185 fitSourceContent
186 layoutVersion={`${frame.x}:${frame.y}`}
187 className="absolute inset-0"
188 />
189 {!desktopOverlay?.hasWebContents ? (
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">
191 Reconnecting preview…
192 </div>
193 ) : null}
194 </>
195 )}
214 {(frame) =>
215 serverTab ? (
216 <div
217 className="pointer-events-auto absolute inset-0 overflow-hidden rounded-[inherit]"
218 style={{ zIndex: PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX }}
219 >
220 <ServerBrowserSurface
221 ref={serverSurfaceRef}
222 environmentId={threadRef.environmentId}
223 threadId={threadRef.threadId}
224 tabId={tabId}
225 visible
226 followSize={false}
227 controlPosition="bottom"
228 onViewport={setStreamViewport}
229 className="size-full"
230 />
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 }
196251 </MiniPlayerShell>
197252 );
198253}
......@@ -285,6 +340,21 @@ function MiniPlayerShell({
285340}) {
286341 const canvas = useChatCanvas();
287342 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]);
288358 const container = canvas?.container ?? null;
289359 const obstacles = NO_PREVIEW_MINI_PLAYER_OBSTACLES;
290360 const sourceKey = previewMiniPlayerSourceKey(miniPlayer.source);
......@@ -326,10 +396,12 @@ function MiniPlayerShell({
326396 if (event.button !== 0 || !frame) return;
327397 gestureRef.current = {
328398 pointerId: event.pointerId,
399 pointerType: event.pointerType,
329400 pointerX: event.clientX,
330401 pointerY: event.clientY,
331402 frame,
332403 direction,
404 moved: false,
333405 };
334406 event.currentTarget.setPointerCapture(event.pointerId);
335407 event.preventDefault();
......@@ -340,6 +412,8 @@ function MiniPlayerShell({
340412 const gesture = gestureRef.current;
341413 if (!gesture || gesture.pointerId !== event.pointerId || !container) return;
342414 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;
343417 const store = usePreviewMiniPlayerStore.getState();
344418 if (gesture.direction === null) {
345419 store.move(
......@@ -366,8 +440,14 @@ function MiniPlayerShell({
366440 };
367441
368442 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;
370445 gestureRef.current = null;
446 handleTappedRef.current =
447 event.type === "pointerup" &&
448 gesture.direction === null &&
449 gesture.pointerType === "touch" &&
450 !gesture.moved;
371451 if (event.currentTarget.hasPointerCapture(event.pointerId)) {
372452 event.currentTarget.releasePointerCapture(event.pointerId);
373453 }
......@@ -389,18 +469,25 @@ function MiniPlayerShell({
389469 }}
390470 >
391471 <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"
393475 style={{ right: pillInset, top: pillInset }}
394476 onPointerDown={(event) => beginGesture(event, null)}
395477 onPointerMove={handlePointerMove}
396478 onPointerUp={endGesture}
397479 onPointerCancel={endGesture}
480 onClick={() => {
481 if (!handleTappedRef.current) return;
482 handleTappedRef.current = false;
483 setPillOpen((open) => !open);
484 }}
398485 >
399486 <div
400487 role={recording ? "status" : undefined}
401488 aria-label={recording ? "Recording preview" : undefined}
402489 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"
404491 >
405492 <span
406493 className={cn(
......@@ -411,7 +498,7 @@ function MiniPlayerShell({
411498 )}
412499 />
413500 </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">
415502 {recording ? (
416503 <span aria-hidden className="flex size-6 shrink-0 items-center justify-center">
417504 <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
99import { recordVisitForThread } from "~/browserHistoryStore";
1010import { useRightPanelStore } from "~/rightPanelStore";
1111import { openPreviewSession } from "./openPreviewSession";
12import { previewRuntimeFor } from "~/browser/previewRuntime";
1213
1314export async function openDiscoveredPort<E>(input: {
1415 readonly threadRef: ScopedThreadRef;
1516 readonly port: DiscoveredLocalServer;
1617 readonly openPreview: OpenPreviewMutation<E>;
1718}): 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);
1924 const result = await openPreviewSession({
2025 openPreview: input.openPreview,
2126 threadRef: input.threadRef,
apps/web/src/components/preview/openPreviewSession.ts+3
......@@ -15,6 +15,7 @@ import {
1515 resolveBrowserDefaults,
1616} from "~/browser/browserDefaults";
1717import { BrowserSettingsReadError } from "~/browser/openFileInPreview";
18import { previewRuntimeFor } from "~/browser/previewRuntime";
1819import { applyPreviewServerSnapshot, rememberPreviewUrl } from "~/previewStateStore";
1920
2021interface OpenPreviewSessionInput<E> {
......@@ -41,6 +42,7 @@ export async function openPreviewSession<E>(
4142 if (defaults instanceof BrowserSettingsReadError) {
4243 return AsyncResult.failure(Cause.fail(defaults));
4344 }
45 const runtime = previewRuntimeFor(input.threadRef.environmentId);
4446 const result = await input.openPreview({
4547 environmentId: input.threadRef.environmentId,
4648 input: {
......@@ -48,6 +50,7 @@ export async function openPreviewSession<E>(
4850 ...(input.url === undefined ? {} : { url: input.url }),
4951 viewport: input.viewport ?? browserDefaultOpenViewport(defaults),
5052 profileId: input.profileId ?? browserDefaultOpenProfileId(defaults),
53 ...(runtime === undefined ? {} : { runtime }),
5154 },
5255 });
5356 if (result._tag === "Failure") {
apps/web/src/components/preview/openTerminalLinkInPreview.ts+5-2
......@@ -9,8 +9,9 @@ import {
99} from "~/browser/browserDefaults";
1010import { isWebUrl, resolveBrowserLinkTargetPreference } from "~/browser/browserLinkTarget";
1111import type { OpenPreviewMutation } from "~/browser/openFileInPreview";
12import { isPreviewAvailableFor, previewRuntimeFor } from "~/browser/previewRuntime";
1213import { recordVisitForThread } from "~/browserHistoryStore";
13import { applyPreviewServerSnapshot, isPreviewSupportedInRuntime } from "~/previewStateStore";
14import { applyPreviewServerSnapshot } from "~/previewStateStore";
1415import { useRightPanelStore } from "~/rightPanelStore";
1516
1617const terminalLinkErrorContext = {
......@@ -48,7 +49,7 @@ export async function openTerminalLinkInPreview<E>(
4849 const supportsPreview =
4950 !input.forceBrowser &&
5051 isWebUrl(input.url) &&
51 isPreviewSupportedInRuntime() &&
52 isPreviewAvailableFor(input.threadRef.environmentId) &&
5253 input.threadRef.threadId.length > 0 &&
5354 (await resolveBrowserLinkTargetPreference()) === "app";
5455
......@@ -64,6 +65,7 @@ export async function openTerminalLinkInPreview<E>(
6465 };
6566
6667 const defaults = await resolveBrowserDefaults();
68 const runtime = previewRuntimeFor(input.threadRef.environmentId);
6769 const result = await input.openPreview({
6870 environmentId: input.threadRef.environmentId,
6971 input: {
......@@ -73,6 +75,7 @@ export async function openTerminalLinkInPreview<E>(
7375 // mapping, so the configured defaults are applied explicitly.
7476 viewport: browserDefaultOpenViewport(defaults),
7577 profileId: browserDefaultOpenProfileId(defaults),
78 ...(runtime === undefined ? {} : { runtime }),
7679 },
7780 });
7881 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: {
3939 threadRef: ScopedThreadRef;
4040 tabId: string;
4141 runtimeTabId: string;
42 /** The server drives this tab and reports its navigation itself. */
43 serverDriven?: boolean;
4244}): void {
43 const { threadRef, tabId, runtimeTabId } = input;
45 const { threadRef, tabId, runtimeTabId, serverDriven = false } = input;
4446 const clearBrowserPointer = useBrowserPointerStore((state) => state.clear);
4547 const reportStatus = useAtomCommand(previewEnvironment.reportStatus, "preview status report");
4648 const bridge = previewBridge;
......@@ -79,7 +81,7 @@ export function usePreviewBridge(input: {
7981 lastReportedUrl: lastReportedUrl.current,
8082 lastReportedKind: lastReportedKind.current,
8183 });
82 if (!reported) return;
84 if (!reported || serverDriven) return;
8385 lastReportedUrl.current = reported.lastReportedUrl;
8486 lastReportedKind.current = reported.lastReportedKind;
8587 void reportStatus({
apps/web/src/components/preview/usePreviewSession.ts+9
......@@ -44,6 +44,13 @@ const previewSessionSyncAtom = Atom.family((threadKey: string) => {
4444 const reconcileSessions = (result: Atom.Type<typeof sessionsAtom>) => {
4545 if (!AsyncResult.isSuccess(result)) return;
4646 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 }
4754 };
4855
4956 const applyLatestEvent = (result: Atom.Type<typeof eventsAtom>) => {
......@@ -67,6 +74,8 @@ const previewSessionSyncAtom = Atom.family((threadKey: string) => {
6774 eventsVersion += 1;
6875 applyLatestEvent(result);
6976 });
77 get.mount(sessionsAtom);
78 get.mount(eventsAtom);
7079 queueMicrotask(() => {
7180 if (disposed) return;
7281 // 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", () => {
6868 });
6969});
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
7197describe("browserProfileRemovalAvailable", () => {
98 it("accepts a server browser in place of the desktop bridge", () => {
99 expect(browserProfileRemovalAvailable(false, true, 1, 1)).toBe(true);
100 });
101
72102 it("requires a ready non-empty catalog and desktop bridge", () => {
73103 expect(browserProfileRemovalAvailable(true, true, 1)).toBe(true);
74104 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";
5050import { useEnvironments, usePrimaryEnvironment } from "~/state/environments";
5151import { deviceEnvironment, useDeviceState } from "~/state/device";
5252import { useAtomCommand } from "~/state/use-atom-command";
53import { previewEnvironment } from "~/state/preview";
54import { useServerConfigs } from "~/state/entities";
55import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
5356import {
5457 AgentDeviceSetupStatus,
5558 DeviceHubSetupStatus,
......@@ -125,28 +128,39 @@ type BrowserProfileDataBridge = Pick<
125128 "clearCookies" | "clearCache"
126129>;
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
128137export async function clearBrowserProfileData(
129138 bridge: BrowserProfileDataBridge | null,
130139 environmentIds: ReadonlyArray<EnvironmentId>,
131140 profileId: string,
141 server?: ServerProfileData,
132142): 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) {
134146 throw new Error("Browser profile data is not available to clear.");
135147 }
136 await Promise.all(
137 environmentIds.flatMap((environmentId) => [
138 bridge.clearCookies(environmentId, profileId),
139 bridge.clearCache(environmentId, profileId),
148 await Promise.all([
149 ...desktopEnvironmentIds.flatMap((environmentId) => [
150 bridge!.clearCookies(environmentId, profileId),
151 bridge!.clearCache(environmentId, profileId),
140152 ]),
141 );
153 ...serverEnvironmentIds.map((environmentId) => server!.clear(environmentId, profileId)),
154 ]);
142155}
143156
144157export function browserProfileRemovalAvailable(
145158 bridgeAvailable: boolean,
146159 environmentsReady: boolean,
147160 environmentCount: number,
161 serverBrowserCount = 0,
148162): boolean {
149 return bridgeAvailable && environmentsReady && environmentCount > 0;
163 return (bridgeAvailable || serverBrowserCount > 0) && environmentsReady && environmentCount > 0;
150164}
151165
152166/**
......@@ -926,12 +940,30 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
926940 readonly environmentName: string;
927941 } | null>(null);
928942 const [profilePendingRemoval, setProfilePendingRemoval] = useState<BrowserProfile | null>(null);
943 const [profilePendingClear, setProfilePendingClear] = useState<BrowserProfile | null>(null);
929944 const [profileRemovalError, setProfileRemovalError] = useState<string | null>(null);
930945 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 };
931962 const removalAvailable = browserProfileRemovalAvailable(
932963 previewBridge !== null,
933964 environmentsReady,
934965 environments.length,
966 serverProfileData.environmentIds.length,
935967 );
936968 const importInFlightRef = useRef(false);
937969 const [importInFlight, setImportInFlight] = useState(false);
......@@ -975,7 +1007,7 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
9751007
9761008 const clearProfileData = (id: string, name: string) => {
9771009 if (!settingsHydrated || importInFlightRef.current) return;
978 if (!previewBridge || !environmentsReady || environments.length === 0) {
1010 if (!removalAvailable) {
9791011 toastManager.add({
9801012 type: "error",
9811013 title: `Could not clear ${name}'s data`,
......@@ -987,6 +1019,7 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
9871019 previewBridge,
9881020 environments.map((environment) => environment.environmentId),
9891021 id,
1022 serverProfileData,
9901023 )
9911024 .then(() => {
9921025 toastManager.add({ type: "success", title: `Cleared ${name}'s cookies and cache` });
......@@ -1011,6 +1044,7 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
10111044 previewBridge,
10121045 environmentsReady ? environments.map((environment) => environment.environmentId) : [],
10131046 id,
1047 serverProfileData,
10141048 );
10151049 } catch {
10161050 setProfileRemovalError("Profile data could not be deleted. Try again.");
......@@ -1319,7 +1353,12 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
13191353 </MenuItem>
13201354 <MenuItem
13211355 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 }}
13231362 >
13241363 Clear cookies and cache
13251364 </MenuItem>
......@@ -1363,8 +1402,8 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
13631402 <AlertDialogHeader>
13641403 <AlertDialogTitle>Remove “{profilePendingRemoval?.name}”?</AlertDialogTitle>
13651404 <AlertDialogDescription>
1366 Its cookies and logins are deleted. Tabs already open in this profile stay open until
1367 you close them.
1405 Its cookies and logins are deleted. Desktop tabs already open in this profile stay
1406 open until you close them; server browser tabs close now.
13681407 </AlertDialogDescription>
13691408 {profileRemovalError ? (
13701409 <p aria-live="polite" className="text-sm text-destructive">
......@@ -1398,6 +1437,36 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
13981437 </AlertDialogFooter>
13991438 </AlertDialogPopup>
14001439 </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>
14011470 {importSession ? (
14021471 <BrowserImportWizard
14031472 source={importSession.source}
apps/web/src/previewStateStore.test.ts+13
......@@ -466,6 +466,19 @@ describe("previewStateStore (single-tab)", () => {
466466 reconcilePreviewServerSessions(ref, { sessions: [], serverEpoch, revision: 0 });
467467
468468 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 });
469482 });
470483
471484 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 {
4343 desktopOverlay: DesktopPreviewOverlay | null;
4444 desktopByTabId: Record<string, DesktopPreviewOverlay>;
4545 recentlySeenUrls: string[];
46 /** Whether the first authoritative tab list has arrived. */
47 listLoaded: boolean;
4648 /** Server process currently authoritative for revision ordering. */
4749 serverEpoch: string | null;
4850 /** Latest ordered server revision applied from a list response or event. */
......@@ -57,6 +59,7 @@ const EMPTY_THREAD_PREVIEW_STATE: ThreadPreviewState = Object.freeze({
5759 desktopOverlay: null,
5860 desktopByTabId: {},
5961 recentlySeenUrls: [] as string[],
62 listLoaded: false,
6063 serverEpoch: null,
6164 serverRevision: 0,
6265});
......@@ -311,7 +314,9 @@ export function reconcilePreviewServerSessions(
311314): void {
312315 updateThreadPreviewState(ref, (current) => {
313316 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 }
315320 const snapshots = result.sessions;
316321 const sessions: Record<string, PreviewSessionSnapshot> = {};
317322 const currentSuppressedTabIds = sameServer ? current.suppressedTabIds : new Set<string>();
......@@ -349,6 +354,7 @@ export function reconcilePreviewServerSessions(
349354 desktopByTabId,
350355 desktopOverlay: activeTabId ? (desktopByTabId[activeTabId] ?? null) : null,
351356 recentlySeenUrls,
357 listLoaded: true,
352358 serverEpoch: result.serverEpoch,
353359 serverRevision: result.revision,
354360 };
apps/web/src/rightPanelStore.test.ts+26
......@@ -1049,6 +1049,32 @@ describe("rightPanelStore", () => {
10491049 });
10501050 });
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
10521078 it("reconciles browser surfaces without deleting other surface kinds", () => {
10531079 useRightPanelStore.getState().openTerminal(refA, "term-1");
10541080 useRightPanelStore.getState().openBrowser(refA, "tab-a");
apps/web/src/rightPanelStore.ts+7-3
......@@ -163,7 +163,11 @@ interface RightPanelStoreState {
163163 closeOtherSurfaces: (ref: ScopedThreadRef, surfaceId: string) => void;
164164 closeSurfacesToRight: (ref: ScopedThreadRef, surfaceId: string) => void;
165165 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;
167171 reconcileFileSurfaces: (ref: ScopedThreadRef, workspaceAvailable: boolean) => void;
168172 show: (ref: ScopedThreadRef) => void;
169173 close: (ref: ScopedThreadRef) => void;
......@@ -850,7 +854,7 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
850854 : { ...current, isOpen: false, surfaces: [], activeSurfaceId: null },
851855 ),
852856 ),
853 reconcileBrowserSurfaces: (ref, tabIds) =>
857 reconcileBrowserSurfaces: (ref, tabIds, hiddenTabIds) =>
854858 set((state) =>
855859 automaticUpdate(state, scopedThreadKey(ref), (current) => {
856860 const validIds = new Set(tabIds.map((tabId) => `browser:${tabId}`));
......@@ -863,7 +867,7 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
863867 );
864868 const knownIds = new Set(existingBrowser.map((surface) => surface.id));
865869 const added = tabIds
866 .filter((tabId) => !knownIds.has(`browser:${tabId}`))
870 .filter((tabId) => !knownIds.has(`browser:${tabId}`) && !hiddenTabIds?.has(tabId))
867871 .map((tabId) => browserSurface(tabId));
868872 const surfaces = [...nonBrowser, ...existingBrowser, ...added];
869873 const activeStillExists = surfaces.some(
apps/web/src/routes/_chat.tsx+2-2
......@@ -8,6 +8,7 @@ import { resolveThreadRouteTarget } from "../threadRoutes";
88import { useClientSettings, useLegacySidebarEnabled } from "../hooks/useSettings";
99import { openCommandPalette } from "../commandPaletteBus";
1010import { useProjects } from "../state/entities";
11import { isPreviewAvailableFor } from "../browser/previewRuntime";
1112import { usePrimaryEnvironmentId } from "../state/environments";
1213import { selectProjectGroupingSettings } from "../logicalProject";
1314import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping";
......@@ -22,7 +23,6 @@ import { isModelPickerOpen } from "../modelPickerVisibility";
2223import { undoLatestThreadAction } from "../hooks/showThreadUndoNotice";
2324import { resolveShortcutCommand } from "../keybindings";
2425import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
25import { isPreviewSupportedInRuntime } from "../previewStateStore";
2626import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore";
2727import { useThreadSelectionStore } from "../threadSelectionStore";
2828import { stackedThreadToast, toastManager } from "~/components/ui/toast";
......@@ -141,7 +141,7 @@ function ChatRouteGlobalShortcuts() {
141141 event.preventDefault();
142142 event.stopPropagation();
143143 if (!routeThreadRef) return;
144 if (!isPreviewSupportedInRuntime()) {
144 if (!isPreviewAvailableFor(routeThreadRef.environmentId)) {
145145 toastManager.add(
146146 stackedThreadToast({
147147 type: "info",
apps/web/src/rpc/requestLatencyState.test.ts+2-9
......@@ -52,13 +52,6 @@ describe("requestLatencyState", () => {
5252 expect(getSlowRpcAckRequests()).toEqual([]);
5353 });
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
6255 it("ignores usage summary requests", () => {
6356 trackRpcRequestSent("1", WS_METHODS.serverGetUsageSummary);
6457 vi.advanceTimersByTime(SLOW_RPC_ACK_THRESHOLD_MS * 2);
......@@ -79,8 +72,8 @@ describe("requestLatencyState", () => {
7972 it("keeps ignoring untracked methods when a display tag is supplied", () => {
8073 trackRpcRequestSent(
8174 "1",
82 WS_METHODS.previewAutomationConnect,
83 `${WS_METHODS.previewAutomationConnect} · env-1`,
75 WS_METHODS.serverGetUsageSummary,
76 `${WS_METHODS.serverGetUsageSummary} · env-1`,
8477 );
8578 vi.advanceTimersByTime(SLOW_RPC_ACK_THRESHOLD_MS * 2);
8679
apps/web/src/rpc/requestLatencyState.ts+1-4
......@@ -28,10 +28,7 @@ interface PendingRpcAckRequest {
2828}
2929
3030const pendingRpcAckRequests = new Map<string, PendingRpcAckRequest>();
31const untrackedRpcAckMethods = new Set<string>([
32 WS_METHODS.previewAutomationConnect,
33 WS_METHODS.serverGetUsageSummary,
34]);
31const untrackedRpcAckMethods = new Set<string>([WS_METHODS.serverGetUsageSummary]);
3532const longRunningRpcAckMethods = new Set<string>([
3633 WS_METHODS.serverUpdateProvider,
3734 WS_METHODS.serverRefreshProviders,
apps/web/src/state/entities.ts+17
......@@ -201,6 +201,23 @@ export function waitForThreadShell(ref: ScopedThreadRef, timeoutMs = 5_000): Pro
201201 });
202202}
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
204221export function readEnvironmentSupportsTitleRegeneration(environmentId: EnvironmentId): boolean {
205222 return (
206223 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
66
77Agents 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.
172172For Antigravity's Google callback on a remote host, see
173173[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
175223## Manage or revoke access
176224
177225On the host, **Settings → Connections** lets authorized administrators create
knip.jsonc+1
......@@ -77,6 +77,7 @@
7777 "plugins/*.cjs",
7878 "scripts/generate-device-stream.mts",
7979 "src/features/devices/device-stream.browser.ts!",
80 "src/features/browser/preview-stream.browser.ts!",
8081 ],
8182 // Fonts are configured as asset paths; Expo autolinks the native packages.
8283 "ignoreDependencies": [
packages/client-runtime/package.json+4
......@@ -19,6 +19,10 @@
1919 "types": "./src/device/hubAccess.ts",
2020 "default": "./src/device/hubAccess.ts"
2121 },
22 "./preview/server-browser-stream": {
23 "types": "./src/preview/serverBrowserStream.ts",
24 "default": "./src/preview/serverBrowserStream.ts"
25 },
2226 "./worktree-setup": {
2327 "types": "./src/worktreeSetup.ts",
2428 "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 {
22 DEFAULT_SERVER_SETTINGS,
33 EnvironmentAuthorizationError,
44 EnvironmentId,
5 PreviewTabId,
6 ThreadId,
7 type PreviewAutomationStreamEvent,
85 type RelayClientInstallProgressEvent,
96 type ServerConfigStreamEvent,
107 type ServerLifecycleStreamEvent,
......@@ -92,156 +89,6 @@ const makeHarness = Effect.fn("TestEnvironmentRpc.makeHarness")(function* () {
9289});
9390
9491describe("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
24592 it.effect("reuses the session config stream instead of opening a duplicate subscription", () =>
24693 Effect.gen(function* () {
24794 const event: ServerConfigStreamEvent = {
packages/client-runtime/src/rpc/client.ts+1-10
......@@ -9,7 +9,6 @@ import * as Duration from "effect/Duration";
99import * as Effect from "effect/Effect";
1010import * as Option from "effect/Option";
1111import * as Schema from "effect/Schema";
12import * as Schedule from "effect/Schedule";
1312import * as Stream from "effect/Stream";
1413import * as SubscriptionRef from "effect/SubscriptionRef";
1514import { RpcClientError } from "effect/rpc";
......@@ -61,7 +60,6 @@ export type EnvironmentSubscriptionRpcTag =
6160 | typeof WS_METHODS.subscribeDeviceState
6261 | typeof WS_METHODS.subscribeResourceTelemetry
6362 | typeof WS_METHODS.pullRequestsSubscribeRefreshes
64 | typeof WS_METHODS.previewAutomationConnect
6563 | typeof WS_METHODS.subscribeVcsStatus
6664 | typeof WS_METHODS.subscribeWorktreeSetup
6765 | typeof WS_METHODS.subscribeProjectClones
......@@ -272,14 +270,7 @@ function subscribeDynamicMapped<TTag extends EnvironmentSubscriptionRpcTag, A>(
272270 }),
273271 ),
274272 );
275 // An evicted preview host completes its registration stream.
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));
273 return stream.pipe(Stream.ensuring(completeObservation));
283274 }),
284275 ).pipe(
285276 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 {
99 createEnvironmentRpcSubscriptionAtomFamily,
1010} 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
2012export function createPreviewEnvironmentAtoms<R, E>(
2113 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,
2214) {
2315 const lifecycleScheduler = createAtomCommandScheduler();
2416 const statusScheduler = createAtomCommandScheduler();
25 const automationScheduler = createAtomCommandScheduler();
2617 const lifecycleConcurrency = {
2718 mode: "serial" as const,
2819 key: ({ environmentId, input }: { environmentId: string; input: { threadId: string } }) =>
......@@ -45,14 +36,6 @@ export function createPreviewEnvironmentAtoms<R, E>(
4536 // unmounted projects stop contributing probe candidates on the server.
4637 idleTtlMs: 0,
4738 }),
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 }),
5639 open: createEnvironmentRpcCommand(runtime, {
5740 label: "environment-data:preview:open",
5841 tag: WS_METHODS.previewOpen,
......@@ -71,6 +54,12 @@ export function createPreviewEnvironmentAtoms<R, E>(
7154 scheduler: lifecycleScheduler,
7255 concurrency: lifecycleConcurrency,
7356 }),
57 adjust: createEnvironmentRpcCommand(runtime, {
58 label: "environment-data:preview:adjust",
59 tag: WS_METHODS.previewAdjust,
60 scheduler: lifecycleScheduler,
61 concurrency: lifecycleConcurrency,
62 }),
7463 refresh: createEnvironmentRpcCommand(runtime, {
7564 label: "environment-data:preview:refresh",
7665 tag: WS_METHODS.previewRefresh,
......@@ -83,6 +72,10 @@ export function createPreviewEnvironmentAtoms<R, E>(
8372 scheduler: lifecycleScheduler,
8473 concurrency: lifecycleConcurrency,
8574 }),
75 clearProfile: createEnvironmentRpcCommand(runtime, {
76 label: "environment-data:preview:clear-profile",
77 tag: WS_METHODS.previewClearProfile,
78 }),
8679 reportStatus: createEnvironmentRpcCommand(runtime, {
8780 label: "environment-data:preview:report-status",
8881 tag: WS_METHODS.previewReportStatus,
......@@ -93,24 +86,5 @@ export function createPreviewEnvironmentAtoms<R, E>(
9386 JSON.stringify([environmentId, input.threadId, input.tabId]),
9487 },
9588 }),
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 }),
11589 };
11690}
packages/contracts/src/desktopBootstrap.ts+4
......@@ -24,6 +24,10 @@ export const DesktopBackendBootstrap = Schema.Struct({
2424 otlpLogsUrl: Schema.optional(Schema.String),
2525 desktopTelemetryFd: Schema.optionalKey(PositiveInt),
2626 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),
2731 resourceMonitorPath: Schema.optionalKey(TrimmedNonEmptyString),
2832});
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({
208208 desktop servers whose app predates the remote trigger, where clients
209209 must keep telling the user to update the app on that machine. */
210210 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),
211215});
212216export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type;
213217
packages/contracts/src/index.ts+1
......@@ -9,6 +9,7 @@ export * from "./environment.ts";
99export * from "./environmentHttp.ts";
1010export * from "./relayClient.ts";
1111export * from "./desktopBootstrap.ts";
12export * from "./desktopBrowser.ts";
1213export * from "./desktopAppActivation.ts";
1314export * from "./remoteAccess.ts";
1415export * from "./ipc.ts";
packages/contracts/src/ipc.ts+12-56
......@@ -1,15 +1,5 @@
11import * 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";
133import { SnapShotSource } from "./chatAttachment.ts";
144import { EnvironmentId, TrimmedNonEmptyString } from "./baseSchemas.ts";
155import { BrowserProfileId } from "./browserProfile.ts";
......@@ -647,12 +637,6 @@ export const DesktopPreviewTabIdSchema = Schema.String.check(Schema.isTrimmed())
647637 Schema.isNonEmpty(),
648638);
649639
650export const DesktopPreviewAutomationStatusSchema = Schema.Struct({
651 ...PreviewAutomationStatus.fields,
652 tabId: Schema.NullOr(DesktopPreviewTabIdSchema),
653});
654export type DesktopPreviewAutomationStatus = typeof DesktopPreviewAutomationStatusSchema.Type;
655
656640export interface DesktopPreviewPointerEvent {
657641 tabId: string;
658642 phase: "move" | "click";
......@@ -1026,11 +1010,16 @@ export const DesktopPreviewCreateTabInputSchema = Schema.Struct({
10261010 tabId: DesktopPreviewTabIdSchema,
10271011 zoomFactor: Schema.optional(Schema.Number.check(Schema.isGreaterThan(0))),
10281012 colorScheme: Schema.optional(DesktopPreviewColorSchemeSchema),
1013 serverTab: Schema.optional(
1014 Schema.Struct({ threadId: TrimmedNonEmptyString, tabId: TrimmedNonEmptyString }),
1015 ),
10291016});
10301017
10311018export interface DesktopPreviewTabDefaults {
10321019 readonly zoomFactor?: number | undefined;
10331020 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;
10341023}
10351024
10361025export const DesktopPreviewRegisterWebviewInputSchema = Schema.Struct({
......@@ -1065,6 +1054,11 @@ export const DesktopPreviewSetColorSchemeInputSchema = Schema.Struct({
10651054 colorScheme: DesktopPreviewColorSchemeSchema,
10661055});
10671056
1057export const DesktopPreviewSetZoomFactorInputSchema = Schema.Struct({
1058 tabId: DesktopPreviewTabIdSchema,
1059 zoomFactor: Schema.Number.check(Schema.isGreaterThan(0)),
1060});
1061
10681062export const DesktopPreviewSetAudioMutedInputSchema = Schema.Struct({
10691063 tabId: DesktopPreviewTabIdSchema,
10701064 audioMuted: Schema.Boolean,
......@@ -1084,36 +1078,6 @@ export const DesktopPreviewRecordingSaveInputSchema = Schema.Struct({
10841078 data: Schema.Uint8Array,
10851079});
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
11171081/**
11181082 * A System Settings pane the app can deep-link to. The identifier crosses IPC
11191083 * rather than a URL, so the renderer can only reach these known destinations.
......@@ -1272,6 +1236,8 @@ export interface DesktopPreviewBridge {
12721236 zoomIn: (tabId: string) => Promise<void>;
12731237 zoomOut: (tabId: string) => Promise<void>;
12741238 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>;
12751241 /** Reload bypassing the HTTP cache. */
12761242 hardReload: (tabId: string) => Promise<void>;
12771243 /**
......@@ -1337,16 +1303,6 @@ export interface DesktopPreviewBridge {
13371303 ) => Promise<DesktopPreviewRecordingArtifact>;
13381304 onFrame: (listener: (frame: DesktopPreviewRecordingFrame) => void) => () => void;
13391305 };
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 };
13501306 onStateChange: (listener: (tabId: string, state: DesktopPreviewTabState) => void) => () => void;
13511307 onPointerEvent: (listener: (event: DesktopPreviewPointerEvent) => void) => () => void;
13521308}
packages/contracts/src/preview.ts+89-1
......@@ -162,6 +162,29 @@ export const PreviewNavStatus = Schema.Union([
162162]);
163163export 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
165188export const PreviewSessionSnapshot = Schema.Struct({
166189 threadId: TrimmedNonEmptyString,
167190 tabId: PreviewTabId,
......@@ -170,12 +193,23 @@ export const PreviewSessionSnapshot = Schema.Struct({
170193 canGoForward: Schema.Boolean,
171194 /** Missing snapshots from older servers are treated as fill-panel mode. */
172195 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),
173200 /**
174201 * Browser profile the tab's Chromium partition is derived from. Fixed at
175202 * open: Electron only honours a `<webview>`'s partition before attach, so
176203 * switching would require tearing the guest down and losing page state.
177204 */
178205 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 })),
179213 updatedAt: Schema.String,
180214});
181215export type PreviewSessionSnapshot = typeof PreviewSessionSnapshot.Type;
......@@ -193,6 +227,10 @@ export const PreviewOpenInput = Schema.Struct({
193227 viewport: Schema.optional(PreviewViewportSetting),
194228 /** Omit to open under the client's configured default profile. */
195229 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),
196234});
197235export type PreviewOpenInput = typeof PreviewOpenInput.Type;
198236
......@@ -226,12 +264,33 @@ export const PreviewResizeInput = Schema.Struct({
226264});
227265export 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
229283export const PreviewCloseInput = Schema.Struct({
230284 threadId: ThreadId,
231285 tabId: Schema.optional(PreviewTabId),
232286});
233287export type PreviewCloseInput = typeof PreviewCloseInput.Type;
234288
289export const PreviewClearProfileInput = Schema.Struct({
290 profileId: BrowserProfileId,
291});
292export type PreviewClearProfileInput = typeof PreviewClearProfileInput.Type;
293
235294export const PreviewListInput = Schema.Struct({
236295 threadId: ThreadId,
237296});
......@@ -272,6 +331,13 @@ const PreviewResizedEvent = Schema.Struct({
272331 ...PreviewEventBaseSchema.fields,
273332 type: Schema.Literal("resized"),
274333 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 ),
275341});
276342
277343const PreviewFailedEvent = Schema.Struct({
......@@ -350,5 +416,27 @@ export class PreviewInvalidUrlError extends Schema.TaggedError<PreviewInvalidUrl
350416 }
351417}
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]);
354442export type PreviewError = typeof PreviewError.Type;
packages/contracts/src/previewAutomation.ts+224-17
......@@ -39,13 +39,22 @@ export const PREVIEW_AUTOMATION_V1_OPERATIONS = [
3939] as const;
4040
4141/** Advertised by current desktop hosts for mixed-version routing. */
42export const PREVIEW_AUTOMATION_OPERATIONS = [
42const PREVIEW_AUTOMATION_OPERATIONS = [
4343 ...PREVIEW_AUTOMATION_V1_OPERATIONS,
4444 "resize",
4545 "setColorScheme",
4646] 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);
4958export type PreviewAutomationOperation = typeof PreviewAutomationOperation.Type;
5059
5160const PreviewAutomationTabTargetFields = {
......@@ -70,13 +79,66 @@ export const PreviewAutomationStatus = Schema.Struct({
7079 url: Schema.NullOr(Schema.String),
7180 title: Schema.NullOr(Schema.String),
7281 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 ),
7398 /** Optional for compatibility with desktop hosts predating viewport sizing. */
7499 viewportSetting: Schema.optional(PreviewViewportSetting),
75100 /** Measured guest-page viewport in CSS pixels when a webview is ready. */
76101 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 ),
77128});
78129export 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
80142export const PreviewAutomationOpenInput = Schema.Struct({
81143 ...PreviewAutomationTabTargetFields,
82144 url: Schema.optional(BoundedUrl).annotate({
......@@ -282,7 +344,7 @@ export type PreviewAutomationSetColorSchemeResult =
282344
283345const Locator = TrimmedNonEmptyString.annotate({
284346 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.",
286348});
287349
288350const LegacySelector = TrimmedNonEmptyString.annotate({
......@@ -290,7 +352,7 @@ const LegacySelector = TrimmedNonEmptyString.annotate({
290352 "Legacy CSS selector such as button[type='submit']. Prefer locator for resilient role/text targeting.",
291353});
292354
293export const PreviewAutomationClickInput = Schema.Struct({
355const PointerTargetFields = {
294356 ...PreviewAutomationTabTargetFields,
295357 selector: Schema.optional(LegacySelector).annotate({
296358 description:
......@@ -298,7 +360,7 @@ export const PreviewAutomationClickInput = Schema.Struct({
298360 }),
299361 locator: Schema.optional(Locator).annotate({
300362 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.",
302364 }),
303365 x: Schema.optional(
304366 Schema.Finite.annotate({
......@@ -311,24 +373,128 @@ export const PreviewAutomationClickInput = Schema.Struct({
311373 }),
312374 ),
313375 timeoutMs: OptionalTimeoutMs,
314})
315 .check(
316 Schema.makeFilter((input) => {
317 const selectorModes =
318 Number(input.selector !== undefined) + Number(input.locator !== undefined);
319 const hasX = input.x !== undefined;
320 const hasY = input.y !== undefined;
321 if (hasX !== hasY) return "Coordinates require both x and y.";
322 const coordinateModes = hasX && hasY ? 1 : 0;
323 return selectorModes + coordinateModes === 1 || "Provide exactly one click target.";
376};
377
378const singlePointerTarget = Schema.makeFilter(
379 (input: {
380 readonly selector?: string | undefined;
381 readonly locator?: string | undefined;
382 readonly x?: number | undefined;
383 readonly y?: number | undefined;
384 }) => {
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.",
324400 }),
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)
326409 .annotate({
327410 description:
328411 "Clicks one target. Provide exactly one of locator, selector, or the x/y coordinate pair.",
329412 });
330413export 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
332498export const PreviewAutomationTypeInput = Schema.Struct({
333499 ...PreviewAutomationTabTargetFields,
334500 text: Schema.String.annotate({ description: "Literal text to insert." }),
......@@ -607,6 +773,8 @@ export const PreviewAutomationRequest = Schema.Struct({
607773 threadId: ThreadId,
608774 tabId: Schema.optional(PreviewTabId),
609775 tabIdExplicit: Schema.optional(Schema.Boolean),
776 /** Supplied by the broker from authenticated scope, never from tool input. */
777 agentSessionId: Schema.optional(Schema.String),
610778 operation: PreviewAutomationOperation,
611779 input: Schema.Unknown,
612780 timeoutMs: Schema.Int.check(Schema.isGreaterThan(0)),
......@@ -684,6 +852,9 @@ const PreviewAutomationScopeErrorFields = {
684852 providerInstanceId: ProviderInstanceId,
685853};
686854
855/** The automation host id of an environment's own server browser. */
856export const SERVER_BROWSER_AUTOMATION_CLIENT_ID = "server-browser";
857
687858const PreviewAutomationRequestErrorFields = {
688859 ...PreviewAutomationScopeErrorFields,
689860 clientId: TrimmedNonEmptyString,
......@@ -767,14 +938,39 @@ export class PreviewAutomationTimeoutError extends Schema.TaggedError<PreviewAut
767938 }
768939}
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
770952export class PreviewAutomationControlInterruptedError extends Schema.TaggedError<PreviewAutomationControlInterruptedError>()(
771953 "PreviewAutomationControlInterruptedError",
772954 {
773955 ...PreviewAutomationRequestErrorFields,
774956 ...PreviewAutomationRemoteDiagnosticFields,
957 reason: Schema.optional(PreviewAutomationControlReason),
775958 },
776959) {
777960 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.";
778974 return `Preview automation ${this.operation} was interrupted on client ${this.clientId}.`;
779975 }
780976}
......@@ -784,10 +980,17 @@ export class PreviewAutomationExecutionError extends Schema.TaggedError<PreviewA
784980 {
785981 ...PreviewAutomationRequestErrorFields,
786982 ...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),
787988 },
788989) {
789990 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}`;
791994 }
792995}
793996
......@@ -798,9 +1001,13 @@ export class PreviewAutomationInvalidSelectorError extends Schema.TaggedError<Pr
7981001 ...PreviewAutomationRemoteDiagnosticFields,
7991002 selectorKind: Schema.optional(Schema.Literals(["locator", "selector"])),
8001003 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),
8011006 },
8021007) {
8031008 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.";
8041011 if (this.selectorKind !== undefined && this.selectorLength !== undefined) {
8051012 return `Preview automation ${this.operation} received an invalid ${this.selectorKind} (${this.selectorLength} characters).`;
8061013 }
packages/contracts/src/rpc.ts+19-30
......@@ -237,11 +237,14 @@ import {
237237 PreviewEvent,
238238 PreviewListInput,
239239 PreviewListResult,
240 PreviewClearProfileError,
241 PreviewClearProfileInput,
240242 PreviewNavigateInput,
241243 PreviewOpenInput,
242244 PreviewRefreshInput,
243245 PreviewReportStatusInput,
244246 PreviewResizeInput,
247 PreviewAdjustInput,
245248 PreviewSessionSnapshot,
246249} from "./preview.ts";
247250import {
......@@ -259,13 +262,7 @@ import {
259262 DeviceSession,
260263 DeviceShutdownInput,
261264} from "./device.ts";
262import {
263 PreviewAutomationError,
264 PreviewAutomationHost,
265 PreviewAutomationHostFocus,
266 PreviewAutomationResponse,
267 PreviewAutomationStreamEvent,
268} from "./previewAutomation.ts";
265import {} from "./previewAutomation.ts";
269266import {
270267 ServerConfigStreamEvent,
271268 DesktopUpdateCommitInput,
......@@ -418,13 +415,12 @@ export const WS_METHODS = {
418415 previewOpen: "preview.open",
419416 previewNavigate: "preview.navigate",
420417 previewResize: "preview.resize",
418 previewAdjust: "preview.adjust",
421419 previewRefresh: "preview.refresh",
422420 previewClose: "preview.close",
423421 previewList: "preview.list",
422 previewClearProfile: "preview.clearProfile",
424423 previewReportStatus: "preview.reportStatus",
425 previewAutomationConnect: "previewAutomation.connect",
426 previewAutomationRespond: "previewAutomation.respond",
427 previewAutomationFocusHost: "previewAutomation.focusHost",
428424
429425 // Device methods
430426 deviceConfigure: "device.configure",
......@@ -1398,6 +1394,12 @@ const WsPreviewResizeRpc = Rpc.make(WS_METHODS.previewResize, {
13981394 error: Schema.Union([PreviewError, EnvironmentAuthorizationError]),
13991395});
14001396
1397const WsPreviewAdjustRpc = Rpc.make(WS_METHODS.previewAdjust, {
1398 payload: PreviewAdjustInput,
1399 success: PreviewSessionSnapshot,
1400 error: Schema.Union([PreviewError, EnvironmentAuthorizationError]),
1401});
1402
14011403const WsPreviewRefreshRpc = Rpc.make(WS_METHODS.previewRefresh, {
14021404 payload: PreviewRefreshInput,
14031405 error: Schema.Union([PreviewError, EnvironmentAuthorizationError]),
......@@ -1414,28 +1416,16 @@ const WsPreviewListRpc = Rpc.make(WS_METHODS.previewList, {
14141416 error: EnvironmentAuthorizationError,
14151417});
14161418
1419const WsPreviewClearProfileRpc = Rpc.make(WS_METHODS.previewClearProfile, {
1420 payload: PreviewClearProfileInput,
1421 error: Schema.Union([PreviewClearProfileError, EnvironmentAuthorizationError]),
1422});
1423
14171424const WsPreviewReportStatusRpc = Rpc.make(WS_METHODS.previewReportStatus, {
14181425 payload: PreviewReportStatusInput,
14191426 error: Schema.Union([PreviewError, EnvironmentAuthorizationError]),
14201427});
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
14391429const WsSubscribePreviewEventsRpc = Rpc.make(WS_METHODS.subscribePreviewEvents, {
14401430 payload: Schema.Struct({}),
14411431 success: PreviewEvent,
......@@ -1885,13 +1875,12 @@ export const WsRpcGroup = RpcGroup.make(
18851875 WsPreviewOpenRpc,
18861876 WsPreviewNavigateRpc,
18871877 WsPreviewResizeRpc,
1878 WsPreviewAdjustRpc,
18881879 WsPreviewRefreshRpc,
18891880 WsPreviewCloseRpc,
18901881 WsPreviewListRpc,
1882 WsPreviewClearProfileRpc,
18911883 WsPreviewReportStatusRpc,
1892 WsPreviewAutomationConnectRpc,
1893 WsPreviewAutomationRespondRpc,
1894 WsPreviewAutomationFocusHostRpc,
18951884 WsSubscribePreviewEventsRpc,
18961885 WsSubscribeDiscoveredLocalServersRpc,
18971886 WsDeviceConfigureRpc,
packages/shared/src/t3McpToolPresentation.ts+25
......@@ -158,6 +158,11 @@ const T3_MCP_TOOLS: Readonly<Record<string, T3McpToolDefinition>> = {
158158 "browser",
159159 "browser",
160160 ),
161 preview_dialog: tool(
162 ["Respond", "Responding", "Responded", "to a preview browser dialog"],
163 "browser",
164 "browser",
165 ),
161166 preview_snapshot: tool(
162167 ["Take a snapshot of", "Taking a snapshot of", "Took a snapshot of", "the preview page"],
163168 "browser",
......@@ -175,6 +180,26 @@ const T3_MCP_TOOLS: Readonly<Record<string, T3McpToolDefinition>> = {
175180 "browser",
176181 ),
177182 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 ),
178203 preview_scroll: tool(
179204 ["Scroll", "Scrolling", "Scrolled", "the preview browser"],
180205 "browser",
pnpm-lock.yaml+3
......@@ -564,6 +564,9 @@ importers:
564564 node-pty:
565565 specifier: ^1.2.0-beta.15
566566 version: 1.2.0-beta.15(patch_hash=f2fe901c61cde17986240002d05c172d5d0272d83ffaab8d0ebeb922763be414)
567 playwright-core:
568 specifier: 1.60.0
569 version: 1.60.0
567570 proper-lockfile:
568571 specifier: 4.1.2
569572 version: 4.1.2
scripts/lib/cli-external-packages.test.ts+1-1
......@@ -83,7 +83,7 @@ describe("selectCliRuntimeExternalDependencies", () => {
8383 it("selects every external root declared by the server", () => {
8484 assert.deepStrictEqual(
8585 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"],
8787 );
8888 });
8989});
scripts/lib/cli-external-packages.ts+2
......@@ -28,6 +28,8 @@
2828export const CLI_RUNTIME_EXTERNAL_PREFIXES = [
2929 // Cursor ships computed Webpack imports and platform helper packages.
3030 "@cursor/sdk",
31 // Playwright reads package.json and browsers.json beside its runtime modules.
32 "playwright-core",
3133 "node-pty",
3234 "ffi-rs",
3335 "@yuuang/",