| author | |
| committer | |
| log | ac8e9453ca0948469ccf5651b765086d8d9ecd64 |
| tree | 4ea0ebf121fae1fcc17167a7f62f89b0d04130a6 |
| parent | 9bd1d8009a6b7c50f9dd9458e2bf27d481ff3b43 |
| signature | Signed by PGP key B5690EEEBB952194 |
165 files changed, 14298 insertions(+), 8447 deletions(-)
.github/workflows/ci.yml+3| ... | ... | @@ -255,6 +255,9 @@ jobs: |
| 255 | 255 | # No Electron setup here: `t3` (apps/server) has no Electron dependency |
| 256 | 256 | # and none of its tests touch the runtime. Only the non-server `test` |
| 257 | 257 | # job, which covers @t3tools/desktop, needs the download. |
| 258 | - name: Install Chromium for server browser integration tests | |
| 259 | run: node apps/server/node_modules/playwright-core/cli.js install --with-deps --only-shell chromium | |
| 260 | ||
| 258 | 261 | - name: Test |
| 259 | 262 | env: |
| 260 | 263 | T3CODE_TRANSFER_BUDGET_REPORT_PATH: ${{ runner.temp }}/t3code-transfer-budget.md |
apps/desktop/src/backend/DesktopBackendConfiguration.ts+2| ... | ... | @@ -568,6 +568,8 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv |
| 568 | 568 | tailscaleServePort: backendExposure.tailscaleServePort, |
| 569 | 569 | desktopTelemetryFd: 4, |
| 570 | 570 | desktopTelemetryControlFd: 5, |
| 571 | desktopBrowserFd: 6, | |
| 572 | desktopBrowserControlFd: 7, | |
| 571 | 573 | ...Option.match(input.resourceMonitorPath, { |
| 572 | 574 | onNone: () => ({}), |
| 573 | 575 | onSome: (resourceMonitorPath) => ({ resourceMonitorPath }), |
apps/desktop/src/backend/DesktopBackendManager.test.ts+2| ... | ... | @@ -27,6 +27,7 @@ import * as DesktopApp from "../app/DesktopApp.ts"; |
| 27 | 27 | import * as DesktopBackendPool from "./DesktopBackendPool.ts"; |
| 28 | 28 | import * as DesktopObservability from "../app/DesktopObservability.ts"; |
| 29 | 29 | import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; |
| 30 | import * as DesktopBrowserHost from "../preview/DesktopBrowserHost.ts"; | |
| 30 | 31 | import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; |
| 31 | 32 | |
| 32 | 33 | const decodeDesktopBackendBootstrap = Schema.decodeEffect( |
| ... | ... | @@ -173,6 +174,7 @@ function makeTestInstance(input: MakeInstanceInput) { |
| 173 | 174 | updateCancellations: Stream.empty, |
| 174 | 175 | ...input.desktopTelemetryPublisher, |
| 175 | 176 | }), |
| 177 | DesktopBrowserHost.layer, | |
| 176 | 178 | DesktopWslEnvironment.layerTest( |
| 177 | 179 | input.pruneRuntimes === undefined ? {} : { pruneRuntimes: input.pruneRuntimes }, |
| 178 | 180 | ), |
apps/desktop/src/backend/DesktopBackendManager.ts+42| ... | ... | @@ -52,6 +52,7 @@ import { waitForHttpReady as waitForHttpReadyShared } from "@t3tools/shared/http |
| 52 | 52 | |
| 53 | 53 | import * as DesktopObservability from "../app/DesktopObservability.ts"; |
| 54 | 54 | import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; |
| 55 | import * as DesktopBrowserHost from "../preview/DesktopBrowserHost.ts"; | |
| 55 | 56 | import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; |
| 56 | 57 | |
| 57 | 58 | const INITIAL_RESTART_DELAY = Duration.millis(500); |
| ... | ... | @@ -222,6 +223,10 @@ export type BackendProcessError = typeof BackendProcessError.Type; |
| 222 | 223 | |
| 223 | 224 | interface RunBackendProcessOptions extends DesktopBackendStartConfig { |
| 224 | 225 | readonly desktopTelemetryStream: Stream.Stream<Uint8Array>; |
| 226 | /** Events for the desktop's browser tabs, written to `desktopBrowserFd`. */ | |
| 227 | readonly desktopBrowserStream?: Stream.Stream<Uint8Array>; | |
| 228 | /** Each line the backend writes to `desktopBrowserControlFd`. */ | |
| 229 | readonly onDesktopBrowserCommand?: (line: string) => Effect.Effect<void>; | |
| 225 | 230 | readonly onDesktopTelemetryControl?: ( |
| 226 | 231 | message: DesktopTelemetryControlMessageValue, |
| 227 | 232 | ) => Effect.Effect<void>; |
| ... | ... | @@ -472,6 +477,18 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( |
| 472 | 477 | type: "output", |
| 473 | 478 | }; |
| 474 | 479 | } |
| 480 | if ( | |
| 481 | options.bootstrap.desktopBrowserFd !== undefined && | |
| 482 | options.desktopBrowserStream !== undefined | |
| 483 | ) { | |
| 484 | additionalFds[`fd${options.bootstrap.desktopBrowserFd}`] = { | |
| 485 | type: "input", | |
| 486 | stream: options.desktopBrowserStream, | |
| 487 | }; | |
| 488 | } | |
| 489 | if (options.bootstrap.desktopBrowserControlFd !== undefined) { | |
| 490 | additionalFds[`fd${options.bootstrap.desktopBrowserControlFd}`] = { type: "output" }; | |
| 491 | } | |
| 475 | 492 | } |
| 476 | 493 | const command = ChildProcess.make(options.executablePath, options.args, { |
| 477 | 494 | cwd: options.cwd, |
| ... | ... | @@ -542,6 +559,26 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( |
| 542 | 559 | Effect.forkScoped, |
| 543 | 560 | ); |
| 544 | 561 | } |
| 562 | if ( | |
| 563 | options.bootstrap.desktopBrowserControlFd !== undefined && | |
| 564 | options.onDesktopBrowserCommand !== undefined | |
| 565 | ) { | |
| 566 | const browserFd = options.bootstrap.desktopBrowserControlFd; | |
| 567 | const handleCommand = options.onDesktopBrowserCommand; | |
| 568 | yield* handle.getOutputFd(browserFd).pipe( | |
| 569 | Stream.decodeText(), | |
| 570 | Stream.splitLines, | |
| 571 | Stream.filter((line) => line.length > 0), | |
| 572 | Stream.runForEach(handleCommand), | |
| 573 | Effect.catchCause((cause) => | |
| 574 | logBackendProcessWarning("desktop browser command stream stopped", { | |
| 575 | fd: browserFd, | |
| 576 | cause: Cause.pretty(cause), | |
| 577 | }), | |
| 578 | ), | |
| 579 | Effect.forkScoped, | |
| 580 | ); | |
| 581 | } | |
| 545 | 582 | if (options.captureOutput) { |
| 546 | 583 | const outputContext = { |
| 547 | 584 | executablePath: options.executablePath, |
| ... | ... | @@ -642,6 +679,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( |
| 642 | 679 | | HttpClient.HttpClient |
| 643 | 680 | | DesktopObservability.DesktopBackendOutputLogFactory |
| 644 | 681 | | DesktopTelemetryPublisher.DesktopTelemetryPublisher |
| 682 | | DesktopBrowserHost.DesktopBrowserHost | |
| 645 | 683 | | DesktopWslEnvironment.DesktopWslEnvironment |
| 646 | 684 | | Scope.Scope |
| 647 | 685 | > { |
| ... | ... | @@ -650,6 +688,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( |
| 650 | 688 | const backendOutputLogFactory = yield* DesktopObservability.DesktopBackendOutputLogFactory; |
| 651 | 689 | const backendOutputLog = yield* backendOutputLogFactory.forInstance(spec.id); |
| 652 | 690 | const desktopTelemetryPublisher = yield* DesktopTelemetryPublisher.DesktopTelemetryPublisher; |
| 691 | const desktopBrowserHost = yield* DesktopBrowserHost.DesktopBrowserHost; | |
| 653 | 692 | const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; |
| 654 | 693 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 655 | 694 | const httpClient = yield* HttpClient.HttpClient; |
| ... | ... | @@ -907,6 +946,9 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( |
| 907 | 946 | const program = runBackendProcess({ |
| 908 | 947 | ...config.value, |
| 909 | 948 | desktopTelemetryStream: desktopTelemetryPublisher.encoded, |
| 949 | // Only a bootstrap that names the browser fds (the local primary) gets them. | |
| 950 | desktopBrowserStream: desktopBrowserHost.events, | |
| 951 | onDesktopBrowserCommand: desktopBrowserHost.handleCommandLine, | |
| 910 | 952 | onDesktopTelemetryControl: (message) => |
| 911 | 953 | desktopTelemetryPublisher.handleControlForSource(spec.id, message), |
| 912 | 954 | onStarted: Effect.fn("desktop.backendInstance.onStarted")(function* (pid) { |
apps/desktop/src/backend/DesktopBackendPool.test.ts+2| ... | ... | @@ -12,6 +12,7 @@ import { ChildProcessSpawner } from "effect/process"; |
| 12 | 12 | import * as DesktopObservability from "../app/DesktopObservability.ts"; |
| 13 | 13 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; |
| 14 | 14 | import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; |
| 15 | import * as DesktopBrowserHost from "../preview/DesktopBrowserHost.ts"; | |
| 15 | 16 | import * as ElectronDialog from "../electron/ElectronDialog.ts"; |
| 16 | 17 | import * as DesktopWindow from "../window/DesktopWindow.ts"; |
| 17 | 18 | import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; |
| ... | ... | @@ -75,6 +76,7 @@ function layerPool(labelRef: Ref.Ref<string>): Layer.Layer<DesktopBackendPool.De |
| 75 | 76 | updateCommits: Stream.empty, |
| 76 | 77 | updateCancellations: Stream.empty, |
| 77 | 78 | }), |
| 79 | DesktopBrowserHost.layer, | |
| 78 | 80 | Layer.succeed(DesktopBackendConfiguration.DesktopBackendConfiguration, { |
| 79 | 81 | resolvePrimary: Effect.die("unexpected primary config resolve"), |
| 80 | 82 | resolvePrimaryLabel: Ref.get(labelRef), |
apps/desktop/src/backend/DesktopBackendPool.ts+2| ... | ... | @@ -98,6 +98,7 @@ import * as DesktopBackendManager from "./DesktopBackendManager.ts"; |
| 98 | 98 | import * as DesktopObservability from "../app/DesktopObservability.ts"; |
| 99 | 99 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; |
| 100 | 100 | import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; |
| 101 | import * as DesktopBrowserHost from "../preview/DesktopBrowserHost.ts"; | |
| 101 | 102 | import * as DesktopWindow from "../window/DesktopWindow.ts"; |
| 102 | 103 | import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; |
| 103 | 104 | import * as ElectronDialog from "../electron/ElectronDialog.ts"; |
| ... | ... | @@ -180,6 +181,7 @@ export type BackendInstanceFactoryRequirements = |
| 180 | 181 | | HttpClient.HttpClient |
| 181 | 182 | | DesktopObservability.DesktopBackendOutputLogFactory |
| 182 | 183 | | DesktopTelemetryPublisher.DesktopTelemetryPublisher |
| 184 | | DesktopBrowserHost.DesktopBrowserHost | |
| 183 | 185 | | DesktopWslEnvironment.DesktopWslEnvironment; |
| 184 | 186 | |
| 185 | 187 | interface ActiveRegisteredInstance { |
apps/desktop/src/ipc/channels.ts+1-8| ... | ... | @@ -78,6 +78,7 @@ export const PREVIEW_REFRESH_CHANNEL = "desktop:preview-refresh"; |
| 78 | 78 | export const PREVIEW_ZOOM_IN_CHANNEL = "desktop:preview-zoom-in"; |
| 79 | 79 | export const PREVIEW_ZOOM_OUT_CHANNEL = "desktop:preview-zoom-out"; |
| 80 | 80 | export const PREVIEW_RESET_ZOOM_CHANNEL = "desktop:preview-reset-zoom"; |
| 81 | export const PREVIEW_SET_ZOOM_FACTOR_CHANNEL = "desktop:preview-set-zoom-factor"; | |
| 81 | 82 | export const PREVIEW_HARD_RELOAD_CHANNEL = "desktop:preview-hard-reload"; |
| 82 | 83 | export const PREVIEW_SET_COLOR_SCHEME_CHANNEL = "desktop:preview-set-color-scheme"; |
| 83 | 84 | export const PREVIEW_SET_AUDIO_MUTED_CHANNEL = "desktop:preview-set-audio-muted"; |
| ... | ... | @@ -96,14 +97,6 @@ export const PREVIEW_COPY_ARTIFACT_CHANNEL = "desktop:preview-copy-artifact"; |
| 96 | 97 | export const PREVIEW_PICTURE_IN_PICTURE_OPEN_CHANNEL = "desktop:preview-pip-open"; |
| 97 | 98 | export const PREVIEW_PICTURE_IN_PICTURE_CLOSE_CHANNEL = "desktop:preview-pip-close"; |
| 98 | 99 | export const PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL = "desktop:preview-pip-frame"; |
| 99 | export const PREVIEW_AUTOMATION_STATUS_CHANNEL = "desktop:preview-automation-status"; | |
| 100 | export const PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL = "desktop:preview-automation-snapshot"; | |
| 101 | export const PREVIEW_AUTOMATION_CLICK_CHANNEL = "desktop:preview-automation-click"; | |
| 102 | export const PREVIEW_AUTOMATION_TYPE_CHANNEL = "desktop:preview-automation-type"; | |
| 103 | export const PREVIEW_AUTOMATION_PRESS_CHANNEL = "desktop:preview-automation-press"; | |
| 104 | export const PREVIEW_AUTOMATION_SCROLL_CHANNEL = "desktop:preview-automation-scroll"; | |
| 105 | export const PREVIEW_AUTOMATION_EVALUATE_CHANNEL = "desktop:preview-automation-evaluate"; | |
| 106 | export const PREVIEW_AUTOMATION_WAIT_FOR_CHANNEL = "desktop:preview-automation-wait-for"; | |
| 107 | 100 | export const PREVIEW_RECORDING_START_CHANNEL = "desktop:preview-recording-start"; |
| 108 | 101 | export const PREVIEW_RECORDING_STOP_CHANNEL = "desktop:preview-recording-stop"; |
| 109 | 102 | export const PREVIEW_RECORDING_SAVE_CHANNEL = "desktop:preview-recording-save"; |
apps/desktop/src/ipc/methods/preview.test.ts-26| ... | ... | @@ -128,32 +128,6 @@ describe("preview IPC methods", () => { |
| 128 | 128 | ), |
| 129 | 129 | ); |
| 130 | 130 | |
| 131 | effectIt.effect("returns automation status for long runtime tab ids", () => | |
| 132 | Effect.gen(function* () { | |
| 133 | const tabId = | |
| 134 | `["environment-1","thread:delegated-task:${"a".repeat(120)}",` + | |
| 135 | `"server-epoch-1","preview-1"]`; | |
| 136 | const status = { | |
| 137 | available: false, | |
| 138 | visible: true, | |
| 139 | tabId, | |
| 140 | url: null, | |
| 141 | title: null, | |
| 142 | loading: false, | |
| 143 | }; | |
| 144 | const manager = PreviewManager.PreviewManager.of({ | |
| 145 | automationStatus: () => Effect.succeed(status), | |
| 146 | } as unknown as PreviewManager.PreviewManager["Service"]); | |
| 147 | ||
| 148 | expect(tabId.length).toBeGreaterThan(128); | |
| 149 | expect( | |
| 150 | yield* PreviewIpc.automationStatus | |
| 151 | .handler({ tabId }) | |
| 152 | .pipe(Effect.provideService(PreviewManager.PreviewManager, manager)), | |
| 153 | ).toEqual(status); | |
| 154 | }), | |
| 155 | ); | |
| 156 | ||
| 157 | 131 | it("keeps the public automation status tab id limit", () => { |
| 158 | 132 | const encode = Schema.encodeUnknownSync(PreviewAutomationStatus); |
| 159 | 133 | const tabId = "t".repeat(129); |
apps/desktop/src/ipc/methods/preview.ts+13-97| ... | ... | @@ -1,13 +1,6 @@ |
| 1 | 1 | import { |
| 2 | 2 | DesktopPreviewAnnotationThemeInputSchema, |
| 3 | 3 | DesktopPreviewArtifactInputSchema, |
| 4 | DesktopPreviewAutomationClickInputSchema, | |
| 5 | DesktopPreviewAutomationEvaluateInputSchema, | |
| 6 | DesktopPreviewAutomationPressInputSchema, | |
| 7 | DesktopPreviewAutomationScrollInputSchema, | |
| 8 | DesktopPreviewAutomationStatusSchema, | |
| 9 | DesktopPreviewAutomationTypeInputSchema, | |
| 10 | DesktopPreviewAutomationWaitForInputSchema, | |
| 11 | 4 | DesktopPreviewConfigInputSchema, |
| 12 | 5 | DesktopPreviewNavigateInputSchema, |
| 13 | 6 | DesktopPreviewRecordingArtifactSchema, |
| ... | ... | @@ -16,6 +9,7 @@ import { |
| 16 | 9 | DesktopPreviewScreenshotArtifactSchema, |
| 17 | 10 | DesktopPreviewSetAudioMutedInputSchema, |
| 18 | 11 | DesktopPreviewSetColorSchemeInputSchema, |
| 12 | DesktopPreviewSetZoomFactorInputSchema, | |
| 19 | 13 | BrowserImportResult, |
| 20 | 14 | BrowserImportSource, |
| 21 | 15 | DesktopPreviewClearDataInputSchema, |
| ... | ... | @@ -24,7 +18,6 @@ import { |
| 24 | 18 | DesktopPreviewTabInputSchema, |
| 25 | 19 | DesktopPreviewWebviewConfigSchema, |
| 26 | 20 | PreviewAnnotationSubmissionResultSchema, |
| 27 | PreviewAutomationSnapshot, | |
| 28 | 21 | DEFAULT_BROWSER_PROFILE_ID, |
| 29 | 22 | INCOGNITO_BROWSER_PROFILE_ID, |
| 30 | 23 | } from "@t3tools/contracts"; |
| ... | ... | @@ -68,9 +61,10 @@ export const createTab = DesktopIpc.makeIpcMethod({ |
| 68 | 61 | tabId, |
| 69 | 62 | zoomFactor, |
| 70 | 63 | colorScheme, |
| 64 | serverTab, | |
| 71 | 65 | }) { |
| 72 | 66 | const manager = yield* PreviewManager.PreviewManager; |
| 73 | yield* manager.createTab(tabId, { zoomFactor, colorScheme }); | |
| 67 | yield* manager.createTab(tabId, { zoomFactor, colorScheme, serverTab }); | |
| 74 | 68 | }), |
| 75 | 69 | }); |
| 76 | 70 | |
| ... | ... | @@ -157,6 +151,15 @@ export const hardReload = tabMethod( |
| 157 | 151 | "desktop.ipc.preview.hardReload", |
| 158 | 152 | (manager, tabId) => manager.hardReload(tabId), |
| 159 | 153 | ); |
| 154 | export 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 | }); | |
| 160 | 163 | export const setColorScheme = DesktopIpc.makeIpcMethod({ |
| 161 | 164 | channel: IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL, |
| 162 | 165 | payload: DesktopPreviewSetColorSchemeInputSchema, |
| ... | ... | @@ -392,86 +395,6 @@ export const copyArtifactToClipboard = DesktopIpc.makeIpcMethod({ |
| 392 | 395 | }), |
| 393 | 396 | }); |
| 394 | 397 | |
| 395 | export 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 | ||
| 405 | export 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 | ||
| 415 | export 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 | ||
| 425 | export 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 | ||
| 435 | export 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 | ||
| 445 | export 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 | ||
| 455 | export 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 | ||
| 465 | export 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 | ||
| 475 | 398 | export const saveRecording = DesktopIpc.makeIpcMethod({ |
| 476 | 399 | channel: IpcChannels.PREVIEW_RECORDING_SAVE_CHANNEL, |
| 477 | 400 | payload: DesktopPreviewRecordingSaveInputSchema, |
| ... | ... | @@ -495,6 +418,7 @@ export const methods = [ |
| 495 | 418 | resetZoom, |
| 496 | 419 | hardReload, |
| 497 | 420 | setColorScheme, |
| 421 | setZoomFactor, | |
| 498 | 422 | setAudioMuted, |
| 499 | 423 | openDevTools, |
| 500 | 424 | clearCookies, |
| ... | ... | @@ -508,14 +432,6 @@ export const methods = [ |
| 508 | 432 | copyArtifactToClipboard, |
| 509 | 433 | openPictureInPicture, |
| 510 | 434 | closePictureInPicture, |
| 511 | automationStatus, | |
| 512 | automationSnapshot, | |
| 513 | automationClick, | |
| 514 | automationType, | |
| 515 | automationPress, | |
| 516 | automationScroll, | |
| 517 | automationEvaluate, | |
| 518 | automationWaitFor, | |
| 519 | 435 | startRecording, |
| 520 | 436 | stopRecording, |
| 521 | 437 | saveRecording, |
apps/desktop/src/main.ts+2| ... | ... | @@ -67,6 +67,7 @@ import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; |
| 67 | 67 | import * as BrowserImport from "./preview/BrowserImport/BrowserImport.ts"; |
| 68 | 68 | import * as LinuxBrowserSecret from "./preview/BrowserImport/LinuxBrowserSecret.ts"; |
| 69 | 69 | import * as BrowserSession from "./preview/BrowserSession.ts"; |
| 70 | import * as DesktopBrowserHost from "./preview/DesktopBrowserHost.ts"; | |
| 70 | 71 | import * as PreviewManager from "./preview/Manager.ts"; |
| 71 | 72 | import * as DesktopWindow from "./window/DesktopWindow.ts"; |
| 72 | 73 | import * as DesktopWslBackend from "./wsl/DesktopWslBackend.ts"; |
| ... | ... | @@ -161,6 +162,7 @@ const layerDesktopServerExposure = DesktopServerExposure.layer.pipe( |
| 161 | 162 | ); |
| 162 | 163 | |
| 163 | 164 | const layerDesktopPreview = PreviewManager.layer.pipe( |
| 165 | Layer.provideMerge(DesktopBrowserHost.layer), | |
| 164 | 166 | // Merged rather than provided so the IPC handlers can reach the import |
| 165 | 167 | // service alongside the manager; both sit on the same BrowserSession. |
| 166 | 168 | Layer.provideMerge(BrowserImport.layer.pipe(Layer.provide(LinuxBrowserSecret.layer))), |
apps/desktop/src/preload.ts+3-18| ... | ... | @@ -303,6 +303,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 303 | 303 | ipcRenderer.invoke(IpcChannels.PREVIEW_CREATE_TAB_CHANNEL, { |
| 304 | 304 | tabId, |
| 305 | 305 | zoomFactor: defaults?.zoomFactor, |
| 306 | serverTab: defaults?.serverTab, | |
| 306 | 307 | colorScheme: defaults?.colorScheme, |
| 307 | 308 | }), |
| 308 | 309 | closeTab: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_CLOSE_TAB_CHANNEL, { tabId }), |
| ... | ... | @@ -316,6 +317,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 316 | 317 | zoomIn: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_ZOOM_IN_CHANNEL, { tabId }), |
| 317 | 318 | zoomOut: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_ZOOM_OUT_CHANNEL, { tabId }), |
| 318 | 319 | resetZoom: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_RESET_ZOOM_CHANNEL, { tabId }), |
| 320 | setZoomFactor: (tabId, zoomFactor) => | |
| 321 | ipcRenderer.invoke(IpcChannels.PREVIEW_SET_ZOOM_FACTOR_CHANNEL, { tabId, zoomFactor }), | |
| 319 | 322 | hardReload: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_HARD_RELOAD_CHANNEL, { tabId }), |
| 320 | 323 | setColorScheme: (tabId, colorScheme) => |
| 321 | 324 | ipcRenderer.invoke(IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL, { tabId, colorScheme }), |
| ... | ... | @@ -379,24 +382,6 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 379 | 382 | ipcRenderer.removeListener(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, wrappedListener); |
| 380 | 383 | }, |
| 381 | 384 | }, |
| 382 | automation: { | |
| 383 | status: (tabId) => | |
| 384 | ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL, { tabId }), | |
| 385 | snapshot: (tabId) => | |
| 386 | ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL, { tabId }), | |
| 387 | click: (tabId, input) => | |
| 388 | ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_CLICK_CHANNEL, { tabId, input }), | |
| 389 | type: (tabId, input) => | |
| 390 | ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_TYPE_CHANNEL, { tabId, input }), | |
| 391 | press: (tabId, input) => | |
| 392 | ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_PRESS_CHANNEL, { tabId, input }), | |
| 393 | scroll: (tabId, input) => | |
| 394 | ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_SCROLL_CHANNEL, { tabId, input }), | |
| 395 | evaluate: (tabId, input) => | |
| 396 | ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_EVALUATE_CHANNEL, { tabId, input }), | |
| 397 | waitFor: (tabId, input) => | |
| 398 | ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_WAIT_FOR_CHANNEL, { tabId, input }), | |
| 399 | }, | |
| 400 | 385 | onStateChange: (listener) => { |
| 401 | 386 | const wrappedListener = ( |
| 402 | 387 | _event: Electron.IpcRendererEvent, |
apps/desktop/src/preview/CdpRelay.test.ts created+174| ... | ... | @@ -0,0 +1,174 @@ |
| 1 | import { describe, expect, it, vi } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { createCdpRelayConnection, type CdpRelayTarget } from "./CdpRelay.ts"; | |
| 4 | ||
| 5 | const 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. | |
| 18 | const settle = () => new Promise<void>((resolve) => setImmediate(resolve)); | |
| 19 | ||
| 20 | describe("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 | ||
| 97 | describe("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 | ||
| 138 | describe("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 | ||
| 12 | export 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 | ||
| 32 | export 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 | ||
| 39 | interface CdpCommand { | |
| 40 | readonly id: number; | |
| 41 | readonly method: string; | |
| 42 | readonly params?: Record<string, unknown>; | |
| 43 | readonly sessionId?: string; | |
| 44 | } | |
| 45 | ||
| 46 | const 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 | ||
| 52 | const BROWSER_CONTEXT_ID = "t3-preview"; | |
| 53 | /** Marks an event to send right after a command's reply. */ | |
| 54 | const ANNOUNCE = Symbol("announce"); | |
| 55 | /** The page's root session id, as Playwright sees it. */ | |
| 56 | const PAGE_SESSION_ID = "t3-preview-page"; | |
| 57 | ||
| 58 | /** Browser-level commands that only need acknowledging for a page the desktop owns. */ | |
| 59 | const ACKNOWLEDGED = new Set([ | |
| 60 | "Target.setDiscoverTargets", | |
| 61 | "Browser.grantPermissions", | |
| 62 | "Browser.resetPermissions", | |
| 63 | "Browser.cancelDownload", | |
| 64 | ]); | |
| 65 | ||
| 66 | export 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. | |
| 2 | import { describe, expect, it } from "@effect/vitest"; | |
| 3 | import { DesktopBrowserEvent } from "@t3tools/contracts"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Fiber from "effect/Fiber"; | |
| 6 | import * as Schema from "effect/Schema"; | |
| 7 | import * as Stream from "effect/Stream"; | |
| 8 | import * as NodeEvents from "node:events"; | |
| 9 | ||
| 10 | import * as DesktopBrowserHost from "./DesktopBrowserHost.ts"; | |
| 11 | ||
| 12 | const decodeEvent = Schema.decodeUnknownSync(Schema.fromJsonString(DesktopBrowserEvent)); | |
| 13 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | |
| 14 | const decodeCdpReply = Schema.decodeUnknownSync( | |
| 15 | Schema.fromJsonString(Schema.Struct({ id: Schema.Number })), | |
| 16 | ); | |
| 17 | const key = { threadId: "thread-1", tabId: "tab-1" }; | |
| 18 | ||
| 19 | /** A tab's webContents and debugger, with the debugger's commands left pending until released. */ | |
| 20 | const 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. */ | |
| 49 | const 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 | ||
| 56 | describe("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 | */ | |
| 12 | import { | |
| 13 | DesktopBrowserCommand, | |
| 14 | DesktopBrowserEvent, | |
| 15 | type DesktopBrowserEvent as DesktopBrowserEventType, | |
| 16 | } from "@t3tools/contracts"; | |
| 17 | import * as Context from "effect/Context"; | |
| 18 | import * as NodePath from "node:path"; | |
| 19 | import * as Effect from "effect/Effect"; | |
| 20 | import * as Layer from "effect/Layer"; | |
| 21 | import * as Option from "effect/Option"; | |
| 22 | import * as PubSub from "effect/PubSub"; | |
| 23 | import * as Schema from "effect/Schema"; | |
| 24 | import * as Stream from "effect/Stream"; | |
| 25 | ||
| 26 | import { createCdpRelayConnection, type CdpRelayConnection } from "./CdpRelay.ts"; | |
| 27 | ||
| 28 | const encodeEvent = Schema.encodeSync(Schema.fromJsonString(DesktopBrowserEvent)); | |
| 29 | const decodeCommand = Schema.decodeUnknownOption(Schema.fromJsonString(DesktopBrowserCommand)); | |
| 30 | const lineEncoder = new TextEncoder(); | |
| 31 | ||
| 32 | export 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. */ | |
| 38 | export interface DesktopBrowserTabDebugger { | |
| 39 | readonly webContents: Electron.WebContents; | |
| 40 | readonly debugger: Electron.Debugger; | |
| 41 | } | |
| 42 | ||
| 43 | const keyOf = ({ threadId, tabId }: DesktopBrowserTabKey) => `${threadId}\u0000${tabId}`; | |
| 44 | ||
| 45 | interface 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 | ||
| 61 | export 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 | ||
| 87 | export 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 | ||
| 225 | export const layer = Layer.effect(DesktopBrowserHost, make); |
apps/desktop/src/preview/Manager.test.ts+37-905| ... | ... | @@ -1,5 +1,3 @@ |
| 1 | import * as NodeVM from "node:vm"; | |
| 2 | import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; | |
| 3 | 1 | import { it as effectIt } from "@effect/vitest"; |
| 4 | 2 | import { DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER } from "@t3tools/contracts"; |
| 5 | 3 | import type { |
| ... | ... | @@ -13,11 +11,12 @@ import * as Effect from "effect/Effect"; |
| 13 | 11 | import * as Exit from "effect/Exit"; |
| 14 | 12 | import * as FileSystem from "effect/FileSystem"; |
| 15 | 13 | import * as Fiber from "effect/Fiber"; |
| 14 | import * as Queue from "effect/Queue"; | |
| 15 | import * as Stream from "effect/Stream"; | |
| 16 | 16 | import * as Layer from "effect/Layer"; |
| 17 | 17 | import * as Logger from "effect/Logger"; |
| 18 | 18 | import * as Option from "effect/Option"; |
| 19 | 19 | import * as Path from "effect/Path"; |
| 20 | import * as Schema from "effect/Schema"; | |
| 21 | 20 | import type * as Scope from "effect/Scope"; |
| 22 | 21 | import { TestClock } from "effect/testing"; |
| 23 | 22 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| ... | ... | @@ -26,6 +25,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 26 | 25 | import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts"; |
| 27 | 26 | import * as ElectronWindow from "../electron/ElectronWindow.ts"; |
| 28 | 27 | import * as BrowserSession from "./BrowserSession.ts"; |
| 28 | import * as DesktopBrowserHost from "./DesktopBrowserHost.ts"; | |
| 29 | 29 | import * as PreviewManager from "./Manager.ts"; |
| 30 | 30 | |
| 31 | 31 | describe("fitPictureInPictureContentSize", () => { |
| ... | ... | @@ -276,14 +276,13 @@ const layer = PreviewManager.layer.pipe( |
| 276 | 276 | shutdown: Effect.void, |
| 277 | 277 | }), |
| 278 | 278 | ), |
| 279 | Layer.provideMerge(DesktopBrowserHost.layer), | |
| 279 | 280 | Layer.provideMerge(layerBrowserSession), |
| 280 | 281 | Layer.provideMerge(layerEnvironment), |
| 281 | 282 | Layer.provideMerge(layerFileSystem), |
| 282 | 283 | Layer.provideMerge(Path.layer), |
| 283 | Layer.provideMerge(NodeCrypto.layer), | |
| 284 | 284 | Layer.provideMerge(Layer.succeed(HostProcessPlatform, "darwin")), |
| 285 | 285 | ); |
| 286 | const encodePreviewManagerError = Schema.encodeSync(PreviewManager.PreviewManagerError); | |
| 287 | 286 | |
| 288 | 287 | const withManager = <A>( |
| 289 | 288 | use: ( |
| ... | ... | @@ -676,33 +675,6 @@ describe("PreviewManager", () => { |
| 676 | 675 | ), |
| 677 | 676 | ); |
| 678 | 677 | |
| 679 | effectIt.effect("reports an unregistered webview as temporarily unavailable", () => | |
| 680 | withManager((manager) => | |
| 681 | Effect.gen(function* () { | |
| 682 | expect(yield* manager.automationStatus("tab_1")).toEqual({ | |
| 683 | available: false, | |
| 684 | visible: true, | |
| 685 | tabId: "tab_1", | |
| 686 | url: null, | |
| 687 | title: null, | |
| 688 | loading: false, | |
| 689 | }); | |
| 690 | ||
| 691 | yield* manager.createTab("tab_1"); | |
| 692 | ||
| 693 | expect(yield* manager.automationStatus("tab_1")).toEqual({ | |
| 694 | available: false, | |
| 695 | visible: true, | |
| 696 | tabId: "tab_1", | |
| 697 | url: null, | |
| 698 | title: null, | |
| 699 | loading: false, | |
| 700 | }); | |
| 701 | expect(fromId).not.toHaveBeenCalled(); | |
| 702 | }), | |
| 703 | ), | |
| 704 | ); | |
| 705 | ||
| 706 | 678 | effectIt.effect("rejects a destroyed webview during registration", () => |
| 707 | 679 | withManager((manager) => |
| 708 | 680 | Effect.gen(function* () { |
| ... | ... | @@ -832,15 +804,6 @@ describe("PreviewManager", () => { |
| 832 | 804 | |
| 833 | 805 | yield* manager.navigate("tab_pending", "localhost:3200"); |
| 834 | 806 | |
| 835 | expect(yield* manager.automationStatus("tab_pending")).toEqual({ | |
| 836 | available: false, | |
| 837 | visible: true, | |
| 838 | tabId: "tab_pending", | |
| 839 | url: "http://localhost:3200/", | |
| 840 | title: "", | |
| 841 | loading: true, | |
| 842 | }); | |
| 843 | ||
| 844 | 807 | yield* manager.registerWebview("tab_pending", 42); |
| 845 | 808 | yield* Effect.yieldNow; |
| 846 | 809 | |
| ... | ... | @@ -2721,6 +2684,39 @@ describe("PreviewManager", () => { |
| 2721 | 2684 | ), |
| 2722 | 2685 | ); |
| 2723 | 2686 | |
| 2687 | effectIt.effect("tells the server when a tab it renders natively closes", () => | |
| 2688 | Effect.gen(function* () { | |
| 2689 | const host = yield* DesktopBrowserHost.DesktopBrowserHost; | |
| 2690 | const serverTab = { threadId: "thread-1", tabId: "server-tab-1" }; | |
| 2691 | const lines = yield* Queue.unbounded<string>(); | |
| 2692 | yield* host.events.pipe( | |
| 2693 | Stream.runForEach((line) => Queue.offer(lines, new TextDecoder().decode(line))), | |
| 2694 | Effect.forkScoped, | |
| 2695 | ); | |
| 2696 | const nextType = Queue.take(lines).pipe( | |
| 2697 | Effect.map((line) => /"type":"(\w+)"/.exec(line)?.[1]), | |
| 2698 | ); | |
| 2699 | const capturePage = vi.fn(async () => ({ | |
| 2700 | toPNG: () => Buffer.from("png"), | |
| 2701 | toJPEG: () => Buffer.from("jpeg"), | |
| 2702 | getSize: () => ({ width: 100, height: 80 }), | |
| 2703 | })); | |
| 2704 | fromId.mockReturnValue( | |
| 2705 | Object.assign(makeTestPreviewWebContents(capturePage, 42), { | |
| 2706 | isDevToolsOpened: () => false, | |
| 2707 | getUserAgent: () => "Electron", | |
| 2708 | }), | |
| 2709 | ); | |
| 2710 | const manager = yield* PreviewManager.PreviewManager; | |
| 2711 | yield* manager.createTab("tab_1", { serverTab }); | |
| 2712 | yield* manager.registerWebview("tab_1", 42); | |
| 2713 | // The debugger attaches in the background; the server hears it here. | |
| 2714 | expect(yield* nextType).toBe("attached"); | |
| 2715 | yield* manager.closeTab("tab_1"); | |
| 2716 | expect(yield* nextType).toBe("detached"); | |
| 2717 | }).pipe(Effect.provide(layer), Effect.scoped), | |
| 2718 | ); | |
| 2719 | ||
| 2724 | 2720 | effectIt.effect("stops capture retries when the tab swaps during the retry delay", () => |
| 2725 | 2721 | withManager((manager) => |
| 2726 | 2722 | Effect.gen(function* () { |
| ... | ... | @@ -2783,65 +2779,6 @@ describe("PreviewManager", () => { |
| 2783 | 2779 | ), |
| 2784 | 2780 | ); |
| 2785 | 2781 | |
| 2786 | effectIt.effect("releases snapshot control when every capture attempt stalls", () => | |
| 2787 | withManager((manager) => | |
| 2788 | Effect.gen(function* () { | |
| 2789 | const capturePage = vi.fn(() => new Promise<TestCapturedPreviewImage>(() => {})); | |
| 2790 | const wc = makeTestPreviewWebContents(capturePage); | |
| 2791 | Object.assign(wc, { isDevToolsOpened: () => false }); | |
| 2792 | Object.assign(wc.debugger, { | |
| 2793 | sendCommand: vi.fn(async (method: string, params?: Record<string, unknown>) => { | |
| 2794 | if (method === "Runtime.evaluate") { | |
| 2795 | return { | |
| 2796 | result: { | |
| 2797 | value: | |
| 2798 | params?.["expression"] === "42" | |
| 2799 | ? 42 | |
| 2800 | : { | |
| 2801 | url: "https://example.com", | |
| 2802 | title: "Example", | |
| 2803 | loading: false, | |
| 2804 | visibleText: "Example", | |
| 2805 | interactiveElements: [], | |
| 2806 | }, | |
| 2807 | }, | |
| 2808 | }; | |
| 2809 | } | |
| 2810 | return method === "Accessibility.getFullAXTree" ? { nodes: [] } : undefined; | |
| 2811 | }), | |
| 2812 | }); | |
| 2813 | fromId.mockReturnValue(wc); | |
| 2814 | yield* manager.createTab("tab_1"); | |
| 2815 | yield* manager.registerWebview("tab_1", 42); | |
| 2816 | ||
| 2817 | const snapshot = yield* Effect.exit(manager.automationSnapshot("tab_1")).pipe( | |
| 2818 | Effect.forkChild({ startImmediately: true }), | |
| 2819 | ); | |
| 2820 | yield* TestClock.adjust(100); | |
| 2821 | expect(capturePage).toHaveBeenCalledOnce(); | |
| 2822 | const evaluate = yield* manager | |
| 2823 | .automationEvaluate("tab_1", { expression: "42" }) | |
| 2824 | .pipe(Effect.forkChild({ startImmediately: true })); | |
| 2825 | expect(evaluate.pollUnsafe()).toBeUndefined(); | |
| 2826 | ||
| 2827 | yield* TestClock.adjust(4_000); | |
| 2828 | const exit = yield* Fiber.join(snapshot); | |
| 2829 | expect(Exit.isFailure(exit)).toBe(true); | |
| 2830 | expect(capturePage).toHaveBeenCalledTimes(3); | |
| 2831 | if (Exit.isSuccess(exit)) return; | |
| 2832 | const error = Option.getOrThrow(Cause.findErrorOption(exit.cause)); | |
| 2833 | expect(error).toMatchObject({ | |
| 2834 | _tag: "PreviewOperationError", | |
| 2835 | operation: "automationSnapshot.capturePage", | |
| 2836 | tabId: "tab_1", | |
| 2837 | webContentsId: 42, | |
| 2838 | cause: { _tag: "TimeoutError" }, | |
| 2839 | }); | |
| 2840 | expect(yield* Fiber.join(evaluate)).toBe(42); | |
| 2841 | }), | |
| 2842 | ), | |
| 2843 | ); | |
| 2844 | ||
| 2845 | 2782 | effectIt.effect("grants each concurrent preview recording its own tab frame", () => |
| 2846 | 2783 | withManager((manager) => |
| 2847 | 2784 | Effect.gen(function* () { |
| ... | ... | @@ -4192,725 +4129,6 @@ describe("PreviewManager", () => { |
| 4192 | 4129 | }), |
| 4193 | 4130 | ), |
| 4194 | 4131 | ); |
| 4195 | ||
| 4196 | effectIt.effect( | |
| 4197 | "records the resolved pointer target before dispatching an automation click", | |
| 4198 | () => | |
| 4199 | withManager((manager) => | |
| 4200 | Effect.gen(function* () { | |
| 4201 | let humanInput: ((_event: unknown, signal: unknown) => void) | undefined; | |
| 4202 | const activity: string[] = []; | |
| 4203 | const sendCommand = vi.fn(async (method: string, params?: Record<string, unknown>) => { | |
| 4204 | if (method === "Runtime.evaluate") { | |
| 4205 | return { | |
| 4206 | result: { | |
| 4207 | value: { width: 800, height: 600 }, | |
| 4208 | }, | |
| 4209 | }; | |
| 4210 | } | |
| 4211 | if (method === "Input.dispatchMouseEvent" && params?.type === "mousePressed") { | |
| 4212 | activity.push("mousePressed"); | |
| 4213 | humanInput?.({}, { kind: "pointer", x: params.x, y: params.y, button: 0 }); | |
| 4214 | } | |
| 4215 | return undefined; | |
| 4216 | }); | |
| 4217 | fromId.mockReturnValue({ | |
| 4218 | id: 42, | |
| 4219 | hostWebContents: makeTestHostWebContents(), | |
| 4220 | capturePage: vi.fn(async () => ({ toPNG: () => Buffer.from("frame") })), | |
| 4221 | setBackgroundThrottling: vi.fn(), | |
| 4222 | isDestroyed: () => false, | |
| 4223 | getType: () => "webview", | |
| 4224 | getURL: () => "https://example.com", | |
| 4225 | getTitle: () => "Example", | |
| 4226 | isLoading: () => false, | |
| 4227 | isDevToolsOpened: () => false, | |
| 4228 | getZoomFactor: () => 1, | |
| 4229 | setZoomFactor: vi.fn(), | |
| 4230 | setAudioMuted: vi.fn(), | |
| 4231 | isCurrentlyAudible: () => false, | |
| 4232 | on: vi.fn(), | |
| 4233 | off: vi.fn(), | |
| 4234 | ipc: { | |
| 4235 | on: vi.fn((channel: string, listener: typeof humanInput) => { | |
| 4236 | if (channel === "preview:human-input") humanInput = listener; | |
| 4237 | }), | |
| 4238 | off: vi.fn(), | |
| 4239 | }, | |
| 4240 | send: webviewSend, | |
| 4241 | navigationHistory: { canGoBack: () => false, canGoForward: () => false }, | |
| 4242 | setIgnoreMenuShortcuts: vi.fn(), | |
| 4243 | setWindowOpenHandler: vi.fn(), | |
| 4244 | debugger: { | |
| 4245 | isAttached: () => false, | |
| 4246 | attach: vi.fn(), | |
| 4247 | sendCommand, | |
| 4248 | on: vi.fn(), | |
| 4249 | off: vi.fn(), | |
| 4250 | }, | |
| 4251 | } as never); | |
| 4252 | ||
| 4253 | yield* manager.subscribePointerEvents((event) => | |
| 4254 | Effect.sync(() => { | |
| 4255 | activity.push(event.phase); | |
| 4256 | }), | |
| 4257 | ); | |
| 4258 | yield* manager.createTab("tab_1"); | |
| 4259 | yield* manager.registerWebview("tab_1", 42); | |
| 4260 | yield* manager.startRecording("tab_1"); | |
| 4261 | const click = yield* manager | |
| 4262 | .automationClick("tab_1", { x: 120, y: 80 }) | |
| 4263 | .pipe(Effect.forkChild({ startImmediately: true })); | |
| 4264 | yield* TestClock.adjust(200); | |
| 4265 | yield* Fiber.join(click); | |
| 4266 | ||
| 4267 | expect(activity).toEqual(["move", "click", "mousePressed"]); | |
| 4268 | expect( | |
| 4269 | webviewSend.mock.calls | |
| 4270 | .filter(([channel]) => channel === "preview:recording-controller") | |
| 4271 | .map(([, controller]) => controller), | |
| 4272 | ).toEqual(["agent", "none"]); | |
| 4273 | ||
| 4274 | const recordedPointer = webviewSend.mock.calls | |
| 4275 | .filter(([channel]) => channel === "preview:recording-pointer") | |
| 4276 | .map(([, event]) => event); | |
| 4277 | expect(recordedPointer).toEqual([ | |
| 4278 | expect.objectContaining({ phase: "move", x: 120, y: 80 }), | |
| 4279 | expect.objectContaining({ phase: "click", x: 120, y: 80 }), | |
| 4280 | ]); | |
| 4281 | expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", { | |
| 4282 | type: "mousePressed", | |
| 4283 | x: 120, | |
| 4284 | y: 80, | |
| 4285 | button: "left", | |
| 4286 | clickCount: 1, | |
| 4287 | }); | |
| 4288 | yield* manager.stopRecording("tab_1"); | |
| 4289 | expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", { | |
| 4290 | type: "mouseReleased", | |
| 4291 | x: 120, | |
| 4292 | y: 80, | |
| 4293 | button: "left", | |
| 4294 | clickCount: 1, | |
| 4295 | }); | |
| 4296 | }), | |
| 4297 | ), | |
| 4298 | ); | |
| 4299 | ||
| 4300 | effectIt.effect("types in background webviews and enables native key input", () => | |
| 4301 | withManager((manager) => | |
| 4302 | Effect.gen(function* () { | |
| 4303 | let failKeyDown = false; | |
| 4304 | let routeToIframe = false; | |
| 4305 | let interruptFrameKeyDown = false; | |
| 4306 | let holdKeyUp = false; | |
| 4307 | let releaseKeyUp: (() => void) | undefined; | |
| 4308 | let notifyKeyUpQueued: (() => void) | undefined; | |
| 4309 | const keyUpQueued = new Promise<void>((resolve) => { | |
| 4310 | notifyKeyUpQueued = resolve; | |
| 4311 | }); | |
| 4312 | const listeners = new Map<string, (event: unknown) => void>(); | |
| 4313 | const eventCounts = new Map<string, number>(); | |
| 4314 | const animationFrames = new Map<number, () => void>(); | |
| 4315 | let animationFrameId = 0; | |
| 4316 | const renderFrame = () => { | |
| 4317 | const callbacks = [...animationFrames.values()]; | |
| 4318 | animationFrames.clear(); | |
| 4319 | callbacks.forEach((callback) => callback()); | |
| 4320 | }; | |
| 4321 | const frameContext = NodeVM.createContext({ | |
| 4322 | performance: { eventCounts }, | |
| 4323 | requestAnimationFrame: (callback: () => void) => { | |
| 4324 | const id = ++animationFrameId; | |
| 4325 | animationFrames.set(id, callback); | |
| 4326 | return id; | |
| 4327 | }, | |
| 4328 | cancelAnimationFrame: (id: number) => animationFrames.delete(id), | |
| 4329 | window: { | |
| 4330 | addEventListener: (type: string, listener: (event: unknown) => void) => | |
| 4331 | listeners.set(type, listener), | |
| 4332 | removeEventListener: (type: string) => listeners.delete(type), | |
| 4333 | }, | |
| 4334 | }); | |
| 4335 | const frame = { | |
| 4336 | executeJavaScript: vi.fn(async (expression: string) => | |
| 4337 | NodeVM.runInContext(expression, frameContext), | |
| 4338 | ), | |
| 4339 | }; | |
| 4340 | let humanInput: ((_event: unknown, signal: unknown) => void) | undefined; | |
| 4341 | const sendCommand = vi.fn( | |
| 4342 | async (method: string, params?: Record<string, unknown>, sessionId?: string) => { | |
| 4343 | if (method === "Runtime.evaluate") { | |
| 4344 | if (params?.["returnByValue"] === true) return { result: { value: { ok: true } } }; | |
| 4345 | return { | |
| 4346 | result: | |
| 4347 | routeToIframe && !sessionId | |
| 4348 | ? { objectId: "focused-iframe-object" } | |
| 4349 | : { subtype: "null" }, | |
| 4350 | }; | |
| 4351 | } | |
| 4352 | if (method === "DOM.describeNode") return { node: { frameId: "focused-frame" } }; | |
| 4353 | if (method === "Target.getTargets") | |
| 4354 | return { | |
| 4355 | targetInfos: [ | |
| 4356 | { targetId: "unrelated-frame", type: "iframe" }, | |
| 4357 | { targetId: "focused-frame", type: "iframe" }, | |
| 4358 | ], | |
| 4359 | }; | |
| 4360 | if (method === "Target.attachToTarget") return { sessionId: "child-session" }; | |
| 4361 | if (method === "Input.dispatchKeyEvent" && params?.["type"] !== "keyUp") { | |
| 4362 | if (failKeyDown) throw new Error("key dispatch failed"); | |
| 4363 | if (interruptFrameKeyDown) | |
| 4364 | humanInput?.({}, { kind: "pointer", x: 80, y: 40, button: 0 }); | |
| 4365 | } | |
| 4366 | return undefined; | |
| 4367 | }, | |
| 4368 | ); | |
| 4369 | const sendInputEvent = vi.fn((input: Electron.KeyboardInputEvent) => { | |
| 4370 | const signal = { | |
| 4371 | kind: "key", | |
| 4372 | key: input.keyCode, | |
| 4373 | code: input.keyCode === "!" ? "Digit1" : `Key${input.keyCode.toUpperCase()}`, | |
| 4374 | }; | |
| 4375 | if (input.type === "keyUp") { | |
| 4376 | const deliver = () => { | |
| 4377 | eventCounts.set("keyup", (eventCounts.get("keyup") ?? 0) + 1); | |
| 4378 | renderFrame(); | |
| 4379 | }; | |
| 4380 | if (holdKeyUp) { | |
| 4381 | releaseKeyUp = deliver; | |
| 4382 | notifyKeyUpQueued?.(); | |
| 4383 | } else { | |
| 4384 | queueMicrotask(deliver); | |
| 4385 | } | |
| 4386 | } | |
| 4387 | if (input.type !== "keyDown") return; | |
| 4388 | if (failKeyDown) throw new Error("key dispatch failed"); | |
| 4389 | humanInput?.({}, signal); | |
| 4390 | }); | |
| 4391 | const restoreFocus = vi.fn(); | |
| 4392 | const focus = vi.fn(); | |
| 4393 | getFocusedWebContents.mockReturnValue({ | |
| 4394 | id: 7, | |
| 4395 | isDestroyed: () => false, | |
| 4396 | focus: restoreFocus, | |
| 4397 | } as never); | |
| 4398 | fromId.mockReturnValue({ | |
| 4399 | id: 42, | |
| 4400 | mainFrame: { framesInSubtree: [frame] }, | |
| 4401 | isDestroyed: () => false, | |
| 4402 | getType: () => "webview", | |
| 4403 | getURL: () => "https://example.com", | |
| 4404 | getTitle: () => "Example", | |
| 4405 | isLoading: () => false, | |
| 4406 | isDevToolsOpened: () => false, | |
| 4407 | focus, | |
| 4408 | sendInputEvent, | |
| 4409 | getZoomFactor: () => 1, | |
| 4410 | setZoomFactor: vi.fn(), | |
| 4411 | setAudioMuted: vi.fn(), | |
| 4412 | isCurrentlyAudible: () => false, | |
| 4413 | on: vi.fn(), | |
| 4414 | off: vi.fn(), | |
| 4415 | ipc: { | |
| 4416 | on: vi.fn((channel: string, listener: typeof humanInput) => { | |
| 4417 | if (channel === "preview:human-input") humanInput = listener; | |
| 4418 | }), | |
| 4419 | off: vi.fn(), | |
| 4420 | }, | |
| 4421 | send: webviewSend, | |
| 4422 | navigationHistory: { canGoBack: () => false, canGoForward: () => false }, | |
| 4423 | setIgnoreMenuShortcuts: vi.fn(), | |
| 4424 | setWindowOpenHandler: vi.fn(), | |
| 4425 | debugger: { | |
| 4426 | isAttached: () => false, | |
| 4427 | attach: vi.fn(), | |
| 4428 | sendCommand, | |
| 4429 | on: vi.fn(), | |
| 4430 | off: vi.fn(), | |
| 4431 | }, | |
| 4432 | } as never); | |
| 4433 | ||
| 4434 | yield* manager.createTab("tab_input"); | |
| 4435 | yield* manager.registerWebview("tab_input", 42); | |
| 4436 | yield* manager.automationType("tab_input", { text: "hello", clear: true }); | |
| 4437 | yield* manager.automationType("tab_input", { text: "", clear: true }); | |
| 4438 | yield* manager.automationPress("tab_input", { key: "x" }); | |
| 4439 | ||
| 4440 | const calls = sendCommand.mock.calls; | |
| 4441 | const methods = calls.map(([method]) => method); | |
| 4442 | const enableIndex = methods.indexOf("Input.setIgnoreInputEvents"); | |
| 4443 | const focusOnIndex = calls.findIndex( | |
| 4444 | ([method, params]) => | |
| 4445 | method === "Emulation.setFocusEmulationEnabled" && params?.["enabled"] === true, | |
| 4446 | ); | |
| 4447 | const focusOffIndex = calls.findIndex( | |
| 4448 | ([method, params]) => | |
| 4449 | method === "Emulation.setFocusEmulationEnabled" && params?.["enabled"] === false, | |
| 4450 | ); | |
| 4451 | const typeEvaluation = sendCommand.mock.calls.find( | |
| 4452 | ([method, params]) => | |
| 4453 | method === "Runtime.evaluate" && | |
| 4454 | typeof params === "object" && | |
| 4455 | params !== null && | |
| 4456 | "expression" in params && | |
| 4457 | typeof params.expression === "string" && | |
| 4458 | params.expression.includes('document.execCommand("insertText"'), | |
| 4459 | ); | |
| 4460 | expect(typeEvaluation).toBeDefined(); | |
| 4461 | const clearOnlyEvaluation = sendCommand.mock.calls.find( | |
| 4462 | ([method, params]) => | |
| 4463 | method === "Runtime.evaluate" && | |
| 4464 | typeof params === "object" && | |
| 4465 | params !== null && | |
| 4466 | "expression" in params && | |
| 4467 | typeof params.expression === "string" && | |
| 4468 | params.expression.includes('const text = ""') && | |
| 4469 | params.expression.includes("Object.getOwnPropertyDescriptor"), | |
| 4470 | ); | |
| 4471 | expect(clearOnlyEvaluation).toBeDefined(); | |
| 4472 | expect(methods).not.toContain("Input.insertText"); | |
| 4473 | expect(enableIndex).toBeGreaterThanOrEqual(0); | |
| 4474 | expect(methods).not.toContain("Page.bringToFront"); | |
| 4475 | expect(methods).not.toContain("Input.dispatchKeyEvent"); | |
| 4476 | expect(enableIndex).toBeLessThan(focusOnIndex); | |
| 4477 | expect(sendCommand.mock.invocationCallOrder[focusOnIndex]).toBeLessThan( | |
| 4478 | sendInputEvent.mock.invocationCallOrder[0]!, | |
| 4479 | ); | |
| 4480 | expect(sendInputEvent.mock.invocationCallOrder[2]).toBeLessThan( | |
| 4481 | sendCommand.mock.invocationCallOrder[focusOffIndex]!, | |
| 4482 | ); | |
| 4483 | expect(sendInputEvent.mock.calls.map(([input]) => input.type)).toEqual([ | |
| 4484 | "keyDown", | |
| 4485 | "char", | |
| 4486 | "keyUp", | |
| 4487 | ]); | |
| 4488 | expect(sendCommand).toHaveBeenCalledWith("Input.setIgnoreInputEvents", { ignore: false }); | |
| 4489 | expect(listeners.size).toBe(0); | |
| 4490 | expect(animationFrames.size).toBe(0); | |
| 4491 | ||
| 4492 | sendCommand.mockClear(); | |
| 4493 | sendInputEvent.mockClear(); | |
| 4494 | getFocusedWebContents.mockReturnValue(null); | |
| 4495 | holdKeyUp = true; | |
| 4496 | const backgroundPress = yield* manager | |
| 4497 | .automationPress("tab_input", { key: "x" }) | |
| 4498 | .pipe(Effect.forkChild({ startImmediately: true })); | |
| 4499 | yield* Effect.promise(() => keyUpQueued); | |
| 4500 | expect(sendCommand).not.toHaveBeenCalledWith("Emulation.setFocusEmulationEnabled", { | |
| 4501 | enabled: false, | |
| 4502 | }); | |
| 4503 | renderFrame(); | |
| 4504 | expect(animationFrames.size).toBe(1); | |
| 4505 | releaseKeyUp?.(); | |
| 4506 | yield* Fiber.join(backgroundPress); | |
| 4507 | expect(listeners.size).toBe(0); | |
| 4508 | expect(animationFrames.size).toBe(0); | |
| 4509 | expect(sendCommand).toHaveBeenCalledWith("Emulation.setFocusEmulationEnabled", { | |
| 4510 | enabled: false, | |
| 4511 | }); | |
| 4512 | holdKeyUp = false; | |
| 4513 | ||
| 4514 | // Both native failures and expected-input matching must leave focus emulation off. | |
| 4515 | for (const key of ["y", "!"]) { | |
| 4516 | sendCommand.mockClear(); | |
| 4517 | sendInputEvent.mockClear(); | |
| 4518 | failKeyDown = key === "y"; | |
| 4519 | const exit = yield* Effect.exit(manager.automationPress("tab_input", { key })); | |
| 4520 | expect(Exit.isFailure(exit)).toBe(failKeyDown); | |
| 4521 | expect(sendInputEvent.mock.calls.map(([input]) => input.type)).toEqual( | |
| 4522 | failKeyDown ? ["keyDown", "keyUp"] : ["keyDown", "char", "keyUp"], | |
| 4523 | ); | |
| 4524 | expect(sendCommand).toHaveBeenCalledWith("Emulation.setFocusEmulationEnabled", { | |
| 4525 | enabled: false, | |
| 4526 | }); | |
| 4527 | } | |
| 4528 | ||
| 4529 | routeToIframe = true; | |
| 4530 | sendInputEvent.mockClear(); | |
| 4531 | for (const outcome of ["success", "failure", "interrupted"]) { | |
| 4532 | sendCommand.mockClear(); | |
| 4533 | failKeyDown = outcome === "failure"; | |
| 4534 | interruptFrameKeyDown = outcome === "interrupted"; | |
| 4535 | const exit = yield* Effect.exit( | |
| 4536 | manager.automationPress("tab_input", { | |
| 4537 | key: outcome === "success" ? "Enter" : "x", | |
| 4538 | }), | |
| 4539 | ); | |
| 4540 | expect(Exit.isSuccess(exit)).toBe(outcome === "success"); | |
| 4541 | if (outcome === "interrupted" && Exit.isFailure(exit)) { | |
| 4542 | expect(Option.getOrThrow(Cause.findErrorOption(exit.cause))).toMatchObject({ | |
| 4543 | _tag: "PreviewAutomationControlInterruptedError", | |
| 4544 | }); | |
| 4545 | } | |
| 4546 | expect(sendInputEvent).not.toHaveBeenCalled(); | |
| 4547 | expect(sendCommand).toHaveBeenCalledWith("Target.attachToTarget", { | |
| 4548 | targetId: "focused-frame", | |
| 4549 | flatten: true, | |
| 4550 | }); | |
| 4551 | expect( | |
| 4552 | sendCommand.mock.calls | |
| 4553 | .filter(([method]) => method === "Input.dispatchKeyEvent") | |
| 4554 | .map(([, params, sessionId]) => ({ type: params?.["type"], sessionId })), | |
| 4555 | ).toEqual([ | |
| 4556 | { type: "keyDown", sessionId: "child-session" }, | |
| 4557 | { type: "keyUp", sessionId: "child-session" }, | |
| 4558 | ]); | |
| 4559 | expect(sendCommand).toHaveBeenCalledWith( | |
| 4560 | "Emulation.setFocusEmulationEnabled", | |
| 4561 | { enabled: false }, | |
| 4562 | "child-session", | |
| 4563 | ); | |
| 4564 | expect(sendCommand).toHaveBeenCalledWith("Target.detachFromTarget", { | |
| 4565 | sessionId: "child-session", | |
| 4566 | }); | |
| 4567 | } | |
| 4568 | expect(focus).not.toHaveBeenCalled(); | |
| 4569 | expect(restoreFocus).not.toHaveBeenCalled(); | |
| 4570 | }), | |
| 4571 | ), | |
| 4572 | ); | |
| 4573 | ||
| 4574 | effectIt.effect("still interrupts agent control for a different human pointer event", () => | |
| 4575 | withManager((manager) => | |
| 4576 | Effect.gen(function* () { | |
| 4577 | let humanInput: ((_event: unknown, signal: unknown) => void) | undefined; | |
| 4578 | const sendCommand = vi.fn(async (method: string) => { | |
| 4579 | if (method === "Runtime.evaluate") { | |
| 4580 | return { | |
| 4581 | result: { | |
| 4582 | value: { width: 800, height: 600 }, | |
| 4583 | }, | |
| 4584 | }; | |
| 4585 | } | |
| 4586 | if (method === "Input.dispatchMouseEvent") { | |
| 4587 | humanInput?.({}, { kind: "pointer", x: 400, y: 300, button: 0 }); | |
| 4588 | } | |
| 4589 | return undefined; | |
| 4590 | }); | |
| 4591 | fromId.mockReturnValue({ | |
| 4592 | id: 42, | |
| 4593 | hostWebContents: makeTestHostWebContents(), | |
| 4594 | capturePage: vi.fn(async () => ({ toPNG: () => Buffer.from("frame") })), | |
| 4595 | setBackgroundThrottling: vi.fn(), | |
| 4596 | isDestroyed: () => false, | |
| 4597 | getType: () => "webview", | |
| 4598 | getURL: () => "https://example.com", | |
| 4599 | getTitle: () => "Example", | |
| 4600 | isLoading: () => false, | |
| 4601 | isDevToolsOpened: () => false, | |
| 4602 | getZoomFactor: () => 1, | |
| 4603 | setZoomFactor: vi.fn(), | |
| 4604 | setAudioMuted: vi.fn(), | |
| 4605 | isCurrentlyAudible: () => false, | |
| 4606 | on: vi.fn(), | |
| 4607 | off: vi.fn(), | |
| 4608 | ipc: { | |
| 4609 | on: vi.fn((channel: string, listener: typeof humanInput) => { | |
| 4610 | if (channel === "preview:human-input") humanInput = listener; | |
| 4611 | }), | |
| 4612 | off: vi.fn(), | |
| 4613 | }, | |
| 4614 | send: webviewSend, | |
| 4615 | navigationHistory: { canGoBack: () => false, canGoForward: () => false }, | |
| 4616 | setIgnoreMenuShortcuts: vi.fn(), | |
| 4617 | setWindowOpenHandler: vi.fn(), | |
| 4618 | debugger: { | |
| 4619 | isAttached: () => false, | |
| 4620 | attach: vi.fn(), | |
| 4621 | sendCommand, | |
| 4622 | on: vi.fn(), | |
| 4623 | off: vi.fn(), | |
| 4624 | }, | |
| 4625 | } as never); | |
| 4626 | ||
| 4627 | yield* manager.createTab("tab_1"); | |
| 4628 | yield* manager.registerWebview("tab_1", 42); | |
| 4629 | yield* manager.startRecording("tab_1"); | |
| 4630 | ||
| 4631 | const click = yield* manager | |
| 4632 | .automationClick("tab_1", { x: 120, y: 80 }) | |
| 4633 | .pipe(Effect.forkChild({ startImmediately: true })); | |
| 4634 | yield* TestClock.adjust(200); | |
| 4635 | const exit = yield* Fiber.await(click); | |
| 4636 | expect(Exit.isFailure(exit)).toBe(true); | |
| 4637 | expect( | |
| 4638 | webviewSend.mock.calls | |
| 4639 | .filter(([channel]) => channel === "preview:recording-controller") | |
| 4640 | .map(([, controller]) => controller), | |
| 4641 | ).toEqual(["agent", "human", "none"]); | |
| 4642 | yield* manager.stopRecording("tab_1"); | |
| 4643 | if (Exit.isSuccess(exit)) return; | |
| 4644 | const error = Option.getOrThrow(Cause.findErrorOption(exit.cause)); | |
| 4645 | expect(error).toMatchObject({ | |
| 4646 | _tag: "PreviewAutomationControlInterruptedError", | |
| 4647 | operation: "click", | |
| 4648 | tabId: "tab_1", | |
| 4649 | webContentsId: 42, | |
| 4650 | }); | |
| 4651 | expect(error).toBeInstanceOf(Error); | |
| 4652 | if (error instanceof Error) { | |
| 4653 | expect(error.name).toBe("PreviewAutomationControlInterruptedError"); | |
| 4654 | } | |
| 4655 | expect("cause" in error).toBe(false); | |
| 4656 | }), | |
| 4657 | ), | |
| 4658 | ); | |
| 4659 | ||
| 4660 | effectIt.effect( | |
| 4661 | "releases evaluation objects after success, exceptions, takeover, and cancellation", | |
| 4662 | () => | |
| 4663 | withManager((manager) => | |
| 4664 | Effect.gen(function* () { | |
| 4665 | const retained = new Map<string, number>(); | |
| 4666 | const pendingEvaluation = yield* Deferred.make<void>(); | |
| 4667 | const lateRelease = yield* Deferred.make<void>(); | |
| 4668 | let finishEvaluation: (() => void) | undefined; | |
| 4669 | let cancelledGroup: string | undefined; | |
| 4670 | let humanInput: ((event: unknown, signal: unknown) => void) | undefined; | |
| 4671 | const wc = makeTestPreviewWebContents(vi.fn()); | |
| 4672 | Object.assign(wc, { isDevToolsOpened: () => false }); | |
| 4673 | Object.assign(wc.ipc, { | |
| 4674 | on: vi.fn((channel: string, listener: typeof humanInput) => { | |
| 4675 | if (channel === "preview:human-input") humanInput = listener; | |
| 4676 | }), | |
| 4677 | }); | |
| 4678 | Object.assign(wc.debugger, { | |
| 4679 | sendCommand: vi.fn(async (method: string, params?: Record<string, unknown>) => { | |
| 4680 | const group = String(params?.objectGroup ?? ""); | |
| 4681 | if (method === "Runtime.releaseObjectGroup") { | |
| 4682 | retained.delete(group); | |
| 4683 | if (group === cancelledGroup) Deferred.doneUnsafe(lateRelease, Effect.void); | |
| 4684 | } | |
| 4685 | if (method !== "Runtime.evaluate") return undefined; | |
| 4686 | if (params?.expression === "cancelled") { | |
| 4687 | cancelledGroup = group; | |
| 4688 | Deferred.doneUnsafe(pendingEvaluation, Effect.void); | |
| 4689 | await new Promise<void>((resolve) => { | |
| 4690 | finishEvaluation = resolve; | |
| 4691 | }); | |
| 4692 | } | |
| 4693 | retained.set(group, 2); | |
| 4694 | if (params?.expression === "exception") { | |
| 4695 | return { | |
| 4696 | result: { objectId: "exception-result" }, | |
| 4697 | exceptionDetails: { | |
| 4698 | text: "failure", | |
| 4699 | exception: { objectId: "exception-detail" }, | |
| 4700 | }, | |
| 4701 | }; | |
| 4702 | } | |
| 4703 | if (params?.expression === "takeover") { | |
| 4704 | humanInput?.({}, { kind: "pointer", x: 1, y: 2, button: 0 }); | |
| 4705 | } | |
| 4706 | return { result: { value: 42 } }; | |
| 4707 | }), | |
| 4708 | }); | |
| 4709 | fromId.mockReturnValue(wc); | |
| 4710 | yield* manager.createTab("tab_1"); | |
| 4711 | yield* manager.registerWebview("tab_1", 42); | |
| 4712 | ||
| 4713 | expect(yield* manager.automationEvaluate("tab_1", { expression: "success" })).toBe(42); | |
| 4714 | expect(retained.size).toBe(0); | |
| 4715 | const failure = yield* Effect.exit( | |
| 4716 | manager.automationEvaluate("tab_1", { expression: "exception" }), | |
| 4717 | ); | |
| 4718 | expect(Exit.isFailure(failure)).toBe(true); | |
| 4719 | expect(retained.size).toBe(0); | |
| 4720 | const takeover = yield* Effect.exit( | |
| 4721 | manager.automationEvaluate("tab_1", { expression: "takeover" }), | |
| 4722 | ); | |
| 4723 | expect(Exit.isFailure(takeover)).toBe(true); | |
| 4724 | if (Exit.isFailure(takeover)) { | |
| 4725 | expect(Option.getOrThrow(Cause.findErrorOption(takeover.cause))).toMatchObject({ | |
| 4726 | _tag: "PreviewAutomationControlInterruptedError", | |
| 4727 | }); | |
| 4728 | } | |
| 4729 | expect(retained.size).toBe(0); | |
| 4730 | const cancelled = yield* manager | |
| 4731 | .automationEvaluate("tab_1", { expression: "cancelled" }) | |
| 4732 | .pipe(Effect.forkChild({ startImmediately: true })); | |
| 4733 | yield* Deferred.await(pendingEvaluation); | |
| 4734 | yield* Fiber.interrupt(cancelled); | |
| 4735 | finishEvaluation?.(); | |
| 4736 | yield* Deferred.await(lateRelease); | |
| 4737 | expect(retained.size).toBe(0); | |
| 4738 | }), | |
| 4739 | ), | |
| 4740 | ); | |
| 4741 | ||
| 4742 | effectIt.effect("derives evaluation detail kind and length from the same non-empty source", () => | |
| 4743 | withManager((manager) => | |
| 4744 | Effect.gen(function* () { | |
| 4745 | const text = "ReferenceError: fallbackDetail is not defined"; | |
| 4746 | const exceptionDetails = { | |
| 4747 | text, | |
| 4748 | exception: { description: "" }, | |
| 4749 | }; | |
| 4750 | const sendCommand = vi.fn(async (method: string) => | |
| 4751 | method === "Runtime.evaluate" ? { exceptionDetails } : undefined, | |
| 4752 | ); | |
| 4753 | fromId.mockReturnValue({ | |
| 4754 | id: 42, | |
| 4755 | isDestroyed: () => false, | |
| 4756 | getType: () => "webview", | |
| 4757 | getURL: () => "https://example.com", | |
| 4758 | getTitle: () => "Example", | |
| 4759 | isLoading: () => false, | |
| 4760 | isDevToolsOpened: () => false, | |
| 4761 | getZoomFactor: () => 1, | |
| 4762 | setZoomFactor: vi.fn(), | |
| 4763 | setAudioMuted: vi.fn(), | |
| 4764 | isCurrentlyAudible: () => false, | |
| 4765 | on: vi.fn(), | |
| 4766 | off: vi.fn(), | |
| 4767 | ipc: { on: vi.fn(), off: vi.fn() }, | |
| 4768 | send: webviewSend, | |
| 4769 | navigationHistory: { canGoBack: () => false, canGoForward: () => false }, | |
| 4770 | setIgnoreMenuShortcuts: vi.fn(), | |
| 4771 | setWindowOpenHandler: vi.fn(), | |
| 4772 | debugger: { | |
| 4773 | isAttached: () => false, | |
| 4774 | attach: vi.fn(), | |
| 4775 | sendCommand, | |
| 4776 | on: vi.fn(), | |
| 4777 | off: vi.fn(), | |
| 4778 | }, | |
| 4779 | } as never); | |
| 4780 | ||
| 4781 | yield* manager.createTab("tab_1"); | |
| 4782 | yield* manager.registerWebview("tab_1", 42); | |
| 4783 | const exit = yield* Effect.exit( | |
| 4784 | manager.automationEvaluate("tab_1", { expression: "fallbackDetail" }), | |
| 4785 | ); | |
| 4786 | ||
| 4787 | expect(Exit.isFailure(exit)).toBe(true); | |
| 4788 | if (Exit.isSuccess(exit)) return; | |
| 4789 | const error = Option.getOrThrow(Cause.findErrorOption(exit.cause)); | |
| 4790 | expect(error).toMatchObject({ | |
| 4791 | _tag: "PreviewAutomationEvaluationError", | |
| 4792 | detailKind: "exception-text", | |
| 4793 | detailLength: text.length, | |
| 4794 | cause: exceptionDetails, | |
| 4795 | }); | |
| 4796 | }), | |
| 4797 | ), | |
| 4798 | ); | |
| 4799 | effectIt.effect("saves downloads from agent-driven pages without a Save dialog", () => | |
| 4800 | withManager((manager) => | |
| 4801 | Effect.gen(function* () { | |
| 4802 | let humanInput: ((event: unknown, signal?: unknown) => void) | undefined; | |
| 4803 | const wc = makeTestPreviewWebContents(vi.fn()); | |
| 4804 | Object.assign(wc, { | |
| 4805 | isDevToolsOpened: () => false, | |
| 4806 | loadURL: vi.fn(async () => undefined), | |
| 4807 | reload: vi.fn(), | |
| 4808 | }); | |
| 4809 | Object.assign(wc.ipc, { | |
| 4810 | on: vi.fn((channel: string, listener: typeof humanInput) => { | |
| 4811 | if (channel === "preview:human-input") humanInput = listener; | |
| 4812 | }), | |
| 4813 | }); | |
| 4814 | let holdEvaluate = false; | |
| 4815 | let releaseEvaluate: (() => void) | undefined; | |
| 4816 | Object.assign(wc.debugger, { | |
| 4817 | sendCommand: vi.fn(async (method: string, params?: { expression?: string }) => { | |
| 4818 | if (method !== "Runtime.evaluate") return undefined; | |
| 4819 | if (params?.expression?.includes("matched")) | |
| 4820 | return { result: { value: { matched: true } } }; | |
| 4821 | if (holdEvaluate) { | |
| 4822 | holdEvaluate = false; | |
| 4823 | await new Promise<void>((resolve) => { | |
| 4824 | releaseEvaluate = resolve; | |
| 4825 | }); | |
| 4826 | } | |
| 4827 | return { result: { value: 42 } }; | |
| 4828 | }), | |
| 4829 | }); | |
| 4830 | fromId.mockReturnValue(wc); | |
| 4831 | const takeovers = [yield* Deferred.make<void>(), yield* Deferred.make<void>()]; | |
| 4832 | let takeoverCount = 0; | |
| 4833 | let controller = "none"; | |
| 4834 | yield* manager.subscribeStateChanges((_tabId, state) => { | |
| 4835 | const takeover = | |
| 4836 | controller !== "human" && state.controller === "human" | |
| 4837 | ? takeovers[takeoverCount++] | |
| 4838 | : undefined; | |
| 4839 | controller = state.controller; | |
| 4840 | return takeover ? Deferred.succeed(takeover, undefined).pipe(Effect.asVoid) : Effect.void; | |
| 4841 | }); | |
| 4842 | yield* manager.getBrowserSession(); | |
| 4843 | yield* manager.getBrowserSession(); | |
| 4844 | const installs = previewSession.on.mock.calls.filter( | |
| 4845 | ([event]) => event === "will-download", | |
| 4846 | ); | |
| 4847 | expect(installs).toHaveLength(1); | |
| 4848 | const willDownload = installs[0]![1] as ( | |
| 4849 | event: unknown, | |
| 4850 | item: { | |
| 4851 | getFilename: () => string; | |
| 4852 | getStartTime: () => number; | |
| 4853 | setSavePath: (path: string) => void; | |
| 4854 | }, | |
| 4855 | source: Electron.WebContents, | |
| 4856 | ) => void; | |
| 4857 | const download = () => { | |
| 4858 | const setSavePath = vi.fn(); | |
| 4859 | willDownload( | |
| 4860 | {}, | |
| 4861 | { getFilename: () => "chart.png", getStartTime: () => 1_790_844_530.5, setSavePath }, | |
| 4862 | wc, | |
| 4863 | ); | |
| 4864 | return setSavePath; | |
| 4865 | }; | |
| 4866 | yield* manager.createTab("tab_1"); | |
| 4867 | yield* manager.registerWebview("tab_1", 42); | |
| 4868 | ||
| 4869 | expect(download()).not.toHaveBeenCalled(); | |
| 4870 | ||
| 4871 | yield* manager.automationEvaluate("tab_1", { expression: "42" }); | |
| 4872 | const startedAt = (1_790_844_530_500).toString(36); | |
| 4873 | expect(download()).toHaveBeenCalledWith( | |
| 4874 | `/tmp/t3/dev/browser-artifacts/browser-download-${startedAt}-0-chart.png`, | |
| 4875 | ); | |
| 4876 | // Same name, same millisecond: still a separate file. | |
| 4877 | expect(download()).toHaveBeenCalledWith( | |
| 4878 | `/tmp/t3/dev/browser-artifacts/browser-download-${startedAt}-1-chart.png`, | |
| 4879 | ); | |
| 4880 | ||
| 4881 | humanInput?.({}, { kind: "pointer", x: 10, y: 10, button: 0 }); | |
| 4882 | yield* Deferred.await(takeovers[0]!); | |
| 4883 | expect(download()).not.toHaveBeenCalled(); | |
| 4884 | ||
| 4885 | // An action still waiting for the page when the human takes over must | |
| 4886 | // not mark it as agent-driven again. | |
| 4887 | holdEvaluate = true; | |
| 4888 | const running = yield* manager | |
| 4889 | .automationEvaluate("tab_1", { expression: "42" }) | |
| 4890 | .pipe(Effect.forkChild({ startImmediately: true })); | |
| 4891 | yield* settle(() => releaseEvaluate !== undefined); | |
| 4892 | const queued = yield* manager | |
| 4893 | .automationEvaluate("tab_1", { expression: "42" }) | |
| 4894 | .pipe(Effect.forkChild({ startImmediately: true })); | |
| 4895 | humanInput?.({}, { kind: "pointer", x: 20, y: 20, button: 0 }); | |
| 4896 | yield* Deferred.await(takeovers[1]!); | |
| 4897 | releaseEvaluate?.(); | |
| 4898 | yield* Fiber.await(running); | |
| 4899 | expect(Exit.isFailure(yield* Fiber.await(queued))).toBe(true); | |
| 4900 | expect(download()).not.toHaveBeenCalled(); | |
| 4901 | ||
| 4902 | // URL-bar navigation hands the page back to the human. | |
| 4903 | yield* manager.automationEvaluate("tab_1", { expression: "42" }); | |
| 4904 | expect(download()).toHaveBeenCalled(); | |
| 4905 | yield* manager.navigate("tab_1", "https://example.com/report.csv"); | |
| 4906 | expect(download()).not.toHaveBeenCalled(); | |
| 4907 | ||
| 4908 | // Reading the page does not make it agent-driven. | |
| 4909 | yield* manager.automationWaitFor("tab_1", { text: "Example" }); | |
| 4910 | expect(download()).not.toHaveBeenCalled(); | |
| 4911 | }), | |
| 4912 | ), | |
| 4913 | ); | |
| 4914 | 4132 | }); |
| 4915 | 4133 | |
| 4916 | 4134 | describe("PreviewOperationError", () => { |
| ... | ... | @@ -4927,89 +4145,3 @@ describe("PreviewOperationError", () => { |
| 4927 | 4145 | expect(PreviewManager.PreviewOperationError.toTimelineMessage(error)).toBe(cause.message); |
| 4928 | 4146 | }); |
| 4929 | 4147 | }); |
| 4930 | ||
| 4931 | describe("Preview automation diagnostics", () => { | |
| 4932 | it("keeps browser exception detail out of structural diagnostics", () => { | |
| 4933 | const secret = "unrelated-browser-payload-secret"; | |
| 4934 | const detail = "ReferenceError: missingValue is not defined"; | |
| 4935 | const cause = { | |
| 4936 | text: "Uncaught Error", | |
| 4937 | exception: { description: detail }, | |
| 4938 | unsafePayload: secret, | |
| 4939 | }; | |
| 4940 | const error = new PreviewManager.PreviewAutomationEvaluationError({ | |
| 4941 | tabId: "tab_1", | |
| 4942 | detailKind: "exception-description", | |
| 4943 | detailLength: detail.length, | |
| 4944 | cause, | |
| 4945 | }); | |
| 4946 | ||
| 4947 | const encoded = encodePreviewManagerError(error); | |
| 4948 | const { cause: encodedCause, ...encodedDiagnostics } = encoded as typeof encoded & { | |
| 4949 | readonly cause?: unknown; | |
| 4950 | }; | |
| 4951 | ||
| 4952 | expect(error.cause).toBe(cause); | |
| 4953 | expect(encodedCause).toStrictEqual(cause); | |
| 4954 | expect(error.message).toBe("Preview JavaScript evaluation failed in tab tab_1"); | |
| 4955 | expect(error.message).not.toContain(secret); | |
| 4956 | expect(JSON.stringify(encodedDiagnostics)).not.toContain(secret); | |
| 4957 | expect("detail" in error).toBe(false); | |
| 4958 | expect(PreviewManager.PreviewAutomationEvaluationError.toTimelineMessage(error)).toBe(detail); | |
| 4959 | expect(PreviewManager.PreviewAutomationEvaluationError.toTimelineMessage(error)).not.toContain( | |
| 4960 | secret, | |
| 4961 | ); | |
| 4962 | }); | |
| 4963 | ||
| 4964 | it("retains bounded selector diagnostics without exposing selector or reason text", () => { | |
| 4965 | const selector = "role=button[name='selector-secret']"; | |
| 4966 | const reason = "Unexpected token near reason-secret"; | |
| 4967 | const cause = { invalidSelector: true as const, message: reason }; | |
| 4968 | const error = new PreviewManager.PreviewAutomationInvalidSelectorError({ | |
| 4969 | operation: "click", | |
| 4970 | tabId: "tab_1", | |
| 4971 | selectorKind: "locator", | |
| 4972 | selectorLength: selector.length, | |
| 4973 | reasonLength: reason.length, | |
| 4974 | cause, | |
| 4975 | }); | |
| 4976 | ||
| 4977 | const encoded = encodePreviewManagerError(error); | |
| 4978 | const { cause: encodedCause, ...encodedDiagnostics } = encoded as typeof encoded & { | |
| 4979 | readonly cause?: unknown; | |
| 4980 | }; | |
| 4981 | ||
| 4982 | expect(error.cause).toBe(cause); | |
| 4983 | expect(encodedCause).toStrictEqual(cause); | |
| 4984 | expect(error).toMatchObject({ | |
| 4985 | selectorKind: "locator", | |
| 4986 | selectorLength: selector.length, | |
| 4987 | reasonLength: reason.length, | |
| 4988 | }); | |
| 4989 | expect(error.detail).toEqual({ | |
| 4990 | selectorKind: "locator", | |
| 4991 | selectorLength: selector.length, | |
| 4992 | }); | |
| 4993 | expect(error.message).not.toContain("secret"); | |
| 4994 | expect(JSON.stringify(encodedDiagnostics)).not.toContain("secret"); | |
| 4995 | expect("selector" in error).toBe(false); | |
| 4996 | expect("reason" in error).toBe(false); | |
| 4997 | expect(PreviewManager.PreviewAutomationInvalidSelectorError.toTimelineMessage(error)).toBe( | |
| 4998 | reason, | |
| 4999 | ); | |
| 5000 | }); | |
| 5001 | ||
| 5002 | it("does not retain a missing target locator", () => { | |
| 5003 | const selector = "[data-token='target-secret']"; | |
| 5004 | const error = new PreviewManager.PreviewAutomationTargetNotFoundError({ | |
| 5005 | operation: "scroll", | |
| 5006 | tabId: "tab_1", | |
| 5007 | selectorKind: "selector", | |
| 5008 | selectorLength: selector.length, | |
| 5009 | }); | |
| 5010 | ||
| 5011 | expect(error.message).not.toContain(selector); | |
| 5012 | expect(JSON.stringify(error)).not.toContain(selector); | |
| 5013 | expect("locator" in error).toBe(false); | |
| 5014 | }); | |
| 5015 | }); |
apps/desktop/src/preview/Manager.ts+107-1737| ... | ... | @@ -11,7 +11,6 @@ import { |
| 11 | 11 | } from "@t3tools/contracts"; |
| 12 | 12 | import type { |
| 13 | 13 | DesktopPreviewAnnotationTheme, |
| 14 | DesktopPreviewAutomationStatus, | |
| 15 | 14 | DesktopPreviewColorScheme, |
| 16 | 15 | DesktopPreviewFavicon, |
| 17 | 16 | DesktopPreviewPointerEvent, |
| ... | ... | @@ -23,16 +22,6 @@ import type { |
| 23 | 22 | DesktopPreviewRecordingInputEvent, |
| 24 | 23 | DesktopPreviewScreenshotArtifact, |
| 25 | 24 | DesktopPreviewTabDefaults, |
| 26 | PreviewAutomationClickInput, | |
| 27 | PreviewAutomationActionEvent, | |
| 28 | PreviewAutomationConsoleEntry, | |
| 29 | PreviewAutomationEvaluateInput, | |
| 30 | PreviewAutomationPressInput, | |
| 31 | PreviewAutomationNetworkEntry, | |
| 32 | PreviewAutomationScrollInput, | |
| 33 | PreviewAutomationSnapshot, | |
| 34 | PreviewAutomationTypeInput, | |
| 35 | PreviewAutomationWaitForInput, | |
| 36 | 25 | } from "@t3tools/contracts"; |
| 37 | 26 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 38 | 27 | import { normalizePreviewUrl } from "@t3tools/shared/preview"; |
| ... | ... | @@ -48,7 +37,6 @@ import { |
| 48 | 37 | import * as Cause from "effect/Cause"; |
| 49 | 38 | import * as Clock from "effect/Clock"; |
| 50 | 39 | import * as Context from "effect/Context"; |
| 51 | import * as Crypto from "effect/Crypto"; | |
| 52 | 40 | import * as DateTime from "effect/DateTime"; |
| 53 | 41 | import * as Deferred from "effect/Deferred"; |
| 54 | 42 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -62,6 +50,7 @@ import * as Queue from "effect/Queue"; |
| 62 | 50 | import * as Ref from "effect/Ref"; |
| 63 | 51 | import * as Schema from "effect/Schema"; |
| 64 | 52 | import * as Semaphore from "effect/Semaphore"; |
| 53 | import * as Stream from "effect/Stream"; | |
| 65 | 54 | import * as Schedule from "effect/Schedule"; |
| 66 | 55 | import * as Scope from "effect/Scope"; |
| 67 | 56 | import * as SynchronizedRef from "effect/SynchronizedRef"; |
| ... | ... | @@ -69,6 +58,7 @@ import * as SynchronizedRef from "effect/SynchronizedRef"; |
| 69 | 58 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 70 | 59 | import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts"; |
| 71 | 60 | import { PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL } from "../ipc/channels.ts"; |
| 61 | import * as DesktopBrowserHost from "./DesktopBrowserHost.ts"; | |
| 72 | 62 | import * as BrowserSession from "./BrowserSession.ts"; |
| 73 | 63 | import { |
| 74 | 64 | ANNOTATION_CAPTURED_CHANNEL, |
| ... | ... | @@ -78,19 +68,12 @@ import { |
| 78 | 68 | HUMAN_INPUT_CHANNEL, |
| 79 | 69 | MOUSE_NAVIGATE_CHANNEL, |
| 80 | 70 | RECORDING_CURSOR_CHANNEL, |
| 81 | RECORDING_POINTER_CHANNEL, | |
| 82 | RECORDING_KEY_CHANNEL, | |
| 83 | 71 | RECORDING_INPUT_CHANNEL, |
| 84 | 72 | RECORDING_CONTROLLER_CHANNEL, |
| 73 | RECORDING_POINTER_CHANNEL, | |
| 85 | 74 | START_PICK_CHANNEL, |
| 86 | 75 | } from "./GuestProtocol.ts"; |
| 87 | 76 | import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts"; |
| 88 | import { playwrightInjectedRuntimeInstallExpression } from "./PlaywrightInjectedRuntime.ts"; | |
| 89 | import { | |
| 90 | makePreviewAutomationKeySequence, | |
| 91 | makePreviewAutomationNativeKeySequence, | |
| 92 | previewAutomationEditingCommandExpression, | |
| 93 | } from "./PreviewKeyboard.ts"; | |
| 94 | 77 | import { captureFavicon, safeHttpOrigin, selectFaviconCandidates } from "./FaviconCapture.ts"; |
| 95 | 78 | import { DEFAULT_RECORDING_INPUT_OPTIONS, type RecordingInputOptions } from "./RecordingInput.ts"; |
| 96 | 79 | |
| ... | ... | @@ -121,6 +104,8 @@ export interface PreviewTabState { |
| 121 | 104 | audible: boolean; |
| 122 | 105 | controller: "human" | "agent" | "none"; |
| 123 | 106 | favicon?: DesktopPreviewFavicon; |
| 107 | /** Set for a tab of the desktop's own server, which drives it over the browser channel. */ | |
| 108 | serverTab?: { readonly threadId: string; readonly tabId: string }; | |
| 124 | 109 | updatedAt: string; |
| 125 | 110 | } |
| 126 | 111 | |
| ... | ... | @@ -131,16 +116,11 @@ const ZOOM_LEVELS: ReadonlyArray<number> = [ |
| 131 | 116 | |
| 132 | 117 | const DEFAULT_ZOOM_FACTOR = 1.0; |
| 133 | 118 | const ZOOM_EPSILON = 0.001; |
| 134 | const MAX_EVALUATION_BYTES = 64_000; | |
| 135 | const MAX_VISIBLE_TEXT_LENGTH = 20_000; | |
| 136 | const MAX_INTERACTIVE_ELEMENTS = 200; | |
| 137 | 119 | /** |
| 138 | 120 | * A `[role]` container's innerText is its whole subtree, which turned one |
| 139 | 121 | * snapshot's element list into 60 KB of repeated page text. Names are labels, |
| 140 | 122 | * not content, so cap them where they are read. |
| 141 | 123 | */ |
| 142 | const MAX_INTERACTIVE_ELEMENT_NAME_LENGTH = 200; | |
| 143 | const MAX_SCREENSHOT_WIDTH = 1280; | |
| 144 | 124 | /** How long an armed tab keeps the exclusive display-media slot before another tab may take it. */ |
| 145 | 125 | const RECORDING_ARM_GRACE_MS = 10_000; |
| 146 | 126 | const PICTURE_IN_PICTURE_FRAME_INTERVAL_MS = Math.ceil(1_000 / 12); |
| ... | ... | @@ -157,13 +137,9 @@ const PICTURE_IN_PICTURE_INITIAL_HEIGHT = 320; |
| 157 | 137 | const PICTURE_IN_PICTURE_MIN_WIDTH = 240; |
| 158 | 138 | const PICTURE_IN_PICTURE_MIN_HEIGHT = 160; |
| 159 | 139 | const PICTURE_IN_PICTURE_ASPECT_RATIO_EPSILON = 0.002; |
| 160 | const DIAGNOSTIC_BUFFER_LIMIT = 200; | |
| 161 | 140 | const MAX_ARTIFACT_SITE_SLUG_LENGTH = 80; |
| 162 | const AGENT_CURSOR_MOVE_MS = 160; | |
| 163 | const AGENT_CURSOR_CLICK_LEAD_MS = 40; | |
| 164 | 141 | const requestRecordingCaptureExpression = (tabId: string): string => |
| 165 | 142 | `globalThis[${JSON.stringify(DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER)}]?.(${JSON.stringify(tabId)}) === true`; |
| 166 | const encodeUnknownJson = Schema.encodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); | |
| 167 | 143 | const DEFAULT_ANNOTATION_THEME: DesktopPreviewAnnotationTheme = { |
| 168 | 144 | colorScheme: "light", |
| 169 | 145 | radius: "0.625rem", |
| ... | ... | @@ -253,17 +229,6 @@ const artifactSiteSlug = (rawUrl: string): string => { |
| 253 | 229 | } |
| 254 | 230 | }; |
| 255 | 231 | |
| 256 | interface 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 | ||
| 267 | 232 | export const PreviewAutomationSelectorKind = Schema.Literals([ |
| 268 | 233 | "focused-element", |
| 269 | 234 | "selector", |
| ... | ... | @@ -279,36 +244,6 @@ export const PreviewAutomationEvaluationDetailKind = Schema.Literals([ |
| 279 | 244 | export type PreviewAutomationEvaluationDetailKind = |
| 280 | 245 | typeof PreviewAutomationEvaluationDetailKind.Type; |
| 281 | 246 | |
| 282 | const 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 | ||
| 304 | const previewAutomationTargetLabel = ( | |
| 305 | selectorKind: PreviewAutomationSelectorKind, | |
| 306 | selectorLength?: number, | |
| 307 | ) => | |
| 308 | selectorKind === "focused-element" | |
| 309 | ? "the focused element" | |
| 310 | : `${selectorKind} (${selectorLength ?? 0} characters)`; | |
| 311 | ||
| 312 | 247 | interface PreviewOperationContext { |
| 313 | 248 | readonly operation: string; |
| 314 | 249 | readonly tabId?: string; |
| ... | ... | @@ -492,23 +427,12 @@ interface BrowserControlSession { |
| 492 | 427 | ) => void; |
| 493 | 428 | } |
| 494 | 429 | |
| 495 | interface BrowserDiagnostics { | |
| 496 | readonly consoleEntries: ReadonlyArray<PreviewAutomationConsoleEntry>; | |
| 497 | readonly networkEntries: ReadonlyArray<PreviewAutomationNetworkEntry>; | |
| 498 | readonly requests: ReadonlyMap<string, { url: string; method: string }>; | |
| 499 | } | |
| 500 | ||
| 501 | 430 | const isRecordingInput = Schema.is(DesktopPreviewRecordingInputSchema); |
| 502 | 431 | |
| 503 | 432 | type RecordingInputListener = (event: DesktopPreviewRecordingInputEvent) => Effect.Effect<void>; |
| 504 | 433 | |
| 505 | 434 | type PointerEventListener = (event: DesktopPreviewPointerEvent) => Effect.Effect<void>; |
| 506 | 435 | |
| 507 | interface ExpectedAgentInput { | |
| 508 | readonly signal: PreviewInputSignal; | |
| 509 | readonly expiresAt: number; | |
| 510 | } | |
| 511 | ||
| 512 | 436 | /** |
| 513 | 437 | * Protocols a preview page may open in a real popup window. |
| 514 | 438 | * |
| ... | ... | @@ -522,9 +446,6 @@ interface ExpectedAgentInput { |
| 522 | 446 | */ |
| 523 | 447 | const POPUP_PROTOCOLS = new Set(["http:", "https:"]); |
| 524 | 448 | |
| 525 | /** Control actions that only read the page, so they do not make it agent-driven. */ | |
| 526 | const READ_ONLY_CONTROL_ACTIONS = new Set(["snapshot", "waitFor"]); | |
| 527 | ||
| 528 | 449 | const isPopupUrl = (rawUrl: string): boolean => { |
| 529 | 450 | try { |
| 530 | 451 | return POPUP_PROTOCOLS.has(new URL(rawUrl).protocol); |
| ... | ... | @@ -618,23 +539,6 @@ const isPreviewInputSignal = (value: unknown): value is PreviewInputSignal => { |
| 618 | 539 | ); |
| 619 | 540 | }; |
| 620 | 541 | |
| 621 | const 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 | ||
| 638 | 542 | const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function* ( |
| 639 | 543 | artifactDirectory: string, |
| 640 | 544 | pictureInPicturePreloadPath: string, |
| ... | ... | @@ -643,14 +547,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 643 | 547 | const rendererHistory = yield* DesktopRendererHistory.DesktopRendererHistory; |
| 644 | 548 | const hostPlatform = yield* HostProcessPlatform; |
| 645 | 549 | const path = yield* Path.Path; |
| 646 | const crypto = yield* Crypto.Crypto; | |
| 647 | 550 | const parentScope = yield* Scope.Scope; |
| 551 | const browserHost = yield* DesktopBrowserHost.DesktopBrowserHost; | |
| 648 | 552 | const context = yield* Effect.context<never>(); |
| 649 | 553 | const runFork = Effect.runForkWith(context); |
| 650 | 554 | const resolvedArtifactDirectory = path.resolve(artifactDirectory); |
| 651 | const playwrightInstallExpression = yield* Effect.cached( | |
| 652 | playwrightInjectedRuntimeInstallExpression(), | |
| 653 | ); | |
| 654 | 555 | |
| 655 | 556 | const annotationThemeRef = yield* Ref.make(DEFAULT_ANNOTATION_THEME); |
| 656 | 557 | const mainWindowRef = yield* Ref.make<Option.Option<BrowserWindow>>(Option.none()); |
| ... | ... | @@ -668,16 +569,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 668 | 569 | const controlSessionsRef = yield* SynchronizedRef.make< |
| 669 | 570 | ReadonlyMap<number, BrowserControlSession> |
| 670 | 571 | >(new Map()); |
| 671 | const diagnosticsRef = yield* Ref.make<ReadonlyMap<number, BrowserDiagnostics>>(new Map()); | |
| 672 | const expectedAgentInputsRef = yield* Ref.make< | |
| 673 | ReadonlyMap<string, ReadonlyArray<ExpectedAgentInput>> | |
| 674 | >(new Map()); | |
| 675 | const controlEpochRef = yield* Ref.make<ReadonlyMap<string, number>>(new Map()); | |
| 676 | const actionTimelineRef = yield* Ref.make< | |
| 677 | ReadonlyMap<string, ReadonlyArray<PreviewAutomationActionEvent>> | |
| 678 | >(new Map()); | |
| 679 | const actionSequenceRef = yield* Ref.make(0); | |
| 680 | const pointerSequenceRef = yield* Ref.make(0); | |
| 681 | 572 | const frameCaptureSessionsRef = yield* SynchronizedRef.make< |
| 682 | 573 | ReadonlyMap<string, FrameCaptureSession> |
| 683 | 574 | >(new Map()); |
| ... | ... | @@ -692,10 +583,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 692 | 583 | // (electron#44618) and now always rejects with NotAllowedError. |
| 693 | 584 | let pendingRecording: PendingRecording | null = null; |
| 694 | 585 | const displayMediaHandlerSessions = new WeakSet<Session>(); |
| 695 | const downloadHandlerSessions = new WeakSet<Session>(); | |
| 696 | let downloadCount = 0; | |
| 697 | // Preview pages whose latest input came from an agent action, not the human. | |
| 698 | const agentDrivenWebContents = new WeakSet<Electron.WebContents>(); | |
| 699 | 586 | let frameCaptureWindowOpen = true; |
| 700 | 587 | let currentMainWindow: BrowserWindow | undefined; |
| 701 | 588 | let mainWindowCleanupFiber: Fiber.Fiber<void, never> | undefined; |
| ... | ... | @@ -756,12 +643,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 756 | 643 | }); |
| 757 | 644 | const currentIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); |
| 758 | 645 | const currentMillis = Clock.currentTimeMillis; |
| 759 | const encodeJson = (errorContext: PreviewOperationContext, value: unknown) => | |
| 760 | encodeUnknownJson(value).pipe( | |
| 761 | Effect.mapError((cause) => new PreviewOperationError({ ...errorContext, cause })), | |
| 762 | ); | |
| 763 | const nextCounter = (ref: Ref.Ref<number>) => | |
| 764 | Ref.modify(ref, (value) => [value, value + 1] as const); | |
| 765 | 646 | const replaceMap = <K, V>( |
| 766 | 647 | source: ReadonlyMap<K, V>, |
| 767 | 648 | update: (copy: Map<K, V>) => void, |
| ... | ... | @@ -1143,139 +1024,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1143 | 1024 | ); |
| 1144 | 1025 | }); |
| 1145 | 1026 | |
| 1146 | const pushBounded = <A>(buffer: ReadonlyArray<A>, entry: A): ReadonlyArray<A> => | |
| 1147 | [...buffer, entry].slice(-DIAGNOSTIC_BUFFER_LIMIT); | |
| 1148 | ||
| 1149 | const captureDiagnosticMessage = Effect.fnUntraced(function* ( | |
| 1150 | webContentsId: number, | |
| 1151 | method: string, | |
| 1152 | params: Record<string, unknown>, | |
| 1153 | ) { | |
| 1154 | const timestamp = yield* currentIso; | |
| 1155 | yield* Ref.update(diagnosticsRef, (allDiagnostics) => { | |
| 1156 | const current = allDiagnostics.get(webContentsId); | |
| 1157 | if (!current) return allDiagnostics; | |
| 1158 | const requestId = typeof params["requestId"] === "string" ? params["requestId"] : null; | |
| 1159 | const next = (() => { | |
| 1160 | if (method === "Runtime.consoleAPICalled") { | |
| 1161 | const args = Array.isArray(params["args"]) ? params["args"] : []; | |
| 1162 | const text = args | |
| 1163 | .map((arg) => { | |
| 1164 | if (typeof arg !== "object" || arg === null) return String(arg); | |
| 1165 | const value = arg as Record<string, unknown>; | |
| 1166 | return String(value["value"] ?? value["description"] ?? ""); | |
| 1167 | }) | |
| 1168 | .join(" "); | |
| 1169 | return { | |
| 1170 | ...current, | |
| 1171 | consoleEntries: pushBounded(current.consoleEntries, { | |
| 1172 | level: typeof params["type"] === "string" ? params["type"] : "log", | |
| 1173 | text, | |
| 1174 | timestamp, | |
| 1175 | source: "console", | |
| 1176 | }), | |
| 1177 | }; | |
| 1178 | } | |
| 1179 | if (method === "Runtime.exceptionThrown") { | |
| 1180 | const details = | |
| 1181 | typeof params["exceptionDetails"] === "object" && params["exceptionDetails"] !== null | |
| 1182 | ? (params["exceptionDetails"] as Record<string, unknown>) | |
| 1183 | : {}; | |
| 1184 | return { | |
| 1185 | ...current, | |
| 1186 | consoleEntries: pushBounded(current.consoleEntries, { | |
| 1187 | level: "error", | |
| 1188 | text: String(details["text"] ?? "Uncaught exception"), | |
| 1189 | timestamp, | |
| 1190 | source: "exception", | |
| 1191 | }), | |
| 1192 | }; | |
| 1193 | } | |
| 1194 | if (method === "Log.entryAdded") { | |
| 1195 | const entry = | |
| 1196 | typeof params["entry"] === "object" && params["entry"] !== null | |
| 1197 | ? (params["entry"] as Record<string, unknown>) | |
| 1198 | : {}; | |
| 1199 | return { | |
| 1200 | ...current, | |
| 1201 | consoleEntries: pushBounded(current.consoleEntries, { | |
| 1202 | level: typeof entry["level"] === "string" ? entry["level"] : "info", | |
| 1203 | text: String(entry["text"] ?? ""), | |
| 1204 | timestamp, | |
| 1205 | source: typeof entry["source"] === "string" ? entry["source"] : "log", | |
| 1206 | }), | |
| 1207 | }; | |
| 1208 | } | |
| 1209 | if (method === "Network.requestWillBeSent" && requestId) { | |
| 1210 | const request = | |
| 1211 | typeof params["request"] === "object" && params["request"] !== null | |
| 1212 | ? (params["request"] as Record<string, unknown>) | |
| 1213 | : {}; | |
| 1214 | return { | |
| 1215 | ...current, | |
| 1216 | requests: replaceMap(current.requests, (copy) => { | |
| 1217 | copy.set(requestId, { | |
| 1218 | url: String(request["url"] ?? ""), | |
| 1219 | method: String(request["method"] ?? "GET"), | |
| 1220 | }); | |
| 1221 | }), | |
| 1222 | }; | |
| 1223 | } | |
| 1224 | if (method === "Network.responseReceived" && requestId) { | |
| 1225 | const request = current.requests.get(requestId); | |
| 1226 | const response = | |
| 1227 | typeof params["response"] === "object" && params["response"] !== null | |
| 1228 | ? (params["response"] as Record<string, unknown>) | |
| 1229 | : {}; | |
| 1230 | const status = typeof response["status"] === "number" ? response["status"] : null; | |
| 1231 | return request && status !== null && status >= 400 | |
| 1232 | ? { | |
| 1233 | ...current, | |
| 1234 | networkEntries: pushBounded(current.networkEntries, { | |
| 1235 | ...request, | |
| 1236 | status, | |
| 1237 | failed: true, | |
| 1238 | timestamp, | |
| 1239 | }), | |
| 1240 | } | |
| 1241 | : current; | |
| 1242 | } | |
| 1243 | if (method === "Network.loadingFailed" && requestId) { | |
| 1244 | const request = current.requests.get(requestId); | |
| 1245 | return { | |
| 1246 | ...current, | |
| 1247 | requests: replaceMap(current.requests, (copy) => { | |
| 1248 | copy.delete(requestId); | |
| 1249 | }), | |
| 1250 | networkEntries: request | |
| 1251 | ? pushBounded(current.networkEntries, { | |
| 1252 | ...request, | |
| 1253 | status: null, | |
| 1254 | failed: true, | |
| 1255 | errorText: String(params["errorText"] ?? "Network request failed"), | |
| 1256 | timestamp, | |
| 1257 | }) | |
| 1258 | : current.networkEntries, | |
| 1259 | }; | |
| 1260 | } | |
| 1261 | if (method === "Network.loadingFinished" && requestId) { | |
| 1262 | return { | |
| 1263 | ...current, | |
| 1264 | requests: replaceMap(current.requests, (copy) => { | |
| 1265 | copy.delete(requestId); | |
| 1266 | }), | |
| 1267 | }; | |
| 1268 | } | |
| 1269 | return current; | |
| 1270 | })(); | |
| 1271 | return replaceMap(allDiagnostics, (copy) => { | |
| 1272 | copy.set(webContentsId, next); | |
| 1273 | }); | |
| 1274 | }); | |
| 1275 | }); | |
| 1276 | ||
| 1277 | 1027 | const detachControlSession = Effect.fn("PreviewManager.detachControlSession")(function* ( |
| 1278 | 1028 | webContentsId: number, |
| 1029 | /** The server tab it rendered, when the caller already took the tab out of `tabsRef`. */ | |
| 1030 | closedServerTab?: PreviewTabState["serverTab"], | |
| 1279 | 1031 | ) { |
| 1280 | 1032 | const control = yield* SynchronizedRef.modify(controlSessionsRef, (sessions) => [ |
| 1281 | 1033 | sessions.get(webContentsId), |
| ... | ... | @@ -1284,14 +1036,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1284 | 1036 | }), |
| 1285 | 1037 | ]); |
| 1286 | 1038 | if (control) { |
| 1039 | // The server can only drive a tab while the desktop holds its debugger. | |
| 1040 | if (closedServerTab) browserHost.detach(closedServerTab); | |
| 1041 | for (const tab of (yield* SynchronizedRef.get(tabsRef)).values()) { | |
| 1042 | if (tab.webContentsId === webContentsId && tab.serverTab) browserHost.detach(tab.serverTab); | |
| 1043 | } | |
| 1287 | 1044 | yield* Scope.close(control.scope, Exit.void).pipe(Effect.ignore); |
| 1288 | return; | |
| 1289 | 1045 | } |
| 1290 | yield* Ref.update(diagnosticsRef, (diagnostics) => | |
| 1291 | replaceMap(diagnostics, (copy) => { | |
| 1292 | copy.delete(webContentsId); | |
| 1293 | }), | |
| 1294 | ); | |
| 1295 | 1046 | }); |
| 1296 | 1047 | |
| 1297 | 1048 | const ensureControlSession = Effect.fn("PreviewManager.ensureControlSession")(function* ( |
| ... | ... | @@ -1400,7 +1151,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1400 | 1151 | } |
| 1401 | 1152 | } |
| 1402 | 1153 | } |
| 1403 | yield* captureDiagnosticMessage(wc.id, method, params); | |
| 1404 | 1154 | if (method === "Runtime.consoleAPICalled" || method === "Runtime.exceptionThrown") { |
| 1405 | 1155 | yield* Queue.offer(consoleReleases, undefined); |
| 1406 | 1156 | } |
| ... | ... | @@ -1410,20 +1160,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1410 | 1160 | }; |
| 1411 | 1161 | yield* Scope.addFinalizer( |
| 1412 | 1162 | 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), | |
| 1427 | 1167 | ); |
| 1428 | 1168 | const control: BrowserControlSession = { |
| 1429 | 1169 | webContentsId: wc.id, |
| ... | ... | @@ -1433,15 +1173,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1433 | 1173 | onMessage, |
| 1434 | 1174 | }; |
| 1435 | 1175 | const initialize = Effect.fn("PreviewManager.initializeControlSession")(function* () { |
| 1436 | yield* Ref.update(diagnosticsRef, (diagnostics) => | |
| 1437 | replaceMap(diagnostics, (copy) => { | |
| 1438 | copy.set(wc.id, { | |
| 1439 | consoleEntries: [], | |
| 1440 | networkEntries: [], | |
| 1441 | requests: new Map(), | |
| 1442 | }); | |
| 1443 | }), | |
| 1444 | ); | |
| 1445 | 1176 | yield* attempt({ operation: "attachDebuggerListeners", webContentsId: wc.id }, () => { |
| 1446 | 1177 | wcDebugger.on("message", onMessage); |
| 1447 | 1178 | wcDebugger.attach("1.3"); |
| ... | ... | @@ -1461,15 +1192,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1461 | 1192 | color: { r: 255, g: 255, b: 255, a: 1 }, |
| 1462 | 1193 | }), |
| 1463 | 1194 | ); |
| 1464 | yield* Effect.forEach( | |
| 1465 | ["Runtime.enable", "Accessibility.enable", "Network.enable", "Log.enable"], | |
| 1466 | (method) => | |
| 1467 | attemptPromise( | |
| 1468 | { operation: `initializeDebugger.${method}`, webContentsId: wc.id }, | |
| 1469 | () => wcDebugger.sendCommand(method), | |
| 1470 | ), | |
| 1471 | { concurrency: "unbounded", discard: true }, | |
| 1472 | ); | |
| 1473 | 1195 | return [ |
| 1474 | 1196 | control, |
| 1475 | 1197 | replaceMap(sessions, (copy) => { |
| ... | ... | @@ -1486,250 +1208,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1486 | 1208 | ); |
| 1487 | 1209 | }); |
| 1488 | 1210 | |
| 1489 | const pushAction = (tabId: string, event: PreviewAutomationActionEvent) => | |
| 1490 | Ref.update(actionTimelineRef, (timelines) => | |
| 1491 | replaceMap(timelines, (copy) => { | |
| 1492 | copy.set(tabId, [...(timelines.get(tabId) ?? []), event].slice(-200)); | |
| 1493 | }), | |
| 1494 | ); | |
| 1495 | const replaceAction = (tabId: string, event: PreviewAutomationActionEvent) => | |
| 1496 | Ref.update(actionTimelineRef, (timelines) => { | |
| 1497 | const timeline = timelines.get(tabId); | |
| 1498 | if (!timeline) return timelines; | |
| 1499 | return replaceMap(timelines, (copy) => { | |
| 1500 | copy.set( | |
| 1501 | tabId, | |
| 1502 | timeline.map((candidate) => (candidate.id === event.id ? event : candidate)), | |
| 1503 | ); | |
| 1504 | }); | |
| 1505 | }); | |
| 1506 | ||
| 1507 | type SendCommand = ( | |
| 1508 | method: string, | |
| 1509 | commandParams?: Record<string, unknown>, | |
| 1510 | sessionId?: string, | |
| 1511 | ) => Effect.Effect<unknown, PreviewManagerError>; | |
| 1512 | ||
| 1513 | const prepareAutomationInput = Effect.fn("PreviewManager.prepareAutomationInput")(function* ( | |
| 1514 | send: SendCommand, | |
| 1515 | enableRuntime: boolean, | |
| 1516 | ) { | |
| 1517 | yield* Effect.all( | |
| 1518 | [ | |
| 1519 | ...(enableRuntime ? [send("Runtime.enable")] : []), | |
| 1520 | send("Input.setIgnoreInputEvents", { ignore: false }), | |
| 1521 | ], | |
| 1522 | { concurrency: 2, discard: true }, | |
| 1523 | ); | |
| 1524 | }); | |
| 1525 | ||
| 1526 | const withControlSession = Effect.fn("PreviewManager.withControlSession")(function* <A>( | |
| 1527 | tabId: string, | |
| 1528 | wc: Electron.WebContents, | |
| 1529 | action: string, | |
| 1530 | use: ( | |
| 1531 | send: SendCommand, | |
| 1532 | sendCleanup: SendCommand, | |
| 1533 | checkControl: Effect.Effect<void, PreviewManagerError>, | |
| 1534 | ) => Effect.Effect<A, PreviewManagerError>, | |
| 1535 | ) { | |
| 1536 | const sequence = yield* nextCounter(actionSequenceRef); | |
| 1537 | const startedAt = yield* currentIso; | |
| 1538 | const millis = yield* currentMillis; | |
| 1539 | const actionEvent: PreviewAutomationActionEvent = { | |
| 1540 | id: `browser-action-${millis.toString(36)}-${sequence.toString(36)}`, | |
| 1541 | action, | |
| 1542 | status: "running", | |
| 1543 | startedAt, | |
| 1544 | }; | |
| 1545 | yield* pushAction(tabId, actionEvent); | |
| 1546 | const epoch = (yield* Ref.get(controlEpochRef)).get(tabId) ?? 0; | |
| 1547 | const control = yield* ensureControlSession(wc); | |
| 1548 | const execute = Effect.fn("PreviewManager.executeControlAction")(function* () { | |
| 1549 | const checkControl = Effect.gen(function* () { | |
| 1550 | const currentEpoch = (yield* Ref.get(controlEpochRef)).get(tabId) ?? 0; | |
| 1551 | if (currentEpoch !== epoch) { | |
| 1552 | return yield* new PreviewAutomationControlInterruptedError({ | |
| 1553 | operation: action, | |
| 1554 | tabId, | |
| 1555 | webContentsId: wc.id, | |
| 1556 | }); | |
| 1557 | } | |
| 1558 | }); | |
| 1559 | // A human who took over while this action waited for the permit owns | |
| 1560 | // the page; marking it agent-driven would hide their Save dialog. | |
| 1561 | yield* checkControl; | |
| 1562 | if (!READ_ONLY_CONTROL_ACTIONS.has(action)) agentDrivenWebContents.add(wc); | |
| 1563 | yield* update(tabId, { controller: "agent" }); | |
| 1564 | const send: SendCommand = Effect.fn("PreviewManager.sendCommand")( | |
| 1565 | function* (method, commandParams, sessionId) { | |
| 1566 | yield* checkControl; | |
| 1567 | const result = yield* attemptPromise( | |
| 1568 | { operation: `${action}.${method}`, tabId, webContentsId: wc.id }, | |
| 1569 | async () => { | |
| 1570 | try { | |
| 1571 | return await (sessionId === undefined | |
| 1572 | ? control.debugger.sendCommand(method, commandParams) | |
| 1573 | : control.debugger.sendCommand(method, commandParams, sessionId)); | |
| 1574 | } finally { | |
| 1575 | const objectGroup = | |
| 1576 | method === "Runtime.evaluate" ? commandParams?.["objectGroup"] : undefined; | |
| 1577 | // Cancelling an Effect does not cancel the CDP promise. Release | |
| 1578 | // after it settles, even if the caller has already left, so a | |
| 1579 | // late result cannot recreate handles after an early cleanup. | |
| 1580 | if (typeof objectGroup === "string" && objectGroup.startsWith("t3-evaluation-")) { | |
| 1581 | const params = { objectGroup }; | |
| 1582 | await ( | |
| 1583 | sessionId === undefined | |
| 1584 | ? control.debugger.sendCommand("Runtime.releaseObjectGroup", params) | |
| 1585 | : control.debugger.sendCommand( | |
| 1586 | "Runtime.releaseObjectGroup", | |
| 1587 | params, | |
| 1588 | sessionId, | |
| 1589 | ) | |
| 1590 | ).catch(() => undefined); | |
| 1591 | } | |
| 1592 | } | |
| 1593 | }, | |
| 1594 | ); | |
| 1595 | yield* checkControl; | |
| 1596 | return result; | |
| 1597 | }, | |
| 1598 | ); | |
| 1599 | // Cleanup commands must still run after human input invalidates the action's | |
| 1600 | // control epoch. Otherwise a partially dispatched input can leave Chromium | |
| 1601 | // with a held key or focus emulation enabled for subsequent actions. | |
| 1602 | const sendCleanup: SendCommand = Effect.fn("PreviewManager.sendCleanupCommand")( | |
| 1603 | function* (method, commandParams, sessionId) { | |
| 1604 | return yield* attemptPromise( | |
| 1605 | { | |
| 1606 | operation: `${action}.cleanup.${method}`, | |
| 1607 | tabId, | |
| 1608 | webContentsId: wc.id, | |
| 1609 | }, | |
| 1610 | () => | |
| 1611 | sessionId === undefined | |
| 1612 | ? control.debugger.sendCommand(method, commandParams) | |
| 1613 | : control.debugger.sendCommand(method, commandParams, sessionId), | |
| 1614 | ); | |
| 1615 | }, | |
| 1616 | ); | |
| 1617 | return yield* use(send, sendCleanup, checkControl); | |
| 1618 | }); | |
| 1619 | const finalize = Effect.fn("PreviewManager.finalizeControlAction")(function* ( | |
| 1620 | exit: Exit.Exit<A, PreviewManagerError>, | |
| 1621 | ) { | |
| 1622 | const completedAt = yield* currentIso; | |
| 1623 | if (exit._tag === "Success") { | |
| 1624 | yield* replaceAction(tabId, { | |
| 1625 | ...actionEvent, | |
| 1626 | status: "succeeded", | |
| 1627 | completedAt, | |
| 1628 | }); | |
| 1629 | } else { | |
| 1630 | const error = Option.getOrNull(Cause.findErrorOption(exit.cause)); | |
| 1631 | const interrupted = isPreviewAutomationControlInterruptedError(error); | |
| 1632 | const errorMessage = isPreviewOperationError(error) | |
| 1633 | ? PreviewOperationError.toTimelineMessage(error) | |
| 1634 | : isPreviewAutomationEvaluationError(error) | |
| 1635 | ? PreviewAutomationEvaluationError.toTimelineMessage(error) | |
| 1636 | : isPreviewAutomationInvalidSelectorError(error) | |
| 1637 | ? PreviewAutomationInvalidSelectorError.toTimelineMessage(error) | |
| 1638 | : error instanceof Error | |
| 1639 | ? error.message | |
| 1640 | : String(error); | |
| 1641 | yield* replaceAction(tabId, { | |
| 1642 | ...actionEvent, | |
| 1643 | status: interrupted ? "interrupted" : "failed", | |
| 1644 | completedAt, | |
| 1645 | error: errorMessage, | |
| 1646 | }); | |
| 1647 | } | |
| 1648 | const tabs = yield* SynchronizedRef.get(tabsRef); | |
| 1649 | if (tabs.has(tabId)) yield* update(tabId, { controller: "none" }); | |
| 1650 | }); | |
| 1651 | return yield* control.semaphore.withPermit(execute().pipe(Effect.onExit(finalize))); | |
| 1652 | }); | |
| 1653 | ||
| 1654 | const evaluateWithDebugger = <A = unknown>( | |
| 1655 | tabId: string, | |
| 1656 | send: SendCommand, | |
| 1657 | expression: string, | |
| 1658 | returnByValue: boolean, | |
| 1659 | awaitPromise = true, | |
| 1660 | ): Effect.Effect<A, PreviewManagerError> => | |
| 1661 | Effect.gen(function* () { | |
| 1662 | const objectGroup = `t3-evaluation-${yield* crypto.randomUUIDv4.pipe(Effect.orDie)}`; | |
| 1663 | return yield* send("Runtime.evaluate", { | |
| 1664 | expression, | |
| 1665 | awaitPromise, | |
| 1666 | returnByValue, | |
| 1667 | userGesture: true, | |
| 1668 | objectGroup, | |
| 1669 | }).pipe( | |
| 1670 | Effect.flatMap((rawResponse) => { | |
| 1671 | const response = rawResponse as CdpEvaluationResult; | |
| 1672 | if (!response.exceptionDetails) { | |
| 1673 | return Effect.succeed(response.result?.value as A); | |
| 1674 | } | |
| 1675 | const detail = previewAutomationEvaluationDetail(response.exceptionDetails); | |
| 1676 | return Effect.fail( | |
| 1677 | new PreviewAutomationEvaluationError({ | |
| 1678 | tabId, | |
| 1679 | detailKind: detail.detailKind, | |
| 1680 | detailLength: detail.detail?.length ?? 0, | |
| 1681 | cause: response.exceptionDetails, | |
| 1682 | }), | |
| 1683 | ); | |
| 1684 | }), | |
| 1685 | ); | |
| 1686 | }); | |
| 1687 | ||
| 1688 | const automationLocator = (input: { | |
| 1689 | readonly selector?: string | undefined; | |
| 1690 | readonly locator?: string | undefined; | |
| 1691 | }): string | null => input.locator ?? (input.selector ? `css=${input.selector}` : null); | |
| 1692 | ||
| 1693 | const automationSelectorDiagnostics = (input: { | |
| 1694 | readonly selector?: string | undefined; | |
| 1695 | readonly locator?: string | undefined; | |
| 1696 | }): { | |
| 1697 | readonly selectorKind: PreviewAutomationSelectorKind; | |
| 1698 | readonly selectorLength?: number; | |
| 1699 | } => { | |
| 1700 | if (input.locator !== undefined) { | |
| 1701 | return { selectorKind: "locator", selectorLength: input.locator.length }; | |
| 1702 | } | |
| 1703 | if (input.selector !== undefined) { | |
| 1704 | return { selectorKind: "selector", selectorLength: input.selector.length }; | |
| 1705 | } | |
| 1706 | return { selectorKind: "focused-element" }; | |
| 1707 | }; | |
| 1708 | ||
| 1709 | const ensurePlaywrightInjected = Effect.fn("PreviewManager.ensurePlaywrightInjected")(function* ( | |
| 1710 | tabId: string, | |
| 1711 | send: SendCommand, | |
| 1712 | ) { | |
| 1713 | const installed = yield* evaluateWithDebugger<boolean>( | |
| 1714 | tabId, | |
| 1715 | send, | |
| 1716 | "Boolean(globalThis.__t3PlaywrightInjected)", | |
| 1717 | true, | |
| 1718 | ); | |
| 1719 | if (installed) return; | |
| 1720 | const expression = yield* playwrightInstallExpression.pipe( | |
| 1721 | Effect.mapError( | |
| 1722 | (cause) => | |
| 1723 | new PreviewOperationError({ | |
| 1724 | operation: "ensurePlaywrightInjected", | |
| 1725 | tabId, | |
| 1726 | cause, | |
| 1727 | }), | |
| 1728 | ), | |
| 1729 | ); | |
| 1730 | yield* evaluateWithDebugger(tabId, send, expression, true); | |
| 1731 | }); | |
| 1732 | ||
| 1733 | 1211 | const cancelPickElement = Effect.fn("PreviewManager.cancelPickElement")(function* ( |
| 1734 | 1212 | tabId: string, |
| 1735 | 1213 | ) { |
| ... | ... | @@ -1760,44 +1238,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1760 | 1238 | return { kind: "Success", url, title }; |
| 1761 | 1239 | }; |
| 1762 | 1240 | |
| 1763 | const consumeExpectedAgentInput = Effect.fn("PreviewManager.consumeExpectedAgentInput")( | |
| 1764 | function* (tabId: string, signal: PreviewInputSignal) { | |
| 1765 | const now = yield* currentMillis; | |
| 1766 | return yield* Ref.modify(expectedAgentInputsRef, (allExpected) => { | |
| 1767 | const pending = (allExpected.get(tabId) ?? []).filter( | |
| 1768 | (expected) => expected.expiresAt > now, | |
| 1769 | ); | |
| 1770 | const index = pending.findIndex((expected) => inputSignalsMatch(expected.signal, signal)); | |
| 1771 | const matched = index >= 0; | |
| 1772 | const nextPending = matched | |
| 1773 | ? pending.filter((_, pendingIndex) => pendingIndex !== index) | |
| 1774 | : pending; | |
| 1775 | return [ | |
| 1776 | matched, | |
| 1777 | replaceMap(allExpected, (copy) => { | |
| 1778 | if (nextPending.length === 0) copy.delete(tabId); | |
| 1779 | else copy.set(tabId, nextPending); | |
| 1780 | }), | |
| 1781 | ] as const; | |
| 1782 | }); | |
| 1783 | }, | |
| 1784 | ); | |
| 1785 | ||
| 1786 | const expectAgentInput = Effect.fn("PreviewManager.expectAgentInput")(function* ( | |
| 1787 | tabId: string, | |
| 1788 | signal: PreviewInputSignal, | |
| 1789 | ) { | |
| 1790 | const now = yield* currentMillis; | |
| 1791 | yield* Ref.update(expectedAgentInputsRef, (allExpected) => | |
| 1792 | replaceMap(allExpected, (copy) => { | |
| 1793 | const pending = (allExpected.get(tabId) ?? []).filter( | |
| 1794 | (expected) => expected.expiresAt > now, | |
| 1795 | ); | |
| 1796 | copy.set(tabId, [...pending, { signal, expiresAt: now + 1_000 }]); | |
| 1797 | }), | |
| 1798 | ); | |
| 1799 | }); | |
| 1800 | ||
| 1801 | 1241 | const attachListeners = Effect.fn("PreviewManager.attachListeners")(function* ( |
| 1802 | 1242 | tabId: string, |
| 1803 | 1243 | wc: Electron.WebContents, |
| ... | ... | @@ -2007,15 +1447,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 2007 | 1447 | const handleHumanInput = Effect.fn("PreviewManager.handleHumanInput")(function* ( |
| 2008 | 1448 | rawSignal?: unknown, |
| 2009 | 1449 | ) { |
| 2010 | if (isPreviewInputSignal(rawSignal) && (yield* consumeExpectedAgentInput(tabId, rawSignal))) { | |
| 2011 | return; | |
| 2012 | } | |
| 2013 | agentDrivenWebContents.delete(wc); | |
| 2014 | yield* Ref.update(controlEpochRef, (epochs) => | |
| 2015 | replaceMap(epochs, (copy) => { | |
| 2016 | copy.set(tabId, (epochs.get(tabId) ?? 0) + 1); | |
| 2017 | }), | |
| 2018 | ); | |
| 2019 | 1450 | yield* update( |
| 2020 | 1451 | tabId, |
| 2021 | 1452 | { controller: "human" }, |
| ... | ... | @@ -2214,6 +1645,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 2214 | 1645 | zoomFactor: normalizeZoomFactor(defaults?.zoomFactor), |
| 2215 | 1646 | pictureInPicture: false, |
| 2216 | 1647 | colorScheme: defaults?.colorScheme ?? "system", |
| 1648 | ...(defaults?.serverTab === undefined ? {} : { serverTab: defaults.serverTab }), | |
| 2217 | 1649 | audioMuted: false, |
| 2218 | 1650 | audible: false, |
| 2219 | 1651 | controller: "none", |
| ... | ... | @@ -2269,7 +1701,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 2269 | 1701 | const closedTab = tab.value; |
| 2270 | 1702 | if (closedTab.webContentsId != null) { |
| 2271 | 1703 | yield* Effect.all( |
| 2272 | [detachControlSession(closedTab.webContentsId), detachListeners(closedTab.webContentsId)], | |
| 1704 | [ | |
| 1705 | detachControlSession(closedTab.webContentsId, closedTab.serverTab), | |
| 1706 | detachListeners(closedTab.webContentsId), | |
| 1707 | ], | |
| 2273 | 1708 | { concurrency: 2, discard: true }, |
| 2274 | 1709 | ); |
| 2275 | 1710 | } |
| ... | ... | @@ -2593,7 +2028,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 2593 | 2028 | } |
| 2594 | 2029 | // URL-bar navigation hands the page back to the human. The agent's own |
| 2595 | 2030 | // navigation shares this path; its next action marks the page again. |
| 2596 | agentDrivenWebContents.delete(wc); | |
| 2597 | 2031 | if (wc.getURL() === url) { |
| 2598 | 2032 | yield* attempt({ operation: "navigate.reload", tabId, webContentsId: wc.id }, () => |
| 2599 | 2033 | wc.reload(), |
| ... | ... | @@ -2887,6 +2321,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 2887 | 2321 | yield* detachControlSession(wc.id); |
| 2888 | 2322 | return; |
| 2889 | 2323 | } |
| 2324 | if (afterAttach.serverTab) { | |
| 2325 | yield* listenForAgentPointers; | |
| 2326 | browserHost.attach(afterAttach.serverTab, { webContents: wc, debugger: control.debugger }); | |
| 2327 | } | |
| 2890 | 2328 | if (afterAttach.colorScheme !== "system") { |
| 2891 | 2329 | yield* attemptPromise({ operation: "applyColorScheme", tabId, webContentsId: wc.id }, () => |
| 2892 | 2330 | control.debugger.sendCommand("Emulation.setEmulatedMedia", { |
| ... | ... | @@ -3623,24 +3061,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 3623 | 3061 | }); |
| 3624 | 3062 | }; |
| 3625 | 3063 | |
| 3626 | // Installed once per session. Electron opens a native Save dialog for a | |
| 3627 | // download with no save path, so every export an agent clicked while testing | |
| 3628 | // a page put a modal over the whole app. Downloads from a page the agent drove | |
| 3629 | // last go to the artifact directory instead. Downloads the human starts keep | |
| 3630 | // the Save dialog. | |
| 3631 | const installDownloadHandler = (session: Session) => { | |
| 3632 | if (downloadHandlerSessions.has(session)) return; | |
| 3633 | downloadHandlerSessions.add(session); | |
| 3634 | session.on("will-download", (_event, item, source) => { | |
| 3635 | if (!agentDrivenWebContents.has(source)) return; | |
| 3636 | // The start time keeps names unique across restarts; the count keeps two | |
| 3637 | // same-name downloads in one millisecond from overwriting each other. | |
| 3638 | const id = `${Math.round(item.getStartTime() * 1000).toString(36)}-${(downloadCount++).toString(36)}`; | |
| 3639 | const fileName = `browser-download-${id}-${path.basename(item.getFilename())}`; | |
| 3640 | item.setSavePath(path.join(resolvedArtifactDirectory, fileName)); | |
| 3641 | }); | |
| 3642 | }; | |
| 3643 | ||
| 3644 | 3064 | const startRecording = Effect.fn("PreviewManager.startRecording")(function* ( |
| 3645 | 3065 | tabId: string, |
| 3646 | 3066 | options: RecordingInputOptions = DEFAULT_RECORDING_INPUT_OPTIONS, |
| ... | ... | @@ -3763,956 +3183,86 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 3763 | 3183 | }; |
| 3764 | 3184 | }); |
| 3765 | 3185 | |
| 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, | |
| 3906 | 3188 | ) { |
| 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), | |
| 3910 | 3192 | ); |
| 3911 | 3193 | }); |
| 3912 | 3194 | |
| 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, | |
| 3917 | 3197 | ) { |
| 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), | |
| 3949 | 3202 | ); |
| 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 }); | |
| 3965 | 3205 | } |
| 3966 | return point; | |
| 3206 | yield* attemptPromise( | |
| 3207 | { operation: "copyArtifactToClipboard.write", artifactPath: resolvedPath }, | |
| 3208 | () => | |
| 3209 | clipboard.write([ | |
| 3210 | new ClipboardItem({ | |
| 3211 | "image/png": new Blob([Uint8Array.from(image.toPNG())], { type: "image/png" }), | |
| 3212 | }), | |
| 3213 | ]), | |
| 3214 | ); | |
| 3967 | 3215 | }); |
| 3968 | 3216 | |
| 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; | |
| 3975 | 3244 | if (recording?.consumers.has("recording") && webContentsId != null) { |
| 3976 | yield* attempt({ operation: "recording.pointer", tabId: event.tabId }, () => { | |
| 3245 | yield* attempt({ operation: "recording.pointer", tabId: tab.tabId }, () => { | |
| 3977 | 3246 | const contents = webContents.fromId(webContentsId); |
| 3978 | 3247 | if (contents && !contents.isDestroyed()) contents.send(RECORDING_POINTER_CHANNEL, event); |
| 3979 | }); | |
| 3248 | }).pipe(Effect.ignore); | |
| 3980 | 3249 | } |
| 3981 | 3250 | const listeners = yield* Ref.get(pointerEventListenersRef); |
| 3982 | 3251 | yield* Effect.forEach( |
| 3983 | 3252 | listeners, |
| 3984 | (listener) => deliverEvent("pointer-event", event.tabId, () => listener(event)), | |
| 3253 | (listener) => deliverEvent("pointer-event", tab.tabId, () => listener(event)), | |
| 3985 | 3254 | { discard: true }, |
| 3986 | 3255 | ); |
| 3987 | 3256 | }); |
| 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, | |
| 4716 | 3266 | ); |
| 4717 | 3267 | }); |
| 4718 | 3268 | |
| ... | ... | @@ -4736,7 +3286,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 4736 | 3286 | yield* Effect.all( |
| 4737 | 3287 | [ |
| 4738 | 3288 | Ref.set(listenersRef, new Set()), |
| 4739 | Ref.set(expectedAgentInputsRef, new Map()), | |
| 4740 | 3289 | Ref.set(pointerEventListenersRef, new Set()), |
| 4741 | 3290 | Ref.set(recordingFrameListenersRef, new Set()), |
| 4742 | 3291 | Ref.set(recordingInputListenersRef, new Set()), |
| ... | ... | @@ -4748,14 +3297,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 4748 | 3297 | yield* Effect.addFinalizer(() => destroy().pipe(Effect.ignore)); |
| 4749 | 3298 | |
| 4750 | 3299 | return { |
| 4751 | automationClick, | |
| 4752 | automationEvaluate, | |
| 4753 | automationPress, | |
| 4754 | automationScroll, | |
| 4755 | automationSnapshot, | |
| 4756 | automationStatus, | |
| 4757 | automationType, | |
| 4758 | automationWaitFor, | |
| 4759 | 3300 | cancelPickElement, |
| 4760 | 3301 | captureScreenshot, |
| 4761 | 3302 | closeTab, |
| ... | ... | @@ -4764,7 +3305,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 4764 | 3305 | goBack, |
| 4765 | 3306 | goForward, |
| 4766 | 3307 | hardReload, |
| 4767 | installDownloadHandler, | |
| 4768 | 3308 | navigate, |
| 4769 | 3309 | openPictureInPicture, |
| 4770 | 3310 | openDevTools, |
| ... | ... | @@ -4774,6 +3314,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 4774 | 3314 | refresh, |
| 4775 | 3315 | registerWebview, |
| 4776 | 3316 | resetZoom: (tabId: string) => applyZoom(tabId, () => DEFAULT_ZOOM_FACTOR), |
| 3317 | setZoomFactor: (tabId: string, zoomFactor: number) => | |
| 3318 | applyZoom(tabId, () => normalizeZoomFactor(zoomFactor)), | |
| 4777 | 3319 | revealArtifact, |
| 4778 | 3320 | saveRecording, |
| 4779 | 3321 | setAnnotationTheme, |
| ... | ... | @@ -4921,143 +3463,6 @@ export class PreviewAutomationDebuggerAttachedError extends Schema.TaggedError<P |
| 4921 | 3463 | } |
| 4922 | 3464 | } |
| 4923 | 3465 | |
| 4924 | export 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 | ||
| 4942 | export 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 | ||
| 4957 | export 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 | ||
| 4971 | export 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 | ||
| 4986 | export 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 | ||
| 5019 | export 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 | ||
| 5036 | export 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 | ||
| 5048 | export 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 | ||
| 5061 | 3466 | export const PreviewManagerError = Schema.Union([ |
| 5062 | 3467 | PreviewTabNotFoundError, |
| 5063 | 3468 | PreviewWebContentsNotFoundError, |
| ... | ... | @@ -5070,23 +3475,9 @@ export const PreviewManagerError = Schema.Union([ |
| 5070 | 3475 | PreviewArtifactImageLoadError, |
| 5071 | 3476 | PreviewAutomationDevToolsOpenError, |
| 5072 | 3477 | PreviewAutomationDebuggerAttachedError, |
| 5073 | PreviewAutomationEvaluationError, | |
| 5074 | PreviewAutomationTargetNotFoundError, | |
| 5075 | PreviewAutomationTargetNotEditableError, | |
| 5076 | PreviewAutomationCoordinatesOutsideViewportError, | |
| 5077 | PreviewAutomationInvalidSelectorError, | |
| 5078 | PreviewAutomationResultTooLargeError, | |
| 5079 | PreviewAutomationTimeoutError, | |
| 5080 | PreviewAutomationControlInterruptedError, | |
| 5081 | 3478 | ]); |
| 5082 | 3479 | export type PreviewManagerError = typeof PreviewManagerError.Type; |
| 5083 | 3480 | |
| 5084 | const isPreviewAutomationControlInterruptedError = Schema.is( | |
| 5085 | PreviewAutomationControlInterruptedError, | |
| 5086 | ); | |
| 5087 | const isPreviewAutomationEvaluationError = Schema.is(PreviewAutomationEvaluationError); | |
| 5088 | const isPreviewAutomationInvalidSelectorError = Schema.is(PreviewAutomationInvalidSelectorError); | |
| 5089 | ||
| 5090 | 3481 | export class PreviewManager extends Context.Service< |
| 5091 | 3482 | PreviewManager, |
| 5092 | 3483 | { |
| ... | ... | @@ -5122,6 +3513,10 @@ export class PreviewManager extends Context.Service< |
| 5122 | 3513 | tabId: string, |
| 5123 | 3514 | colorScheme: DesktopPreviewColorScheme, |
| 5124 | 3515 | ) => Effect.Effect<void, PreviewManagerError>; |
| 3516 | readonly setZoomFactor: ( | |
| 3517 | tabId: string, | |
| 3518 | zoomFactor: number, | |
| 3519 | ) => Effect.Effect<void, PreviewManagerError>; | |
| 5125 | 3520 | readonly setAudioMuted: ( |
| 5126 | 3521 | tabId: string, |
| 5127 | 3522 | audioMuted: boolean, |
| ... | ... | @@ -5162,36 +3557,6 @@ export class PreviewManager extends Context.Service< |
| 5162 | 3557 | mimeType: string, |
| 5163 | 3558 | data: Uint8Array, |
| 5164 | 3559 | ) => Effect.Effect<DesktopPreviewRecordingArtifact, PreviewManagerError>; |
| 5165 | readonly automationStatus: ( | |
| 5166 | tabId: string, | |
| 5167 | ) => Effect.Effect<DesktopPreviewAutomationStatus, PreviewManagerError>; | |
| 5168 | readonly automationSnapshot: ( | |
| 5169 | tabId: string, | |
| 5170 | ) => Effect.Effect<PreviewAutomationSnapshot, PreviewManagerError>; | |
| 5171 | readonly automationClick: ( | |
| 5172 | tabId: string, | |
| 5173 | input: PreviewAutomationClickInput, | |
| 5174 | ) => Effect.Effect<void, PreviewManagerError>; | |
| 5175 | readonly automationType: ( | |
| 5176 | tabId: string, | |
| 5177 | input: PreviewAutomationTypeInput, | |
| 5178 | ) => Effect.Effect<void, PreviewManagerError>; | |
| 5179 | readonly automationPress: ( | |
| 5180 | tabId: string, | |
| 5181 | input: PreviewAutomationPressInput, | |
| 5182 | ) => Effect.Effect<void, PreviewManagerError>; | |
| 5183 | readonly automationScroll: ( | |
| 5184 | tabId: string, | |
| 5185 | input: PreviewAutomationScrollInput, | |
| 5186 | ) => Effect.Effect<void, PreviewManagerError>; | |
| 5187 | readonly automationEvaluate: ( | |
| 5188 | tabId: string, | |
| 5189 | input: PreviewAutomationEvaluateInput, | |
| 5190 | ) => Effect.Effect<unknown, PreviewManagerError>; | |
| 5191 | readonly automationWaitFor: ( | |
| 5192 | tabId: string, | |
| 5193 | input: PreviewAutomationWaitForInput, | |
| 5194 | ) => Effect.Effect<void, PreviewManagerError>; | |
| 5195 | 3560 | readonly subscribeStateChanges: (listener: Listener) => Effect.Effect<void, never, Scope.Scope>; |
| 5196 | 3561 | readonly subscribePointerEvents: ( |
| 5197 | 3562 | listener: PointerEventListener, |
| ... | ... | @@ -5209,6 +3574,18 @@ export class PreviewManager extends Context.Service< |
| 5209 | 3574 | export const make = Effect.gen(function* PreviewManagerMake() { |
| 5210 | 3575 | const environment = yield* DesktopEnvironment.DesktopEnvironment; |
| 5211 | 3576 | const browserSession = yield* BrowserSession.BrowserSession; |
| 3577 | const browserHost = yield* DesktopBrowserHost.DesktopBrowserHost; | |
| 3578 | const downloadSessions = new WeakSet<Electron.Session>(); | |
| 3579 | // Server tabs save downloads where the server's engine reads them. Downloads | |
| 3580 | // the person starts in a tab the server is not driving keep Electron's dialog. | |
| 3581 | const placeServerDownloads = (session: Electron.Session) => { | |
| 3582 | if (downloadSessions.has(session)) return; | |
| 3583 | downloadSessions.add(session); | |
| 3584 | session.on("will-download", (_event, item, source) => { | |
| 3585 | browserHost.placeDownload(source, item); | |
| 3586 | }); | |
| 3587 | }; | |
| 3588 | ||
| 5212 | 3589 | const operations = yield* makeNativeOperations( |
| 5213 | 3590 | environment.browserArtifactsDir, |
| 5214 | 3591 | environment.path.join(environment.dirname, "preview-pip-preload.cjs"), |
| ... | ... | @@ -5225,7 +3602,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 5225 | 3602 | (cause) => new PreviewOperationError({ operation: "getBrowserSession", cause }), |
| 5226 | 3603 | ), |
| 5227 | 3604 | ); |
| 5228 | operations.installDownloadHandler(session); | |
| 3605 | placeServerDownloads(session); | |
| 5229 | 3606 | return session; |
| 5230 | 3607 | }, |
| 5231 | 3608 | ), |
| ... | ... | @@ -5244,6 +3621,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 5244 | 3621 | reapplyZoom: operations.reapplyZoom, |
| 5245 | 3622 | hardReload: operations.hardReload, |
| 5246 | 3623 | setColorScheme: operations.setColorScheme, |
| 3624 | setZoomFactor: operations.setZoomFactor, | |
| 5247 | 3625 | setAudioMuted: operations.setAudioMuted, |
| 5248 | 3626 | openDevTools: operations.openDevTools, |
| 5249 | 3627 | clearCookies: Effect.fn("PreviewManager.clearCookies")(function* (partitions) { |
| ... | ... | @@ -5284,14 +3662,6 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 5284 | 3662 | startRecording: operations.startRecording, |
| 5285 | 3663 | stopRecording: operations.stopRecording, |
| 5286 | 3664 | saveRecording: operations.saveRecording, |
| 5287 | automationStatus: operations.automationStatus, | |
| 5288 | automationSnapshot: operations.automationSnapshot, | |
| 5289 | automationClick: operations.automationClick, | |
| 5290 | automationType: operations.automationType, | |
| 5291 | automationPress: operations.automationPress, | |
| 5292 | automationScroll: operations.automationScroll, | |
| 5293 | automationEvaluate: operations.automationEvaluate, | |
| 5294 | automationWaitFor: operations.automationWaitFor, | |
| 5295 | 3665 | subscribeStateChanges: operations.subscribeStateChanges, |
| 5296 | 3666 | subscribePointerEvents: operations.subscribePointerEvents, |
| 5297 | 3667 | subscribeRecordingFrames: operations.subscribeRecordingFrames, |
apps/desktop/src/preview/PlaywrightInjectedRuntime.test.ts deleted-80| ... | ... | @@ -1,80 +0,0 @@ |
| 1 | import { it as effectIt } from "@effect/vitest"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import { describe, expect } from "vite-plus/test"; | |
| 4 | ||
| 5 | import { | |
| 6 | extractPlaywrightInjectedRuntimeSource, | |
| 7 | playwrightInjectedRuntimeInstallExpression, | |
| 8 | playwrightInjectedRuntimeSource, | |
| 9 | } from "./PlaywrightInjectedRuntime.ts"; | |
| 10 | ||
| 11 | const bundleWithSourceLiteral = (literal: string): string => | |
| 12 | `const source3 = ${literal};\n }\n});`; | |
| 13 | ||
| 14 | describe("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. | |
| 2 | import * as NodeFSP from "node:fs/promises"; | |
| 3 | import * as NodeModule from "node:module"; | |
| 4 | import * as NodePath from "node:path"; | |
| 5 | import * as NodeVM from "node:vm"; | |
| 6 | ||
| 7 | import * as Effect from "effect/Effect"; | |
| 8 | import * as Schema from "effect/Schema"; | |
| 9 | ||
| 10 | const require = NodeModule.createRequire(import.meta.url); | |
| 11 | const encodeUnknownJson = Schema.encodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); | |
| 12 | const PLAYWRIGHT_PACKAGE_SPECIFIER = "playwright-core/package.json"; | |
| 13 | const PLAYWRIGHT_SOURCE_MARKER = "source3 = "; | |
| 14 | const PLAYWRIGHT_SOURCE_TERMINATOR = ";\n }\n});"; | |
| 15 | const PLAYWRIGHT_SOURCE_MINIMUM_LENGTH = 100_000; | |
| 16 | const PLAYWRIGHT_SOURCE_EVALUATION_TIMEOUT_MS = 1_000; | |
| 17 | const PLAYWRIGHT_SDK_LANGUAGE = "javascript"; | |
| 18 | const PLAYWRIGHT_BROWSER_NAME = "chromium"; | |
| 19 | ||
| 20 | export 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 | ||
| 32 | export 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 | ||
| 44 | export 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 | ||
| 56 | export 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 | ||
| 68 | export 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 | ||
| 81 | export 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 | ||
| 99 | export 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 | ||
| 112 | export const PlaywrightInjectedRuntimeError = Schema.Union([ | |
| 113 | PlaywrightPackageResolveError, | |
| 114 | PlaywrightCoreBundleReadError, | |
| 115 | PlaywrightSourceMarkerNotFoundError, | |
| 116 | PlaywrightSourceTerminatorNotFoundError, | |
| 117 | PlaywrightSourceEvaluationError, | |
| 118 | PlaywrightSourceValidationError, | |
| 119 | PlaywrightOptionsEncodeError, | |
| 120 | ]); | |
| 121 | export type PlaywrightInjectedRuntimeError = typeof PlaywrightInjectedRuntimeError.Type; | |
| 122 | ||
| 123 | export 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 | ||
| 165 | export 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 | ||
| 184 | export 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 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { | |
| 4 | makePreviewAutomationKeySequence, | |
| 5 | makePreviewAutomationNativeKeySequence, | |
| 6 | } from "./PreviewKeyboard.ts"; | |
| 7 | ||
| 8 | describe("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 @@ |
| 1 | import type { PreviewAutomationPressInput } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | interface 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 | ||
| 12 | export 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 | ||
| 26 | export 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 | ||
| 36 | const 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 | ||
| 59 | const 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 | */ | |
| 88 | const 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 | }; | |
| 105 | const SHORTCUT_MODIFIER_ORDER = ["Shift", "Control", "Alt", "Meta"] as const; | |
| 106 | ||
| 107 | const 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 | ||
| 119 | const 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 | ||
| 133 | function 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 | */ | |
| 174 | export 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. */ | |
| 206 | export 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. */ | |
| 243 | export function previewAutomationEditingCommandExpression( | |
| 244 | input: PreviewAutomationPressInput, | |
| 245 | sequence: ReturnType<typeof makePreviewAutomationNativeKeySequence>, | |
| 246 | clipboardData: ReadonlyArray<{ readonly type: string; readonly data: string }> = [], | |
| 247 | ): string { | |
| 248 | const definition = resolveKeyDefinition(input); | |
| 249 | const event = { | |
| 250 | key: definition.key, | |
| 251 | code: definition.code, | |
| 252 | keyCode: definition.keyCode, | |
| 253 | which: definition.keyCode, | |
| 254 | location: definition.location ?? 0, | |
| 255 | altKey: input.modifiers?.includes("Alt") ?? false, | |
| 256 | ctrlKey: input.modifiers?.includes("Control") ?? false, | |
| 257 | metaKey: input.modifiers?.includes("Meta") ?? false, | |
| 258 | shiftKey: input.modifiers?.includes("Shift") ?? false, | |
| 259 | bubbles: true, | |
| 260 | cancelable: true, | |
| 261 | composed: true, | |
| 262 | }; | |
| 263 | return `(() => { | |
| 264 | let element = document.activeElement; | |
| 265 | while (element?.shadowRoot?.activeElement) element = element.shadowRoot.activeElement; | |
| 266 | if (!element) return; | |
| 267 | const event = ${JSON.stringify(event)}; | |
| 268 | try { | |
| 269 | if (!element.dispatchEvent(new KeyboardEvent("keydown", event))) return; | |
| 270 | for (const command of ${JSON.stringify(sequence.commands ?? [])}) { | |
| 271 | // Main-process clipboard reads also work on insecure HTTP previews. | |
| 272 | // Let the page's paste handler consume the clipboard MIME formats. | |
| 273 | if (command === "paste") { | |
| 274 | const transfer = new DataTransfer(); | |
| 275 | for (const { type, data } of ${JSON.stringify(clipboardData)}) { | |
| 276 | if (type === "text/html") { | |
| 277 | // Match native paste sanitization before page handlers or insertion. | |
| 278 | const container = document.createElement("div"); | |
| 279 | container.setHTML(data); | |
| 280 | transfer.setData(type, container.innerHTML); | |
| 281 | } else if (type.startsWith("text/")) transfer.setData(type, data); | |
| 282 | else { | |
| 283 | const bytes = Uint8Array.from(atob(data), character => character.charCodeAt(0)); | |
| 284 | transfer.items.add(new File([bytes], "clipboard", { type })); | |
| 285 | } | |
| 286 | } | |
| 287 | if (!element.dispatchEvent(new ClipboardEvent("paste", { | |
| 288 | clipboardData: transfer, bubbles: true, cancelable: true, composed: true, | |
| 289 | }))) continue; | |
| 290 | const text = transfer.getData("text/plain"); | |
| 291 | if (!element.dispatchEvent(new InputEvent("beforeinput", { | |
| 292 | inputType: "insertFromPaste", data: text, dataTransfer: transfer, | |
| 293 | bubbles: true, cancelable: true, composed: true, | |
| 294 | }))) continue; | |
| 295 | const html = element.isContentEditable ? transfer.getData("text/html") : ""; | |
| 296 | document.execCommand(html ? "insertHTML" : "insertText", false, html || text); | |
| 297 | continue; | |
| 298 | } | |
| 299 | const inputType = command === "deleteToBeginningOfLine" ? "deleteSoftLineBackward" | |
| 300 | : command === "undo" ? "historyUndo" | |
| 301 | : command === "redo" ? "historyRedo" : null; | |
| 302 | // execCommand emits input without beforeinput. Let controlled editors | |
| 303 | // perform the edit before applying the browser's default operation. | |
| 304 | if (inputType && !element.dispatchEvent(new InputEvent("beforeinput", { | |
| 305 | inputType, bubbles: true, cancelable: true, composed: true, | |
| 306 | }))) continue; | |
| 307 | const selection = document.getSelection(); | |
| 308 | if (command === "deleteToBeginningOfLine") { | |
| 309 | const collapsed = typeof element.selectionStart === "number" | |
| 310 | ? element.selectionStart === element.selectionEnd | |
| 311 | : selection?.isCollapsed; | |
| 312 | if (collapsed) selection?.modify("extend", "backward", "lineboundary"); | |
| 313 | document.execCommand("delete"); | |
| 314 | } else if (command.startsWith("moveTo")) { | |
| 315 | const selectionElement = selection?.anchorNode?.nodeType === Node.ELEMENT_NODE | |
| 316 | ? selection.anchorNode : selection?.anchorNode?.parentElement; | |
| 317 | const editable = element.isContentEditable || selectionElement?.isContentEditable || | |
| 318 | (((element instanceof HTMLInputElement && element.selectionStart !== null) || | |
| 319 | element instanceof HTMLTextAreaElement) && | |
| 320 | !element.readOnly && !element.disabled); | |
| 321 | if (!editable && (command === "moveToBeginningOfDocument" || command === "moveToEndOfDocument")) { | |
| 322 | let scrollable = element === document.body ? selectionElement ?? element : element; | |
| 323 | while (scrollable && !(scrollable.scrollHeight > scrollable.clientHeight && | |
| 324 | /^(auto|scroll|overlay)$/.test(getComputedStyle(scrollable).overflowY))) { | |
| 325 | scrollable = scrollable.parentElement ?? scrollable.getRootNode().host; | |
| 326 | } | |
| 327 | scrollable ??= document.scrollingElement; | |
| 328 | if (scrollable) scrollable.scrollTop = command === "moveToBeginningOfDocument" | |
| 329 | ? 0 : scrollable.scrollHeight; | |
| 330 | continue; | |
| 331 | } | |
| 332 | const direction = command.includes("Beginning") ? "backward" | |
| 333 | : command.includes("Left") ? "left" | |
| 334 | : command.includes("Right") ? "right" : "forward"; | |
| 335 | selection?.modify( | |
| 336 | command.endsWith("AndModifySelection") ? "extend" : "move", | |
| 337 | direction, | |
| 338 | command.includes("Document") ? "documentboundary" : "lineboundary", | |
| 339 | ); | |
| 340 | if (element instanceof HTMLInputElement && element.selectionStart !== null) { | |
| 341 | if (command.includes("Left") || command.includes("Beginning")) element.scrollLeft = 0; | |
| 342 | else element.scrollLeft = element.scrollWidth; | |
| 343 | } | |
| 344 | // Programmatic selection changes do not reveal the caret like native editing commands. | |
| 345 | if (editable && command.includes("Document")) { | |
| 346 | const beginning = command.includes("Beginning"); | |
| 347 | if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) { | |
| 348 | element.scrollTop = beginning ? 0 : element.scrollHeight; | |
| 349 | } else { | |
| 350 | const caretElement = selection?.focusNode?.nodeType === Node.ELEMENT_NODE | |
| 351 | ? selection.focusNode : selection?.focusNode?.parentElement; | |
| 352 | caretElement?.scrollIntoView({ block: beginning ? "start" : "end", inline: "nearest" }); | |
| 353 | } | |
| 354 | } | |
| 355 | } else { | |
| 356 | document.execCommand(command); | |
| 357 | } | |
| 358 | } | |
| 359 | } finally { | |
| 360 | element.dispatchEvent(new KeyboardEvent("keyup", event)); | |
| 361 | } | |
| 362 | })()`; | |
| 363 | } |
apps/mobile/app.config.ts+3| ... | ... | @@ -270,6 +270,9 @@ const config: ExpoConfig = { |
| 270 | 270 | NSLocalNetworkUsageDescription: |
| 271 | 271 | "Allow T3 Code to connect to T3 Code servers on your local network or tailnet.", |
| 272 | 272 | NSPhotoLibraryAddUsageDescription: "Allow T3 Code to save images to your photo library.", |
| 273 | // "Audio, AirPlay, and Picture in Picture": the browser screen's system | |
| 274 | // picture in picture needs it to start and to stay up outside the app. | |
| 275 | UIBackgroundModes: ["audio"], | |
| 273 | 276 | ITSAppUsesNonExemptEncryption: false, |
| 274 | 277 | // The App Store screenshot harness rotates the iPad interface from |
| 275 | 278 | // inside the app (CI denies osascript the Accessibility access that |
apps/mobile/metro.config.js+38-18| ... | ... | @@ -18,6 +18,7 @@ const licenseGeneratorSource = path.join( |
| 18 | 18 | const escapedWorkspaceRoot = workspaceRoot.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); |
| 19 | 19 | const mobileShikiRoot = path.dirname(require.resolve("shiki/package.json", { paths: [__dirname] })); |
| 20 | 20 | const generatedDeviceStreamRoot = path.join(__dirname, ".generated", "device-stream"); |
| 21 | const generatedPreviewStreamRoot = path.join(__dirname, ".generated", "preview-stream"); | |
| 21 | 22 | const resolveShikiDependencyRoot = (packageName) => { |
| 22 | 23 | const entryPath = require.resolve(packageName, { paths: [mobileShikiRoot] }); |
| 23 | 24 | let currentDir = path.dirname(entryPath); |
| ... | ... | @@ -48,6 +49,7 @@ config.resolver = { |
| 48 | 49 | ...config.resolver?.extraNodeModules, |
| 49 | 50 | "@t3tools/mobile-third-party-licenses": generatedLicenseModuleRoot, |
| 50 | 51 | "@t3tools/mobile-device-stream": generatedDeviceStreamRoot, |
| 52 | "@t3tools/mobile-preview-stream": generatedPreviewStreamRoot, | |
| 51 | 53 | shiki: mobileShikiRoot, |
| 52 | 54 | "@shikijs/core": resolveShikiDependencyRoot("@shikijs/core"), |
| 53 | 55 | "@shikijs/engine-javascript": resolveShikiDependencyRoot("@shikijs/engine-javascript"), |
| ... | ... | @@ -95,35 +97,53 @@ async function generateMobileThirdPartyLicenses() { |
| 95 | 97 | ]); |
| 96 | 98 | } |
| 97 | 99 | |
| 98 | async function prepareDeviceStream() { | |
| 99 | const { generateDeviceStreamScript } = await import( | |
| 100 | async function prepareStreamScripts() { | |
| 101 | const { generateDeviceStreamScript, generatePreviewStreamScript } = await import( | |
| 100 | 102 | pathToFileURL(path.join(__dirname, "scripts", "generate-device-stream.mts")).href |
| 101 | 103 | ); |
| 102 | await generateDeviceStreamScript(); | |
| 104 | const generateAll = () => | |
| 105 | Promise.all([generateDeviceStreamScript(), generatePreviewStreamScript()]); | |
| 106 | await generateAll(); | |
| 103 | 107 | if (process.env.NODE_ENV !== "production") { |
| 104 | 108 | 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 [ | |
| 107 | 110 | [ |
| 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, | |
| 110 | 126 | ], |
| 111 | 127 | ]) { |
| 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) => { | |
| 114 | 130 | 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 | }); | |
| 118 | 137 | }); |
| 119 | 138 | } |
| 120 | 139 | } |
| 121 | 140 | } |
| 122 | 141 | |
| 123 | module.exports = Promise.all([generateMobileThirdPartyLicenses(), prepareDeviceStream()]).then(() => | |
| 124 | withUniwindConfig(config, { | |
| 125 | cssEntryFile: "./global.css", | |
| 126 | extraThemes, | |
| 127 | polyfills: { rem: 14 }, | |
| 128 | }), | |
| 142 | module.exports = Promise.all([generateMobileThirdPartyLicenses(), prepareStreamScripts()]).then( | |
| 143 | () => | |
| 144 | withUniwindConfig(config, { | |
| 145 | cssEntryFile: "./global.css", | |
| 146 | extraThemes, | |
| 147 | polyfills: { rem: 14 }, | |
| 148 | }), | |
| 129 | 149 | ); |
apps/mobile/scripts/generate-device-stream.mts+12-8| ... | ... | @@ -5,8 +5,9 @@ import { build } from "vite-plus"; |
| 5 | 5 | |
| 6 | 6 | const mobileRoot = NodePath.resolve(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), ".."); |
| 7 | 7 | |
| 8 | /** Metro embeds the shared browser transport as a small script in the native WebView. */ | |
| 9 | export async function generateDeviceStreamScript() { | |
| 8 | /** Metro embeds each shared browser transport as a small script in a native WebView. */ | |
| 9 | async function generateWebViewScript(feature: string, name: string) { | |
| 10 | const stem = `${name.toLowerCase()}-stream`; | |
| 10 | 11 | const result = await build({ |
| 11 | 12 | configFile: false, |
| 12 | 13 | logLevel: "silent", |
| ... | ... | @@ -15,8 +16,8 @@ export async function generateDeviceStreamScript() { |
| 15 | 16 | target: "es2022", |
| 16 | 17 | minify: true, |
| 17 | 18 | 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`, | |
| 20 | 21 | formats: ["iife"], |
| 21 | 22 | }, |
| 22 | 23 | }, |
| ... | ... | @@ -25,15 +26,18 @@ export async function generateDeviceStreamScript() { |
| 25 | 26 | const chunk = bundles |
| 26 | 27 | .flatMap((bundle) => ("output" in bundle ? bundle.output : [])) |
| 27 | 28 | .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); | |
| 30 | 31 | await NodeFSP.mkdir(root, { recursive: true }); |
| 31 | for (const [name, contents] of [ | |
| 32 | for (const [file, contents] of [ | |
| 32 | 33 | ["index.js", `module.exports = ${JSON.stringify(chunk.code)};\n`], |
| 33 | 34 | ["package.json", '{"main":"index.js"}\n'], |
| 34 | 35 | ] as const) { |
| 35 | const destination = NodePath.join(root, name); | |
| 36 | const destination = NodePath.join(root, file); | |
| 36 | 37 | const previous = await NodeFSP.readFile(destination, "utf8").catch(() => null); |
| 37 | 38 | if (previous !== contents) await NodeFSP.writeFile(destination, contents); |
| 38 | 39 | } |
| 39 | 40 | } |
| 41 | ||
| 42 | export const generateDeviceStreamScript = () => generateWebViewScript("devices", "Device"); | |
| 43 | export const generatePreviewStreamScript = () => generateWebViewScript("browser", "Preview"); |
apps/mobile/src/Stack.tsx+11| ... | ... | @@ -43,6 +43,7 @@ import { ReviewCommentComposerSheet } from "./features/review/ReviewCommentCompo |
| 43 | 43 | import { ReviewSheet } from "./features/review/ReviewSheet"; |
| 44 | 44 | import { ThreadTerminalRouteScreen } from "./features/terminal/ThreadTerminalRouteScreen"; |
| 45 | 45 | import { DevicePreviewRouteScreen } from "./features/devices/DevicePreviewRouteScreen"; |
| 46 | import { BrowserPreviewRouteScreen } from "./features/browser/BrowserPreviewRouteScreen"; | |
| 46 | 47 | import { GitBranchesSheet } from "./features/threads/git/GitBranchesSheet"; |
| 47 | 48 | import { GitCommitSheet } from "./features/threads/git/GitCommitSheet"; |
| 48 | 49 | import { GitConfirmSheet } from "./features/threads/git/GitConfirmSheet"; |
| ... | ... | @@ -548,6 +549,7 @@ const WORKSPACE_OVERLAY_ROUTES = new Set([ |
| 548 | 549 | "ThreadQueue", |
| 549 | 550 | "ThreadReviewComment", |
| 550 | 551 | "ThreadDevicePreview", |
| 552 | "ThreadBrowserPreview", | |
| 551 | 553 | "ThreadSettingsSheet", |
| 552 | 554 | ]); |
| 553 | 555 | |
| ... | ... | @@ -705,6 +707,15 @@ const RootStackConfig = createNativeStackNavigator({ |
| 705 | 707 | navigationBarHidden: true, |
| 706 | 708 | }, |
| 707 | 709 | }), |
| 710 | ThreadBrowserPreview: createNativeStackScreen({ | |
| 711 | screen: BrowserPreviewRouteScreen, | |
| 712 | linking: `${THREAD_LINKING_PREFIX}/browser`, | |
| 713 | options: { | |
| 714 | presentation: "fullScreenModal", | |
| 715 | headerShown: false, | |
| 716 | gestureEnabled: false, | |
| 717 | }, | |
| 718 | }), | |
| 708 | 719 | ThreadReview: createNativeStackScreen({ |
| 709 | 720 | screen: ReviewSheet, |
| 710 | 721 | linking: `${THREAD_LINKING_PREFIX}/review`, |
apps/mobile/src/features/browser/BrowserPreviewRouteScreen.tsx created+273| ... | ... | @@ -0,0 +1,273 @@ |
| 1 | import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native"; | |
| 2 | import { createNativeStackNavigator } from "@react-navigation/native-stack"; | |
| 3 | import { | |
| 4 | EnvironmentId, | |
| 5 | FILL_PREVIEW_VIEWPORT, | |
| 6 | ThreadId, | |
| 7 | type PreviewSessionSnapshot, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | import { normalizePreviewUrl } from "@t3tools/shared/preview"; | |
| 10 | import { useCallback, useEffect, useRef, useState } from "react"; | |
| 11 | import { ActivityIndicator, Alert, AppState, Platform, TextInput, View } from "react-native"; | |
| 12 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | |
| 13 | ||
| 14 | import { ControlPill } from "../../components/ControlPill"; | |
| 15 | import { ScreenHeader } from "../../components/ScreenHeader"; | |
| 16 | import { NativeHeaderToolbar } from "../../native/StackHeader"; | |
| 17 | import { useThreadServerBrowserTabs } from "../../state/preview"; | |
| 18 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | |
| 19 | import { BrowserTabMenu } from "./BrowserTabMenu"; | |
| 20 | import { browserTabTitle, browserTabUrl, latestBrowserTab } from "./browserTabs"; | |
| 21 | import { | |
| 22 | PreviewStreamWebView, | |
| 23 | type PreviewPictureInPictureState, | |
| 24 | type PreviewStreamRef, | |
| 25 | } from "./PreviewStreamWebView"; | |
| 26 | ||
| 27 | const BrowserPreviewStack = createNativeStackNavigator<{ BrowserPreview: undefined }>(); | |
| 28 | ||
| 29 | const NO_PICTURE_IN_PICTURE: PreviewPictureInPictureState = { supported: false, active: false }; | |
| 30 | ||
| 31 | type 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. */ | |
| 38 | export 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 | ||
| 67 | function 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 | ||
| 207 | function 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 @@ |
| 1 | import type { MenuAction } from "@react-native-menu/menu"; | |
| 2 | import { | |
| 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"; | |
| 11 | import { PREVIEW_VIEWPORT_PRESETS } from "@t3tools/shared/previewViewport"; | |
| 12 | import { Alert } from "react-native"; | |
| 13 | ||
| 14 | import { ControlPill, ControlPillMenu } from "../../components/ControlPill"; | |
| 15 | import { previewEnvironment } from "../../state/preview"; | |
| 16 | import { useAtomCommand } from "../../state/use-atom-command"; | |
| 17 | ||
| 18 | const 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. */ | |
| 25 | const 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 | */ | |
| 45 | export 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 @@ |
| 1 | import previewStreamScript from "@t3tools/mobile-preview-stream"; | |
| 2 | import { | |
| 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"; | |
| 10 | import type { EnvironmentId, PreviewStreamHostSetup } from "@t3tools/contracts"; | |
| 11 | import { | |
| 12 | useEffect, | |
| 13 | useEffectEvent, | |
| 14 | useImperativeHandle, | |
| 15 | useLayoutEffect, | |
| 16 | useMemo, | |
| 17 | useRef, | |
| 18 | useState, | |
| 19 | type Ref, | |
| 20 | } from "react"; | |
| 21 | import { ActivityIndicator, Alert, Platform, Pressable, TextInput, View } from "react-native"; | |
| 22 | import { WebView } from "react-native-webview"; | |
| 23 | import * as Clipboard from "expo-clipboard"; | |
| 24 | ||
| 25 | import { AppText } from "../../components/AppText"; | |
| 26 | import { downloadAndShareAttachment } from "../../lib/attachmentDownload"; | |
| 27 | import { beginForegroundHandoff } from "../../lib/foreground-handoff"; | |
| 28 | import { usePreviewStreamAccess } from "../../state/preview"; | |
| 29 | ||
| 30 | import { | |
| 31 | previewStreamDocument, | |
| 32 | previewStreamMessage, | |
| 33 | type PreviewStreamConfiguration, | |
| 34 | } from "./preview-stream-document"; | |
| 35 | ||
| 36 | export interface PreviewStreamRef { | |
| 37 | /** Navigation requires current control of the browser. */ | |
| 38 | readonly command: (input: PreviewStreamInput) => void; | |
| 39 | readonly togglePictureInPicture: () => void; | |
| 40 | } | |
| 41 | ||
| 42 | export interface PreviewPictureInPictureState { | |
| 43 | readonly supported: boolean; | |
| 44 | readonly active: boolean; | |
| 45 | } | |
| 46 | ||
| 47 | type 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. */ | |
| 63 | async 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. */ | |
| 92 | function 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. | |
| 114 | const MAX_REFUSALS = 3; | |
| 115 | ||
| 116 | export 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 | ||
| 161 | function 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 | ||
| 227 | function 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 @@ |
| 1 | import { useIsFocused } from "@react-navigation/native"; | |
| 2 | import type { EnvironmentId, PreviewSessionSnapshot, ThreadId } from "@t3tools/contracts"; | |
| 3 | import { useCallback, useEffect, useRef, useState } from "react"; | |
| 4 | import { AppState, Pressable, View } from "react-native"; | |
| 5 | import Animated, { FadeIn, FadeOut, ReduceMotion } from "react-native-reanimated"; | |
| 6 | ||
| 7 | import { SymbolView } from "../../components/AppSymbol"; | |
| 8 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | |
| 9 | import { browserTabTitle } from "./browserTabs"; | |
| 10 | import { PreviewStreamWebView } from "./PreviewStreamWebView"; | |
| 11 | ||
| 12 | const PLAYER_LONG_SIDE = 184; | |
| 13 | const PLAYER_ENTERING = FadeIn.duration(180).reduceMotion(ReduceMotion.System); | |
| 14 | const PLAYER_EXITING = FadeOut.duration(120).reduceMotion(ReduceMotion.System); | |
| 15 | ||
| 16 | export 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 | ||
| 71 | function 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 @@ |
| 1 | import { Pressable, View } from "react-native"; | |
| 2 | ||
| 3 | import { AppText } from "../../components/AppText"; | |
| 4 | import { SymbolView } from "../../components/AppSymbol"; | |
| 5 | ||
| 6 | /** Opens the thread's server-hosted browser tabs. Sits beside the device preview button. */ | |
| 7 | export 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 @@ |
| 1 | import type { PreviewSessionSnapshot } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | /** The tab the agent touched last, the default when nothing is selected. */ | |
| 4 | export 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 | ||
| 11 | export function browserTabUrl(tab: PreviewSessionSnapshot) { | |
| 12 | return tab.navStatus._tag === "Idle" ? "" : tab.navStatus.url; | |
| 13 | } | |
| 14 | ||
| 15 | export 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 @@ |
| 1 | import type { DeviceHubAccess } from "@t3tools/client-runtime/device/hub-access"; | |
| 2 | import type { PreviewStreamHostSetup } from "@t3tools/contracts"; | |
| 3 | import type { | |
| 4 | PreviewStreamControl, | |
| 5 | PreviewStreamDownload, | |
| 6 | PreviewStreamFileChooser, | |
| 7 | } from "@t3tools/client-runtime/preview/server-browser-stream"; | |
| 8 | ||
| 9 | export 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. */ | |
| 19 | export 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 | ||
| 40 | export 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 | ||
| 77 | export 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 @@ |
| 1 | import { | |
| 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 | ||
| 12 | import type { PreviewStreamConfiguration, PreviewStreamMessage } from "./preview-stream-document"; | |
| 13 | ||
| 14 | declare 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. */ | |
| 21 | interface PresentationVideo extends HTMLVideoElement { | |
| 22 | webkitSetPresentationMode?: (mode: "inline" | "picture-in-picture") => void; | |
| 23 | webkitPresentationMode?: string; | |
| 24 | } | |
| 25 | ||
| 26 | type WheelInput = Extract<PreviewStreamInput, { type: "wheel" }>; | |
| 27 | type MouseInput = Extract<PreviewStreamInput, { type: "mouse" }>; | |
| 28 | const mouseButton = (button: number): MouseInput["button"] => | |
| 29 | button === 0 ? "left" : button === 1 ? "middle" : button === 2 ? "right" : "none"; | |
| 30 | ||
| 31 | const RESIZE_DEBOUNCE_MS = 150; | |
| 32 | const TAP_SLOP_PX = 8; | |
| 33 | const MULTI_CLICK_MS = 500; | |
| 34 | const MULTI_CLICK_SLOP_PX = 4; | |
| 35 | const WHEEL_LINE_PX = 16; | |
| 36 | // JPEG frames past 2x cost bandwidth without a visible gain on a phone. | |
| 37 | const 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. | |
| 40 | const PROBE_REUSE_PX = 24; | |
| 41 | const 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. | |
| 44 | const SENTINEL = "\u200b"; | |
| 45 | // The agent cursor stays bright while the agent acts, then dims like the web panel's. | |
| 46 | const AGENT_CURSOR_ACTIVE_MS = 700; | |
| 47 | const 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 | ||
| 50 | interface Viewer { | |
| 51 | readonly stop: () => void; | |
| 52 | readonly command: (input: PreviewStreamInput) => void; | |
| 53 | readonly togglePictureInPicture: () => Promise<void>; | |
| 54 | } | |
| 55 | ||
| 56 | let activeViewer: Viewer | null = null; | |
| 57 | ||
| 58 | export function stop() { | |
| 59 | activeViewer?.stop(); | |
| 60 | activeViewer = null; | |
| 61 | } | |
| 62 | ||
| 63 | /** Navigation and history from the native chrome require current browser control. */ | |
| 64 | export function command(input: PreviewStreamInput) { | |
| 65 | activeViewer?.command(input); | |
| 66 | } | |
| 67 | ||
| 68 | export function pictureInPicture() { | |
| 69 | void activeViewer?.togglePictureInPicture(); | |
| 70 | } | |
| 71 | ||
| 72 | export function start(configuration: PreviewStreamConfiguration) { | |
| 73 | stop(); | |
| 74 | const post = (message: PreviewStreamMessage) => { | |
| 75 | // oxlint-disable-next-line unicorn/require-post-message-target-origin -- The native WebView bridge takes one string. | |
| 76 | window.ReactNativeWebView.postMessage(JSON.stringify(message)); | |
| 77 | }; | |
| 78 | const { interactive } = configuration; | |
| 79 | document.body.style.background = configuration.background; | |
| 80 | const container = document.createElement("div"); | |
| 81 | const canvas = document.createElement("canvas"); | |
| 82 | canvas.setAttribute("role", "img"); | |
| 83 | canvas.setAttribute("aria-label", "Browser page"); | |
| 84 | const agentCursor = document.createElement("div"); | |
| 85 | agentCursor.className = "agent-cursor"; | |
| 86 | agentCursor.setAttribute("aria-hidden", "true"); | |
| 87 | agentCursor.innerHTML = AGENT_CURSOR_SVG; | |
| 88 | container.append(canvas, agentCursor); | |
| 89 | const input = document.createElement("textarea"); | |
| 90 | input.setAttribute("aria-label", "Browser page input"); | |
| 91 | input.autocapitalize = "off"; | |
| 92 | input.autocomplete = "off"; | |
| 93 | input.spellcheck = false; | |
| 94 | input.setAttribute("autocorrect", "off"); | |
| 95 | input.disabled = true; | |
| 96 | document.body.replaceChildren(container, ...(interactive ? [input] : [])); | |
| 97 | ||
| 98 | let stopped = false; | |
| 99 | let streaming = false; | |
| 100 | let viewport: PreviewStreamViewport | null = null; | |
| 101 | let size: { width: number; height: number } | null = null; | |
| 102 | // Frame cap in device px, fixed per socket. It only grows, so only outgrowing it reconnects. | |
| 103 | let cap: { width: number; height: number } | null = null; | |
| 104 | let client: PreviewStreamClient | null = null; | |
| 105 | let control: PreviewStreamControl | null = null; | |
| 106 | let resizeTimer: ReturnType<typeof setTimeout> | null = null; | |
| 107 | let wheelFrame: number | null = null; | |
| 108 | let pendingWheel: WheelInput | null = null; | |
| 109 | let mouseFrame: number | null = null; | |
| 110 | let pendingMouse: MouseInput | null = null; | |
| 111 | let mouseButtons = 0; | |
| 112 | const mouseClicks = { left: 1, middle: 1, right: 1, none: 1 }; | |
| 113 | // The latest probe. `editable` is the answer its tap acts on, null until known; | |
| 114 | // `end` records what touch end did, so a late answer can still act or correct it. | |
| 115 | let probe: { | |
| 116 | readonly x: number; | |
| 117 | readonly y: number; | |
| 118 | readonly clientX: number; | |
| 119 | readonly clientY: number; | |
| 120 | editable: boolean | null; | |
| 121 | end: "touching" | "acted" | "late" | "panned"; | |
| 122 | } | null = null; | |
| 123 | let probeCache: { | |
| 124 | readonly clientX: number; | |
| 125 | readonly clientY: number; | |
| 126 | readonly editable: boolean; | |
| 127 | readonly time: number; | |
| 128 | } | null = null; | |
| 129 | // A keydown already sent this key; its `input` must not send it again. | |
| 130 | let keySent = false; | |
| 131 | let touch: { | |
| 132 | start: PointerEvent; | |
| 133 | last: PointerEvent; | |
| 134 | panning: boolean; | |
| 135 | } | null = null; | |
| 136 | let lastTap: { | |
| 137 | time: number; | |
| 138 | x: number; | |
| 139 | y: number; | |
| 140 | count: number; | |
| 141 | button: MouseInput["button"]; | |
| 142 | } | null = null; | |
| 143 | ||
| 144 | const reportStatus = (status: "connecting" | "streaming") => { | |
| 145 | streaming = status === "streaming"; | |
| 146 | post({ type: "status", status }); | |
| 147 | }; | |
| 148 | const painter = createPreviewFramePainter(canvas, () => { | |
| 149 | if (!streaming && !stopped) reportStatus("streaming"); | |
| 150 | }); | |
| 151 | const send = (message: PreviewStreamInput) => client?.send(message) ?? false; | |
| 152 | const clearInput = () => { | |
| 153 | if (wheelFrame !== null) cancelAnimationFrame(wheelFrame); | |
| 154 | if (mouseFrame !== null) cancelAnimationFrame(mouseFrame); | |
| 155 | wheelFrame = mouseFrame = null; | |
| 156 | pendingWheel = pendingMouse = null; | |
| 157 | mouseButtons = 0; | |
| 158 | Object.assign(mouseClicks, { left: 1, middle: 1, right: 1, none: 1 }); | |
| 159 | probe = probeCache = touch = lastTap = null; | |
| 160 | keySent = false; | |
| 161 | input.value = SENTINEL; | |
| 162 | input.disabled = true; | |
| 163 | input.blur(); | |
| 164 | }; | |
| 165 | ||
| 166 | const connect = () => { | |
| 167 | // Stopping suppresses the old socket's disconnect report; native also drops its control. | |
| 168 | if (client) reportStatus("connecting"); | |
| 169 | client?.stop(); | |
| 170 | control = null; | |
| 171 | clearInput(); | |
| 172 | if (!cap || stopped) return; | |
| 173 | const next = createPreviewStreamClient( | |
| 174 | { | |
| 175 | access: configuration.access, | |
| 176 | threadId: configuration.threadId, | |
| 177 | tabId: configuration.tabId, | |
| 178 | maxWidth: cap.width, | |
| 179 | maxHeight: cap.height, | |
| 180 | interactive, | |
| 181 | }, | |
| 182 | { | |
| 183 | onFrame: (jpeg) => painter.paint(jpeg), | |
| 184 | onPointer: showAgentCursor, | |
| 185 | onClipboard: (text) => post({ type: "clipboard", text }), | |
| 186 | onDownload: (download) => post({ type: "download", ...download }), | |
| 187 | onFileChooser: (chooser) => post({ type: "fileChooser", chooser }), | |
| 188 | onViewport: (page) => { | |
| 189 | if (viewport?.width === page.width && viewport.height === page.height) return; | |
| 190 | viewport = page; | |
| 191 | post({ type: "viewport", width: page.width, height: page.height }); | |
| 192 | }, | |
| 193 | onProbe: (result) => { | |
| 194 | if (control?.controller !== "you") return; | |
| 195 | const current = probe; | |
| 196 | if (!current || current.x !== result.x || current.y !== result.y) return; | |
| 197 | probeCache = { | |
| 198 | clientX: current.clientX, | |
| 199 | clientY: current.clientY, | |
| 200 | editable: result.editable, | |
| 201 | time: performance.now(), | |
| 202 | }; | |
| 203 | if (current.end === "touching") { | |
| 204 | current.editable = result.editable; | |
| 205 | return; | |
| 206 | } | |
| 207 | probe = null; | |
| 208 | if (current.end === "panned") return; | |
| 209 | if (current.end === "acted" && current.editable === result.editable) return; | |
| 210 | // Late or corrected answer: Android still raises the keyboard; iOS waits | |
| 211 | // for the next tap, which can reuse this answer. | |
| 212 | if (result.editable) input.focus({ preventScroll: true }); | |
| 213 | else input.blur(); | |
| 214 | }, | |
| 215 | onControl: (nextControl) => { | |
| 216 | const previous = control; | |
| 217 | control = nextControl; | |
| 218 | post({ type: "control", ...nextControl }); | |
| 219 | // Taking over hides the agent cursor; the person's own touch is the pointer now. | |
| 220 | if (nextControl.controller === "you") agentCursor.style.opacity = "0"; | |
| 221 | if (nextControl.controller !== "you") clearInput(); | |
| 222 | else { | |
| 223 | input.disabled = !interactive; | |
| 224 | if (interactive && size && previous?.controller !== "you") | |
| 225 | next.send({ type: "resize", ...size }); | |
| 226 | } | |
| 227 | }, | |
| 228 | onConnectedChange: (connected) => { | |
| 229 | if (!connected) { | |
| 230 | control = null; | |
| 231 | clearInput(); | |
| 232 | if (streaming) reportStatus("connecting"); | |
| 233 | return; | |
| 234 | } | |
| 235 | }, | |
| 236 | onUnauthorized: () => post({ type: "unauthorized" }), | |
| 237 | onGone: () => post({ type: "gone" }), | |
| 238 | onHostSetup: (setup) => post({ type: "hostSetup", ...setup }), | |
| 239 | }, | |
| 240 | ); | |
| 241 | client = next; | |
| 242 | }; | |
| 243 | ||
| 244 | const measure = () => { | |
| 245 | resizeTimer = null; | |
| 246 | const rect = container.getBoundingClientRect(); | |
| 247 | if (rect.width < 1 || rect.height < 1) return; | |
| 248 | const next = { width: Math.round(rect.width), height: Math.round(rect.height) }; | |
| 249 | if (size?.width === next.width && size.height === next.height) return; | |
| 250 | size = next; | |
| 251 | if (interactive) send({ type: "resize", ...next }); | |
| 252 | const ratio = Math.min(window.devicePixelRatio || 1, MAX_PIXEL_RATIO); | |
| 253 | // The floating player scales a page of any shape into its box, so its cap is square. | |
| 254 | const side = Math.max(next.width, next.height); | |
| 255 | const width = Math.round((interactive ? next.width : side) * ratio); | |
| 256 | const height = Math.round((interactive ? next.height : side) * ratio); | |
| 257 | if (cap && cap.width >= width && cap.height >= height) return; | |
| 258 | cap = { width: Math.max(width, cap?.width ?? 0), height: Math.max(height, cap?.height ?? 0) }; | |
| 259 | connect(); | |
| 260 | }; | |
| 261 | const observer = new ResizeObserver(() => { | |
| 262 | if (resizeTimer !== null) clearTimeout(resizeTimer); | |
| 263 | // The first size connects right away; later ones settle before resizing the page. | |
| 264 | resizeTimer = setTimeout(measure, size === null ? 0 : RESIZE_DEBOUNCE_MS); | |
| 265 | }); | |
| 266 | observer.observe(container); | |
| 267 | ||
| 268 | // iOS keeps the layout viewport under the soft keyboard; follow the visible area | |
| 269 | // so the page resizes above it. | |
| 270 | const visualViewport = window.visualViewport; | |
| 271 | const followVisualViewport = () => { | |
| 272 | if (!visualViewport) return; | |
| 273 | container.style.top = `${visualViewport.offsetTop}px`; | |
| 274 | container.style.height = `${visualViewport.height}px`; | |
| 275 | }; | |
| 276 | if (interactive) { | |
| 277 | visualViewport?.addEventListener("resize", followVisualViewport); | |
| 278 | visualViewport?.addEventListener("scroll", followVisualViewport); | |
| 279 | } | |
| 280 | ||
| 281 | let agentCursorTimer: ReturnType<typeof setTimeout> | null = null; | |
| 282 | /** Mirrors the frame's letterbox so the cursor lands on the element the agent targets. */ | |
| 283 | const showAgentCursor = (pointer: PreviewStreamPointer) => { | |
| 284 | if (!viewport || canvas.width === 0 || canvas.height === 0 || control?.controller === "you") | |
| 285 | return; | |
| 286 | const box = { width: canvas.clientWidth, height: canvas.clientHeight }; | |
| 287 | const fit = Math.min(box.width / canvas.width, box.height / canvas.height); | |
| 288 | const width = canvas.width * fit; | |
| 289 | const height = canvas.height * fit; | |
| 290 | const left = (box.width - width) / 2 + (pointer.x * width) / viewport.width; | |
| 291 | const top = (box.height - height) / 2 + (pointer.y * height) / viewport.height; | |
| 292 | agentCursor.style.transform = `translate3d(${left}px, ${top}px, 0)`; | |
| 293 | agentCursor.style.opacity = "1"; | |
| 294 | if (pointer.phase === "click") { | |
| 295 | agentCursor.querySelector(".ping")?.remove(); | |
| 296 | const ping = document.createElement("span"); | |
| 297 | ping.className = "ping"; | |
| 298 | agentCursor.prepend(ping); | |
| 299 | } | |
| 300 | if (agentCursorTimer !== null) clearTimeout(agentCursorTimer); | |
| 301 | agentCursorTimer = setTimeout(() => { | |
| 302 | agentCursor.style.opacity = control?.controller === "agent" ? "0.35" : "0"; | |
| 303 | }, AGENT_CURSOR_ACTIVE_MS); | |
| 304 | }; | |
| 305 | ||
| 306 | const pagePoint = (clientX: number, clientY: number, clamp: boolean) => { | |
| 307 | if (!viewport || canvas.width === 0 || canvas.height === 0 || control?.controller !== "you") | |
| 308 | return null; | |
| 309 | const rect = canvas.getBoundingClientRect(); | |
| 310 | // `object-fit: contain` letterboxes the frame inside the canvas box. | |
| 311 | const fit = Math.min(rect.width / canvas.width, rect.height / canvas.height); | |
| 312 | const width = canvas.width * fit; | |
| 313 | const height = canvas.height * fit; | |
| 314 | if (!(width > 0 && height > 0)) return null; | |
| 315 | const scale = viewport.width / width; | |
| 316 | const x = (clientX - rect.left - (rect.width - width) / 2) * scale; | |
| 317 | const y = (clientY - rect.top - (rect.height - height) / 2) * (viewport.height / height); | |
| 318 | const inside = x >= 0 && y >= 0 && x <= viewport.width && y <= viewport.height; | |
| 319 | if (!inside && !clamp) return null; | |
| 320 | return { | |
| 321 | x: Math.min(Math.max(x, 0), viewport.width), | |
| 322 | y: Math.min(Math.max(y, 0), viewport.height), | |
| 323 | scale, | |
| 324 | }; | |
| 325 | }; | |
| 326 | ||
| 327 | // Wheel deltas coalesce to one message per animation frame. | |
| 328 | const flushWheel = () => { | |
| 329 | if (wheelFrame !== null) cancelAnimationFrame(wheelFrame); | |
| 330 | wheelFrame = null; | |
| 331 | const wheel = pendingWheel; | |
| 332 | pendingWheel = null; | |
| 333 | if (wheel) send(wheel); | |
| 334 | }; | |
| 335 | const queueWheel = (point: { x: number; y: number }, deltaX: number, deltaY: number) => { | |
| 336 | pendingWheel = { | |
| 337 | type: "wheel", | |
| 338 | x: point.x, | |
| 339 | y: point.y, | |
| 340 | deltaX: (pendingWheel?.deltaX ?? 0) + deltaX, | |
| 341 | deltaY: (pendingWheel?.deltaY ?? 0) + deltaY, | |
| 342 | modifiers: 0, | |
| 343 | }; | |
| 344 | wheelFrame ??= requestAnimationFrame(flushWheel); | |
| 345 | }; | |
| 346 | ||
| 347 | const flushMouse = () => { | |
| 348 | if (mouseFrame !== null) cancelAnimationFrame(mouseFrame); | |
| 349 | mouseFrame = null; | |
| 350 | if (pendingMouse) send(pendingMouse); | |
| 351 | pendingMouse = null; | |
| 352 | }; | |
| 353 | const countClick = (event: PointerEvent, button: MouseInput["button"]) => { | |
| 354 | const last = lastTap; | |
| 355 | const count = | |
| 356 | last && | |
| 357 | last.button === button && | |
| 358 | event.timeStamp - last.time < MULTI_CLICK_MS && | |
| 359 | Math.hypot(event.clientX - last.x, event.clientY - last.y) < MULTI_CLICK_SLOP_PX | |
| 360 | ? last.count + 1 | |
| 361 | : 1; | |
| 362 | lastTap = { time: event.timeStamp, x: event.clientX, y: event.clientY, count, button }; | |
| 363 | return count; | |
| 364 | }; | |
| 365 | const mouseInput = ( | |
| 366 | event: PointerEvent, | |
| 367 | point: { x: number; y: number }, | |
| 368 | action: MouseInput["action"], | |
| 369 | button: MouseInput["button"], | |
| 370 | buttons = event.buttons, | |
| 371 | clickCount = mouseClicks[button], | |
| 372 | ): MouseInput => ({ | |
| 373 | type: "mouse", | |
| 374 | action, | |
| 375 | x: point.x, | |
| 376 | y: point.y, | |
| 377 | button, | |
| 378 | buttons, | |
| 379 | clickCount, | |
| 380 | modifiers: previewStreamModifiers(event), | |
| 381 | }); | |
| 382 | const onPointerDown = (event: PointerEvent) => { | |
| 383 | if (control?.controller !== "you") return; | |
| 384 | if (!event.isPrimary) return; | |
| 385 | event.preventDefault(); | |
| 386 | if (event.pointerType !== "touch") { | |
| 387 | const point = pagePoint(event.clientX, event.clientY, false); | |
| 388 | if (!point) return; | |
| 389 | canvas.setPointerCapture(event.pointerId); | |
| 390 | input.focus({ preventScroll: true }); | |
| 391 | flushMouse(); | |
| 392 | flushWheel(); | |
| 393 | mouseButtons = event.buttons & 7; | |
| 394 | const button = mouseButton(event.button); | |
| 395 | mouseClicks[button] = countClick(event, button); | |
| 396 | send(mouseInput(event, point, "down", button)); | |
| 397 | return; | |
| 398 | } | |
| 399 | touch = { start: event, last: event, panning: false }; | |
| 400 | const point = pagePoint(event.clientX, event.clientY, false); | |
| 401 | if (!point) { | |
| 402 | probe = null; | |
| 403 | return; | |
| 404 | } | |
| 405 | const cached = probeCache; | |
| 406 | const reuse = | |
| 407 | cached !== null && | |
| 408 | performance.now() - cached.time < PROBE_REUSE_MS && | |
| 409 | Math.hypot(event.clientX - cached.clientX, event.clientY - cached.clientY) <= PROBE_REUSE_PX; | |
| 410 | probe = { | |
| 411 | x: point.x, | |
| 412 | y: point.y, | |
| 413 | clientX: event.clientX, | |
| 414 | clientY: event.clientY, | |
| 415 | editable: reuse ? cached.editable : null, | |
| 416 | end: "touching", | |
| 417 | }; | |
| 418 | // Sent even with a cached answer, so the next tap reuses a fresh one. | |
| 419 | send({ type: "probe", x: point.x, y: point.y }); | |
| 420 | }; | |
| 421 | const onPointerMove = (event: PointerEvent) => { | |
| 422 | if (control?.controller !== "you") return; | |
| 423 | if (event.pointerType !== "touch") { | |
| 424 | const point = pagePoint(event.clientX, event.clientY, mouseButtons !== 0); | |
| 425 | if (!point) return; | |
| 426 | // Chorded presses and releases arrive as pointermove while another button is held. | |
| 427 | const changed = mouseButtons ^ (event.buttons & 7); | |
| 428 | if (mouseButtons !== 0 && changed !== 0) { | |
| 429 | flushMouse(); | |
| 430 | flushWheel(); | |
| 431 | for (const bit of [1, 2, 4]) { | |
| 432 | if (!(changed & bit)) continue; | |
| 433 | const button = bit === 1 ? "left" : bit === 2 ? "right" : "middle"; | |
| 434 | const down = (event.buttons & bit) !== 0; | |
| 435 | mouseButtons ^= bit; | |
| 436 | if (down) mouseClicks[button] = countClick(event, button); | |
| 437 | send(mouseInput(event, point, down ? "down" : "up", button, mouseButtons)); | |
| 438 | } | |
| 439 | } | |
| 440 | const button = | |
| 441 | mouseButtons & 1 | |
| 442 | ? "left" | |
| 443 | : mouseButtons & 2 | |
| 444 | ? "right" | |
| 445 | : mouseButtons & 4 | |
| 446 | ? "middle" | |
| 447 | : "none"; | |
| 448 | pendingMouse = mouseInput(event, point, "move", button, event.buttons, 0); | |
| 449 | mouseFrame ??= requestAnimationFrame(flushMouse); | |
| 450 | return; | |
| 451 | } | |
| 452 | if (!touch || touch.start.pointerId !== event.pointerId) return; | |
| 453 | if ( | |
| 454 | !touch.panning && | |
| 455 | Math.hypot(event.clientX - touch.start.clientX, event.clientY - touch.start.clientY) < | |
| 456 | TAP_SLOP_PX | |
| 457 | ) { | |
| 458 | return; | |
| 459 | } | |
| 460 | touch.panning = true; | |
| 461 | const point = pagePoint(event.clientX, event.clientY, true); | |
| 462 | // Dragging the page up scrolls it down, following the finger. | |
| 463 | if (point) { | |
| 464 | queueWheel( | |
| 465 | point, | |
| 466 | (touch.last.clientX - event.clientX) * point.scale, | |
| 467 | (touch.last.clientY - event.clientY) * point.scale, | |
| 468 | ); | |
| 469 | } | |
| 470 | touch.last = event; | |
| 471 | }; | |
| 472 | const releaseMouse = (event: PointerEvent, cancelled: boolean) => { | |
| 473 | let buttons = mouseButtons; | |
| 474 | if (buttons === 0) return; | |
| 475 | mouseButtons = cancelled ? 0 : event.buttons & 7; | |
| 476 | flushMouse(); | |
| 477 | flushWheel(); | |
| 478 | const point = pagePoint(event.clientX, event.clientY, true); | |
| 479 | if (!point) return; | |
| 480 | for (const bit of [1, 2, 4]) { | |
| 481 | if (!(buttons & bit) || (mouseButtons & bit) !== 0) continue; | |
| 482 | buttons &= ~bit; | |
| 483 | const button = bit === 1 ? "left" : bit === 2 ? "right" : "middle"; | |
| 484 | send(mouseInput(event, point, "up", button, buttons)); | |
| 485 | } | |
| 486 | }; | |
| 487 | const onPointerEnd = (event: PointerEvent) => { | |
| 488 | const cancelled = event.type === "pointercancel"; | |
| 489 | if (event.pointerType !== "touch") { | |
| 490 | releaseMouse(event, cancelled); | |
| 491 | return; | |
| 492 | } | |
| 493 | const ended = touch; | |
| 494 | if (!ended || ended.start.pointerId !== event.pointerId) return; | |
| 495 | touch = null; | |
| 496 | const answered = probe; | |
| 497 | if (cancelled || ended.panning) { | |
| 498 | if (answered) answered.end = "panned"; | |
| 499 | return; | |
| 500 | } | |
| 501 | const point = pagePoint(event.clientX, event.clientY, false); | |
| 502 | if (!point) return; | |
| 503 | // Focusing inside the tap's user activation is what lets iOS raise the keyboard. | |
| 504 | if (answered?.editable === true) input.focus({ preventScroll: true }); | |
| 505 | else if (answered?.editable === false) input.blur(); | |
| 506 | if (answered) answered.end = answered.editable === null ? "late" : "acted"; | |
| 507 | flushWheel(); | |
| 508 | const clickCount = countClick(event, "left"); | |
| 509 | const at = { x: point.x, y: point.y, modifiers: 0 }; | |
| 510 | send({ type: "mouse", action: "move", ...at, button: "none", buttons: 0, clickCount: 0 }); | |
| 511 | send({ type: "mouse", action: "down", ...at, button: "left", buttons: 1, clickCount }); | |
| 512 | send({ type: "mouse", action: "up", ...at, button: "left", buttons: 0, clickCount }); | |
| 513 | }; | |
| 514 | // Trackpads and mice on tablets scroll with wheel events. | |
| 515 | const onWheel = (event: WheelEvent) => { | |
| 516 | const point = pagePoint(event.clientX, event.clientY, false); | |
| 517 | if (!point) return; | |
| 518 | event.preventDefault(); | |
| 519 | const unit = | |
| 520 | event.deltaMode === WheelEvent.DOM_DELTA_LINE | |
| 521 | ? WHEEL_LINE_PX | |
| 522 | : event.deltaMode === WheelEvent.DOM_DELTA_PAGE | |
| 523 | ? (viewport?.height ?? 0) | |
| 524 | : 1; | |
| 525 | queueWheel(point, event.deltaX * unit, event.deltaY * unit); | |
| 526 | }; | |
| 527 | // Keeps focus in the page input and stops native selection and callouts. | |
| 528 | const preventDefault = (event: Event) => event.preventDefault(); | |
| 529 | ||
| 530 | const onKey = (action: "down" | "up", event: KeyboardEvent) => { | |
| 531 | if (control?.controller !== "you") return; | |
| 532 | // IME and soft keyboards deliver text through composition and input events. | |
| 533 | if ( | |
| 534 | event.isComposing || | |
| 535 | event.keyCode === 229 || | |
| 536 | event.key === "Process" || | |
| 537 | event.key === "Unidentified" | |
| 538 | ) { | |
| 539 | return; | |
| 540 | } | |
| 541 | const shortcut = event.ctrlKey || event.metaKey; | |
| 542 | // Paste arrives as input text from this device's clipboard. Copy and cut run in | |
| 543 | // the page, which sends the copied text back. Shift+Insert is the same paste. | |
| 544 | if (shortcut && event.key.toLowerCase() === "v") return; | |
| 545 | if (event.shiftKey && event.key === "Insert") return; | |
| 546 | // Enter carries "\r" like Puppeteer's key table, so forms submit and textareas break lines. | |
| 547 | const text = shortcut | |
| 548 | ? undefined | |
| 549 | : [...event.key].length === 1 | |
| 550 | ? event.key | |
| 551 | : event.key === "Enter" | |
| 552 | ? "\r" | |
| 553 | : undefined; | |
| 554 | send({ | |
| 555 | type: "key", | |
| 556 | action, | |
| 557 | key: event.key, | |
| 558 | code: event.code, | |
| 559 | keyCode: event.keyCode, | |
| 560 | ...(action === "down" && text !== undefined ? { text } : {}), | |
| 561 | modifiers: previewStreamModifiers(event), | |
| 562 | }); | |
| 563 | // Some Android keyboards edit the textarea even when keydown is prevented. | |
| 564 | // Shortcuts keep their default, so a paste still arrives as text. | |
| 565 | keySent = | |
| 566 | action === "down" && | |
| 567 | !shortcut && | |
| 568 | (text !== undefined || event.key === "Backspace" || event.key === "Delete"); | |
| 569 | if (!shortcut) event.preventDefault(); | |
| 570 | }; | |
| 571 | const resetInput = () => { | |
| 572 | input.value = SENTINEL; | |
| 573 | input.setSelectionRange(SENTINEL.length, SENTINEL.length); | |
| 574 | }; | |
| 575 | const pressKey = (key: "Backspace" | "Delete", keyCode: number) => { | |
| 576 | for (const action of ["down", "up"] as const) { | |
| 577 | send({ type: "key", action, key, code: key, keyCode, modifiers: 0 }); | |
| 578 | } | |
| 579 | }; | |
| 580 | const onInput = (event: Event) => { | |
| 581 | if (event instanceof InputEvent && event.isComposing) return; | |
| 582 | const inputType = event instanceof InputEvent ? event.inputType : ""; | |
| 583 | if (keySent) keySent = false; | |
| 584 | else if (inputType === "deleteContentBackward") pressKey("Backspace", 8); | |
| 585 | else if (inputType === "deleteContentForward") pressKey("Delete", 46); | |
| 586 | else { | |
| 587 | const value = input.value; | |
| 588 | const text = value.startsWith(SENTINEL) ? value.slice(SENTINEL.length) : value; | |
| 589 | if (text) send({ type: "text", text }); | |
| 590 | } | |
| 591 | resetInput(); | |
| 592 | }; | |
| 593 | const onCompositionEnd = (event: CompositionEvent) => { | |
| 594 | const text = event.data.startsWith(SENTINEL) ? event.data.slice(SENTINEL.length) : event.data; | |
| 595 | if (text) send({ type: "text", text }); | |
| 596 | resetInput(); | |
| 597 | }; | |
| 598 | ||
| 599 | if (interactive) { | |
| 600 | canvas.addEventListener("pointerdown", onPointerDown); | |
| 601 | canvas.addEventListener("pointermove", onPointerMove); | |
| 602 | canvas.addEventListener("pointerup", onPointerEnd); | |
| 603 | canvas.addEventListener("pointercancel", onPointerEnd); | |
| 604 | canvas.addEventListener("wheel", onWheel, { passive: false }); | |
| 605 | canvas.addEventListener("mousedown", preventDefault); | |
| 606 | canvas.addEventListener("contextmenu", preventDefault); | |
| 607 | input.addEventListener("keydown", (event) => onKey("down", event)); | |
| 608 | input.addEventListener("keyup", (event) => onKey("up", event)); | |
| 609 | input.addEventListener("input", onInput); | |
| 610 | input.addEventListener("compositionend", onCompositionEnd); | |
| 611 | input.addEventListener("focus", resetInput); | |
| 612 | // Copying the input would put its sentinel on this device's clipboard. | |
| 613 | input.addEventListener("copy", preventDefault); | |
| 614 | input.addEventListener("cut", preventDefault); | |
| 615 | resetInput(); | |
| 616 | } | |
| 617 | ||
| 618 | // WebKit reports no support on a video that has not loaded yet; check the prototype. | |
| 619 | const videoPrototype: PresentationVideo = HTMLVideoElement.prototype; | |
| 620 | const pictureInPictureSupported = | |
| 621 | interactive && | |
| 622 | typeof canvas.captureStream === "function" && | |
| 623 | ((document.pictureInPictureEnabled === true && | |
| 624 | typeof videoPrototype.requestPictureInPicture === "function") || | |
| 625 | typeof videoPrototype.webkitSetPresentationMode === "function"); | |
| 626 | let video: PresentationVideo | null = null; | |
| 627 | const pictureInPictureActive = () => | |
| 628 | video !== null && | |
| 629 | (document.pictureInPictureElement === video || | |
| 630 | video.webkitPresentationMode === "picture-in-picture"); | |
| 631 | const reportPictureInPicture = (detail?: string) => | |
| 632 | post({ | |
| 633 | type: "pictureInPicture", | |
| 634 | supported: pictureInPictureSupported, | |
| 635 | active: pictureInPictureActive(), | |
| 636 | ...(detail ? { detail } : {}), | |
| 637 | }); | |
| 638 | const ensureVideo = () => { | |
| 639 | if (video) return video; | |
| 640 | const element: PresentationVideo = document.createElement("video"); | |
| 641 | element.muted = true; | |
| 642 | element.playsInline = true; | |
| 643 | element.autoplay = true; | |
| 644 | // Under the canvas at full size: WebKit pauses muted video it considers off screen. | |
| 645 | element.srcObject = canvas.captureStream(); | |
| 646 | // A static page sends no new frames; repaint once so the stream has one. | |
| 647 | if (canvas.width > 0 && canvas.height > 0) canvas.getContext("2d")?.drawImage(canvas, 0, 0); | |
| 648 | for (const name of [ | |
| 649 | "enterpictureinpicture", | |
| 650 | "leavepictureinpicture", | |
| 651 | "webkitpresentationmodechanged", | |
| 652 | ]) { | |
| 653 | element.addEventListener(name, () => { | |
| 654 | if (!pictureInPictureActive()) element.pause(); | |
| 655 | reportPictureInPicture(); | |
| 656 | }); | |
| 657 | } | |
| 658 | container.prepend(element); | |
| 659 | video = element; | |
| 660 | return element; | |
| 661 | }; | |
| 662 | const togglePictureInPicture = async () => { | |
| 663 | if (!pictureInPictureSupported) return; | |
| 664 | try { | |
| 665 | if (pictureInPictureActive()) { | |
| 666 | if (document.pictureInPictureElement) await document.exitPictureInPicture(); | |
| 667 | else video?.webkitSetPresentationMode?.("inline"); | |
| 668 | return; | |
| 669 | } | |
| 670 | const element = ensureVideo(); | |
| 671 | await element.play(); | |
| 672 | if (document.pictureInPictureEnabled) await element.requestPictureInPicture(); | |
| 673 | else element.webkitSetPresentationMode?.("picture-in-picture"); | |
| 674 | } catch (error) { | |
| 675 | reportPictureInPicture( | |
| 676 | error instanceof Error ? error.message : "Picture in picture is unavailable.", | |
| 677 | ); | |
| 678 | } | |
| 679 | }; | |
| 680 | ||
| 681 | activeViewer = { | |
| 682 | stop: () => { | |
| 683 | stopped = true; | |
| 684 | observer.disconnect(); | |
| 685 | visualViewport?.removeEventListener("resize", followVisualViewport); | |
| 686 | visualViewport?.removeEventListener("scroll", followVisualViewport); | |
| 687 | if (resizeTimer !== null) clearTimeout(resizeTimer); | |
| 688 | if (wheelFrame !== null) cancelAnimationFrame(wheelFrame); | |
| 689 | if (mouseFrame !== null) cancelAnimationFrame(mouseFrame); | |
| 690 | if (agentCursorTimer !== null) clearTimeout(agentCursorTimer); | |
| 691 | painter.stop(); | |
| 692 | client?.stop(); | |
| 693 | client = null; | |
| 694 | if (video) { | |
| 695 | for (const track of video.srcObject instanceof MediaStream | |
| 696 | ? video.srcObject.getTracks() | |
| 697 | : []) { | |
| 698 | track.stop(); | |
| 699 | } | |
| 700 | video.srcObject = null; | |
| 701 | } | |
| 702 | }, | |
| 703 | command: (message) => { | |
| 704 | send(message); | |
| 705 | }, | |
| 706 | togglePictureInPicture, | |
| 707 | }; | |
| 708 | window.addEventListener("pagehide", stop, { once: true }); | |
| 709 | reportStatus("connecting"); | |
| 710 | if (interactive) reportPictureInPicture(); | |
| 711 | } |
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+36| ... | ... | @@ -95,6 +95,8 @@ import { editPendingThreadMessage } from "../../state/edit-pending-thread-messag |
| 95 | 95 | import { deviceEnvironment } from "../../state/device"; |
| 96 | 96 | import { useEnvironmentQuery } from "../../state/query"; |
| 97 | 97 | import { threadDevicePreviews } from "../devices/threadDevicePreviews"; |
| 98 | import { ThreadBrowserFloat } from "../browser/ThreadBrowserFloat"; | |
| 99 | import { useThreadServerBrowserTabs } from "../../state/preview"; | |
| 98 | 100 | import type { QueuedThreadMessage } from "../../state/thread-outbox-model"; |
| 99 | 101 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 100 | 102 | import { |
| ... | ... | @@ -333,6 +335,22 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 333 | 335 | threadId: props.selectedThread.id, |
| 334 | 336 | }); |
| 335 | 337 | }, [navigation, props.environmentId, props.selectedThread.id]); |
| 338 | const browserTabs = useThreadServerBrowserTabs({ | |
| 339 | environmentId: props.environmentId, | |
| 340 | threadId: props.selectedThread.id, | |
| 341 | enabled: props.serverConfig?.environment.capabilities.serverBrowser === true, | |
| 342 | }); | |
| 343 | const openBrowserPreview = useCallback( | |
| 344 | (tabId?: string) => { | |
| 345 | Keyboard.dismiss(); | |
| 346 | navigation.navigate("ThreadBrowserPreview", { | |
| 347 | environmentId: props.environmentId, | |
| 348 | threadId: props.selectedThread.id, | |
| 349 | ...(tabId === undefined ? {} : { tabId }), | |
| 350 | }); | |
| 351 | }, | |
| 352 | [navigation, props.environmentId, props.selectedThread.id], | |
| 353 | ); | |
| 336 | 354 | const insets = useSafeAreaInsets(); |
| 337 | 355 | const isKeyboardVisible = useKeyboardState((state) => state.isVisible); |
| 338 | 356 | const liveKeyboardHeight = useKeyboardState((state) => state.height); |
| ... | ... | @@ -512,6 +530,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 512 | 530 | queuedCount > 0 || |
| 513 | 531 | agentsSegment !== null || |
| 514 | 532 | devicePreviews.length > 0 || |
| 533 | browserTabs.tabs.length > 0 || | |
| 515 | 534 | props.connectionStateLabel !== "connected" || |
| 516 | 535 | props.queuedMessages.length > 0 || |
| 517 | 536 | props.selectedThreadFeed.some( |
| ... | ... | @@ -1129,6 +1148,18 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 1129 | 1148 | <View className="flex-1" /> |
| 1130 | 1149 | )} |
| 1131 | 1150 | |
| 1151 | {showContent ? ( | |
| 1152 | <ThreadBrowserFloat | |
| 1153 | key={selectedThreadKey} | |
| 1154 | environmentId={props.environmentId} | |
| 1155 | threadId={props.selectedThread.id} | |
| 1156 | tabs={browserTabs.tabs} | |
| 1157 | loaded={browserTabs.loaded} | |
| 1158 | top={navigationHeaderHeight + 8} | |
| 1159 | onOpen={openBrowserPreview} | |
| 1160 | /> | |
| 1161 | ) : null} | |
| 1162 | ||
| 1132 | 1163 | {/* Floating composer — sticks to keyboard via KeyboardStickyView */} |
| 1133 | 1164 | {showContent ? ( |
| 1134 | 1165 | <KeyboardStickyView |
| ... | ... | @@ -1161,6 +1192,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 1161 | 1192 | ? { count: devicePreviews.length, onPress: openDevicePreview } |
| 1162 | 1193 | : null |
| 1163 | 1194 | } |
| 1195 | browserPreview={ | |
| 1196 | browserTabs.tabs.length > 0 | |
| 1197 | ? { count: browserTabs.tabs.length, onPress: () => openBrowserPreview() } | |
| 1198 | : null | |
| 1199 | } | |
| 1164 | 1200 | showScrollToEnd={showScrollToEndButton} |
| 1165 | 1201 | onScrollToEnd={handleScrollToEnd} |
| 1166 | 1202 | agents={agentsSegment} |
apps/mobile/src/features/threads/floating-working-control.tsx+23-13| ... | ... | @@ -26,6 +26,7 @@ import { AppText as Text } from "../../components/AppText"; |
| 26 | 26 | import { SymbolView } from "../../components/AppSymbol"; |
| 27 | 27 | import { ControlPill } from "../../components/ControlPill"; |
| 28 | 28 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; |
| 29 | import { BrowserPreviewButton } from "../browser/browser-preview-button"; | |
| 29 | 30 | import { DevicePreviewButton } from "../devices/device-preview-button"; |
| 30 | 31 | import type { FloatingWorkingStatus } from "./floating-working-status"; |
| 31 | 32 | import { ShimmeringWorkContent } from "./thread-work-log"; |
| ... | ... | @@ -67,6 +68,7 @@ export function FloatingWorkingControl(props: { |
| 67 | 68 | readonly colorScheme: "light" | "dark"; |
| 68 | 69 | readonly status: FloatingWorkingStatus | null; |
| 69 | 70 | readonly devicePreview: { readonly count: number; readonly onPress: () => void } | null; |
| 71 | readonly browserPreview: { readonly count: number; readonly onPress: () => void } | null; | |
| 70 | 72 | readonly showScrollToEnd: boolean; |
| 71 | 73 | readonly onScrollToEnd: () => void; |
| 72 | 74 | readonly agents: SubagentPillSegment | null; |
| ... | ... | @@ -81,8 +83,8 @@ export function FloatingWorkingControl(props: { |
| 81 | 83 | const [queueWidth, setQueueWidth] = useState(0); |
| 82 | 84 | const [agentsWidth, setAgentsWidth] = useState(0); |
| 83 | 85 | 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); | |
| 86 | 88 | const agents = props.agents; |
| 87 | 89 | const hasAgents = agents !== null; |
| 88 | 90 | // Segments keep their measured width; only the status label absorbs the |
| ... | ... | @@ -94,7 +96,7 @@ export function FloatingWorkingControl(props: { |
| 94 | 96 | 32 - |
| 95 | 97 | (hasQueue ? queueWidth : 0) - |
| 96 | 98 | (hasAgents ? agentsWidth : 0) - |
| 97 | (hasDevicePreview ? deviceWidth : 0), | |
| 99 | (hasPreview ? previewWidth : 0), | |
| 98 | 100 | ); |
| 99 | 101 | const separationProgress = useSharedValue(props.showScrollToEnd ? 1 : 0); |
| 100 | 102 | |
| ... | ... | @@ -131,7 +133,7 @@ export function FloatingWorkingControl(props: { |
| 131 | 133 | // Forget the width while no label is shown so the next one appears at its |
| 132 | 134 | // own size instead of animating from the previous label's. |
| 133 | 135 | const hasStatus = props.status !== null; |
| 134 | const hasCapsule = hasStatus || hasQueue || hasAgents || hasDevicePreview; | |
| 136 | const hasCapsule = hasStatus || hasQueue || hasAgents || hasPreview; | |
| 135 | 137 | useEffect(() => { |
| 136 | 138 | if (!hasStatus) { |
| 137 | 139 | measuredWidthRef.current = null; |
| ... | ... | @@ -151,9 +153,15 @@ export function FloatingWorkingControl(props: { |
| 151 | 153 | return null; |
| 152 | 154 | } |
| 153 | 155 | |
| 156 | // Both preview buttons fit beside each other only as icons. | |
| 157 | const previewCompact = | |
| 158 | hasStatus || | |
| 159 | hasAgents || | |
| 160 | hasQueue || | |
| 161 | (props.devicePreview !== null && props.browserPreview !== null); | |
| 154 | 162 | // The queue, agents, and reconnect labels have separate tap targets. |
| 155 | 163 | const statusInteractive = props.status?.kind === "connection"; |
| 156 | const capsuleInteractive = statusInteractive || hasQueue || hasAgents || hasDevicePreview; | |
| 164 | const capsuleInteractive = statusInteractive || hasQueue || hasAgents || hasPreview; | |
| 157 | 165 | // The host stays centered on the capsule, but its measurement constraint |
| 158 | 166 | // comes from the overlay, independent of the capsule's current width. |
| 159 | 167 | const statusContent = |
| ... | ... | @@ -184,16 +192,18 @@ export function FloatingWorkingControl(props: { |
| 184 | 192 | const capsuleContent = ( |
| 185 | 193 | <View className="flex-row items-center"> |
| 186 | 194 | {statusContent} |
| 187 | {props.devicePreview !== null ? ( | |
| 195 | {hasPreview ? ( | |
| 188 | 196 | <View |
| 189 | 197 | className="h-11 flex-row items-center" |
| 190 | onLayout={(event) => setDeviceWidth(event.nativeEvent.layout.width)} | |
| 198 | onLayout={(event) => setPreviewWidth(event.nativeEvent.layout.width)} | |
| 191 | 199 | > |
| 192 | 200 | {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} | |
| 197 | 207 | </View> |
| 198 | 208 | ) : null} |
| 199 | 209 | {agents !== null ? ( |
| ... | ... | @@ -205,7 +215,7 @@ export function FloatingWorkingControl(props: { |
| 205 | 215 | onLayout={(event) => setAgentsWidth(event.nativeEvent.layout.width)} |
| 206 | 216 | className="h-11 flex-row items-center gap-1.5 px-3 active:opacity-70" |
| 207 | 217 | > |
| 208 | {hasStatus || hasDevicePreview ? <View className="mr-1 h-4 w-px bg-border" /> : null} | |
| 218 | {hasStatus || hasPreview ? <View className="mr-1 h-4 w-px bg-border" /> : null} | |
| 209 | 219 | <SymbolView name="person.2" size={13} tintColorClassName="accent-foreground-muted" /> |
| 210 | 220 | <Text className="font-t3-medium text-xs tabular-nums" numberOfLines={1}> |
| 211 | 221 | {agents.label} |
| ... | ... | @@ -222,7 +232,7 @@ export function FloatingWorkingControl(props: { |
| 222 | 232 | style={{ maxWidth: Math.min(overlayWidth, windowWidth) * 0.45 }} |
| 223 | 233 | className="h-11 flex-row items-center gap-2 px-3 active:opacity-70" |
| 224 | 234 | > |
| 225 | {hasStatus || hasDevicePreview || hasAgents ? ( | |
| 235 | {hasStatus || hasPreview || hasAgents ? ( | |
| 226 | 236 | <View className="mr-1 h-4 w-px bg-border" /> |
| 227 | 237 | ) : null} |
| 228 | 238 | <SymbolView name="list.number" size={13} tintColorClassName="accent-foreground-muted" /> |
apps/mobile/src/state/preview.ts created+166| ... | ... | @@ -0,0 +1,166 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | |
| 2 | import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime/environment"; | |
| 3 | import { PREVIEW_STREAM_BASE_PATH } from "@t3tools/client-runtime/preview/server-browser-stream"; | |
| 4 | import { createPreviewEnvironmentAtoms } from "@t3tools/client-runtime/state/preview"; | |
| 5 | import { resolveDeviceHubAccess } from "@t3tools/client-runtime/state/deviceHubAccess"; | |
| 6 | import type { | |
| 7 | EnvironmentId, | |
| 8 | PreviewEvent, | |
| 9 | PreviewListResult, | |
| 10 | PreviewSessionSnapshot, | |
| 11 | ThreadId, | |
| 12 | } from "@t3tools/contracts"; | |
| 13 | import * as Effect from "effect/Effect"; | |
| 14 | import * as Option from "effect/Option"; | |
| 15 | import { AsyncResult, Atom } from "effect/reactivity"; | |
| 16 | import { useMemo } from "react"; | |
| 17 | ||
| 18 | import { connectionAtomRuntime } from "../connection/runtime"; | |
| 19 | import { useEnvironmentQuery } from "./query"; | |
| 20 | import { environmentSession, usePreparedConnection } from "./session"; | |
| 21 | ||
| 22 | export const previewEnvironment = createPreviewEnvironmentAtoms(connectionAtomRuntime); | |
| 23 | ||
| 24 | interface 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 | ||
| 32 | const EMPTY_TABS: ThreadPreviewTabs = { | |
| 33 | serverEpoch: null, | |
| 34 | revision: 0, | |
| 35 | sessions: [], | |
| 36 | listed: false, | |
| 37 | }; | |
| 38 | const emptyTabsAtom = Atom.make(EMPTY_TABS).pipe(Atom.withLabel("mobile-preview-tabs:empty")); | |
| 39 | ||
| 40 | const MAX_REPLAY_EVENTS = 200; | |
| 41 | ||
| 42 | function 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 | ||
| 70 | const 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 | ||
| 129 | export 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 | ||
| 148 | const 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 | ||
| 161 | export 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 @@ |
| 1 | declare module "@t3tools/mobile-preview-stream" { | |
| 2 | const script: string; | |
| 3 | export default script; | |
| 4 | } |
apps/server/package.json+1| ... | ... | @@ -42,6 +42,7 @@ |
| 42 | 42 | "effect": "catalog:", |
| 43 | 43 | "jose": "catalog:", |
| 44 | 44 | "node-pty": "^1.2.0-beta.15", |
| 45 | "playwright-core": "1.60.0", | |
| 45 | 46 | "proper-lockfile": "4.1.2", |
| 46 | 47 | "stream-chain": "^4.2.5", |
| 47 | 48 | "stream-json": "3.6.0", |
apps/server/src/auth/RpcAuthorization.ts+2-3| ... | ... | @@ -187,13 +187,12 @@ export const RPC_REQUIRED_SCOPES = { |
| 187 | 187 | [WS_METHODS.previewOpen]: AuthOrchestrationOperateScope, |
| 188 | 188 | [WS_METHODS.previewNavigate]: AuthOrchestrationOperateScope, |
| 189 | 189 | [WS_METHODS.previewResize]: AuthOrchestrationOperateScope, |
| 190 | [WS_METHODS.previewAdjust]: AuthOrchestrationOperateScope, | |
| 190 | 191 | [WS_METHODS.previewRefresh]: AuthOrchestrationOperateScope, |
| 191 | 192 | [WS_METHODS.previewClose]: AuthOrchestrationOperateScope, |
| 192 | 193 | [WS_METHODS.previewList]: AuthOrchestrationReadScope, |
| 194 | [WS_METHODS.previewClearProfile]: AuthOrchestrationOperateScope, | |
| 193 | 195 | [WS_METHODS.previewReportStatus]: AuthOrchestrationOperateScope, |
| 194 | [WS_METHODS.previewAutomationConnect]: AuthOrchestrationOperateScope, | |
| 195 | [WS_METHODS.previewAutomationRespond]: AuthOrchestrationOperateScope, | |
| 196 | [WS_METHODS.previewAutomationFocusHost]: AuthOrchestrationOperateScope, | |
| 197 | 196 | [WS_METHODS.subscribePreviewEvents]: AuthOrchestrationReadScope, |
| 198 | 197 | [WS_METHODS.subscribeDiscoveredLocalServers]: AuthOrchestrationReadScope, |
| 199 | 198 | [WS_METHODS.deviceConfigure]: AuthOrchestrationOperateScope, |
apps/server/src/auth/http.ts+30| ... | ... | @@ -157,6 +157,36 @@ export function failEnvironmentNotFound(reason: EnvironmentResourceNotFoundReaso |
| 157 | 157 | ); |
| 158 | 158 | } |
| 159 | 159 | |
| 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 | */ | |
| 167 | export 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 | ||
| 160 | 190 | export function failEnvironmentInternal(reason: EnvironmentInternalErrorReason, error?: unknown) { |
| 161 | 191 | return Effect.gen(function* () { |
| 162 | 192 | const traceId = yield* currentEnvironmentTraceId; |
apps/server/src/binCli.ts+2| ... | ... | @@ -10,6 +10,7 @@ import packageJson from "../package.json" with { type: "json" }; |
| 10 | 10 | import { acpMcpBridgeCommand, acpMcpCallCommand } from "./cli/acpMcpBridge.ts"; |
| 11 | 11 | import { authCommand } from "./cli/auth.ts"; |
| 12 | 12 | import { appCommand } from "./cli/app.ts"; |
| 13 | import { browserCommand } from "./cli/browser.ts"; | |
| 13 | 14 | import { connectCommand } from "./cli/connect.ts"; |
| 14 | 15 | import { pairCommand } from "./cli/pair.ts"; |
| 15 | 16 | import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; |
| ... | ... | @@ -71,6 +72,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => |
| 71 | 72 | appCommand, |
| 72 | 73 | pairCommand, |
| 73 | 74 | authCommand, |
| 75 | browserCommand, | |
| 74 | 76 | projectCommand, |
| 75 | 77 | serviceCommand, |
| 76 | 78 | updateCommand, |
apps/server/src/cli/browser.ts created+195| ... | ... | @@ -0,0 +1,195 @@ |
| 1 | /** | |
| 2 | * `t3 browser setup` - prepares a Linux host for T3's headless browser, which | |
| 3 | * server browser tabs and HTML render previews share. It is the fix every | |
| 4 | * browser host error names, so it does the whole job in one run: | |
| 5 | * | |
| 6 | * - installs the AppArmor profile that lets Chrome's sandbox run where the host | |
| 7 | * restricts unprivileged user namespaces (Ubuntu 23.10+), and | |
| 8 | * - installs the Debian packages for any libraries the browser cannot load. | |
| 9 | * | |
| 10 | * Both need root. Without it, the command prints what it would change and the | |
| 11 | * `sudo` line to run. It is safe to run again; it skips what is already done. | |
| 12 | */ | |
| 13 | import { | |
| 14 | HostProcessEnvironment, | |
| 15 | HostProcessPlatform, | |
| 16 | HostProcessUserId, | |
| 17 | } from "@t3tools/shared/hostProcess"; | |
| 18 | import * as Console from "effect/Console"; | |
| 19 | import * as Effect from "effect/Effect"; | |
| 20 | import * as FileSystem from "effect/FileSystem"; | |
| 21 | import * as Option from "effect/Option"; | |
| 22 | import * as Path from "effect/Path"; | |
| 23 | import * as Schema from "effect/Schema"; | |
| 24 | import { Command } from "effect/cli"; | |
| 25 | import * as ChildProcess from "effect/process/ChildProcess"; | |
| 26 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; | |
| 27 | ||
| 28 | import * as PreviewBrowserHost from "../preview/PreviewBrowserHost.ts"; | |
| 29 | import { resolveBaseDir } from "../os-jank.ts"; | |
| 30 | import { baseDirFlag } from "./config.ts"; | |
| 31 | import { resolveRootCliCommand } from "./invocation.ts"; | |
| 32 | ||
| 33 | export 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. */ | |
| 43 | const 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 | */ | |
| 63 | const 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`. */ | |
| 79 | const 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. */ | |
| 89 | const 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 | ||
| 106 | const 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 | ||
| 192 | export const browserCommand = Command.make("browser").pipe( | |
| 193 | Command.withDescription("Manage T3's headless browser on this host."), | |
| 194 | Command.withSubcommands([browserSetupCommand]), | |
| 195 | ); |
apps/server/src/cli/config.ts+4| ... | ... | @@ -362,6 +362,8 @@ export const resolveServerConfig = ( |
| 362 | 362 | const desktopBootstrapSecret = bootstrap?.desktopBootstrapSecret; |
| 363 | 363 | const desktopTelemetryFd = bootstrap?.desktopTelemetryFd; |
| 364 | 364 | const desktopTelemetryControlFd = bootstrap?.desktopTelemetryControlFd; |
| 365 | const desktopBrowserFd = bootstrap?.desktopBrowserFd; | |
| 366 | const desktopBrowserControlFd = bootstrap?.desktopBrowserControlFd; | |
| 365 | 367 | const resourceMonitorPath = bootstrap?.resourceMonitorPath; |
| 366 | 368 | const autoBootstrapProjectFromCwd = Option.getOrElse( |
| 367 | 369 | resolveOptionPrecedence( |
| ... | ... | @@ -468,6 +470,8 @@ export const resolveServerConfig = ( |
| 468 | 470 | ...(desktopBootstrapSecret === undefined ? {} : { desktopBootstrapSecret }), |
| 469 | 471 | desktopTelemetryFd, |
| 470 | 472 | desktopTelemetryControlFd, |
| 473 | desktopBrowserFd, | |
| 474 | desktopBrowserControlFd, | |
| 471 | 475 | resourceMonitorPath, |
| 472 | 476 | autoBootstrapProjectFromCwd, |
| 473 | 477 | logWebSocketEvents, |
apps/server/src/cli/invocation.test.ts+28-1| ... | ... | @@ -10,7 +10,11 @@ import * as Effect from "effect/Effect"; |
| 10 | 10 | import * as FileSystem from "effect/FileSystem"; |
| 11 | 11 | import * as Path from "effect/Path"; |
| 12 | 12 | |
| 13 | import { formatCliCommand, resolveServerInstallation } from "./invocation.ts"; | |
| 13 | import { | |
| 14 | formatCliCommand, | |
| 15 | resolveRootCliCommand, | |
| 16 | resolveServerInstallation, | |
| 17 | } from "./invocation.ts"; | |
| 14 | 18 | |
| 15 | 19 | it("formats package runner commands from their cache entry paths", () => { |
| 16 | 20 | for (const [entryPath, expected] of [ |
| ... | ... | @@ -98,6 +102,29 @@ it("formats serve suggestions to match the launching command", () => { |
| 98 | 102 | ); |
| 99 | 103 | }); |
| 100 | 104 | |
| 105 | it.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 | ||
| 101 | 128 | it.layer(NodeServices.layer)("manual server installation ownership", (it) => { |
| 102 | 129 | it.effect("recognizes runner caches for both script and executable packages", () => |
| 103 | 130 | Effect.gen(function* () { |
apps/server/src/cli/invocation.ts+26| ... | ... | @@ -145,3 +145,29 @@ export const resolveCliCommand = (subcommand: string) => |
| 145 | 145 | version: packageJson.version, |
| 146 | 146 | }), |
| 147 | 147 | ); |
| 148 | ||
| 149 | /** | |
| 150 | * `t3 <subcommand>` as root, for setup a person runs once on the host. `sudo` | |
| 151 | * resets PATH on most distributions, which drops a user-installed Node (nvm, | |
| 152 | * fnm, a tarball) and with it `npx` or a global `t3`, so the command carries | |
| 153 | * PATH through unless Node is on root's PATH too. | |
| 154 | */ | |
| 155 | export 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. */ | |
| 166 | const ROOT_PATH_DIRECTORIES = [ | |
| 167 | "/usr/local/sbin", | |
| 168 | "/usr/local/bin", | |
| 169 | "/usr/sbin", | |
| 170 | "/usr/bin", | |
| 171 | "/sbin", | |
| 172 | "/bin", | |
| 173 | ]; |
apps/server/src/cli/pair.ts+2| ... | ... | @@ -335,6 +335,8 @@ const makePairServerConfig = Effect.fn(function* (input: { |
| 335 | 335 | startupPresentation: "headless", |
| 336 | 336 | desktopBootstrapToken: undefined, |
| 337 | 337 | desktopTelemetryFd: undefined, |
| 338 | desktopBrowserFd: undefined, | |
| 339 | desktopBrowserControlFd: undefined, | |
| 338 | 340 | desktopTelemetryControlFd: undefined, |
| 339 | 341 | resourceMonitorPath: undefined, |
| 340 | 342 | autoBootstrapProjectFromCwd: false, |
apps/server/src/config.ts+4| ... | ... | @@ -97,6 +97,8 @@ export class ServerConfig extends Context.Service< |
| 97 | 97 | readonly desktopBootstrapSecret?: string | undefined; |
| 98 | 98 | readonly desktopTelemetryFd?: number | undefined; |
| 99 | 99 | readonly desktopTelemetryControlFd?: number | undefined; |
| 100 | readonly desktopBrowserFd?: number | undefined; | |
| 101 | readonly desktopBrowserControlFd?: number | undefined; | |
| 100 | 102 | readonly resourceMonitorPath?: string | undefined; |
| 101 | 103 | readonly autoBootstrapProjectFromCwd: boolean; |
| 102 | 104 | readonly logWebSocketEvents: boolean; |
| ... | ... | @@ -235,6 +237,8 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* ( |
| 235 | 237 | desktopBootstrapToken: undefined, |
| 236 | 238 | desktopTelemetryFd: undefined, |
| 237 | 239 | desktopTelemetryControlFd: undefined, |
| 240 | desktopBrowserFd: undefined, | |
| 241 | desktopBrowserControlFd: undefined, | |
| 238 | 242 | resourceMonitorPath: undefined, |
| 239 | 243 | staticDir: undefined, |
| 240 | 244 | devUrl, |
apps/server/src/device/DeviceHubProxy.ts+5-41| ... | ... | @@ -11,11 +11,7 @@ |
| 11 | 11 | * Only the routes the Device panel needs are forwarded. Anything under the |
| 12 | 12 | * hub's dashboard, exec, or WebRTC surface is rejected here. |
| 13 | 13 | */ |
| 14 | import { | |
| 15 | AuthOrchestrationReadScope, | |
| 16 | AuthOrchestrationOperateScope, | |
| 17 | type AuthEnvironmentScope, | |
| 18 | } from "@t3tools/contracts"; | |
| 14 | import { AuthOrchestrationReadScope, AuthOrchestrationOperateScope } from "@t3tools/contracts"; | |
| 19 | 15 | import * as Effect from "effect/Effect"; |
| 20 | 16 | import * as Option from "effect/Option"; |
| 21 | 17 | import { |
| ... | ... | @@ -28,12 +24,7 @@ import { |
| 28 | 24 | import * as Socket from "effect/socket/Socket"; |
| 29 | 25 | import * as NodeSocket from "@effect/platform-node/NodeSocket"; |
| 30 | 26 | |
| 31 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; | |
| 32 | import { | |
| 33 | failEnvironmentAuthInvalid, | |
| 34 | failEnvironmentInternal, | |
| 35 | failEnvironmentScopeRequired, | |
| 36 | } from "../auth/http.ts"; | |
| 27 | import { authenticateMediaRequest } from "../auth/http.ts"; | |
| 37 | 28 | import * as DeviceService from "./DeviceService.ts"; |
| 38 | 29 | |
| 39 | 30 | const ALLOWED_PATHS: ReadonlyArray<RegExp> = [ |
| ... | ... | @@ -80,35 +71,6 @@ const DROPPED_REQUEST_HEADERS = new Set([ |
| 80 | 71 | const isWebSocketUpgrade = (request: HttpServerRequest.HttpServerRequest) => |
| 81 | 72 | request.headers.upgrade?.toLowerCase() === "websocket"; |
| 82 | 73 | |
| 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 | */ | |
| 90 | const 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 | ||
| 112 | 74 | const forwardHeaders = (request: HttpServerRequest.HttpServerRequest, origin: string) => { |
| 113 | 75 | const headers: Record<string, string> = {}; |
| 114 | 76 | for (const [name, value] of Object.entries(request.headers)) { |
| ... | ... | @@ -206,7 +168,9 @@ const handler = Effect.gen(function* () { |
| 206 | 168 | const controlsDevice = |
| 207 | 169 | (upgrade && hubPath !== "/api/devices/ws") || |
| 208 | 170 | (!readOnly && /\/api\/(stream-(mode|settings)|fold)$/.test(hubPath)); |
| 209 | yield* authenticate(controlsDevice ? AuthOrchestrationOperateScope : AuthOrchestrationReadScope); | |
| 171 | yield* authenticateMediaRequest( | |
| 172 | controlsDevice ? AuthOrchestrationOperateScope : AuthOrchestrationReadScope, | |
| 173 | ); | |
| 210 | 174 | const devices = yield* DeviceService.DeviceService; |
| 211 | 175 | const ready = yield* devices.currentReadiness(url.value.searchParams.get("hostId") ?? undefined); |
| 212 | 176 | if (!ready) { |
apps/server/src/environment/ServerEnvironment.ts+1| ... | ... | @@ -258,6 +258,7 @@ export const make = Effect.gen(function* () { |
| 258 | 258 | ? { serverSelfUpdateProgress: true } |
| 259 | 259 | : {}), |
| 260 | 260 | ...(desktopAppUpdate ? { desktopAppUpdate: true } : {}), |
| 261 | serverBrowser: true, | |
| 261 | 262 | }, |
| 262 | 263 | }; |
| 263 | 264 |
apps/server/src/htmlRender/HtmlRender.test.ts+1-1| ... | ... | @@ -24,7 +24,7 @@ import * as NodeURL from "node:url"; |
| 24 | 24 | import { resolveAttachmentPathById } from "../attachmentStore.ts"; |
| 25 | 25 | import * as ServerConfig from "../config.ts"; |
| 26 | 26 | import * as HtmlRender from "./HtmlRender.ts"; |
| 27 | import * as PreviewBrowser from "./PreviewBrowser.ts"; | |
| 27 | import * as PreviewBrowser from "../preview/PreviewBrowser.ts"; | |
| 28 | 28 | |
| 29 | 29 | // Real-browser tests run only when this names a chrome-headless-shell, for |
| 30 | 30 | // example one T3 installed under <T3 home>/tools/chrome-headless-shell. |
apps/server/src/htmlRender/HtmlRender.ts+26-35| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import type { ThreadId } from "@t3tools/contracts"; |
| 2 | import { HostProcessUserId } from "@t3tools/shared/hostProcess"; | |
| 2 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; | |
| 3 | 3 | import { |
| 4 | 4 | clampHtmlRenderHeight, |
| 5 | 5 | HTML_RENDER_COLUMN_WIDTH, |
| ... | ... | @@ -29,9 +29,11 @@ import type * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 29 | 29 | |
| 30 | 30 | import { resolveAttachmentRelativePath } from "../attachmentPaths.ts"; |
| 31 | 31 | import { createAttachmentId } from "../attachmentStore.ts"; |
| 32 | import { resolveRootCliCommand } from "../cli/invocation.ts"; | |
| 32 | 33 | import * as ServerConfig from "../config.ts"; |
| 33 | 34 | import * as HeadlessChrome from "./headlessChrome.ts"; |
| 34 | import * as PreviewBrowser from "./PreviewBrowser.ts"; | |
| 35 | import * as PreviewBrowser from "../preview/PreviewBrowser.ts"; | |
| 36 | import * as PreviewBrowserHost from "../preview/PreviewBrowserHost.ts"; | |
| 35 | 37 | |
| 36 | 38 | const MIB = 1024 * 1024; |
| 37 | 39 | const MAX_IMAGE_BYTES = 10 * MIB; |
| ... | ... | @@ -118,6 +120,7 @@ export class HtmlRender extends Context.Service< |
| 118 | 120 | | PreviewBrowser.PreviewBrowserInstallError |
| 119 | 121 | | PreviewBrowser.PreviewBrowserInstallingError |
| 120 | 122 | | PreviewBrowser.PreviewBrowserUnsupportedError |
| 123 | | PreviewBrowserHost.PreviewBrowserHostError | |
| 121 | 124 | | HeadlessChrome.HtmlRenderBrowserError |
| 122 | 125 | >; |
| 123 | 126 | } |
| ... | ... | @@ -336,42 +339,30 @@ const make = Effect.gen(function* () { |
| 336 | 339 | FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner |
| 337 | 340 | >(); |
| 338 | 341 | 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); | |
| 342 | 346 | |
| 347 | /** Runs one browser launch; a host that cannot start it gets setup steps instead. */ | |
| 343 | 348 | 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>, | |
| 350 | 351 | ) => |
| 351 | 352 | 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( | |
| 354 | 354 | 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 | ), | |
| 375 | 366 | }), |
| 376 | 367 | ), |
| 377 | 368 | ); |
| ... | ... | @@ -381,7 +372,7 @@ const make = Effect.gen(function* () { |
| 381 | 372 | Effect.gen(function* () { |
| 382 | 373 | const executable = yield* previewBrowser.installed; |
| 383 | 374 | if (Option.isNone(executable)) return undefined; |
| 384 | const heights = yield* launching((noSandbox) => | |
| 375 | const heights = yield* launching(executable.value, (noSandbox) => | |
| 385 | 376 | HeadlessChrome.measureHtmlHeights({ |
| 386 | 377 | executable: executable.value, |
| 387 | 378 | noSandbox, |
| ... | ... | @@ -473,7 +464,7 @@ const make = Effect.gen(function* () { |
| 473 | 464 | appearance, |
| 474 | 465 | HTML_RENDER_MEASURE_FONTS, |
| 475 | 466 | ); |
| 476 | const screenshot = yield* launching((noSandbox) => | |
| 467 | const screenshot = yield* launching(executable, (noSandbox) => | |
| 477 | 468 | HeadlessChrome.captureHtmlScreenshot({ |
| 478 | 469 | executable, |
| 479 | 470 | noSandbox, |
apps/server/src/htmlRender/PreviewBrowser.test.ts deleted-296| ... | ... | @@ -1,296 +0,0 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { expect, it } from "@effect/vitest"; | |
| 3 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 4 | import * as Deferred from "effect/Deferred"; | |
| 5 | import type * as Duration from "effect/Duration"; | |
| 6 | import * as DateTime from "effect/DateTime"; | |
| 7 | import * as Effect from "effect/Effect"; | |
| 8 | import * as Fiber from "effect/Fiber"; | |
| 9 | import * as FileSystem from "effect/FileSystem"; | |
| 10 | import * as Option from "effect/Option"; | |
| 11 | import * as Path from "effect/Path"; | |
| 12 | import * as Stream from "effect/Stream"; | |
| 13 | import * as TestClock from "effect/testing/TestClock"; | |
| 14 | import { HttpClient, HttpClientResponse } from "effect/http"; | |
| 15 | import * as NodeCrypto from "node:crypto"; | |
| 16 | import * as NodeZlib from "node:zlib"; | |
| 17 | ||
| 18 | import * as PreviewBrowser from "./PreviewBrowser.ts"; | |
| 19 | ||
| 20 | // POSIX modes cannot be checked on NTFS, so follow the host like the Antigravity suite. | |
| 21 | const hostPlatform: NodeJS.Platform = | |
| 22 | HostProcessPlatform.defaultValue() === "win32" ? "win32" : "linux"; | |
| 23 | const executableName = | |
| 24 | hostPlatform === "win32" ? "chrome-headless-shell.exe" : "chrome-headless-shell"; | |
| 25 | const ROOT = "chrome-headless-shell-fixture/"; | |
| 26 | ||
| 27 | /** A stored ZIP whose entries carry Unix modes, like Chrome for Testing's archives. */ | |
| 28 | const 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 | ||
| 69 | const 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 | ||
| 77 | const 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 | ||
| 123 | it.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 | ||
| 255 | it("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 | ||
| 286 | it("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. | |
| 2 | import * as EffectNodeStream from "@effect/platform-node/NodeStream"; | |
| 3 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 4 | import * as Cause from "effect/Cause"; | |
| 5 | import * as Clock from "effect/Clock"; | |
| 6 | import * as Context from "effect/Context"; | |
| 7 | import * as Deferred from "effect/Deferred"; | |
| 8 | import type * as Duration from "effect/Duration"; | |
| 9 | import * as Effect from "effect/Effect"; | |
| 10 | import * as Exit from "effect/Exit"; | |
| 11 | import * as FileSystem from "effect/FileSystem"; | |
| 12 | import * as Layer from "effect/Layer"; | |
| 13 | import * as Option from "effect/Option"; | |
| 14 | import * as Path from "effect/Path"; | |
| 15 | import * as Schema from "effect/Schema"; | |
| 16 | import * as Semaphore from "effect/Semaphore"; | |
| 17 | import * as Stream from "effect/Stream"; | |
| 18 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; | |
| 19 | import * as NodeCrypto from "node:crypto"; | |
| 20 | ||
| 21 | import * as ServerConfig from "../config.ts"; | |
| 22 | import { 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. | |
| 30 | const VERSION = "154.0.8037.92"; | |
| 31 | const 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 | ||
| 58 | const 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 | ||
| 72 | export 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 | ||
| 81 | export 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 | ||
| 96 | const megabytes = (bytes: number) => Math.round(bytes / 1_000_000); | |
| 97 | ||
| 98 | export 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 | } | |
| 106 | const isInstallError = Schema.is(PreviewBrowserInstallError); | |
| 107 | ||
| 108 | export 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 | ||
| 120 | export 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 | ||
| 129 | export 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 | ||
| 146 | export 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 | ||
| 153 | const DOWNLOAD_TIMEOUT = "15 minutes"; | |
| 154 | // Longer than any install can run: the download times out at 15 minutes. | |
| 155 | const STAGING_STALE_AFTER_MS = 60 * 60_000; | |
| 156 | ||
| 157 | const wrapFailure = (detail: string) => (cause: unknown) => | |
| 158 | isInstallError(cause) ? cause : new PreviewBrowserInstallError({ detail, cause }); | |
| 159 | ||
| 160 | type 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 | ||
| 169 | export 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 | ||
| 456 | export const layer = Layer.effect( | |
| 457 | PreviewBrowser, | |
| 458 | Effect.gen(function* () { | |
| 459 | const config = yield* ServerConfig.ServerConfig; | |
| 460 | return yield* makePreviewBrowser({ baseDir: config.baseDir }); | |
| 461 | }), | |
| 462 | ); |
apps/server/src/htmlRender/headlessChrome.ts+15-25| ... | ... | @@ -14,24 +14,19 @@ import { publicProxy } from "./publicProxy.ts"; |
| 14 | 14 | |
| 15 | 15 | export class HtmlRenderBrowserError extends Schema.TaggedError<HtmlRenderBrowserError>()( |
| 16 | 16 | "HtmlRenderBrowserError", |
| 17 | { reason: Schema.String, cause: Schema.optional(Schema.Defect()) }, | |
| 17 | { | |
| 18 | reason: Schema.String, | |
| 19 | cause: Schema.optional(Schema.Defect()), | |
| 20 | /** The end of the browser's stderr when it exited, for diagnosing host setup. */ | |
| 21 | output: Schema.optional(Schema.String), | |
| 22 | }, | |
| 18 | 23 | ) { |
| 19 | 24 | override get message(): string { |
| 20 | 25 | return `Headless Chrome could not render the page: ${this.reason}.`; |
| 21 | 26 | } |
| 22 | 27 | } |
| 23 | 28 | |
| 24 | /** The browser died at startup because this host cannot give it a sandbox. */ | |
| 25 | export 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 | ||
| 34 | type BrowserFailure = HtmlRenderBrowserError | HtmlRenderSandboxUnavailableError; | |
| 29 | type BrowserFailure = HtmlRenderBrowserError; | |
| 35 | 30 | |
| 36 | 31 | export interface ConsoleMessage { |
| 37 | 32 | readonly level: "log" | "info" | "warning" | "error"; |
| ... | ... | @@ -67,9 +62,6 @@ const textEncoder = new TextEncoder(); |
| 67 | 62 | */ |
| 68 | 63 | const pageBody = (html: string) => |
| 69 | 64 | Buffer.from(Buffer.from(html, "utf8").toString("base64"), "latin1"); |
| 70 | // What Chrome prints before aborting when it cannot sandbox itself, e.g. on | |
| 71 | // Ubuntu 23.10+ where AppArmor restricts unprivileged user namespaces. | |
| 72 | const NO_SANDBOX_SIGNATURE = "No usable sandbox"; | |
| 73 | 65 | |
| 74 | 66 | const CdpMessage = Schema.fromJsonString( |
| 75 | 67 | Schema.Struct({ |
| ... | ... | @@ -239,13 +231,11 @@ const launchBrowser = Effect.fnUntraced(function* (input: { |
| 239 | 231 | ); |
| 240 | 232 | |
| 241 | 233 | let stderrTail = ""; |
| 242 | let sandboxUnavailable = false; | |
| 243 | 234 | const stderrReader = yield* child.stderr.pipe( |
| 244 | 235 | Stream.decodeText(), |
| 245 | 236 | Stream.runForEach((text) => |
| 246 | 237 | 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); | |
| 249 | 239 | }), |
| 250 | 240 | ), |
| 251 | 241 | Effect.ignore, |
| ... | ... | @@ -340,13 +330,14 @@ const launchBrowser = Effect.fnUntraced(function* (input: { |
| 340 | 330 | return Effect.void; |
| 341 | 331 | }; |
| 342 | 332 | |
| 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. | |
| 345 | 335 | const disconnect = Effect.gen(function* () { |
| 346 | 336 | 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 | }); | |
| 350 | 341 | disconnected = error; |
| 351 | 342 | const waiters = [...pending.values()]; |
| 352 | 343 | pending.clear(); |
| ... | ... | @@ -396,8 +387,7 @@ const launchBrowser = Effect.fnUntraced(function* (input: { |
| 396 | 387 | return yield* Deferred.await(reply).pipe( |
| 397 | 388 | Effect.flatMap(Schema.decodeUnknownEffect(result)), |
| 398 | 389 | Effect.mapError((error) => |
| 399 | error._tag === "HtmlRenderBrowserError" || | |
| 400 | error._tag === "HtmlRenderSandboxUnavailableError" | |
| 390 | error._tag === "HtmlRenderBrowserError" | |
| 401 | 391 | ? error |
| 402 | 392 | : new HtmlRenderBrowserError({ |
| 403 | 393 | reason: `${method} returned an unexpected result`, |
apps/server/src/mcp/McpHttpServer.test.ts+39| ... | ... | @@ -468,6 +468,45 @@ it.effect( |
| 468 | 468 | }).pipe(Effect.provide(layerPullRequestsTest)), |
| 469 | 469 | ); |
| 470 | 470 | |
| 471 | it.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 | ||
| 489 | it.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 | ||
| 471 | 510 | it.effect("keeps the snapshot text under the agent's output ceiling", () => |
| 472 | 511 | Effect.scoped( |
| 473 | 512 | Effect.gen(function* () { |
apps/server/src/mcp/McpHttpServer.ts+14-5| ... | ... | @@ -19,7 +19,6 @@ import packageJson from "../../package.json" with { type: "json" }; |
| 19 | 19 | import * as ServerConfig from "../config.ts"; |
| 20 | 20 | import * as DeviceService from "../device/DeviceService.ts"; |
| 21 | 21 | import * as HtmlRender from "../htmlRender/HtmlRender.ts"; |
| 22 | import * as PreviewBrowser from "../htmlRender/PreviewBrowser.ts"; | |
| 23 | 22 | import * as McpInvocationContext from "./McpInvocationContext.ts"; |
| 24 | 23 | import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; |
| 25 | 24 | import { PreviewControlsToolkit } from "./toolkits/previewControls/tools.ts"; |
| ... | ... | @@ -181,7 +180,7 @@ type SnapshotMetadata = { |
| 181 | 180 | }; |
| 182 | 181 | |
| 183 | 182 | /** |
| 184 | * Drops the accessibility tree, shortens page text, element names, identifiers, | |
| 183 | * Keeps server ARIA refs, drops legacy object trees, shortens page text, names, identifiers, | |
| 185 | 184 | * and log strings, keeps only the newest log entries, and finally sheds |
| 186 | 185 | * interactive elements until the JSON fits. Returns the bounded value, its |
| 187 | 186 | * text, and notes on what is missing so the agent can reach for |
| ... | ... | @@ -190,7 +189,8 @@ type SnapshotMetadata = { |
| 190 | 189 | const boundSnapshotMetadata = (metadata: SnapshotMetadata) => { |
| 191 | 190 | const omitted: Array<string> = []; |
| 192 | 191 | const { accessibilityTree, ...withoutTree } = metadata; |
| 193 | if (accessibilityTree !== undefined) { | |
| 192 | const ariaTree = typeof accessibilityTree === "string" ? accessibilityTree : undefined; | |
| 193 | if (accessibilityTree !== undefined && ariaTree === undefined) { | |
| 194 | 194 | omitted.push("accessibilityTree (use interactiveElements locators or preview_evaluate)"); |
| 195 | 195 | } |
| 196 | 196 | const tail = <A>(entries: ReadonlyArray<A>, label: string) => { |
| ... | ... | @@ -253,8 +253,10 @@ const boundSnapshotMetadata = (metadata: SnapshotMetadata) => { |
| 253 | 253 | actionTimeline: 0, |
| 254 | 254 | }; |
| 255 | 255 | let visibleTextChars = Math.min(metadata.visibleText.length, MAX_SNAPSHOT_VISIBLE_TEXT_CHARS); |
| 256 | let ariaTreeChars = Math.min(ariaTree?.length ?? 0, 20_000); | |
| 256 | 257 | const value = () => ({ |
| 257 | 258 | ...bounded, |
| 259 | ...(ariaTree === undefined ? {} : { accessibilityTree: cutText(ariaTree, ariaTreeChars) }), | |
| 258 | 260 | visibleText: cutText(metadata.visibleText, visibleTextChars), |
| 259 | 261 | ...lists, |
| 260 | 262 | }); |
| ... | ... | @@ -269,10 +271,14 @@ const boundSnapshotMetadata = (metadata: SnapshotMetadata) => { |
| 269 | 271 | ? "visibleText" |
| 270 | 272 | : lists.interactiveElements.length > 0 |
| 271 | 273 | ? "interactiveElements" |
| 272 | : undefined); | |
| 274 | : ariaTreeChars > 0 | |
| 275 | ? "accessibilityTree" | |
| 276 | : undefined); | |
| 273 | 277 | if (key === undefined) break; |
| 274 | 278 | if (key === "visibleText") { |
| 275 | 279 | visibleTextChars = Math.floor(visibleTextChars / 2); |
| 280 | } else if (key === "accessibilityTree") { | |
| 281 | ariaTreeChars = Math.floor(ariaTreeChars / 2); | |
| 276 | 282 | } else { |
| 277 | 283 | const keep = Math.floor(lists[key].length / 2); |
| 278 | 284 | dropped[key] += lists[key].length - keep; |
| ... | ... | @@ -291,6 +297,9 @@ const boundSnapshotMetadata = (metadata: SnapshotMetadata) => { |
| 291 | 297 | `visibleText after ${visibleTextChars} characters (use preview_evaluate for more)`, |
| 292 | 298 | ); |
| 293 | 299 | } |
| 300 | if (ariaTree !== undefined && ariaTreeChars < ariaTree.length) { | |
| 301 | omitted.push(`accessibilityTree after ${ariaTreeChars} characters`); | |
| 302 | } | |
| 294 | 303 | for (const key of shedOrder) { |
| 295 | 304 | if (dropped[key] > 0) { |
| 296 | 305 | omitted.push(`${dropped[key]} of ${bounded[key].length} ${key}`); |
| ... | ... | @@ -683,7 +692,7 @@ const registerHtmlPreview = Effect.fn("McpHttpServer.registerHtmlPreview")(funct |
| 683 | 692 | export const layerHtmlToolkit = Layer.mergeAll( |
| 684 | 693 | McpServer.toolkit(HtmlRenderToolkit).pipe(Layer.provide(HtmlHandlers.layerRender)), |
| 685 | 694 | Layer.effectDiscard(registerHtmlPreview()).pipe(Layer.provide(HtmlHandlers.layerPreview)), |
| 686 | ).pipe(Layer.provide(HtmlRender.layer), Layer.provide(PreviewBrowser.layer)); | |
| 695 | ).pipe(Layer.provide(HtmlRender.layer)); | |
| 687 | 696 | |
| 688 | 697 | const layerPreviewStandardToolkitRegistration = McpServer.toolkit(PreviewStandardToolkit).pipe( |
| 689 | 698 | Layer.provide(PreviewHandlers.layerStandard), |
apps/server/src/mcp/PreviewAutomationBroker.test.ts+108-112| ... | ... | @@ -16,6 +16,7 @@ import { |
| 16 | 16 | type PreviewAutomationHost, |
| 17 | 17 | type PreviewAutomationRequest, |
| 18 | 18 | type PreviewAutomationStreamEvent, |
| 19 | SERVER_BROWSER_AUTOMATION_CLIENT_ID, | |
| 19 | 20 | } from "@t3tools/contracts"; |
| 20 | 21 | import * as Effect from "effect/Effect"; |
| 21 | 22 | import * as Layer from "effect/Layer"; |
| ... | ... | @@ -332,6 +333,44 @@ it.effect("announces a live replacement stream before delivering requests", () = |
| 332 | 333 | ), |
| 333 | 334 | ); |
| 334 | 335 | |
| 336 | it.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 | ||
| 335 | 374 | it.effect("preserves bounded request and remote selector diagnostics", () => { |
| 336 | 375 | const locator = "role=button[name='request-secret']"; |
| 337 | 376 | const remoteMessage = "Unexpected token near remote-secret."; |
| ... | ... | @@ -481,6 +520,37 @@ it.effect.each([ |
| 481 | 520 | ), |
| 482 | 521 | ); |
| 483 | 522 | |
| 523 | it.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 | ||
| 484 | 554 | it.effect("distinguishes malformed remote failures", () => |
| 485 | 555 | Effect.scoped( |
| 486 | 556 | Effect.gen(function* () { |
| ... | ... | @@ -1256,118 +1326,6 @@ it.effect("accepts responses only from the host that received the request", () = |
| 1256 | 1326 | ), |
| 1257 | 1327 | ); |
| 1258 | 1328 | |
| 1259 | it.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 | ||
| 1371 | 1329 | it.effect("discards buffered actions before completing an evicted host stream", () => |
| 1372 | 1330 | Effect.scoped( |
| 1373 | 1331 | Effect.gen(function* () { |
| ... | ... | @@ -1505,3 +1463,41 @@ it.effect("keeps a host that responds with an operation timeout", () => |
| 1505 | 1463 | }), |
| 1506 | 1464 | ), |
| 1507 | 1465 | ); |
| 1466 | ||
| 1467 | it.effect("keeps the host connected when a background status read times out", () => | |
| 1468 | Effect.scoped( | |
| 1469 | Effect.gen(function* () { | |
| 1470 | const broker = yield* makeBroker; | |
| 1471 | const requests = requestsFrom(yield* broker.connect(makeHost())); | |
| 1472 | // A busy host answers its actions but not the metadata read behind them. | |
| 1473 | yield* Stream.runForEach(requests, (request) => | |
| 1474 | request.operation === "status" | |
| 1475 | ? Effect.void | |
| 1476 | : broker.respond({ | |
| 1477 | clientId: "client-1", | |
| 1478 | connectionId: request.connectionId, | |
| 1479 | requestId: request.requestId, | |
| 1480 | ok: true, | |
| 1481 | result: { operation: request.operation }, | |
| 1482 | }), | |
| 1483 | ).pipe(Effect.forkScoped); | |
| 1484 | yield* Effect.yieldNow; | |
| 1485 | ||
| 1486 | const status = yield* broker | |
| 1487 | .invoke<void>({ | |
| 1488 | scope, | |
| 1489 | operation: "status", | |
| 1490 | input: {}, | |
| 1491 | timeoutMs: 500, | |
| 1492 | updateCurrentTab: false, | |
| 1493 | }) | |
| 1494 | .pipe(Effect.flip, Effect.forkScoped); | |
| 1495 | yield* TestClock.adjust(500); | |
| 1496 | expect(yield* Fiber.join(status)).toMatchObject({ _tag: "PreviewAutomationTimeoutError" }); | |
| 1497 | ||
| 1498 | expect(yield* broker.invoke({ scope, operation: "snapshot", input: {} })).toEqual({ | |
| 1499 | operation: "snapshot", | |
| 1500 | }); | |
| 1501 | }), | |
| 1502 | ), | |
| 1503 | ); |
apps/server/src/mcp/PreviewAutomationBroker.ts+49-5| ... | ... | @@ -2,7 +2,9 @@ import { |
| 2 | 2 | PREVIEW_AUTOMATION_V1_OPERATIONS, |
| 3 | 3 | PreviewAutomationClientDisconnectedError, |
| 4 | 4 | PreviewAutomationControlInterruptedError, |
| 5 | PreviewAutomationControlReason, | |
| 5 | 6 | PreviewAutomationExecutionError, |
| 7 | SERVER_BROWSER_AUTOMATION_CLIENT_ID, | |
| 6 | 8 | PreviewAutomationInvalidSelectorError, |
| 7 | 9 | PreviewAutomationMalformedResponseError, |
| 8 | 10 | PreviewAutomationNoAvailableHostError, |
| ... | ... | @@ -52,11 +54,21 @@ export interface PreviewAutomationInvokeInput { |
| 52 | 54 | readonly onTargetTab?: (tabId: PreviewTabId | undefined) => void; |
| 53 | 55 | } |
| 54 | 56 | |
| 57 | export 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 | ||
| 55 | 66 | export class PreviewAutomationBroker extends Context.Service< |
| 56 | 67 | PreviewAutomationBroker, |
| 57 | 68 | { |
| 58 | 69 | readonly connect: ( |
| 59 | 70 | host: PreviewAutomationHost, |
| 71 | options?: PreviewAutomationConnectOptions, | |
| 60 | 72 | ) => Effect.Effect<Stream.Stream<PreviewAutomationStreamEvent>>; |
| 61 | 73 | readonly focusHost: (host: PreviewAutomationHostFocus) => Effect.Effect<void>; |
| 62 | 74 | readonly respond: ( |
| ... | ... | @@ -74,6 +86,7 @@ interface ClientConnection { |
| 74 | 86 | readonly environmentId: PreviewAutomationHost["environmentId"]; |
| 75 | 87 | readonly supportedOperations: ReadonlySet<PreviewAutomationOperation>; |
| 76 | 88 | readonly focused: boolean; |
| 89 | readonly preferred: boolean; | |
| 77 | 90 | readonly liveTabs: NonNullable<PreviewAutomationHostFocus["liveTabs"]>; |
| 78 | 91 | readonly focusOrder: number; |
| 79 | 92 | readonly queue: Queue.Queue<PreviewAutomationStreamEvent, Cause.Done>; |
| ... | ... | @@ -165,6 +178,7 @@ const hostAssignmentKey = (scope: McpInvocationContext.McpThreadInvocationScope) |
| 165 | 178 | `${scope.environmentId}\u0000${scope.thread.providerSessionId}`; |
| 166 | 179 | |
| 167 | 180 | const isPreviewTabId = Schema.is(PreviewTabId); |
| 181 | const decodeControlReason = Schema.decodeUnknownOption(PreviewAutomationControlReason); | |
| 168 | 182 | |
| 169 | 183 | const readResultTabId = (result: unknown): PreviewTabId | null | undefined => { |
| 170 | 184 | if (typeof result !== "object" || result === null || !("tabId" in result)) return undefined; |
| ... | ... | @@ -194,6 +208,9 @@ function remoteDetailKind(detail: unknown): RemoteDetailKind { |
| 194 | 208 | } |
| 195 | 209 | } |
| 196 | 210 | |
| 211 | /** Enough for a browser's own error line, such as a refused connection and its URL. */ | |
| 212 | const MAX_REASON_CHARS = 500; | |
| 213 | ||
| 197 | 214 | const classifyResponseError = ( |
| 198 | 215 | context: PreviewAutomationRequestErrorContext, |
| 199 | 216 | error: NonNullable<PreviewAutomationResponse["error"]>, |
| ... | ... | @@ -245,15 +262,24 @@ const classifyResponseError = ( |
| 245 | 262 | ...context, |
| 246 | 263 | ...remoteDiagnostics, |
| 247 | 264 | }); |
| 248 | case "PreviewAutomationControlInterruptedError": | |
| 265 | case "PreviewAutomationControlInterruptedError": { | |
| 266 | const reason = decodeControlReason(error.detail); | |
| 249 | 267 | return new PreviewAutomationControlInterruptedError({ |
| 250 | 268 | ...context, |
| 251 | 269 | ...remoteDiagnostics, |
| 270 | ...(Option.isSome(reason) ? { reason: reason.value } : {}), | |
| 252 | 271 | }); |
| 272 | } | |
| 253 | 273 | case "PreviewAutomationInvalidSelectorError": { |
| 274 | const staleRef = | |
| 275 | typeof error.detail === "object" && | |
| 276 | error.detail !== null && | |
| 277 | "staleRef" in error.detail && | |
| 278 | error.detail.staleRef === true; | |
| 254 | 279 | return new PreviewAutomationInvalidSelectorError({ |
| 255 | 280 | ...context, |
| 256 | 281 | ...remoteDiagnostics, |
| 282 | ...(staleRef ? { staleRef } : {}), | |
| 257 | 283 | }); |
| 258 | 284 | } |
| 259 | 285 | case "PreviewAutomationTargetNotEditableError": { |
| ... | ... | @@ -312,6 +338,10 @@ const classifyResponseError = ( |
| 312 | 338 | return new PreviewAutomationExecutionError({ |
| 313 | 339 | ...context, |
| 314 | 340 | ...remoteDiagnostics, |
| 341 | // The server's own browser writes these; other hosts' text stays out of the agent's context. | |
| 342 | ...(context.clientId === SERVER_BROWSER_AUTOMATION_CLIENT_ID | |
| 343 | ? { reason: error.message.slice(0, MAX_REASON_CHARS) } | |
| 344 | : {}), | |
| 315 | 345 | }); |
| 316 | 346 | } |
| 317 | 347 | }; |
| ... | ... | @@ -367,6 +397,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { |
| 367 | 397 | |
| 368 | 398 | const acquireConnection = Effect.fn("PreviewAutomationBroker.acquireConnection")(function* ( |
| 369 | 399 | host: PreviewAutomationHost, |
| 400 | options: PreviewAutomationConnectOptions | undefined, | |
| 370 | 401 | ) { |
| 371 | 402 | const clientId = host.clientId; |
| 372 | 403 | const queue = yield* Queue.unbounded<PreviewAutomationStreamEvent, Cause.Done>(); |
| ... | ... | @@ -378,6 +409,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { |
| 378 | 409 | environmentId: host.environmentId, |
| 379 | 410 | supportedOperations: new Set(host.supportedOperations ?? PREVIEW_AUTOMATION_V1_OPERATIONS), |
| 380 | 411 | focused: false, |
| 412 | preferred: options?.preferred ?? false, | |
| 381 | 413 | liveTabs: [], |
| 382 | 414 | focusOrder: 0, |
| 383 | 415 | queue, |
| ... | ... | @@ -408,10 +440,10 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { |
| 408 | 440 | |
| 409 | 441 | const connect: PreviewAutomationBroker["Service"]["connect"] = Effect.fn( |
| 410 | 442 | "PreviewAutomationBroker.connect", |
| 411 | )((host) => | |
| 443 | )((host, options) => | |
| 412 | 444 | Effect.succeed( |
| 413 | 445 | Stream.unwrap( |
| 414 | Effect.acquireRelease(acquireConnection(host), (connection) => | |
| 446 | Effect.acquireRelease(acquireConnection(host, options), (connection) => | |
| 415 | 447 | disconnect(connection.clientId, connection.queue), |
| 416 | 448 | ).pipe(Effect.map((connection) => Stream.fromQueue(connection.queue))), |
| 417 | 449 | ), |
| ... | ... | @@ -474,7 +506,6 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { |
| 474 | 506 | const invoke = Effect.fn("PreviewAutomationBroker.invoke")(function* <A = unknown>( |
| 475 | 507 | input: Parameters<PreviewAutomationBroker["Service"]["invoke"]>[0], |
| 476 | 508 | ): Effect.fn.Return<A, PreviewAutomationError> { |
| 477 | const timeoutMs = input.timeoutMs ?? 15_000; | |
| 478 | 509 | const deferred = yield* Deferred.make<unknown, PreviewAutomationError>(); |
| 479 | 510 | const route = yield* SynchronizedRef.modify(state, (current) => { |
| 480 | 511 | const assignments = new Map( |
| ... | ... | @@ -516,6 +547,9 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { |
| 516 | 547 | ) |
| 517 | 548 | .sort( |
| 518 | 549 | (left, right) => |
| 550 | Number(input.tabId !== undefined && ownsTargetTab(right)) - | |
| 551 | Number(input.tabId !== undefined && ownsTargetTab(left)) || | |
| 552 | Number(right.preferred) - Number(left.preferred) || | |
| 519 | 553 | Number(ownsTargetTab(right, true)) - Number(ownsTargetTab(left, true)) || |
| 520 | 554 | Number(ownsTargetTab(right)) - Number(ownsTargetTab(left)) || |
| 521 | 555 | Number(right.focused) - Number(left.focused) || |
| ... | ... | @@ -525,6 +559,10 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { |
| 525 | 559 | if (!hasLiveAssignment) assignments.delete(assignmentKey); |
| 526 | 560 | return [undefined, { ...current, assignments }] as const; |
| 527 | 561 | } |
| 562 | // The environment host may install Chromium on its first open (up to | |
| 563 | // ten minutes). Keep that request alive without replaying its effects. | |
| 564 | const timeoutMs = | |
| 565 | input.timeoutMs ?? (input.operation === "open" && connection.preferred ? 660_000 : 15_000); | |
| 528 | 566 | const canReuseAssignedTab = |
| 529 | 567 | assigned !== undefined && |
| 530 | 568 | assigned.connectionId === connection.connectionId && |
| ... | ... | @@ -573,6 +611,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { |
| 573 | 611 | }); |
| 574 | 612 | } |
| 575 | 613 | const { connection, requestId, requestContext, requestSequence } = route; |
| 614 | const { timeoutMs } = requestContext; | |
| 576 | 615 | input.onTargetTab?.(requestContext.tabId); |
| 577 | 616 | const removePending = SynchronizedRef.update(state, (next) => { |
| 578 | 617 | if (!next.pending.has(requestId)) return next; |
| ... | ... | @@ -598,6 +637,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { |
| 598 | 637 | threadId: input.scope.thread.threadId, |
| 599 | 638 | tabId: requestContext.tabId, |
| 600 | 639 | tabIdExplicit: input.tabId !== undefined, |
| 640 | agentSessionId: hostAssignmentKey(input.scope), | |
| 601 | 641 | operation: input.operation, |
| 602 | 642 | input: input.input, |
| 603 | 643 | timeoutMs, |
| ... | ... | @@ -617,7 +657,11 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { |
| 617 | 657 | Effect.gen(function* () { |
| 618 | 658 | // An unanswered request invalidates this connection. Do not replay |
| 619 | 659 | // actions: the client may have applied them before becoming unreachable. |
| 620 | yield* disconnect(connection.clientId, connection.queue, true); | |
| 660 | // A background metadata read has a short budget and changes nothing, | |
| 661 | // so a slow one must not cut the host off from the agent's next call. | |
| 662 | if (input.updateCurrentTab !== false) { | |
| 663 | yield* disconnect(connection.clientId, connection.queue, true); | |
| 664 | } | |
| 621 | 665 | return yield* new PreviewAutomationTimeoutError(requestContext); |
| 622 | 666 | }), |
| 623 | 667 | onSome: (value) => Effect.succeed(value as A), |
apps/server/src/mcp/toolkits/core.test.ts+2| ... | ... | @@ -21,6 +21,7 @@ import * as ServerConfig from "../../config.ts"; |
| 21 | 21 | import { OrchestratorProjectionError } from "../../orchestration-v2/Orchestrator.ts"; |
| 22 | 22 | import * as ProviderAdapterRegistry from "../../orchestration-v2/ProviderAdapterRegistry.ts"; |
| 23 | 23 | import * as ThreadManagement from "../../orchestration-v2/ThreadManagementService.ts"; |
| 24 | import * as PreviewBrowser from "../../preview/PreviewBrowser.ts"; | |
| 24 | 25 | import * as ProjectService from "../../project/ProjectService.ts"; |
| 25 | 26 | import * as ProviderRegistry from "../../provider/ProviderRegistry.ts"; |
| 26 | 27 | import * as SecretRequests from "../../secrets/SecretRequests.ts"; |
| ... | ... | @@ -205,6 +206,7 @@ it.effect("returns an HTML render reference that Codex and Claude tool rows both |
| 205 | 206 | Effect.provide( |
| 206 | 207 | McpHttpServer.layerHtmlToolkit.pipe( |
| 207 | 208 | Layer.provideMerge(McpServer.McpServer.layer), |
| 209 | Layer.provide(PreviewBrowser.layer), | |
| 208 | 210 | Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-html-render-" })), |
| 209 | 211 | Layer.provide(NodeServices.layer), |
| 210 | 212 | // The preview browser is not installed in a fresh home, so nothing downloads. |
apps/server/src/mcp/toolkits/preview/handlers.ts+7| ... | ... | @@ -13,6 +13,7 @@ import { |
| 13 | 13 | type PreviewAutomationOpenInput, |
| 14 | 14 | type PreviewAutomationRecordingStatus, |
| 15 | 15 | type PreviewAutomationResizeResult, |
| 16 | type PreviewAutomationSelectResult, | |
| 16 | 17 | type PreviewAutomationSetColorSchemeResult, |
| 17 | 18 | type PreviewAutomationSnapshot, |
| 18 | 19 | type PreviewAutomationStatus, |
| ... | ... | @@ -188,6 +189,7 @@ export const claimPreviewRecording = Effect.fn("PreviewToolkit.claimRecording")( |
| 188 | 189 | }); |
| 189 | 190 | |
| 190 | 191 | const handlers = { |
| 192 | preview_dialog: (input) => invokeTargeted<PreviewAutomationStatus>("dialog", input), | |
| 191 | 193 | preview_status: (input) => invokeTargeted<PreviewAutomationStatus>("status", input ?? {}), |
| 192 | 194 | preview_open: (input) => |
| 193 | 195 | invokeTargeted<PreviewAutomationStatus>("open", normalizePreviewOpenInput(input)), |
| ... | ... | @@ -204,6 +206,11 @@ const handlers = { |
| 204 | 206 | }, |
| 205 | 207 | preview_click: (input) => invokeTargeted<object>("click", input, input.timeoutMs), |
| 206 | 208 | preview_type: (input) => invokeTargeted<object>("type", input, input.timeoutMs), |
| 209 | preview_hover: (input) => invokeTargeted<object>("hover", input, input.timeoutMs), | |
| 210 | preview_select: (input) => | |
| 211 | invokeTargeted<PreviewAutomationSelectResult>("select", input, input.timeoutMs), | |
| 212 | preview_drag: (input) => invokeTargeted<object>("drag", input, input.timeoutMs), | |
| 213 | preview_upload: (input) => invokeTargeted<object>("upload", input, input.timeoutMs), | |
| 207 | 214 | preview_press: (input) => invokeTargeted<object>("press", input), |
| 208 | 215 | preview_scroll: (input) => invokeTargeted<object>("scroll", input), |
| 209 | 216 | preview_evaluate: ({ tabId, ...input }) => |
apps/server/src/mcp/toolkits/preview/tools.test.ts+3| ... | ... | @@ -60,6 +60,9 @@ it("exports exact object result schemas for preview actions", () => { |
| 60 | 60 | const actionNames = [ |
| 61 | 61 | "preview_click", |
| 62 | 62 | "preview_type", |
| 63 | "preview_hover", | |
| 64 | "preview_drag", | |
| 65 | "preview_upload", | |
| 63 | 66 | "preview_press", |
| 64 | 67 | "preview_scroll", |
| 65 | 68 | "preview_wait_for", |
apps/server/src/mcp/toolkits/preview/tools.ts+78-4| ... | ... | @@ -1,8 +1,11 @@ |
| 1 | 1 | import { |
| 2 | 2 | ToolActivityIcon, |
| 3 | 3 | PreviewAutomationClickInput, |
| 4 | PreviewAutomationDialogInput, | |
| 5 | PreviewAutomationDragInput, | |
| 4 | 6 | PreviewAutomationError, |
| 5 | 7 | PreviewAutomationEvaluateInput, |
| 8 | PreviewAutomationHoverInput, | |
| 6 | 9 | PreviewAutomationNavigateInput, |
| 7 | 10 | PreviewAutomationOpenInput, |
| 8 | 11 | PreviewAutomationPressInput, |
| ... | ... | @@ -11,12 +14,15 @@ import { |
| 11 | 14 | PreviewAutomationResizeInput, |
| 12 | 15 | PreviewAutomationResizeResult, |
| 13 | 16 | PreviewAutomationScrollInput, |
| 17 | PreviewAutomationSelectInput, | |
| 18 | PreviewAutomationSelectResult, | |
| 14 | 19 | PreviewAutomationSetColorSchemeInput, |
| 15 | 20 | PreviewAutomationSetColorSchemeResult, |
| 16 | 21 | PreviewAutomationSnapshot, |
| 17 | 22 | PreviewAutomationStatus, |
| 18 | 23 | PreviewAutomationTabTargetInput, |
| 19 | 24 | PreviewAutomationTypeInput, |
| 25 | PreviewAutomationUploadInput, | |
| 20 | 26 | PreviewAutomationWaitForInput, |
| 21 | 27 | } from "@t3tools/contracts"; |
| 22 | 28 | import * as Schema from "effect/Schema"; |
| ... | ... | @@ -52,7 +58,7 @@ const readonlyBrowserTool = <T extends Tool.Any>(tool: T): T => |
| 52 | 58 | |
| 53 | 59 | const PreviewStatusTool = Tool.make("preview_status", { |
| 54 | 60 | description: |
| 55 | "Report whether a collaborative browser tab is automation-capable, including its URL, title, visibility, loading state, viewport mode, and measured CSS-pixel size. Pass tabId to inspect a specific tab; omit it to use this agent session's current tab.", | |
| 61 | "Report whether a collaborative browser tab is automation-capable, including its control owner, pending dialog, URL, title, visibility, loading state, viewport mode, and measured CSS-pixel size. Pass tabId to inspect a specific tab; omit it to use this agent session's current tab.", | |
| 56 | 62 | parameters: PreviewAutomationTabTargetInput, |
| 57 | 63 | success: PreviewAutomationStatus, |
| 58 | 64 | failure: PreviewAutomationError, |
| ... | ... | @@ -66,7 +72,7 @@ const PreviewStatusTool = Tool.make("preview_status", { |
| 66 | 72 | const PreviewOpenTool = browserTool( |
| 67 | 73 | Tool.make("preview_open", { |
| 68 | 74 | description: |
| 69 | "Initialize a collaborative browser tab and open its thread-bound inline preview by default. Set open=false for background-only automation. Pass tabId to reuse a specific existing tab, set reuseExistingTab=false to create another tab, or omit both to use this agent session's current tab.", | |
| 75 | "Initialize a collaborative browser tab and open its thread-bound inline preview by default. Set open=false for background-only automation. Pass tabId to reuse a specific existing tab, set reuseExistingTab=false to create another tab, or omit both to use this agent session's current tab. Parallel subagents sharing a provider session must each open with reuseExistingTab=false and pass their returned tabId on every call. Server tabs use isolated storage and cannot be operated by a different agent session.", | |
| 70 | 76 | parameters: PreviewAutomationOpenInput, |
| 71 | 77 | success: PreviewAutomationStatus, |
| 72 | 78 | failure: PreviewAutomationError, |
| ... | ... | @@ -76,6 +82,17 @@ const PreviewOpenTool = browserTool( |
| 76 | 82 | .annotate(Tool.Destructive, false), |
| 77 | 83 | ); |
| 78 | 84 | |
| 85 | const 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 | ||
| 79 | 96 | const PreviewNavigateTool = safeBrowserTool( |
| 80 | 97 | Tool.make("preview_navigate", { |
| 81 | 98 | description: |
| ... | ... | @@ -119,7 +136,7 @@ const PreviewSetAppearanceTool = safeBrowserTool( |
| 119 | 136 | export const PreviewSnapshotTool = readonlyBrowserTool( |
| 120 | 137 | Tool.make("preview_snapshot", { |
| 121 | 138 | description: |
| 122 | "Inspect a page before interacting. Pass tabId to inspect a specific tab; omit it to use this agent session's current tab. Returns page state, semantic elements, diagnostics, action history, and a PNG screenshot. The text is capped near 20 KB and lists what it omitted; use preview_evaluate to read more. Set includeImage=false for text-only output with the same page metadata. Set save=true to also write the PNG to disk and get screenshotPath back; with includeImage=false, save=true returns only the url and screenshotPath. Embed that path in your reply as  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  so the user sees it. This is the only way to show the user a screenshot; the image in the tool result is not saved anywhere.", | |
| 123 | 140 | parameters: Schema.Struct({ |
| 124 | 141 | ...PreviewAutomationTabTargetInput.fields, |
| 125 | 142 | includeImage: Schema.optional( |
| ... | ... | @@ -144,7 +161,7 @@ export const PreviewSnapshotTool = readonlyBrowserTool( |
| 144 | 161 | const PreviewClickTool = browserTool( |
| 145 | 162 | Tool.make("preview_click", { |
| 146 | 163 | description: |
| 147 | "Click exactly one target in the tab selected by tabId, or this agent session's current tab when omitted. Prefer a Playwright locator; selector accepts legacy CSS; x and y must be supplied together.", | |
| 164 | "Click exactly one target in the tab selected by tabId, or this agent session's current tab when omitted. Prefer a Playwright locator; selector accepts legacy CSS; x and y must be supplied together. Set button=right for a context menu or clickCount=2 for a double-click; server browser tabs only.", | |
| 148 | 165 | parameters: PreviewAutomationClickInput, |
| 149 | 166 | success: PreviewActionResult, |
| 150 | 167 | failure: PreviewAutomationError, |
| ... | ... | @@ -163,6 +180,53 @@ const PreviewTypeTool = browserTool( |
| 163 | 180 | }).annotate(Tool.Title, "Type into preview page"), |
| 164 | 181 | ); |
| 165 | 182 | |
| 183 | const 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 | ||
| 194 | const 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 | ||
| 208 | const 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 | ||
| 219 | const 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 | ||
| 166 | 230 | const PreviewPressTool = browserTool( |
| 167 | 231 | Tool.make("preview_press", { |
| 168 | 232 | description: |
| ... | ... | @@ -242,6 +306,7 @@ const PreviewRecordingStopTool = safeBrowserTool( |
| 242 | 306 | ); |
| 243 | 307 | |
| 244 | 308 | export const PreviewToolkit = Toolkit.make( |
| 309 | PreviewDialogTool, | |
| 245 | 310 | PreviewStatusTool, |
| 246 | 311 | PreviewOpenTool, |
| 247 | 312 | PreviewNavigateTool, |
| ... | ... | @@ -250,6 +315,10 @@ export const PreviewToolkit = Toolkit.make( |
| 250 | 315 | PreviewSnapshotTool, |
| 251 | 316 | PreviewClickTool, |
| 252 | 317 | PreviewTypeTool, |
| 318 | PreviewHoverTool, | |
| 319 | PreviewSelectTool, | |
| 320 | PreviewDragTool, | |
| 321 | PreviewUploadTool, | |
| 253 | 322 | PreviewPressTool, |
| 254 | 323 | PreviewScrollTool, |
| 255 | 324 | PreviewEvaluateTool, |
| ... | ... | @@ -259,6 +328,7 @@ export const PreviewToolkit = Toolkit.make( |
| 259 | 328 | ); |
| 260 | 329 | |
| 261 | 330 | export const PreviewStandardToolkit = Toolkit.make( |
| 331 | PreviewDialogTool, | |
| 262 | 332 | PreviewStatusTool, |
| 263 | 333 | PreviewOpenTool, |
| 264 | 334 | PreviewNavigateTool, |
| ... | ... | @@ -266,6 +336,10 @@ export const PreviewStandardToolkit = Toolkit.make( |
| 266 | 336 | PreviewSetAppearanceTool, |
| 267 | 337 | PreviewClickTool, |
| 268 | 338 | PreviewTypeTool, |
| 339 | PreviewHoverTool, | |
| 340 | PreviewSelectTool, | |
| 341 | PreviewDragTool, | |
| 342 | PreviewUploadTool, | |
| 269 | 343 | PreviewPressTool, |
| 270 | 344 | PreviewScrollTool, |
| 271 | 345 | PreviewEvaluateTool, |
apps/server/src/mcp/toolkits/previewControls/handlers.test.ts+14-1| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | 3 | import { expect, it } from "@effect/vitest"; |
| 3 | 4 | import { |
| 4 | 5 | DEFAULT_SERVER_SETTINGS, |
| ... | ... | @@ -12,9 +13,11 @@ import * as Effect from "effect/Effect"; |
| 12 | 13 | import * as Layer from "effect/Layer"; |
| 13 | 14 | import * as Stream from "effect/Stream"; |
| 14 | 15 | |
| 16 | import * as ServerConfig from "../../../config.ts"; | |
| 15 | 17 | import * as Preview from "../../../preview/Manager.ts"; |
| 16 | 18 | import * as ServerSettings from "../../../serverSettings.ts"; |
| 17 | 19 | import * as McpInvocationContext from "../../McpInvocationContext.ts"; |
| 20 | import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; | |
| 18 | 21 | import * as PreviewControlsHandlers from "./handlers.ts"; |
| 19 | 22 | import { PreviewControlsToolkit } from "./tools.ts"; |
| 20 | 23 | |
| ... | ... | @@ -46,9 +49,19 @@ it.effect.each([ |
| 46 | 49 | capabilities: new Set(effective.enableAgentBrowserAccess ? ["preview"] : []), |
| 47 | 50 | issuedAt: 0, |
| 48 | 51 | }; |
| 49 | const manager = yield* Preview.make.pipe(Effect.provide(NodeCrypto.layer)); | |
| 52 | const manager = yield* Preview.make.pipe( | |
| 53 | Effect.provide( | |
| 54 | Layer.merge( | |
| 55 | ServerConfig.layerTest(process.cwd(), { prefix: "t3-preview-controls-" }).pipe( | |
| 56 | Layer.provide(NodeServices.layer), | |
| 57 | ), | |
| 58 | NodeCrypto.layer, | |
| 59 | ), | |
| 60 | ), | |
| 61 | ); | |
| 50 | 62 | const tab = yield* manager.open({ threadId, url: "http://localhost:3000" }); |
| 51 | 63 | const layerDependencies = Layer.mergeAll( |
| 64 | PreviewAutomationBroker.layer.pipe(Layer.provide(NodeServices.layer)), | |
| 52 | 65 | Layer.succeed(Preview.PreviewManager, manager), |
| 53 | 66 | Layer.succeed(McpInvocationContext.McpInvocationContext, scope), |
| 54 | 67 | Layer.mock(ServerSettings.ServerSettingsService)({ |
apps/server/src/mcp/toolkits/previewControls/handlers.ts+11| ... | ... | @@ -3,6 +3,7 @@ import * as Preview from "../../../preview/Manager.ts"; |
| 3 | 3 | import { requireThreadMcpCapability } from "../../McpInvocationContext.ts"; |
| 4 | 4 | import { unavailable } from "../../threadAccess.ts"; |
| 5 | 5 | import { PreviewControlsToolkit } from "./tools.ts"; |
| 6 | import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; | |
| 6 | 7 | |
| 7 | 8 | const access = Effect.gen(function* () { |
| 8 | 9 | // The preview capability already reflects the calling project's access setting. |
| ... | ... | @@ -25,6 +26,16 @@ export const layer = PreviewControlsToolkit.toLayer({ |
| 25 | 26 | t3_preview_close: (input) => |
| 26 | 27 | Effect.gen(function* () { |
| 27 | 28 | const { scope, manager } = yield* access; |
| 29 | const { sessions } = yield* manager.list({ threadId: scope.thread.threadId }); | |
| 30 | if ( | |
| 31 | sessions.some((session) => session.tabId === input.tabId && session.runtime === "server") | |
| 32 | ) { | |
| 33 | const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; | |
| 34 | yield* broker | |
| 35 | .invoke({ scope, operation: "close", input: {}, tabId: input.tabId }) | |
| 36 | .pipe(Effect.mapError(unavailable)); | |
| 37 | return {}; | |
| 38 | } | |
| 28 | 39 | yield* manager |
| 29 | 40 | .close({ threadId: scope.thread.threadId, tabId: input.tabId }) |
| 30 | 41 | .pipe(Effect.mapError(unavailable)); |
apps/server/src/mcp/toolkits/previewControls/tools.ts+6-1| ... | ... | @@ -8,12 +8,17 @@ import { |
| 8 | 8 | import * as Schema from "effect/Schema"; |
| 9 | 9 | import { Tool, Toolkit } from "effect/ai"; |
| 10 | 10 | import * as PreviewManager from "../../../preview/Manager.ts"; |
| 11 | import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; | |
| 11 | 12 | import * as McpInvocationContext from "../../McpInvocationContext.ts"; |
| 12 | 13 | |
| 13 | 14 | const shared = { |
| 14 | 15 | failure: Schema.Union([OrchestratorMcpFailure, PreviewAutomationUnavailableError]), |
| 15 | 16 | failureMode: "return" as const, |
| 16 | dependencies: [McpInvocationContext.McpInvocationContext, PreviewManager.PreviewManager], | |
| 17 | dependencies: [ | |
| 18 | McpInvocationContext.McpInvocationContext, | |
| 19 | PreviewManager.PreviewManager, | |
| 20 | PreviewAutomationBroker.PreviewAutomationBroker, | |
| 21 | ], | |
| 17 | 22 | }; |
| 18 | 23 | const PreviewListTool = Tool.make("t3_preview_list", { |
| 19 | 24 | ...shared, |
apps/server/src/mcp/toolkits/worktree/registration.test.ts+2| ... | ... | @@ -25,6 +25,7 @@ import * as VcsStatusBroadcaster from "../../../vcs/VcsStatusBroadcaster.ts"; |
| 25 | 25 | import * as McpHttpServer from "../../McpHttpServer.ts"; |
| 26 | 26 | import * as McpSessionRegistry from "../../McpSessionRegistry.ts"; |
| 27 | 27 | import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; |
| 28 | import * as PreviewBrowser from "../../../preview/PreviewBrowser.ts"; | |
| 28 | 29 | |
| 29 | 30 | const layerStubServices = Layer.mergeAll( |
| 30 | 31 | Layer.mock(Orchestrator.OrchestratorV2)({}), |
| ... | ... | @@ -77,6 +78,7 @@ it.effect("production mcp layer lists worktree tools over http", () => |
| 77 | 78 | }), |
| 78 | 79 | ), |
| 79 | 80 | Layer.provide(PreviewAutomationBroker.layer), |
| 81 | Layer.provide(PreviewBrowser.layer), | |
| 80 | 82 | Layer.provide(layerStubServices), |
| 81 | 83 | Layer.build, |
| 82 | 84 | ); |
apps/server/src/preview/DesktopBrowserChannel.test.ts created+58| ... | ... | @@ -0,0 +1,58 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off - The channel reads real file descriptors. | |
| 2 | import * as NodeFS from "node:fs"; | |
| 3 | import * as NodeOS from "node:os"; | |
| 4 | import * as NodePath from "node:path"; | |
| 5 | ||
| 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 7 | import { expect, it } from "@effect/vitest"; | |
| 8 | import * as Context from "effect/Context"; | |
| 9 | import * as Effect from "effect/Effect"; | |
| 10 | import * as Exit from "effect/Exit"; | |
| 11 | import * as Layer from "effect/Layer"; | |
| 12 | ||
| 13 | import * as ServerConfig from "../config.ts"; | |
| 14 | import * as DesktopBrowserChannel from "./DesktopBrowserChannel.ts"; | |
| 15 | ||
| 16 | const key = { threadId: "thread-1", tabId: "tab-1" }; | |
| 17 | ||
| 18 | /** The channel over two files: what the desktop sent, and where commands go. */ | |
| 19 | const 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 | ||
| 46 | it.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 | */ | |
| 10 | import * as NodeStream from "@effect/platform-node/NodeStream"; | |
| 11 | import * as NodeSocketServer from "@effect/platform-node/NodeSocketServer"; | |
| 12 | import { | |
| 13 | DesktopBrowserCommand, | |
| 14 | DesktopBrowserEvent, | |
| 15 | type DesktopBrowserCommand as DesktopBrowserCommandType, | |
| 16 | } from "@t3tools/contracts"; | |
| 17 | import * as Context from "effect/Context"; | |
| 18 | import * as Duration from "effect/Duration"; | |
| 19 | import * as Effect from "effect/Effect"; | |
| 20 | import * as Layer from "effect/Layer"; | |
| 21 | import * as Option from "effect/Option"; | |
| 22 | import * as PubSub from "effect/PubSub"; | |
| 23 | import * as Queue from "effect/Queue"; | |
| 24 | import * as Schema from "effect/Schema"; | |
| 25 | import * as Scope from "effect/Scope"; | |
| 26 | import * as Semaphore from "effect/Semaphore"; | |
| 27 | import * as Stream from "effect/Stream"; | |
| 28 | import * as Ndjson from "effect/encoding/Ndjson"; | |
| 29 | import * as NodeCrypto from "node:crypto"; | |
| 30 | import * as NodeFS from "node:fs"; | |
| 31 | ||
| 32 | import * as ServerConfig from "../config.ts"; | |
| 33 | import { writeAllToFileDescriptor } from "../resourceTelemetry/DesktopTelemetryReceiver.ts"; | |
| 34 | ||
| 35 | const decodeEvent = Schema.decodeUnknownEffect(DesktopBrowserEvent); | |
| 36 | const encodeCommand = Schema.encodeEffect(Schema.fromJsonString(DesktopBrowserCommand)); | |
| 37 | ||
| 38 | export interface DesktopTabKey { | |
| 39 | readonly threadId: string; | |
| 40 | readonly tabId: string; | |
| 41 | } | |
| 42 | ||
| 43 | const keyOf = ({ threadId, tabId }: DesktopTabKey) => `${threadId}\u0000${tabId}`; | |
| 44 | ||
| 45 | export 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 | ||
| 71 | const 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 | ||
| 225 | export const layer = Layer.effect(DesktopBrowserChannel, make); |
apps/server/src/preview/Manager.test.ts+118-1| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | 3 | import { it } from "@effect/vitest"; |
| 3 | 4 | import { type PreviewEvent, ThreadId } from "@t3tools/contracts"; |
| 4 | 5 | import { PreviewUrlNormalizationError } from "@t3tools/shared/preview"; |
| ... | ... | @@ -7,6 +8,7 @@ import * as Layer from "effect/Layer"; |
| 7 | 8 | import * as PubSub from "effect/PubSub"; |
| 8 | 9 | import { expect } from "vite-plus/test"; |
| 9 | 10 | |
| 11 | import * as ServerConfig from "../config.ts"; | |
| 10 | 12 | import * as PreviewManager from "./Manager.ts"; |
| 11 | 13 | |
| 12 | 14 | const DRAIN_LIMIT = 100; |
| ... | ... | @@ -38,7 +40,13 @@ const collectEvents = Effect.gen(function* () { |
| 38 | 40 | return collector; |
| 39 | 41 | }).pipe(Effect.withSpan("preview.test.collectEvents")); |
| 40 | 42 | |
| 41 | it.layer(PreviewManager.layer.pipe(Layer.provide(NodeCrypto.layer)))("PreviewManager", (it) => { | |
| 43 | const 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 | ||
| 49 | it.layer(layer)("PreviewManager", (it) => { | |
| 42 | 50 | it.effect("opens a session and emits opened with normalized URL", () => |
| 43 | 51 | Effect.gen(function* () { |
| 44 | 52 | const threadId = freshThreadId(); |
| ... | ... | @@ -92,6 +100,75 @@ it.layer(PreviewManager.layer.pipe(Layer.provide(NodeCrypto.layer)))("PreviewMan |
| 92 | 100 | }), |
| 93 | 101 | ); |
| 94 | 102 | |
| 103 | it.effect("lets any client size, theme, and zoom a server tab", () => | |
| 104 | Effect.gen(function* () { | |
| 105 | const threadId = freshThreadId(); | |
| 106 | const manager = yield* PreviewManager.PreviewManager; | |
| 107 | const collector = yield* collectEvents; | |
| 108 | const opened = yield* manager.open({ threadId, runtime: "server" }); | |
| 109 | const viewport = { | |
| 110 | _tag: "preset", | |
| 111 | presetId: "iphone-12-pro", | |
| 112 | width: 390, | |
| 113 | height: 844, | |
| 114 | } as const; | |
| 115 | yield* manager.resize({ threadId, tabId: opened.tabId, viewport }); | |
| 116 | const adjusted = yield* manager.adjust({ | |
| 117 | threadId, | |
| 118 | tabId: opened.tabId, | |
| 119 | colorScheme: "dark", | |
| 120 | zoomFactor: 1.25, | |
| 121 | }); | |
| 122 | expect(adjusted).toMatchObject({ viewport, colorScheme: "dark", zoomFactor: 1.25 }); | |
| 123 | // One-off requests go to the server's browser and leave the tab's state alone. | |
| 124 | yield* manager.adjust({ threadId, tabId: opened.tabId, hardReload: true, clear: "cache" }); | |
| 125 | const events = (yield* collector.drain).filter((event) => event.type === "resized"); | |
| 126 | expect(events.at(-1)).toMatchObject({ | |
| 127 | request: { hardReload: true, clear: "cache" }, | |
| 128 | snapshot: { colorScheme: "dark", zoomFactor: 1.25 }, | |
| 129 | }); | |
| 130 | expect(events.at(-2)).not.toHaveProperty("request"); | |
| 131 | }), | |
| 132 | ); | |
| 133 | ||
| 134 | it.effect("reissues presentation requests without replaying them on navigation", () => | |
| 135 | Effect.gen(function* () { | |
| 136 | const threadId = freshThreadId(); | |
| 137 | const manager = yield* PreviewManager.PreviewManager; | |
| 138 | const collector = yield* collectEvents; | |
| 139 | const opened = yield* manager.open({ threadId, runtime: "server", reveal: false }); | |
| 140 | yield* manager.requestReveal({ threadId, tabId: opened.tabId, force: true }); | |
| 141 | const first = (yield* manager.list({ threadId })).sessions[0]; | |
| 142 | expect(first?.reveal).toBe(true); | |
| 143 | expect(first?.revealRequest?.force).toBe(true); | |
| 144 | // Server tabs report navigation through their controlling browser. | |
| 145 | const refused = yield* manager | |
| 146 | .navigate({ threadId, tabId: opened.tabId, url: "localhost:5173" }) | |
| 147 | .pipe(Effect.flip); | |
| 148 | expect(refused._tag).toBe("PreviewControlRequiredError"); | |
| 149 | yield* manager.reportStatus({ | |
| 150 | threadId, | |
| 151 | tabId: opened.tabId, | |
| 152 | serverControlled: true, | |
| 153 | navStatus: { _tag: "Success", url: "http://localhost:5173/", title: "Dev" }, | |
| 154 | canGoBack: false, | |
| 155 | canGoForward: false, | |
| 156 | }); | |
| 157 | expect((yield* manager.list({ threadId })).sessions[0]?.revealRequest).toEqual( | |
| 158 | first?.revealRequest, | |
| 159 | ); | |
| 160 | yield* manager.requestReveal({ threadId, tabId: opened.tabId, force: false }); | |
| 161 | const second = (yield* manager.list({ threadId })).sessions[0]; | |
| 162 | expect(second?.revealRequest?.id).not.toBe(first?.revealRequest?.id); | |
| 163 | expect(second?.revealRequest?.force).toBe(false); | |
| 164 | const events = yield* collector.drain; | |
| 165 | const last = events.at(-1); | |
| 166 | expect(last?.type).toBe("navigated"); | |
| 167 | if (last?.type === "navigated") | |
| 168 | expect(last.snapshot.revealRequest).toEqual(second?.revealRequest); | |
| 169 | }), | |
| 170 | ); | |
| 171 | ||
| 95 | 172 | it.effect("opens an Idle tab when no URL is supplied", () => |
| 96 | 173 | Effect.gen(function* () { |
| 97 | 174 | const threadId = freshThreadId(); |
| ... | ... | @@ -266,6 +343,46 @@ it.layer(PreviewManager.layer.pipe(Layer.provide(NodeCrypto.layer)))("PreviewMan |
| 266 | 343 | }), |
| 267 | 344 | ); |
| 268 | 345 | |
| 346 | it.effect( | |
| 347 | "rejects client status reports for server tabs without changing state or emitting events", | |
| 348 | () => | |
| 349 | Effect.gen(function* () { | |
| 350 | const threadId = freshThreadId(); | |
| 351 | const manager = yield* PreviewManager.PreviewManager; | |
| 352 | const opened = yield* manager.open({ threadId, runtime: "server", reveal: false }); | |
| 353 | const collector = yield* collectEvents; | |
| 354 | const before = yield* manager.list({ threadId }); | |
| 355 | const input = { | |
| 356 | threadId, | |
| 357 | tabId: opened.tabId, | |
| 358 | navStatus: { | |
| 359 | _tag: "Success" as const, | |
| 360 | url: "http://localhost:5173/changed", | |
| 361 | title: "Changed", | |
| 362 | }, | |
| 363 | canGoBack: true, | |
| 364 | canGoForward: true, | |
| 365 | }; | |
| 366 | const rejected = yield* manager.reportStatus(input).pipe(Effect.flip); | |
| 367 | expect(rejected._tag).toBe("PreviewControlRequiredError"); | |
| 368 | expect(yield* manager.list({ threadId })).toEqual(before); | |
| 369 | expect(yield* collector.drain).toEqual([]); | |
| 370 | ||
| 371 | yield* manager.reportStatus({ ...input, serverControlled: true }); | |
| 372 | expect((yield* manager.list({ threadId })).sessions[0]).toMatchObject({ | |
| 373 | navStatus: input.navStatus, | |
| 374 | canGoBack: true, | |
| 375 | canGoForward: true, | |
| 376 | }); | |
| 377 | const events = yield* collector.drain; | |
| 378 | expect(events).toHaveLength(1); | |
| 379 | expect(events[0]).toMatchObject({ | |
| 380 | type: "navigated", | |
| 381 | snapshot: { navStatus: input.navStatus }, | |
| 382 | }); | |
| 383 | }), | |
| 384 | ); | |
| 385 | ||
| 269 | 386 | it.effect("reportStatus emits failed for LoadFailed nav", () => |
| 270 | 387 | Effect.gen(function* () { |
| 271 | 388 | const threadId = freshThreadId(); |
apps/server/src/preview/Manager.ts+125-73| ... | ... | @@ -21,10 +21,10 @@ import { |
| 21 | 21 | type PreviewRefreshInput, |
| 22 | 22 | type PreviewReportStatusInput, |
| 23 | 23 | type PreviewResizeInput, |
| 24 | type PreviewAdjustInput, | |
| 24 | 25 | FILL_PREVIEW_VIEWPORT, |
| 25 | 26 | PreviewSessionLookupError, |
| 26 | 27 | type PreviewSessionSnapshot, |
| 27 | type PreviewViewportSetting, | |
| 28 | 28 | } from "@t3tools/contracts"; |
| 29 | 29 | import { |
| 30 | 30 | isPreviewUrlNormalizationError, |
| ... | ... | @@ -40,18 +40,38 @@ import * as PubSub from "effect/PubSub"; |
| 40 | 40 | import * as Scope from "effect/Scope"; |
| 41 | 41 | import * as Stream from "effect/Stream"; |
| 42 | 42 | import * as SynchronizedRef from "effect/SynchronizedRef"; |
| 43 | import { PreviewControlRequiredError } from "@t3tools/contracts"; | |
| 43 | 44 | |
| 44 | 45 | export class PreviewManager extends Context.Service< |
| 45 | 46 | PreviewManager, |
| 46 | 47 | { |
| 47 | readonly open: (input: PreviewOpenInput) => Effect.Effect<PreviewSessionSnapshot, PreviewError>; | |
| 48 | readonly open: ( | |
| 49 | input: PreviewOpenInput & { | |
| 50 | readonly automationOwner?: string; | |
| 51 | /** Runs before the `opened` event publishes, so subscribers find state keyed by the tab. */ | |
| 52 | readonly beforePublish?: (snapshot: PreviewSessionSnapshot) => void; | |
| 53 | }, | |
| 54 | ) => Effect.Effect<PreviewSessionSnapshot, PreviewError>; | |
| 48 | 55 | readonly navigate: ( |
| 49 | 56 | input: PreviewNavigateInput, |
| 50 | 57 | ) => Effect.Effect<PreviewSessionSnapshot, PreviewError>; |
| 51 | readonly reportStatus: (input: PreviewReportStatusInput) => Effect.Effect<void, PreviewError>; | |
| 58 | readonly reportStatus: ( | |
| 59 | input: PreviewReportStatusInput & { readonly serverControlled?: boolean }, | |
| 60 | ) => Effect.Effect<void, PreviewError>; | |
| 61 | readonly requestReveal: ( | |
| 62 | input: PreviewCloseInput & { readonly tabId: string; readonly force: boolean }, | |
| 63 | ) => Effect.Effect<void, PreviewError>; | |
| 52 | 64 | readonly resize: ( |
| 53 | 65 | input: PreviewResizeInput, |
| 54 | 66 | ) => Effect.Effect<PreviewSessionSnapshot, PreviewError>; |
| 67 | /** | |
| 68 | * Records a server tab's appearance or zoom and publishes it; the server's | |
| 69 | * browser applies it to the page. Any client may send it, as it changes how | |
| 70 | * the page renders, not what it does. | |
| 71 | */ | |
| 72 | readonly adjust: ( | |
| 73 | input: PreviewAdjustInput, | |
| 74 | ) => Effect.Effect<PreviewSessionSnapshot, PreviewError>; | |
| 55 | 75 | readonly refresh: (input: PreviewRefreshInput) => Effect.Effect<void, PreviewError>; |
| 56 | 76 | readonly close: (input: PreviewCloseInput) => Effect.Effect<void, PreviewError>; |
| 57 | 77 | readonly list: (input: PreviewListInput) => Effect.Effect<PreviewListResult>; |
| ... | ... | @@ -117,42 +137,6 @@ const normalizeUrl = (rawUrl: string): Effect.Effect<string, PreviewInvalidUrlEr |
| 117 | 137 | |
| 118 | 138 | const currentIsoTimestamp = DateTime.now.pipe(Effect.map(DateTime.formatIso)); |
| 119 | 139 | |
| 120 | const 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 | ||
| 139 | const buildIdleSnapshot = (input: { | |
| 140 | readonly threadId: string; | |
| 141 | readonly tabId: string; | |
| 142 | readonly viewport: PreviewViewportSetting; | |
| 143 | readonly profileId?: string | undefined; | |
| 144 | readonly updatedAt: string; | |
| 145 | }): PreviewSessionSnapshot => ({ | |
| 146 | threadId: input.threadId, | |
| 147 | tabId: input.tabId, | |
| 148 | navStatus: { _tag: "Idle" }, | |
| 149 | canGoBack: false, | |
| 150 | canGoForward: false, | |
| 151 | viewport: input.viewport, | |
| 152 | ...(input.profileId === undefined ? {} : { profileId: input.profileId }), | |
| 153 | updatedAt: input.updatedAt, | |
| 154 | }); | |
| 155 | ||
| 156 | 140 | /** @public Service construction is part of the canonical Effect module API. */ |
| 157 | 141 | export const make = Effect.gen(function* PreviewManagerMake() { |
| 158 | 142 | const crypto = yield* Crypto.Crypto; |
| ... | ... | @@ -222,29 +206,27 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 222 | 206 | |
| 223 | 207 | const open: PreviewManager["Service"]["open"] = Effect.fn("PreviewManager.open")( |
| 224 | 208 | function* (input) { |
| 225 | const tabId = newPreviewTabId(); | |
| 209 | const runtime = input.runtime; | |
| 210 | // Persisted client surfaces must not bind to a different tab after a server restart. | |
| 211 | const tabId = `${newPreviewTabId()}${runtime === "server" ? `_${serverEpoch}` : ""}`; | |
| 226 | 212 | 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 | }; | |
| 248 | 230 | yield* SynchronizedRef.modifyEffect(stateRef, (state) => |
| 249 | 231 | Effect.gen(function* () { |
| 250 | 232 | const revision = state.revision + 1; |
| ... | ... | @@ -254,6 +236,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 254 | 236 | tabId, |
| 255 | 237 | snapshot, |
| 256 | 238 | }); |
| 239 | input.beforePublish?.(snapshot); | |
| 257 | 240 | yield* PubSub.publish(eventsPubSub, { |
| 258 | 241 | type: "opened", |
| 259 | 242 | threadId: input.threadId, |
| ... | ... | @@ -277,20 +260,16 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 277 | 260 | input.threadId, |
| 278 | 261 | input.tabId, |
| 279 | 262 | Effect.fn("PreviewManager.navigateSession")(function* (session) { |
| 263 | if (session.snapshot.runtime === "server") | |
| 264 | return yield* new PreviewControlRequiredError({ tabId: input.tabId }); | |
| 280 | 265 | const updatedAt = yield* currentIsoTimestamp; |
| 281 | 266 | const previousTitle = |
| 282 | 267 | session.snapshot.navStatus._tag === "Idle" ? "" : session.snapshot.navStatus.title; |
| 283 | 268 | const resolvedTitle = input.resolvedTitle ?? previousTitle; |
| 284 | 269 | const snapshot: PreviewSessionSnapshot = { |
| 285 | threadId: session.threadId, | |
| 286 | tabId: session.tabId, | |
| 270 | ...session.snapshot, | |
| 287 | 271 | navStatus: { _tag: "Success", url, title: resolvedTitle }, |
| 288 | canGoBack: session.snapshot.canGoBack, | |
| 289 | canGoForward: session.snapshot.canGoForward, | |
| 290 | 272 | viewport: session.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT, |
| 291 | ...(session.snapshot.profileId === undefined | |
| 292 | ? {} | |
| 293 | : { profileId: session.snapshot.profileId }), | |
| 294 | 273 | updatedAt, |
| 295 | 274 | }; |
| 296 | 275 | return { |
| ... | ... | @@ -316,17 +295,15 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 316 | 295 | input.threadId, |
| 317 | 296 | input.tabId, |
| 318 | 297 | Effect.fn("PreviewManager.reportSessionStatus")(function* (session) { |
| 298 | if (session.snapshot.runtime === "server" && !input.serverControlled) | |
| 299 | return yield* new PreviewControlRequiredError({ tabId: input.tabId }); | |
| 319 | 300 | const updatedAt = yield* currentIsoTimestamp; |
| 320 | 301 | const snapshot: PreviewSessionSnapshot = { |
| 321 | threadId: session.threadId, | |
| 322 | tabId: session.tabId, | |
| 302 | ...session.snapshot, | |
| 323 | 303 | navStatus: input.navStatus, |
| 324 | 304 | canGoBack: input.canGoBack, |
| 325 | 305 | canGoForward: input.canGoForward, |
| 326 | 306 | viewport: session.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT, |
| 327 | ...(session.snapshot.profileId === undefined | |
| 328 | ? {} | |
| 329 | : { profileId: session.snapshot.profileId }), | |
| 330 | 307 | updatedAt, |
| 331 | 308 | }; |
| 332 | 309 | const emit: PreviewEventDraft = |
| ... | ... | @@ -362,6 +339,8 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 362 | 339 | return yield* mutateExistingSession( |
| 363 | 340 | input.threadId, |
| 364 | 341 | input.tabId, |
| 342 | // Any client may size a tab, as the desktop has always allowed; the | |
| 343 | // server's browser follows the published setting for its own tabs. | |
| 365 | 344 | Effect.fn("PreviewManager.resizeSession")(function* (session) { |
| 366 | 345 | const updatedAt = yield* currentIsoTimestamp; |
| 367 | 346 | const snapshot: PreviewSessionSnapshot = { |
| ... | ... | @@ -385,6 +364,46 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 385 | 364 | }, |
| 386 | 365 | ); |
| 387 | 366 | |
| 367 | const adjust: PreviewManager["Service"]["adjust"] = Effect.fn("PreviewManager.adjust")( | |
| 368 | function* (input) { | |
| 369 | return yield* mutateExistingSession( | |
| 370 | input.threadId, | |
| 371 | input.tabId, | |
| 372 | Effect.fn("PreviewManager.adjustSession")(function* (session) { | |
| 373 | const updatedAt = yield* currentIsoTimestamp; | |
| 374 | const snapshot: PreviewSessionSnapshot = { | |
| 375 | ...session.snapshot, | |
| 376 | ...(input.colorScheme === undefined ? {} : { colorScheme: input.colorScheme }), | |
| 377 | ...(input.zoomFactor === undefined ? {} : { zoomFactor: input.zoomFactor }), | |
| 378 | updatedAt, | |
| 379 | }; | |
| 380 | // One-off requests ride on the event for the server's browser to act on. | |
| 381 | const request = | |
| 382 | input.hardReload || input.clear | |
| 383 | ? { | |
| 384 | request: { | |
| 385 | ...(input.hardReload ? { hardReload: true } : {}), | |
| 386 | ...(input.clear ? { clear: input.clear } : {}), | |
| 387 | }, | |
| 388 | } | |
| 389 | : {}; | |
| 390 | return { | |
| 391 | next: { ...session, snapshot }, | |
| 392 | emit: { | |
| 393 | ...request, | |
| 394 | type: "resized", | |
| 395 | threadId: session.threadId, | |
| 396 | tabId: session.tabId, | |
| 397 | createdAt: snapshot.updatedAt, | |
| 398 | snapshot, | |
| 399 | }, | |
| 400 | result: snapshot, | |
| 401 | }; | |
| 402 | }), | |
| 403 | ); | |
| 404 | }, | |
| 405 | ); | |
| 406 | ||
| 388 | 407 | const refresh: PreviewManager["Service"]["refresh"] = Effect.fn("PreviewManager.refresh")( |
| 389 | 408 | function* (input) { |
| 390 | 409 | // Verify the session exists; the desktop bridge handles the actual reload |
| ... | ... | @@ -446,11 +465,44 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 446 | 465 | }, |
| 447 | 466 | ); |
| 448 | 467 | |
| 468 | const requestReveal: PreviewManager["Service"]["requestReveal"] = Effect.fn( | |
| 469 | "PreviewManager.requestReveal", | |
| 470 | )(function* (input) { | |
| 471 | yield* mutateExistingSession( | |
| 472 | input.threadId, | |
| 473 | input.tabId, | |
| 474 | Effect.fn("PreviewManager.revealSession")(function* (session) { | |
| 475 | const snapshot = { | |
| 476 | ...session.snapshot, | |
| 477 | reveal: true, | |
| 478 | revealRequest: { | |
| 479 | id: yield* crypto.randomUUIDv4.pipe(Effect.orDie), | |
| 480 | force: input.force, | |
| 481 | }, | |
| 482 | updatedAt: yield* currentIsoTimestamp, | |
| 483 | }; | |
| 484 | return { | |
| 485 | next: { ...session, snapshot }, | |
| 486 | emit: { | |
| 487 | type: "navigated" as const, | |
| 488 | threadId: session.threadId, | |
| 489 | tabId: session.tabId, | |
| 490 | createdAt: snapshot.updatedAt, | |
| 491 | snapshot, | |
| 492 | }, | |
| 493 | result: undefined, | |
| 494 | }; | |
| 495 | }), | |
| 496 | ); | |
| 497 | }); | |
| 498 | ||
| 449 | 499 | return PreviewManager.of({ |
| 450 | 500 | open, |
| 501 | requestReveal, | |
| 451 | 502 | navigate, |
| 452 | 503 | reportStatus, |
| 453 | 504 | resize, |
| 505 | adjust, | |
| 454 | 506 | refresh, |
| 455 | 507 | close, |
| 456 | 508 | list, |
apps/server/src/preview/PreviewBrowser.test.ts created+292| ... | ... | @@ -0,0 +1,292 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { expect, it } from "@effect/vitest"; | |
| 3 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 4 | import * as Deferred from "effect/Deferred"; | |
| 5 | import type * as Duration from "effect/Duration"; | |
| 6 | import * as DateTime from "effect/DateTime"; | |
| 7 | import * as Effect from "effect/Effect"; | |
| 8 | import * as Fiber from "effect/Fiber"; | |
| 9 | import * as FileSystem from "effect/FileSystem"; | |
| 10 | import * as Option from "effect/Option"; | |
| 11 | import * as Path from "effect/Path"; | |
| 12 | import * as Stream from "effect/Stream"; | |
| 13 | import * as TestClock from "effect/testing/TestClock"; | |
| 14 | import { HttpClient, HttpClientResponse } from "effect/http"; | |
| 15 | import * as NodeCrypto from "node:crypto"; | |
| 16 | import * as NodeZlib from "node:zlib"; | |
| 17 | ||
| 18 | import * as PreviewBrowser from "./PreviewBrowser.ts"; | |
| 19 | ||
| 20 | // POSIX modes cannot be checked on NTFS, so follow the host like the Antigravity suite. | |
| 21 | const hostPlatform: NodeJS.Platform = | |
| 22 | HostProcessPlatform.defaultValue() === "win32" ? "win32" : "linux"; | |
| 23 | const executableName = | |
| 24 | hostPlatform === "win32" ? "chrome-headless-shell.exe" : "chrome-headless-shell"; | |
| 25 | const ROOT = "chrome-headless-shell-fixture/"; | |
| 26 | ||
| 27 | /** A stored ZIP whose entries carry Unix modes, like Chrome for Testing's archives. */ | |
| 28 | const 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 | ||
| 69 | const 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 | ||
| 77 | const 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 | ||
| 123 | it.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 | ||
| 253 | it("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 | ||
| 284 | it("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. | |
| 2 | import * as EffectNodeStream from "@effect/platform-node/NodeStream"; | |
| 3 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 4 | import * as Cause from "effect/Cause"; | |
| 5 | import * as Clock from "effect/Clock"; | |
| 6 | import * as Context from "effect/Context"; | |
| 7 | import * as Deferred from "effect/Deferred"; | |
| 8 | import type * as Duration from "effect/Duration"; | |
| 9 | import * as Effect from "effect/Effect"; | |
| 10 | import * as Exit from "effect/Exit"; | |
| 11 | import * as FileSystem from "effect/FileSystem"; | |
| 12 | import * as Layer from "effect/Layer"; | |
| 13 | import * as Option from "effect/Option"; | |
| 14 | import * as Path from "effect/Path"; | |
| 15 | import * as Schema from "effect/Schema"; | |
| 16 | import * as Semaphore from "effect/Semaphore"; | |
| 17 | import * as Stream from "effect/Stream"; | |
| 18 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; | |
| 19 | import * as NodeCrypto from "node:crypto"; | |
| 20 | ||
| 21 | import * as ServerConfig from "../config.ts"; | |
| 22 | import { 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. | |
| 31 | const VERSION = "154.0.8037.92"; | |
| 32 | const 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 | ||
| 59 | const 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 | ||
| 73 | export 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 | ||
| 82 | export 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 | ||
| 97 | const megabytes = (bytes: number) => Math.round(bytes / 1_000_000); | |
| 98 | ||
| 99 | export 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 | } | |
| 107 | const isInstallError = Schema.is(PreviewBrowserInstallError); | |
| 108 | ||
| 109 | export 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 | ||
| 121 | export 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 | ||
| 130 | export 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 | ||
| 147 | export 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 | ||
| 154 | const DOWNLOAD_TIMEOUT = "15 minutes"; | |
| 155 | // Longer than any install can run: the download times out at 15 minutes. | |
| 156 | const STAGING_STALE_AFTER_MS = 60 * 60_000; | |
| 157 | ||
| 158 | const wrapFailure = (detail: string) => (cause: unknown) => | |
| 159 | isInstallError(cause) ? cause : new PreviewBrowserInstallError({ detail, cause }); | |
| 160 | ||
| 161 | type 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 | ||
| 170 | export 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 | ||
| 457 | export 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 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | |
| 2 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Sink from "effect/Sink"; | |
| 5 | import * as Stream from "effect/Stream"; | |
| 6 | import { ChildProcessSpawner } from "effect/process"; | |
| 7 | ||
| 8 | import * as PreviewBrowserHost from "./PreviewBrowserHost.ts"; | |
| 9 | ||
| 10 | // What `ldd` printed for the pinned headless shell in a bare debian:trixie-slim container. | |
| 11 | const 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. | |
| 20 | const 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 | ||
| 23 | const 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 | ||
| 50 | const 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 | ||
| 63 | describe("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 | */ | |
| 10 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 11 | import * as Effect from "effect/Effect"; | |
| 12 | import * as FileSystem from "effect/FileSystem"; | |
| 13 | import * as Schema from "effect/Schema"; | |
| 14 | import * as ChildProcess from "effect/process/ChildProcess"; | |
| 15 | import * 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. */ | |
| 18 | export const SETUP_SUBCOMMAND = "browser setup"; | |
| 19 | ||
| 20 | /** What Chrome prints before aborting when it cannot sandbox itself. */ | |
| 21 | export const NO_SANDBOX_SIGNATURE = "No usable sandbox"; | |
| 22 | ||
| 23 | /** Whether an operator explicitly allowed Chrome to run without its sandbox. */ | |
| 24 | export 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. */ | |
| 28 | const USERNS_RESTRICTION = "/proc/sys/kernel/apparmor_restrict_unprivileged_userns"; | |
| 29 | export 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 | */ | |
| 36 | export const APPARMOR_PROFILE = `# Written by \`t3 browser setup\`: lets T3 Code's headless browser use Chrome's sandbox. | |
| 37 | abi <abi/4.0>, | |
| 38 | include <tunables/global> | |
| 39 | ||
| 40 | profile 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 | */ | |
| 52 | export 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 | ||
| 72 | export 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 | ||
| 81 | export 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 | ||
| 90 | export type PreviewBrowserHostError = PreviewBrowserSandboxError | PreviewBrowserLibrariesError; | |
| 91 | ||
| 92 | const 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 | */ | |
| 99 | export 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`. */ | |
| 118 | export 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 | */ | |
| 136 | export 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 @@ |
| 1 | import * as NodeEvents from "node:events"; | |
| 2 | ||
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 4 | import { it } from "@effect/vitest"; | |
| 5 | import { | |
| 6 | EnvironmentId, | |
| 7 | PreviewTabId, | |
| 8 | ProviderInstanceId, | |
| 9 | ThreadId, | |
| 10 | type PreviewAutomationSnapshot, | |
| 11 | type PreviewAutomationStatus, | |
| 12 | } from "@t3tools/contracts"; | |
| 13 | import * as Clock from "effect/Clock"; | |
| 14 | import * as Effect from "effect/Effect"; | |
| 15 | import * as Fiber from "effect/Fiber"; | |
| 16 | import * as FileSystem from "effect/FileSystem"; | |
| 17 | import * as Layer from "effect/Layer"; | |
| 18 | import * as Option from "effect/Option"; | |
| 19 | import * as Queue from "effect/Queue"; | |
| 20 | import * as Stream from "effect/Stream"; | |
| 21 | import type { BrowserContext, Page } from "playwright-core"; | |
| 22 | import { beforeEach, expect, vi } from "vite-plus/test"; | |
| 23 | ||
| 24 | import * as ServerConfig from "../config.ts"; | |
| 25 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; | |
| 26 | import * as Broker from "../mcp/PreviewAutomationBroker.ts"; | |
| 27 | import * as DesktopChannel from "./DesktopBrowserChannel.ts"; | |
| 28 | import * as Manager from "./Manager.ts"; | |
| 29 | import * as ServerBrowser from "./ServerBrowser.ts"; | |
| 30 | import * as PreviewBrowser from "./PreviewBrowser.ts"; | |
| 31 | ||
| 32 | // Keep the manager, broker, ownership, refs, and viewer paths real; replace Chromium I/O only. | |
| 33 | vi.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 | ||
| 60 | function 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 | ||
| 73 | function 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 | ||
| 149 | const contexts: ReturnType<typeof makeContext>[] = []; | |
| 150 | let contextGate: PromiseWithResolvers<void> | null = null; | |
| 151 | type ClipboardBinding = (source: { page: unknown }, text: unknown) => void; | |
| 152 | let clipboardBinding: ClipboardBinding | null = null; | |
| 153 | let contextFailure: Error | null = null; | |
| 154 | /** Server tabs the fake desktop renders, and the endpoints the server connected to. */ | |
| 155 | let desktopRendersNext = false; | |
| 156 | /** Pages the fake desktop takes back; the channel's detached stream emits them. */ | |
| 157 | const desktopDetaches = new NodeEvents.EventEmitter(); | |
| 158 | const desktopTabs = new Set<string>(); | |
| 159 | const desktopRenders = (tabId: string) => { | |
| 160 | if (desktopRendersNext) { | |
| 161 | desktopRendersNext = false; | |
| 162 | desktopTabs.add(tabId); | |
| 163 | } | |
| 164 | return desktopTabs.has(tabId); | |
| 165 | }; | |
| 166 | const releasedDesktopTabs: Array<string> = []; | |
| 167 | const desktopConnections: Array<{ endpoint: string; context: ReturnType<typeof makeContext> }> = []; | |
| 168 | const testThread = { | |
| 169 | threadId: ThreadId.make("browser-test-thread"), | |
| 170 | providerSessionId: "agent-a", | |
| 171 | providerInstanceId: ProviderInstanceId.make("codex"), | |
| 172 | }; | |
| 173 | const 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. */ | |
| 182 | const asSession = (providerSessionId: string) => ({ | |
| 183 | ...scope, | |
| 184 | thread: { ...testThread, providerSessionId }, | |
| 185 | }); | |
| 186 | const 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 | ); | |
| 223 | const layer = ServerBrowser.layer.pipe(Layer.provideMerge(dependencies)); | |
| 224 | const 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. */ | |
| 237 | const 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 | }; | |
| 245 | const 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 | ||
| 254 | beforeEach(() => { | |
| 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 | ||
| 264 | it.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 | ||
| 307 | it.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 | ||
| 362 | it.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 | ||
| 420 | it.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 | ||
| 459 | it.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 | ||
| 493 | it.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 | ||
| 554 | it.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 | ||
| 633 | it.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 | ||
| 690 | it.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 | ||
| 729 | it.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 | ||
| 755 | it.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 | ||
| 783 | it.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 | ||
| 838 | it.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 | ||
| 874 | it.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 | ||
| 900 | it.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 | ||
| 925 | it.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 | ||
| 961 | it.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 | ||
| 1004 | it.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 | ||
| 1029 | it.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 | ||
| 1067 | it.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. | |
| 4 | import { | |
| 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"; | |
| 39 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; | |
| 40 | import { normalizePreviewUrl } from "@t3tools/shared/preview"; | |
| 41 | import { resolvePreviewViewport } from "@t3tools/shared/previewViewport"; | |
| 42 | import * as NodeCrypto from "node:crypto"; | |
| 43 | import * as NodeFSP from "node:fs/promises"; | |
| 44 | import * as NodePath from "node:path"; | |
| 45 | import * as Context from "effect/Context"; | |
| 46 | import * as Effect from "effect/Effect"; | |
| 47 | import { constVoid } from "effect/Function"; | |
| 48 | import * as Layer from "effect/Layer"; | |
| 49 | import * as Option from "effect/Option"; | |
| 50 | import * as Queue from "effect/Queue"; | |
| 51 | import * as Schedule from "effect/Schedule"; | |
| 52 | import * as Schema from "effect/Schema"; | |
| 53 | import * as Exit from "effect/Exit"; | |
| 54 | import * as Scope from "effect/Scope"; | |
| 55 | import * as Stream from "effect/Stream"; | |
| 56 | import type * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; | |
| 57 | import type { | |
| 58 | BrowserContext, | |
| 59 | CDPSession, | |
| 60 | Dialog, | |
| 61 | Download, | |
| 62 | FileChooser, | |
| 63 | Page, | |
| 64 | } from "playwright-core"; | |
| 65 | ||
| 66 | import { PENDING_ATTACHMENT_THREAD_SEGMENT } from "../attachmentStore.ts"; | |
| 67 | import * as ServerConfig from "../config.ts"; | |
| 68 | import { resolveRootCliCommand } from "../cli/invocation.ts"; | |
| 69 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; | |
| 70 | import * as PreviewAutomationBroker from "../mcp/PreviewAutomationBroker.ts"; | |
| 71 | import * as DesktopBrowserChannel from "./DesktopBrowserChannel.ts"; | |
| 72 | import * as PreviewManager from "./Manager.ts"; | |
| 73 | import * as ServerBrowserPage from "./ServerBrowserPage.ts"; | |
| 74 | import * as PreviewBrowser from "./PreviewBrowser.ts"; | |
| 75 | import * as PreviewBrowserHost from "./PreviewBrowserHost.ts"; | |
| 76 | import { ServerBrowserContexts } from "./ServerBrowserContexts.ts"; | |
| 77 | import { BrowserControlInterrupted, SessionControl } from "./SessionControl.ts"; | |
| 78 | ||
| 79 | const SERVER_HOST_CLIENT_ID = SERVER_BROWSER_AUTOMATION_CLIENT_ID; | |
| 80 | const RENDER_SCALE = 2; | |
| 81 | // Chromium allows three unacked frames, so 100 ms pacing caps viewers near 30 fps. | |
| 82 | const SCREENCAST_ACK_PACE_MS = 100; | |
| 83 | const SCREENCAST_SETTLE_MS = 200; | |
| 84 | // Only scrolling triggers reduced-quality motion frames; animations stay sharp. | |
| 85 | const SCREENCAST_MOTION_FRAMES = 4; | |
| 86 | const SCREENCAST_MOTION_WINDOW_MS = 300; | |
| 87 | const SCREENCAST_MOTION_QUALITY = 50; | |
| 88 | const HOST_RECONNECT_DELAY = "1 second"; | |
| 89 | /** How long a new tab waits for the desktop app to mount it before running headless. */ | |
| 90 | const DESKTOP_ATTACH_TIMEOUT = "10 seconds"; | |
| 91 | const VIEWER_OUTPUT_LIMIT = 64; | |
| 92 | const RECORDING_SCREENCAST = { format: "jpeg", quality: 90, everyNthFrame: 1 } as const; | |
| 93 | const decodeViewportSetting = Schema.decodeUnknownSync(PreviewViewportSettingSchema); | |
| 94 | /** The agent cursor glides to its target, then pulses just before the press, like desktop tabs. */ | |
| 95 | const AGENT_CURSOR_MOVE_MS = 160; | |
| 96 | const AGENT_CURSOR_CLICK_LEAD_MS = 40; | |
| 97 | ||
| 98 | const 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. | |
| 102 | const 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 | }`; | |
| 118 | const UNATTACHED_FILL_VIEWPORT = { width: 1280, height: 800 } as const; | |
| 119 | const NAVIGATION_TIMEOUT_MS = 15_000; | |
| 120 | const VIEWER_NAVIGATION_OPTIONS = { waitUntil: "commit", timeout: NAVIGATION_TIMEOUT_MS } as const; | |
| 121 | const ACTION_TIMELINE_LIMIT = 50; | |
| 122 | ||
| 123 | export 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 | ||
| 132 | const isTabNotFound = Schema.is(ServerBrowserTabNotFoundError); | |
| 133 | const isHostSetupError = Schema.is( | |
| 134 | Schema.Union([ | |
| 135 | PreviewBrowserHost.PreviewBrowserSandboxError, | |
| 136 | PreviewBrowserHost.PreviewBrowserLibrariesError, | |
| 137 | ]), | |
| 138 | ); | |
| 139 | ||
| 140 | export 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 | ||
| 149 | export 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 | ||
| 198 | export interface ServerBrowserViewer { | |
| 199 | readonly output: Queue.Dequeue<ServerBrowserViewerOutput>; | |
| 200 | readonly input: (message: unknown) => Effect.Effect<void>; | |
| 201 | } | |
| 202 | ||
| 203 | export 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 | ||
| 243 | interface 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 | ||
| 258 | interface 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 | ||
| 267 | interface 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 | ||
| 275 | interface 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 | ||
| 322 | interface 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. */ | |
| 332 | const AGENT_TAB_LIMIT = 8; | |
| 333 | const SERVER_TAB_LIMIT = 32; | |
| 334 | /** Agent tabs nobody watches close after this long without an agent request. */ | |
| 335 | const AGENT_TAB_IDLE_MS = 30 * 60 * 1000; | |
| 336 | const IDLE_SWEEP_INTERVAL = "1 minute"; | |
| 337 | ||
| 338 | const DOWNLOAD_LIMIT = 20; | |
| 339 | const DOWNLOAD_MAX_BYTES = 1024 * 1024 * 1024; | |
| 340 | ||
| 341 | const tabKey = (threadId: string, tabId: string) => `${threadId}\u0000${tabId}`; | |
| 342 | ||
| 343 | const 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 | ||
| 352 | const 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 | ||
| 357 | const fixedViewportSize = (setting: PreviewViewportSetting) => | |
| 358 | setting._tag === "fill" ? null : { width: setting.width, height: setting.height }; | |
| 359 | ||
| 360 | const asRecord = (value: unknown): Record<string, unknown> | null => | |
| 361 | typeof value === "object" && value !== null ? (value as Record<string, unknown>) : null; | |
| 362 | ||
| 363 | const num = (value: unknown, fallback = 0) => | |
| 364 | typeof value === "number" && Number.isFinite(value) ? value : fallback; | |
| 365 | ||
| 366 | const 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. | |
| 374 | const 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 | ||
| 395 | const CLIPBOARD_TEXT_LIMIT = 1024 * 1024; | |
| 396 | /** Copies reach the viewer only this soon after it last touched the page. */ | |
| 397 | const CLIPBOARD_GESTURE_MS = 5_000; | |
| 398 | const 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. | |
| 401 | const 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 | ||
| 436 | const 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 | ||
| 2202 | export const layer = Layer.effect(ServerBrowser, make); |
apps/server/src/preview/ServerBrowserContexts.test.ts created+245| ... | ... | @@ -0,0 +1,245 @@ |
| 1 | import * as NodeEvents from "node:events"; | |
| 2 | ||
| 3 | import { INCOGNITO_BROWSER_PROFILE_ID } from "@t3tools/contracts"; | |
| 4 | import type { Browser, BrowserContext, BrowserType } from "playwright-core"; | |
| 5 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 6 | ||
| 7 | import { ServerBrowserContexts } from "./ServerBrowserContexts.ts"; | |
| 8 | ||
| 9 | const 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 | })); | |
| 15 | vi.mock("playwright-core", () => ({ | |
| 16 | chromium: { launch: launches.launch, launchPersistentContext: launches.persistent }, | |
| 17 | })); | |
| 18 | vi.mock("node:fs/promises", () => ({ mkdir: launches.mkdir, rm: launches.rm })); | |
| 19 | ||
| 20 | const 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 | ||
| 35 | const 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 | ||
| 53 | const options = () => ({ | |
| 54 | profilesDir: "/test/profiles", | |
| 55 | executable: vi.fn(async () => "/test/chromium"), | |
| 56 | env: {}, | |
| 57 | }); | |
| 58 | ||
| 59 | beforeEach(() => { | |
| 60 | launches.launch.mockReset(); | |
| 61 | launches.persistent.mockReset(); | |
| 62 | launches.mkdir.mockClear(); | |
| 63 | launches.rm.mockClear(); | |
| 64 | }); | |
| 65 | ||
| 66 | describe("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. | |
| 2 | import { INCOGNITO_BROWSER_PROFILE_ID } from "@t3tools/contracts"; | |
| 3 | import { constVoid } from "effect/Function"; | |
| 4 | import * as NodeFSP from "node:fs/promises"; | |
| 5 | import * as NodePath from "node:path"; | |
| 6 | import type { Browser, BrowserContext } from "playwright-core"; | |
| 7 | ||
| 8 | import { sandboxDisabled } from "./PreviewBrowserHost.ts"; | |
| 9 | ||
| 10 | interface 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. */ | |
| 21 | export 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 @@ |
| 1 | import { | |
| 2 | chromium, | |
| 3 | type Browser, | |
| 4 | type BrowserContext, | |
| 5 | type CDPSession, | |
| 6 | type Page, | |
| 7 | } from "playwright-core"; | |
| 8 | import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vite-plus/test"; | |
| 9 | ||
| 10 | import * as ServerBrowserPage from "./ServerBrowserPage.ts"; | |
| 11 | ||
| 12 | describe("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 | ||
| 3 | import { | |
| 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"; | |
| 20 | import { constVoid } from "effect/Function"; | |
| 21 | import type { CDPSession, Locator, Page } from "playwright-core"; | |
| 22 | import * as NodeCrypto from "node:crypto"; | |
| 23 | import { BrowserControlInterrupted } from "./SessionControl.ts"; | |
| 24 | ||
| 25 | const MAX_EVALUATION_BYTES = 64_000; | |
| 26 | const MAX_VISIBLE_TEXT_LENGTH = 20_000; | |
| 27 | const MAX_SCREENSHOT_WIDTH = 1280; | |
| 28 | const WAIT_POLL_MS = 100; | |
| 29 | export const DIAGNOSTIC_BUFFER_LIMIT = 200; | |
| 30 | ||
| 31 | export 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 | ||
| 42 | export 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 | ||
| 66 | const DEFAULT_TIMEOUT_MS = 15_000; | |
| 67 | ||
| 68 | const pageRefs = new WeakMap<Page, { generation: string; refs: Map<string, string> }>(); | |
| 69 | // A compact runtime namespace prevents old refs from aliasing after a server restart. | |
| 70 | const refNamespace = NodeCrypto.randomUUID().slice(0, 8); | |
| 71 | let snapshotSequence = 0; | |
| 72 | const nextRefGeneration = () => `${refNamespace}-${(++snapshotSequence).toString(36)}`; | |
| 73 | ||
| 74 | /** A takeover revokes previously issued refs even when the document stays unchanged. */ | |
| 75 | export const invalidateRefs = (page: Page) => { | |
| 76 | const state = pageRefs.get(page); | |
| 77 | if (state) { | |
| 78 | state.generation = nextRefGeneration(); | |
| 79 | state.refs.clear(); | |
| 80 | } | |
| 81 | }; | |
| 82 | ||
| 83 | const 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 | ||
| 94 | const 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. */ | |
| 122 | export type PointerReporter = ( | |
| 123 | point: { readonly x: number; readonly y: number }, | |
| 124 | phase: "move" | "click", | |
| 125 | ) => Promise<void>; | |
| 126 | const noPointer: PointerReporter = async () => {}; | |
| 127 | ||
| 128 | /** The viewport point an action will hit: the target's center after scrolling it into view. */ | |
| 129 | const 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 | ||
| 143 | const 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. | |
| 154 | export 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 | ||
| 178 | export 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 | */ | |
| 233 | export 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 | ||
| 260 | export 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. */ | |
| 323 | export 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 | ||
| 338 | export 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 | ||
| 374 | export 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. */ | |
| 391 | export 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 | ||
| 398 | export const press = async (page: Page, input: PreviewAutomationPressInput) => { | |
| 399 | await page.keyboard.press([...(input.modifiers ?? []), input.key].join("+")); | |
| 400 | }; | |
| 401 | ||
| 402 | export 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 | ||
| 413 | export 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 | ||
| 438 | export 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 | ||
| 469 | const 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 | ||
| 482 | export 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. | |
| 489 | export 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 @@ |
| 1 | import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; | |
| 2 | import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; | |
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 4 | import { expect, it } from "@effect/vitest"; | |
| 5 | import { | |
| 6 | AuthOrchestrationOperateScope, | |
| 7 | AuthOrchestrationReadScope, | |
| 8 | AuthSessionId, | |
| 9 | PREVIEW_STREAM_HOST_SETUP_CLOSE_CODE, | |
| 10 | PreviewStreamHostSetup, | |
| 11 | type AuthEnvironmentScope, | |
| 12 | } from "@t3tools/contracts"; | |
| 13 | import * as Context from "effect/Context"; | |
| 14 | import * as Effect from "effect/Effect"; | |
| 15 | import * as FileSystem from "effect/FileSystem"; | |
| 16 | import * as Layer from "effect/Layer"; | |
| 17 | import * as Option from "effect/Option"; | |
| 18 | import * as Queue from "effect/Queue"; | |
| 19 | import * as Schema from "effect/Schema"; | |
| 20 | import { HttpRouter, HttpServer } from "effect/http"; | |
| 21 | ||
| 22 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; | |
| 23 | import * as PreviewBrowserHost from "./PreviewBrowserHost.ts"; | |
| 24 | import * as ServerBrowser from "./ServerBrowser.ts"; | |
| 25 | import { routeLayer } from "./ServerBrowserStream.ts"; | |
| 26 | ||
| 27 | const platformLayer = NodeHttpPlatform.layer.pipe(Layer.provideMerge(NodeServices.layer)); | |
| 28 | ||
| 29 | const 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 | ||
| 50 | const 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 | ||
| 66 | it.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 | ||
| 156 | it.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 | ||
| 198 | it.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 | ||
| 248 | it.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 | ||
| 300 | const decodeHostSetup = Schema.decodeUnknownEffect(Schema.fromJsonString(PreviewStreamHostSetup)); | |
| 301 | ||
| 302 | it.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 @@ |
| 1 | import * as NodeHttpServerRequest from "@effect/platform-node/NodeHttpServerRequest"; | |
| 2 | import { | |
| 3 | AuthOrchestrationOperateScope, | |
| 4 | AuthOrchestrationReadScope, | |
| 5 | PREVIEW_STREAM_HOST_SETUP_CLOSE_CODE, | |
| 6 | PreviewStreamHostSetup, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | import * as Effect from "effect/Effect"; | |
| 9 | import * as Option from "effect/Option"; | |
| 10 | import * as Queue from "effect/Queue"; | |
| 11 | import * as Schema from "effect/Schema"; | |
| 12 | import * as FileSystem from "effect/FileSystem"; | |
| 13 | import * as Path from "effect/Path"; | |
| 14 | import { | |
| 15 | HttpPlatform, | |
| 16 | HttpRouter, | |
| 17 | HttpServerRequest, | |
| 18 | HttpServerResponse, | |
| 19 | Multipart, | |
| 20 | } from "effect/http"; | |
| 21 | import * as Socket from "effect/socket/Socket"; | |
| 22 | ||
| 23 | import { authenticateMediaRequest } from "../auth/http.ts"; | |
| 24 | import { assetResponseHeaders } from "../http.ts"; | |
| 25 | import * as PreviewBrowserHost from "./PreviewBrowserHost.ts"; | |
| 26 | import * as ServerBrowser from "./ServerBrowser.ts"; | |
| 27 | ||
| 28 | const PREVIEW_STREAM_ROUTE_PREFIX = "/api/preview-stream"; | |
| 29 | /** Matches `PREVIEW_STREAM_TAB_GONE_CODE` in the client. */ | |
| 30 | const TAB_GONE_CODE = 4404; | |
| 31 | ||
| 32 | const DEFAULT_QUALITY = 70; | |
| 33 | const MAX_SOCKET_BUFFER_BYTES = 8 * 1024 * 1024; | |
| 34 | const MAX_UNACKNOWLEDGED_FRAMES = 64; | |
| 35 | const textDecoder = new TextDecoder(); | |
| 36 | ||
| 37 | const 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 | ||
| 42 | const isAck = (message: unknown) => | |
| 43 | typeof message === "object" && message !== null && "type" in message && message.type === "ack"; | |
| 44 | ||
| 45 | const 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 | ||
| 53 | const 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. */ | |
| 203 | const 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; | |
| 209 | const isSandboxError = Schema.is(PreviewBrowserHost.PreviewBrowserSandboxError); | |
| 210 | const isLibrariesError = Schema.is(PreviewBrowserHost.PreviewBrowserLibrariesError); | |
| 211 | const encodeHostSetup = Schema.encodeSync(Schema.fromJsonString(PreviewStreamHostSetup)); | |
| 212 | ||
| 213 | /** `GET /api/preview-stream/download?threadId&tabId&id`: a file a server tab downloaded. */ | |
| 214 | const 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 | ||
| 231 | const UPLOAD_MAX_FILE_BYTES = 100 * 1024 * 1024; | |
| 232 | const 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 | */ | |
| 238 | const 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. | |
| 277 | export 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 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { BrowserControlInterrupted, SessionControl } from "./SessionControl.ts"; | |
| 4 | ||
| 5 | describe("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 @@ |
| 1 | import type { PreviewAutomationControlReason } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | export 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. */ | |
| 15 | export class SessionControl { | |
| 16 | readonly agentId: string | null; | |
| 17 | private readonly onGenerationChange: () => void; | |
| 18 | private tail: Promise<unknown> = Promise.resolve(); | |
| 19 | private pending: Promise<void> = Promise.resolve(); | |
| 20 | private owner: string | null = null; | |
| 21 | private epoch = 0; | |
| 22 | private closed = false; | |
| 23 | ||
| 24 | constructor(agentId: string | null, onGenerationChange: () => void = () => {}) { | |
| 25 | this.agentId = agentId; | |
| 26 | this.onGenerationChange = onGenerationChange; | |
| 27 | } | |
| 28 | ||
| 29 | get controller() { | |
| 30 | return this.owner; | |
| 31 | } | |
| 32 | ||
| 33 | get generation() { | |
| 34 | return this.epoch; | |
| 35 | } | |
| 36 | ||
| 37 | private enqueue<A>(run: () => Promise<A>): Promise<A> { | |
| 38 | const result = this.tail.then(run); | |
| 39 | this.tail = result.then( | |
| 40 | () => this.pending, | |
| 41 | () => this.pending, | |
| 42 | ); | |
| 43 | return result; | |
| 44 | } | |
| 45 | ||
| 46 | /** An action can respond before its navigation commits, while later actions still wait. */ | |
| 47 | track(work: Promise<unknown>) { | |
| 48 | const settled = work.then( | |
| 49 | () => undefined, | |
| 50 | () => undefined, | |
| 51 | ); | |
| 52 | this.pending = Promise.all([this.pending, settled]).then(() => undefined); | |
| 53 | } | |
| 54 | ||
| 55 | private assertOpen() { | |
| 56 | if (this.closed) throw new BrowserControlInterrupted("This browser tab is closed.", "closed"); | |
| 57 | } | |
| 58 | ||
| 59 | private changeOwner(owner: string | null) { | |
| 60 | this.owner = owner; | |
| 61 | this.epoch++; | |
| 62 | this.onGenerationChange(); | |
| 63 | } | |
| 64 | ||
| 65 | private async action<A>(allowed: () => boolean, run: () => Promise<A>) { | |
| 66 | this.assertOpen(); | |
| 67 | if (!allowed()) throw new BrowserControlInterrupted("You do not control this browser tab."); | |
| 68 | const epoch = this.epoch; | |
| 69 | return this.enqueue(async () => { | |
| 70 | this.assertOpen(); | |
| 71 | if (epoch !== this.epoch || !allowed()) throw new BrowserControlInterrupted(); | |
| 72 | return run(); | |
| 73 | }); | |
| 74 | } | |
| 75 | ||
| 76 | agent<A>(agentId: string, run: () => Promise<A>) { | |
| 77 | if (this.agentId !== agentId) | |
| 78 | return Promise.reject( | |
| 79 | new BrowserControlInterrupted("This tab belongs to another agent.", "agentMismatch"), | |
| 80 | ); | |
| 81 | if (this.owner !== null) | |
| 82 | return Promise.reject( | |
| 83 | new BrowserControlInterrupted("A human controls this tab.", "humanControl"), | |
| 84 | ); | |
| 85 | return this.action(() => this.agentId === agentId && this.owner === null, run); | |
| 86 | } | |
| 87 | ||
| 88 | /** | |
| 89 | * Work any client may ask for, such as a viewport or appearance change. It | |
| 90 | * waits its turn behind running actions but needs no control. | |
| 91 | */ | |
| 92 | system<A>(run: () => Promise<A>) { | |
| 93 | this.assertOpen(); | |
| 94 | return this.enqueue(async () => { | |
| 95 | this.assertOpen(); | |
| 96 | return run(); | |
| 97 | }); | |
| 98 | } | |
| 99 | ||
| 100 | human<A>(viewerId: string, run: () => Promise<A>) { | |
| 101 | return this.action(() => this.owner === viewerId, run); | |
| 102 | } | |
| 103 | ||
| 104 | async take(viewerId: string) { | |
| 105 | this.assertOpen(); | |
| 106 | if (this.owner !== null && this.owner !== viewerId) { | |
| 107 | throw new BrowserControlInterrupted("Another viewer controls this browser tab."); | |
| 108 | } | |
| 109 | if (this.owner !== viewerId) this.changeOwner(viewerId); | |
| 110 | return this.human(viewerId, async () => {}); | |
| 111 | } | |
| 112 | ||
| 113 | async release(viewerId: string, afterDrain: () => Promise<void> = async () => {}) { | |
| 114 | this.assertOpen(); | |
| 115 | if (this.owner !== viewerId) { | |
| 116 | throw new BrowserControlInterrupted( | |
| 117 | "Only the controlling viewer can release this browser tab.", | |
| 118 | ); | |
| 119 | } | |
| 120 | this.changeOwner(null); | |
| 121 | await this.enqueue(afterDrain); | |
| 122 | } | |
| 123 | ||
| 124 | async disconnect(viewerId: string, afterDrain?: () => Promise<void>) { | |
| 125 | if (!this.closed && this.owner === viewerId) await this.release(viewerId, afterDrain); | |
| 126 | } | |
| 127 | ||
| 128 | async close() { | |
| 129 | if (!this.closed) { | |
| 130 | this.closed = true; | |
| 131 | this.changeOwner(null); | |
| 132 | } | |
| 133 | await this.tail; | |
| 134 | } | |
| 135 | } |
apps/server/src/server.ts+9| ... | ... | @@ -63,6 +63,10 @@ import * as DeviceService from "./device/DeviceService.ts"; |
| 63 | 63 | import * as DeviceHubProxy from "./device/DeviceHubProxy.ts"; |
| 64 | 64 | import * as PreviewManager from "./preview/Manager.ts"; |
| 65 | 65 | import * as PortScanner from "./preview/PortScanner.ts"; |
| 66 | import * as ServerBrowser from "./preview/ServerBrowser.ts"; | |
| 67 | import * as DesktopBrowserChannel from "./preview/DesktopBrowserChannel.ts"; | |
| 68 | import * as ServerBrowserStream from "./preview/ServerBrowserStream.ts"; | |
| 69 | import * as PreviewBrowser from "./preview/PreviewBrowser.ts"; | |
| 66 | 70 | import * as ProcessRunner from "./processRunner.ts"; |
| 67 | 71 | import * as GitManager from "./git/GitManager.ts"; |
| 68 | 72 | import * as EnvironmentTheme from "./environmentTheme.ts"; |
| ... | ... | @@ -661,6 +665,7 @@ const layerMakeRoutes = Layer.mergeAll( |
| 661 | 665 | ServerHttp.layerAssetRoute, |
| 662 | 666 | ServerHttp.layerAttachmentUploadRoute, |
| 663 | 667 | DeviceHubProxy.layer, |
| 668 | ServerBrowserStream.routeLayer, | |
| 664 | 669 | ServerHttp.layerStaticAndDevRoute, |
| 665 | 670 | Ws.layer, |
| 666 | 671 | ), |
| ... | ... | @@ -676,6 +681,10 @@ const layerMakeRoutes = Layer.mergeAll( |
| 676 | 681 | // Both transports consume the same service instance, so caches single-flight across clients |
| 677 | 682 | // and mutations observed on WebSocket invalidate patches subsequently read over HTTP. |
| 678 | 683 | Layer.provide(layerPullRequestService), |
| 684 | // The stream route and the WebSocket RPCs share one browser. | |
| 685 | Layer.provide(ServerBrowser.layer.pipe(Layer.provide(DesktopBrowserChannel.layer))), | |
| 686 | // Server browser tabs and HTML render previews install and run the same headless browser. | |
| 687 | Layer.provide(PreviewBrowser.layer), | |
| 679 | 688 | Layer.provide(PreviewAutomationBroker.layer), |
| 680 | 689 | Layer.provide(ServerSelfUpdate.layer.pipe(Layer.provide(layerDesktopAppUpdate))), |
| 681 | 690 | Layer.provide(layerCommandReadiness), |
apps/server/src/ws.ts+20-19| ... | ... | @@ -180,6 +180,7 @@ import * as ServerSettings from "./serverSettings.ts"; |
| 180 | 180 | import * as TerminalManager from "./terminal/Manager.ts"; |
| 181 | 181 | import { withTerminalOutputWindow } from "./terminal/OutputProtocol.ts"; |
| 182 | 182 | import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; |
| 183 | import * as ServerBrowser from "./preview/ServerBrowser.ts"; | |
| 183 | 184 | import * as DeviceService from "./device/DeviceService.ts"; |
| 184 | 185 | import { remoteSshDeviceHosts } from "./device/localSshDeviceHost.ts"; |
| 185 | 186 | import * as PreviewManager from "./preview/Manager.ts"; |
| ... | ... | @@ -1182,6 +1183,7 @@ const layerWsRpc = ( |
| 1182 | 1183 | clientOrigin: OrchestrationClientOrigin, |
| 1183 | 1184 | clientAnalyticsProps: Readonly<Record<string, unknown>>, |
| 1184 | 1185 | previewAutomationBroker: PreviewAutomationBroker.PreviewAutomationBroker["Service"], |
| 1186 | serverBrowser: ServerBrowser.ServerBrowser["Service"], | |
| 1185 | 1187 | ) => |
| 1186 | 1188 | ServerWsRpcGroup.toLayer( |
| 1187 | 1189 | Effect.gen(function* () { |
| ... | ... | @@ -3489,6 +3491,10 @@ const layerWsRpc = ( |
| 3489 | 3491 | observeRpcEffect(WS_METHODS.previewResize, previewManager.resize(input), { |
| 3490 | 3492 | "rpc.aggregate": "preview", |
| 3491 | 3493 | }), |
| 3494 | [WS_METHODS.previewAdjust]: (input) => | |
| 3495 | observeRpcEffect(WS_METHODS.previewAdjust, previewManager.adjust(input), { | |
| 3496 | "rpc.aggregate": "preview", | |
| 3497 | }), | |
| 3492 | 3498 | [WS_METHODS.previewRefresh]: (input) => |
| 3493 | 3499 | observeRpcEffect(WS_METHODS.previewRefresh, previewManager.refresh(input), { |
| 3494 | 3500 | "rpc.aggregate": "preview", |
| ... | ... | @@ -3501,28 +3507,16 @@ const layerWsRpc = ( |
| 3501 | 3507 | observeRpcEffect(WS_METHODS.previewList, previewManager.list(input), { |
| 3502 | 3508 | "rpc.aggregate": "preview", |
| 3503 | 3509 | }), |
| 3510 | [WS_METHODS.previewClearProfile]: (input) => | |
| 3511 | observeRpcEffect( | |
| 3512 | WS_METHODS.previewClearProfile, | |
| 3513 | serverBrowser.clearProfile(input.profileId), | |
| 3514 | { "rpc.aggregate": "preview" }, | |
| 3515 | ), | |
| 3504 | 3516 | [WS_METHODS.previewReportStatus]: (input) => |
| 3505 | 3517 | observeRpcEffect(WS_METHODS.previewReportStatus, previewManager.reportStatus(input), { |
| 3506 | 3518 | "rpc.aggregate": "preview", |
| 3507 | 3519 | }), |
| 3508 | [WS_METHODS.previewAutomationConnect]: (input) => | |
| 3509 | observeRpcStreamEffect( | |
| 3510 | WS_METHODS.previewAutomationConnect, | |
| 3511 | previewAutomationBroker.connect(input), | |
| 3512 | { "rpc.aggregate": "preview-automation" }, | |
| 3513 | ), | |
| 3514 | [WS_METHODS.previewAutomationRespond]: (input) => | |
| 3515 | observeRpcEffect( | |
| 3516 | WS_METHODS.previewAutomationRespond, | |
| 3517 | previewAutomationBroker.respond(input), | |
| 3518 | { "rpc.aggregate": "preview-automation" }, | |
| 3519 | ), | |
| 3520 | [WS_METHODS.previewAutomationFocusHost]: (input) => | |
| 3521 | observeRpcEffect( | |
| 3522 | WS_METHODS.previewAutomationFocusHost, | |
| 3523 | previewAutomationBroker.focusHost(input), | |
| 3524 | { "rpc.aggregate": "preview-automation" }, | |
| 3525 | ), | |
| 3526 | 3520 | [WS_METHODS.subscribePreviewEvents]: (_input) => |
| 3527 | 3521 | observeRpcStream(WS_METHODS.subscribePreviewEvents, previewManager.events, { |
| 3528 | 3522 | "rpc.aggregate": "preview", |
| ... | ... | @@ -3796,6 +3790,7 @@ const layerWsRpc = ( |
| 3796 | 3790 | export const layer = Layer.unwrap( |
| 3797 | 3791 | Effect.gen(function* () { |
| 3798 | 3792 | const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker; |
| 3793 | const serverBrowser = yield* ServerBrowser.ServerBrowser; | |
| 3799 | 3794 | const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; |
| 3800 | 3795 | const pullRequests = yield* PullRequestService.PullRequestService; |
| 3801 | 3796 | const sql = yield* SqlClient.SqlClient; |
| ... | ... | @@ -3844,7 +3839,13 @@ export const layer = Layer.unwrap( |
| 3844 | 3839 | return httpEffect; |
| 3845 | 3840 | }).pipe( |
| 3846 | 3841 | Effect.provide( |
| 3847 | layerWsRpc(session, clientOrigin, clientAnalyticsProps, previewAutomationBroker).pipe( | |
| 3842 | layerWsRpc( | |
| 3843 | session, | |
| 3844 | clientOrigin, | |
| 3845 | clientAnalyticsProps, | |
| 3846 | previewAutomationBroker, | |
| 3847 | serverBrowser, | |
| 3848 | ).pipe( | |
| 3848 | 3849 | Layer.provideMerge(RpcSerialization.layerJson), |
| 3849 | 3850 | Layer.provide(Layer.succeed(SqlClient.SqlClient, sql)), |
| 3850 | 3851 | Layer.provide(AgentSessionScanner.layer), |
apps/web/src/AppRoot.tsx-2| ... | ... | @@ -1,7 +1,6 @@ |
| 1 | 1 | import { RouterProvider } from "@tanstack/react-router"; |
| 2 | 2 | |
| 3 | 3 | import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; |
| 4 | import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts"; | |
| 5 | 4 | import { QuitHoldOverlay } from "./components/QuitHoldOverlay"; |
| 6 | 5 | import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; |
| 7 | 6 | import type { AppRouter } from "./router"; |
| ... | ... | @@ -15,7 +14,6 @@ export function AppRoot({ router }: { readonly router: AppRouter }) { |
| 15 | 14 | return ( |
| 16 | 15 | <AppAtomRegistryProvider> |
| 17 | 16 | <RouterProvider router={router} /> |
| 18 | <PreviewAutomationHosts /> | |
| 19 | 17 | <ElectronBrowserHost /> |
| 20 | 18 | <QuitHoldOverlay /> |
| 21 | 19 | </AppAtomRegistryProvider> |
apps/web/src/browser/ElectronBrowserHost.tsx+33-12| ... | ... | @@ -2,8 +2,11 @@ |
| 2 | 2 | |
| 3 | 3 | import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; |
| 4 | 4 | import { FILL_PREVIEW_VIEWPORT } from "@t3tools/contracts"; |
| 5 | import { useAtomValue } from "@effect/atom-react"; | |
| 5 | 6 | import { useEffect, useMemo } from "react"; |
| 6 | 7 | |
| 8 | import { primaryEnvironmentIdAtom } from "~/state/primaryEnvironment"; | |
| 9 | ||
| 7 | 10 | import { isElectron } from "~/env"; |
| 8 | 11 | import { useTheme } from "~/hooks/useTheme"; |
| 9 | 12 | import { useActivePreviewSessions } from "~/previewStateStore"; |
| ... | ... | @@ -11,31 +14,40 @@ import { useActivePreviewSessions } from "~/previewStateStore"; |
| 11 | 14 | import { readPreviewAnnotationTheme } from "./annotationTheme"; |
| 12 | 15 | import { useBrowserPointerStore } from "./browserPointerStore"; |
| 13 | 16 | import { HostedBrowserWebview } from "./HostedBrowserWebview"; |
| 17 | import { rendersServerTabNatively } from "./previewRuntime"; | |
| 14 | 18 | import { previewRuntimeTabId } from "./previewRuntimeTabId"; |
| 15 | 19 | |
| 16 | 20 | export function ElectronBrowserHost() { |
| 17 | 21 | const { resolvedTheme } = useTheme(); |
| 18 | 22 | const previewByThreadKey = useActivePreviewSessions(); |
| 23 | const primaryEnvironmentId = useAtomValue(primaryEnvironmentIdAtom); | |
| 19 | 24 | const sessions = useMemo( |
| 20 | 25 | () => |
| 21 | 26 | Object.entries(previewByThreadKey).flatMap(([threadKey, previewState]) => { |
| 22 | 27 | const threadRef = parseScopedThreadKey(threadKey); |
| 28 | // Server tabs of other environments stream; this desktop's own server tabs render here. | |
| 23 | 29 | 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) => ({ | |
| 28 | 37 | 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 | })) | |
| 36 | 48 | : []; |
| 37 | 49 | }), |
| 38 | [previewByThreadKey], | |
| 50 | [previewByThreadKey, primaryEnvironmentId], | |
| 39 | 51 | ); |
| 40 | 52 | |
| 41 | 53 | useEffect(() => { |
| ... | ... | @@ -95,6 +107,15 @@ export function ElectronBrowserHost() { |
| 95 | 107 | pictureInPicture={pictureInPicture} |
| 96 | 108 | profileId={snapshot.profileId} |
| 97 | 109 | zoomFactor={zoomFactor} |
| 110 | serverDriven={snapshot.runtime === "server"} | |
| 111 | {...(snapshot.runtime === "server" | |
| 112 | ? { | |
| 113 | serverRendering: { | |
| 114 | colorScheme: snapshot.colorScheme ?? "system", | |
| 115 | zoomFactor: snapshot.zoomFactor ?? 1, | |
| 116 | }, | |
| 117 | } | |
| 118 | : {})} | |
| 98 | 119 | /> |
| 99 | 120 | ); |
| 100 | 121 | })} |
apps/web/src/browser/HostedBrowserWebview.test.tsx+1-1| ... | ... | @@ -174,7 +174,7 @@ describe("HostedBrowserWebview settings hydration", () => { |
| 174 | 174 | await retry; |
| 175 | 175 | }); |
| 176 | 176 | |
| 177 | expect(acquire).toHaveBeenCalledExactlyOnceWith(runtimeTabId); | |
| 177 | expect(acquire).toHaveBeenCalledExactlyOnceWith(runtimeTabId, undefined); | |
| 178 | 178 | expect(mocks.getPreviewConfig).toHaveBeenCalledExactlyOnceWith(threadRef.environmentId, "work"); |
| 179 | 179 | expect(createGuest).toHaveBeenCalledOnce(); |
| 180 | 180 | expect(createGuest).toHaveBeenCalledWith( |
apps/web/src/browser/HostedBrowserWebview.tsx+39-5| ... | ... | @@ -1,6 +1,10 @@ |
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | import type { PreviewViewportSetting, ScopedThreadRef } from "@t3tools/contracts"; | |
| 3 | import type { | |
| 4 | DesktopPreviewColorScheme, | |
| 5 | PreviewViewportSetting, | |
| 6 | ScopedThreadRef, | |
| 7 | } from "@t3tools/contracts"; | |
| 4 | 8 | import { useShallow } from "zustand/react/shallow"; |
| 5 | 9 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 6 | 10 | |
| ... | ... | @@ -18,7 +22,7 @@ import { |
| 18 | 22 | } from "./browserViewportLayout"; |
| 19 | 23 | import { BrowserDeviceToolbar } from "./BrowserDeviceToolbar"; |
| 20 | 24 | import { BrowserViewportResizeHandles } from "./BrowserViewportResizeHandles"; |
| 21 | import { acquireDesktopTab, type AcquiredDesktopTab } from "./desktopTabLifetime"; | |
| 25 | import { acquireDesktopTab, type AcquiredDesktopTab, withDesktopTab } from "./desktopTabLifetime"; | |
| 22 | 26 | import { resolveHostedBrowserWebviewWrapperStyle } from "./hostedBrowserWebviewStyle"; |
| 23 | 27 | import { usePreviewWebviewConfig } from "./previewWebviewConfigState"; |
| 24 | 28 | import { useBrowserViewportResize } from "./useBrowserViewportResize"; |
| ... | ... | @@ -56,6 +60,16 @@ export function HostedBrowserWebview(props: { |
| 56 | 60 | */ |
| 57 | 61 | readonly profileId: string | undefined; |
| 58 | 62 | readonly zoomFactor: number; |
| 63 | /** A tab of the desktop's own server; the server drives this webview's page. */ | |
| 64 | readonly serverDriven?: boolean; | |
| 65 | /** | |
| 66 | * For a server-driven tab, the appearance and zoom its environment published, | |
| 67 | * which this webview follows so every client and agent sees one state. | |
| 68 | */ | |
| 69 | readonly serverRendering?: { | |
| 70 | readonly colorScheme: DesktopPreviewColorScheme; | |
| 71 | readonly zoomFactor: number; | |
| 72 | }; | |
| 59 | 73 | }) { |
| 60 | 74 | const { |
| 61 | 75 | threadRef, |
| ... | ... | @@ -66,6 +80,8 @@ export function HostedBrowserWebview(props: { |
| 66 | 80 | pictureInPicture, |
| 67 | 81 | zoomFactor, |
| 68 | 82 | profileId, |
| 83 | serverDriven = false, | |
| 84 | serverRendering, | |
| 69 | 85 | } = props; |
| 70 | 86 | const clientSettingsHydrated = useClientSettingsHydrated(); |
| 71 | 87 | const config = usePreviewWebviewConfig(threadRef.environmentId, profileId); |
| ... | ... | @@ -93,18 +109,36 @@ export function HostedBrowserWebview(props: { |
| 93 | 109 | (state) => (state.activityByTabId[runtimeTabId] ?? 0) > 0, |
| 94 | 110 | ); |
| 95 | 111 | const recordingActive = useActiveBrowserRecordingTabIds().has(runtimeTabId); |
| 96 | usePreviewBridge({ threadRef, tabId, runtimeTabId }); | |
| 112 | usePreviewBridge({ threadRef, tabId, runtimeTabId, serverDriven }); | |
| 113 | ||
| 114 | const serverColorScheme = serverRendering?.colorScheme; | |
| 115 | const serverZoomFactor = serverRendering?.zoomFactor; | |
| 97 | 116 | |
| 98 | 117 | useEffect(() => { |
| 99 | 118 | if (!clientSettingsHydrated) return; |
| 100 | 119 | crashRecoveryRef.current = INITIAL_WEBVIEW_CRASH_RECOVERY_STATE; |
| 101 | const lease = acquireDesktopTab(runtimeTabId); | |
| 120 | const lease = acquireDesktopTab( | |
| 121 | runtimeTabId, | |
| 122 | serverDriven ? { threadId: threadRef.threadId, tabId } : undefined, | |
| 123 | ); | |
| 102 | 124 | tabLeaseRef.current = lease; |
| 103 | 125 | return () => { |
| 104 | 126 | if (tabLeaseRef.current === lease) tabLeaseRef.current = null; |
| 105 | 127 | lease.release(); |
| 106 | 128 | }; |
| 107 | }, [clientSettingsHydrated, runtimeTabId]); | |
| 129 | }, [clientSettingsHydrated, runtimeTabId, serverDriven, tabId, threadRef.threadId]); | |
| 130 | ||
| 131 | // A server tab looks the way its environment published, once the desktop tab exists. | |
| 132 | useEffect(() => { | |
| 133 | const bridge = window.desktopBridge?.preview; | |
| 134 | if (!bridge || serverColorScheme === undefined) return; | |
| 135 | withDesktopTab(runtimeTabId, () => bridge.setColorScheme(runtimeTabId, serverColorScheme)); | |
| 136 | }, [runtimeTabId, serverColorScheme]); | |
| 137 | useEffect(() => { | |
| 138 | const bridge = window.desktopBridge?.preview; | |
| 139 | if (!bridge || serverZoomFactor === undefined) return; | |
| 140 | withDesktopTab(runtimeTabId, () => bridge.setZoomFactor(runtimeTabId, serverZoomFactor)); | |
| 141 | }, [runtimeTabId, serverZoomFactor]); | |
| 108 | 142 | |
| 109 | 143 | const [webviewGeneration, setWebviewGeneration] = useState(0); |
| 110 | 144 | const [recoverySrc, setRecoverySrc] = useState(initialSrc); |
apps/web/src/browser/ServerBrowserSurface.tsx created+930| ... | ... | @@ -0,0 +1,930 @@ |
| 1 | "use client"; | |
| 2 | ||
| 3 | import { | |
| 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"; | |
| 19 | import type { | |
| 20 | EnvironmentId, | |
| 21 | PreviewStreamHostSetup, | |
| 22 | PreviewViewportSetting, | |
| 23 | } from "@t3tools/contracts"; | |
| 24 | import { | |
| 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 | ||
| 37 | import { AgentCursorMark } from "~/components/preview/AgentBrowserCursor"; | |
| 38 | import { CommandBlock } from "~/components/CommandBlock"; | |
| 39 | import { Button } from "~/components/ui/button"; | |
| 40 | import { Input } from "~/components/ui/input"; | |
| 41 | import { toastManager } from "~/components/ui/toast"; | |
| 42 | import { cn } from "~/lib/utils"; | |
| 43 | import { refreshPreviewStreamAccess, usePreviewStreamAccess } from "~/state/previewStream"; | |
| 44 | ||
| 45 | /** Chrome-row controls for a server tab; commands require current ownership. */ | |
| 46 | export 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 | ||
| 54 | const RESIZE_DEBOUNCE_MS = 150; | |
| 55 | const 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. | |
| 58 | const PROBE_REUSE_PX = 24; | |
| 59 | const 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. | |
| 62 | const INPUT_SENTINEL = "\u200b"; | |
| 63 | const BACKSPACE = { key: "Backspace", code: "Backspace", keyCode: 8 } as const; | |
| 64 | const DELETE = { key: "Delete", code: "Delete", keyCode: 46 } as const; | |
| 65 | const MAX_UNAUTHORIZED_REFUSALS = 3; | |
| 66 | const TAP_SLOP_PX = 8; | |
| 67 | const MULTI_CLICK_MS = 500; | |
| 68 | const MULTI_CLICK_SLOP_PX = 4; | |
| 69 | const WHEEL_LINE_PX = 16; | |
| 70 | ||
| 71 | type MouseInput = Extract<PreviewStreamInput, { type: "mouse" }>; | |
| 72 | type WheelInput = Extract<PreviewStreamInput, { type: "wheel" }>; | |
| 73 | ||
| 74 | interface PagePoint { | |
| 75 | readonly x: number; | |
| 76 | readonly y: number; | |
| 77 | /** Page CSS px per client px. */ | |
| 78 | readonly scale: number; | |
| 79 | } | |
| 80 | ||
| 81 | interface 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. */ | |
| 91 | interface 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 | ||
| 101 | const buttonOf = (button: number): PreviewStreamMouseButton => | |
| 102 | button === 0 ? "left" : button === 1 ? "middle" : button === 2 ? "right" : "none"; | |
| 103 | ||
| 104 | const 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. */ | |
| 108 | async 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. */ | |
| 127 | function 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 | ||
| 144 | export 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 | ||
| 906 | interface 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. */ | |
| 914 | function placeAgentCursor( | |
| 915 | pointer: PreviewStreamPointer, | |
| 916 | canvas: HTMLCanvasElement | null, | |
| 917 | viewport: PreviewStreamViewport | null, | |
| 918 | ): AgentCursorPlacement | null { | |
| 919 | if (!canvas || !viewport || canvas.width < 1 || canvas.height < 1) return null; | |
| 920 | const box = { width: canvas.clientWidth, height: canvas.clientHeight }; | |
| 921 | const fit = Math.min(box.width / canvas.width, box.height / canvas.height); | |
| 922 | const width = canvas.width * fit; | |
| 923 | const height = canvas.height * fit; | |
| 924 | return { | |
| 925 | phase: pointer.phase, | |
| 926 | sequence: pointer.sequence, | |
| 927 | left: (box.width - width) / 2 + (pointer.x * width) / viewport.width, | |
| 928 | top: (box.height - height) / 2 + (pointer.y * height) / viewport.height, | |
| 929 | }; | |
| 930 | } |
apps/web/src/browser/browserLinkTarget.ts+5-5| ... | ... | @@ -8,10 +8,10 @@ |
| 8 | 8 | * |
| 9 | 9 | * @module browserLinkTarget |
| 10 | 10 | */ |
| 11 | import type { BrowserLinkTarget } from "@t3tools/contracts"; | |
| 11 | import type { BrowserLinkTarget, ScopedThreadRef } from "@t3tools/contracts"; | |
| 12 | 12 | |
| 13 | 13 | import { ensureClientSettingsHydrated, getClientSettings } from "~/hooks/useSettings"; |
| 14 | import { isPreviewSupportedInRuntime } from "~/previewStateStore"; | |
| 14 | import { isPreviewAvailableFor } from "./previewRuntime"; | |
| 15 | 15 | |
| 16 | 16 | export interface ResolveLinkTargetInput { |
| 17 | 17 | readonly url: string; |
| ... | ... | @@ -62,7 +62,7 @@ export async function resolveBrowserLinkTargetPreference(): Promise<BrowserLinkT |
| 62 | 62 | return getClientSettings().browserLinkTarget; |
| 63 | 63 | } |
| 64 | 64 | |
| 65 | /** Whether the in-app target is available at all in this client. */ | |
| 66 | export function canOpenLinksInApp(hasThread: boolean): boolean { | |
| 67 | return hasThread && isPreviewSupportedInRuntime(); | |
| 65 | /** Whether this thread's environment has an in-app browser available to this client. */ | |
| 66 | export function canOpenLinksInApp(threadRef: ScopedThreadRef | null | undefined): boolean { | |
| 67 | return threadRef != null && isPreviewAvailableFor(threadRef.environmentId); | |
| 68 | 68 | } |
apps/web/src/browser/browserRecordingUpload.ts deleted-84| ... | ... | @@ -1,84 +0,0 @@ |
| 1 | import { | |
| 2 | PROVIDER_SEND_TURN_MAX_FILE_BYTES, | |
| 3 | PreviewAutomationRecordingTransferError, | |
| 4 | PreviewAutomationRecordingTooLargeError, | |
| 5 | PreviewAutomationRecordingDeadlineExpiredError, | |
| 6 | type DesktopPreviewRecordingArtifact, | |
| 7 | type ScopedThreadRef, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; | |
| 10 | import { | |
| 11 | deletePendingAttachmentUpload, | |
| 12 | runAttachmentUploadCycle, | |
| 13 | } from "@t3tools/client-runtime/state/attachments"; | |
| 14 | ||
| 15 | import { appAtomRegistry } from "~/rpc/atomRegistry"; | |
| 16 | import { attachmentEnvironment } from "~/state/attachments"; | |
| 17 | import { readPreparedConnection } from "~/state/session"; | |
| 18 | ||
| 19 | /** Sends the finished encoded file once; capture frames never cross the environment connection. */ | |
| 20 | export async function uploadBrowserRecording( | |
| 21 | { environmentId, threadId }: ScopedThreadRef, | |
| 22 | artifact: DesktopPreviewRecordingArtifact, | |
| 23 | blob: Blob, | |
| 24 | deadlineMs: number, | |
| 25 | ): Promise<string> { | |
| 26 | if (blob.size > PROVIDER_SEND_TURN_MAX_FILE_BYTES) { | |
| 27 | throw new PreviewAutomationRecordingTooLargeError({ threadId }); | |
| 28 | } | |
| 29 | const result = await runAttachmentUploadCycle({ | |
| 30 | registry: appAtomRegistry, | |
| 31 | createUploadUrl: attachmentEnvironment.createUploadUrl, | |
| 32 | remove: attachmentEnvironment.remove, | |
| 33 | environmentId, | |
| 34 | upload: { | |
| 35 | type: "file", | |
| 36 | name: artifact.path.split(/[\\/]/).at(-1) ?? artifact.id, | |
| 37 | mimeType: artifact.mimeType, | |
| 38 | sizeBytes: blob.size, | |
| 39 | }, | |
| 40 | resolveUploadUrl: (relativeUrl) => { | |
| 41 | const connection = readPreparedConnection(environmentId); | |
| 42 | return connection ? resolveAssetUrl(connection.httpBaseUrl, relativeUrl) : null; | |
| 43 | }, | |
| 44 | transport: (url) => { | |
| 45 | const controller = new AbortController(); | |
| 46 | // Encoding, saving and minting consume the same request budget. Leave time to reply. | |
| 47 | const remainingMs = deadlineMs - Date.now() - 1_000; | |
| 48 | return { | |
| 49 | abort: () => controller.abort(), | |
| 50 | done: | |
| 51 | remainingMs <= 0 | |
| 52 | ? Promise.reject(new Error("Recording transfer deadline expired.")) | |
| 53 | : fetch(url, { | |
| 54 | method: "POST", | |
| 55 | headers: { "Content-Type": artifact.mimeType }, | |
| 56 | body: blob, | |
| 57 | signal: AbortSignal.any([controller.signal, AbortSignal.timeout(remainingMs)]), | |
| 58 | }).then((response) => { | |
| 59 | if (!response.ok) | |
| 60 | throw new Error(`Recording upload rejected (${response.status}).`); | |
| 61 | }), | |
| 62 | }; | |
| 63 | }, | |
| 64 | }); | |
| 65 | if (result.status !== "uploaded") { | |
| 66 | if (result.attachmentId) { | |
| 67 | deletePendingAttachmentUpload({ | |
| 68 | registry: appAtomRegistry, | |
| 69 | remove: attachmentEnvironment.remove, | |
| 70 | environmentId, | |
| 71 | attachmentId: result.attachmentId, | |
| 72 | }); | |
| 73 | } | |
| 74 | const cause = result.status === "failed" ? result.error : undefined; | |
| 75 | if (Date.now() >= deadlineMs - 1_000) { | |
| 76 | throw new PreviewAutomationRecordingDeadlineExpiredError({ threadId, cause }); | |
| 77 | } | |
| 78 | throw new PreviewAutomationRecordingTransferError({ | |
| 79 | threadId, | |
| 80 | cause, | |
| 81 | }); | |
| 82 | } | |
| 83 | return result.attachmentId; | |
| 84 | } |
apps/web/src/browser/desktopTabLifetime.ts+17-2| ... | ... | @@ -31,12 +31,24 @@ const enqueueDesktopTabOperation = ( |
| 31 | 31 | return pending; |
| 32 | 32 | }; |
| 33 | 33 | |
| 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 | */ | |
| 38 | export function withDesktopTab(tabId: string, operation: () => Promise<void>): void { | |
| 39 | void enqueueDesktopTabOperation(tabId, operation).catch(() => undefined); | |
| 40 | } | |
| 41 | ||
| 34 | 42 | export interface AcquiredDesktopTab { |
| 35 | 43 | readonly ready: Promise<void>; |
| 36 | 44 | readonly release: () => void; |
| 37 | 45 | } |
| 38 | 46 | |
| 39 | export function acquireDesktopTab(tabId: string): AcquiredDesktopTab { | |
| 47 | /** `serverTab` names the server tab this desktop tab renders, so the server can drive it. */ | |
| 48 | export function acquireDesktopTab( | |
| 49 | tabId: string, | |
| 50 | serverTab?: { readonly threadId: string; readonly tabId: string }, | |
| 51 | ): AcquiredDesktopTab { | |
| 40 | 52 | const current = |
| 41 | 53 | leases.get(tabId) ?? |
| 42 | 54 | ({ |
| ... | ... | @@ -45,7 +57,10 @@ export function acquireDesktopTab(tabId: string): AcquiredDesktopTab { |
| 45 | 57 | // Zoom/appearance defaults travel with creation so the guest never |
| 46 | 58 | // paints a frame at 100%/system before the preference is applied. |
| 47 | 59 | ready: enqueueDesktopTabOperation(tabId, async () => |
| 48 | previewBridge?.createTab(tabId, browserDefaultTabState(await resolveBrowserDefaults())), | |
| 60 | previewBridge?.createTab(tabId, { | |
| 61 | ...browserDefaultTabState(await resolveBrowserDefaults()), | |
| 62 | ...(serverTab === undefined ? {} : { serverTab }), | |
| 63 | }), | |
| 49 | 64 | ), |
| 50 | 65 | } satisfies DesktopTabLease); |
| 51 | 66 | if (current.closeTimer !== null) window.clearTimeout(current.closeTimer); |
apps/web/src/browser/openFileInPreview.ts+5-6| ... | ... | @@ -16,11 +16,8 @@ import * as Data from "effect/Data"; |
| 16 | 16 | import { AsyncResult } from "effect/reactivity"; |
| 17 | 17 | |
| 18 | 18 | import { resolveAssetUrl } from "~/assets/assetUrls"; |
| 19 | import { | |
| 20 | applyPreviewServerSnapshot, | |
| 21 | isPreviewSupportedInRuntime, | |
| 22 | rememberPreviewUrl, | |
| 23 | } from "~/previewStateStore"; | |
| 19 | import { isPreviewAvailableFor, previewRuntimeFor } from "~/browser/previewRuntime"; | |
| 20 | import { applyPreviewServerSnapshot, rememberPreviewUrl } from "~/previewStateStore"; | |
| 24 | 21 | import { useRightPanelStore } from "~/rightPanelStore"; |
| 25 | 22 | |
| 26 | 23 | import { |
| ... | ... | @@ -62,6 +59,7 @@ export async function openUrlInPreview<E>(input: { |
| 62 | 59 | if (defaults instanceof BrowserSettingsReadError) { |
| 63 | 60 | return AsyncResult.failure(Cause.fail(defaults)); |
| 64 | 61 | } |
| 62 | const runtime = previewRuntimeFor(input.threadRef.environmentId); | |
| 65 | 63 | const result = await input.openPreview({ |
| 66 | 64 | environmentId: input.threadRef.environmentId, |
| 67 | 65 | input: { |
| ... | ... | @@ -72,6 +70,7 @@ export async function openUrlInPreview<E>(input: { |
| 72 | 70 | // applied explicitly or file/link opens would ignore them. |
| 73 | 71 | viewport: browserDefaultOpenViewport(defaults), |
| 74 | 72 | profileId: browserDefaultOpenProfileId(defaults), |
| 73 | ...(runtime === undefined ? {} : { runtime }), | |
| 75 | 74 | }, |
| 76 | 75 | }); |
| 77 | 76 | return mapAtomCommandResult(result, (snapshot) => { |
| ... | ... | @@ -101,7 +100,7 @@ export async function openFileInPreview<AssetError, PreviewError>(input: { |
| 101 | 100 | AssetError | PreviewError | BrowserPreviewUnavailableError | BrowserSettingsReadError |
| 102 | 101 | > |
| 103 | 102 | > { |
| 104 | if (!isPreviewSupportedInRuntime()) { | |
| 103 | if (!isPreviewAvailableFor(input.threadRef.environmentId)) { | |
| 105 | 104 | return AsyncResult.failure( |
| 106 | 105 | Cause.fail( |
| 107 | 106 | new BrowserPreviewUnavailableError({ |
apps/web/src/browser/previewRuntime.ts created+49| ... | ... | @@ -0,0 +1,49 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | |
| 2 | import type { EnvironmentId, PreviewRuntime, PreviewSessionSnapshot } from "@t3tools/contracts"; | |
| 3 | ||
| 4 | import { isElectron } from "~/env"; | |
| 5 | import { isPreviewSupportedInRuntime } from "~/previewStateStore"; | |
| 6 | import { primaryEnvironmentIdAtom } from "~/state/primaryEnvironment"; | |
| 7 | import { | |
| 8 | readEnvironmentSupportsServerBrowser, | |
| 9 | useEnvironmentSupportsServerBrowser, | |
| 10 | } from "~/state/entities"; | |
| 11 | ||
| 12 | export 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. */ | |
| 17 | export function isPreviewAvailableFor(environmentId: EnvironmentId): boolean { | |
| 18 | return isPreviewSupportedInRuntime() || readEnvironmentSupportsServerBrowser(environmentId); | |
| 19 | } | |
| 20 | ||
| 21 | export 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 | */ | |
| 31 | export 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 | ||
| 44 | export 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 @@ |
| 1 | import { | |
| 2 | createPreviewFramePainter, | |
| 3 | createPreviewStreamClient, | |
| 4 | type PreviewStreamClient, | |
| 5 | } from "@t3tools/client-runtime/preview/server-browser-stream"; | |
| 6 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 7 | import { useSyncExternalStore } from "react"; | |
| 8 | ||
| 9 | import { readPreviewStreamAccess } from "~/state/previewStream"; | |
| 10 | ||
| 11 | interface WebKitVideo { | |
| 12 | webkitSetPresentationMode?: (mode: string) => void; | |
| 13 | webkitPresentationMode?: string; | |
| 14 | } | |
| 15 | ||
| 16 | interface ActivePictureInPicture { | |
| 17 | readonly key: string; | |
| 18 | readonly stop: () => void; | |
| 19 | } | |
| 20 | ||
| 21 | // Owns its stream outside React so the floating window survives panel unmounts. | |
| 22 | let active: ActivePictureInPicture | null = null; | |
| 23 | const listeners = new Set<() => void>(); | |
| 24 | ||
| 25 | const emit = () => { | |
| 26 | for (const listener of listeners) listener(); | |
| 27 | }; | |
| 28 | ||
| 29 | const subscribe = (listener: () => void) => { | |
| 30 | listeners.add(listener); | |
| 31 | return () => listeners.delete(listener); | |
| 32 | }; | |
| 33 | ||
| 34 | export 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. */ | |
| 38 | export 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 | ||
| 48 | export function useServerPictureInPictureKey(): string | null { | |
| 49 | return useSyncExternalStore( | |
| 50 | subscribe, | |
| 51 | () => active?.key ?? null, | |
| 52 | () => null, | |
| 53 | ); | |
| 54 | } | |
| 55 | ||
| 56 | export 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. */ | |
| 64 | export async function openServerPictureInPicture(input: { | |
| 65 | readonly environmentId: EnvironmentId; | |
| 66 | readonly threadId: string; | |
| 67 | readonly tabId: string; | |
| 68 | readonly seed: HTMLCanvasElement | null; | |
| 69 | }): Promise<void> { | |
| 70 | closeServerPictureInPicture(); | |
| 71 | const canvas = document.createElement("canvas"); | |
| 72 | const context = canvas.getContext("2d"); | |
| 73 | const seed = input.seed && input.seed.width > 0 && input.seed.height > 0 ? input.seed : null; | |
| 74 | canvas.width = seed?.width ?? 640; | |
| 75 | canvas.height = seed?.height ?? 400; | |
| 76 | // The capture emits a frame per draw, so the seed is drawn after it starts. | |
| 77 | const stream = canvas.captureStream(); | |
| 78 | if (seed) context?.drawImage(seed, 0, 0); | |
| 79 | else context?.fillRect(0, 0, canvas.width, canvas.height); | |
| 80 | const video = document.createElement("video") as HTMLVideoElement & WebKitVideo; | |
| 81 | video.muted = true; | |
| 82 | video.playsInline = true; | |
| 83 | video.srcObject = stream; | |
| 84 | // Safari only floats a video that is in the document. | |
| 85 | video.style.cssText = | |
| 86 | "position:fixed;right:0;bottom:0;width:1px;height:1px;opacity:0;pointer-events:none"; | |
| 87 | document.body.append(video); | |
| 88 | ||
| 89 | const painter = createPreviewFramePainter(canvas); | |
| 90 | let client: PreviewStreamClient | null = null; | |
| 91 | let stopped = false; | |
| 92 | let refusals = 0; | |
| 93 | let retryTimer: ReturnType<typeof setTimeout> | null = null; | |
| 94 | const onLeave = () => { | |
| 95 | if (active === current) closeServerPictureInPicture(); | |
| 96 | }; | |
| 97 | const connect = async (refresh: boolean) => { | |
| 98 | const access = await readPreviewStreamAccess(input.environmentId, refresh); | |
| 99 | if (stopped) return; | |
| 100 | if (!access) { | |
| 101 | closeServerPictureInPicture(); | |
| 102 | return; | |
| 103 | } | |
| 104 | client = createPreviewStreamClient( | |
| 105 | { | |
| 106 | access, | |
| 107 | threadId: input.threadId, | |
| 108 | tabId: input.tabId, | |
| 109 | maxWidth: 1280, | |
| 110 | maxHeight: 1280, | |
| 111 | interactive: false, | |
| 112 | }, | |
| 113 | { | |
| 114 | onFrame: (jpeg) => { | |
| 115 | refusals = 0; | |
| 116 | painter.paint(jpeg); | |
| 117 | }, | |
| 118 | onViewport: () => undefined, | |
| 119 | onConnectedChange: () => undefined, | |
| 120 | onUnauthorized: () => { | |
| 121 | if (stopped) return; | |
| 122 | if (refusals >= 5) { | |
| 123 | onLeave(); | |
| 124 | return; | |
| 125 | } | |
| 126 | retryTimer = setTimeout(() => void connect(true), 1_000 * 2 ** refusals++); | |
| 127 | }, | |
| 128 | onGone: onLeave, | |
| 129 | }, | |
| 130 | ); | |
| 131 | }; | |
| 132 | ||
| 133 | const onPresentationMode = () => { | |
| 134 | if (video.webkitPresentationMode !== "picture-in-picture") onLeave(); | |
| 135 | }; | |
| 136 | const current: ActivePictureInPicture = { | |
| 137 | key: serverPictureInPictureKey(input.threadId, input.tabId), | |
| 138 | stop: () => { | |
| 139 | stopped = true; | |
| 140 | if (retryTimer !== null) clearTimeout(retryTimer); | |
| 141 | client?.stop(); | |
| 142 | painter.stop(); | |
| 143 | video.removeEventListener("leavepictureinpicture", onLeave); | |
| 144 | video.removeEventListener("webkitpresentationmodechanged", onPresentationMode); | |
| 145 | if (document.pictureInPictureElement === video) { | |
| 146 | void document.exitPictureInPicture().catch(() => undefined); | |
| 147 | } else if (video.webkitPresentationMode === "picture-in-picture") { | |
| 148 | video.webkitSetPresentationMode?.("inline"); | |
| 149 | } | |
| 150 | for (const track of stream.getTracks()) track.stop(); | |
| 151 | video.srcObject = null; | |
| 152 | video.remove(); | |
| 153 | }, | |
| 154 | }; | |
| 155 | active = current; | |
| 156 | emit(); | |
| 157 | ||
| 158 | try { | |
| 159 | await video.play(); | |
| 160 | if (video.readyState < HTMLMediaElement.HAVE_METADATA) { | |
| 161 | await new Promise((resolve) => | |
| 162 | video.addEventListener("loadedmetadata", resolve, { once: true }), | |
| 163 | ); | |
| 164 | } | |
| 165 | if (document.pictureInPictureEnabled && typeof video.requestPictureInPicture === "function") { | |
| 166 | video.addEventListener("leavepictureinpicture", onLeave); | |
| 167 | await video.requestPictureInPicture(); | |
| 168 | } else { | |
| 169 | video.addEventListener("webkitpresentationmodechanged", onPresentationMode); | |
| 170 | video.webkitSetPresentationMode?.("picture-in-picture"); | |
| 171 | } | |
| 172 | } catch (error) { | |
| 173 | onLeave(); | |
| 174 | throw error; | |
| 175 | } | |
| 176 | if (active === current) void connect(false); | |
| 177 | } |
apps/web/src/browser/useOpenLink.ts+1-1| ... | ... | @@ -46,7 +46,7 @@ export function useOpenLink(threadRef: ScopedThreadRef | null | undefined): ( |
| 46 | 46 | url, |
| 47 | 47 | event: options.event ?? NO_MODIFIER, |
| 48 | 48 | preference: await resolveBrowserLinkTargetPreference(), |
| 49 | canOpenInApp: canOpenLinksInApp(Boolean(targetThreadRef)), | |
| 49 | canOpenInApp: canOpenLinksInApp(targetThreadRef), | |
| 50 | 50 | }); |
| 51 | 51 | if (target === "app" && targetThreadRef) { |
| 52 | 52 | const result = await openUrlInPreview({ threadRef: targetThreadRef, url, openPreview }); |
apps/web/src/components/ChatMarkdown.tsx+3-3| ... | ... | @@ -190,7 +190,7 @@ import { |
| 190 | 190 | } from "~/lib/openPullRequestLink"; |
| 191 | 191 | import { useOpenLink } from "../browser/useOpenLink"; |
| 192 | 192 | import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; |
| 193 | import { isPreviewSupportedInRuntime } from "../previewStateStore"; | |
| 193 | import { isPreviewAvailableFor } from "../browser/previewRuntime"; | |
| 194 | 194 | import { isAbsolutePath, resolvePathLinkTarget } from "../terminal-links"; |
| 195 | 195 | import { |
| 196 | 196 | isBrowserPreviewFile, |
| ... | ... | @@ -2803,7 +2803,7 @@ function useChatMarkdownState({ |
| 2803 | 2803 | revealLabel={revealInFileManagerLabel} |
| 2804 | 2804 | onOpenInBrowser={ |
| 2805 | 2805 | threadRef && |
| 2806 | isPreviewSupportedInRuntime() && | |
| 2806 | isPreviewAvailableFor(threadRef.environmentId) && | |
| 2807 | 2807 | isBrowserPreviewFile(fileLinkMeta.filePath) |
| 2808 | 2808 | ? () => openMarkdownFileInPreview(fileLinkMeta.filePath) |
| 2809 | 2809 | : undefined |
| ... | ... | @@ -3073,7 +3073,7 @@ const CHAT_MARKDOWN_COMPONENTS = { |
| 3073 | 3073 | : null; |
| 3074 | 3074 | const isSameDocumentLink = href?.startsWith("#") ?? false; |
| 3075 | 3075 | const onClick = props.onClick; |
| 3076 | const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime(); | |
| 3076 | const canOpenInPreview = Boolean(threadRef && isPreviewAvailableFor(threadRef.environmentId)); | |
| 3077 | 3077 | const linkChildren = <MarkdownLinkContext value>{children}</MarkdownLinkContext>; |
| 3078 | 3078 | const link = ( |
| 3079 | 3079 | <a |
apps/web/src/components/ChatView.tsx+83-13| ... | ... | @@ -259,6 +259,7 @@ import { previewRuntimeTabId } from "../browser/previewRuntimeTabId"; |
| 259 | 259 | import { addBrowserSurface } from "./preview/addBrowserSurface"; |
| 260 | 260 | import { closePreviewSession } from "./preview/closePreviewSession"; |
| 261 | 261 | import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer"; |
| 262 | import { usePreviewSession } from "./preview/usePreviewSession"; | |
| 262 | 263 | import { subscribePreviewAction } from "./preview/previewActionBus"; |
| 263 | 264 | import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; |
| 264 | 265 | |
| ... | ... | @@ -408,6 +409,7 @@ import { projectCloneDisplayName, projectCloneProgressSummary } from "@t3tools/c |
| 408 | 409 | import { useEnvironments, usePrimaryEnvironment } from "../state/environments"; |
| 409 | 410 | import { |
| 410 | 411 | resolveThreadDetailRef, |
| 412 | useEnvironmentSupportsServerBrowser, | |
| 411 | 413 | useProject, |
| 412 | 414 | useProjects, |
| 413 | 415 | useThreadProjection, |
| ... | ... | @@ -2195,6 +2197,11 @@ export default function ChatView(props: ChatViewProps) { |
| 2195 | 2197 | [activeThread], |
| 2196 | 2198 | ); |
| 2197 | 2199 | const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; |
| 2200 | const activeEnvironmentServerBrowser = useEnvironmentSupportsServerBrowser( | |
| 2201 | activeThreadRef?.environmentId ?? null, | |
| 2202 | ); | |
| 2203 | // Electron hosts its own browser tabs; other clients need the environment to host them. | |
| 2204 | const browserAvailable = isPreviewSupportedInRuntime() || activeEnvironmentServerBrowser; | |
| 2198 | 2205 | const previewPanelInlineSize = usePreviewPanelInlineSize(undefined, { |
| 2199 | 2206 | containerWidth: workspaceLayoutWidth ?? undefined, |
| 2200 | 2207 | widthStorageKey: `t3code:preview-panel-width:${activeThreadKey}`, |
| ... | ... | @@ -2260,6 +2267,7 @@ export default function ChatView(props: ChatViewProps) { |
| 2260 | 2267 | ); |
| 2261 | 2268 | const activePreviewState = useThreadPreviewState(activeThreadRef); |
| 2262 | 2269 | const activePreviewServerEpoch = activePreviewState.serverEpoch; |
| 2270 | const previewSessionsReady = !activeEnvironmentServerBrowser || activePreviewState.listLoaded; | |
| 2263 | 2271 | const resolvePreviewRuntimeTabId = useMemo( |
| 2264 | 2272 | () => |
| 2265 | 2273 | activeThreadRef |
| ... | ... | @@ -2283,7 +2291,7 @@ export default function ChatView(props: ChatViewProps) { |
| 2283 | 2291 | () => [...new Set([...activeKnownTerminalIds, ...panelTerminalIds])], |
| 2284 | 2292 | [activeKnownTerminalIds, panelTerminalIds], |
| 2285 | 2293 | ); |
| 2286 | const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime(); | |
| 2294 | const previewPanelOpen = activeRightPanelKind === "preview" && browserAvailable; | |
| 2287 | 2295 | const rightPanelOpen = rightPanelState.isOpen; |
| 2288 | 2296 | const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } = |
| 2289 | 2297 | usePanelAnimationSettings(); |
| ... | ... | @@ -2334,21 +2342,32 @@ export default function ChatView(props: ChatViewProps) { |
| 2334 | 2342 | ); |
| 2335 | 2343 | |
| 2336 | 2344 | useEffect(() => { |
| 2337 | if (!activeThreadRef) return; | |
| 2345 | if (!activeThreadRef || !previewSessionsReady) return; | |
| 2346 | const hiddenTabIds = new Set( | |
| 2347 | Object.values(activePreviewState.sessions) | |
| 2348 | .filter((session) => session.runtime === "server" && session.reveal === false) | |
| 2349 | .map((session) => session.tabId), | |
| 2350 | ); | |
| 2338 | 2351 | useRightPanelStore |
| 2339 | 2352 | .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]); | |
| 2342 | 2359 | |
| 2343 | 2360 | 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 | ) { | |
| 2349 | 2368 | usePreviewMiniPlayerStore.getState().close(activeThreadRef); |
| 2350 | 2369 | } |
| 2351 | }, [activePreviewMiniPlayer, activePreviewState.sessions, activeThreadRef]); | |
| 2370 | }, [activePreviewMiniPlayer, activePreviewState.sessions, activeThreadRef, previewSessionsReady]); | |
| 2352 | 2371 | |
| 2353 | 2372 | const existingOpenTerminalThreadKeys = useMemo(() => { |
| 2354 | 2373 | const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]); |
| ... | ... | @@ -5349,6 +5368,47 @@ export default function ChatView(props: ChatViewProps) { |
| 5349 | 5368 | deviceState.sessions, |
| 5350 | 5369 | deviceState.devices, |
| 5351 | 5370 | ]); |
| 5371 | // Baseline loaded tabs so reloads never reopen previews the user dismissed. | |
| 5372 | const previousServerPreviewTabs = useRef(new Map<string, Map<string, string | undefined>>()); | |
| 5373 | useEffect(() => { | |
| 5374 | if (!activeThreadRef || !activeEnvironmentServerBrowser || !activePreviewState.listLoaded) | |
| 5375 | return; | |
| 5376 | const threadKey = scopedThreadKey(activeThreadRef); | |
| 5377 | const serverSessions = Object.values(activePreviewState.sessions).filter( | |
| 5378 | (session) => session.runtime === "server", | |
| 5379 | ); | |
| 5380 | const previous = previousServerPreviewTabs.current.get(threadKey); | |
| 5381 | previousServerPreviewTabs.current.set( | |
| 5382 | threadKey, | |
| 5383 | new Map(serverSessions.map((session) => [session.tabId, session.revealRequest?.id])), | |
| 5384 | ); | |
| 5385 | if (!previous) return; | |
| 5386 | for (const session of serverSessions) { | |
| 5387 | const requested = session.revealRequest; | |
| 5388 | const fresh = requested | |
| 5389 | ? previous.get(session.tabId) !== requested.id | |
| 5390 | : !previous.has(session.tabId); | |
| 5391 | if (!fresh || session.reveal !== true) continue; | |
| 5392 | if (!autoShowFloatingPreview && requested?.force !== true) continue; | |
| 5393 | const surface = rightPanelState.surfaces.find( | |
| 5394 | (surface) => surface.kind === "preview" && surface.resourceId === session.tabId, | |
| 5395 | ); | |
| 5396 | if (surface && requested?.force === true) { | |
| 5397 | useRightPanelStore.getState().activateSurface(activeThreadRef, surface.id); | |
| 5398 | } else if (!surface) { | |
| 5399 | usePreviewMiniPlayerStore | |
| 5400 | .getState() | |
| 5401 | .open(activeThreadRef, browserMiniPlayerSource(session.tabId)); | |
| 5402 | } | |
| 5403 | } | |
| 5404 | }, [ | |
| 5405 | activeEnvironmentServerBrowser, | |
| 5406 | activePreviewState.listLoaded, | |
| 5407 | activePreviewState.sessions, | |
| 5408 | activeThreadRef, | |
| 5409 | autoShowFloatingPreview, | |
| 5410 | rightPanelState.surfaces, | |
| 5411 | ]); | |
| 5352 | 5412 | // A floating device follows its session: once the agent or another client |
| 5353 | 5413 | // closes the device there is nothing left to stream. |
| 5354 | 5414 | useEffect(() => { |
| ... | ... | @@ -5653,7 +5713,7 @@ export default function ChatView(props: ChatViewProps) { |
| 5653 | 5713 | } |
| 5654 | 5714 | }, [activeRightPanelSurface, activeThreadRef]); |
| 5655 | 5715 | const togglePreviewPanel = useCallback(() => { |
| 5656 | if (!activeThreadRef || !isPreviewSupportedInRuntime()) return; | |
| 5716 | if (!activeThreadRef || !browserAvailable) return; | |
| 5657 | 5717 | if (previewPanelOpen) { |
| 5658 | 5718 | closePreviewPanel(); |
| 5659 | 5719 | return; |
| ... | ... | @@ -5667,6 +5727,7 @@ export default function ChatView(props: ChatViewProps) { |
| 5667 | 5727 | }, [ |
| 5668 | 5728 | activePreviewState.activeTabId, |
| 5669 | 5729 | activeThreadRef, |
| 5730 | browserAvailable, | |
| 5670 | 5731 | closePreviewPanel, |
| 5671 | 5732 | createBrowserSurface, |
| 5672 | 5733 | previewPanelOpen, |
| ... | ... | @@ -11449,6 +11510,9 @@ export default function ChatView(props: ChatViewProps) { |
| 11449 | 11510 | </div> |
| 11450 | 11511 | </div> |
| 11451 | 11512 | |
| 11513 | {activeThreadRef && activeEnvironmentServerBrowser ? ( | |
| 11514 | <PreviewSessionSync threadRef={activeThreadRef} /> | |
| 11515 | ) : null} | |
| 11452 | 11516 | {activeThreadRef && activePreviewMiniPlayer && previewMiniPlayerVisible ? ( |
| 11453 | 11517 | <ThreadPreviewMiniPlayer |
| 11454 | 11518 | key={`${activeThreadKey}:${previewMiniPlayerSourceKey(activePreviewMiniPlayer.source)}`} |
| ... | ... | @@ -11562,7 +11626,7 @@ export default function ChatView(props: ChatViewProps) { |
| 11562 | 11626 | onAddPullRequest={addPullRequestSurface} |
| 11563 | 11627 | onAddPullRequests={addPullRequestsSurface} |
| 11564 | 11628 | onAddDevice={addDeviceSurface} |
| 11565 | browserAvailable={isPreviewSupportedInRuntime()} | |
| 11629 | browserAvailable={browserAvailable} | |
| 11566 | 11630 | terminalAvailable={activeProject !== null} |
| 11567 | 11631 | diffAvailable={isServerThread && isGitRepo} |
| 11568 | 11632 | filesAvailable={activeProject !== null} |
| ... | ... | @@ -11617,7 +11681,7 @@ export default function ChatView(props: ChatViewProps) { |
| 11617 | 11681 | onAddPullRequest={addPullRequestSurface} |
| 11618 | 11682 | onAddPullRequests={addPullRequestsSurface} |
| 11619 | 11683 | onAddDevice={addDeviceSurface} |
| 11620 | browserAvailable={isPreviewSupportedInRuntime()} | |
| 11684 | browserAvailable={browserAvailable} | |
| 11621 | 11685 | terminalAvailable={activeProject !== null} |
| 11622 | 11686 | diffAvailable={isServerThread && isGitRepo} |
| 11623 | 11687 | filesAvailable={activeProject !== null} |
| ... | ... | @@ -11679,3 +11743,9 @@ export default function ChatView(props: ChatViewProps) { |
| 11679 | 11743 | </div> |
| 11680 | 11744 | ); |
| 11681 | 11745 | } |
| 11746 | ||
| 11747 | /** Keeps the thread's preview tabs synced while no browser panel is mounted. */ | |
| 11748 | function 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 @@ |
| 1 | import { Check, Copy } from "lucide"; | |
| 2 | ||
| 3 | import { MorphIcon } from "~/components/MorphIcon"; | |
| 4 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; | |
| 5 | import { cn } from "~/lib/utils"; | |
| 6 | ||
| 7 | import { Button } from "./ui/button"; | |
| 8 | ||
| 9 | /** A shell command to copy and run elsewhere, such as on the environment's host. */ | |
| 10 | export function CommandBlock({ | |
| 11 | command, | |
| 12 | className, | |
| 13 | prominent = false, | |
| 14 | }: { | |
| 15 | readonly command: string; | |
| 16 | readonly className?: string; | |
| 17 | readonly prominent?: boolean; | |
| 18 | }) { | |
| 19 | const { copyToClipboard, isCopied } = useCopyToClipboard({ | |
| 20 | timeout: 1500, | |
| 21 | target: "command", | |
| 22 | }); | |
| 23 | return ( | |
| 24 | <div | |
| 25 | className={cn( | |
| 26 | "flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/50 font-mono", | |
| 27 | prominent ? "px-4 py-3.5 text-base" : "px-3 py-2.5 text-sm", | |
| 28 | className, | |
| 29 | )} | |
| 30 | > | |
| 31 | <span className="min-w-0 truncate"> | |
| 32 | <span className="mr-2 text-muted-foreground">$</span> | |
| 33 | {command} | |
| 34 | </span> | |
| 35 | <Button | |
| 36 | size="icon-xs" | |
| 37 | variant="ghost" | |
| 38 | aria-label="Copy command" | |
| 39 | onClick={() => copyToClipboard(command, undefined)} | |
| 40 | > | |
| 41 | <MorphIcon className="size-3.5" icon={isCopied ? Check : Copy} /> | |
| 42 | </Button> | |
| 43 | </div> | |
| 44 | ); | |
| 45 | } |
apps/web/src/components/files/FilePreviewPanel.tsx+3-2| ... | ... | @@ -40,13 +40,13 @@ import { resolveDiffThemeName } from "~/lib/diffRendering"; |
| 40 | 40 | import { PREFERRED_HIGHLIGHTER } from "~/lib/syntaxHighlighting"; |
| 41 | 41 | import { cn } from "~/lib/utils"; |
| 42 | 42 | import type { ChatFileAttachment } from "~/types"; |
| 43 | import { isPreviewSupportedInRuntime } from "~/previewStateStore"; | |
| 44 | 43 | import { isAbsolutePath, resolvePathLinkTarget } from "~/terminal-links"; |
| 45 | 44 | import { ScrollArea } from "~/components/ui/scroll-area"; |
| 46 | 45 | import { stackedThreadToast, toastManager } from "~/components/ui/toast"; |
| 47 | 46 | import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; |
| 48 | 47 | import { buildFileReviewComment } from "~/reviewCommentContext"; |
| 49 | 48 | import { assetEnvironment } from "~/state/assets"; |
| 49 | import { usePreviewAvailable } from "~/browser/previewRuntime"; | |
| 50 | 50 | import { useEnvironmentHttpBaseUrl, usePrimaryEnvironmentId } from "~/state/environments"; |
| 51 | 51 | import { previewEnvironment } from "~/state/preview"; |
| 52 | 52 | import { useAtomCommand } from "~/state/use-atom-command"; |
| ... | ... | @@ -930,6 +930,7 @@ export default function FilePreviewPanel({ |
| 930 | 930 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| 931 | 931 | const remoteOpenState = useRemoteOpenState(environmentId); |
| 932 | 932 | const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(environmentId); |
| 933 | const previewAvailable = usePreviewAvailable(environmentId); | |
| 933 | 934 | const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { |
| 934 | 935 | reportFailure: false, |
| 935 | 936 | }); |
| ... | ... | @@ -1032,7 +1033,7 @@ export default function FilePreviewPanel({ |
| 1032 | 1033 | previewPath !== null && |
| 1033 | 1034 | attachment === undefined && |
| 1034 | 1035 | !isVideo && |
| 1035 | isPreviewSupportedInRuntime() && | |
| 1036 | previewAvailable && | |
| 1036 | 1037 | isBrowserPreviewFile(previewPath); |
| 1037 | 1038 | const absolutePath = |
| 1038 | 1039 | relativePath && attachment === undefined ? resolvePathLinkTarget(relativePath, cwd) : null; |
apps/web/src/components/onboarding/WelcomeWizard.tsx+1-40| ... | ... | @@ -30,7 +30,6 @@ import { |
| 30 | 30 | MonitorIcon, |
| 31 | 31 | TerminalIcon, |
| 32 | 32 | } from "lucide-react"; |
| 33 | import { Check, Copy } from "lucide"; | |
| 34 | 33 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 35 | 34 | |
| 36 | 35 | import { TYPOGRAPHY_ADVANCED_STORAGE_KEY } from "../../appearanceFonts"; |
| ... | ... | @@ -52,7 +51,6 @@ import { |
| 52 | 51 | resolveOnboardingProviderLoginCommand, |
| 53 | 52 | selectOnboardingProvidersByDriver, |
| 54 | 53 | } from "../../onboarding/providerReadiness.logic"; |
| 55 | import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; | |
| 56 | 54 | import { newProjectId, randomUUID } from "../../lib/utils"; |
| 57 | 55 | import { agentSessionImport } from "../../state/agentSessions"; |
| 58 | 56 | import { readProjects, useProjects } from "../../state/entities"; |
| ... | ... | @@ -76,8 +74,8 @@ import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; |
| 76 | 74 | import { T3Wordmark } from "../T3Wordmark"; |
| 77 | 75 | import { Alert, AlertDescription } from "../ui/alert"; |
| 78 | 76 | import { Button } from "../ui/button"; |
| 77 | import { CommandBlock } from "../CommandBlock"; | |
| 79 | 78 | import { Checkbox } from "../ui/checkbox"; |
| 80 | import { MorphIcon } from "~/components/MorphIcon"; | |
| 81 | 79 | import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; |
| 82 | 80 | import { Input } from "../ui/input"; |
| 83 | 81 | import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; |
| ... | ... | @@ -1713,40 +1711,3 @@ function StepShell({ |
| 1713 | 1711 | </> |
| 1714 | 1712 | ); |
| 1715 | 1713 | } |
| 1716 | ||
| 1717 | function CommandBlock({ | |
| 1718 | command, | |
| 1719 | className, | |
| 1720 | prominent = false, | |
| 1721 | }: { | |
| 1722 | readonly command: string; | |
| 1723 | readonly className?: string; | |
| 1724 | readonly prominent?: boolean; | |
| 1725 | }) { | |
| 1726 | const { copyToClipboard, isCopied } = useCopyToClipboard({ | |
| 1727 | timeout: 1500, | |
| 1728 | target: "command", | |
| 1729 | }); | |
| 1730 | return ( | |
| 1731 | <div | |
| 1732 | className={cn( | |
| 1733 | "flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/50 font-mono", | |
| 1734 | prominent ? "px-4 py-3.5 text-base" : "px-3 py-2.5 text-sm", | |
| 1735 | className, | |
| 1736 | )} | |
| 1737 | > | |
| 1738 | <span className="min-w-0 truncate"> | |
| 1739 | <span className="mr-2 text-muted-foreground">$</span> | |
| 1740 | {command} | |
| 1741 | </span> | |
| 1742 | <Button | |
| 1743 | size="icon-xs" | |
| 1744 | variant="ghost" | |
| 1745 | aria-label="Copy command" | |
| 1746 | onClick={() => copyToClipboard(command, undefined)} | |
| 1747 | > | |
| 1748 | <MorphIcon className="size-3.5" icon={isCopied ? Check : Copy} /> | |
| 1749 | </Button> | |
| 1750 | </div> | |
| 1751 | ); | |
| 1752 | } |
apps/web/src/components/preview/AgentBrowserCursor.tsx+27-6| ... | ... | @@ -45,27 +45,48 @@ function AgentBrowserCursorEvent(props: { |
| 45 | 45 | readonly controller: BrowserController; |
| 46 | 46 | }) { |
| 47 | 47 | const { event, content, zoomFactor, controller } = props; |
| 48 | const scale = zoomFactor * (content?.scale ?? 1); | |
| 49 | return ( | |
| 50 | <AgentCursorMark | |
| 51 | phase={event.phase} | |
| 52 | sequence={event.sequence} | |
| 53 | left={event.x * scale + (content?.x ?? 0) - (content?.scrollLeft ?? 0)} | |
| 54 | top={event.y * scale + (content?.y ?? 0) - (content?.scrollTop ?? 0)} | |
| 55 | controller={controller} | |
| 56 | /> | |
| 57 | ); | |
| 58 | } | |
| 59 | ||
| 60 | /** The agent's pointer at a surface position; it fades once the agent stops acting. */ | |
| 61 | export function AgentCursorMark(props: { | |
| 62 | readonly phase: "move" | "click"; | |
| 63 | readonly sequence: number; | |
| 64 | readonly left: number; | |
| 65 | readonly top: number; | |
| 66 | readonly controller: BrowserController; | |
| 67 | }) { | |
| 68 | const { phase, sequence, left, top, controller } = props; | |
| 48 | 69 | const [inactiveSequence, setInactiveSequence] = useState<number | null>(null); |
| 49 | const active = inactiveSequence !== event.sequence; | |
| 70 | const active = inactiveSequence !== sequence; | |
| 50 | 71 | |
| 51 | 72 | useEffect(() => { |
| 52 | const timeout = window.setTimeout(() => setInactiveSequence(event.sequence), CURSOR_ACTIVE_MS); | |
| 73 | const timeout = window.setTimeout(() => setInactiveSequence(sequence), CURSOR_ACTIVE_MS); | |
| 53 | 74 | return () => window.clearTimeout(timeout); |
| 54 | }, [event.sequence]); | |
| 75 | }, [sequence]); | |
| 55 | 76 | |
| 56 | 77 | return ( |
| 57 | 78 | <div |
| 58 | 79 | className="pointer-events-none absolute left-0 top-0 z-40 transition-[opacity,transform] duration-150 ease-out motion-reduce:transition-none" |
| 59 | 80 | style={{ |
| 60 | 81 | opacity: agentBrowserCursorOpacity(active, controller), |
| 61 | transform: `translate3d(${event.x * zoomFactor * (content?.scale ?? 1) + (content?.x ?? 0) - (content?.scrollLeft ?? 0)}px, ${event.y * zoomFactor * (content?.scale ?? 1) + (content?.y ?? 0) - (content?.scrollTop ?? 0)}px, 0)`, | |
| 82 | transform: `translate3d(${left}px, ${top}px, 0)`, | |
| 62 | 83 | }} |
| 63 | 84 | aria-hidden="true" |
| 64 | 85 | data-agent-browser-cursor |
| 65 | 86 | > |
| 66 | {event.phase === "click" ? ( | |
| 87 | {phase === "click" ? ( | |
| 67 | 88 | <span |
| 68 | key={event.sequence} | |
| 89 | key={sequence} | |
| 69 | 90 | className="absolute left-0.5 top-0.5 size-4 animate-status-ping rounded-full bg-primary/25 motion-reduce:animate-none" |
| 70 | 91 | /> |
| 71 | 92 | ) : null} |
apps/web/src/components/preview/PreviewAutomationHosts.test.tsx deleted-363| ... | ... | @@ -1,363 +0,0 @@ |
| 1 | import { | |
| 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"; | |
| 11 | import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; | |
| 12 | import * as Cause from "effect/Cause"; | |
| 13 | import { AsyncResult, Atom } from "effect/reactivity"; | |
| 14 | import { act } from "react"; | |
| 15 | import { create, type ReactTestRenderer } from "react-test-renderer"; | |
| 16 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 17 | ||
| 18 | import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore"; | |
| 19 | import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; | |
| 20 | import { __resetClientSettingsPersistenceForTests } from "~/hooks/useSettings"; | |
| 21 | import { | |
| 22 | applyPreviewServerSnapshot, | |
| 23 | readThreadPreviewState, | |
| 24 | resetPreviewStateForTests, | |
| 25 | applyPreviewDesktopState, | |
| 26 | } from "~/previewStateStore"; | |
| 27 | import { appAtomRegistry, AppAtomRegistryProvider } from "~/rpc/atomRegistry"; | |
| 28 | ||
| 29 | import { PreviewAutomationHosts } from "./PreviewAutomationHosts"; | |
| 30 | ||
| 31 | const 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 | ||
| 46 | vi.mock("~/localApi", () => ({ | |
| 47 | ensureLocalApi: () => ({ persistence: mocks }), | |
| 48 | })); | |
| 49 | vi.mock("~/env", () => ({ isElectron: true })); | |
| 50 | vi.mock("~/state/environments", () => ({ | |
| 51 | useEnvironments: () => ({ environments: [{ environmentId }] }), | |
| 52 | })); | |
| 53 | vi.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 | })); | |
| 63 | vi.mock("~/state/use-atom-command", () => ({ | |
| 64 | useAtomCommand: (command: unknown) => command, | |
| 65 | })); | |
| 66 | vi.mock("~/state/use-atom-query-runner", () => ({ | |
| 67 | useAtomQueryRunner: () => mocks.list, | |
| 68 | })); | |
| 69 | vi.mock("./previewBridge", () => ({ previewBridge: { automation: {} } })); | |
| 70 | ||
| 71 | const environmentId = EnvironmentId.make("automation-environment"); | |
| 72 | const threadId = ThreadId.make("automation-thread"); | |
| 73 | const threadRef = { environmentId, threadId }; | |
| 74 | const viewport = { _tag: "freeform", width: 1440, height: 900 } as const; | |
| 75 | const savedSettings: ClientSettings = { | |
| 76 | ...DEFAULT_CLIENT_SETTINGS, | |
| 77 | browserDefaultViewport: viewport, | |
| 78 | browserDefaultProfileId: "work", | |
| 79 | browserProfiles: [{ id: "work", name: "Work", kind: "persistent" }], | |
| 80 | }; | |
| 81 | const 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 | }; | |
| 91 | const emptyList = { sessions: [], serverEpoch: "test-server", revision: 0 }; | |
| 92 | const listAtom = Atom.make(AsyncResult.success(emptyList)); | |
| 93 | const requestsAtom = Atom.make<AsyncResult.AsyncResult<PreviewAutomationStreamEvent, Error>>( | |
| 94 | AsyncResult.initial(false), | |
| 95 | ); | |
| 96 | const 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 | ||
| 108 | function 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 | ||
| 116 | let renderer: ReactTestRenderer | null = null; | |
| 117 | ||
| 118 | beforeEach(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 | ||
| 146 | afterEach(async () => { | |
| 147 | await act(() => renderer?.unmount()); | |
| 148 | renderer = null; | |
| 149 | resetPreviewStateForTests(); | |
| 150 | __resetClientSettingsPersistenceForTests(); | |
| 151 | vi.unstubAllGlobals(); | |
| 152 | vi.restoreAllMocks(); | |
| 153 | }); | |
| 154 | ||
| 155 | describe("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 | ||
| 214 | describe("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 | ||
| 3 | import { RegistryContext, useAtomSet, useAtomValue } from "@effect/atom-react"; | |
| 4 | import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; | |
| 5 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | |
| 6 | import { | |
| 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"; | |
| 23 | import { resolvePreviewViewport } from "@t3tools/shared/previewViewport"; | |
| 24 | import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; | |
| 25 | import { Atom } from "effect/reactivity"; | |
| 26 | import { useShallow } from "zustand/react/shallow"; | |
| 27 | ||
| 28 | import { | |
| 29 | applyPreviewServerSnapshot, | |
| 30 | readThreadPreviewState, | |
| 31 | reconcilePreviewServerSessions, | |
| 32 | updatePreviewServerSnapshot, | |
| 33 | useActivePreviewSessions, | |
| 34 | } from "~/previewStateStore"; | |
| 35 | import { | |
| 36 | browserMiniPlayerSource, | |
| 37 | selectThreadPreviewMiniPlayerTabId, | |
| 38 | usePreviewMiniPlayerStore, | |
| 39 | } from "~/previewMiniPlayerStore"; | |
| 40 | import { resolveBrowserNavigationTarget } from "~/browser/browserTargetResolver"; | |
| 41 | import { | |
| 42 | readActiveBrowserRecordingTargets, | |
| 43 | startBrowserRecording, | |
| 44 | stopBrowserRecording, | |
| 45 | stopBrowserRecordingForUpload, | |
| 46 | } from "~/browser/browserRecording"; | |
| 47 | import { resolveBrowserRecordingStopTarget } from "~/browser/browserRecordingScope"; | |
| 48 | import { uploadBrowserRecording } from "~/browser/browserRecordingUpload"; | |
| 49 | import { | |
| 50 | acquireBrowserSurfaceActivity, | |
| 51 | useBrowserSurfaceStore, | |
| 52 | } from "~/browser/browserSurfaceStore"; | |
| 53 | import { | |
| 54 | browserDefaultOpenProfileId, | |
| 55 | browserDefaultOpenViewport, | |
| 56 | resolveBrowserDefaults, | |
| 57 | } from "~/browser/browserDefaults"; | |
| 58 | import { runBrowserViewportMutation } from "~/browser/browserViewportActions"; | |
| 59 | import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; | |
| 60 | import { isElectron } from "~/env"; | |
| 61 | import { useEnvironments } from "~/state/environments"; | |
| 62 | import { previewEnvironment } from "~/state/preview"; | |
| 63 | import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; | |
| 64 | import { useAtomCommand } from "~/state/use-atom-command"; | |
| 65 | ||
| 66 | import { previewBridge } from "./previewBridge"; | |
| 67 | import { | |
| 68 | PreviewAutomationOperationError, | |
| 69 | PreviewAutomationOverlayTimeoutError, | |
| 70 | PreviewAutomationRecordingNotActiveError, | |
| 71 | PreviewAutomationTargetUnavailableError, | |
| 72 | PreviewAutomationViewportTimeoutError, | |
| 73 | } from "./previewAutomationErrors"; | |
| 74 | import { | |
| 75 | explicitlySuppressesPreviewMiniPlayer, | |
| 76 | previewAutomationDefaultViewport, | |
| 77 | previewAutomationOpenNeedsOverlay, | |
| 78 | shouldAutoShowPreviewForAutomationUse, | |
| 79 | shouldOpenPreviewMiniPlayer, | |
| 80 | } from "./previewAutomationOpenReadiness"; | |
| 81 | import { | |
| 82 | assertPreviewRuntimeCurrent, | |
| 83 | waitForNavigationReadiness, | |
| 84 | } from "./previewNavigationReadiness"; | |
| 85 | import { createPreviewAutomationRequestConsumerAtom } from "./previewAutomationRequestConsumer"; | |
| 86 | import { createPreviewAutomationClientId } from "./previewAutomationClientId"; | |
| 87 | import { | |
| 88 | needsPreviewAutomationSessionSync, | |
| 89 | resolvePreviewAutomationOpenTab, | |
| 90 | resolvePreviewAutomationTarget, | |
| 91 | } from "./previewAutomationTarget"; | |
| 92 | import { resolveHostWaitBudgetMs, waitForHostReadiness } from "./previewAutomationHostBudget"; | |
| 93 | import { runPreviewClickKeepingHostFocus } from "./previewClickFocus"; | |
| 94 | import { isPreviewViewportReady } from "./previewViewportReadiness"; | |
| 95 | import { shouldRollbackPreviewViewport } from "./previewViewportRollback"; | |
| 96 | ||
| 97 | const PREVIEW_PRESENTATION_SETTLE_TIMEOUT_MS = 500; | |
| 98 | ||
| 99 | const 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 | ||
| 107 | const 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 | ||
| 136 | interface ExecutablePreviewWebview extends Element { | |
| 137 | readonly executeJavaScript: (code: string, userGesture?: boolean) => Promise<unknown>; | |
| 138 | } | |
| 139 | ||
| 140 | const 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 | ||
| 145 | const isPreviewWebviewRendering = (runtimeTabId: string): boolean => { | |
| 146 | const wrapper = findPreviewWebview(runtimeTabId)?.closest<HTMLElement>("[data-preview-viewport]"); | |
| 147 | return wrapper?.getAttribute("data-preview-rendering") === "active"; | |
| 148 | }; | |
| 149 | ||
| 150 | const 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 | ||
| 168 | const 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 | ||
| 176 | const 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 | ||
| 186 | const 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 | ||
| 231 | const 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 | ||
| 267 | const raiseAtomCommandFailure = (result: Parameters<typeof squashAtomCommandFailure>[0]): never => { | |
| 268 | throw squashAtomCommandFailure(result); | |
| 269 | }; | |
| 270 | ||
| 271 | const raisePreviewAutomationHostError = ( | |
| 272 | error: PreviewAutomationRecordingNotActiveError, | |
| 273 | ): never => { | |
| 274 | throw error; | |
| 275 | }; | |
| 276 | ||
| 277 | export 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 | ||
| 297 | function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) { | |
| 298 | const { environmentId } = props; | |
| 299 | const previewSessions = useActivePreviewSessions(); | |
| 300 | const visibleRuntimeTabIds = useBrowserSurfaceStore( | |
| 301 | useShallow((state) => | |
| 302 | Object.keys(state.byTabId).filter((tabId) => state.byTabId[tabId]?.visible), | |
| 303 | ), | |
| 304 | ); | |
| 305 | const liveTabs = useMemo( | |
| 306 | () => | |
| 307 | Object.entries(previewSessions).flatMap(([key, state]) => { | |
| 308 | const ref = parseScopedThreadKey(key); | |
| 309 | if (ref?.environmentId !== environmentId) return []; | |
| 310 | return Object.values(state.sessions) | |
| 311 | .filter((tab) => state.desktopByTabId[tab.tabId]?.hasWebContents) | |
| 312 | .map((tab) => ({ | |
| 313 | threadId: ref.threadId, | |
| 314 | tabId: tab.tabId, | |
| 315 | visible: visibleRuntimeTabIds.includes( | |
| 316 | previewRuntimeTabId(ref, state.serverEpoch, tab.tabId), | |
| 317 | ), | |
| 318 | })); | |
| 319 | }), | |
| 320 | [environmentId, previewSessions, visibleRuntimeTabIds], | |
| 321 | ); | |
| 322 | const lastFocusReportRef = useRef<string | null>(null); | |
| 323 | const registry = useContext(RegistryContext); | |
| 324 | const [automationClientId] = useState(createPreviewAutomationClientId); | |
| 325 | const initialAutomationHost = useMemo<PreviewAutomationHostState>( | |
| 326 | () => ({ | |
| 327 | clientId: automationClientId, | |
| 328 | environmentId, | |
| 329 | supportedOperations: [...PREVIEW_AUTOMATION_OPERATIONS], | |
| 330 | }), | |
| 331 | [automationClientId, environmentId], | |
| 332 | ); | |
| 333 | const automationRequestsAtom = previewEnvironment.automationRequests({ | |
| 334 | environmentId, | |
| 335 | input: initialAutomationHost, | |
| 336 | }); | |
| 337 | const listPreviews = useAtomQueryRunner(previewEnvironment.list, { | |
| 338 | reportFailure: false, | |
| 339 | }); | |
| 340 | const open = useAtomCommand(previewEnvironment.open, { | |
| 341 | reportFailure: false, | |
| 342 | }); | |
| 343 | const resize = useAtomCommand(previewEnvironment.resize, { | |
| 344 | reportFailure: false, | |
| 345 | }); | |
| 346 | const respondToAutomation = useAtomCommand( | |
| 347 | previewEnvironment.respondToAutomation, | |
| 348 | "preview automation response", | |
| 349 | ); | |
| 350 | const focusAutomationHost = useAtomCommand( | |
| 351 | previewEnvironment.focusAutomationHost, | |
| 352 | "preview automation host focus", | |
| 353 | ); | |
| 354 | const [automationConnectionAtom] = useState(() => Atom.make<string | null>(null)); | |
| 355 | const automationConnectionId = useAtomValue(automationConnectionAtom); | |
| 356 | const presentationSuppressedRuntimeTabsRef = useRef(new Map<string, Set<string>>()); | |
| 357 | ||
| 358 | const handleRequest = useCallback( | |
| 359 | async (request: PreviewAutomationRequest): Promise<unknown> => { | |
| 360 | // Session sync and tab creation consume the same budget as overlay registration. | |
| 361 | const hostDeadlineMs = Date.now() + resolveHostWaitBudgetMs(request.timeoutMs); | |
| 362 | const threadRef: ScopedThreadRef = { | |
| 363 | environmentId, | |
| 364 | threadId: request.threadId, | |
| 365 | }; | |
| 366 | let tabId = request.tabId ?? null; | |
| 367 | const browserActivity = { release: null as (() => void) | null }; | |
| 368 | try { | |
| 369 | let state = readThreadPreviewState(threadRef); | |
| 370 | const needsSessionSync = needsPreviewAutomationSessionSync(state, request.tabId); | |
| 371 | if (needsSessionSync) { | |
| 372 | const listTarget = { | |
| 373 | environmentId, | |
| 374 | input: { threadId: request.threadId }, | |
| 375 | } as const; | |
| 376 | registry.refresh(previewEnvironment.list(listTarget)); | |
| 377 | const result = await listPreviews(listTarget); | |
| 378 | if (result._tag === "Failure") { | |
| 379 | return raiseAtomCommandFailure(result); | |
| 380 | } | |
| 381 | reconcilePreviewServerSessions(threadRef, result.value); | |
| 382 | state = readThreadPreviewState(threadRef); | |
| 383 | } | |
| 384 | tabId = request.tabId ?? state.snapshot?.tabId ?? null; | |
| 385 | const unavailableTarget = { | |
| 386 | requestId: request.requestId, | |
| 387 | operation: request.operation, | |
| 388 | environmentId, | |
| 389 | threadId: request.threadId, | |
| 390 | tabId, | |
| 391 | bridgeAvailable: Boolean(previewBridge), | |
| 392 | }; | |
| 393 | const requireReadyTab = async () => { | |
| 394 | const bridge = previewBridge; | |
| 395 | const readyTabId = tabId; | |
| 396 | if (!bridge || !readyTabId) { | |
| 397 | throw new PreviewAutomationTargetUnavailableError(unavailableTarget); | |
| 398 | } | |
| 399 | const readyState = readThreadPreviewState(threadRef); | |
| 400 | const runtimeTabId = previewRuntimeTabId(threadRef, readyState.serverEpoch, readyTabId); | |
| 401 | if (request.operation !== "open") { | |
| 402 | const { autoShowFloatingPreview } = await resolveBrowserDefaults(); | |
| 403 | if ( | |
| 404 | shouldAutoShowPreviewForAutomationUse({ | |
| 405 | operation: request.operation, | |
| 406 | autoShowFloatingPreview, | |
| 407 | presentationSuppressed: | |
| 408 | presentationSuppressedRuntimeTabsRef.current | |
| 409 | .get(request.threadId) | |
| 410 | ?.has(runtimeTabId) ?? false, | |
| 411 | }) | |
| 412 | ) { | |
| 413 | usePreviewMiniPlayerStore | |
| 414 | .getState() | |
| 415 | .open(threadRef, browserMiniPlayerSource(readyTabId)); | |
| 416 | } | |
| 417 | } | |
| 418 | browserActivity.release ??= acquireBrowserSurfaceActivity(runtimeTabId); | |
| 419 | await waitForDesktopOverlay( | |
| 420 | threadRef, | |
| 421 | request.requestId, | |
| 422 | readyTabId, | |
| 423 | runtimeTabId, | |
| 424 | request.operation, | |
| 425 | hostDeadlineMs, | |
| 426 | ); | |
| 427 | return { | |
| 428 | bridge, | |
| 429 | tabId: readyTabId, | |
| 430 | runtimeTabId, | |
| 431 | }; | |
| 432 | }; | |
| 433 | switch (request.operation) { | |
| 434 | case "status": | |
| 435 | return await currentStatus(threadRef, tabId); | |
| 436 | case "open": { | |
| 437 | const input = request.input as PreviewAutomationOpenInput; | |
| 438 | const resolvedInputUrl = input.url | |
| 439 | ? resolveBrowserNavigationTarget(environmentId, { | |
| 440 | kind: "url", | |
| 441 | url: input.url, | |
| 442 | }).resolvedUrl | |
| 443 | : undefined; | |
| 444 | let activeTabId = resolvePreviewAutomationOpenTab( | |
| 445 | state, | |
| 446 | request.tabId, | |
| 447 | input.reuseExistingTab ?? true, | |
| 448 | ); | |
| 449 | let activeSnapshot = activeTabId | |
| 450 | ? (state.sessions[activeTabId] ?? state.snapshot ?? undefined) | |
| 451 | : undefined; | |
| 452 | const reusedExistingTab = activeTabId !== null; | |
| 453 | tabId = activeTabId; | |
| 454 | if (!activeTabId) { | |
| 455 | const defaults = await resolveBrowserDefaults(); | |
| 456 | const result = await open({ | |
| 457 | environmentId, | |
| 458 | input: { | |
| 459 | threadId: request.threadId, | |
| 460 | ...(resolvedInputUrl ? { url: resolvedInputUrl } : {}), | |
| 461 | // An agent that didn't state a size gets the user's | |
| 462 | // configured default, same as a hand-opened tab. | |
| 463 | viewport: browserDefaultOpenViewport(defaults), | |
| 464 | profileId: browserDefaultOpenProfileId(defaults), | |
| 465 | }, | |
| 466 | }); | |
| 467 | if (result._tag === "Failure") { | |
| 468 | return raiseAtomCommandFailure(result); | |
| 469 | } | |
| 470 | const snapshot = result.value; | |
| 471 | applyPreviewServerSnapshot(threadRef, snapshot); | |
| 472 | activeTabId = snapshot.tabId; | |
| 473 | activeSnapshot = snapshot; | |
| 474 | tabId = activeTabId; | |
| 475 | } | |
| 476 | const activeRuntimeTabId = previewRuntimeTabId( | |
| 477 | threadRef, | |
| 478 | readThreadPreviewState(threadRef).serverEpoch, | |
| 479 | activeTabId, | |
| 480 | ); | |
| 481 | if (activeSnapshot) { | |
| 482 | const defaultViewport = previewAutomationDefaultViewport( | |
| 483 | reusedExistingTab, | |
| 484 | activeSnapshot, | |
| 485 | ); | |
| 486 | if (defaultViewport) { | |
| 487 | const resizeResult = await runBrowserViewportMutation( | |
| 488 | activeRuntimeTabId, | |
| 489 | async () => { | |
| 490 | assertPreviewRuntimeCurrent( | |
| 491 | threadRef, | |
| 492 | activeTabId, | |
| 493 | activeRuntimeTabId, | |
| 494 | request, | |
| 495 | ); | |
| 496 | return await resize({ | |
| 497 | environmentId, | |
| 498 | input: { | |
| 499 | threadId: request.threadId, | |
| 500 | tabId: activeTabId, | |
| 501 | viewport: defaultViewport, | |
| 502 | }, | |
| 503 | }); | |
| 504 | }, | |
| 505 | ); | |
| 506 | if (resizeResult._tag === "Failure") { | |
| 507 | return raiseAtomCommandFailure(resizeResult); | |
| 508 | } | |
| 509 | activeSnapshot = resizeResult.value; | |
| 510 | updatePreviewServerSnapshot(threadRef, resizeResult.value); | |
| 511 | } | |
| 512 | } | |
| 513 | const shouldPresentPreview = shouldOpenPreviewMiniPlayer( | |
| 514 | input, | |
| 515 | (await resolveBrowserDefaults()).autoShowFloatingPreview, | |
| 516 | ); | |
| 517 | const explicitlySuppressed = explicitlySuppressesPreviewMiniPlayer(input); | |
| 518 | const suppressedTabs = presentationSuppressedRuntimeTabsRef.current.get( | |
| 519 | request.threadId, | |
| 520 | ); | |
| 521 | if (explicitlySuppressed) { | |
| 522 | if (suppressedTabs) { | |
| 523 | suppressedTabs.add(activeRuntimeTabId); | |
| 524 | } else { | |
| 525 | presentationSuppressedRuntimeTabsRef.current.set( | |
| 526 | request.threadId, | |
| 527 | new Set([activeRuntimeTabId]), | |
| 528 | ); | |
| 529 | } | |
| 530 | const miniPlayerTabId = selectThreadPreviewMiniPlayerTabId( | |
| 531 | usePreviewMiniPlayerStore.getState().byThreadKey, | |
| 532 | threadRef, | |
| 533 | ); | |
| 534 | if (miniPlayerTabId === activeTabId) { | |
| 535 | usePreviewMiniPlayerStore.getState().close(threadRef); | |
| 536 | } | |
| 537 | } else if (shouldPresentPreview) { | |
| 538 | suppressedTabs?.delete(activeRuntimeTabId); | |
| 539 | if (suppressedTabs?.size === 0) { | |
| 540 | presentationSuppressedRuntimeTabsRef.current.delete(request.threadId); | |
| 541 | } | |
| 542 | } | |
| 543 | if (shouldPresentPreview) { | |
| 544 | usePreviewMiniPlayerStore | |
| 545 | .getState() | |
| 546 | .open(threadRef, browserMiniPlayerSource(activeTabId)); | |
| 547 | } | |
| 548 | if (activeSnapshot && previewAutomationOpenNeedsOverlay(input, activeSnapshot)) { | |
| 549 | await requireReadyTab(); | |
| 550 | } | |
| 551 | if (shouldPresentPreview) { | |
| 552 | // React commits the thread-bound surface asynchronously. Settle | |
| 553 | // briefly so active-thread opens report visible=true, without | |
| 554 | // turning a background thread's offscreen mini player into an | |
| 555 | // operation failure. | |
| 556 | await waitForPreviewPresentation(activeRuntimeTabId); | |
| 557 | } | |
| 558 | if (reusedExistingTab && resolvedInputUrl && previewBridge) { | |
| 559 | assertPreviewRuntimeCurrent(threadRef, activeTabId, activeRuntimeTabId, request); | |
| 560 | await previewBridge.navigate(activeRuntimeTabId, resolvedInputUrl); | |
| 561 | await waitForNavigationReadiness( | |
| 562 | threadRef, | |
| 563 | request.requestId, | |
| 564 | activeTabId, | |
| 565 | activeRuntimeTabId, | |
| 566 | request.operation, | |
| 567 | "load", | |
| 568 | request.timeoutMs, | |
| 569 | ); | |
| 570 | } | |
| 571 | return await currentStatus(threadRef, activeTabId); | |
| 572 | } | |
| 573 | case "navigate": { | |
| 574 | const ready = await requireReadyTab(); | |
| 575 | const input = request.input as PreviewAutomationNavigateInput; | |
| 576 | const resolution = resolveBrowserNavigationTarget( | |
| 577 | environmentId, | |
| 578 | input.target ?? { | |
| 579 | kind: "url", | |
| 580 | url: input.url!, | |
| 581 | }, | |
| 582 | ); | |
| 583 | await ready.bridge.navigate(ready.runtimeTabId, resolution.resolvedUrl); | |
| 584 | await waitForNavigationReadiness( | |
| 585 | threadRef, | |
| 586 | request.requestId, | |
| 587 | ready.tabId, | |
| 588 | ready.runtimeTabId, | |
| 589 | request.operation, | |
| 590 | input.readiness ?? "load", | |
| 591 | input.timeoutMs ?? request.timeoutMs, | |
| 592 | ); | |
| 593 | return await currentStatus(threadRef, ready.tabId); | |
| 594 | } | |
| 595 | case "resize": { | |
| 596 | const ready = await requireReadyTab(); | |
| 597 | const input = request.input as PreviewAutomationResizeInput; | |
| 598 | const setting = resolvePreviewViewport(input); | |
| 599 | const applied = await runBrowserViewportMutation(ready.runtimeTabId, async () => { | |
| 600 | const operationState = assertPreviewRuntimeCurrent( | |
| 601 | threadRef, | |
| 602 | ready.tabId, | |
| 603 | ready.runtimeTabId, | |
| 604 | request, | |
| 605 | ); | |
| 606 | const previousSetting = | |
| 607 | operationState.sessions[ready.tabId]?.viewport ?? FILL_PREVIEW_VIEWPORT; | |
| 608 | const result = await resize({ | |
| 609 | environmentId, | |
| 610 | input: { | |
| 611 | threadId: request.threadId, | |
| 612 | tabId: ready.tabId, | |
| 613 | viewport: setting, | |
| 614 | }, | |
| 615 | }); | |
| 616 | if (result._tag === "Failure") { | |
| 617 | return raiseAtomCommandFailure(result); | |
| 618 | } | |
| 619 | updatePreviewServerSnapshot(threadRef, result.value); | |
| 620 | return { | |
| 621 | previousSetting, | |
| 622 | serverEpoch: operationState.serverEpoch, | |
| 623 | }; | |
| 624 | }); | |
| 625 | let viewport: PreviewRenderedViewportSize; | |
| 626 | try { | |
| 627 | viewport = await waitForRenderedViewport( | |
| 628 | threadRef, | |
| 629 | ready.tabId, | |
| 630 | ready.runtimeTabId, | |
| 631 | setting, | |
| 632 | input.timeoutMs ?? request.timeoutMs, | |
| 633 | { | |
| 634 | requestId: request.requestId, | |
| 635 | operation: request.operation, | |
| 636 | environmentId, | |
| 637 | threadId: request.threadId, | |
| 638 | }, | |
| 639 | ); | |
| 640 | } catch (cause) { | |
| 641 | await runBrowserViewportMutation(ready.runtimeTabId, async () => { | |
| 642 | const latestState = readThreadPreviewState(threadRef); | |
| 643 | const latestSetting = | |
| 644 | latestState.sessions[ready.tabId]?.viewport ?? FILL_PREVIEW_VIEWPORT; | |
| 645 | if ( | |
| 646 | shouldRollbackPreviewViewport( | |
| 647 | applied.previousSetting, | |
| 648 | setting, | |
| 649 | latestSetting, | |
| 650 | applied.serverEpoch, | |
| 651 | latestState.serverEpoch, | |
| 652 | ) | |
| 653 | ) { | |
| 654 | const rollback = await resize({ | |
| 655 | environmentId, | |
| 656 | input: { | |
| 657 | threadId: request.threadId, | |
| 658 | tabId: ready.tabId, | |
| 659 | viewport: applied.previousSetting, | |
| 660 | }, | |
| 661 | }); | |
| 662 | if (rollback._tag !== "Failure") { | |
| 663 | updatePreviewServerSnapshot(threadRef, rollback.value); | |
| 664 | } | |
| 665 | } | |
| 666 | }); | |
| 667 | throw cause; | |
| 668 | } | |
| 669 | return { | |
| 670 | tabId: ready.tabId, | |
| 671 | setting, | |
| 672 | viewport, | |
| 673 | } satisfies PreviewAutomationResizeResult; | |
| 674 | } | |
| 675 | case "setColorScheme": { | |
| 676 | const ready = await requireReadyTab(); | |
| 677 | const input = request.input as PreviewAutomationSetColorSchemeInput; | |
| 678 | await ready.bridge.setColorScheme(ready.runtimeTabId, input.colorScheme); | |
| 679 | return { | |
| 680 | tabId: ready.tabId, | |
| 681 | colorScheme: input.colorScheme, | |
| 682 | } satisfies PreviewAutomationSetColorSchemeResult; | |
| 683 | } | |
| 684 | case "snapshot": { | |
| 685 | const ready = await requireReadyTab(); | |
| 686 | return await ready.bridge.automation.snapshot(ready.runtimeTabId); | |
| 687 | } | |
| 688 | case "click": { | |
| 689 | const ready = await requireReadyTab(); | |
| 690 | return await runPreviewClickKeepingHostFocus(ready.runtimeTabId, () => | |
| 691 | ready.bridge.automation.click( | |
| 692 | ready.runtimeTabId, | |
| 693 | request.input as Parameters<typeof ready.bridge.automation.click>[1], | |
| 694 | ), | |
| 695 | ); | |
| 696 | } | |
| 697 | case "type": { | |
| 698 | const ready = await requireReadyTab(); | |
| 699 | return await ready.bridge.automation.type( | |
| 700 | ready.runtimeTabId, | |
| 701 | request.input as Parameters<typeof ready.bridge.automation.type>[1], | |
| 702 | ); | |
| 703 | } | |
| 704 | case "press": { | |
| 705 | const ready = await requireReadyTab(); | |
| 706 | return await ready.bridge.automation.press( | |
| 707 | ready.runtimeTabId, | |
| 708 | request.input as Parameters<typeof ready.bridge.automation.press>[1], | |
| 709 | ); | |
| 710 | } | |
| 711 | case "scroll": { | |
| 712 | const ready = await requireReadyTab(); | |
| 713 | return await ready.bridge.automation.scroll( | |
| 714 | ready.runtimeTabId, | |
| 715 | request.input as Parameters<typeof ready.bridge.automation.scroll>[1], | |
| 716 | ); | |
| 717 | } | |
| 718 | case "evaluate": { | |
| 719 | const ready = await requireReadyTab(); | |
| 720 | return await ready.bridge.automation.evaluate( | |
| 721 | ready.runtimeTabId, | |
| 722 | request.input as Parameters<typeof ready.bridge.automation.evaluate>[1], | |
| 723 | ); | |
| 724 | } | |
| 725 | case "waitFor": { | |
| 726 | const ready = await requireReadyTab(); | |
| 727 | return await ready.bridge.automation.waitFor( | |
| 728 | ready.runtimeTabId, | |
| 729 | request.input as Parameters<typeof ready.bridge.automation.waitFor>[1], | |
| 730 | ); | |
| 731 | } | |
| 732 | case "recordingStart": { | |
| 733 | const ready = await requireReadyTab(); | |
| 734 | const startedAt = await startBrowserRecording( | |
| 735 | ready.runtimeTabId, | |
| 736 | threadRef, | |
| 737 | ready.tabId, | |
| 738 | ); | |
| 739 | return { | |
| 740 | tabId: ready.tabId, | |
| 741 | recording: true, | |
| 742 | startedAt, | |
| 743 | }; | |
| 744 | } | |
| 745 | case "recordingStop": { | |
| 746 | const activeRecordings = readActiveBrowserRecordingTargets(threadRef); | |
| 747 | const activeTabIds = new Set( | |
| 748 | activeRecordings.map((recording) => recording.serverTabId), | |
| 749 | ); | |
| 750 | const stopTabId = resolveBrowserRecordingStopTarget( | |
| 751 | activeTabIds, | |
| 752 | tabId, | |
| 753 | request.tabIdExplicit ? request.tabId : undefined, | |
| 754 | ); | |
| 755 | tabId = stopTabId ?? tabId; | |
| 756 | const stopRuntimeTabId = | |
| 757 | activeRecordings.find((recording) => recording.serverTabId === stopTabId) | |
| 758 | ?.runtimeTabId ?? null; | |
| 759 | const transferToEnvironment = | |
| 760 | typeof request.input === "object" && | |
| 761 | request.input !== null && | |
| 762 | "transferToEnvironment" in request.input && | |
| 763 | request.input.transferToEnvironment === true; | |
| 764 | const artifact = stopRuntimeTabId | |
| 765 | ? transferToEnvironment | |
| 766 | ? await stopBrowserRecordingForUpload(stopRuntimeTabId, (saved, blob) => | |
| 767 | uploadBrowserRecording(threadRef, saved, blob, hostDeadlineMs), | |
| 768 | ) | |
| 769 | : await stopBrowserRecording(stopRuntimeTabId) | |
| 770 | : null; | |
| 771 | if (!artifact || !stopTabId) { | |
| 772 | return raisePreviewAutomationHostError( | |
| 773 | new PreviewAutomationRecordingNotActiveError({ | |
| 774 | requestId: request.requestId, | |
| 775 | environmentId, | |
| 776 | threadId: request.threadId, | |
| 777 | tabId, | |
| 778 | }), | |
| 779 | ); | |
| 780 | } | |
| 781 | return { | |
| 782 | ...artifact, | |
| 783 | tabId: stopTabId, | |
| 784 | }; | |
| 785 | } | |
| 786 | } | |
| 787 | } catch (cause) { | |
| 788 | throw PreviewAutomationOperationError.fromCause({ | |
| 789 | requestId: request.requestId, | |
| 790 | operation: request.operation, | |
| 791 | environmentId, | |
| 792 | threadId: request.threadId, | |
| 793 | tabId, | |
| 794 | cause, | |
| 795 | }); | |
| 796 | } finally { | |
| 797 | browserActivity.release?.(); | |
| 798 | } | |
| 799 | }, | |
| 800 | [environmentId, listPreviews, open, registry, resize], | |
| 801 | ); | |
| 802 | const [requestHandlerAtom] = useState(() => Atom.make({ handle: handleRequest })); | |
| 803 | const setRequestHandler = useAtomSet(requestHandlerAtom); | |
| 804 | useEffect(() => { | |
| 805 | setRequestHandler({ handle: handleRequest }); | |
| 806 | }, [handleRequest, setRequestHandler]); | |
| 807 | ||
| 808 | const automationRequestConsumerAtom = useMemo( | |
| 809 | () => | |
| 810 | createPreviewAutomationRequestConsumerAtom({ | |
| 811 | requestsAtom: automationRequestsAtom, | |
| 812 | clientId: automationClientId, | |
| 813 | connectionAtom: automationConnectionAtom, | |
| 814 | environmentId, | |
| 815 | requestHandlerAtom, | |
| 816 | respond: (response) => | |
| 817 | respondToAutomation({ | |
| 818 | environmentId, | |
| 819 | input: response, | |
| 820 | }), | |
| 821 | label: `preview:automation-host:${environmentId}:${automationClientId}`, | |
| 822 | }), | |
| 823 | [ | |
| 824 | automationClientId, | |
| 825 | automationConnectionAtom, | |
| 826 | automationRequestsAtom, | |
| 827 | requestHandlerAtom, | |
| 828 | respondToAutomation, | |
| 829 | environmentId, | |
| 830 | ], | |
| 831 | ); | |
| 832 | useAtomValue(automationRequestConsumerAtom); | |
| 833 | ||
| 834 | useEffect(() => { | |
| 835 | const report = () => { | |
| 836 | if (!automationConnectionId) return; | |
| 837 | const input = { | |
| 838 | clientId: automationClientId, | |
| 839 | environmentId, | |
| 840 | connectionId: automationConnectionId, | |
| 841 | focused: document.hasFocus() && document.visibilityState === "visible", | |
| 842 | liveTabs: liveTabs.map((tab) => ({ | |
| 843 | ...tab, | |
| 844 | visible: tab.visible && document.visibilityState === "visible", | |
| 845 | })), | |
| 846 | }; | |
| 847 | const reportKey = JSON.stringify(input); | |
| 848 | if (lastFocusReportRef.current === reportKey) return; | |
| 849 | lastFocusReportRef.current = reportKey; | |
| 850 | void focusAutomationHost({ environmentId, input }).then((result) => { | |
| 851 | if (result._tag === "Failure" && lastFocusReportRef.current === reportKey) { | |
| 852 | lastFocusReportRef.current = null; | |
| 853 | } | |
| 854 | }); | |
| 855 | }; | |
| 856 | report(); | |
| 857 | window.addEventListener("focus", report); | |
| 858 | window.addEventListener("blur", report); | |
| 859 | document.addEventListener("visibilitychange", report); | |
| 860 | return () => { | |
| 861 | window.removeEventListener("focus", report); | |
| 862 | window.removeEventListener("blur", report); | |
| 863 | document.removeEventListener("visibilitychange", report); | |
| 864 | }; | |
| 865 | }, [automationClientId, automationConnectionId, environmentId, focusAutomationHost, liveTabs]); | |
| 866 | ||
| 867 | return null; | |
| 868 | } |
apps/web/src/components/preview/PreviewMoreMenu.tsx+69-96| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | import type { DesktopPreviewColorScheme, EnvironmentId } from "@t3tools/contracts"; | |
| 3 | import type { DesktopPreviewColorScheme } from "@t3tools/contracts"; | |
| 4 | 4 | import { Minus, MoreVertical, Plus as PlusIcon, RotateCcw } from "lucide-react"; |
| 5 | 5 | |
| 6 | 6 | import { Button } from "~/components/ui/button"; |
| ... | ... | @@ -20,8 +20,6 @@ import { |
| 20 | 20 | } from "~/components/ui/menu"; |
| 21 | 21 | import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; |
| 22 | 22 | |
| 23 | import { previewBridge } from "./previewBridge"; | |
| 24 | ||
| 25 | 23 | const COLOR_SCHEME_OPTIONS: ReadonlyArray<{ |
| 26 | 24 | value: DesktopPreviewColorScheme; |
| 27 | 25 | label: string; |
| ... | ... | @@ -31,18 +29,32 @@ const COLOR_SCHEME_OPTIONS: ReadonlyArray<{ |
| 31 | 29 | { value: "dark", label: "Dark" }, |
| 32 | 30 | ]; |
| 33 | 31 | |
| 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 | */ | |
| 37 | export 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 | ||
| 34 | 51 | interface 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; | |
| 43 | 55 | /** Current zoom factor as a number (1.0 = 100%). */ |
| 44 | 56 | zoomFactor: number; |
| 45 | /** Emulated `prefers-color-scheme` for the guest page. */ | |
| 57 | /** Emulated `prefers-color-scheme` for the page. */ | |
| 46 | 58 | colorScheme: DesktopPreviewColorScheme; |
| 47 | 59 | /** Fixed viewport modes expose the device toolbar and resize rails. */ |
| 48 | 60 | deviceToolbarVisible: boolean; |
| ... | ... | @@ -50,90 +62,66 @@ interface Props { |
| 50 | 62 | onToggleDeviceToolbar: () => void; |
| 51 | 63 | /** Whether the separate native always-on-top preview window is open. */ |
| 52 | 64 | nativePictureInPicture: boolean; |
| 53 | /** Toggles the optional native always-on-top preview window. */ | |
| 54 | onNativePictureInPicture: () => void; | |
| 55 | /** Environment the tab belongs to; scopes storage clearing to its partitions. */ | |
| 56 | environmentId: EnvironmentId; | |
| 57 | /** Profile the tab was opened under, if the server recorded one. */ | |
| 58 | /** | |
| 59 | * Required: the IPC layer reads an absent profile as "every profile", so a | |
| 60 | * tab whose own profile is unknown must resolve the default before it gets | |
| 61 | * here rather than passing the gap along. | |
| 62 | */ | |
| 63 | profileId: string; | |
| 64 | 65 | /** Profile display name, shown so the menu says which data is being cleared. */ |
| 65 | 66 | profileName: string | undefined; |
| 66 | 67 | } |
| 67 | 68 | |
| 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 | */ | |
| 69 | const 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. */ | |
| 73 | 85 | export function PreviewMoreMenu({ |
| 74 | tabId, | |
| 75 | hasWebContents, | |
| 86 | enabled, | |
| 87 | actions, | |
| 76 | 88 | zoomFactor, |
| 77 | 89 | colorScheme, |
| 78 | 90 | deviceToolbarVisible, |
| 79 | 91 | onToggleDeviceToolbar, |
| 80 | 92 | nativePictureInPicture, |
| 81 | onNativePictureInPicture, | |
| 82 | environmentId, | |
| 83 | profileId, | |
| 84 | 93 | profileName, |
| 85 | 94 | }: Props) { |
| 86 | if (!previewBridge) return null; | |
| 87 | const bridge = previewBridge; | |
| 88 | const tabDisabled = !tabId || !hasWebContents; | |
| 89 | const callTab = (op: (tabId: string) => Promise<void>) => () => { | |
| 90 | if (!tabId) return; | |
| 91 | void op(tabId).catch(() => undefined); | |
| 92 | }; | |
| 93 | ||
| 94 | 95 | const zoomLabel = `${Math.round(zoomFactor * 100)}%`; |
| 96 | const disabled = !enabled; | |
| 95 | 97 | return ( |
| 96 | 98 | <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 /> | |
| 111 | 100 | <MenuPopup align="end" sideOffset={6}> |
| 112 | <MenuItem onClick={callTab(bridge.hardReload)} disabled={tabDisabled}> | |
| 101 | <MenuItem onClick={actions.hardReload} disabled={disabled}> | |
| 113 | 102 | Hard reload |
| 114 | 103 | </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}> | |
| 124 | 117 | {deviceToolbarVisible ? "Hide device toolbar" : "Show device toolbar"} |
| 125 | 118 | </MenuItem> |
| 126 | 119 | <MenuSub> |
| 127 | <MenuSubTrigger disabled={tabDisabled}>Appearance</MenuSubTrigger> | |
| 120 | <MenuSubTrigger disabled={disabled}>Appearance</MenuSubTrigger> | |
| 128 | 121 | <MenuSubPopup> |
| 129 | 122 | <MenuRadioGroup |
| 130 | 123 | 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)} | |
| 137 | 125 | > |
| 138 | 126 | {COLOR_SCHEME_OPTIONS.map((option) => ( |
| 139 | 127 | <MenuRadioItem key={option.value} value={option.value}> |
| ... | ... | @@ -152,7 +140,7 @@ export function PreviewMoreMenu({ |
| 152 | 140 | closeOnClick={false} |
| 153 | 141 | onClick={(event: React.MouseEvent) => event.preventDefault()} |
| 154 | 142 | className="justify-between" |
| 155 | disabled={tabDisabled} | |
| 143 | disabled={disabled} | |
| 156 | 144 | > |
| 157 | 145 | <span>Zoom</span> |
| 158 | 146 | <span className="flex items-center gap-1"> |
| ... | ... | @@ -160,9 +148,9 @@ export function PreviewMoreMenu({ |
| 160 | 148 | variant="outline" |
| 161 | 149 | size="icon-xs" |
| 162 | 150 | type="button" |
| 163 | onClick={callTab(bridge.zoomOut)} | |
| 151 | onClick={actions.zoomOut} | |
| 164 | 152 | aria-label="Zoom out" |
| 165 | disabled={tabDisabled} | |
| 153 | disabled={disabled} | |
| 166 | 154 | > |
| 167 | 155 | <Minus /> |
| 168 | 156 | </Button> |
| ... | ... | @@ -173,9 +161,9 @@ export function PreviewMoreMenu({ |
| 173 | 161 | variant="outline" |
| 174 | 162 | size="icon-xs" |
| 175 | 163 | type="button" |
| 176 | onClick={callTab(bridge.zoomIn)} | |
| 164 | onClick={actions.zoomIn} | |
| 177 | 165 | aria-label="Zoom in" |
| 178 | disabled={tabDisabled} | |
| 166 | disabled={disabled} | |
| 179 | 167 | > |
| 180 | 168 | <PlusIcon /> |
| 181 | 169 | </Button> |
| ... | ... | @@ -183,9 +171,9 @@ export function PreviewMoreMenu({ |
| 183 | 171 | variant="ghost" |
| 184 | 172 | size="icon-xs" |
| 185 | 173 | type="button" |
| 186 | onClick={callTab(bridge.resetZoom)} | |
| 174 | onClick={actions.resetZoom} | |
| 187 | 175 | aria-label="Reset zoom" |
| 188 | disabled={tabDisabled} | |
| 176 | disabled={disabled} | |
| 189 | 177 | > |
| 190 | 178 | <RotateCcw /> |
| 191 | 179 | </Button> |
| ... | ... | @@ -199,11 +187,6 @@ export function PreviewMoreMenu({ |
| 199 | 187 | at open and nothing else in the chrome shows it. |
| 200 | 188 | */} |
| 201 | 189 | <MenuGroup> |
| 202 | {/* | |
| 203 | The heading carries the profile so the actions below can keep | |
| 204 | fixed-length labels: repeating a name of up to 48 characters in | |
| 205 | each one drove the popup far past its width. | |
| 206 | */} | |
| 207 | 190 | {profileName ? ( |
| 208 | 191 | // Truncation needs a block box: `text-overflow` on an inline child |
| 209 | 192 | // never applies and a long name would push the popup past its width. |
| ... | ... | @@ -211,18 +194,8 @@ export function PreviewMoreMenu({ |
| 211 | 194 | <span className="block truncate">Profile: {profileName}</span> |
| 212 | 195 | </MenuGroupLabel> |
| 213 | 196 | ) : 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> | |
| 226 | 199 | </MenuGroup> |
| 227 | 200 | </MenuPopup> |
| 228 | 201 | </Menu> |
apps/web/src/components/preview/PreviewPanel.tsx+2-2| ... | ... | @@ -3,7 +3,7 @@ |
| 3 | 3 | import type { PreviewAnnotationPayload, ScopedThreadRef } from "@t3tools/contracts"; |
| 4 | 4 | |
| 5 | 5 | import type { ComposerImageAttachment } from "~/composerDraftStore"; |
| 6 | import { isPreviewSupportedInRuntime } from "~/previewStateStore"; | |
| 6 | import { usePreviewAvailable } from "~/browser/previewRuntime"; | |
| 7 | 7 | |
| 8 | 8 | import { PreviewPanelShell, type PreviewPanelMode } from "./PreviewPanelShell"; |
| 9 | 9 | import { PreviewView } from "./PreviewView"; |
| ... | ... | @@ -28,7 +28,7 @@ export function PreviewPanel({ |
| 28 | 28 | visible, |
| 29 | 29 | onSendAnnotation, |
| 30 | 30 | }: Props) { |
| 31 | if (!isPreviewSupportedInRuntime()) { | |
| 31 | if (!usePreviewAvailable(threadRef.environmentId)) { | |
| 32 | 32 | return ( |
| 33 | 33 | <PreviewPanelShell mode={mode}> |
| 34 | 34 | <div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 p-8 text-center"> |
apps/web/src/components/preview/PreviewView.test.tsx+4-4| ... | ... | @@ -221,14 +221,14 @@ vi.mock("./PreviewChromeRow", () => ({ |
| 221 | 221 | onPictureInPicture?: () => void; |
| 222 | 222 | pictureInPicture?: boolean; |
| 223 | 223 | trailingActions?: { |
| 224 | props: { onNativePictureInPicture?: () => void }; | |
| 224 | props: { actions?: { toggleNativePictureInPicture?: () => void } }; | |
| 225 | 225 | }; |
| 226 | 226 | }) => { |
| 227 | 227 | mocks.submittedUrl = props.onSubmit; |
| 228 | 228 | mocks.toggleAnnotation = props.onPickElement ?? null; |
| 229 | 229 | mocks.togglePictureInPicture = props.onPictureInPicture ?? null; |
| 230 | 230 | mocks.toggleNativePictureInPicture = |
| 231 | props.trailingActions?.props.onNativePictureInPicture ?? null; | |
| 231 | props.trailingActions?.props.actions?.toggleNativePictureInPicture ?? null; | |
| 232 | 232 | mocks.pictureInPicturePressed = props.pictureInPicture ?? false; |
| 233 | 233 | return null; |
| 234 | 234 | }, |
| ... | ... | @@ -241,8 +241,8 @@ vi.mock("./PreviewEmptyState", () => ({ |
| 241 | 241 | }, |
| 242 | 242 | })); |
| 243 | 243 | vi.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; | |
| 246 | 246 | return null; |
| 247 | 247 | }, |
| 248 | 248 | })); |
apps/web/src/components/preview/PreviewView.tsx+237-41| ... | ... | @@ -11,6 +11,9 @@ import { |
| 11 | 11 | type PreviewAnnotationPayload, |
| 12 | 12 | type PreviewViewportSetting, |
| 13 | 13 | type ScopedThreadRef, |
| 14 | DEFAULT_PREVIEW_ZOOM_FACTOR, | |
| 15 | PREVIEW_ZOOM_LEVELS, | |
| 16 | type PreviewAdjustInput, | |
| 14 | 17 | } from "@t3tools/contracts"; |
| 15 | 18 | import { normalizePreviewUrl } from "@t3tools/shared/preview"; |
| 16 | 19 | import { useCallback, useEffect, useRef, useState } from "react"; |
| ... | ... | @@ -46,12 +49,14 @@ import { subscribePreviewAction } from "./previewActionBus"; |
| 46 | 49 | import { openPreviewSession } from "./openPreviewSession"; |
| 47 | 50 | import { PreviewChromeRow } from "./PreviewChromeRow"; |
| 48 | 51 | import { PreviewEmptyState } from "./PreviewEmptyState"; |
| 49 | import { PreviewMoreMenu } from "./PreviewMoreMenu"; | |
| 52 | import { PreviewMoreMenu, type PreviewMoreMenuActions } from "./PreviewMoreMenu"; | |
| 50 | 53 | import { |
| 51 | 54 | commitBrowserViewportChange, |
| 52 | 55 | subscribeBrowserViewportChange, |
| 53 | 56 | } from "~/browser/browserViewportActions"; |
| 54 | 57 | import { browserResponsiveViewportForToggle, useBrowserDefaults } from "~/browser/browserDefaults"; |
| 58 | import { BrowserDeviceToolbar } from "~/browser/BrowserDeviceToolbar"; | |
| 59 | import { BROWSER_DEVICE_TOOLBAR_HEIGHT } from "~/browser/browserViewportLayout"; | |
| 55 | 60 | import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; |
| 56 | 61 | import { BrowserSettingsReadError } from "~/browser/openFileInPreview"; |
| 57 | 62 | import { PreviewUnreachable } from "./PreviewUnreachable"; |
| ... | ... | @@ -59,6 +64,9 @@ import { revealInFileExplorerLabel } from "./fileExplorerLabel"; |
| 59 | 64 | import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic"; |
| 60 | 65 | import { Badge } from "~/components/ui/badge"; |
| 61 | 66 | import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot"; |
| 67 | import { useRendersServerTabNatively } from "~/browser/previewRuntime"; | |
| 68 | import { ServerBrowserSurface, type ServerBrowserHandle } from "~/browser/ServerBrowserSurface"; | |
| 69 | import { cn } from "~/lib/utils"; | |
| 62 | 70 | import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore"; |
| 63 | 71 | import { usePreviewSession } from "./usePreviewSession"; |
| 64 | 72 | import { ZoomIndicator } from "./ZoomIndicator"; |
| ... | ... | @@ -129,6 +137,7 @@ export function PreviewView({ |
| 129 | 137 | : null; |
| 130 | 138 | const open = useAtomCommand(previewEnvironment.open); |
| 131 | 139 | const resize = useAtomCommand(previewEnvironment.resize, "preview viewport resize"); |
| 140 | const adjust = useAtomCommand(previewEnvironment.adjust, "preview appearance or zoom"); | |
| 132 | 141 | |
| 133 | 142 | usePreviewSession(threadRef); |
| 134 | 143 | |
| ... | ... | @@ -150,6 +159,31 @@ export function PreviewView({ |
| 150 | 159 | : findActiveBrowserRecordingRuntimeTabId(threadRef, tabId) |
| 151 | 160 | : null; |
| 152 | 161 | const snapshot = tabId ? (previewState.sessions[tabId] ?? null) : null; |
| 162 | // Server tabs run in the environment's browser and stream to any client, except the | |
| 163 | // desktop app's own server's tabs, which render here natively while the server drives them. | |
| 164 | const nativeServerTab = useRendersServerTabNatively(threadRef.environmentId, snapshot); | |
| 165 | const isServerTab = snapshot?.runtime === "server" && !nativeServerTab; | |
| 166 | /** The server owns this tab's appearance, zoom, and size, whoever renders it. */ | |
| 167 | const serverOwnsRendering = snapshot?.runtime === "server"; | |
| 168 | const serverSurfaceRef = useRef<ServerBrowserHandle | null>(null); | |
| 169 | const [serverAspectRatioLocked, setServerAspectRatioLocked] = useState(false); | |
| 170 | const serverBodyRef = useRef<HTMLDivElement | null>(null); | |
| 171 | const [serverToolbarWidth, setServerToolbarWidth] = useState(0); | |
| 172 | // The streamed tab's device toolbar spans the panel; only measured while it shows. | |
| 173 | const serverToolbarShown = | |
| 174 | snapshot?.runtime === "server" && (snapshot.viewport ?? FILL_PREVIEW_VIEWPORT)._tag !== "fill"; | |
| 175 | useEffect(() => { | |
| 176 | const element = serverBodyRef.current; | |
| 177 | if (!element || !serverToolbarShown) return; | |
| 178 | const observer = new ResizeObserver(([entry]) => | |
| 179 | setServerToolbarWidth(Math.max(1, Math.round(entry?.contentRect.width ?? 0))), | |
| 180 | ); | |
| 181 | observer.observe(element); | |
| 182 | return () => observer.disconnect(); | |
| 183 | }, [serverToolbarShown]); | |
| 184 | const [serverControlledTabId, setServerControlledTabId] = useState<string | null>(null); | |
| 185 | const serverInputDisabled = isServerTab && serverControlledTabId !== runtimeTabId; | |
| 186 | const [serverFrameTabId, setServerFrameTabId] = useState<string | null>(null); | |
| 153 | 187 | const desktopOverlay = tabId ? (previewState.desktopByTabId[tabId] ?? null) : null; |
| 154 | 188 | const navStatus = snapshot?.navStatus ?? { _tag: "Idle" as const }; |
| 155 | 189 | const url = navStatus._tag === "Idle" ? "" : navStatus.url; |
| ... | ... | @@ -159,6 +193,8 @@ export function PreviewView({ |
| 159 | 193 | const refreshDisabled = navStatus._tag === "Idle"; |
| 160 | 194 | const isUnreachable = navStatus._tag === "LoadFailed"; |
| 161 | 195 | const showEmptyState = shouldShowPreviewEmptyState(snapshot); |
| 196 | const serverStreamPending = | |
| 197 | isServerTab && !showEmptyState && !isUnreachable && serverFrameTabId !== runtimeTabId; | |
| 162 | 198 | const controller = desktopOverlay?.controller ?? "none"; |
| 163 | 199 | const viewport = snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT; |
| 164 | 200 | const browserDefaults = useBrowserDefaults(); |
| ... | ... | @@ -187,6 +223,12 @@ export function PreviewView({ |
| 187 | 223 | |
| 188 | 224 | const navigateToResolvedUrl = useCallback( |
| 189 | 225 | async (resolvedUrl: string) => { |
| 226 | if (isServerTab && serverSurfaceRef.current) { | |
| 227 | if (serverInputDisabled) return false; | |
| 228 | serverSurfaceRef.current.navigate(resolvedUrl); | |
| 229 | rememberPreviewUrl(threadRef, resolvedUrl); | |
| 230 | return true; | |
| 231 | } | |
| 190 | 232 | if (runtimeTabId && previewBridge) { |
| 191 | 233 | // The bridge mirrors the resolved URL back to the server. |
| 192 | 234 | await previewBridge.navigate(runtimeTabId, resolvedUrl); |
| ... | ... | @@ -206,7 +248,7 @@ export function PreviewView({ |
| 206 | 248 | } |
| 207 | 249 | return result._tag === "Success"; |
| 208 | 250 | }, |
| 209 | [open, runtimeTabId, threadRef], | |
| 251 | [isServerTab, open, runtimeTabId, serverInputDisabled, threadRef], | |
| 210 | 252 | ); |
| 211 | 253 | |
| 212 | 254 | const handleSubmitUrl = useCallback( |
| ... | ... | @@ -226,7 +268,11 @@ export function PreviewView({ |
| 226 | 268 | const handleOpenServerUrl = useCallback( |
| 227 | 269 | async (next: string) => { |
| 228 | 270 | try { |
| 229 | const resolved = resolveDiscoveredServerUrl(threadRef.environmentId, next); | |
| 271 | // A server tab's browser runs on the environment, where loopback is already right. | |
| 272 | const resolved = | |
| 273 | isServerTab || !previewBridge | |
| 274 | ? normalizePreviewUrl(next) | |
| 275 | : resolveDiscoveredServerUrl(threadRef.environmentId, next); | |
| 230 | 276 | if (await navigateToResolvedUrl(resolved)) { |
| 231 | 277 | recordVisitForThread(threadRef, next); |
| 232 | 278 | } |
| ... | ... | @@ -234,24 +280,68 @@ export function PreviewView({ |
| 234 | 280 | // Server-side `failed` event renders the unreachable view. |
| 235 | 281 | } |
| 236 | 282 | }, |
| 237 | [navigateToResolvedUrl, threadRef], | |
| 283 | [isServerTab, navigateToResolvedUrl, threadRef], | |
| 238 | 284 | ); |
| 239 | 285 | |
| 240 | 286 | 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 | ); | |
| 243 | 330 | |
| 244 | 331 | 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]); | |
| 247 | 335 | |
| 248 | 336 | 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]); | |
| 251 | 340 | |
| 252 | 341 | 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]); | |
| 255 | 345 | |
| 256 | 346 | const handleViewportChange = useCallback( |
| 257 | 347 | async (nextViewport: PreviewViewportSetting) => { |
| ... | ... | @@ -290,7 +380,7 @@ export function PreviewView({ |
| 290 | 380 | browserResponsiveViewportForToggle({ |
| 291 | 381 | defaults: browserDefaults, |
| 292 | 382 | panelRect, |
| 293 | zoomFactor: desktopOverlay?.zoomFactor, | |
| 383 | zoomFactor: serverOwnsRendering ? serverZoomFactor : desktopOverlay?.zoomFactor, | |
| 294 | 384 | }), |
| 295 | 385 | ).catch(() => undefined); |
| 296 | 386 | }; |
| ... | ... | @@ -301,12 +391,14 @@ export function PreviewView({ |
| 301 | 391 | }, [handleViewportChange, runtimeTabId]); |
| 302 | 392 | |
| 303 | 393 | 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]); | |
| 306 | 397 | |
| 307 | 398 | 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]); | |
| 310 | 402 | |
| 311 | 403 | const handleOpenInBrowser = useCallback(() => { |
| 312 | 404 | if (!localApi || !url) return; |
| ... | ... | @@ -323,6 +415,53 @@ export function PreviewView({ |
| 323 | 415 | useRightPanelStore.getState().close(threadRef); |
| 324 | 416 | }, [miniPlayerTabId, tabId, threadRef]); |
| 325 | 417 | |
| 418 | /** | |
| 419 | * The menu's actions. A server tab, streamed or rendered natively here, runs | |
| 420 | * them through its environment, so every client and agent sees one state. | |
| 421 | * Opening DevTools and a separate window need the desktop's own page. | |
| 422 | */ | |
| 423 | const desktopCall = (op: ((tabId: string) => Promise<void>) | undefined) => () => { | |
| 424 | if (op && runtimeTabId) void op(runtimeTabId).catch(() => undefined); | |
| 425 | }; | |
| 426 | const moreMenuActions: PreviewMoreMenuActions | null = serverOwnsRendering | |
| 427 | ? { | |
| 428 | hardReload: () => void adjustServerTab({ hardReload: true }), | |
| 429 | setColorScheme: (colorScheme) => void adjustServerTab({ colorScheme }), | |
| 430 | zoomIn: handleZoomIn, | |
| 431 | zoomOut: handleZoomOut, | |
| 432 | resetZoom: handleResetZoom, | |
| 433 | clearCookies: () => void adjustServerTab({ clear: "cookies" }), | |
| 434 | clearCache: () => void adjustServerTab({ clear: "cache" }), | |
| 435 | ...(nativeServerTab && previewBridge | |
| 436 | ? { | |
| 437 | openDevTools: desktopCall(previewBridge.openDevTools), | |
| 438 | toggleNativePictureInPicture: () => handleNativePictureInPicture(), | |
| 439 | } | |
| 440 | : {}), | |
| 441 | } | |
| 442 | : previewBridge | |
| 443 | ? { | |
| 444 | hardReload: desktopCall(previewBridge.hardReload), | |
| 445 | setColorScheme: (colorScheme) => { | |
| 446 | if (runtimeTabId) | |
| 447 | void previewBridge?.setColorScheme(runtimeTabId, colorScheme).catch(() => undefined); | |
| 448 | }, | |
| 449 | zoomIn: handleZoomIn, | |
| 450 | zoomOut: handleZoomOut, | |
| 451 | resetZoom: handleResetZoom, | |
| 452 | clearCookies: () => | |
| 453 | void previewBridge | |
| 454 | ?.clearCookies(threadRef.environmentId, activeProfileId) | |
| 455 | .catch(() => undefined), | |
| 456 | clearCache: () => | |
| 457 | void previewBridge | |
| 458 | ?.clearCache(threadRef.environmentId, activeProfileId) | |
| 459 | .catch(() => undefined), | |
| 460 | openDevTools: desktopCall(previewBridge.openDevTools), | |
| 461 | toggleNativePictureInPicture: () => handleNativePictureInPicture(), | |
| 462 | } | |
| 463 | : null; | |
| 464 | ||
| 326 | 465 | const handleNativePictureInPicture = useCallback(() => { |
| 327 | 466 | if (!previewBridge || !runtimeTabId) return; |
| 328 | 467 | const operation = desktopOverlay?.pictureInPicture |
| ... | ... | @@ -712,23 +851,30 @@ export function PreviewView({ |
| 712 | 851 | > |
| 713 | 852 | <PreviewChromeRow |
| 714 | 853 | 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} | |
| 719 | 859 | focusUrlNonce={focusUrlNonce} |
| 720 | 860 | onBack={handleBack} |
| 721 | 861 | onForward={handleForward} |
| 722 | 862 | onRefresh={handleRefresh} |
| 723 | 863 | onSubmit={(next) => void handleSubmitUrl(next)} |
| 724 | 864 | onOpenInBrowser={tabId ? handleOpenInBrowser : undefined} |
| 725 | onCapture={previewBridge && tabId ? handleCapture : undefined} | |
| 865 | // Capture, annotation, and the more menu drive the desktop webview, so | |
| 866 | // server tabs leave them out. Floating works for both. | |
| 867 | onCapture={previewBridge && tabId && !isServerTab ? handleCapture : undefined} | |
| 726 | 868 | captureDisabled={!desktopOverlay || isUnreachable} |
| 727 | 869 | recording={recordingRuntimeTabId !== null} |
| 728 | onPictureInPicture={previewBridge && tabId ? handlePictureInPicture : undefined} | |
| 870 | onPictureInPicture={ | |
| 871 | tabId && (isServerTab || previewBridge) ? handlePictureInPicture : undefined | |
| 872 | } | |
| 729 | 873 | 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} | |
| 732 | 878 | pickActive={pickActive} |
| 733 | 879 | // Disable when there's no tab (nothing to pick on) OR the page |
| 734 | 880 | // failed to load (a React overlay covers the webview, so the |
| ... | ... | @@ -758,32 +904,82 @@ export function PreviewView({ |
| 758 | 904 | ) : null |
| 759 | 905 | } |
| 760 | 906 | trailingActions={ |
| 761 | previewBridge ? ( | |
| 907 | moreMenuActions ? ( | |
| 762 | 908 | <PreviewMoreMenu |
| 763 | environmentId={threadRef.environmentId} | |
| 764 | profileId={activeProfileId} | |
| 909 | enabled={ | |
| 910 | runtimeTabId !== null && | |
| 911 | (serverOwnsRendering || (desktopOverlay?.hasWebContents ?? false)) | |
| 912 | } | |
| 913 | actions={moreMenuActions} | |
| 765 | 914 | 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 | } | |
| 770 | 923 | deviceToolbarVisible={viewport._tag !== "fill"} |
| 771 | 924 | onToggleDeviceToolbar={handleToggleDeviceToolbar} |
| 772 | 925 | nativePictureInPicture={desktopOverlay?.pictureInPicture ?? false} |
| 773 | onNativePictureInPicture={handleNativePictureInPicture} | |
| 774 | 926 | /> |
| 775 | 927 | ) : null |
| 776 | 928 | } |
| 777 | 929 | /> |
| 778 | 930 | |
| 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 | ) | |
| 787 | 983 | ) : null} |
| 788 | 984 | {showEmptyState ? ( |
| 789 | 985 | <PreviewEmptyState |
apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx+147-60| ... | ... | @@ -6,6 +6,7 @@ import { |
| 6 | 6 | type PointerEvent as ReactPointerEvent, |
| 7 | 7 | type ReactNode, |
| 8 | 8 | useCallback, |
| 9 | useEffect, | |
| 9 | 10 | useLayoutEffect, |
| 10 | 11 | useRef, |
| 11 | 12 | useState, |
| ... | ... | @@ -20,6 +21,15 @@ import { |
| 20 | 21 | import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore"; |
| 21 | 22 | import type { BrowserViewportResizeDirection } from "~/browser/browserViewportLayout"; |
| 22 | 23 | import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; |
| 24 | import { useRendersServerTabNatively } from "~/browser/previewRuntime"; | |
| 25 | import { type ServerBrowserHandle, ServerBrowserSurface } from "~/browser/ServerBrowserSurface"; | |
| 26 | import { | |
| 27 | closeServerPictureInPicture, | |
| 28 | openServerPictureInPicture, | |
| 29 | serverPictureInPictureKey, | |
| 30 | supportsServerPictureInPicture, | |
| 31 | useServerPictureInPictureKey, | |
| 32 | } from "~/browser/serverPictureInPicture"; | |
| 23 | 33 | import { Button } from "~/components/ui/button"; |
| 24 | 34 | import { toastManager } from "~/components/ui/toast"; |
| 25 | 35 | import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; |
| ... | ... | @@ -37,6 +47,7 @@ import { useDeviceState } from "~/state/device"; |
| 37 | 47 | |
| 38 | 48 | import { DeviceStreamView } from "../device/DeviceStreamView"; |
| 39 | 49 | import type { DeviceScreenSize } from "@t3tools/client-runtime/device/stream"; |
| 50 | import type { PreviewStreamViewport } from "@t3tools/client-runtime/preview/server-browser-stream"; | |
| 40 | 51 | import { previewBridge } from "./previewBridge"; |
| 41 | 52 | import { |
| 42 | 53 | clampPreviewMiniPlayerPosition, |
| ... | ... | @@ -52,12 +63,17 @@ import { |
| 52 | 63 | |
| 53 | 64 | interface PointerGesture { |
| 54 | 65 | readonly pointerId: number; |
| 66 | readonly pointerType: string; | |
| 55 | 67 | readonly pointerX: number; |
| 56 | 68 | readonly pointerY: number; |
| 57 | 69 | readonly frame: PreviewMiniPlayerFrame; |
| 58 | 70 | readonly direction: BrowserViewportResizeDirection | null; |
| 71 | moved: boolean; | |
| 59 | 72 | } |
| 60 | 73 | |
| 74 | // A touch that travels less than this is a tap on the handle, not a drag. | |
| 75 | const HANDLE_TAP_SLOP_PX = 6; | |
| 76 | ||
| 61 | 77 | interface Props { |
| 62 | 78 | readonly threadRef: ScopedThreadRef; |
| 63 | 79 | readonly miniPlayer: PreviewMiniPlayerState; |
| ... | ... | @@ -112,32 +128,54 @@ function BrowserMiniPlayer({ threadRef, tabId, miniPlayer }: Props & { readonly |
| 112 | 128 | const fittedSourceContent = useBrowserSurfaceStore( |
| 113 | 129 | (state) => state.byTabId[runtimeTabId]?.fittedSourceContent ?? null, |
| 114 | 130 | ); |
| 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 | ); | |
| 120 | 145 | |
| 121 | 146 | const openInPanel = () => { |
| 122 | 147 | usePreviewMiniPlayerStore.getState().close(threadRef); |
| 123 | 148 | useRightPanelStore.getState().openBrowser(threadRef, tabId); |
| 124 | 149 | }; |
| 125 | 150 | |
| 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) { | |
| 132 | 168 | toastManager.add({ |
| 133 | 169 | type: "error", |
| 134 | title: "Unable to update popped-out preview", | |
| 170 | title: serverTab ? "Unable to pop out preview" : "Unable to update popped-out preview", | |
| 135 | 171 | description: error instanceof Error ? error.message : "An error occurred.", |
| 136 | 172 | }); |
| 137 | }); | |
| 173 | } | |
| 138 | 174 | }; |
| 139 | 175 | |
| 140 | 176 | if (!snapshot) return null; |
| 177 | const poppedOut = serverTab ? serverPictureInPicture : Boolean(desktopOverlay?.pictureInPicture); | |
| 178 | const canPopOut = serverTab ? supportsServerPictureInPicture() : true; | |
| 141 | 179 | |
| 142 | 180 | return ( |
| 143 | 181 | <MiniPlayerShell |
| ... | ... | @@ -148,51 +186,68 @@ function BrowserMiniPlayer({ threadRef, tabId, miniPlayer }: Props & { readonly |
| 148 | 186 | recording={recording} |
| 149 | 187 | onOpenInPanel={openInPanel} |
| 150 | 188 | 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 | |
| 176 | 212 | } |
| 177 | 213 | > |
| 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 | } | |
| 196 | 251 | </MiniPlayerShell> |
| 197 | 252 | ); |
| 198 | 253 | } |
| ... | ... | @@ -285,6 +340,21 @@ function MiniPlayerShell({ |
| 285 | 340 | }) { |
| 286 | 341 | const canvas = useChatCanvas(); |
| 287 | 342 | const gestureRef = useRef<PointerGesture | null>(null); |
| 343 | // Touch has no hover, so tapping the handle toggles the pill instead. The | |
| 344 | // toggle waits for the tap's click, which would otherwise land on the pill. | |
| 345 | const [pillOpen, setPillOpen] = useState(false); | |
| 346 | const handleTappedRef = useRef(false); | |
| 347 | const handleRef = useRef<HTMLDivElement | null>(null); | |
| 348 | // The pill covers the handle, so a tap anywhere else dismisses it. | |
| 349 | useEffect(() => { | |
| 350 | if (!pillOpen) return; | |
| 351 | const dismiss = (event: PointerEvent) => { | |
| 352 | if (event.target instanceof Node && handleRef.current?.contains(event.target)) return; | |
| 353 | setPillOpen(false); | |
| 354 | }; | |
| 355 | document.addEventListener("pointerdown", dismiss, true); | |
| 356 | return () => document.removeEventListener("pointerdown", dismiss, true); | |
| 357 | }, [pillOpen]); | |
| 288 | 358 | const container = canvas?.container ?? null; |
| 289 | 359 | const obstacles = NO_PREVIEW_MINI_PLAYER_OBSTACLES; |
| 290 | 360 | const sourceKey = previewMiniPlayerSourceKey(miniPlayer.source); |
| ... | ... | @@ -326,10 +396,12 @@ function MiniPlayerShell({ |
| 326 | 396 | if (event.button !== 0 || !frame) return; |
| 327 | 397 | gestureRef.current = { |
| 328 | 398 | pointerId: event.pointerId, |
| 399 | pointerType: event.pointerType, | |
| 329 | 400 | pointerX: event.clientX, |
| 330 | 401 | pointerY: event.clientY, |
| 331 | 402 | frame, |
| 332 | 403 | direction, |
| 404 | moved: false, | |
| 333 | 405 | }; |
| 334 | 406 | event.currentTarget.setPointerCapture(event.pointerId); |
| 335 | 407 | event.preventDefault(); |
| ... | ... | @@ -340,6 +412,8 @@ function MiniPlayerShell({ |
| 340 | 412 | const gesture = gestureRef.current; |
| 341 | 413 | if (!gesture || gesture.pointerId !== event.pointerId || !container) return; |
| 342 | 414 | const delta = { x: event.clientX - gesture.pointerX, y: event.clientY - gesture.pointerY }; |
| 415 | if (!gesture.moved && Math.hypot(delta.x, delta.y) < HANDLE_TAP_SLOP_PX) return; | |
| 416 | gesture.moved = true; | |
| 343 | 417 | const store = usePreviewMiniPlayerStore.getState(); |
| 344 | 418 | if (gesture.direction === null) { |
| 345 | 419 | store.move( |
| ... | ... | @@ -366,8 +440,14 @@ function MiniPlayerShell({ |
| 366 | 440 | }; |
| 367 | 441 | |
| 368 | 442 | const endGesture = (event: ReactPointerEvent<HTMLElement>) => { |
| 369 | if (gestureRef.current?.pointerId !== event.pointerId) return; | |
| 443 | const gesture = gestureRef.current; | |
| 444 | if (gesture?.pointerId !== event.pointerId) return; | |
| 370 | 445 | gestureRef.current = null; |
| 446 | handleTappedRef.current = | |
| 447 | event.type === "pointerup" && | |
| 448 | gesture.direction === null && | |
| 449 | gesture.pointerType === "touch" && | |
| 450 | !gesture.moved; | |
| 371 | 451 | if (event.currentTarget.hasPointerCapture(event.pointerId)) { |
| 372 | 452 | event.currentTarget.releasePointerCapture(event.pointerId); |
| 373 | 453 | } |
| ... | ... | @@ -389,18 +469,25 @@ function MiniPlayerShell({ |
| 389 | 469 | }} |
| 390 | 470 | > |
| 391 | 471 | <div |
| 392 | className="group pointer-events-auto absolute z-[49] size-3 touch-none cursor-grab active:cursor-grabbing" | |
| 472 | ref={handleRef} | |
| 473 | data-pill-open={pillOpen ? "" : undefined} | |
| 474 | className="group pointer-events-auto absolute z-[49] size-3 touch-none cursor-grab active:cursor-grabbing pointer-coarse:-m-2.5 pointer-coarse:size-8" | |
| 393 | 475 | style={{ right: pillInset, top: pillInset }} |
| 394 | 476 | onPointerDown={(event) => beginGesture(event, null)} |
| 395 | 477 | onPointerMove={handlePointerMove} |
| 396 | 478 | onPointerUp={endGesture} |
| 397 | 479 | onPointerCancel={endGesture} |
| 480 | onClick={() => { | |
| 481 | if (!handleTappedRef.current) return; | |
| 482 | handleTappedRef.current = false; | |
| 483 | setPillOpen((open) => !open); | |
| 484 | }} | |
| 398 | 485 | > |
| 399 | 486 | <div |
| 400 | 487 | role={recording ? "status" : undefined} |
| 401 | 488 | aria-label={recording ? "Recording preview" : undefined} |
| 402 | 489 | aria-hidden={!recording} |
| 403 | className="absolute right-0 top-0 size-2 transition-opacity group-hover:opacity-0 group-focus-within:opacity-0" | |
| 490 | className="absolute right-0 top-0 size-2 transition-opacity group-hover:opacity-0 group-focus-within:opacity-0 group-data-pill-open:opacity-0 pointer-coarse:right-2.5 pointer-coarse:top-2.5" | |
| 404 | 491 | > |
| 405 | 492 | <span |
| 406 | 493 | className={cn( |
| ... | ... | @@ -411,7 +498,7 @@ function MiniPlayerShell({ |
| 411 | 498 | )} |
| 412 | 499 | /> |
| 413 | 500 | </div> |
| 414 | <div className="pointer-events-none absolute right-0 top-0 flex h-8 cursor-grab items-center gap-0.5 rounded-lg border border-border/80 bg-popover/92 p-0.5 opacity-0 shadow-lg/20 backdrop-blur-xl transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100 active:cursor-grabbing"> | |
| 501 | <div className="pointer-events-none absolute right-0 top-0 flex h-8 cursor-grab items-center gap-0.5 rounded-lg border border-border/80 bg-popover/92 p-0.5 opacity-0 shadow-lg/20 backdrop-blur-xl transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-data-pill-open:pointer-events-auto group-data-pill-open:opacity-100 active:cursor-grabbing pointer-coarse:right-2.5 pointer-coarse:top-2.5"> | |
| 415 | 502 | {recording ? ( |
| 416 | 503 | <span aria-hidden className="flex size-6 shrink-0 items-center justify-center"> |
| 417 | 504 | <span className="size-2 rounded-full bg-destructive motion-safe:animate-status-pulse" /> |
apps/web/src/components/preview/openDiscoveredPort.ts+6-1| ... | ... | @@ -9,13 +9,18 @@ import type { BrowserSettingsReadError, OpenPreviewMutation } from "~/browser/op |
| 9 | 9 | import { recordVisitForThread } from "~/browserHistoryStore"; |
| 10 | 10 | import { useRightPanelStore } from "~/rightPanelStore"; |
| 11 | 11 | import { openPreviewSession } from "./openPreviewSession"; |
| 12 | import { previewRuntimeFor } from "~/browser/previewRuntime"; | |
| 12 | 13 | |
| 13 | 14 | export async function openDiscoveredPort<E>(input: { |
| 14 | 15 | readonly threadRef: ScopedThreadRef; |
| 15 | 16 | readonly port: DiscoveredLocalServer; |
| 16 | 17 | readonly openPreview: OpenPreviewMutation<E>; |
| 17 | 18 | }): Promise<AtomCommandResult<void, E | BrowserSettingsReadError>> { |
| 18 | const resolvedUrl = resolveDiscoveredServerUrl(input.threadRef.environmentId, input.port.url); | |
| 19 | // A server tab runs on the environment, where loopback is already right. | |
| 20 | const resolvedUrl = | |
| 21 | previewRuntimeFor(input.threadRef.environmentId) === "server" | |
| 22 | ? input.port.url | |
| 23 | : resolveDiscoveredServerUrl(input.threadRef.environmentId, input.port.url); | |
| 19 | 24 | const result = await openPreviewSession({ |
| 20 | 25 | openPreview: input.openPreview, |
| 21 | 26 | threadRef: input.threadRef, |
apps/web/src/components/preview/openPreviewSession.ts+3| ... | ... | @@ -15,6 +15,7 @@ import { |
| 15 | 15 | resolveBrowserDefaults, |
| 16 | 16 | } from "~/browser/browserDefaults"; |
| 17 | 17 | import { BrowserSettingsReadError } from "~/browser/openFileInPreview"; |
| 18 | import { previewRuntimeFor } from "~/browser/previewRuntime"; | |
| 18 | 19 | import { applyPreviewServerSnapshot, rememberPreviewUrl } from "~/previewStateStore"; |
| 19 | 20 | |
| 20 | 21 | interface OpenPreviewSessionInput<E> { |
| ... | ... | @@ -41,6 +42,7 @@ export async function openPreviewSession<E>( |
| 41 | 42 | if (defaults instanceof BrowserSettingsReadError) { |
| 42 | 43 | return AsyncResult.failure(Cause.fail(defaults)); |
| 43 | 44 | } |
| 45 | const runtime = previewRuntimeFor(input.threadRef.environmentId); | |
| 44 | 46 | const result = await input.openPreview({ |
| 45 | 47 | environmentId: input.threadRef.environmentId, |
| 46 | 48 | input: { |
| ... | ... | @@ -48,6 +50,7 @@ export async function openPreviewSession<E>( |
| 48 | 50 | ...(input.url === undefined ? {} : { url: input.url }), |
| 49 | 51 | viewport: input.viewport ?? browserDefaultOpenViewport(defaults), |
| 50 | 52 | profileId: input.profileId ?? browserDefaultOpenProfileId(defaults), |
| 53 | ...(runtime === undefined ? {} : { runtime }), | |
| 51 | 54 | }, |
| 52 | 55 | }); |
| 53 | 56 | if (result._tag === "Failure") { |
apps/web/src/components/preview/openTerminalLinkInPreview.ts+5-2| ... | ... | @@ -9,8 +9,9 @@ import { |
| 9 | 9 | } from "~/browser/browserDefaults"; |
| 10 | 10 | import { isWebUrl, resolveBrowserLinkTargetPreference } from "~/browser/browserLinkTarget"; |
| 11 | 11 | import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; |
| 12 | import { isPreviewAvailableFor, previewRuntimeFor } from "~/browser/previewRuntime"; | |
| 12 | 13 | import { recordVisitForThread } from "~/browserHistoryStore"; |
| 13 | import { applyPreviewServerSnapshot, isPreviewSupportedInRuntime } from "~/previewStateStore"; | |
| 14 | import { applyPreviewServerSnapshot } from "~/previewStateStore"; | |
| 14 | 15 | import { useRightPanelStore } from "~/rightPanelStore"; |
| 15 | 16 | |
| 16 | 17 | const terminalLinkErrorContext = { |
| ... | ... | @@ -48,7 +49,7 @@ export async function openTerminalLinkInPreview<E>( |
| 48 | 49 | const supportsPreview = |
| 49 | 50 | !input.forceBrowser && |
| 50 | 51 | isWebUrl(input.url) && |
| 51 | isPreviewSupportedInRuntime() && | |
| 52 | isPreviewAvailableFor(input.threadRef.environmentId) && | |
| 52 | 53 | input.threadRef.threadId.length > 0 && |
| 53 | 54 | (await resolveBrowserLinkTargetPreference()) === "app"; |
| 54 | 55 | |
| ... | ... | @@ -64,6 +65,7 @@ export async function openTerminalLinkInPreview<E>( |
| 64 | 65 | }; |
| 65 | 66 | |
| 66 | 67 | const defaults = await resolveBrowserDefaults(); |
| 68 | const runtime = previewRuntimeFor(input.threadRef.environmentId); | |
| 67 | 69 | const result = await input.openPreview({ |
| 68 | 70 | environmentId: input.threadRef.environmentId, |
| 69 | 71 | input: { |
| ... | ... | @@ -73,6 +75,7 @@ export async function openTerminalLinkInPreview<E>( |
| 73 | 75 | // mapping, so the configured defaults are applied explicitly. |
| 74 | 76 | viewport: browserDefaultOpenViewport(defaults), |
| 75 | 77 | profileId: browserDefaultOpenProfileId(defaults), |
| 78 | ...(runtime === undefined ? {} : { runtime }), | |
| 76 | 79 | }, |
| 77 | 80 | }); |
| 78 | 81 | if (result._tag === "Failure") { |
apps/web/src/components/preview/previewAutomationClientId.ts deleted-4| ... | ... | @@ -1,4 +0,0 @@ |
| 1 | export 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 @@ |
| 1 | import { | |
| 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"; | |
| 15 | import * as Schema from "effect/Schema"; | |
| 16 | ||
| 17 | export 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 | ||
| 25 | export 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 | ||
| 43 | export 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 | ||
| 63 | export 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 | ||
| 82 | export 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 | ||
| 102 | export 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 | ||
| 120 | export 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 | ||
| 141 | const 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 | ||
| 175 | export 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 | ||
| 212 | export 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 | ]); | |
| 225 | export type PreviewAutomationHostError = typeof PreviewAutomationHostError.Type; | |
| 226 | ||
| 227 | const isPreviewAutomationHostError = Schema.is(PreviewAutomationHostError); | |
| 228 | ||
| 229 | export 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 @@ |
| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { | |
| 4 | PREVIEW_HOST_RESPONSE_MARGIN_MS, | |
| 5 | resolveHostWaitBudgetMs, | |
| 6 | waitForHostReadiness, | |
| 7 | } from "./previewAutomationHostBudget"; | |
| 8 | ||
| 9 | describe("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 | ||
| 27 | describe("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. */ | |
| 2 | export const PREVIEW_HOST_RESPONSE_MARGIN_MS = 1_500; | |
| 3 | ||
| 4 | const HOST_RESPONSE_MARGIN_FRACTION = 0.2; | |
| 5 | ||
| 6 | export 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. */ | |
| 18 | export 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 @@ |
| 1 | import type { PreviewAutomationOpenInput, PreviewSessionSnapshot } from "@t3tools/contracts"; | |
| 2 | import { describe, expect, it } from "vite-plus/test"; | |
| 3 | ||
| 4 | import { | |
| 5 | DEFAULT_PREVIEW_AUTOMATION_VIEWPORT, | |
| 6 | explicitlySuppressesPreviewMiniPlayer, | |
| 7 | previewAutomationDefaultViewport, | |
| 8 | previewAutomationOpenNeedsOverlay, | |
| 9 | shouldAutoShowPreviewForAutomationUse, | |
| 10 | shouldOpenPreviewMiniPlayer, | |
| 11 | } from "./previewAutomationOpenReadiness"; | |
| 12 | ||
| 13 | const 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 | ||
| 22 | describe("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 | ||
| 83 | describe("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 | ||
| 104 | describe("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 @@ |
| 1 | import { | |
| 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 | */ | |
| 15 | export 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 | */ | |
| 26 | export function shouldOpenPreviewMiniPlayer( | |
| 27 | input: PreviewAutomationOpenInput, | |
| 28 | autoShowFloatingPreview = true, | |
| 29 | ): boolean { | |
| 30 | return input.open ?? input.show ?? autoShowFloatingPreview; | |
| 31 | } | |
| 32 | ||
| 33 | export 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 | ||
| 43 | export function explicitlySuppressesPreviewMiniPlayer(input: PreviewAutomationOpenInput): boolean { | |
| 44 | return (input.open ?? input.show) === false; | |
| 45 | } | |
| 46 | ||
| 47 | export 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 | */ | |
| 61 | export 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 @@ |
| 1 | import { | |
| 2 | EnvironmentId, | |
| 3 | type PreviewAutomationRequest, | |
| 4 | type PreviewAutomationResponse, | |
| 5 | type PreviewAutomationStreamEvent, | |
| 6 | PreviewTabId, | |
| 7 | ThreadId, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; | |
| 10 | import { describe, expect, it, vi } from "vite-plus/test"; | |
| 11 | ||
| 12 | import { | |
| 13 | PreviewAutomationRecordingNotActiveError, | |
| 14 | PreviewAutomationTargetUnavailableError, | |
| 15 | PreviewAutomationViewportTimeoutError, | |
| 16 | } from "./previewAutomationErrors"; | |
| 17 | import { | |
| 18 | createPreviewAutomationRequestConsumerAtom, | |
| 19 | serializePreviewAutomationError, | |
| 20 | } from "./previewAutomationRequestConsumer"; | |
| 21 | ||
| 22 | const environmentId = EnvironmentId.make("environment-1"); | |
| 23 | const threadId = ThreadId.make("thread-1"); | |
| 24 | const tabId = PreviewTabId.make("tab-1"); | |
| 25 | const clientId = "client-1"; | |
| 26 | const connectionId = "connection-1"; | |
| 27 | ||
| 28 | const 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 | ||
| 40 | const 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 | ||
| 50 | const consumerState = (handleRequest: (request: PreviewAutomationRequest) => Promise<unknown>) => ({ | |
| 51 | connectionAtom: Atom.make<string | null>(null), | |
| 52 | requestHandlerAtom: Atom.make({ handle: handleRequest }), | |
| 53 | }); | |
| 54 | ||
| 55 | describe("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 @@ |
| 1 | import type { | |
| 2 | PreviewAutomationHost, | |
| 3 | PreviewAutomationRequest, | |
| 4 | PreviewAutomationResponse, | |
| 5 | PreviewAutomationStreamEvent, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import { AsyncResult, Atom } from "effect/reactivity"; | |
| 8 | ||
| 9 | import { | |
| 10 | PreviewAutomationOperationError, | |
| 11 | type PreviewAutomationOperationContext, | |
| 12 | serializePreviewAutomationHostError, | |
| 13 | } from "./previewAutomationErrors"; | |
| 14 | ||
| 15 | type AutomationStreamResult<E> = AsyncResult.AsyncResult<PreviewAutomationStreamEvent, E>; | |
| 16 | ||
| 17 | export function serializePreviewAutomationError( | |
| 18 | error: unknown, | |
| 19 | context: PreviewAutomationOperationContext, | |
| 20 | ): NonNullable<PreviewAutomationResponse["error"]> { | |
| 21 | return serializePreviewAutomationHostError( | |
| 22 | PreviewAutomationOperationError.fromCause({ ...context, cause: error }), | |
| 23 | ); | |
| 24 | } | |
| 25 | ||
| 26 | export 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 @@ |
| 1 | import type { PreviewSessionSnapshot } from "@t3tools/contracts"; | |
| 2 | import { describe, expect, it } from "vite-plus/test"; | |
| 3 | ||
| 4 | import { | |
| 5 | needsPreviewAutomationSessionSync, | |
| 6 | resolvePreviewAutomationOpenTab, | |
| 7 | resolvePreviewAutomationTarget, | |
| 8 | } from "./previewAutomationTarget"; | |
| 9 | ||
| 10 | const 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 | ||
| 19 | describe("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 @@ |
| 1 | import type { PreviewSessionSnapshot } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | interface PreviewAutomationSessionIndex { | |
| 4 | readonly snapshot: PreviewSessionSnapshot | null; | |
| 5 | readonly sessions: Readonly<Record<string, PreviewSessionSnapshot>>; | |
| 6 | } | |
| 7 | ||
| 8 | export 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 | ||
| 19 | export 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 | ||
| 27 | export 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 | ||
| 3 | import { afterEach, describe, expect, it } from "vite-plus/test"; | |
| 4 | ||
| 5 | import { runPreviewClickKeepingHostFocus } from "./previewClickFocus"; | |
| 6 | ||
| 7 | const TAB = "runtime-tab"; | |
| 8 | ||
| 9 | const 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 | ||
| 17 | afterEach(() => { | |
| 18 | document.body.replaceChildren(); | |
| 19 | }); | |
| 20 | ||
| 21 | describe("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 @@ |
| 1 | interface PreviewClick { | |
| 2 | readonly runtimeTabId: string; | |
| 3 | readonly order: number; | |
| 4 | previous: Element | null; | |
| 5 | } | |
| 6 | ||
| 7 | // Agent clicks can overlap across tabs and threads. | |
| 8 | const inFlightClicks = new Set<PreviewClick>(); | |
| 9 | let nextClickOrder = 0; | |
| 10 | ||
| 11 | const focusedPreviewTab = (element: Element | null): string | null => | |
| 12 | element?.localName === "webview" ? element.getAttribute("data-preview-tab") : null; | |
| 13 | ||
| 14 | const 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 | */ | |
| 41 | export 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 @@ |
| 1 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | |
| 2 | import { describe, expect, it, vi } from "vite-plus/test"; | |
| 3 | ||
| 4 | const mocks = vi.hoisted(() => ({ | |
| 5 | readThreadPreviewState: vi.fn(), | |
| 6 | })); | |
| 7 | ||
| 8 | vi.mock("~/previewStateStore", () => ({ | |
| 9 | applyPreviewServerSnapshot: vi.fn(), | |
| 10 | readThreadPreviewState: mocks.readThreadPreviewState, | |
| 11 | reconcilePreviewServerSessions: vi.fn(), | |
| 12 | updatePreviewServerSnapshot: vi.fn(), | |
| 13 | })); | |
| 14 | ||
| 15 | vi.mock("./previewBridge", () => ({ | |
| 16 | previewBridge: { | |
| 17 | automation: { | |
| 18 | evaluate: vi.fn(), | |
| 19 | status: vi.fn(), | |
| 20 | }, | |
| 21 | }, | |
| 22 | })); | |
| 23 | ||
| 24 | import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; | |
| 25 | ||
| 26 | import { PreviewAutomationTargetUnavailableError } from "./previewAutomationErrors"; | |
| 27 | import { waitForNavigationReadiness } from "./previewNavigationReadiness"; | |
| 28 | ||
| 29 | describe("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 @@ |
| 1 | import { | |
| 2 | type PreviewAutomationNavigateInput, | |
| 3 | type PreviewAutomationRequest, | |
| 4 | type ScopedThreadRef, | |
| 5 | } from "@t3tools/contracts"; | |
| 6 | ||
| 7 | import { isCurrentPreviewRuntimeTab } from "~/browser/previewRuntimeTabId"; | |
| 8 | import { readThreadPreviewState } from "~/previewStateStore"; | |
| 9 | ||
| 10 | import { previewBridge } from "./previewBridge"; | |
| 11 | import { | |
| 12 | PreviewAutomationNavigationTimeoutError, | |
| 13 | PreviewAutomationTargetUnavailableError, | |
| 14 | } from "./previewAutomationErrors"; | |
| 15 | ||
| 16 | export 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 | ||
| 39 | export 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 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { browserViewportSettingKey } from "~/browser/browserViewportLayout"; | |
| 4 | ||
| 5 | import { isPreviewViewportReady } from "./previewViewportReadiness"; | |
| 6 | ||
| 7 | describe("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 @@ |
| 1 | import type { PreviewRenderedViewportSize, PreviewViewportSetting } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | import { browserViewportSettingKey } from "~/browser/browserViewportLayout"; | |
| 4 | ||
| 5 | export function isPreviewViewportReady(input: { | |
| 6 | readonly setting: PreviewViewportSetting; | |
| 7 | readonly appliedSettingKey: string | null; | |
| 8 | readonly declaredViewport: PreviewRenderedViewportSize | null; | |
| 9 | readonly renderedViewport: PreviewRenderedViewportSize | null; | |
| 10 | }): boolean { | |
| 11 | const { setting, appliedSettingKey, declaredViewport, renderedViewport } = input; | |
| 12 | if ( | |
| 13 | appliedSettingKey !== browserViewportSettingKey(setting) || | |
| 14 | declaredViewport === null || | |
| 15 | renderedViewport === null | |
| 16 | ) { | |
| 17 | return false; | |
| 18 | } | |
| 19 | ||
| 20 | const expectedViewport = | |
| 21 | setting._tag === "fill" ? declaredViewport : { width: setting.width, height: setting.height }; | |
| 22 | if ( | |
| 23 | setting._tag !== "fill" && | |
| 24 | (declaredViewport.width !== expectedViewport.width || | |
| 25 | declaredViewport.height !== expectedViewport.height) | |
| 26 | ) { | |
| 27 | return false; | |
| 28 | } | |
| 29 | ||
| 30 | // Electron rounds CSS pixels through the guest's fractional zoom/device scale, | |
| 31 | // so a successfully applied fixed viewport can measure one pixel either way. | |
| 32 | const tolerance = 1; | |
| 33 | return ( | |
| 34 | Math.abs(renderedViewport.width - expectedViewport.width) <= tolerance && | |
| 35 | Math.abs(renderedViewport.height - expectedViewport.height) <= tolerance | |
| 36 | ); | |
| 37 | } |
apps/web/src/components/preview/usePreviewBridge.ts+4-2| ... | ... | @@ -39,8 +39,10 @@ export function usePreviewBridge(input: { |
| 39 | 39 | threadRef: ScopedThreadRef; |
| 40 | 40 | tabId: string; |
| 41 | 41 | runtimeTabId: string; |
| 42 | /** The server drives this tab and reports its navigation itself. */ | |
| 43 | serverDriven?: boolean; | |
| 42 | 44 | }): void { |
| 43 | const { threadRef, tabId, runtimeTabId } = input; | |
| 45 | const { threadRef, tabId, runtimeTabId, serverDriven = false } = input; | |
| 44 | 46 | const clearBrowserPointer = useBrowserPointerStore((state) => state.clear); |
| 45 | 47 | const reportStatus = useAtomCommand(previewEnvironment.reportStatus, "preview status report"); |
| 46 | 48 | const bridge = previewBridge; |
| ... | ... | @@ -79,7 +81,7 @@ export function usePreviewBridge(input: { |
| 79 | 81 | lastReportedUrl: lastReportedUrl.current, |
| 80 | 82 | lastReportedKind: lastReportedKind.current, |
| 81 | 83 | }); |
| 82 | if (!reported) return; | |
| 84 | if (!reported || serverDriven) return; | |
| 83 | 85 | lastReportedUrl.current = reported.lastReportedUrl; |
| 84 | 86 | lastReportedKind.current = reported.lastReportedKind; |
| 85 | 87 | void reportStatus({ |
apps/web/src/components/preview/usePreviewSession.ts+9| ... | ... | @@ -44,6 +44,13 @@ const previewSessionSyncAtom = Atom.family((threadKey: string) => { |
| 44 | 44 | const reconcileSessions = (result: Atom.Type<typeof sessionsAtom>) => { |
| 45 | 45 | if (!AsyncResult.isSuccess(result)) return; |
| 46 | 46 | reconcilePreviewServerSessions(threadRef, result.value); |
| 47 | if (!result.waiting && !readThreadPreviewState(threadRef).listLoaded) { | |
| 48 | // An event overtook the first list. Retry the authoritative baseline; | |
| 49 | // do not declare a partial event-only index ready for surface cleanup. | |
| 50 | queueMicrotask(() => { | |
| 51 | if (!disposed) get.refresh(sessionsAtom); | |
| 52 | }); | |
| 53 | } | |
| 47 | 54 | }; |
| 48 | 55 | |
| 49 | 56 | const applyLatestEvent = (result: Atom.Type<typeof eventsAtom>) => { |
| ... | ... | @@ -67,6 +74,8 @@ const previewSessionSyncAtom = Atom.family((threadKey: string) => { |
| 67 | 74 | eventsVersion += 1; |
| 68 | 75 | applyLatestEvent(result); |
| 69 | 76 | }); |
| 77 | get.mount(sessionsAtom); | |
| 78 | get.mount(eventsAtom); | |
| 70 | 79 | queueMicrotask(() => { |
| 71 | 80 | if (disposed) return; |
| 72 | 81 | // The cached list can predate an automation-created tab. Keep the local |
apps/web/src/components/settings/IntegrationsSettings.logic.test.ts+30| ... | ... | @@ -68,7 +68,37 @@ describe("clearBrowserProfileData", () => { |
| 68 | 68 | }); |
| 69 | 69 | }); |
| 70 | 70 | |
| 71 | describe("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 | ||
| 71 | 97 | describe("browserProfileRemovalAvailable", () => { |
| 98 | it("accepts a server browser in place of the desktop bridge", () => { | |
| 99 | expect(browserProfileRemovalAvailable(false, true, 1, 1)).toBe(true); | |
| 100 | }); | |
| 101 | ||
| 72 | 102 | it("requires a ready non-empty catalog and desktop bridge", () => { |
| 73 | 103 | expect(browserProfileRemovalAvailable(true, true, 1)).toBe(true); |
| 74 | 104 | expect(browserProfileRemovalAvailable(true, true, 0)).toBe(false); |
apps/web/src/components/settings/IntegrationsSettings.tsx+80-11| ... | ... | @@ -50,6 +50,9 @@ import { cn, randomUUID } from "~/lib/utils"; |
| 50 | 50 | import { useEnvironments, usePrimaryEnvironment } from "~/state/environments"; |
| 51 | 51 | import { deviceEnvironment, useDeviceState } from "~/state/device"; |
| 52 | 52 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 53 | import { previewEnvironment } from "~/state/preview"; | |
| 54 | import { useServerConfigs } from "~/state/entities"; | |
| 55 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | |
| 53 | 56 | import { |
| 54 | 57 | AgentDeviceSetupStatus, |
| 55 | 58 | DeviceHubSetupStatus, |
| ... | ... | @@ -125,28 +128,39 @@ type BrowserProfileDataBridge = Pick< |
| 125 | 128 | "clearCookies" | "clearCache" |
| 126 | 129 | >; |
| 127 | 130 | |
| 131 | /** Environments that host server-runtime tabs keep their own copy of each profile. */ | |
| 132 | export interface ServerProfileData { | |
| 133 | readonly environmentIds: ReadonlyArray<EnvironmentId>; | |
| 134 | readonly clear: (environmentId: EnvironmentId, profileId: string) => Promise<void>; | |
| 135 | } | |
| 136 | ||
| 128 | 137 | export async function clearBrowserProfileData( |
| 129 | 138 | bridge: BrowserProfileDataBridge | null, |
| 130 | 139 | environmentIds: ReadonlyArray<EnvironmentId>, |
| 131 | 140 | profileId: string, |
| 141 | server?: ServerProfileData, | |
| 132 | 142 | ): Promise<void> { |
| 133 | if (bridge === null || environmentIds.length === 0) { | |
| 143 | const desktopEnvironmentIds = bridge === null ? [] : environmentIds; | |
| 144 | const serverEnvironmentIds = server?.environmentIds ?? []; | |
| 145 | if (desktopEnvironmentIds.length === 0 && serverEnvironmentIds.length === 0) { | |
| 134 | 146 | throw new Error("Browser profile data is not available to clear."); |
| 135 | 147 | } |
| 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), | |
| 140 | 152 | ]), |
| 141 | ); | |
| 153 | ...serverEnvironmentIds.map((environmentId) => server!.clear(environmentId, profileId)), | |
| 154 | ]); | |
| 142 | 155 | } |
| 143 | 156 | |
| 144 | 157 | export function browserProfileRemovalAvailable( |
| 145 | 158 | bridgeAvailable: boolean, |
| 146 | 159 | environmentsReady: boolean, |
| 147 | 160 | environmentCount: number, |
| 161 | serverBrowserCount = 0, | |
| 148 | 162 | ): boolean { |
| 149 | return bridgeAvailable && environmentsReady && environmentCount > 0; | |
| 163 | return (bridgeAvailable || serverBrowserCount > 0) && environmentsReady && environmentCount > 0; | |
| 150 | 164 | } |
| 151 | 165 | |
| 152 | 166 | /** |
| ... | ... | @@ -926,12 +940,30 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { |
| 926 | 940 | readonly environmentName: string; |
| 927 | 941 | } | null>(null); |
| 928 | 942 | const [profilePendingRemoval, setProfilePendingRemoval] = useState<BrowserProfile | null>(null); |
| 943 | const [profilePendingClear, setProfilePendingClear] = useState<BrowserProfile | null>(null); | |
| 929 | 944 | const [profileRemovalError, setProfileRemovalError] = useState<string | null>(null); |
| 930 | 945 | const [profileRemovalInFlight, setProfileRemovalInFlight] = useState(false); |
| 946 | const serverConfigs = useServerConfigs(); | |
| 947 | const runClearServerProfile = useAtomCommand(previewEnvironment.clearProfile, { | |
| 948 | reportFailure: false, | |
| 949 | }); | |
| 950 | const serverProfileData: ServerProfileData = { | |
| 951 | environmentIds: environments | |
| 952 | .map((environment) => environment.environmentId) | |
| 953 | .filter( | |
| 954 | (environmentId) => | |
| 955 | serverConfigs.get(environmentId)?.environment.capabilities.serverBrowser === true, | |
| 956 | ), | |
| 957 | clear: async (environmentId, profileId) => { | |
| 958 | const result = await runClearServerProfile({ environmentId, input: { profileId } }); | |
| 959 | if (result._tag === "Failure") throw squashAtomCommandFailure(result); | |
| 960 | }, | |
| 961 | }; | |
| 931 | 962 | const removalAvailable = browserProfileRemovalAvailable( |
| 932 | 963 | previewBridge !== null, |
| 933 | 964 | environmentsReady, |
| 934 | 965 | environments.length, |
| 966 | serverProfileData.environmentIds.length, | |
| 935 | 967 | ); |
| 936 | 968 | const importInFlightRef = useRef(false); |
| 937 | 969 | const [importInFlight, setImportInFlight] = useState(false); |
| ... | ... | @@ -975,7 +1007,7 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { |
| 975 | 1007 | |
| 976 | 1008 | const clearProfileData = (id: string, name: string) => { |
| 977 | 1009 | if (!settingsHydrated || importInFlightRef.current) return; |
| 978 | if (!previewBridge || !environmentsReady || environments.length === 0) { | |
| 1010 | if (!removalAvailable) { | |
| 979 | 1011 | toastManager.add({ |
| 980 | 1012 | type: "error", |
| 981 | 1013 | title: `Could not clear ${name}'s data`, |
| ... | ... | @@ -987,6 +1019,7 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { |
| 987 | 1019 | previewBridge, |
| 988 | 1020 | environments.map((environment) => environment.environmentId), |
| 989 | 1021 | id, |
| 1022 | serverProfileData, | |
| 990 | 1023 | ) |
| 991 | 1024 | .then(() => { |
| 992 | 1025 | toastManager.add({ type: "success", title: `Cleared ${name}'s cookies and cache` }); |
| ... | ... | @@ -1011,6 +1044,7 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { |
| 1011 | 1044 | previewBridge, |
| 1012 | 1045 | environmentsReady ? environments.map((environment) => environment.environmentId) : [], |
| 1013 | 1046 | id, |
| 1047 | serverProfileData, | |
| 1014 | 1048 | ); |
| 1015 | 1049 | } catch { |
| 1016 | 1050 | setProfileRemovalError("Profile data could not be deleted. Try again."); |
| ... | ... | @@ -1319,7 +1353,12 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { |
| 1319 | 1353 | </MenuItem> |
| 1320 | 1354 | <MenuItem |
| 1321 | 1355 | disabled={!settingsHydrated || !removalAvailable} |
| 1322 | onClick={() => clearProfileData(profile.id, profile.name)} | |
| 1356 | onClick={() => { | |
| 1357 | // Clearing a server copy closes its open tabs, so it asks first. | |
| 1358 | if (serverProfileData.environmentIds.length > 0) { | |
| 1359 | setProfilePendingClear(profile); | |
| 1360 | } else clearProfileData(profile.id, profile.name); | |
| 1361 | }} | |
| 1323 | 1362 | > |
| 1324 | 1363 | Clear cookies and cache |
| 1325 | 1364 | </MenuItem> |
| ... | ... | @@ -1363,8 +1402,8 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { |
| 1363 | 1402 | <AlertDialogHeader> |
| 1364 | 1403 | <AlertDialogTitle>Remove “{profilePendingRemoval?.name}”?</AlertDialogTitle> |
| 1365 | 1404 | <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. | |
| 1368 | 1407 | </AlertDialogDescription> |
| 1369 | 1408 | {profileRemovalError ? ( |
| 1370 | 1409 | <p aria-live="polite" className="text-sm text-destructive"> |
| ... | ... | @@ -1398,6 +1437,36 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { |
| 1398 | 1437 | </AlertDialogFooter> |
| 1399 | 1438 | </AlertDialogPopup> |
| 1400 | 1439 | </AlertDialog> |
| 1440 | <AlertDialog | |
| 1441 | open={profilePendingClear !== null} | |
| 1442 | onOpenChange={(open) => { | |
| 1443 | if (!open) setProfilePendingClear(null); | |
| 1444 | }} | |
| 1445 | > | |
| 1446 | <AlertDialogPopup> | |
| 1447 | <AlertDialogHeader> | |
| 1448 | <AlertDialogTitle> | |
| 1449 | Clear “{profilePendingClear?.name}”’s cookies and cache? | |
| 1450 | </AlertDialogTitle> | |
| 1451 | <AlertDialogDescription> | |
| 1452 | You are signed out of its sites. Server browser tabs open in this profile close now. | |
| 1453 | </AlertDialogDescription> | |
| 1454 | </AlertDialogHeader> | |
| 1455 | <AlertDialogFooter> | |
| 1456 | <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose> | |
| 1457 | <Button | |
| 1458 | variant="destructive" | |
| 1459 | onClick={() => { | |
| 1460 | if (profilePendingClear) | |
| 1461 | clearProfileData(profilePendingClear.id, profilePendingClear.name); | |
| 1462 | setProfilePendingClear(null); | |
| 1463 | }} | |
| 1464 | > | |
| 1465 | Clear data | |
| 1466 | </Button> | |
| 1467 | </AlertDialogFooter> | |
| 1468 | </AlertDialogPopup> | |
| 1469 | </AlertDialog> | |
| 1401 | 1470 | {importSession ? ( |
| 1402 | 1471 | <BrowserImportWizard |
| 1403 | 1472 | source={importSession.source} |
apps/web/src/previewStateStore.test.ts+13| ... | ... | @@ -466,6 +466,19 @@ describe("previewStateStore (single-tab)", () => { |
| 466 | 466 | reconcilePreviewServerSessions(ref, { sessions: [], serverEpoch, revision: 0 }); |
| 467 | 467 | |
| 468 | 468 | expect(readThreadPreviewState(ref).sessions).toEqual({ [snapshot.tabId]: snapshot }); |
| 469 | expect(readThreadPreviewState(ref).listLoaded).toBe(false); | |
| 470 | ||
| 471 | const olderTab = makeSnapshot({ tabId: "tab_before_event" }); | |
| 472 | reconcilePreviewServerSessions(ref, { | |
| 473 | sessions: [olderTab, snapshot], | |
| 474 | serverEpoch, | |
| 475 | revision: 2, | |
| 476 | }); | |
| 477 | expect(readThreadPreviewState(ref).listLoaded).toBe(true); | |
| 478 | expect(readThreadPreviewState(ref).sessions).toEqual({ | |
| 479 | [olderTab.tabId]: olderTab, | |
| 480 | [snapshot.tabId]: snapshot, | |
| 481 | }); | |
| 469 | 482 | }); |
| 470 | 483 | |
| 471 | 484 | it("does not resurrect a tab from an event older than its close", () => { |
apps/web/src/previewStateStore.ts+7-1| ... | ... | @@ -43,6 +43,8 @@ export interface ThreadPreviewState { |
| 43 | 43 | desktopOverlay: DesktopPreviewOverlay | null; |
| 44 | 44 | desktopByTabId: Record<string, DesktopPreviewOverlay>; |
| 45 | 45 | recentlySeenUrls: string[]; |
| 46 | /** Whether the first authoritative tab list has arrived. */ | |
| 47 | listLoaded: boolean; | |
| 46 | 48 | /** Server process currently authoritative for revision ordering. */ |
| 47 | 49 | serverEpoch: string | null; |
| 48 | 50 | /** Latest ordered server revision applied from a list response or event. */ |
| ... | ... | @@ -57,6 +59,7 @@ const EMPTY_THREAD_PREVIEW_STATE: ThreadPreviewState = Object.freeze({ |
| 57 | 59 | desktopOverlay: null, |
| 58 | 60 | desktopByTabId: {}, |
| 59 | 61 | recentlySeenUrls: [] as string[], |
| 62 | listLoaded: false, | |
| 60 | 63 | serverEpoch: null, |
| 61 | 64 | serverRevision: 0, |
| 62 | 65 | }); |
| ... | ... | @@ -311,7 +314,9 @@ export function reconcilePreviewServerSessions( |
| 311 | 314 | ): void { |
| 312 | 315 | updateThreadPreviewState(ref, (current) => { |
| 313 | 316 | const sameServer = current.serverEpoch === result.serverEpoch; |
| 314 | if (sameServer && result.revision < current.serverRevision) return current; | |
| 317 | if (sameServer && result.revision < current.serverRevision) { | |
| 318 | return current; | |
| 319 | } | |
| 315 | 320 | const snapshots = result.sessions; |
| 316 | 321 | const sessions: Record<string, PreviewSessionSnapshot> = {}; |
| 317 | 322 | const currentSuppressedTabIds = sameServer ? current.suppressedTabIds : new Set<string>(); |
| ... | ... | @@ -349,6 +354,7 @@ export function reconcilePreviewServerSessions( |
| 349 | 354 | desktopByTabId, |
| 350 | 355 | desktopOverlay: activeTabId ? (desktopByTabId[activeTabId] ?? null) : null, |
| 351 | 356 | recentlySeenUrls, |
| 357 | listLoaded: true, | |
| 352 | 358 | serverEpoch: result.serverEpoch, |
| 353 | 359 | serverRevision: result.revision, |
| 354 | 360 | }; |
apps/web/src/rightPanelStore.test.ts+26| ... | ... | @@ -1049,6 +1049,32 @@ describe("rightPanelStore", () => { |
| 1049 | 1049 | }); |
| 1050 | 1050 | }); |
| 1051 | 1051 | |
| 1052 | it("does not replace a stale surface with a hidden tab, but preserves explicit opens", () => { | |
| 1053 | const store = useRightPanelStore.getState(); | |
| 1054 | store.openBrowser(refA, "stale-tab"); | |
| 1055 | store.reconcileBrowserSurfaces(refA, ["hidden-tab"], new Set(["hidden-tab"])); | |
| 1056 | expect( | |
| 1057 | selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA), | |
| 1058 | ).toMatchObject({ | |
| 1059 | activeSurfaceId: null, | |
| 1060 | surfaces: [], | |
| 1061 | }); | |
| 1062 | ||
| 1063 | store.openBrowser(refA, "hidden-tab"); | |
| 1064 | store.reconcileBrowserSurfaces( | |
| 1065 | refA, | |
| 1066 | ["hidden-tab", "other-tab"], | |
| 1067 | new Set(["hidden-tab", "other-tab"]), | |
| 1068 | ); | |
| 1069 | expect( | |
| 1070 | selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA), | |
| 1071 | ).toMatchObject({ | |
| 1072 | isOpen: true, | |
| 1073 | activeSurfaceId: "browser:hidden-tab", | |
| 1074 | surfaces: [{ id: "browser:hidden-tab", kind: "preview", resourceId: "hidden-tab" }], | |
| 1075 | }); | |
| 1076 | }); | |
| 1077 | ||
| 1052 | 1078 | it("reconciles browser surfaces without deleting other surface kinds", () => { |
| 1053 | 1079 | useRightPanelStore.getState().openTerminal(refA, "term-1"); |
| 1054 | 1080 | useRightPanelStore.getState().openBrowser(refA, "tab-a"); |
apps/web/src/rightPanelStore.ts+7-3| ... | ... | @@ -163,7 +163,11 @@ interface RightPanelStoreState { |
| 163 | 163 | closeOtherSurfaces: (ref: ScopedThreadRef, surfaceId: string) => void; |
| 164 | 164 | closeSurfacesToRight: (ref: ScopedThreadRef, surfaceId: string) => void; |
| 165 | 165 | closeAllSurfaces: (ref: ScopedThreadRef) => void; |
| 166 | reconcileBrowserSurfaces: (ref: ScopedThreadRef, tabIds: readonly string[]) => void; | |
| 166 | reconcileBrowserSurfaces: ( | |
| 167 | ref: ScopedThreadRef, | |
| 168 | tabIds: readonly string[], | |
| 169 | hiddenTabIds?: ReadonlySet<string>, | |
| 170 | ) => void; | |
| 167 | 171 | reconcileFileSurfaces: (ref: ScopedThreadRef, workspaceAvailable: boolean) => void; |
| 168 | 172 | show: (ref: ScopedThreadRef) => void; |
| 169 | 173 | close: (ref: ScopedThreadRef) => void; |
| ... | ... | @@ -850,7 +854,7 @@ export const useRightPanelStore = create<RightPanelStoreState>()( |
| 850 | 854 | : { ...current, isOpen: false, surfaces: [], activeSurfaceId: null }, |
| 851 | 855 | ), |
| 852 | 856 | ), |
| 853 | reconcileBrowserSurfaces: (ref, tabIds) => | |
| 857 | reconcileBrowserSurfaces: (ref, tabIds, hiddenTabIds) => | |
| 854 | 858 | set((state) => |
| 855 | 859 | automaticUpdate(state, scopedThreadKey(ref), (current) => { |
| 856 | 860 | const validIds = new Set(tabIds.map((tabId) => `browser:${tabId}`)); |
| ... | ... | @@ -863,7 +867,7 @@ export const useRightPanelStore = create<RightPanelStoreState>()( |
| 863 | 867 | ); |
| 864 | 868 | const knownIds = new Set(existingBrowser.map((surface) => surface.id)); |
| 865 | 869 | const added = tabIds |
| 866 | .filter((tabId) => !knownIds.has(`browser:${tabId}`)) | |
| 870 | .filter((tabId) => !knownIds.has(`browser:${tabId}`) && !hiddenTabIds?.has(tabId)) | |
| 867 | 871 | .map((tabId) => browserSurface(tabId)); |
| 868 | 872 | const surfaces = [...nonBrowser, ...existingBrowser, ...added]; |
| 869 | 873 | const activeStillExists = surfaces.some( |
apps/web/src/routes/_chat.tsx+2-2| ... | ... | @@ -8,6 +8,7 @@ import { resolveThreadRouteTarget } from "../threadRoutes"; |
| 8 | 8 | import { useClientSettings, useLegacySidebarEnabled } from "../hooks/useSettings"; |
| 9 | 9 | import { openCommandPalette } from "../commandPaletteBus"; |
| 10 | 10 | import { useProjects } from "../state/entities"; |
| 11 | import { isPreviewAvailableFor } from "../browser/previewRuntime"; | |
| 11 | 12 | import { usePrimaryEnvironmentId } from "../state/environments"; |
| 12 | 13 | import { selectProjectGroupingSettings } from "../logicalProject"; |
| 13 | 14 | import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; |
| ... | ... | @@ -22,7 +23,6 @@ import { isModelPickerOpen } from "../modelPickerVisibility"; |
| 22 | 23 | import { undoLatestThreadAction } from "../hooks/showThreadUndoNotice"; |
| 23 | 24 | import { resolveShortcutCommand } from "../keybindings"; |
| 24 | 25 | import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; |
| 25 | import { isPreviewSupportedInRuntime } from "../previewStateStore"; | |
| 26 | 26 | import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; |
| 27 | 27 | import { useThreadSelectionStore } from "../threadSelectionStore"; |
| 28 | 28 | import { stackedThreadToast, toastManager } from "~/components/ui/toast"; |
| ... | ... | @@ -141,7 +141,7 @@ function ChatRouteGlobalShortcuts() { |
| 141 | 141 | event.preventDefault(); |
| 142 | 142 | event.stopPropagation(); |
| 143 | 143 | if (!routeThreadRef) return; |
| 144 | if (!isPreviewSupportedInRuntime()) { | |
| 144 | if (!isPreviewAvailableFor(routeThreadRef.environmentId)) { | |
| 145 | 145 | toastManager.add( |
| 146 | 146 | stackedThreadToast({ |
| 147 | 147 | type: "info", |
apps/web/src/rpc/requestLatencyState.test.ts+2-9| ... | ... | @@ -52,13 +52,6 @@ describe("requestLatencyState", () => { |
| 52 | 52 | expect(getSlowRpcAckRequests()).toEqual([]); |
| 53 | 53 | }); |
| 54 | 54 | |
| 55 | it("ignores the long-lived preview automation connection", () => { | |
| 56 | trackRpcRequestSent("1", WS_METHODS.previewAutomationConnect); | |
| 57 | vi.advanceTimersByTime(SLOW_RPC_ACK_THRESHOLD_MS * 2); | |
| 58 | ||
| 59 | expect(getSlowRpcAckRequests()).toEqual([]); | |
| 60 | }); | |
| 61 | ||
| 62 | 55 | it("ignores usage summary requests", () => { |
| 63 | 56 | trackRpcRequestSent("1", WS_METHODS.serverGetUsageSummary); |
| 64 | 57 | vi.advanceTimersByTime(SLOW_RPC_ACK_THRESHOLD_MS * 2); |
| ... | ... | @@ -79,8 +72,8 @@ describe("requestLatencyState", () => { |
| 79 | 72 | it("keeps ignoring untracked methods when a display tag is supplied", () => { |
| 80 | 73 | trackRpcRequestSent( |
| 81 | 74 | "1", |
| 82 | WS_METHODS.previewAutomationConnect, | |
| 83 | `${WS_METHODS.previewAutomationConnect} · env-1`, | |
| 75 | WS_METHODS.serverGetUsageSummary, | |
| 76 | `${WS_METHODS.serverGetUsageSummary} · env-1`, | |
| 84 | 77 | ); |
| 85 | 78 | vi.advanceTimersByTime(SLOW_RPC_ACK_THRESHOLD_MS * 2); |
| 86 | 79 |
apps/web/src/rpc/requestLatencyState.ts+1-4| ... | ... | @@ -28,10 +28,7 @@ interface PendingRpcAckRequest { |
| 28 | 28 | } |
| 29 | 29 | |
| 30 | 30 | const pendingRpcAckRequests = new Map<string, PendingRpcAckRequest>(); |
| 31 | const untrackedRpcAckMethods = new Set<string>([ | |
| 32 | WS_METHODS.previewAutomationConnect, | |
| 33 | WS_METHODS.serverGetUsageSummary, | |
| 34 | ]); | |
| 31 | const untrackedRpcAckMethods = new Set<string>([WS_METHODS.serverGetUsageSummary]); | |
| 35 | 32 | const longRunningRpcAckMethods = new Set<string>([ |
| 36 | 33 | WS_METHODS.serverUpdateProvider, |
| 37 | 34 | WS_METHODS.serverRefreshProviders, |
apps/web/src/state/entities.ts+17| ... | ... | @@ -201,6 +201,23 @@ export function waitForThreadShell(ref: ScopedThreadRef, timeoutMs = 5_000): Pro |
| 201 | 201 | }); |
| 202 | 202 | } |
| 203 | 203 | |
| 204 | /** Whether the environment hosts preview tabs in its own browser (`runtime: "server"`), | |
| 205 | so clients without Electron can still use the Browser panel. */ | |
| 206 | export 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 | ||
| 214 | export function readEnvironmentSupportsServerBrowser(environmentId: EnvironmentId): boolean { | |
| 215 | return ( | |
| 216 | appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities | |
| 217 | .serverBrowser === true | |
| 218 | ); | |
| 219 | } | |
| 220 | ||
| 204 | 221 | export function readEnvironmentSupportsTitleRegeneration(environmentId: EnvironmentId): boolean { |
| 205 | 222 | return ( |
| 206 | 223 | appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities |
apps/web/src/state/previewStream.ts created+51| ... | ... | @@ -0,0 +1,51 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | |
| 2 | import { PREVIEW_STREAM_BASE_PATH } from "@t3tools/client-runtime/preview/server-browser-stream"; | |
| 3 | import { | |
| 4 | type DeviceHubAccess, | |
| 5 | resolveDeviceHubAccess, | |
| 6 | } from "@t3tools/client-runtime/state/deviceHubAccess"; | |
| 7 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 8 | import * as Effect from "effect/Effect"; | |
| 9 | import * as Option from "effect/Option"; | |
| 10 | import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; | |
| 11 | ||
| 12 | import { connectionAtomRuntime } from "../connection/runtime"; | |
| 13 | import { appAtomRegistry } from "../rpc/atomRegistry"; | |
| 14 | import { environmentSession } from "./session"; | |
| 15 | ||
| 16 | // Re-pairing changes the prepared connection, invalidating its cached ticket. | |
| 17 | const 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 | ||
| 29 | export function usePreviewStreamAccess(environmentId: EnvironmentId): DeviceHubAccess | null { | |
| 30 | const result = useAtomValue(previewStreamAccessAtom(environmentId)); | |
| 31 | return AsyncResult.isSuccess(result) ? result.value : null; | |
| 32 | } | |
| 33 | ||
| 34 | export 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. */ | |
| 39 | export function readPreviewStreamAccess( | |
| 40 | environmentId: EnvironmentId, | |
| 41 | refresh: boolean, | |
| 42 | ): Promise<DeviceHubAccess | null> { | |
| 43 | const atom = previewStreamAccessAtom(environmentId); | |
| 44 | if (refresh) appAtomRegistry.refresh(atom); | |
| 45 | return Effect.runPromise( | |
| 46 | AtomRegistry.getResult(appAtomRegistry, atom, { suspendOnWaiting: true }).pipe( | |
| 47 | Effect.timeout("10 seconds"), | |
| 48 | Effect.orElseSucceed(() => null), | |
| 49 | ), | |
| 50 | ); | |
| 51 | } |
docs/user/html-renders.md+1-1| ... | ... | @@ -6,4 +6,4 @@ Pages use your current theme, including custom themes, and follow light and dark |
| 6 | 6 | |
| 7 | 7 | Agents 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. |
| 8 | 8 | |
| 9 | Before 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. | |
| 9 | Before publishing, agents check their work with screenshots from a small headless browser that T3 Code keeps for itself; it never uses a browser you installed. The first preview on a machine downloads it once (about 120 MB) into T3 Code's data folder, so that preview can take a minute. Browser tabs on a remote environment use the same browser, so whichever comes first downloads it. Some Linux hosts need [setup](remote-access.md#browser-host-setup) before it can start. Pages publish without it. |
docs/user/remote-access.md+48| ... | ... | @@ -172,6 +172,54 @@ running is left alone. |
| 172 | 172 | For Antigravity's Google callback on a remote host, see |
| 173 | 173 | [remote sign-in](./providers-antigravity.md#sign-in-from-a-remote-device). |
| 174 | 174 | |
| 175 | ## Browser on a remote environment | |
| 176 | ||
| 177 | Browser tabs belong to the environment, so you and your agents see the same | |
| 178 | tabs from any device. The desktop app shows its own environment's tabs | |
| 179 | directly. Every other device, and the desktop app for other environments, | |
| 180 | streams them from the host. Agents keep using them while no device is | |
| 181 | connected, and `localhost` addresses reach servers on the host. | |
| 182 | ||
| 183 | The first tab downloads a headless Chrome, about 120 MB, into the T3 home. It | |
| 184 | is the same browser [HTML renders](html-renders.md) use, so a host downloads it | |
| 185 | only once. Some Linux hosts need [setup](#browser-host-setup) before it can | |
| 186 | start. | |
| 187 | ||
| 188 | Agent tabs have separate storage and share a Chromium process. Take control before | |
| 189 | typing into an agent's tab, then release control when you want the agent to | |
| 190 | continue. Read-only connections can watch without changing the page. | |
| 191 | ||
| 192 | While you have control, the tab works with your device: text the page copies or | |
| 193 | cuts goes to your clipboard, a file picker on the page opens your device's | |
| 194 | picker, and a finished download is offered for you to save. Popups such as | |
| 195 | sign-in windows open as their own tabs. Downloads stay on the host until the | |
| 196 | tab closes. Audio does not play on your device. | |
| 197 | ||
| 198 | On 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 | |
| 200 | apps. | |
| 201 | ||
| 202 | ### Browser host setup | |
| 203 | ||
| 204 | macOS, Windows, and Linux desktops run the browser as is. Some Linux hosts need | |
| 205 | one-time setup: Ubuntu 23.10 and later block the sandbox the browser runs in, | |
| 206 | and minimal images and containers lack libraries it loads. When that happens, | |
| 207 | the server says so at startup, and browser tabs and HTML previews show the | |
| 208 | command to run on the host: | |
| 209 | ||
| 210 | ```sh | |
| 211 | sudo t3 browser setup | |
| 212 | ``` | |
| 213 | ||
| 214 | The 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 | |
| 216 | for your user. It allows Chrome's sandbox with an AppArmor profile and installs | |
| 217 | any missing libraries with apt. It is safe to run again. Without `sudo`, it | |
| 218 | only reports what it would change. | |
| 219 | ||
| 220 | The browser always runs in Chrome's sandbox. Where you cannot change the host, | |
| 221 | set `T3CODE_SERVER_BROWSER_SANDBOX=0` for the environment to run without it. | |
| 222 | ||
| 175 | 223 | ## Manage or revoke access |
| 176 | 224 | |
| 177 | 225 | On the host, **Settings → Connections** lets authorized administrators create |
knip.jsonc+1| ... | ... | @@ -77,6 +77,7 @@ |
| 77 | 77 | "plugins/*.cjs", |
| 78 | 78 | "scripts/generate-device-stream.mts", |
| 79 | 79 | "src/features/devices/device-stream.browser.ts!", |
| 80 | "src/features/browser/preview-stream.browser.ts!", | |
| 80 | 81 | ], |
| 81 | 82 | // Fonts are configured as asset paths; Expo autolinks the native packages. |
| 82 | 83 | "ignoreDependencies": [ |
packages/client-runtime/package.json+4| ... | ... | @@ -19,6 +19,10 @@ |
| 19 | 19 | "types": "./src/device/hubAccess.ts", |
| 20 | 20 | "default": "./src/device/hubAccess.ts" |
| 21 | 21 | }, |
| 22 | "./preview/server-browser-stream": { | |
| 23 | "types": "./src/preview/serverBrowserStream.ts", | |
| 24 | "default": "./src/preview/serverBrowserStream.ts" | |
| 25 | }, | |
| 22 | 26 | "./worktree-setup": { |
| 23 | 27 | "types": "./src/worktreeSetup.ts", |
| 24 | 28 | "default": "./src/worktreeSetup.ts" |
packages/client-runtime/src/preview/serverBrowserStream.test.ts created+201| ... | ... | @@ -0,0 +1,201 @@ |
| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { createPreviewStreamClient, type PreviewStreamControl } from "./serverBrowserStream.ts"; | |
| 4 | ||
| 5 | class 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 | ||
| 31 | const 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 | ||
| 44 | describe("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 | ||
| 145 | describe("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 | ||
| 175 | describe("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. | |
| 2 | import { type DeviceHubAccess, withDeviceHubQuery } from "../device/hubAccess.ts"; | |
| 3 | import { | |
| 4 | PREVIEW_STREAM_HOST_SETUP_CLOSE_CODE, | |
| 5 | type PreviewStreamHostSetup, | |
| 6 | type PreviewViewportSetting, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | ||
| 9 | export const PREVIEW_STREAM_BASE_PATH = "/api/preview-stream"; | |
| 10 | ||
| 11 | export interface PreviewStreamViewport { | |
| 12 | readonly width: number; | |
| 13 | readonly height: number; | |
| 14 | } | |
| 15 | ||
| 16 | export type PreviewStreamMouseButton = "none" | "left" | "middle" | "right"; | |
| 17 | ||
| 18 | export 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. */ | |
| 30 | export 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 | ||
| 43 | const 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. */ | |
| 54 | export 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 | ||
| 97 | export 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 | ||
| 104 | export 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 | ||
| 114 | const 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. */ | |
| 128 | export 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 | ||
| 142 | const 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. */ | |
| 156 | export 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. */ | |
| 163 | export 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. */ | |
| 171 | export 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 | ||
| 182 | export 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 | ||
| 193 | export 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 | ||
| 217 | export 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 | ||
| 223 | const ACK_MESSAGE = JSON.stringify({ type: "ack" }); | |
| 224 | export 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 | ||
| 406 | export 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. */ | |
| 412 | export 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. */ | |
| 459 | const 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. */ | |
| 473 | export const previewStreamHostSetupMessage = (setup: PreviewStreamHostSetup) => | |
| 474 | setup.need === "sandbox" | |
| 475 | ? "This server's host blocks the sandbox its browser runs in. Run this once on the host, then try again:" | |
| 476 | : "This server's host is missing libraries its browser needs. Run this once on the host, then try again:"; |
packages/client-runtime/src/rpc/client.test.ts-153| ... | ... | @@ -2,9 +2,6 @@ import { |
| 2 | 2 | DEFAULT_SERVER_SETTINGS, |
| 3 | 3 | EnvironmentAuthorizationError, |
| 4 | 4 | EnvironmentId, |
| 5 | PreviewTabId, | |
| 6 | ThreadId, | |
| 7 | type PreviewAutomationStreamEvent, | |
| 8 | 5 | type RelayClientInstallProgressEvent, |
| 9 | 6 | type ServerConfigStreamEvent, |
| 10 | 7 | type ServerLifecycleStreamEvent, |
| ... | ... | @@ -92,156 +89,6 @@ const makeHarness = Effect.fn("TestEnvironmentRpc.makeHarness")(function* () { |
| 92 | 89 | }); |
| 93 | 90 | |
| 94 | 91 | describe("environment RPC", () => { |
| 95 | it.effect("registers a fresh preview host after completion without replaying requests", () => | |
| 96 | Effect.gen(function* () { | |
| 97 | const firstCompleted = yield* Deferred.make<void>(); | |
| 98 | const reconnected = yield* Deferred.make<void>(); | |
| 99 | const requests: string[] = []; | |
| 100 | const connections: string[] = []; | |
| 101 | let attempts = 0; | |
| 102 | const client = { | |
| 103 | [WS_METHODS.previewAutomationConnect]: () => | |
| 104 | Stream.suspend(() => { | |
| 105 | attempts += 1; | |
| 106 | const connected: PreviewAutomationStreamEvent = { | |
| 107 | type: "connected", | |
| 108 | connectionId: `connection-${attempts}`, | |
| 109 | }; | |
| 110 | return attempts === 1 | |
| 111 | ? Stream.make(connected, { | |
| 112 | type: "request", | |
| 113 | connectionId: connected.connectionId, | |
| 114 | request: { | |
| 115 | requestId: "timed-out-action", | |
| 116 | operation: "click", | |
| 117 | threadId: ThreadId.make("thread-1"), | |
| 118 | tabId: PreviewTabId.make("tab-1"), | |
| 119 | input: {}, | |
| 120 | timeoutMs: 1_000, | |
| 121 | }, | |
| 122 | } satisfies PreviewAutomationStreamEvent).pipe( | |
| 123 | Stream.ensuring(Deferred.succeed(firstCompleted, undefined)), | |
| 124 | ) | |
| 125 | : Stream.succeed(connected).pipe(Stream.concat(Stream.never)); | |
| 126 | }), | |
| 127 | } as unknown as WsRpcProtocolClient; | |
| 128 | const { activeSession, supervisor } = yield* makeHarness(); | |
| 129 | yield* SubscriptionRef.set(activeSession, Option.some(session(client))); | |
| 130 | const consumer = yield* subscribe(WS_METHODS.previewAutomationConnect, { | |
| 131 | clientId: "preview-host", | |
| 132 | environmentId: TARGET.environmentId, | |
| 133 | }).pipe( | |
| 134 | Stream.runForEach((event) => { | |
| 135 | if (event.type === "request") { | |
| 136 | requests.push(event.request.requestId); | |
| 137 | return Effect.void; | |
| 138 | } | |
| 139 | connections.push(event.connectionId); | |
| 140 | return connections.length === 2 ? Deferred.succeed(reconnected, undefined) : Effect.void; | |
| 141 | }), | |
| 142 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | |
| 143 | Effect.forkChild, | |
| 144 | ); | |
| 145 | yield* Deferred.await(firstCompleted); | |
| 146 | yield* TestClock.adjust(999); | |
| 147 | expect(attempts).toBe(1); | |
| 148 | yield* TestClock.adjust(1); | |
| 149 | expect(attempts).toBe(2); | |
| 150 | yield* Deferred.await(reconnected); | |
| 151 | expect(connections).toEqual(["connection-1", "connection-2"]); | |
| 152 | expect(requests).toEqual(["timed-out-action"]); | |
| 153 | yield* Fiber.interrupt(consumer); | |
| 154 | }), | |
| 155 | ); | |
| 156 | ||
| 157 | it.effect("does not re-register an unmounted preview host during the recovery delay", () => | |
| 158 | Effect.gen(function* () { | |
| 159 | const completed = yield* Deferred.make<void>(); | |
| 160 | let attempts = 0; | |
| 161 | const client = { | |
| 162 | [WS_METHODS.previewAutomationConnect]: () => | |
| 163 | Stream.suspend(() => { | |
| 164 | attempts += 1; | |
| 165 | return Stream.empty.pipe(Stream.ensuring(Deferred.succeed(completed, undefined))); | |
| 166 | }), | |
| 167 | } as unknown as WsRpcProtocolClient; | |
| 168 | const { activeSession, supervisor } = yield* makeHarness(); | |
| 169 | yield* SubscriptionRef.set(activeSession, Option.some(session(client))); | |
| 170 | const consumer = yield* subscribe(WS_METHODS.previewAutomationConnect, { | |
| 171 | clientId: "preview-host", | |
| 172 | environmentId: TARGET.environmentId, | |
| 173 | }).pipe( | |
| 174 | Stream.runDrain, | |
| 175 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | |
| 176 | Effect.forkChild, | |
| 177 | ); | |
| 178 | yield* Deferred.await(completed); | |
| 179 | yield* Fiber.interrupt(consumer); | |
| 180 | yield* TestClock.adjust(10_000); | |
| 181 | expect(attempts).toBe(1); | |
| 182 | }), | |
| 183 | ); | |
| 184 | ||
| 185 | it.effect.each(["completion", "transport failure"] as const)( | |
| 186 | "keeps preview recovery tied to the active session after %s", | |
| 187 | (reason) => | |
| 188 | Effect.gen(function* () { | |
| 189 | const completed = yield* Deferred.make<void>(); | |
| 190 | const nextConnected = yield* Deferred.make<void>(); | |
| 191 | let oldAttempts = 0; | |
| 192 | let nextAttempts = 0; | |
| 193 | const firstClient = { | |
| 194 | [WS_METHODS.previewAutomationConnect]: () => | |
| 195 | Stream.suspend(() => { | |
| 196 | oldAttempts += 1; | |
| 197 | return ( | |
| 198 | reason === "completion" | |
| 199 | ? Stream.empty | |
| 200 | : Stream.fail( | |
| 201 | new RpcClientError.RpcClientError({ | |
| 202 | reason: new RpcClientError.RpcClientDefect({ | |
| 203 | message: "socket closed", | |
| 204 | cause: new Error("socket closed"), | |
| 205 | }), | |
| 206 | }), | |
| 207 | ) | |
| 208 | ).pipe(Stream.ensuring(Deferred.succeed(completed, undefined))); | |
| 209 | }), | |
| 210 | } as unknown as WsRpcProtocolClient; | |
| 211 | const nextClient = { | |
| 212 | [WS_METHODS.previewAutomationConnect]: () => | |
| 213 | Stream.suspend(() => { | |
| 214 | nextAttempts += 1; | |
| 215 | return Stream.fromEffect(Deferred.succeed(nextConnected, undefined)).pipe( | |
| 216 | Stream.drain, | |
| 217 | Stream.concat(Stream.never), | |
| 218 | ); | |
| 219 | }), | |
| 220 | } as unknown as WsRpcProtocolClient; | |
| 221 | const { activeSession, supervisor } = yield* makeHarness(); | |
| 222 | yield* SubscriptionRef.set(activeSession, Option.some(session(firstClient))); | |
| 223 | const consumer = yield* subscribe(WS_METHODS.previewAutomationConnect, { | |
| 224 | clientId: "preview-host", | |
| 225 | environmentId: TARGET.environmentId, | |
| 226 | }).pipe( | |
| 227 | Stream.runDrain, | |
| 228 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | |
| 229 | Effect.forkChild, | |
| 230 | ); | |
| 231 | yield* Deferred.await(completed); | |
| 232 | if (reason === "transport failure") { | |
| 233 | yield* TestClock.adjust(10_000); | |
| 234 | expect(oldAttempts).toBe(1); | |
| 235 | } | |
| 236 | yield* SubscriptionRef.set(activeSession, Option.some(session(nextClient))); | |
| 237 | yield* Deferred.await(nextConnected); | |
| 238 | yield* TestClock.adjust(10_000); | |
| 239 | expect(oldAttempts).toBe(1); | |
| 240 | expect(nextAttempts).toBe(1); | |
| 241 | yield* Fiber.interrupt(consumer); | |
| 242 | }), | |
| 243 | ); | |
| 244 | ||
| 245 | 92 | it.effect("reuses the session config stream instead of opening a duplicate subscription", () => |
| 246 | 93 | Effect.gen(function* () { |
| 247 | 94 | const event: ServerConfigStreamEvent = { |
packages/client-runtime/src/rpc/client.ts+1-10| ... | ... | @@ -9,7 +9,6 @@ import * as Duration from "effect/Duration"; |
| 9 | 9 | import * as Effect from "effect/Effect"; |
| 10 | 10 | import * as Option from "effect/Option"; |
| 11 | 11 | import * as Schema from "effect/Schema"; |
| 12 | import * as Schedule from "effect/Schedule"; | |
| 13 | 12 | import * as Stream from "effect/Stream"; |
| 14 | 13 | import * as SubscriptionRef from "effect/SubscriptionRef"; |
| 15 | 14 | import { RpcClientError } from "effect/rpc"; |
| ... | ... | @@ -61,7 +60,6 @@ export type EnvironmentSubscriptionRpcTag = |
| 61 | 60 | | typeof WS_METHODS.subscribeDeviceState |
| 62 | 61 | | typeof WS_METHODS.subscribeResourceTelemetry |
| 63 | 62 | | typeof WS_METHODS.pullRequestsSubscribeRefreshes |
| 64 | | typeof WS_METHODS.previewAutomationConnect | |
| 65 | 63 | | typeof WS_METHODS.subscribeVcsStatus |
| 66 | 64 | | typeof WS_METHODS.subscribeWorktreeSetup |
| 67 | 65 | | typeof WS_METHODS.subscribeProjectClones |
| ... | ... | @@ -272,14 +270,7 @@ function subscribeDynamicMapped<TTag extends EnvironmentSubscriptionRpcTag, A>( |
| 272 | 270 | }), |
| 273 | 271 | ), |
| 274 | 272 | ); |
| 275 | // An evicted preview host completes its registration stream. | |
| 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)); | |
| 283 | 274 | }), |
| 284 | 275 | ).pipe( |
| 285 | 276 | Stream.tapCause((cause) => |
packages/client-runtime/src/state/preview.test.ts deleted-18| ... | ... | @@ -1,18 +0,0 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { previewAutomationHostFocusConcurrencyKey } from "./preview.ts"; | |
| 4 | ||
| 5 | describe("preview state commands", () => { | |
| 6 | it("keeps focus updates from replacement host connections independent", () => { | |
| 7 | const first = previewAutomationHostFocusConcurrencyKey({ | |
| 8 | environmentId: "environment-1", | |
| 9 | input: { clientId: "client-1", connectionId: "connection-1" }, | |
| 10 | }); | |
| 11 | const replacement = previewAutomationHostFocusConcurrencyKey({ | |
| 12 | environmentId: "environment-1", | |
| 13 | input: { clientId: "client-1", connectionId: "connection-2" }, | |
| 14 | }); | |
| 15 | ||
| 16 | expect(first).not.toBe(replacement); | |
| 17 | }); | |
| 18 | }); |
packages/client-runtime/src/state/preview.ts+10-36| ... | ... | @@ -9,20 +9,11 @@ import { |
| 9 | 9 | createEnvironmentRpcSubscriptionAtomFamily, |
| 10 | 10 | } from "./runtime.ts"; |
| 11 | 11 | |
| 12 | export 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 | ||
| 20 | 12 | export function createPreviewEnvironmentAtoms<R, E>( |
| 21 | 13 | runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>, |
| 22 | 14 | ) { |
| 23 | 15 | const lifecycleScheduler = createAtomCommandScheduler(); |
| 24 | 16 | const statusScheduler = createAtomCommandScheduler(); |
| 25 | const automationScheduler = createAtomCommandScheduler(); | |
| 26 | 17 | const lifecycleConcurrency = { |
| 27 | 18 | mode: "serial" as const, |
| 28 | 19 | key: ({ environmentId, input }: { environmentId: string; input: { threadId: string } }) => |
| ... | ... | @@ -45,14 +36,6 @@ export function createPreviewEnvironmentAtoms<R, E>( |
| 45 | 36 | // unmounted projects stop contributing probe candidates on the server. |
| 46 | 37 | idleTtlMs: 0, |
| 47 | 38 | }), |
| 48 | automationRequests: createEnvironmentRpcSubscriptionAtomFamily(runtime, { | |
| 49 | label: "environment-data:preview:automation-requests", | |
| 50 | tag: WS_METHODS.previewAutomationConnect, | |
| 51 | // Automation requests are commands, not cached query data. Dispose the | |
| 52 | // stream immediately with its owner so stale requests cannot replay when | |
| 53 | // a thread remounts and the server can clear disconnected hosts promptly. | |
| 54 | idleTtlMs: 0, | |
| 55 | }), | |
| 56 | 39 | open: createEnvironmentRpcCommand(runtime, { |
| 57 | 40 | label: "environment-data:preview:open", |
| 58 | 41 | tag: WS_METHODS.previewOpen, |
| ... | ... | @@ -71,6 +54,12 @@ export function createPreviewEnvironmentAtoms<R, E>( |
| 71 | 54 | scheduler: lifecycleScheduler, |
| 72 | 55 | concurrency: lifecycleConcurrency, |
| 73 | 56 | }), |
| 57 | adjust: createEnvironmentRpcCommand(runtime, { | |
| 58 | label: "environment-data:preview:adjust", | |
| 59 | tag: WS_METHODS.previewAdjust, | |
| 60 | scheduler: lifecycleScheduler, | |
| 61 | concurrency: lifecycleConcurrency, | |
| 62 | }), | |
| 74 | 63 | refresh: createEnvironmentRpcCommand(runtime, { |
| 75 | 64 | label: "environment-data:preview:refresh", |
| 76 | 65 | tag: WS_METHODS.previewRefresh, |
| ... | ... | @@ -83,6 +72,10 @@ export function createPreviewEnvironmentAtoms<R, E>( |
| 83 | 72 | scheduler: lifecycleScheduler, |
| 84 | 73 | concurrency: lifecycleConcurrency, |
| 85 | 74 | }), |
| 75 | clearProfile: createEnvironmentRpcCommand(runtime, { | |
| 76 | label: "environment-data:preview:clear-profile", | |
| 77 | tag: WS_METHODS.previewClearProfile, | |
| 78 | }), | |
| 86 | 79 | reportStatus: createEnvironmentRpcCommand(runtime, { |
| 87 | 80 | label: "environment-data:preview:report-status", |
| 88 | 81 | tag: WS_METHODS.previewReportStatus, |
| ... | ... | @@ -93,24 +86,5 @@ export function createPreviewEnvironmentAtoms<R, E>( |
| 93 | 86 | JSON.stringify([environmentId, input.threadId, input.tabId]), |
| 94 | 87 | }, |
| 95 | 88 | }), |
| 96 | respondToAutomation: createEnvironmentRpcCommand(runtime, { | |
| 97 | label: "environment-data:preview:automation-respond", | |
| 98 | tag: WS_METHODS.previewAutomationRespond, | |
| 99 | scheduler: automationScheduler, | |
| 100 | concurrency: { | |
| 101 | mode: "singleFlight", | |
| 102 | key: ({ environmentId, input }) => | |
| 103 | JSON.stringify([environmentId, input.connectionId, input.requestId]), | |
| 104 | }, | |
| 105 | }), | |
| 106 | focusAutomationHost: createEnvironmentRpcCommand(runtime, { | |
| 107 | label: "environment-data:preview:automation-focus-host", | |
| 108 | tag: WS_METHODS.previewAutomationFocusHost, | |
| 109 | scheduler: automationScheduler, | |
| 110 | concurrency: { | |
| 111 | mode: "latest", | |
| 112 | key: previewAutomationHostFocusConcurrencyKey, | |
| 113 | }, | |
| 114 | }), | |
| 115 | 89 | }; |
| 116 | 90 | } |
packages/contracts/src/desktopBootstrap.ts+4| ... | ... | @@ -24,6 +24,10 @@ export const DesktopBackendBootstrap = Schema.Struct({ |
| 24 | 24 | otlpLogsUrl: Schema.optional(Schema.String), |
| 25 | 25 | desktopTelemetryFd: Schema.optionalKey(PositiveInt), |
| 26 | 26 | desktopTelemetryControlFd: Schema.optionalKey(PositiveInt), |
| 27 | /** Desktop -> server: the desktop's browser tabs, as newline-delimited JSON. */ | |
| 28 | desktopBrowserFd: Schema.optionalKey(PositiveInt), | |
| 29 | /** Server -> desktop: commands for those tabs. */ | |
| 30 | desktopBrowserControlFd: Schema.optionalKey(PositiveInt), | |
| 27 | 31 | resourceMonitorPath: Schema.optionalKey(TrimmedNonEmptyString), |
| 28 | 32 | }); |
| 29 | 33 |
packages/contracts/src/desktopBrowser.ts created+46| ... | ... | @@ -0,0 +1,46 @@ |
| 1 | import * as Schema from "effect/Schema"; | |
| 2 | ||
| 3 | import { 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 | ||
| 15 | const TabKey = { | |
| 16 | threadId: TrimmedNonEmptyString, | |
| 17 | tabId: TrimmedNonEmptyString, | |
| 18 | }; | |
| 19 | ||
| 20 | /** Desktop -> server. */ | |
| 21 | export 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 | ]); | |
| 29 | export type DesktopBrowserEvent = typeof DesktopBrowserEvent.Type; | |
| 30 | ||
| 31 | /** Server -> desktop. */ | |
| 32 | export 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 | ]); | |
| 46 | export type DesktopBrowserCommand = typeof DesktopBrowserCommand.Type; |
packages/contracts/src/environment.ts+4| ... | ... | @@ -208,6 +208,10 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ |
| 208 | 208 | desktop servers whose app predates the remote trigger, where clients |
| 209 | 209 | must keep telling the user to update the app on that machine. */ |
| 210 | 210 | desktopAppUpdate: Schema.optionalKey(Schema.Boolean), |
| 211 | /** Server hosts preview tabs in its own headless Chromium (`runtime: | |
| 212 | "server"`) and streams them over `/api/preview-stream`. Clients | |
| 213 | without a local browser runtime open server tabs here. */ | |
| 214 | serverBrowser: Schema.optionalKey(Schema.Boolean), | |
| 211 | 215 | }); |
| 212 | 216 | export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; |
| 213 | 217 |
packages/contracts/src/index.ts+1| ... | ... | @@ -9,6 +9,7 @@ export * from "./environment.ts"; |
| 9 | 9 | export * from "./environmentHttp.ts"; |
| 10 | 10 | export * from "./relayClient.ts"; |
| 11 | 11 | export * from "./desktopBootstrap.ts"; |
| 12 | export * from "./desktopBrowser.ts"; | |
| 12 | 13 | export * from "./desktopAppActivation.ts"; |
| 13 | 14 | export * from "./remoteAccess.ts"; |
| 14 | 15 | export * from "./ipc.ts"; |
packages/contracts/src/ipc.ts+12-56| ... | ... | @@ -1,15 +1,5 @@ |
| 1 | 1 | import * as Schema from "effect/Schema"; |
| 2 | 2 | |
| 3 | import { | |
| 4 | PreviewAutomationClickInput, | |
| 5 | PreviewAutomationEvaluateInput, | |
| 6 | PreviewAutomationPressInput, | |
| 7 | PreviewAutomationScrollInput, | |
| 8 | PreviewAutomationSnapshot, | |
| 9 | PreviewAutomationStatus, | |
| 10 | PreviewAutomationTypeInput, | |
| 11 | PreviewAutomationWaitForInput, | |
| 12 | } from "./previewAutomation.ts"; | |
| 13 | 3 | import { SnapShotSource } from "./chatAttachment.ts"; |
| 14 | 4 | import { EnvironmentId, TrimmedNonEmptyString } from "./baseSchemas.ts"; |
| 15 | 5 | import { BrowserProfileId } from "./browserProfile.ts"; |
| ... | ... | @@ -647,12 +637,6 @@ export const DesktopPreviewTabIdSchema = Schema.String.check(Schema.isTrimmed()) |
| 647 | 637 | Schema.isNonEmpty(), |
| 648 | 638 | ); |
| 649 | 639 | |
| 650 | export const DesktopPreviewAutomationStatusSchema = Schema.Struct({ | |
| 651 | ...PreviewAutomationStatus.fields, | |
| 652 | tabId: Schema.NullOr(DesktopPreviewTabIdSchema), | |
| 653 | }); | |
| 654 | export type DesktopPreviewAutomationStatus = typeof DesktopPreviewAutomationStatusSchema.Type; | |
| 655 | ||
| 656 | 640 | export interface DesktopPreviewPointerEvent { |
| 657 | 641 | tabId: string; |
| 658 | 642 | phase: "move" | "click"; |
| ... | ... | @@ -1026,11 +1010,16 @@ export const DesktopPreviewCreateTabInputSchema = Schema.Struct({ |
| 1026 | 1010 | tabId: DesktopPreviewTabIdSchema, |
| 1027 | 1011 | zoomFactor: Schema.optional(Schema.Number.check(Schema.isGreaterThan(0))), |
| 1028 | 1012 | colorScheme: Schema.optional(DesktopPreviewColorSchemeSchema), |
| 1013 | serverTab: Schema.optional( | |
| 1014 | Schema.Struct({ threadId: TrimmedNonEmptyString, tabId: TrimmedNonEmptyString }), | |
| 1015 | ), | |
| 1029 | 1016 | }); |
| 1030 | 1017 | |
| 1031 | 1018 | export interface DesktopPreviewTabDefaults { |
| 1032 | 1019 | readonly zoomFactor?: number | undefined; |
| 1033 | 1020 | readonly colorScheme?: DesktopPreviewColorScheme | undefined; |
| 1021 | /** A tab of the desktop's own server: the server drives it through the desktop browser channel. */ | |
| 1022 | readonly serverTab?: { readonly threadId: string; readonly tabId: string } | undefined; | |
| 1034 | 1023 | } |
| 1035 | 1024 | |
| 1036 | 1025 | export const DesktopPreviewRegisterWebviewInputSchema = Schema.Struct({ |
| ... | ... | @@ -1065,6 +1054,11 @@ export const DesktopPreviewSetColorSchemeInputSchema = Schema.Struct({ |
| 1065 | 1054 | colorScheme: DesktopPreviewColorSchemeSchema, |
| 1066 | 1055 | }); |
| 1067 | 1056 | |
| 1057 | export const DesktopPreviewSetZoomFactorInputSchema = Schema.Struct({ | |
| 1058 | tabId: DesktopPreviewTabIdSchema, | |
| 1059 | zoomFactor: Schema.Number.check(Schema.isGreaterThan(0)), | |
| 1060 | }); | |
| 1061 | ||
| 1068 | 1062 | export const DesktopPreviewSetAudioMutedInputSchema = Schema.Struct({ |
| 1069 | 1063 | tabId: DesktopPreviewTabIdSchema, |
| 1070 | 1064 | audioMuted: Schema.Boolean, |
| ... | ... | @@ -1084,36 +1078,6 @@ export const DesktopPreviewRecordingSaveInputSchema = Schema.Struct({ |
| 1084 | 1078 | data: Schema.Uint8Array, |
| 1085 | 1079 | }); |
| 1086 | 1080 | |
| 1087 | export const DesktopPreviewAutomationClickInputSchema = Schema.Struct({ | |
| 1088 | tabId: DesktopPreviewTabIdSchema, | |
| 1089 | input: PreviewAutomationClickInput, | |
| 1090 | }); | |
| 1091 | ||
| 1092 | export const DesktopPreviewAutomationTypeInputSchema = Schema.Struct({ | |
| 1093 | tabId: DesktopPreviewTabIdSchema, | |
| 1094 | input: PreviewAutomationTypeInput, | |
| 1095 | }); | |
| 1096 | ||
| 1097 | export const DesktopPreviewAutomationPressInputSchema = Schema.Struct({ | |
| 1098 | tabId: DesktopPreviewTabIdSchema, | |
| 1099 | input: PreviewAutomationPressInput, | |
| 1100 | }); | |
| 1101 | ||
| 1102 | export const DesktopPreviewAutomationScrollInputSchema = Schema.Struct({ | |
| 1103 | tabId: DesktopPreviewTabIdSchema, | |
| 1104 | input: PreviewAutomationScrollInput, | |
| 1105 | }); | |
| 1106 | ||
| 1107 | export const DesktopPreviewAutomationEvaluateInputSchema = Schema.Struct({ | |
| 1108 | tabId: DesktopPreviewTabIdSchema, | |
| 1109 | input: PreviewAutomationEvaluateInput, | |
| 1110 | }); | |
| 1111 | ||
| 1112 | export const DesktopPreviewAutomationWaitForInputSchema = Schema.Struct({ | |
| 1113 | tabId: DesktopPreviewTabIdSchema, | |
| 1114 | input: PreviewAutomationWaitForInput, | |
| 1115 | }); | |
| 1116 | ||
| 1117 | 1081 | /** |
| 1118 | 1082 | * A System Settings pane the app can deep-link to. The identifier crosses IPC |
| 1119 | 1083 | * rather than a URL, so the renderer can only reach these known destinations. |
| ... | ... | @@ -1272,6 +1236,8 @@ export interface DesktopPreviewBridge { |
| 1272 | 1236 | zoomIn: (tabId: string) => Promise<void>; |
| 1273 | 1237 | zoomOut: (tabId: string) => Promise<void>; |
| 1274 | 1238 | resetZoom: (tabId: string) => Promise<void>; |
| 1239 | /** Sets a tab's zoom to the factor its environment published, for tabs the server drives. */ | |
| 1240 | setZoomFactor: (tabId: string, zoomFactor: number) => Promise<void>; | |
| 1275 | 1241 | /** Reload bypassing the HTTP cache. */ |
| 1276 | 1242 | hardReload: (tabId: string) => Promise<void>; |
| 1277 | 1243 | /** |
| ... | ... | @@ -1337,16 +1303,6 @@ export interface DesktopPreviewBridge { |
| 1337 | 1303 | ) => Promise<DesktopPreviewRecordingArtifact>; |
| 1338 | 1304 | onFrame: (listener: (frame: DesktopPreviewRecordingFrame) => void) => () => void; |
| 1339 | 1305 | }; |
| 1340 | automation: { | |
| 1341 | status: (tabId: string) => Promise<DesktopPreviewAutomationStatus>; | |
| 1342 | snapshot: (tabId: string) => Promise<PreviewAutomationSnapshot>; | |
| 1343 | click: (tabId: string, input: PreviewAutomationClickInput) => Promise<void>; | |
| 1344 | type: (tabId: string, input: PreviewAutomationTypeInput) => Promise<void>; | |
| 1345 | press: (tabId: string, input: PreviewAutomationPressInput) => Promise<void>; | |
| 1346 | scroll: (tabId: string, input: PreviewAutomationScrollInput) => Promise<void>; | |
| 1347 | evaluate: (tabId: string, input: PreviewAutomationEvaluateInput) => Promise<unknown>; | |
| 1348 | waitFor: (tabId: string, input: PreviewAutomationWaitForInput) => Promise<void>; | |
| 1349 | }; | |
| 1350 | 1306 | onStateChange: (listener: (tabId: string, state: DesktopPreviewTabState) => void) => () => void; |
| 1351 | 1307 | onPointerEvent: (listener: (event: DesktopPreviewPointerEvent) => void) => () => void; |
| 1352 | 1308 | } |
packages/contracts/src/preview.ts+89-1| ... | ... | @@ -162,6 +162,29 @@ export const PreviewNavStatus = Schema.Union([ |
| 162 | 162 | ]); |
| 163 | 163 | export type PreviewNavStatus = typeof PreviewNavStatus.Type; |
| 164 | 164 | |
| 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 | */ | |
| 171 | export const PreviewRuntime = Schema.Literals(["desktop", "server"]); | |
| 172 | export 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 | */ | |
| 180 | export 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 | }); | |
| 185 | export type PreviewStreamHostSetup = typeof PreviewStreamHostSetup.Type; | |
| 186 | export const PREVIEW_STREAM_HOST_SETUP_CLOSE_CODE = 4503; | |
| 187 | ||
| 165 | 188 | export const PreviewSessionSnapshot = Schema.Struct({ |
| 166 | 189 | threadId: TrimmedNonEmptyString, |
| 167 | 190 | tabId: PreviewTabId, |
| ... | ... | @@ -170,12 +193,23 @@ export const PreviewSessionSnapshot = Schema.Struct({ |
| 170 | 193 | canGoForward: Schema.Boolean, |
| 171 | 194 | /** Missing snapshots from older servers are treated as fill-panel mode. */ |
| 172 | 195 | viewport: Schema.optional(PreviewViewportSetting), |
| 196 | /** Server tabs only; the desktop keeps its own tabs' appearance. Absent means `system`. */ | |
| 197 | colorScheme: Schema.optional(PreviewAppearancePreference), | |
| 198 | /** Server tabs only. Absent means 1. */ | |
| 199 | zoomFactor: Schema.optional(PreviewZoomFactor), | |
| 173 | 200 | /** |
| 174 | 201 | * Browser profile the tab's Chromium partition is derived from. Fixed at |
| 175 | 202 | * open: Electron only honours a `<webview>`'s partition before attach, so |
| 176 | 203 | * switching would require tearing the guest down and losing page state. |
| 177 | 204 | */ |
| 178 | 205 | profileId: Schema.optional(BrowserProfileId), |
| 206 | runtime: Schema.optional(PreviewRuntime), | |
| 207 | /** Authenticated provider session owning an isolated server tab. */ | |
| 208 | automationOwner: Schema.optional(Schema.String), | |
| 209 | /** An agent opened this tab and asked to show it, so viewers float it. */ | |
| 210 | reveal: Schema.optional(Schema.Boolean), | |
| 211 | /** A fresh presentation request, including whether it overrides automatic-floating settings. */ | |
| 212 | revealRequest: Schema.optional(Schema.Struct({ id: Schema.String, force: Schema.Boolean })), | |
| 179 | 213 | updatedAt: Schema.String, |
| 180 | 214 | }); |
| 181 | 215 | export type PreviewSessionSnapshot = typeof PreviewSessionSnapshot.Type; |
| ... | ... | @@ -193,6 +227,10 @@ export const PreviewOpenInput = Schema.Struct({ |
| 193 | 227 | viewport: Schema.optional(PreviewViewportSetting), |
| 194 | 228 | /** Omit to open under the client's configured default profile. */ |
| 195 | 229 | profileId: Schema.optional(BrowserProfileId), |
| 230 | /** Omit for a desktop tab. `server` requires the `serverBrowser` capability. */ | |
| 231 | runtime: Schema.optional(PreviewRuntime), | |
| 232 | /** Set by agent opens that should float for viewers; see the snapshot field. */ | |
| 233 | reveal: Schema.optional(Schema.Boolean), | |
| 196 | 234 | }); |
| 197 | 235 | export type PreviewOpenInput = typeof PreviewOpenInput.Type; |
| 198 | 236 | |
| ... | ... | @@ -226,12 +264,33 @@ export const PreviewResizeInput = Schema.Struct({ |
| 226 | 264 | }); |
| 227 | 265 | export type PreviewResizeInput = typeof PreviewResizeInput.Type; |
| 228 | 266 | |
| 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 | */ | |
| 271 | export 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 | }); | |
| 281 | export type PreviewAdjustInput = typeof PreviewAdjustInput.Type; | |
| 282 | ||
| 229 | 283 | export const PreviewCloseInput = Schema.Struct({ |
| 230 | 284 | threadId: ThreadId, |
| 231 | 285 | tabId: Schema.optional(PreviewTabId), |
| 232 | 286 | }); |
| 233 | 287 | export type PreviewCloseInput = typeof PreviewCloseInput.Type; |
| 234 | 288 | |
| 289 | export const PreviewClearProfileInput = Schema.Struct({ | |
| 290 | profileId: BrowserProfileId, | |
| 291 | }); | |
| 292 | export type PreviewClearProfileInput = typeof PreviewClearProfileInput.Type; | |
| 293 | ||
| 235 | 294 | export const PreviewListInput = Schema.Struct({ |
| 236 | 295 | threadId: ThreadId, |
| 237 | 296 | }); |
| ... | ... | @@ -272,6 +331,13 @@ const PreviewResizedEvent = Schema.Struct({ |
| 272 | 331 | ...PreviewEventBaseSchema.fields, |
| 273 | 332 | type: Schema.Literal("resized"), |
| 274 | 333 | snapshot: PreviewSessionSnapshot, |
| 334 | /** A one-off action for the server's browser, such as a hard reload. */ | |
| 335 | request: Schema.optional( | |
| 336 | Schema.Struct({ | |
| 337 | hardReload: Schema.optional(Schema.Boolean), | |
| 338 | clear: Schema.optional(Schema.Literals(["cookies", "cache"])), | |
| 339 | }), | |
| 340 | ), | |
| 275 | 341 | }); |
| 276 | 342 | |
| 277 | 343 | const PreviewFailedEvent = Schema.Struct({ |
| ... | ... | @@ -350,5 +416,27 @@ export class PreviewInvalidUrlError extends Schema.TaggedError<PreviewInvalidUrl |
| 350 | 416 | } |
| 351 | 417 | } |
| 352 | 418 | |
| 353 | export const PreviewError = Schema.Union([PreviewSessionLookupError, PreviewInvalidUrlError]); | |
| 419 | export 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 | ||
| 428 | export 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 | ||
| 437 | export const PreviewError = Schema.Union([ | |
| 438 | PreviewSessionLookupError, | |
| 439 | PreviewInvalidUrlError, | |
| 440 | PreviewControlRequiredError, | |
| 441 | ]); | |
| 354 | 442 | export type PreviewError = typeof PreviewError.Type; |
packages/contracts/src/previewAutomation.ts+224-17| ... | ... | @@ -39,13 +39,22 @@ export const PREVIEW_AUTOMATION_V1_OPERATIONS = [ |
| 39 | 39 | ] as const; |
| 40 | 40 | |
| 41 | 41 | /** Advertised by current desktop hosts for mixed-version routing. */ |
| 42 | export const PREVIEW_AUTOMATION_OPERATIONS = [ | |
| 42 | const PREVIEW_AUTOMATION_OPERATIONS = [ | |
| 43 | 43 | ...PREVIEW_AUTOMATION_V1_OPERATIONS, |
| 44 | 44 | "resize", |
| 45 | 45 | "setColorScheme", |
| 46 | 46 | ] as const; |
| 47 | 47 | |
| 48 | export const PreviewAutomationOperation = Schema.Literals(PREVIEW_AUTOMATION_OPERATIONS); | |
| 48 | export const PREVIEW_AUTOMATION_SERVER_OPERATIONS = [ | |
| 49 | ...PREVIEW_AUTOMATION_OPERATIONS, | |
| 50 | "dialog", | |
| 51 | "close", | |
| 52 | "upload", | |
| 53 | "hover", | |
| 54 | "select", | |
| 55 | "drag", | |
| 56 | ] as const; | |
| 57 | export const PreviewAutomationOperation = Schema.Literals(PREVIEW_AUTOMATION_SERVER_OPERATIONS); | |
| 49 | 58 | export type PreviewAutomationOperation = typeof PreviewAutomationOperation.Type; |
| 50 | 59 | |
| 51 | 60 | const PreviewAutomationTabTargetFields = { |
| ... | ... | @@ -70,13 +79,66 @@ export const PreviewAutomationStatus = Schema.Struct({ |
| 70 | 79 | url: Schema.NullOr(Schema.String), |
| 71 | 80 | title: Schema.NullOr(Schema.String), |
| 72 | 81 | loading: Schema.Boolean, |
| 82 | control: Schema.optional( | |
| 83 | Schema.Struct({ | |
| 84 | owner: Schema.Literals(["agent", "human", "unclaimed"]), | |
| 85 | ownedByCaller: Schema.Boolean, | |
| 86 | generation: Schema.Number, | |
| 87 | }), | |
| 88 | ), | |
| 89 | dialog: Schema.optional( | |
| 90 | Schema.NullOr( | |
| 91 | Schema.Struct({ | |
| 92 | type: Schema.String, | |
| 93 | message: Schema.String, | |
| 94 | defaultValue: Schema.String, | |
| 95 | }), | |
| 96 | ), | |
| 97 | ), | |
| 73 | 98 | /** Optional for compatibility with desktop hosts predating viewport sizing. */ |
| 74 | 99 | viewportSetting: Schema.optional(PreviewViewportSetting), |
| 75 | 100 | /** Measured guest-page viewport in CSS pixels when a webview is ready. */ |
| 76 | 101 | viewport: Schema.optional(PreviewRenderedViewportSize), |
| 102 | /** Server hosts: a file picker the page opened, answered with preview_upload. */ | |
| 103 | fileChooser: Schema.optional( | |
| 104 | Schema.NullOr(Schema.Struct({ multiple: Schema.Boolean, accept: Schema.String })), | |
| 105 | ), | |
| 106 | /** Server hosts: files this tab downloaded, saved on the environment until the tab closes. */ | |
| 107 | downloads: Schema.optional( | |
| 108 | Schema.Array( | |
| 109 | Schema.Struct({ | |
| 110 | fileName: Schema.String, | |
| 111 | path: Schema.String, | |
| 112 | sizeBytes: Schema.Number, | |
| 113 | url: Schema.String, | |
| 114 | completedAt: Schema.String, | |
| 115 | }), | |
| 116 | ), | |
| 117 | ), | |
| 118 | /** Server hosts: every tab this agent session owns, including popups its pages opened. */ | |
| 119 | tabs: Schema.optional( | |
| 120 | Schema.Array( | |
| 121 | Schema.Struct({ | |
| 122 | tabId: PreviewTabId, | |
| 123 | url: Schema.NullOr(Schema.String), | |
| 124 | openerTabId: Schema.optional(PreviewTabId), | |
| 125 | }), | |
| 126 | ), | |
| 127 | ), | |
| 77 | 128 | }); |
| 78 | 129 | export type PreviewAutomationStatus = typeof PreviewAutomationStatus.Type; |
| 79 | 130 | |
| 131 | export 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 | }); | |
| 140 | export type PreviewAutomationDialogInput = typeof PreviewAutomationDialogInput.Type; | |
| 141 | ||
| 80 | 142 | export const PreviewAutomationOpenInput = Schema.Struct({ |
| 81 | 143 | ...PreviewAutomationTabTargetFields, |
| 82 | 144 | url: Schema.optional(BoundedUrl).annotate({ |
| ... | ... | @@ -282,7 +344,7 @@ export type PreviewAutomationSetColorSchemeResult = |
| 282 | 344 | |
| 283 | 345 | const Locator = TrimmedNonEmptyString.annotate({ |
| 284 | 346 | description: |
| 285 | "Playwright selector, preferably role/text based, for example role=button[name='Send'] or text=Continue. Use snapshot first to inspect the page.", | |
| 347 | "Use locator='aria-ref=<ref>' with a ref from the latest server snapshot (including iframe elements), or a unique Playwright role/text selector. Refs expire on navigation, a new snapshot, or control handoff; refresh the snapshot after a stale-ref error.", | |
| 286 | 348 | }); |
| 287 | 349 | |
| 288 | 350 | const LegacySelector = TrimmedNonEmptyString.annotate({ |
| ... | ... | @@ -290,7 +352,7 @@ const LegacySelector = TrimmedNonEmptyString.annotate({ |
| 290 | 352 | "Legacy CSS selector such as button[type='submit']. Prefer locator for resilient role/text targeting.", |
| 291 | 353 | }); |
| 292 | 354 | |
| 293 | export const PreviewAutomationClickInput = Schema.Struct({ | |
| 355 | const PointerTargetFields = { | |
| 294 | 356 | ...PreviewAutomationTabTargetFields, |
| 295 | 357 | selector: Schema.optional(LegacySelector).annotate({ |
| 296 | 358 | description: |
| ... | ... | @@ -298,7 +360,7 @@ export const PreviewAutomationClickInput = Schema.Struct({ |
| 298 | 360 | }), |
| 299 | 361 | locator: Schema.optional(Locator).annotate({ |
| 300 | 362 | description: |
| 301 | "Playwright selector, preferably role/text based, for example role=button[name='Send'] or text=Continue. Use snapshot first to inspect the page.", | |
| 363 | "Use locator='aria-ref=<ref>' with a ref from the latest server snapshot (including iframe elements), or a unique Playwright role/text selector. Refs expire on navigation, a new snapshot, or control handoff; refresh the snapshot after a stale-ref error.", | |
| 302 | 364 | }), |
| 303 | 365 | x: Schema.optional( |
| 304 | 366 | Schema.Finite.annotate({ |
| ... | ... | @@ -311,24 +373,128 @@ export const PreviewAutomationClickInput = Schema.Struct({ |
| 311 | 373 | }), |
| 312 | 374 | ), |
| 313 | 375 | 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 | ||
| 378 | const 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 | ||
| 395 | export const PreviewAutomationClickInput = Schema.Struct({ | |
| 396 | ...PointerTargetFields, | |
| 397 | button: Schema.optional( | |
| 398 | Schema.Literals(["left", "right", "middle"]).annotate({ | |
| 399 | description: "Mouse button. Defaults to left; right opens the page's context menu.", | |
| 324 | 400 | }), |
| 325 | ) | |
| 401 | ), | |
| 402 | clickCount: Schema.optional( | |
| 403 | Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 3 })).annotate({ | |
| 404 | description: "1 for a click, 2 for a double-click, 3 for a triple-click. Defaults to 1.", | |
| 405 | }), | |
| 406 | ), | |
| 407 | }) | |
| 408 | .check(singlePointerTarget) | |
| 326 | 409 | .annotate({ |
| 327 | 410 | description: |
| 328 | 411 | "Clicks one target. Provide exactly one of locator, selector, or the x/y coordinate pair.", |
| 329 | 412 | }); |
| 330 | 413 | export type PreviewAutomationClickInput = typeof PreviewAutomationClickInput.Type; |
| 331 | 414 | |
| 415 | export 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 | }); | |
| 421 | export type PreviewAutomationHoverInput = typeof PreviewAutomationHoverInput.Type; | |
| 422 | ||
| 423 | export 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." }); | |
| 445 | export type PreviewAutomationSelectInput = typeof PreviewAutomationSelectInput.Type; | |
| 446 | ||
| 447 | export const PreviewAutomationSelectResult = Schema.Struct({ | |
| 448 | selected: Schema.Array(Schema.String).annotate({ | |
| 449 | description: "The values of the options now selected.", | |
| 450 | }), | |
| 451 | }); | |
| 452 | export type PreviewAutomationSelectResult = typeof PreviewAutomationSelectResult.Type; | |
| 453 | ||
| 454 | /** A required locator field. Its JSON schema keeps only the description on its last check. */ | |
| 455 | const DescribedLocator = (description: string) => | |
| 456 | Schema.String.check(Schema.isTrimmed()) | |
| 457 | .check(Schema.isNonEmpty({ description })) | |
| 458 | .annotateKey({ description }); | |
| 459 | ||
| 460 | export 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." }); | |
| 468 | export type PreviewAutomationDragInput = typeof PreviewAutomationDragInput.Type; | |
| 469 | ||
| 470 | export 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 | }); | |
| 496 | export type PreviewAutomationUploadInput = typeof PreviewAutomationUploadInput.Type; | |
| 497 | ||
| 332 | 498 | export const PreviewAutomationTypeInput = Schema.Struct({ |
| 333 | 499 | ...PreviewAutomationTabTargetFields, |
| 334 | 500 | text: Schema.String.annotate({ description: "Literal text to insert." }), |
| ... | ... | @@ -607,6 +773,8 @@ export const PreviewAutomationRequest = Schema.Struct({ |
| 607 | 773 | threadId: ThreadId, |
| 608 | 774 | tabId: Schema.optional(PreviewTabId), |
| 609 | 775 | tabIdExplicit: Schema.optional(Schema.Boolean), |
| 776 | /** Supplied by the broker from authenticated scope, never from tool input. */ | |
| 777 | agentSessionId: Schema.optional(Schema.String), | |
| 610 | 778 | operation: PreviewAutomationOperation, |
| 611 | 779 | input: Schema.Unknown, |
| 612 | 780 | timeoutMs: Schema.Int.check(Schema.isGreaterThan(0)), |
| ... | ... | @@ -684,6 +852,9 @@ const PreviewAutomationScopeErrorFields = { |
| 684 | 852 | providerInstanceId: ProviderInstanceId, |
| 685 | 853 | }; |
| 686 | 854 | |
| 855 | /** The automation host id of an environment's own server browser. */ | |
| 856 | export const SERVER_BROWSER_AUTOMATION_CLIENT_ID = "server-browser"; | |
| 857 | ||
| 687 | 858 | const PreviewAutomationRequestErrorFields = { |
| 688 | 859 | ...PreviewAutomationScopeErrorFields, |
| 689 | 860 | clientId: TrimmedNonEmptyString, |
| ... | ... | @@ -767,14 +938,39 @@ export class PreviewAutomationTimeoutError extends Schema.TaggedError<PreviewAut |
| 767 | 938 | } |
| 768 | 939 | } |
| 769 | 940 | |
| 941 | export const PreviewAutomationControlReason = Schema.Literals([ | |
| 942 | "agentMismatch", | |
| 943 | "humanControl", | |
| 944 | "tabRequired", | |
| 945 | "closed", | |
| 946 | "interrupted", | |
| 947 | "dialogPending", | |
| 948 | "tabLimit", | |
| 949 | ]); | |
| 950 | export type PreviewAutomationControlReason = typeof PreviewAutomationControlReason.Type; | |
| 951 | ||
| 770 | 952 | export class PreviewAutomationControlInterruptedError extends Schema.TaggedError<PreviewAutomationControlInterruptedError>()( |
| 771 | 953 | "PreviewAutomationControlInterruptedError", |
| 772 | 954 | { |
| 773 | 955 | ...PreviewAutomationRequestErrorFields, |
| 774 | 956 | ...PreviewAutomationRemoteDiagnosticFields, |
| 957 | reason: Schema.optional(PreviewAutomationControlReason), | |
| 775 | 958 | }, |
| 776 | 959 | ) { |
| 777 | 960 | override get message(): string { |
| 961 | if (this.reason === "agentMismatch") | |
| 962 | return "This browser tab belongs to another agent session or a human. Open your own tab."; | |
| 963 | if (this.reason === "humanControl") | |
| 964 | return "A human controls this browser tab. Wait until they release control."; | |
| 965 | if (this.reason === "tabRequired") | |
| 966 | return "Multiple browser tabs belong to this session. Pass a tabId from preview_open or from the tabs listed by preview_status."; | |
| 967 | if (this.reason === "dialogPending") | |
| 968 | return "A browser dialog is pending. Read preview_status and resolve it with preview_dialog."; | |
| 969 | if (this.reason === "tabLimit") | |
| 970 | return "Too many server browser tabs are open. Close one with t3_preview_close, or reuse a tabId from preview_status tabs."; | |
| 971 | if (this.reason === "closed") return "This browser tab is closed. Call preview_open."; | |
| 972 | if (this.reason === "interrupted") | |
| 973 | return "Browser control changed. Take a fresh snapshot before trying again."; | |
| 778 | 974 | return `Preview automation ${this.operation} was interrupted on client ${this.clientId}.`; |
| 779 | 975 | } |
| 780 | 976 | } |
| ... | ... | @@ -784,10 +980,17 @@ export class PreviewAutomationExecutionError extends Schema.TaggedError<PreviewA |
| 784 | 980 | { |
| 785 | 981 | ...PreviewAutomationRequestErrorFields, |
| 786 | 982 | ...PreviewAutomationRemoteDiagnosticFields, |
| 983 | /** | |
| 984 | * What went wrong, as the server's own browser reported it, such as a | |
| 985 | * page that refused the connection. Absent for other hosts. | |
| 986 | */ | |
| 987 | reason: Schema.optional(Schema.String), | |
| 787 | 988 | }, |
| 788 | 989 | ) { |
| 789 | 990 | override get message(): string { |
| 790 | return `Preview automation ${this.operation} failed on client ${this.clientId}.`; | |
| 991 | return this.reason === undefined | |
| 992 | ? `Preview automation ${this.operation} failed on client ${this.clientId}.` | |
| 993 | : `Preview automation ${this.operation} failed: ${this.reason}`; | |
| 791 | 994 | } |
| 792 | 995 | } |
| 793 | 996 | |
| ... | ... | @@ -798,9 +1001,13 @@ export class PreviewAutomationInvalidSelectorError extends Schema.TaggedError<Pr |
| 798 | 1001 | ...PreviewAutomationRemoteDiagnosticFields, |
| 799 | 1002 | selectorKind: Schema.optional(Schema.Literals(["locator", "selector"])), |
| 800 | 1003 | selectorLength: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), |
| 1004 | /** The selector was an `aria-ref` from an older snapshot. */ | |
| 1005 | staleRef: Schema.optional(Schema.Boolean), | |
| 801 | 1006 | }, |
| 802 | 1007 | ) { |
| 803 | 1008 | override get message(): string { |
| 1009 | if (this.staleRef) | |
| 1010 | return "This element ref is stale. Navigation, dialogs, and new snapshots replace refs; take a fresh snapshot and use its refs."; | |
| 804 | 1011 | if (this.selectorKind !== undefined && this.selectorLength !== undefined) { |
| 805 | 1012 | return `Preview automation ${this.operation} received an invalid ${this.selectorKind} (${this.selectorLength} characters).`; |
| 806 | 1013 | } |
packages/contracts/src/rpc.ts+19-30| ... | ... | @@ -237,11 +237,14 @@ import { |
| 237 | 237 | PreviewEvent, |
| 238 | 238 | PreviewListInput, |
| 239 | 239 | PreviewListResult, |
| 240 | PreviewClearProfileError, | |
| 241 | PreviewClearProfileInput, | |
| 240 | 242 | PreviewNavigateInput, |
| 241 | 243 | PreviewOpenInput, |
| 242 | 244 | PreviewRefreshInput, |
| 243 | 245 | PreviewReportStatusInput, |
| 244 | 246 | PreviewResizeInput, |
| 247 | PreviewAdjustInput, | |
| 245 | 248 | PreviewSessionSnapshot, |
| 246 | 249 | } from "./preview.ts"; |
| 247 | 250 | import { |
| ... | ... | @@ -259,13 +262,7 @@ import { |
| 259 | 262 | DeviceSession, |
| 260 | 263 | DeviceShutdownInput, |
| 261 | 264 | } from "./device.ts"; |
| 262 | import { | |
| 263 | PreviewAutomationError, | |
| 264 | PreviewAutomationHost, | |
| 265 | PreviewAutomationHostFocus, | |
| 266 | PreviewAutomationResponse, | |
| 267 | PreviewAutomationStreamEvent, | |
| 268 | } from "./previewAutomation.ts"; | |
| 265 | import {} from "./previewAutomation.ts"; | |
| 269 | 266 | import { |
| 270 | 267 | ServerConfigStreamEvent, |
| 271 | 268 | DesktopUpdateCommitInput, |
| ... | ... | @@ -418,13 +415,12 @@ export const WS_METHODS = { |
| 418 | 415 | previewOpen: "preview.open", |
| 419 | 416 | previewNavigate: "preview.navigate", |
| 420 | 417 | previewResize: "preview.resize", |
| 418 | previewAdjust: "preview.adjust", | |
| 421 | 419 | previewRefresh: "preview.refresh", |
| 422 | 420 | previewClose: "preview.close", |
| 423 | 421 | previewList: "preview.list", |
| 422 | previewClearProfile: "preview.clearProfile", | |
| 424 | 423 | previewReportStatus: "preview.reportStatus", |
| 425 | previewAutomationConnect: "previewAutomation.connect", | |
| 426 | previewAutomationRespond: "previewAutomation.respond", | |
| 427 | previewAutomationFocusHost: "previewAutomation.focusHost", | |
| 428 | 424 | |
| 429 | 425 | // Device methods |
| 430 | 426 | deviceConfigure: "device.configure", |
| ... | ... | @@ -1398,6 +1394,12 @@ const WsPreviewResizeRpc = Rpc.make(WS_METHODS.previewResize, { |
| 1398 | 1394 | error: Schema.Union([PreviewError, EnvironmentAuthorizationError]), |
| 1399 | 1395 | }); |
| 1400 | 1396 | |
| 1397 | const WsPreviewAdjustRpc = Rpc.make(WS_METHODS.previewAdjust, { | |
| 1398 | payload: PreviewAdjustInput, | |
| 1399 | success: PreviewSessionSnapshot, | |
| 1400 | error: Schema.Union([PreviewError, EnvironmentAuthorizationError]), | |
| 1401 | }); | |
| 1402 | ||
| 1401 | 1403 | const WsPreviewRefreshRpc = Rpc.make(WS_METHODS.previewRefresh, { |
| 1402 | 1404 | payload: PreviewRefreshInput, |
| 1403 | 1405 | error: Schema.Union([PreviewError, EnvironmentAuthorizationError]), |
| ... | ... | @@ -1414,28 +1416,16 @@ const WsPreviewListRpc = Rpc.make(WS_METHODS.previewList, { |
| 1414 | 1416 | error: EnvironmentAuthorizationError, |
| 1415 | 1417 | }); |
| 1416 | 1418 | |
| 1419 | const WsPreviewClearProfileRpc = Rpc.make(WS_METHODS.previewClearProfile, { | |
| 1420 | payload: PreviewClearProfileInput, | |
| 1421 | error: Schema.Union([PreviewClearProfileError, EnvironmentAuthorizationError]), | |
| 1422 | }); | |
| 1423 | ||
| 1417 | 1424 | const WsPreviewReportStatusRpc = Rpc.make(WS_METHODS.previewReportStatus, { |
| 1418 | 1425 | payload: PreviewReportStatusInput, |
| 1419 | 1426 | error: Schema.Union([PreviewError, EnvironmentAuthorizationError]), |
| 1420 | 1427 | }); |
| 1421 | 1428 | |
| 1422 | const WsPreviewAutomationConnectRpc = Rpc.make(WS_METHODS.previewAutomationConnect, { | |
| 1423 | payload: PreviewAutomationHost, | |
| 1424 | success: PreviewAutomationStreamEvent, | |
| 1425 | error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]), | |
| 1426 | stream: true, | |
| 1427 | }); | |
| 1428 | ||
| 1429 | const WsPreviewAutomationRespondRpc = Rpc.make(WS_METHODS.previewAutomationRespond, { | |
| 1430 | payload: PreviewAutomationResponse, | |
| 1431 | error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]), | |
| 1432 | }); | |
| 1433 | ||
| 1434 | const WsPreviewAutomationFocusHostRpc = Rpc.make(WS_METHODS.previewAutomationFocusHost, { | |
| 1435 | payload: PreviewAutomationHostFocus, | |
| 1436 | error: EnvironmentAuthorizationError, | |
| 1437 | }); | |
| 1438 | ||
| 1439 | 1429 | const WsSubscribePreviewEventsRpc = Rpc.make(WS_METHODS.subscribePreviewEvents, { |
| 1440 | 1430 | payload: Schema.Struct({}), |
| 1441 | 1431 | success: PreviewEvent, |
| ... | ... | @@ -1885,13 +1875,12 @@ export const WsRpcGroup = RpcGroup.make( |
| 1885 | 1875 | WsPreviewOpenRpc, |
| 1886 | 1876 | WsPreviewNavigateRpc, |
| 1887 | 1877 | WsPreviewResizeRpc, |
| 1878 | WsPreviewAdjustRpc, | |
| 1888 | 1879 | WsPreviewRefreshRpc, |
| 1889 | 1880 | WsPreviewCloseRpc, |
| 1890 | 1881 | WsPreviewListRpc, |
| 1882 | WsPreviewClearProfileRpc, | |
| 1891 | 1883 | WsPreviewReportStatusRpc, |
| 1892 | WsPreviewAutomationConnectRpc, | |
| 1893 | WsPreviewAutomationRespondRpc, | |
| 1894 | WsPreviewAutomationFocusHostRpc, | |
| 1895 | 1884 | WsSubscribePreviewEventsRpc, |
| 1896 | 1885 | WsSubscribeDiscoveredLocalServersRpc, |
| 1897 | 1886 | WsDeviceConfigureRpc, |
packages/shared/src/t3McpToolPresentation.ts+25| ... | ... | @@ -158,6 +158,11 @@ const T3_MCP_TOOLS: Readonly<Record<string, T3McpToolDefinition>> = { |
| 158 | 158 | "browser", |
| 159 | 159 | "browser", |
| 160 | 160 | ), |
| 161 | preview_dialog: tool( | |
| 162 | ["Respond", "Responding", "Responded", "to a preview browser dialog"], | |
| 163 | "browser", | |
| 164 | "browser", | |
| 165 | ), | |
| 161 | 166 | preview_snapshot: tool( |
| 162 | 167 | ["Take a snapshot of", "Taking a snapshot of", "Took a snapshot of", "the preview page"], |
| 163 | 168 | "browser", |
| ... | ... | @@ -175,6 +180,26 @@ const T3_MCP_TOOLS: Readonly<Record<string, T3McpToolDefinition>> = { |
| 175 | 180 | "browser", |
| 176 | 181 | ), |
| 177 | 182 | preview_type: tool(["Type", "Typing", "Typed", "in the preview browser"], "browser", "browser"), |
| 183 | preview_hover: tool( | |
| 184 | ["Hover", "Hovering", "Hovered", "in the preview browser"], | |
| 185 | "browser", | |
| 186 | "browser", | |
| 187 | ), | |
| 188 | preview_select: tool( | |
| 189 | ["Choose", "Choosing", "Chose", "an option in the preview browser"], | |
| 190 | "browser", | |
| 191 | "browser", | |
| 192 | ), | |
| 193 | preview_drag: tool( | |
| 194 | ["Drag", "Dragging", "Dragged", "in the preview browser"], | |
| 195 | "browser", | |
| 196 | "browser", | |
| 197 | ), | |
| 198 | preview_upload: tool( | |
| 199 | ["Upload", "Uploading", "Uploaded", "files to the preview browser"], | |
| 200 | "browser", | |
| 201 | "browser", | |
| 202 | ), | |
| 178 | 203 | preview_scroll: tool( |
| 179 | 204 | ["Scroll", "Scrolling", "Scrolled", "the preview browser"], |
| 180 | 205 | "browser", |
pnpm-lock.yaml+3| ... | ... | @@ -564,6 +564,9 @@ importers: |
| 564 | 564 | node-pty: |
| 565 | 565 | specifier: ^1.2.0-beta.15 |
| 566 | 566 | version: 1.2.0-beta.15(patch_hash=f2fe901c61cde17986240002d05c172d5d0272d83ffaab8d0ebeb922763be414) |
| 567 | playwright-core: | |
| 568 | specifier: 1.60.0 | |
| 569 | version: 1.60.0 | |
| 567 | 570 | proper-lockfile: |
| 568 | 571 | specifier: 4.1.2 |
| 569 | 572 | version: 4.1.2 |
scripts/lib/cli-external-packages.test.ts+1-1| ... | ... | @@ -83,7 +83,7 @@ describe("selectCliRuntimeExternalDependencies", () => { |
| 83 | 83 | it("selects every external root declared by the server", () => { |
| 84 | 84 | assert.deepStrictEqual( |
| 85 | 85 | Object.keys(selectCliRuntimeExternalDependencies(serverPackageJson.dependencies)).sort(), |
| 86 | ["@cursor/sdk", "@ff-labs/fff-node", "@napi-rs/keyring", "node-pty"], | |
| 86 | ["@cursor/sdk", "@ff-labs/fff-node", "@napi-rs/keyring", "node-pty", "playwright-core"], | |
| 87 | 87 | ); |
| 88 | 88 | }); |
| 89 | 89 | }); |
scripts/lib/cli-external-packages.ts+2| ... | ... | @@ -28,6 +28,8 @@ |
| 28 | 28 | export const CLI_RUNTIME_EXTERNAL_PREFIXES = [ |
| 29 | 29 | // Cursor ships computed Webpack imports and platform helper packages. |
| 30 | 30 | "@cursor/sdk", |
| 31 | // Playwright reads package.json and browsers.json beside its runtime modules. | |
| 32 | "playwright-core", | |
| 31 | 33 | "node-pty", |
| 32 | 34 | "ffi-rs", |
| 33 | 35 | "@yuuang/", |