| author | |
| committer | |
| log | f4c394323d458224b25898ef784ca0a0cc2969af |
| tree | 02accab0d2f9f26c406535d81335f52bbefe99b7 |
| parent | 2b06c08a4bdc1ffa46a37710d3bf27c80802a949 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>60 files changed, 4103 insertions(+), 594 deletions(-)
apps/desktop/src/electron/ElectronWindow.test.ts+1| ... | ... | @@ -94,6 +94,7 @@ describe("ElectronWindow", () => { |
| 94 | 94 | webPreferences: { |
| 95 | 95 | preload: "/tmp/preload.js", |
| 96 | 96 | partition: "persist:t3code-preview-test", |
| 97 | backgroundThrottling: null, | |
| 97 | 98 | sandbox: true, |
| 98 | 99 | contextIsolation: true, |
| 99 | 100 | nodeIntegration: false, |
apps/desktop/src/electron/ElectronWindow.ts+2| ... | ... | @@ -22,6 +22,7 @@ const ElectronWindowCreateOptions = Schema.Struct({ |
| 22 | 22 | webPreferences: Schema.Struct({ |
| 23 | 23 | preload: Schema.NullOr(Schema.String), |
| 24 | 24 | partition: Schema.NullOr(Schema.String), |
| 25 | backgroundThrottling: Schema.NullOr(Schema.Boolean), | |
| 25 | 26 | sandbox: Schema.NullOr(Schema.Boolean), |
| 26 | 27 | contextIsolation: Schema.NullOr(Schema.Boolean), |
| 27 | 28 | nodeIntegration: Schema.NullOr(Schema.Boolean), |
| ... | ... | @@ -179,6 +180,7 @@ export const make = Effect.gen(function* () { |
| 179 | 180 | webPreferences: { |
| 180 | 181 | preload: webPreferences?.preload ?? null, |
| 181 | 182 | partition: webPreferences?.partition ?? null, |
| 183 | backgroundThrottling: webPreferences?.backgroundThrottling ?? null, | |
| 182 | 184 | sandbox: webPreferences?.sandbox ?? null, |
| 183 | 185 | contextIsolation: webPreferences?.contextIsolation ?? null, |
| 184 | 186 | nodeIntegration: webPreferences?.nodeIntegration ?? null, |
apps/desktop/src/ipc/channels.ts+3| ... | ... | @@ -61,6 +61,9 @@ export const PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL = "desktop:preview-cancel-pick- |
| 61 | 61 | export const PREVIEW_CAPTURE_SCREENSHOT_CHANNEL = "desktop:preview-capture-screenshot"; |
| 62 | 62 | export const PREVIEW_REVEAL_ARTIFACT_CHANNEL = "desktop:preview-reveal-artifact"; |
| 63 | 63 | export const PREVIEW_COPY_ARTIFACT_CHANNEL = "desktop:preview-copy-artifact"; |
| 64 | export const PREVIEW_PICTURE_IN_PICTURE_OPEN_CHANNEL = "desktop:preview-pip-open"; | |
| 65 | export const PREVIEW_PICTURE_IN_PICTURE_CLOSE_CHANNEL = "desktop:preview-pip-close"; | |
| 66 | export const PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL = "desktop:preview-pip-frame"; | |
| 64 | 67 | export const PREVIEW_AUTOMATION_STATUS_CHANNEL = "desktop:preview-automation-status"; |
| 65 | 68 | export const PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL = "desktop:preview-automation-snapshot"; |
| 66 | 69 | export const PREVIEW_AUTOMATION_CLICK_CHANNEL = "desktop:preview-automation-click"; |
apps/desktop/src/ipc/methods/preview.ts+12| ... | ... | @@ -168,6 +168,16 @@ export const stopRecording = tabMethod( |
| 168 | 168 | "desktop.ipc.preview.stopRecording", |
| 169 | 169 | (manager, tabId) => manager.stopRecording(tabId), |
| 170 | 170 | ); |
| 171 | export const openPictureInPicture = tabMethod( | |
| 172 | IpcChannels.PREVIEW_PICTURE_IN_PICTURE_OPEN_CHANNEL, | |
| 173 | "desktop.ipc.preview.openPictureInPicture", | |
| 174 | (manager, tabId) => manager.openPictureInPicture(tabId), | |
| 175 | ); | |
| 176 | export const closePictureInPicture = tabMethod( | |
| 177 | IpcChannels.PREVIEW_PICTURE_IN_PICTURE_CLOSE_CHANNEL, | |
| 178 | "desktop.ipc.preview.closePictureInPicture", | |
| 179 | (manager, tabId) => manager.closePictureInPicture(tabId), | |
| 180 | ); | |
| 171 | 181 | |
| 172 | 182 | export const clearCookies = DesktopIpc.makeIpcMethod({ |
| 173 | 183 | channel: IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL, |
| ... | ... | @@ -367,6 +377,8 @@ export const methods = [ |
| 367 | 377 | captureScreenshot, |
| 368 | 378 | revealArtifact, |
| 369 | 379 | copyArtifactToClipboard, |
| 380 | openPictureInPicture, | |
| 381 | closePictureInPicture, | |
| 370 | 382 | automationStatus, |
| 371 | 383 | automationSnapshot, |
| 372 | 384 | automationClick, |
apps/desktop/src/preload.ts+6| ... | ... | @@ -179,6 +179,12 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 179 | 179 | ipcRenderer.invoke(IpcChannels.PREVIEW_REVEAL_ARTIFACT_CHANNEL, { path }), |
| 180 | 180 | copyArtifactToClipboard: (path) => |
| 181 | 181 | ipcRenderer.invoke(IpcChannels.PREVIEW_COPY_ARTIFACT_CHANNEL, { path }), |
| 182 | pictureInPicture: { | |
| 183 | open: (tabId) => | |
| 184 | ipcRenderer.invoke(IpcChannels.PREVIEW_PICTURE_IN_PICTURE_OPEN_CHANNEL, { tabId }), | |
| 185 | close: (tabId) => | |
| 186 | ipcRenderer.invoke(IpcChannels.PREVIEW_PICTURE_IN_PICTURE_CLOSE_CHANNEL, { tabId }), | |
| 187 | }, | |
| 182 | 188 | recording: { |
| 183 | 189 | startScreencast: (tabId) => |
| 184 | 190 | ipcRenderer.invoke(IpcChannels.PREVIEW_RECORDING_START_CHANNEL, { tabId }), |
apps/desktop/src/preview-pip-preload.ts created+17| ... | ... | @@ -0,0 +1,17 @@ |
| 1 | // @effect-diagnostics globalDate:off - This isolated Electron preload does not run inside an Effect runtime. | |
| 2 | import type { DesktopPreviewRecordingFrame } from "@t3tools/contracts"; | |
| 3 | import { contextBridge, ipcRenderer } from "electron"; | |
| 4 | ||
| 5 | import { PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL } from "./ipc/channels.ts"; | |
| 6 | ||
| 7 | contextBridge.exposeInMainWorld("previewPictureInPicture", { | |
| 8 | onFrame: (listener: (frame: DesktopPreviewRecordingFrame) => void) => { | |
| 9 | const wrappedListener = (_event: Electron.IpcRendererEvent, frame: unknown) => { | |
| 10 | if (typeof frame !== "object" || frame === null) return; | |
| 11 | listener(frame as DesktopPreviewRecordingFrame); | |
| 12 | }; | |
| 13 | ipcRenderer.on(PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL, wrappedListener); | |
| 14 | return () => | |
| 15 | ipcRenderer.removeListener(PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL, wrappedListener); | |
| 16 | }, | |
| 17 | }); |
apps/desktop/src/preview/Manager.test.ts+765-2| ... | ... | @@ -1,6 +1,8 @@ |
| 1 | 1 | import { it as effectIt } from "@effect/vitest"; |
| 2 | import type { DesktopPreviewRecordingFrame } from "@t3tools/contracts"; | |
| 2 | 3 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 3 | 4 | import * as Cause from "effect/Cause"; |
| 5 | import * as Deferred from "effect/Deferred"; | |
| 4 | 6 | import * as Effect from "effect/Effect"; |
| 5 | 7 | import * as Exit from "effect/Exit"; |
| 6 | 8 | import * as FileSystem from "effect/FileSystem"; |
| ... | ... | @@ -19,7 +21,15 @@ import * as ElectronWindow from "../electron/ElectronWindow.ts"; |
| 19 | 21 | import * as BrowserSession from "./BrowserSession.ts"; |
| 20 | 22 | import * as PreviewManager from "./Manager.ts"; |
| 21 | 23 | |
| 24 | describe("fitPictureInPictureContentSize", () => { | |
| 25 | it("fits landscape and portrait viewports without letterboxing the window", () => { | |
| 26 | expect(PreviewManager.fitPictureInPictureContentSize([480, 320], 16 / 9)).toEqual([480, 270]); | |
| 27 | expect(PreviewManager.fitPictureInPictureContentSize([480, 320], 9 / 16)).toEqual([240, 427]); | |
| 28 | }); | |
| 29 | }); | |
| 30 | ||
| 22 | 31 | const { |
| 32 | browserWindowConstructor, | |
| 23 | 33 | createFromPath, |
| 24 | 34 | fromId, |
| 25 | 35 | getFocusedWebContents, |
| ... | ... | @@ -29,8 +39,9 @@ const { |
| 29 | 39 | writeFile, |
| 30 | 40 | writeImage, |
| 31 | 41 | } = vi.hoisted(() => ({ |
| 42 | browserWindowConstructor: vi.fn(), | |
| 32 | 43 | createFromPath: vi.fn((): { readonly isEmpty: () => boolean } => ({ isEmpty: () => false })), |
| 33 | fromId: vi.fn(() => null), | |
| 44 | fromId: vi.fn((_id?: number) => null), | |
| 34 | 45 | getFocusedWebContents: vi.fn(() => null), |
| 35 | 46 | mkdir: vi.fn((_path: string) => undefined), |
| 36 | 47 | showItemInFolder: vi.fn(), |
| ... | ... | @@ -40,6 +51,7 @@ const { |
| 40 | 51 | })); |
| 41 | 52 | |
| 42 | 53 | vi.mock("electron", () => ({ |
| 54 | BrowserWindow: browserWindowConstructor, | |
| 43 | 55 | clipboard: { |
| 44 | 56 | writeImage, |
| 45 | 57 | }, |
| ... | ... | @@ -73,6 +85,10 @@ const environmentLayer = Layer.succeed( |
| 73 | 85 | DesktopEnvironment.DesktopEnvironment, |
| 74 | 86 | DesktopEnvironment.DesktopEnvironment.of({ |
| 75 | 87 | browserArtifactsDir: "/tmp/t3/dev/browser-artifacts", |
| 88 | dirname: "/tmp/t3/desktop", | |
| 89 | path: { | |
| 90 | join: (...parts: ReadonlyArray<string>) => parts.join("/"), | |
| 91 | }, | |
| 76 | 92 | } as DesktopEnvironment.DesktopEnvironment["Service"]), |
| 77 | 93 | ); |
| 78 | 94 | |
| ... | ... | @@ -92,7 +108,7 @@ const layer = PreviewManager.layer.pipe( |
| 92 | 108 | Layer.provideMerge(environmentLayer), |
| 93 | 109 | Layer.provideMerge(fileSystemLayer), |
| 94 | 110 | Layer.provideMerge(Path.layer), |
| 95 | Layer.provideMerge(Layer.succeed(HostProcessPlatform, "linux")), | |
| 111 | Layer.provideMerge(Layer.succeed(HostProcessPlatform, "darwin")), | |
| 96 | 112 | ); |
| 97 | 113 | const encodePreviewManagerError = Schema.encodeSync(PreviewManager.PreviewManagerError); |
| 98 | 114 | |
| ... | ... | @@ -106,8 +122,73 @@ const withManager = <A>( |
| 106 | 122 | return yield* use(manager); |
| 107 | 123 | }).pipe(Effect.provide(layer), Effect.scoped); |
| 108 | 124 | |
| 125 | interface TestCapturedPreviewImage { | |
| 126 | readonly toJPEG: () => Buffer; | |
| 127 | readonly getSize: () => { readonly width: number; readonly height: number }; | |
| 128 | } | |
| 129 | ||
| 130 | const makeTestPreviewWebContents = ( | |
| 131 | capturePage: () => Promise<TestCapturedPreviewImage>, | |
| 132 | id = 42, | |
| 133 | ) => | |
| 134 | ({ | |
| 135 | id, | |
| 136 | isDestroyed: () => false, | |
| 137 | getType: () => "webview", | |
| 138 | getURL: () => "https://example.com", | |
| 139 | getTitle: () => "Example", | |
| 140 | isLoading: () => false, | |
| 141 | getZoomFactor: () => 1, | |
| 142 | setZoomFactor: vi.fn(), | |
| 143 | on: vi.fn(), | |
| 144 | off: vi.fn(), | |
| 145 | ipc: { on: vi.fn(), off: vi.fn() }, | |
| 146 | send: webviewSend, | |
| 147 | navigationHistory: { canGoBack: () => false, canGoForward: () => false }, | |
| 148 | setWindowOpenHandler: vi.fn(), | |
| 149 | debugger: { | |
| 150 | isAttached: () => false, | |
| 151 | attach: vi.fn(), | |
| 152 | sendCommand: vi.fn(async () => undefined), | |
| 153 | on: vi.fn(), | |
| 154 | off: vi.fn(), | |
| 155 | }, | |
| 156 | capturePage, | |
| 157 | }) as never; | |
| 158 | ||
| 159 | const makeTestPictureInPictureWindow = (loadURL: () => Promise<void> = async () => undefined) => { | |
| 160 | const listeners = new Map<string, () => void>(); | |
| 161 | const send = vi.fn(); | |
| 162 | let destroyed = false; | |
| 163 | const pictureInPictureWindow = { | |
| 164 | isDestroyed: vi.fn(() => destroyed), | |
| 165 | once: vi.fn((event: string, listener: () => void) => { | |
| 166 | listeners.set(event, listener); | |
| 167 | }), | |
| 168 | setAlwaysOnTop: vi.fn(), | |
| 169 | setVisibleOnAllWorkspaces: vi.fn(), | |
| 170 | setAspectRatio: vi.fn(), | |
| 171 | getContentSize: vi.fn(() => [480, 320]), | |
| 172 | setContentSize: vi.fn(), | |
| 173 | loadURL: vi.fn(loadURL), | |
| 174 | showInactive: vi.fn(() => { | |
| 175 | if (destroyed) throw new Error("Picture-in-picture window is closed."); | |
| 176 | }), | |
| 177 | close: vi.fn(() => { | |
| 178 | if (destroyed) return; | |
| 179 | destroyed = true; | |
| 180 | listeners.get("closed")?.(); | |
| 181 | }), | |
| 182 | webContents: { | |
| 183 | send, | |
| 184 | }, | |
| 185 | }; | |
| 186 | return { pictureInPictureWindow, send }; | |
| 187 | }; | |
| 188 | ||
| 109 | 189 | describe("PreviewManager", () => { |
| 110 | 190 | beforeEach(() => { |
| 191 | browserWindowConstructor.mockReset(); | |
| 111 | 192 | fromId.mockClear(); |
| 112 | 193 | getFocusedWebContents.mockReset(); |
| 113 | 194 | getFocusedWebContents.mockReturnValue(null); |
| ... | ... | @@ -438,6 +519,80 @@ describe("PreviewManager", () => { |
| 438 | 519 | ), |
| 439 | 520 | ); |
| 440 | 521 | |
| 522 | effectIt.effect("blocks late webview and capture starts during tab close", () => | |
| 523 | withManager((manager) => | |
| 524 | Effect.gen(function* () { | |
| 525 | const capturePage = vi.fn(async () => ({ | |
| 526 | toJPEG: () => Buffer.from("close-race-frame"), | |
| 527 | getSize: () => ({ width: 1280, height: 720 }), | |
| 528 | })); | |
| 529 | const firstWebContents = makeTestPreviewWebContents(capturePage, 42); | |
| 530 | const replacementWebContents = makeTestPreviewWebContents(capturePage, 43); | |
| 531 | const replacementListenerSpies = replacementWebContents as unknown as { | |
| 532 | readonly on: ReturnType<typeof vi.fn>; | |
| 533 | readonly off: ReturnType<typeof vi.fn>; | |
| 534 | readonly ipc: { readonly off: ReturnType<typeof vi.fn> }; | |
| 535 | }; | |
| 536 | fromId.mockImplementation((id) => { | |
| 537 | if (id === 42) return firstWebContents; | |
| 538 | if (id === 43) return replacementWebContents; | |
| 539 | return null; | |
| 540 | }); | |
| 541 | const { pictureInPictureWindow } = makeTestPictureInPictureWindow(); | |
| 542 | browserWindowConstructor.mockImplementation(function () { | |
| 543 | return pictureInPictureWindow; | |
| 544 | }); | |
| 545 | ||
| 546 | yield* manager.createTab("tab_close_register_race"); | |
| 547 | yield* manager.registerWebview("tab_close_register_race", 42); | |
| 548 | yield* manager.openPictureInPicture("tab_close_register_race"); | |
| 549 | ||
| 550 | const closeCleanupPaused = yield* Deferred.make<void>(); | |
| 551 | const continueCloseCleanup = yield* Deferred.make<void>(); | |
| 552 | yield* manager.subscribeStateChanges((_tabId, state) => | |
| 553 | !state.pictureInPicture && state.webContentsId === 42 | |
| 554 | ? Deferred.succeed(closeCleanupPaused, undefined).pipe( | |
| 555 | Effect.andThen(Deferred.await(continueCloseCleanup)), | |
| 556 | ) | |
| 557 | : Effect.void, | |
| 558 | ); | |
| 559 | ||
| 560 | const closeFiber = yield* manager | |
| 561 | .closeTab("tab_close_register_race") | |
| 562 | .pipe(Effect.forkChild({ startImmediately: true })); | |
| 563 | yield* Deferred.await(closeCleanupPaused); | |
| 564 | const recreateFiber = yield* manager | |
| 565 | .createTab("tab_close_register_race") | |
| 566 | .pipe(Effect.forkChild({ startImmediately: true })); | |
| 567 | const registrationFiber = yield* manager | |
| 568 | .registerWebview("tab_close_register_race", 43) | |
| 569 | .pipe(Effect.forkChild({ startImmediately: true })); | |
| 570 | yield* Effect.yieldNow; | |
| 571 | expect(replacementListenerSpies.on).not.toHaveBeenCalled(); | |
| 572 | yield* manager.closeTab("tab_close_register_race"); | |
| 573 | const recordingExit = yield* Effect.exit(manager.startRecording("tab_close_register_race")); | |
| 574 | yield* Deferred.succeed(continueCloseCleanup, undefined); | |
| 575 | yield* Fiber.join(closeFiber); | |
| 576 | const recreated = yield* Fiber.join(recreateFiber); | |
| 577 | const registrationExit = yield* Fiber.await(registrationFiber); | |
| 578 | ||
| 579 | for (const exit of [registrationExit, recordingExit]) { | |
| 580 | expect(Exit.isFailure(exit)).toBe(true); | |
| 581 | if (Exit.isSuccess(exit)) continue; | |
| 582 | expect(Option.getOrThrow(Cause.findErrorOption(exit.cause))).toMatchObject({ | |
| 583 | _tag: "PreviewTabNotFoundError", | |
| 584 | tabId: "tab_close_register_race", | |
| 585 | }); | |
| 586 | } | |
| 587 | expect(replacementListenerSpies.on).not.toHaveBeenCalled(); | |
| 588 | expect(replacementListenerSpies.off).not.toHaveBeenCalled(); | |
| 589 | expect(replacementListenerSpies.ipc.off).not.toHaveBeenCalled(); | |
| 590 | expect(capturePage).toHaveBeenCalledOnce(); | |
| 591 | expect(recreated.webContentsId).toBeNull(); | |
| 592 | }), | |
| 593 | ), | |
| 594 | ); | |
| 595 | ||
| 441 | 596 | effectIt.effect("keeps a main-frame load failure visible until a retry starts", () => |
| 442 | 597 | withManager((manager) => |
| 443 | 598 | Effect.gen(function* () { |
| ... | ... | @@ -603,6 +758,614 @@ describe("PreviewManager", () => { |
| 603 | 758 | ), |
| 604 | 759 | ); |
| 605 | 760 | |
| 761 | effectIt.effect("captures hidden preview recordings independently for concurrent tabs", () => | |
| 762 | withManager((manager) => | |
| 763 | Effect.gen(function* () { | |
| 764 | const firstJpeg = Buffer.from("first-recording-frame"); | |
| 765 | const secondJpeg = Buffer.from("second-recording-frame"); | |
| 766 | const firstCapturePage = vi.fn(async () => ({ | |
| 767 | toJPEG: () => firstJpeg, | |
| 768 | getSize: () => ({ width: 800, height: 600 }), | |
| 769 | })); | |
| 770 | const secondCapturePage = vi.fn(async () => ({ | |
| 771 | toJPEG: () => secondJpeg, | |
| 772 | getSize: () => ({ width: 390, height: 844 }), | |
| 773 | })); | |
| 774 | const firstSendCommand = vi.fn(async () => undefined); | |
| 775 | const secondSendCommand = vi.fn(async () => undefined); | |
| 776 | const makeWebContents = ( | |
| 777 | id: number, | |
| 778 | capturePage: typeof firstCapturePage, | |
| 779 | sendCommand: typeof firstSendCommand, | |
| 780 | ) => | |
| 781 | ({ | |
| 782 | id, | |
| 783 | isDestroyed: () => false, | |
| 784 | getType: () => "webview", | |
| 785 | getURL: () => `https://example.com/${id}`, | |
| 786 | getTitle: () => `Example ${id}`, | |
| 787 | isLoading: () => false, | |
| 788 | getZoomFactor: () => 1, | |
| 789 | setZoomFactor: vi.fn(), | |
| 790 | on: vi.fn(), | |
| 791 | off: vi.fn(), | |
| 792 | ipc: { on: vi.fn(), off: vi.fn() }, | |
| 793 | send: webviewSend, | |
| 794 | navigationHistory: { canGoBack: () => false, canGoForward: () => false }, | |
| 795 | setWindowOpenHandler: vi.fn(), | |
| 796 | debugger: { | |
| 797 | isAttached: () => false, | |
| 798 | attach: vi.fn(), | |
| 799 | sendCommand, | |
| 800 | on: vi.fn(), | |
| 801 | off: vi.fn(), | |
| 802 | }, | |
| 803 | capturePage, | |
| 804 | }) as never; | |
| 805 | const webContentsById = new Map([ | |
| 806 | [41, makeWebContents(41, firstCapturePage, firstSendCommand)], | |
| 807 | [42, makeWebContents(42, secondCapturePage, secondSendCommand)], | |
| 808 | ]); | |
| 809 | fromId.mockImplementation((id) => | |
| 810 | id === undefined ? null : (webContentsById.get(id) ?? null), | |
| 811 | ); | |
| 812 | const frames: DesktopPreviewRecordingFrame[] = []; | |
| 813 | ||
| 814 | yield* manager.subscribeRecordingFrames((frame) => | |
| 815 | Effect.sync(() => { | |
| 816 | frames.push(frame); | |
| 817 | }), | |
| 818 | ); | |
| 819 | yield* manager.createTab("tab_1"); | |
| 820 | yield* manager.createTab("tab_2"); | |
| 821 | yield* manager.registerWebview("tab_1", 41); | |
| 822 | yield* manager.registerWebview("tab_2", 42); | |
| 823 | yield* Effect.all([manager.startRecording("tab_1"), manager.startRecording("tab_2")], { | |
| 824 | concurrency: 2, | |
| 825 | discard: true, | |
| 826 | }); | |
| 827 | ||
| 828 | expect(firstCapturePage).toHaveBeenCalledOnce(); | |
| 829 | expect(secondCapturePage).toHaveBeenCalledOnce(); | |
| 830 | expect(frames).toHaveLength(2); | |
| 831 | expect(frames).toEqual( | |
| 832 | expect.arrayContaining([ | |
| 833 | expect.objectContaining({ | |
| 834 | tabId: "tab_1", | |
| 835 | data: firstJpeg.toString("base64"), | |
| 836 | width: 800, | |
| 837 | height: 600, | |
| 838 | }), | |
| 839 | expect.objectContaining({ | |
| 840 | tabId: "tab_2", | |
| 841 | data: secondJpeg.toString("base64"), | |
| 842 | width: 390, | |
| 843 | height: 844, | |
| 844 | }), | |
| 845 | ]), | |
| 846 | ); | |
| 847 | expect(firstSendCommand).not.toHaveBeenCalledWith( | |
| 848 | "Page.startScreencast", | |
| 849 | expect.anything(), | |
| 850 | ); | |
| 851 | expect(secondSendCommand).not.toHaveBeenCalledWith( | |
| 852 | "Page.startScreencast", | |
| 853 | expect.anything(), | |
| 854 | ); | |
| 855 | ||
| 856 | yield* Effect.all([manager.stopRecording("tab_1"), manager.stopRecording("tab_2")], { | |
| 857 | concurrency: 2, | |
| 858 | discard: true, | |
| 859 | }); | |
| 860 | }), | |
| 861 | ), | |
| 862 | ); | |
| 863 | ||
| 864 | effectIt.effect("drops a captured frame when the tab webview changes during capture", () => | |
| 865 | withManager((manager) => | |
| 866 | Effect.gen(function* () { | |
| 867 | const staleImage: TestCapturedPreviewImage = { | |
| 868 | toJPEG: vi.fn(() => Buffer.from("stale-recording-frame")), | |
| 869 | getSize: vi.fn(() => ({ width: 1280, height: 720 })), | |
| 870 | }; | |
| 871 | let markCaptureStarted!: () => void; | |
| 872 | const captureStarted = new Promise<void>((resolve) => { | |
| 873 | markCaptureStarted = resolve; | |
| 874 | }); | |
| 875 | let resolveCapture: ((image: TestCapturedPreviewImage) => void) | undefined; | |
| 876 | const staleCapturePage = vi.fn(() => { | |
| 877 | markCaptureStarted(); | |
| 878 | return new Promise<TestCapturedPreviewImage>((resolve) => { | |
| 879 | resolveCapture = resolve; | |
| 880 | }); | |
| 881 | }); | |
| 882 | const replacementCapturePage = vi.fn(async () => ({ | |
| 883 | toJPEG: () => Buffer.from("replacement-recording-frame"), | |
| 884 | getSize: () => ({ width: 1280, height: 720 }), | |
| 885 | })); | |
| 886 | const initialWebContents = makeTestPreviewWebContents(staleCapturePage, 42); | |
| 887 | const replacementWebContents = makeTestPreviewWebContents(replacementCapturePage, 43); | |
| 888 | fromId.mockImplementation((webContentsId?: number) => { | |
| 889 | if (webContentsId === 42) return initialWebContents; | |
| 890 | if (webContentsId === 43) return replacementWebContents; | |
| 891 | return null; | |
| 892 | }); | |
| 893 | const frames: DesktopPreviewRecordingFrame[] = []; | |
| 894 | ||
| 895 | yield* manager.subscribeRecordingFrames((frame) => | |
| 896 | Effect.sync(() => { | |
| 897 | frames.push(frame); | |
| 898 | }), | |
| 899 | ); | |
| 900 | yield* manager.createTab("tab_capture_replaced"); | |
| 901 | yield* manager.registerWebview("tab_capture_replaced", 42); | |
| 902 | const recordingFiber = yield* manager | |
| 903 | .startRecording("tab_capture_replaced") | |
| 904 | .pipe(Effect.forkChild({ startImmediately: true })); | |
| 905 | yield* Effect.promise(() => captureStarted); | |
| 906 | ||
| 907 | yield* manager.registerWebview("tab_capture_replaced", 43); | |
| 908 | resolveCapture?.(staleImage); | |
| 909 | yield* Fiber.join(recordingFiber); | |
| 910 | ||
| 911 | expect(staleImage.getSize).not.toHaveBeenCalled(); | |
| 912 | expect(staleImage.toJPEG).not.toHaveBeenCalled(); | |
| 913 | expect(frames).toHaveLength(0); | |
| 914 | expect(replacementCapturePage).not.toHaveBeenCalled(); | |
| 915 | ||
| 916 | yield* manager.stopRecording("tab_capture_replaced"); | |
| 917 | }), | |
| 918 | ), | |
| 919 | ); | |
| 920 | ||
| 921 | effectIt.effect("keeps an in-flight frame when a capture consumer is added", () => | |
| 922 | withManager((manager) => | |
| 923 | Effect.gen(function* () { | |
| 924 | const image: TestCapturedPreviewImage = { | |
| 925 | toJPEG: vi.fn(() => Buffer.from("shared-in-flight-frame")), | |
| 926 | getSize: vi.fn(() => ({ width: 1280, height: 720 })), | |
| 927 | }; | |
| 928 | let markCaptureStarted!: () => void; | |
| 929 | const captureStarted = new Promise<void>((resolve) => { | |
| 930 | markCaptureStarted = resolve; | |
| 931 | }); | |
| 932 | let resolveCapture: ((captured: TestCapturedPreviewImage) => void) | undefined; | |
| 933 | const capturePage = vi.fn(() => { | |
| 934 | markCaptureStarted(); | |
| 935 | return new Promise<TestCapturedPreviewImage>((resolve) => { | |
| 936 | resolveCapture = resolve; | |
| 937 | }); | |
| 938 | }); | |
| 939 | fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); | |
| 940 | const { pictureInPictureWindow, send } = makeTestPictureInPictureWindow(); | |
| 941 | browserWindowConstructor.mockImplementation(function () { | |
| 942 | return pictureInPictureWindow; | |
| 943 | }); | |
| 944 | const recordingFrames: DesktopPreviewRecordingFrame[] = []; | |
| 945 | yield* manager.subscribeRecordingFrames((frame) => | |
| 946 | Effect.sync(() => { | |
| 947 | recordingFrames.push(frame); | |
| 948 | }), | |
| 949 | ); | |
| 950 | ||
| 951 | yield* manager.createTab("tab_capture_consumer_added"); | |
| 952 | yield* manager.registerWebview("tab_capture_consumer_added", 42); | |
| 953 | const recordingFiber = yield* manager | |
| 954 | .startRecording("tab_capture_consumer_added") | |
| 955 | .pipe(Effect.forkChild({ startImmediately: true })); | |
| 956 | yield* Effect.promise(() => captureStarted); | |
| 957 | ||
| 958 | yield* manager.openPictureInPicture("tab_capture_consumer_added"); | |
| 959 | resolveCapture?.(image); | |
| 960 | yield* Fiber.join(recordingFiber); | |
| 961 | ||
| 962 | expect(recordingFrames).toHaveLength(1); | |
| 963 | expect(send).toHaveBeenCalledWith( | |
| 964 | "desktop:preview-pip-frame", | |
| 965 | expect.objectContaining({ | |
| 966 | tabId: "tab_capture_consumer_added", | |
| 967 | data: Buffer.from("shared-in-flight-frame").toString("base64"), | |
| 968 | }), | |
| 969 | ); | |
| 970 | ||
| 971 | yield* manager.stopRecording("tab_capture_consumer_added"); | |
| 972 | yield* manager.closePictureInPicture("tab_capture_consumer_added"); | |
| 973 | }), | |
| 974 | ), | |
| 975 | ); | |
| 976 | ||
| 977 | effectIt.effect("shares background frame capture between recording and picture-in-picture", () => | |
| 978 | withManager((manager) => | |
| 979 | Effect.gen(function* () { | |
| 980 | const jpeg = Buffer.from("shared-preview-frame"); | |
| 981 | const capturePage = vi.fn(async () => ({ | |
| 982 | toJPEG: () => jpeg, | |
| 983 | getSize: () => ({ width: 1280, height: 720 }), | |
| 984 | })); | |
| 985 | fromId.mockReturnValue({ | |
| 986 | id: 42, | |
| 987 | isDestroyed: () => false, | |
| 988 | getType: () => "webview", | |
| 989 | getURL: () => "https://example.com", | |
| 990 | getTitle: () => "Example", | |
| 991 | isLoading: () => false, | |
| 992 | getZoomFactor: () => 1, | |
| 993 | setZoomFactor: vi.fn(), | |
| 994 | on: vi.fn(), | |
| 995 | off: vi.fn(), | |
| 996 | ipc: { on: vi.fn(), off: vi.fn() }, | |
| 997 | send: webviewSend, | |
| 998 | navigationHistory: { canGoBack: () => false, canGoForward: () => false }, | |
| 999 | setWindowOpenHandler: vi.fn(), | |
| 1000 | debugger: { | |
| 1001 | isAttached: () => false, | |
| 1002 | attach: vi.fn(), | |
| 1003 | sendCommand: vi.fn(async () => undefined), | |
| 1004 | on: vi.fn(), | |
| 1005 | off: vi.fn(), | |
| 1006 | }, | |
| 1007 | capturePage, | |
| 1008 | } as never); | |
| 1009 | ||
| 1010 | const pictureInPictureListeners = new Map<string, () => void>(); | |
| 1011 | const pictureInPictureSend = vi.fn(); | |
| 1012 | const pictureInPictureWindow = { | |
| 1013 | isDestroyed: vi.fn(() => false), | |
| 1014 | once: vi.fn((event: string, listener: () => void) => { | |
| 1015 | pictureInPictureListeners.set(event, listener); | |
| 1016 | }), | |
| 1017 | setAlwaysOnTop: vi.fn(), | |
| 1018 | setVisibleOnAllWorkspaces: vi.fn(), | |
| 1019 | setAspectRatio: vi.fn(), | |
| 1020 | getContentSize: vi.fn(() => [480, 320] as [number, number]), | |
| 1021 | setContentSize: vi.fn(), | |
| 1022 | loadURL: vi.fn(async () => undefined), | |
| 1023 | showInactive: vi.fn(), | |
| 1024 | close: vi.fn(() => { | |
| 1025 | pictureInPictureListeners.get("closed")?.(); | |
| 1026 | }), | |
| 1027 | webContents: { | |
| 1028 | send: pictureInPictureSend, | |
| 1029 | }, | |
| 1030 | }; | |
| 1031 | browserWindowConstructor.mockImplementation(function () { | |
| 1032 | return pictureInPictureWindow; | |
| 1033 | }); | |
| 1034 | const states: PreviewManager.PreviewTabState[] = []; | |
| 1035 | const recordingFrames: DesktopPreviewRecordingFrame[] = []; | |
| 1036 | ||
| 1037 | yield* manager.subscribeStateChanges((_tabId, state) => | |
| 1038 | Effect.sync(() => { | |
| 1039 | states.push(state); | |
| 1040 | }), | |
| 1041 | ); | |
| 1042 | yield* manager.subscribeRecordingFrames((frame) => | |
| 1043 | Effect.sync(() => { | |
| 1044 | recordingFrames.push(frame); | |
| 1045 | }), | |
| 1046 | ); | |
| 1047 | yield* manager.createTab("tab_pip"); | |
| 1048 | yield* manager.registerWebview("tab_pip", 42); | |
| 1049 | yield* manager.openPictureInPicture("tab_pip"); | |
| 1050 | ||
| 1051 | expect(browserWindowConstructor).toHaveBeenCalledWith( | |
| 1052 | expect.objectContaining({ | |
| 1053 | alwaysOnTop: true, | |
| 1054 | show: false, | |
| 1055 | skipTaskbar: true, | |
| 1056 | webPreferences: expect.objectContaining({ | |
| 1057 | preload: "/tmp/t3/desktop/preview-pip-preload.cjs", | |
| 1058 | backgroundThrottling: false, | |
| 1059 | }), | |
| 1060 | }), | |
| 1061 | ); | |
| 1062 | expect(pictureInPictureWindow.showInactive).toHaveBeenCalledOnce(); | |
| 1063 | expect(pictureInPictureWindow.setVisibleOnAllWorkspaces).toHaveBeenCalledWith(true, { | |
| 1064 | visibleOnFullScreen: true, | |
| 1065 | skipTransformProcessType: true, | |
| 1066 | }); | |
| 1067 | expect(pictureInPictureWindow.setAspectRatio.mock.calls).toEqual([[0], [1280 / 720]]); | |
| 1068 | expect(pictureInPictureWindow.setContentSize).toHaveBeenCalledWith(480, 270, false); | |
| 1069 | expect(pictureInPictureWindow.setAspectRatio.mock.invocationCallOrder[0]).toBeLessThan( | |
| 1070 | pictureInPictureWindow.setContentSize.mock.invocationCallOrder[0] ?? 0, | |
| 1071 | ); | |
| 1072 | expect(pictureInPictureWindow.setContentSize.mock.invocationCallOrder[0]).toBeLessThan( | |
| 1073 | pictureInPictureWindow.setAspectRatio.mock.invocationCallOrder[1] ?? 0, | |
| 1074 | ); | |
| 1075 | expect(pictureInPictureSend).toHaveBeenCalledWith( | |
| 1076 | "desktop:preview-pip-frame", | |
| 1077 | expect.objectContaining({ | |
| 1078 | tabId: "tab_pip", | |
| 1079 | data: jpeg.toString("base64"), | |
| 1080 | width: 1280, | |
| 1081 | height: 720, | |
| 1082 | }), | |
| 1083 | ); | |
| 1084 | expect(states.at(-1)?.pictureInPicture).toBe(true); | |
| 1085 | expect(capturePage).toHaveBeenCalledOnce(); | |
| 1086 | ||
| 1087 | yield* manager.startRecording("tab_pip"); | |
| 1088 | expect(capturePage).toHaveBeenCalledOnce(); | |
| 1089 | expect(recordingFrames).toHaveLength(0); | |
| 1090 | ||
| 1091 | yield* TestClock.adjust(100); | |
| 1092 | expect(capturePage).toHaveBeenCalledTimes(2); | |
| 1093 | expect(recordingFrames).toHaveLength(1); | |
| 1094 | ||
| 1095 | yield* manager.stopRecording("tab_pip"); | |
| 1096 | const framesBeforePictureInPictureOnlyTick = pictureInPictureSend.mock.calls.length; | |
| 1097 | yield* TestClock.adjust(100); | |
| 1098 | expect(capturePage).toHaveBeenCalledTimes(3); | |
| 1099 | expect(pictureInPictureSend.mock.calls.length).toBeGreaterThan( | |
| 1100 | framesBeforePictureInPictureOnlyTick, | |
| 1101 | ); | |
| 1102 | expect(recordingFrames).toHaveLength(1); | |
| 1103 | ||
| 1104 | yield* manager.closePictureInPicture("tab_pip"); | |
| 1105 | expect(pictureInPictureWindow.close).toHaveBeenCalledOnce(); | |
| 1106 | expect(states.at(-1)?.pictureInPicture).toBe(false); | |
| 1107 | const capturesAfterClose = capturePage.mock.calls.length; | |
| 1108 | yield* TestClock.adjust(200); | |
| 1109 | expect(capturePage).toHaveBeenCalledTimes(capturesAfterClose); | |
| 1110 | }), | |
| 1111 | ), | |
| 1112 | ); | |
| 1113 | ||
| 1114 | effectIt.effect("retries a cold hidden-tab capture without dropping recording", () => | |
| 1115 | withManager((manager) => | |
| 1116 | Effect.gen(function* () { | |
| 1117 | const jpeg = Buffer.from("recovered-preview-frame"); | |
| 1118 | const capturePage = vi.fn(async () => ({ | |
| 1119 | toJPEG: () => jpeg, | |
| 1120 | getSize: () => ({ width: 1280, height: 720 }), | |
| 1121 | })); | |
| 1122 | capturePage.mockRejectedValueOnce(new Error("UnknownVizError")); | |
| 1123 | fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); | |
| 1124 | const frames: DesktopPreviewRecordingFrame[] = []; | |
| 1125 | ||
| 1126 | yield* manager.subscribeRecordingFrames((frame) => | |
| 1127 | Effect.sync(() => { | |
| 1128 | frames.push(frame); | |
| 1129 | }), | |
| 1130 | ); | |
| 1131 | yield* manager.createTab("tab_cold_capture"); | |
| 1132 | yield* manager.registerWebview("tab_cold_capture", 42); | |
| 1133 | ||
| 1134 | yield* manager.startRecording("tab_cold_capture"); | |
| 1135 | ||
| 1136 | expect(capturePage).toHaveBeenCalledOnce(); | |
| 1137 | expect(frames).toHaveLength(0); | |
| 1138 | ||
| 1139 | yield* TestClock.adjust(100); | |
| 1140 | ||
| 1141 | expect(capturePage).toHaveBeenCalledTimes(2); | |
| 1142 | expect(frames).toEqual([ | |
| 1143 | expect.objectContaining({ | |
| 1144 | tabId: "tab_cold_capture", | |
| 1145 | data: jpeg.toString("base64"), | |
| 1146 | width: 1280, | |
| 1147 | height: 720, | |
| 1148 | }), | |
| 1149 | ]); | |
| 1150 | ||
| 1151 | yield* manager.stopRecording("tab_cold_capture"); | |
| 1152 | }), | |
| 1153 | ), | |
| 1154 | ); | |
| 1155 | ||
| 1156 | effectIt.effect("drops empty frames before picture-in-picture delivery", () => | |
| 1157 | withManager((manager) => | |
| 1158 | Effect.gen(function* () { | |
| 1159 | const validImage: TestCapturedPreviewImage = { | |
| 1160 | toJPEG: () => Buffer.from("valid-preview-frame"), | |
| 1161 | getSize: () => ({ width: 1280, height: 720 }), | |
| 1162 | }; | |
| 1163 | const capturePage = vi.fn(async () => validImage); | |
| 1164 | capturePage.mockResolvedValueOnce({ | |
| 1165 | toJPEG: () => Buffer.from("empty-preview-frame"), | |
| 1166 | getSize: () => ({ width: 0, height: 0 }), | |
| 1167 | }); | |
| 1168 | fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); | |
| 1169 | const { pictureInPictureWindow, send } = makeTestPictureInPictureWindow(); | |
| 1170 | browserWindowConstructor.mockImplementation(function () { | |
| 1171 | return pictureInPictureWindow; | |
| 1172 | }); | |
| 1173 | ||
| 1174 | yield* manager.createTab("tab_empty_frame"); | |
| 1175 | yield* manager.registerWebview("tab_empty_frame", 42); | |
| 1176 | yield* manager.openPictureInPicture("tab_empty_frame"); | |
| 1177 | ||
| 1178 | expect(capturePage).toHaveBeenCalledOnce(); | |
| 1179 | expect(pictureInPictureWindow.setAspectRatio).not.toHaveBeenCalled(); | |
| 1180 | expect(send).not.toHaveBeenCalled(); | |
| 1181 | ||
| 1182 | yield* TestClock.adjust(100); | |
| 1183 | ||
| 1184 | expect(pictureInPictureWindow.setAspectRatio.mock.calls).toEqual([[0], [1280 / 720]]); | |
| 1185 | expect(send).toHaveBeenCalledOnce(); | |
| 1186 | yield* manager.closePictureInPicture("tab_empty_frame"); | |
| 1187 | }), | |
| 1188 | ), | |
| 1189 | ); | |
| 1190 | ||
| 1191 | effectIt.effect("does not publish picture-in-picture readiness after window teardown", () => | |
| 1192 | withManager((manager) => | |
| 1193 | Effect.gen(function* () { | |
| 1194 | const capturePage = vi.fn(async () => ({ | |
| 1195 | toJPEG: () => Buffer.from("closing-preview-frame"), | |
| 1196 | getSize: () => ({ width: 1280, height: 720 }), | |
| 1197 | })); | |
| 1198 | fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); | |
| 1199 | const { pictureInPictureWindow } = makeTestPictureInPictureWindow(); | |
| 1200 | pictureInPictureWindow.showInactive.mockImplementationOnce(() => { | |
| 1201 | pictureInPictureWindow.close(); | |
| 1202 | }); | |
| 1203 | browserWindowConstructor.mockImplementation(function () { | |
| 1204 | return pictureInPictureWindow; | |
| 1205 | }); | |
| 1206 | const states: PreviewManager.PreviewTabState[] = []; | |
| 1207 | yield* manager.subscribeStateChanges((_tabId, state) => | |
| 1208 | Effect.sync(() => { | |
| 1209 | states.push(state); | |
| 1210 | }), | |
| 1211 | ); | |
| 1212 | ||
| 1213 | yield* manager.createTab("tab_pip_teardown"); | |
| 1214 | yield* manager.registerWebview("tab_pip_teardown", 42); | |
| 1215 | const openExit = yield* Effect.exit(manager.openPictureInPicture("tab_pip_teardown")); | |
| 1216 | ||
| 1217 | expect(Exit.hasInterrupts(openExit)).toBe(true); | |
| 1218 | expect(pictureInPictureWindow.close).toHaveBeenCalledOnce(); | |
| 1219 | expect(states.some((state) => state.pictureInPicture)).toBe(false); | |
| 1220 | expect(states.at(-1)?.pictureInPicture).toBe(false); | |
| 1221 | }), | |
| 1222 | ), | |
| 1223 | ); | |
| 1224 | ||
| 1225 | effectIt.effect("closes an initializing picture-in-picture without blocking later opens", () => | |
| 1226 | withManager((manager) => | |
| 1227 | Effect.gen(function* () { | |
| 1228 | const capturePage = vi.fn(async () => ({ | |
| 1229 | toJPEG: () => Buffer.from("serialized-preview-frame"), | |
| 1230 | getSize: () => ({ width: 1280, height: 720 }), | |
| 1231 | })); | |
| 1232 | fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); | |
| 1233 | const { pictureInPictureWindow: initializingWindow } = makeTestPictureInPictureWindow( | |
| 1234 | () => | |
| 1235 | new Promise<void>(() => { | |
| 1236 | // Simulate a renderer load that never settles. | |
| 1237 | }), | |
| 1238 | ); | |
| 1239 | const { pictureInPictureWindow: reopenedWindow } = makeTestPictureInPictureWindow(); | |
| 1240 | browserWindowConstructor | |
| 1241 | .mockImplementationOnce(function () { | |
| 1242 | return initializingWindow; | |
| 1243 | }) | |
| 1244 | .mockImplementationOnce(function () { | |
| 1245 | return reopenedWindow; | |
| 1246 | }); | |
| 1247 | const states: PreviewManager.PreviewTabState[] = []; | |
| 1248 | yield* manager.subscribeStateChanges((_tabId, state) => | |
| 1249 | Effect.sync(() => { | |
| 1250 | states.push(state); | |
| 1251 | }), | |
| 1252 | ); | |
| 1253 | yield* manager.createTab("tab_concurrent_pip"); | |
| 1254 | yield* manager.registerWebview("tab_concurrent_pip", 42); | |
| 1255 | ||
| 1256 | const firstOpen = yield* manager | |
| 1257 | .openPictureInPicture("tab_concurrent_pip") | |
| 1258 | .pipe(Effect.forkChild); | |
| 1259 | yield* Effect.yieldNow; | |
| 1260 | const secondOpen = yield* manager | |
| 1261 | .openPictureInPicture("tab_concurrent_pip") | |
| 1262 | .pipe(Effect.forkChild); | |
| 1263 | yield* Effect.yieldNow; | |
| 1264 | const close = yield* manager | |
| 1265 | .closePictureInPicture("tab_concurrent_pip") | |
| 1266 | .pipe(Effect.forkChild); | |
| 1267 | yield* Effect.yieldNow; | |
| 1268 | ||
| 1269 | expect(browserWindowConstructor).toHaveBeenCalledOnce(); | |
| 1270 | expect(initializingWindow.loadURL).toHaveBeenCalledOnce(); | |
| 1271 | expect(initializingWindow.close).toHaveBeenCalledOnce(); | |
| 1272 | const [firstOpenExit, secondOpenExit] = yield* Effect.all([ | |
| 1273 | Fiber.await(firstOpen), | |
| 1274 | Fiber.await(secondOpen), | |
| 1275 | ]); | |
| 1276 | yield* Fiber.join(close); | |
| 1277 | ||
| 1278 | expect(Exit.hasInterrupts(firstOpenExit)).toBe(true); | |
| 1279 | expect(Exit.hasInterrupts(secondOpenExit)).toBe(true); | |
| 1280 | expect(initializingWindow.showInactive).not.toHaveBeenCalled(); | |
| 1281 | expect(capturePage).not.toHaveBeenCalled(); | |
| 1282 | expect(states.at(-1)?.pictureInPicture).toBe(false); | |
| 1283 | ||
| 1284 | yield* manager.openPictureInPicture("tab_concurrent_pip"); | |
| 1285 | ||
| 1286 | expect(browserWindowConstructor).toHaveBeenCalledTimes(2); | |
| 1287 | expect(reopenedWindow.showInactive).toHaveBeenCalledOnce(); | |
| 1288 | expect(capturePage).toHaveBeenCalledOnce(); | |
| 1289 | expect(states.at(-1)?.pictureInPicture).toBe(true); | |
| 1290 | ||
| 1291 | yield* manager.closePictureInPicture("tab_concurrent_pip"); | |
| 1292 | ||
| 1293 | expect(browserWindowConstructor).toHaveBeenCalledTimes(2); | |
| 1294 | expect(reopenedWindow.close).toHaveBeenCalledOnce(); | |
| 1295 | expect(states.at(-1)?.pictureInPicture).toBe(false); | |
| 1296 | const capturesAfterClose = capturePage.mock.calls.length; | |
| 1297 | yield* TestClock.adjust(200); | |
| 1298 | expect(capturePage).toHaveBeenCalledTimes(capturesAfterClose); | |
| 1299 | }), | |
| 1300 | ), | |
| 1301 | ); | |
| 1302 | ||
| 1303 | effectIt.effect("rejects picture-in-picture when its webview changes during initialization", () => | |
| 1304 | withManager((manager) => | |
| 1305 | Effect.gen(function* () { | |
| 1306 | const initialCapturePage = vi.fn(async () => ({ | |
| 1307 | toJPEG: () => Buffer.from("stale-preview-frame"), | |
| 1308 | getSize: () => ({ width: 1280, height: 720 }), | |
| 1309 | })); | |
| 1310 | const replacementCapturePage = vi.fn(async () => ({ | |
| 1311 | toJPEG: () => Buffer.from("replacement-preview-frame"), | |
| 1312 | getSize: () => ({ width: 1280, height: 720 }), | |
| 1313 | })); | |
| 1314 | const initialWebContents = makeTestPreviewWebContents(initialCapturePage, 42); | |
| 1315 | const replacementWebContents = makeTestPreviewWebContents(replacementCapturePage, 43); | |
| 1316 | fromId.mockImplementation((webContentsId?: number) => { | |
| 1317 | if (webContentsId === 42) return initialWebContents; | |
| 1318 | if (webContentsId === 43) return replacementWebContents; | |
| 1319 | return null; | |
| 1320 | }); | |
| 1321 | let resolveLoad: (() => void) | undefined; | |
| 1322 | const { pictureInPictureWindow } = makeTestPictureInPictureWindow( | |
| 1323 | () => | |
| 1324 | new Promise<void>((resolve) => { | |
| 1325 | resolveLoad = resolve; | |
| 1326 | }), | |
| 1327 | ); | |
| 1328 | browserWindowConstructor.mockImplementation(function () { | |
| 1329 | return pictureInPictureWindow; | |
| 1330 | }); | |
| 1331 | ||
| 1332 | yield* manager.createTab("tab_replaced_webview"); | |
| 1333 | yield* manager.registerWebview("tab_replaced_webview", 42); | |
| 1334 | const open = yield* manager | |
| 1335 | .openPictureInPicture("tab_replaced_webview") | |
| 1336 | .pipe(Effect.forkChild({ startImmediately: true })); | |
| 1337 | yield* Effect.yieldNow; | |
| 1338 | expect(pictureInPictureWindow.loadURL).toHaveBeenCalledOnce(); | |
| 1339 | expect(resolveLoad).toBeDefined(); | |
| 1340 | const concurrentOpen = yield* manager | |
| 1341 | .openPictureInPicture("tab_replaced_webview") | |
| 1342 | .pipe(Effect.forkChild({ startImmediately: true })); | |
| 1343 | yield* Effect.yieldNow; | |
| 1344 | ||
| 1345 | yield* manager.registerWebview("tab_replaced_webview", 43); | |
| 1346 | resolveLoad?.(); | |
| 1347 | ||
| 1348 | const openExits = yield* Effect.all([Fiber.await(open), Fiber.await(concurrentOpen)]); | |
| 1349 | for (const openExit of openExits) { | |
| 1350 | expect(Exit.isFailure(openExit)).toBe(true); | |
| 1351 | if (Exit.isSuccess(openExit)) continue; | |
| 1352 | const error = Option.getOrThrow(Cause.findErrorOption(openExit.cause)); | |
| 1353 | expect(error).toMatchObject({ | |
| 1354 | _tag: "PreviewOperationError", | |
| 1355 | operation: "pictureInPicture.validateWebContents", | |
| 1356 | tabId: "tab_replaced_webview", | |
| 1357 | webContentsId: 42, | |
| 1358 | }); | |
| 1359 | } | |
| 1360 | expect(browserWindowConstructor).toHaveBeenCalledOnce(); | |
| 1361 | expect(pictureInPictureWindow.close).toHaveBeenCalledOnce(); | |
| 1362 | expect(pictureInPictureWindow.showInactive).not.toHaveBeenCalled(); | |
| 1363 | expect(initialCapturePage).not.toHaveBeenCalled(); | |
| 1364 | expect(replacementCapturePage).not.toHaveBeenCalled(); | |
| 1365 | }), | |
| 1366 | ), | |
| 1367 | ); | |
| 1368 | ||
| 606 | 1369 | effectIt.effect("keeps element picking active during subframe navigation", () => |
| 607 | 1370 | withManager((manager) => |
| 608 | 1371 | Effect.gen(function* () { |
apps/desktop/src/preview/Manager.ts+797-165| ... | ... | @@ -28,21 +28,16 @@ import type { |
| 28 | 28 | } from "@t3tools/contracts"; |
| 29 | 29 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 30 | 30 | import { normalizePreviewUrl } from "@t3tools/shared/preview"; |
| 31 | import { | |
| 32 | type BrowserWindow, | |
| 33 | type Session, | |
| 34 | clipboard, | |
| 35 | nativeImage, | |
| 36 | shell, | |
| 37 | webContents, | |
| 38 | } from "electron"; | |
| 31 | import { BrowserWindow, type Session, clipboard, nativeImage, shell, webContents } from "electron"; | |
| 39 | 32 | import * as Cause from "effect/Cause"; |
| 40 | 33 | import * as Clock from "effect/Clock"; |
| 41 | 34 | import * as Context from "effect/Context"; |
| 42 | 35 | import * as DateTime from "effect/DateTime"; |
| 36 | import * as Deferred from "effect/Deferred"; | |
| 43 | 37 | import * as Effect from "effect/Effect"; |
| 44 | 38 | import * as Exit from "effect/Exit"; |
| 45 | 39 | import * as FileSystem from "effect/FileSystem"; |
| 40 | import * as Fiber from "effect/Fiber"; | |
| 46 | 41 | import * as Layer from "effect/Layer"; |
| 47 | 42 | import * as Option from "effect/Option"; |
| 48 | 43 | import * as Path from "effect/Path"; |
| ... | ... | @@ -53,6 +48,7 @@ import * as Scope from "effect/Scope"; |
| 53 | 48 | import * as SynchronizedRef from "effect/SynchronizedRef"; |
| 54 | 49 | |
| 55 | 50 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 51 | import { PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL } from "../ipc/channels.ts"; | |
| 56 | 52 | import * as BrowserSession from "./BrowserSession.ts"; |
| 57 | 53 | import { |
| 58 | 54 | ANNOTATION_CAPTURED_CHANNEL, |
| ... | ... | @@ -85,6 +81,7 @@ export interface PreviewTabState { |
| 85 | 81 | canGoBack: boolean; |
| 86 | 82 | canGoForward: boolean; |
| 87 | 83 | zoomFactor: number; |
| 84 | pictureInPicture: boolean; | |
| 88 | 85 | colorScheme: DesktopPreviewColorScheme; |
| 89 | 86 | controller: "human" | "agent" | "none"; |
| 90 | 87 | updatedAt: string; |
| ... | ... | @@ -101,6 +98,12 @@ const MAX_EVALUATION_BYTES = 64_000; |
| 101 | 98 | const MAX_VISIBLE_TEXT_LENGTH = 20_000; |
| 102 | 99 | const MAX_INTERACTIVE_ELEMENTS = 200; |
| 103 | 100 | const MAX_SCREENSHOT_WIDTH = 1280; |
| 101 | const RECORDING_FRAME_INTERVAL_MS = Math.ceil(1_000 / 12); | |
| 102 | const RECORDING_JPEG_QUALITY = 80; | |
| 103 | const PICTURE_IN_PICTURE_INITIAL_WIDTH = 480; | |
| 104 | const PICTURE_IN_PICTURE_INITIAL_HEIGHT = 320; | |
| 105 | const PICTURE_IN_PICTURE_MIN_WIDTH = 240; | |
| 106 | const PICTURE_IN_PICTURE_MIN_HEIGHT = 160; | |
| 104 | 107 | const DIAGNOSTIC_BUFFER_LIMIT = 200; |
| 105 | 108 | const MAX_ARTIFACT_SITE_SLUG_LENGTH = 80; |
| 106 | 109 | const AGENT_CURSOR_MOVE_MS = 160; |
| ... | ... | @@ -126,6 +129,54 @@ const DEFAULT_ANNOTATION_THEME: DesktopPreviewAnnotationTheme = { |
| 126 | 129 | fontMono: "ui-monospace, monospace", |
| 127 | 130 | }; |
| 128 | 131 | |
| 132 | export const buildPreviewPictureInPictureDataUrl = (): string => { | |
| 133 | const html = `<!doctype html> | |
| 134 | <html> | |
| 135 | <head> | |
| 136 | <meta charset="utf-8"> | |
| 137 | <meta | |
| 138 | http-equiv="Content-Security-Policy" | |
| 139 | content="default-src 'none'; img-src data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'" | |
| 140 | > | |
| 141 | <meta name="color-scheme" content="dark"> | |
| 142 | <style> | |
| 143 | html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: #111; } | |
| 144 | body { display: grid; place-items: center; } | |
| 145 | img { width: 100%; height: 100%; object-fit: contain; user-select: none; -webkit-user-drag: none; } | |
| 146 | </style> | |
| 147 | </head> | |
| 148 | <body> | |
| 149 | <img id="preview-frame" alt="Live browser preview"> | |
| 150 | <script> | |
| 151 | const frame = document.getElementById("preview-frame"); | |
| 152 | window.previewPictureInPicture.onFrame((next) => { | |
| 153 | frame.src = "data:image/jpeg;base64," + next.data; | |
| 154 | }); | |
| 155 | </script> | |
| 156 | </body> | |
| 157 | </html>`; | |
| 158 | return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`; | |
| 159 | }; | |
| 160 | ||
| 161 | export const fitPictureInPictureContentSize = ( | |
| 162 | current: ReadonlyArray<number>, | |
| 163 | aspectRatio: number, | |
| 164 | ): readonly [width: number, height: number] => { | |
| 165 | const currentWidth = Math.max(1, current[0] ?? PICTURE_IN_PICTURE_INITIAL_WIDTH); | |
| 166 | const currentHeight = Math.max(1, current[1] ?? PICTURE_IN_PICTURE_INITIAL_HEIGHT); | |
| 167 | let width = | |
| 168 | currentWidth / currentHeight > aspectRatio ? currentHeight * aspectRatio : currentWidth; | |
| 169 | let height = width / aspectRatio; | |
| 170 | const minimumScale = Math.max( | |
| 171 | 1, | |
| 172 | PICTURE_IN_PICTURE_MIN_WIDTH / width, | |
| 173 | PICTURE_IN_PICTURE_MIN_HEIGHT / height, | |
| 174 | ); | |
| 175 | width *= minimumScale; | |
| 176 | height *= minimumScale; | |
| 177 | return [Math.round(width), Math.round(height)]; | |
| 178 | }; | |
| 179 | ||
| 129 | 180 | const artifactSiteSlug = (rawUrl: string): string => { |
| 130 | 181 | try { |
| 131 | 182 | const url = new URL(rawUrl); |
| ... | ... | @@ -296,6 +347,20 @@ interface ManagedListeners { |
| 296 | 347 | readonly scope: Scope.Closeable; |
| 297 | 348 | } |
| 298 | 349 | |
| 350 | type FrameCaptureConsumer = "picture-in-picture" | "recording"; | |
| 351 | ||
| 352 | interface FrameCaptureSession { | |
| 353 | readonly scope: Scope.Closeable; | |
| 354 | readonly consumers: ReadonlySet<FrameCaptureConsumer>; | |
| 355 | } | |
| 356 | ||
| 357 | interface PictureInPictureSession { | |
| 358 | readonly window: BrowserWindow; | |
| 359 | readonly webContentsId: number; | |
| 360 | readonly ready: Deferred.Deferred<void, PreviewManagerError>; | |
| 361 | readonly initializationScope: Scope.Closeable; | |
| 362 | } | |
| 363 | ||
| 299 | 364 | interface PickSession { |
| 300 | 365 | readonly cancel: Effect.Effect<void>; |
| 301 | 366 | } |
| ... | ... | @@ -380,6 +445,7 @@ const inputSignalsMatch = (left: PreviewInputSignal, right: PreviewInputSignal): |
| 380 | 445 | |
| 381 | 446 | const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function* ( |
| 382 | 447 | artifactDirectory: string, |
| 448 | pictureInPicturePreloadPath: string, | |
| 383 | 449 | ) { |
| 384 | 450 | const fileSystem = yield* FileSystem.FileSystem; |
| 385 | 451 | const hostPlatform = yield* HostProcessPlatform; |
| ... | ... | @@ -415,7 +481,20 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 415 | 481 | >(new Map()); |
| 416 | 482 | const actionSequenceRef = yield* Ref.make(0); |
| 417 | 483 | const pointerSequenceRef = yield* Ref.make(0); |
| 418 | const recordingTabIdRef = yield* Ref.make<Option.Option<string>>(Option.none()); | |
| 484 | const frameCaptureSessionsRef = yield* SynchronizedRef.make< | |
| 485 | ReadonlyMap<string, FrameCaptureSession> | |
| 486 | >(new Map()); | |
| 487 | const pictureInPictureSessionsRef = yield* SynchronizedRef.make< | |
| 488 | ReadonlyMap<string, PictureInPictureSession> | |
| 489 | >(new Map()); | |
| 490 | const pictureInPictureAspectRatiosRef = yield* Ref.make<ReadonlyMap<string, number>>(new Map()); | |
| 491 | const pictureInPictureMutationSemaphore = yield* Semaphore.make(1); | |
| 492 | const closingTabIdsRef = yield* Ref.make<ReadonlySet<string>>(new Set()); | |
| 493 | const tabLifecycleLocks = new Map< | |
| 494 | string, | |
| 495 | { readonly semaphore: Semaphore.Semaphore; users: number } | |
| 496 | >(); | |
| 497 | const tabLifecycleGenerations = new Map<string, number>(); | |
| 419 | 498 | |
| 420 | 499 | const attempt = <A>(errorContext: PreviewOperationContext, evaluate: () => A) => |
| 421 | 500 | Effect.try({ |
| ... | ... | @@ -446,6 +525,58 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 446 | 525 | update(copy); |
| 447 | 526 | return copy; |
| 448 | 527 | }; |
| 528 | const withTabLifecycleLock = <A, E, R>( | |
| 529 | tabId: string, | |
| 530 | effect: Effect.Effect<A, E, R>, | |
| 531 | ): Effect.Effect<A, E, R> => | |
| 532 | Effect.suspend(() => { | |
| 533 | const lifecycle = tabLifecycleLocks.get(tabId) ?? { | |
| 534 | semaphore: Semaphore.makeUnsafe(1), | |
| 535 | users: 0, | |
| 536 | }; | |
| 537 | lifecycle.users += 1; | |
| 538 | tabLifecycleLocks.set(tabId, lifecycle); | |
| 539 | return lifecycle.semaphore.withPermit(effect).pipe( | |
| 540 | Effect.ensuring( | |
| 541 | Effect.sync(() => { | |
| 542 | lifecycle.users -= 1; | |
| 543 | if (lifecycle.users === 0 && tabLifecycleLocks.get(tabId) === lifecycle) { | |
| 544 | tabLifecycleLocks.delete(tabId); | |
| 545 | } | |
| 546 | }), | |
| 547 | ), | |
| 548 | ); | |
| 549 | }); | |
| 550 | const stopFrameCapture = Effect.fn("PreviewManager.stopFrameCapture")(function* ( | |
| 551 | tabId: string, | |
| 552 | consumer: FrameCaptureConsumer, | |
| 553 | ) { | |
| 554 | const captureScope = yield* SynchronizedRef.modify(frameCaptureSessionsRef, (sessions) => { | |
| 555 | const current = sessions.get(tabId); | |
| 556 | if (!current || !current.consumers.has(consumer)) { | |
| 557 | return [undefined, sessions] as const; | |
| 558 | } | |
| 559 | const consumers = new Set(current.consumers); | |
| 560 | consumers.delete(consumer); | |
| 561 | if (consumers.size > 0) { | |
| 562 | return [ | |
| 563 | undefined, | |
| 564 | replaceMap(sessions, (copy) => { | |
| 565 | copy.set(tabId, { ...current, consumers }); | |
| 566 | }), | |
| 567 | ] as const; | |
| 568 | } | |
| 569 | return [ | |
| 570 | current.scope, | |
| 571 | replaceMap(sessions, (copy) => { | |
| 572 | copy.delete(tabId); | |
| 573 | }), | |
| 574 | ] as const; | |
| 575 | }); | |
| 576 | if (captureScope) { | |
| 577 | yield* Scope.close(captureScope, Exit.void).pipe(Effect.ignore); | |
| 578 | } | |
| 579 | }); | |
| 449 | 580 | |
| 450 | 581 | const deliverEvent = ( |
| 451 | 582 | eventKind: "state-change" | "recording-frame" | "pointer-event", |
| ... | ... | @@ -539,15 +670,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 539 | 670 | ), |
| 540 | 671 | ); |
| 541 | 672 | |
| 542 | const tabIdForWebContents = Effect.fn("PreviewManager.tabIdForWebContents")(function* ( | |
| 543 | webContentsId: number, | |
| 544 | ) { | |
| 545 | const tabs = yield* SynchronizedRef.get(tabsRef); | |
| 546 | return ( | |
| 547 | Array.from(tabs.entries()).find(([, tab]) => tab.webContentsId === webContentsId)?.[0] ?? null | |
| 548 | ); | |
| 549 | }); | |
| 550 | ||
| 551 | 673 | const pushBounded = <A>(buffer: ReadonlyArray<A>, entry: A): ReadonlyArray<A> => |
| 552 | 674 | [...buffer, entry].slice(-DIAGNOSTIC_BUFFER_LIMIT); |
| 553 | 675 | |
| ... | ... | @@ -731,42 +853,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 731 | 853 | const scope = yield* Scope.fork(parentScope, "sequential"); |
| 732 | 854 | const handleDebuggerMessage = Effect.fn("PreviewManager.handleDebuggerMessage")( |
| 733 | 855 | function* (method: string, params: Record<string, unknown>) { |
| 734 | if (method === "Page.screencastFrame") { | |
| 735 | const sessionId = params["sessionId"]; | |
| 736 | if (typeof sessionId === "number") { | |
| 737 | yield* attemptPromise( | |
| 738 | { | |
| 739 | operation: "ackScreencastFrame", | |
| 740 | webContentsId: wc.id, | |
| 741 | }, | |
| 742 | () => wc.debugger.sendCommand("Page.screencastFrameAck", { sessionId }), | |
| 743 | ).pipe(Effect.ignore); | |
| 744 | } | |
| 745 | const tabId = yield* tabIdForWebContents(wc.id); | |
| 746 | const metadata = | |
| 747 | typeof params["metadata"] === "object" && params["metadata"] !== null | |
| 748 | ? (params["metadata"] as Record<string, unknown>) | |
| 749 | : {}; | |
| 750 | if (tabId && typeof params["data"] === "string") { | |
| 751 | const receivedAt = yield* currentIso; | |
| 752 | const listeners = yield* Ref.get(recordingFrameListenersRef); | |
| 753 | const frame: DesktopPreviewRecordingFrame = { | |
| 754 | tabId, | |
| 755 | data: params["data"], | |
| 756 | width: | |
| 757 | typeof metadata["deviceWidth"] === "number" ? metadata["deviceWidth"] : 0, | |
| 758 | height: | |
| 759 | typeof metadata["deviceHeight"] === "number" ? metadata["deviceHeight"] : 0, | |
| 760 | receivedAt, | |
| 761 | }; | |
| 762 | yield* Effect.forEach( | |
| 763 | listeners, | |
| 764 | (listener) => | |
| 765 | deliverEvent("recording-frame", frame.tabId, () => listener(frame)), | |
| 766 | { discard: true }, | |
| 767 | ); | |
| 768 | } | |
| 769 | } | |
| 770 | 856 | yield* captureDiagnosticMessage(wc.id, method, params); |
| 771 | 857 | }, |
| 772 | 858 | ); |
| ... | ... | @@ -1276,71 +1362,132 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1276 | 1362 | window: BrowserWindow, |
| 1277 | 1363 | ) { |
| 1278 | 1364 | yield* Ref.set(mainWindowRef, Option.some(window)); |
| 1365 | window.once("closed", () => { | |
| 1366 | runFork(closeAllPictureInPicture()); | |
| 1367 | }); | |
| 1279 | 1368 | }); |
| 1280 | 1369 | |
| 1281 | const createTab = Effect.fn("PreviewManager.createTab")(function* (tabId: string) { | |
| 1370 | const createTabUnlocked = Effect.fn("PreviewManager.createTabUnlocked")(function* ( | |
| 1371 | tabId: string, | |
| 1372 | ) { | |
| 1282 | 1373 | const updatedAt = yield* currentIso; |
| 1283 | const state = yield* SynchronizedRef.modify(tabsRef, (tabs) => { | |
| 1284 | const existing = tabs.get(tabId); | |
| 1285 | if (existing) return [existing, tabs] as const; | |
| 1286 | const initial: PreviewTabState = { | |
| 1287 | tabId, | |
| 1288 | webContentsId: null, | |
| 1289 | navStatus: { kind: "Idle" }, | |
| 1290 | canGoBack: false, | |
| 1291 | canGoForward: false, | |
| 1292 | zoomFactor: DEFAULT_ZOOM_FACTOR, | |
| 1293 | colorScheme: "system", | |
| 1294 | controller: "none", | |
| 1295 | updatedAt, | |
| 1296 | }; | |
| 1374 | const result = yield* SynchronizedRef.modify( | |
| 1375 | tabsRef, | |
| 1376 | ( | |
| 1377 | tabs, | |
| 1378 | ): readonly [ | |
| 1379 | { readonly state: PreviewTabState; readonly created: boolean }, | |
| 1380 | ReadonlyMap<string, PreviewTabState>, | |
| 1381 | ] => { | |
| 1382 | const existing = tabs.get(tabId); | |
| 1383 | if (existing) return [{ state: existing, created: false }, tabs] as const; | |
| 1384 | const initial: PreviewTabState = { | |
| 1385 | tabId, | |
| 1386 | webContentsId: null, | |
| 1387 | navStatus: { kind: "Idle" }, | |
| 1388 | canGoBack: false, | |
| 1389 | canGoForward: false, | |
| 1390 | zoomFactor: DEFAULT_ZOOM_FACTOR, | |
| 1391 | pictureInPicture: false, | |
| 1392 | colorScheme: "system", | |
| 1393 | controller: "none", | |
| 1394 | updatedAt, | |
| 1395 | }; | |
| 1396 | return [ | |
| 1397 | { state: initial, created: true }, | |
| 1398 | replaceMap(tabs, (copy) => { | |
| 1399 | copy.set(tabId, initial); | |
| 1400 | }), | |
| 1401 | ] as const; | |
| 1402 | }, | |
| 1403 | ); | |
| 1404 | if (result.created) { | |
| 1405 | tabLifecycleGenerations.set(tabId, (tabLifecycleGenerations.get(tabId) ?? 0) + 1); | |
| 1406 | } | |
| 1407 | yield* emit(tabId, result.state); | |
| 1408 | return result.state; | |
| 1409 | }); | |
| 1410 | ||
| 1411 | const createTab = Effect.fn("PreviewManager.createTab")(function* (tabId: string) { | |
| 1412 | return yield* withTabLifecycleLock(tabId, createTabUnlocked(tabId)); | |
| 1413 | }); | |
| 1414 | ||
| 1415 | const closeTabUnlocked = Effect.fn("PreviewManager.closeTabUnlocked")(function* (tabId: string) { | |
| 1416 | if (!(yield* SynchronizedRef.get(tabsRef)).has(tabId)) return; | |
| 1417 | yield* Effect.all( | |
| 1418 | [ | |
| 1419 | cancelPickElement(tabId), | |
| 1420 | closePictureInPicture(tabId), | |
| 1421 | stopFrameCapture(tabId, "recording"), | |
| 1422 | ], | |
| 1423 | { | |
| 1424 | concurrency: 3, | |
| 1425 | discard: true, | |
| 1426 | }, | |
| 1427 | ); | |
| 1428 | const tab = yield* SynchronizedRef.modify(tabsRef, (tabs) => { | |
| 1429 | const current = tabs.get(tabId); | |
| 1430 | if (!current) return [Option.none<PreviewTabState>(), tabs] as const; | |
| 1297 | 1431 | return [ |
| 1298 | initial, | |
| 1432 | Option.some(current), | |
| 1299 | 1433 | replaceMap(tabs, (copy) => { |
| 1300 | copy.set(tabId, initial); | |
| 1434 | copy.delete(tabId); | |
| 1301 | 1435 | }), |
| 1302 | 1436 | ] as const; |
| 1303 | 1437 | }); |
| 1304 | yield* emit(tabId, state); | |
| 1305 | return state; | |
| 1306 | }); | |
| 1307 | ||
| 1308 | const closeTab = Effect.fn("PreviewManager.closeTab")(function* (tabId: string) { | |
| 1309 | const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); | |
| 1310 | if (!tab) return; | |
| 1311 | yield* cancelPickElement(tabId); | |
| 1312 | if (tab.webContentsId != null) { | |
| 1438 | if (Option.isNone(tab)) return; | |
| 1439 | const closedTab = tab.value; | |
| 1440 | if (closedTab.webContentsId != null) { | |
| 1313 | 1441 | yield* Effect.all( |
| 1314 | [detachControlSession(tab.webContentsId), detachListeners(tab.webContentsId)], | |
| 1442 | [detachControlSession(closedTab.webContentsId), detachListeners(closedTab.webContentsId)], | |
| 1315 | 1443 | { concurrency: 2, discard: true }, |
| 1316 | 1444 | ); |
| 1317 | 1445 | } |
| 1318 | 1446 | const updatedAt = yield* currentIso; |
| 1319 | 1447 | const closed: PreviewTabState = { |
| 1320 | ...tab, | |
| 1448 | ...closedTab, | |
| 1321 | 1449 | webContentsId: null, |
| 1322 | 1450 | navStatus: { kind: "Idle" }, |
| 1323 | 1451 | canGoBack: false, |
| 1324 | 1452 | canGoForward: false, |
| 1325 | 1453 | zoomFactor: DEFAULT_ZOOM_FACTOR, |
| 1454 | pictureInPicture: false, | |
| 1326 | 1455 | colorScheme: "system", |
| 1327 | 1456 | controller: "none", |
| 1328 | 1457 | updatedAt, |
| 1329 | 1458 | }; |
| 1330 | yield* SynchronizedRef.update(tabsRef, (tabs) => | |
| 1331 | replaceMap(tabs, (copy) => { | |
| 1332 | copy.delete(tabId); | |
| 1333 | }), | |
| 1334 | ); | |
| 1335 | 1459 | yield* emit(tabId, closed); |
| 1336 | 1460 | }); |
| 1337 | 1461 | |
| 1338 | const registerWebview = Effect.fn("PreviewManager.registerWebview")(function* ( | |
| 1462 | const closeTab = Effect.fn("PreviewManager.closeTab")(function* (tabId: string) { | |
| 1463 | const claimed = yield* Ref.modify(closingTabIdsRef, (closingTabIds) => { | |
| 1464 | if (closingTabIds.has(tabId)) return [false, closingTabIds] as const; | |
| 1465 | return [true, new Set([...closingTabIds, tabId])] as const; | |
| 1466 | }); | |
| 1467 | if (!claimed) return; | |
| 1468 | return yield* withTabLifecycleLock(tabId, closeTabUnlocked(tabId)).pipe( | |
| 1469 | Effect.ensuring( | |
| 1470 | Ref.update(closingTabIdsRef, (closingTabIds) => { | |
| 1471 | if (!closingTabIds.has(tabId)) return closingTabIds; | |
| 1472 | const next = new Set(closingTabIds); | |
| 1473 | next.delete(tabId); | |
| 1474 | return next; | |
| 1475 | }), | |
| 1476 | ), | |
| 1477 | ); | |
| 1478 | }); | |
| 1479 | ||
| 1480 | const registerWebviewUnlocked = Effect.fn("PreviewManager.registerWebviewUnlocked")(function* ( | |
| 1339 | 1481 | tabId: string, |
| 1340 | 1482 | webContentsId: number, |
| 1483 | expectedGeneration: number | undefined, | |
| 1341 | 1484 | ) { |
| 1342 | 1485 | const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); |
| 1343 | if (!tab) { | |
| 1486 | if ( | |
| 1487 | !tab || | |
| 1488 | tabLifecycleGenerations.get(tabId) !== expectedGeneration || | |
| 1489 | (yield* Ref.get(closingTabIdsRef)).has(tabId) | |
| 1490 | ) { | |
| 1344 | 1491 | return yield* new PreviewTabNotFoundError({ tabId }); |
| 1345 | 1492 | } |
| 1346 | 1493 | const wc = webContents.fromId(webContentsId); |
| ... | ... | @@ -1377,53 +1524,71 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1377 | 1524 | { concurrency: 3, discard: true }, |
| 1378 | 1525 | ); |
| 1379 | 1526 | } |
| 1527 | const currentTab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); | |
| 1528 | if ( | |
| 1529 | !currentTab || | |
| 1530 | tabLifecycleGenerations.get(tabId) !== expectedGeneration || | |
| 1531 | (yield* Ref.get(closingTabIdsRef)).has(tabId) | |
| 1532 | ) { | |
| 1533 | return yield* new PreviewTabNotFoundError({ tabId }); | |
| 1534 | } | |
| 1380 | 1535 | const zoomFactor = |
| 1381 | 1536 | replacedWebContentsId !== null |
| 1382 | 1537 | ? yield* attempt( |
| 1383 | 1538 | { operation: "registerWebview.restoreZoomFactor", tabId, webContentsId }, |
| 1384 | 1539 | () => { |
| 1385 | wc.setZoomFactor(tab.zoomFactor); | |
| 1386 | return tab.zoomFactor; | |
| 1540 | wc.setZoomFactor(currentTab.zoomFactor); | |
| 1541 | return currentTab.zoomFactor; | |
| 1387 | 1542 | }, |
| 1388 | 1543 | ) |
| 1389 | 1544 | : yield* attempt({ operation: "registerWebview.getZoomFactor", tabId, webContentsId }, () => |
| 1390 | 1545 | wc.getZoomFactor(), |
| 1391 | 1546 | ); |
| 1392 | 1547 | yield* attachListeners(tabId, wc); |
| 1393 | runFork(restoreControlSession(tabId, wc)); | |
| 1394 | 1548 | const registeredAt = yield* currentIso; |
| 1395 | const registration = yield* SynchronizedRef.modify(tabsRef, (tabs) => { | |
| 1396 | const current = tabs.get(tabId); | |
| 1397 | if (!current) { | |
| 1549 | const registration = yield* SynchronizedRef.modifyEffect(tabsRef, (tabs) => | |
| 1550 | Effect.gen(function* () { | |
| 1551 | const current = tabs.get(tabId); | |
| 1552 | if ( | |
| 1553 | !current || | |
| 1554 | tabLifecycleGenerations.get(tabId) !== expectedGeneration || | |
| 1555 | (yield* Ref.get(closingTabIdsRef)).has(tabId) | |
| 1556 | ) { | |
| 1557 | return [ | |
| 1558 | Option.none<{ readonly state: PreviewTabState; readonly pendingUrl: string | null }>(), | |
| 1559 | tabs, | |
| 1560 | ] as const; | |
| 1561 | } | |
| 1562 | const pendingUrl = current.navStatus.kind === "Loading" ? current.navStatus.url : null; | |
| 1563 | const next: PreviewTabState = { | |
| 1564 | ...current, | |
| 1565 | webContentsId, | |
| 1566 | navStatus: pendingUrl === null ? computeNavStatus(wc) : current.navStatus, | |
| 1567 | canGoBack: wc.navigationHistory.canGoBack(), | |
| 1568 | canGoForward: wc.navigationHistory.canGoForward(), | |
| 1569 | zoomFactor, | |
| 1570 | updatedAt: registeredAt, | |
| 1571 | }; | |
| 1398 | 1572 | return [ |
| 1399 | Option.none<{ readonly state: PreviewTabState; readonly pendingUrl: string | null }>(), | |
| 1400 | tabs, | |
| 1573 | Option.some({ | |
| 1574 | state: next, | |
| 1575 | pendingUrl, | |
| 1576 | }), | |
| 1577 | replaceMap(tabs, (copy) => { | |
| 1578 | copy.set(tabId, next); | |
| 1579 | }), | |
| 1401 | 1580 | ] as const; |
| 1402 | } | |
| 1403 | const pendingUrl = current.navStatus.kind === "Loading" ? current.navStatus.url : null; | |
| 1404 | const next: PreviewTabState = { | |
| 1405 | ...current, | |
| 1406 | webContentsId, | |
| 1407 | navStatus: pendingUrl === null ? computeNavStatus(wc) : current.navStatus, | |
| 1408 | canGoBack: wc.navigationHistory.canGoBack(), | |
| 1409 | canGoForward: wc.navigationHistory.canGoForward(), | |
| 1410 | zoomFactor, | |
| 1411 | updatedAt: registeredAt, | |
| 1412 | }; | |
| 1413 | return [ | |
| 1414 | Option.some({ | |
| 1415 | state: next, | |
| 1416 | pendingUrl, | |
| 1417 | }), | |
| 1418 | replaceMap(tabs, (copy) => { | |
| 1419 | copy.set(tabId, next); | |
| 1420 | }), | |
| 1421 | ] as const; | |
| 1422 | }); | |
| 1581 | }), | |
| 1582 | ); | |
| 1423 | 1583 | if (Option.isNone(registration)) { |
| 1584 | yield* Effect.all([detachControlSession(webContentsId), detachListeners(webContentsId)], { | |
| 1585 | concurrency: 2, | |
| 1586 | discard: true, | |
| 1587 | }); | |
| 1424 | 1588 | return yield* new PreviewTabNotFoundError({ tabId }); |
| 1425 | 1589 | } |
| 1426 | 1590 | const { state: registered, pendingUrl } = registration.value; |
| 1591 | runFork(restoreControlSession(tabId, wc)); | |
| 1427 | 1592 | yield* emit(tabId, registered); |
| 1428 | 1593 | yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => |
| 1429 | 1594 | wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme), |
| ... | ... | @@ -1443,6 +1608,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1443 | 1608 | } |
| 1444 | 1609 | }); |
| 1445 | 1610 | |
| 1611 | const registerWebview = Effect.fn("PreviewManager.registerWebview")(function* ( | |
| 1612 | tabId: string, | |
| 1613 | webContentsId: number, | |
| 1614 | ) { | |
| 1615 | const expectedGeneration = tabLifecycleGenerations.get(tabId); | |
| 1616 | return yield* withTabLifecycleLock( | |
| 1617 | tabId, | |
| 1618 | registerWebviewUnlocked(tabId, webContentsId, expectedGeneration), | |
| 1619 | ); | |
| 1620 | }); | |
| 1621 | ||
| 1446 | 1622 | const navigate = Effect.fn("PreviewManager.navigate")(function* (tabId: string, rawUrl: string) { |
| 1447 | 1623 | const url = yield* attempt({ operation: "navigate.normalizeUrl", tabId }, () => |
| 1448 | 1624 | normalizePreviewUrl(rawUrl), |
| ... | ... | @@ -1461,6 +1637,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1461 | 1637 | canGoBack: current?.canGoBack ?? false, |
| 1462 | 1638 | canGoForward: current?.canGoForward ?? false, |
| 1463 | 1639 | zoomFactor: current?.zoomFactor ?? DEFAULT_ZOOM_FACTOR, |
| 1640 | pictureInPicture: current?.pictureInPicture ?? false, | |
| 1464 | 1641 | colorScheme: current?.colorScheme ?? "system", |
| 1465 | 1642 | controller: current?.controller ?? "none", |
| 1466 | 1643 | updatedAt, |
| ... | ... | @@ -1716,14 +1893,28 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1716 | 1893 | // session attaches so a concurrent setColorScheme is not overwritten with |
| 1717 | 1894 | // a stale snapshot. |
| 1718 | 1895 | const restoreControlSession = (tabId: string, wc: Electron.WebContents) => |
| 1719 | ensureControlSession(wc).pipe( | |
| 1720 | Effect.andThen(SynchronizedRef.get(tabsRef)), | |
| 1721 | Effect.flatMap((tabs) => { | |
| 1722 | const colorScheme = tabs.get(tabId)?.colorScheme ?? "system"; | |
| 1723 | return colorScheme === "system" ? Effect.void : applyColorScheme(tabId, wc, colorScheme); | |
| 1724 | }), | |
| 1725 | Effect.ignore, | |
| 1726 | ); | |
| 1896 | Effect.gen(function* () { | |
| 1897 | const beforeAttach = (yield* SynchronizedRef.get(tabsRef)).get(tabId); | |
| 1898 | if (beforeAttach?.webContentsId !== wc.id) return; | |
| 1899 | yield* ensureControlSession(wc); | |
| 1900 | const afterAttach = (yield* SynchronizedRef.get(tabsRef)).get(tabId); | |
| 1901 | if (afterAttach?.webContentsId !== wc.id) { | |
| 1902 | yield* detachControlSession(wc.id); | |
| 1903 | return; | |
| 1904 | } | |
| 1905 | if (afterAttach.colorScheme !== "system") { | |
| 1906 | yield* attemptPromise({ operation: "applyColorScheme", tabId, webContentsId: wc.id }, () => | |
| 1907 | wc.debugger.sendCommand("Emulation.setEmulatedMedia", { | |
| 1908 | features: [ | |
| 1909 | { | |
| 1910 | name: "prefers-color-scheme", | |
| 1911 | value: afterAttach.colorScheme, | |
| 1912 | }, | |
| 1913 | ], | |
| 1914 | }), | |
| 1915 | ); | |
| 1916 | } | |
| 1917 | }).pipe(Effect.ignore); | |
| 1727 | 1918 | |
| 1728 | 1919 | const setColorScheme = Effect.fn("PreviewManager.setColorScheme")(function* ( |
| 1729 | 1920 | tabId: string, |
| ... | ... | @@ -1801,40 +1992,485 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1801 | 1992 | }; |
| 1802 | 1993 | }); |
| 1803 | 1994 | |
| 1804 | const startScreencast = Effect.fn("PreviewManager.startScreencast")(function* ( | |
| 1805 | send: SendCommand, | |
| 1995 | const capturePreviewFrame = Effect.fn("PreviewManager.capturePreviewFrame")(function* ( | |
| 1996 | tabId: string, | |
| 1806 | 1997 | ) { |
| 1807 | yield* send("Page.enable"); | |
| 1808 | yield* send("Page.startScreencast", { | |
| 1809 | format: "jpeg", | |
| 1810 | quality: 80, | |
| 1811 | maxWidth: 1600, | |
| 1812 | maxHeight: 1200, | |
| 1813 | everyNthFrame: 1, | |
| 1998 | const captureSession = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(tabId); | |
| 1999 | if (!captureSession) return; | |
| 2000 | const wc = yield* requireWebContents(tabId); | |
| 2001 | const image = yield* attemptPromise( | |
| 2002 | { | |
| 2003 | operation: "frameCapture.capturePage", | |
| 2004 | tabId, | |
| 2005 | webContentsId: wc.id, | |
| 2006 | }, | |
| 2007 | () => wc.capturePage(), | |
| 2008 | ); | |
| 2009 | const currentCaptureSession = yield* Effect.all( | |
| 2010 | [SynchronizedRef.get(frameCaptureSessionsRef), SynchronizedRef.get(tabsRef)], | |
| 2011 | { concurrency: 2 }, | |
| 2012 | ).pipe( | |
| 2013 | Effect.map(([captureSessions, tabs]) => { | |
| 2014 | const current = captureSessions.get(tabId); | |
| 2015 | return current?.scope === captureSession.scope && | |
| 2016 | tabs.get(tabId)?.webContentsId === wc.id && | |
| 2017 | !wc.isDestroyed() | |
| 2018 | ? current | |
| 2019 | : undefined; | |
| 2020 | }), | |
| 2021 | ); | |
| 2022 | if (!currentCaptureSession) return; | |
| 2023 | const size = yield* attempt( | |
| 2024 | { | |
| 2025 | operation: "frameCapture.measureFrame", | |
| 2026 | tabId, | |
| 2027 | webContentsId: wc.id, | |
| 2028 | }, | |
| 2029 | () => image.getSize(), | |
| 2030 | ); | |
| 2031 | if ( | |
| 2032 | !Number.isFinite(size.width) || | |
| 2033 | !Number.isFinite(size.height) || | |
| 2034 | size.width <= 0 || | |
| 2035 | size.height <= 0 | |
| 2036 | ) { | |
| 2037 | return; | |
| 2038 | } | |
| 2039 | const encoded = yield* attempt( | |
| 2040 | { | |
| 2041 | operation: "frameCapture.encodeFrame", | |
| 2042 | tabId, | |
| 2043 | webContentsId: wc.id, | |
| 2044 | }, | |
| 2045 | () => image.toJPEG(RECORDING_JPEG_QUALITY).toString("base64"), | |
| 2046 | ); | |
| 2047 | const receivedAt = yield* currentIso; | |
| 2048 | const frame: DesktopPreviewRecordingFrame = { | |
| 2049 | tabId, | |
| 2050 | data: encoded, | |
| 2051 | width: size.width, | |
| 2052 | height: size.height, | |
| 2053 | receivedAt, | |
| 2054 | }; | |
| 2055 | const deliveries: Array<Effect.Effect<void>> = []; | |
| 2056 | if (currentCaptureSession.consumers.has("recording")) { | |
| 2057 | const listeners = yield* Ref.get(recordingFrameListenersRef); | |
| 2058 | deliveries.push( | |
| 2059 | Effect.forEach( | |
| 2060 | listeners, | |
| 2061 | (listener) => deliverEvent("recording-frame", frame.tabId, () => listener(frame)), | |
| 2062 | { discard: true }, | |
| 2063 | ), | |
| 2064 | ); | |
| 2065 | } | |
| 2066 | if (currentCaptureSession.consumers.has("picture-in-picture")) { | |
| 2067 | const pictureInPictureWindow = (yield* SynchronizedRef.get(pictureInPictureSessionsRef)).get( | |
| 2068 | tabId, | |
| 2069 | )?.window; | |
| 2070 | if (pictureInPictureWindow && !pictureInPictureWindow.isDestroyed()) { | |
| 2071 | deliveries.push( | |
| 2072 | Effect.gen(function* () { | |
| 2073 | const previousAspectRatio = (yield* Ref.get(pictureInPictureAspectRatiosRef)).get( | |
| 2074 | tabId, | |
| 2075 | ); | |
| 2076 | const aspectRatio = frame.width / frame.height; | |
| 2077 | if (previousAspectRatio !== aspectRatio) { | |
| 2078 | yield* attempt( | |
| 2079 | { | |
| 2080 | operation: "pictureInPicture.setAspectRatio", | |
| 2081 | tabId, | |
| 2082 | webContentsId: wc.id, | |
| 2083 | }, | |
| 2084 | () => { | |
| 2085 | const contentSize = fitPictureInPictureContentSize( | |
| 2086 | pictureInPictureWindow.getContentSize(), | |
| 2087 | aspectRatio, | |
| 2088 | ); | |
| 2089 | pictureInPictureWindow.setAspectRatio(0); | |
| 2090 | pictureInPictureWindow.setContentSize(contentSize[0], contentSize[1], false); | |
| 2091 | pictureInPictureWindow.setAspectRatio(aspectRatio); | |
| 2092 | }, | |
| 2093 | ); | |
| 2094 | yield* Ref.update(pictureInPictureAspectRatiosRef, (aspectRatios) => | |
| 2095 | replaceMap(aspectRatios, (copy) => { | |
| 2096 | copy.set(tabId, aspectRatio); | |
| 2097 | }), | |
| 2098 | ); | |
| 2099 | } | |
| 2100 | yield* attempt( | |
| 2101 | { | |
| 2102 | operation: "pictureInPicture.deliverFrame", | |
| 2103 | tabId, | |
| 2104 | webContentsId: wc.id, | |
| 2105 | }, | |
| 2106 | () => { | |
| 2107 | pictureInPictureWindow.webContents.send( | |
| 2108 | PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL, | |
| 2109 | frame, | |
| 2110 | ); | |
| 2111 | }, | |
| 2112 | ); | |
| 2113 | }).pipe( | |
| 2114 | Effect.catch((error) => | |
| 2115 | Effect.logWarning("Picture-in-picture frame delivery failed.", { | |
| 2116 | tabId, | |
| 2117 | error, | |
| 2118 | }), | |
| 2119 | ), | |
| 2120 | ), | |
| 2121 | ); | |
| 2122 | } | |
| 2123 | } | |
| 2124 | yield* Effect.all(deliveries, { concurrency: 2, discard: true }); | |
| 2125 | }); | |
| 2126 | ||
| 2127 | const startFrameCapture = Effect.fn("PreviewManager.startFrameCapture")(function* ( | |
| 2128 | tabId: string, | |
| 2129 | consumer: FrameCaptureConsumer, | |
| 2130 | ) { | |
| 2131 | // Validate the tab synchronously, but treat capturePage failures as | |
| 2132 | // transient. Chromium can return UnknownVizError while a hidden guest is | |
| 2133 | // warming its first compositor frame; the scheduled loop should keep the | |
| 2134 | // consumer alive and recover instead of tearing recording/PiP back down. | |
| 2135 | yield* requireWebContents(tabId); | |
| 2136 | const captureNextFrame = Effect.sleep(RECORDING_FRAME_INTERVAL_MS).pipe( | |
| 2137 | Effect.andThen(capturePreviewFrame(tabId)), | |
| 2138 | Effect.catch((error) => | |
| 2139 | Effect.logWarning("Background preview frame capture failed.", { | |
| 2140 | tabId, | |
| 2141 | error, | |
| 2142 | }), | |
| 2143 | ), | |
| 2144 | ); | |
| 2145 | const created = yield* SynchronizedRef.modifyEffect(frameCaptureSessionsRef, (sessions) => { | |
| 2146 | return Effect.gen(function* () { | |
| 2147 | const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); | |
| 2148 | if (!tab || (yield* Ref.get(closingTabIdsRef)).has(tabId)) { | |
| 2149 | return yield* new PreviewTabNotFoundError({ tabId }); | |
| 2150 | } | |
| 2151 | const current = sessions.get(tabId); | |
| 2152 | if (current) { | |
| 2153 | if (current.consumers.has(consumer)) { | |
| 2154 | return [false, sessions] as const; | |
| 2155 | } | |
| 2156 | return [ | |
| 2157 | false, | |
| 2158 | replaceMap(sessions, (copy) => { | |
| 2159 | copy.set(tabId, { | |
| 2160 | ...current, | |
| 2161 | consumers: new Set([...current.consumers, consumer]), | |
| 2162 | }); | |
| 2163 | }), | |
| 2164 | ] as const; | |
| 2165 | } | |
| 2166 | const scope = yield* Scope.fork(parentScope, "sequential"); | |
| 2167 | yield* Effect.forkIn(Effect.forever(captureNextFrame), scope); | |
| 2168 | return [ | |
| 2169 | true, | |
| 2170 | replaceMap(sessions, (copy) => { | |
| 2171 | copy.set(tabId, { | |
| 2172 | scope, | |
| 2173 | consumers: new Set([consumer]), | |
| 2174 | }); | |
| 2175 | }), | |
| 2176 | ] as const; | |
| 2177 | }); | |
| 2178 | }); | |
| 2179 | if (!created) return; | |
| 2180 | yield* capturePreviewFrame(tabId).pipe( | |
| 2181 | Effect.catch((error) => | |
| 2182 | Effect.logWarning("Initial background preview frame was not ready; capture will retry.", { | |
| 2183 | tabId, | |
| 2184 | consumer, | |
| 2185 | error, | |
| 2186 | }), | |
| 2187 | ), | |
| 2188 | ); | |
| 2189 | }); | |
| 2190 | ||
| 2191 | const releasePictureInPicture = Effect.fn("PreviewManager.releasePictureInPicture")(function* ( | |
| 2192 | tabId: string, | |
| 2193 | expectedSession: PictureInPictureSession, | |
| 2194 | closeWindow: boolean, | |
| 2195 | ) { | |
| 2196 | const removed = yield* SynchronizedRef.modify(pictureInPictureSessionsRef, (sessions) => { | |
| 2197 | if (sessions.get(tabId) !== expectedSession) { | |
| 2198 | return [false, sessions] as const; | |
| 2199 | } | |
| 2200 | return [ | |
| 2201 | true, | |
| 2202 | replaceMap(sessions, (copy) => { | |
| 2203 | copy.delete(tabId); | |
| 2204 | }), | |
| 2205 | ] as const; | |
| 1814 | 2206 | }); |
| 2207 | if (!removed) return; | |
| 2208 | yield* Deferred.interrupt(expectedSession.ready); | |
| 2209 | yield* Scope.close(expectedSession.initializationScope, Exit.void).pipe(Effect.ignore); | |
| 2210 | yield* Ref.update(pictureInPictureAspectRatiosRef, (aspectRatios) => | |
| 2211 | replaceMap(aspectRatios, (copy) => { | |
| 2212 | copy.delete(tabId); | |
| 2213 | }), | |
| 2214 | ); | |
| 2215 | yield* stopFrameCapture(tabId, "picture-in-picture"); | |
| 2216 | const tabs = yield* SynchronizedRef.get(tabsRef); | |
| 2217 | if (tabs.has(tabId)) { | |
| 2218 | yield* update(tabId, { pictureInPicture: false }); | |
| 2219 | } | |
| 2220 | if (closeWindow && !expectedSession.window.isDestroyed()) { | |
| 2221 | yield* attempt({ operation: "pictureInPicture.close", tabId }, () => | |
| 2222 | expectedSession.window.close(), | |
| 2223 | ).pipe(Effect.ignore); | |
| 2224 | } | |
| 1815 | 2225 | }); |
| 1816 | 2226 | |
| 1817 | const startRecording = Effect.fn("PreviewManager.startRecording")(function* (tabId: string) { | |
| 1818 | const recordingTabId = yield* Ref.get(recordingTabIdRef); | |
| 1819 | if (Option.isSome(recordingTabId) && recordingTabId.value !== tabId) { | |
| 1820 | return yield* new PreviewRecordingAlreadyActiveError({ | |
| 1821 | requestedTabId: tabId, | |
| 1822 | activeTabId: recordingTabId.value, | |
| 2227 | const closePictureInPictureUnlocked = Effect.fn("PreviewManager.closePictureInPictureUnlocked")( | |
| 2228 | function* (tabId: string) { | |
| 2229 | const pictureInPictureSession = (yield* SynchronizedRef.get(pictureInPictureSessionsRef)).get( | |
| 2230 | tabId, | |
| 2231 | ); | |
| 2232 | if (!pictureInPictureSession) { | |
| 2233 | yield* stopFrameCapture(tabId, "picture-in-picture"); | |
| 2234 | const tabs = yield* SynchronizedRef.get(tabsRef); | |
| 2235 | if (tabs.has(tabId)) { | |
| 2236 | yield* update(tabId, { pictureInPicture: false }); | |
| 2237 | } | |
| 2238 | return; | |
| 2239 | } | |
| 2240 | yield* releasePictureInPicture(tabId, pictureInPictureSession, true); | |
| 2241 | }, | |
| 2242 | ); | |
| 2243 | ||
| 2244 | const closePictureInPicture = Effect.fn("PreviewManager.closePictureInPicture")(function* ( | |
| 2245 | tabId: string, | |
| 2246 | ) { | |
| 2247 | yield* pictureInPictureMutationSemaphore.withPermit(closePictureInPictureUnlocked(tabId)); | |
| 2248 | }); | |
| 2249 | ||
| 2250 | const closeAllPictureInPicture = Effect.fn("PreviewManager.closeAllPictureInPicture")( | |
| 2251 | function* () { | |
| 2252 | const sessions = yield* SynchronizedRef.get(pictureInPictureSessionsRef); | |
| 2253 | yield* Effect.forEach(sessions.keys(), closePictureInPicture, { | |
| 2254 | concurrency: "unbounded", | |
| 2255 | discard: true, | |
| 1823 | 2256 | }); |
| 2257 | }, | |
| 2258 | ); | |
| 2259 | ||
| 2260 | const openPictureInPicture = Effect.fn("PreviewManager.openPictureInPicture")(function* ( | |
| 2261 | tabId: string, | |
| 2262 | ) { | |
| 2263 | const claim = yield* pictureInPictureMutationSemaphore.withPermit( | |
| 2264 | Effect.gen(function* () { | |
| 2265 | const existing = (yield* SynchronizedRef.get(pictureInPictureSessionsRef)).get(tabId); | |
| 2266 | if (existing && !existing.window.isDestroyed()) { | |
| 2267 | return { kind: "existing" as const, session: existing }; | |
| 2268 | } | |
| 2269 | if (existing) { | |
| 2270 | yield* releasePictureInPicture(tabId, existing, false); | |
| 2271 | } | |
| 2272 | const wc = yield* requireWebContents(tabId); | |
| 2273 | const title = yield* attempt( | |
| 2274 | { | |
| 2275 | operation: "pictureInPicture.readTitle", | |
| 2276 | tabId, | |
| 2277 | webContentsId: wc.id, | |
| 2278 | }, | |
| 2279 | () => wc.getTitle().trim(), | |
| 2280 | ); | |
| 2281 | const pictureInPictureWindow = yield* attempt( | |
| 2282 | { | |
| 2283 | operation: "pictureInPicture.create", | |
| 2284 | tabId, | |
| 2285 | webContentsId: wc.id, | |
| 2286 | }, | |
| 2287 | () => | |
| 2288 | new BrowserWindow({ | |
| 2289 | width: PICTURE_IN_PICTURE_INITIAL_WIDTH, | |
| 2290 | height: PICTURE_IN_PICTURE_INITIAL_HEIGHT, | |
| 2291 | minWidth: PICTURE_IN_PICTURE_MIN_WIDTH, | |
| 2292 | minHeight: PICTURE_IN_PICTURE_MIN_HEIGHT, | |
| 2293 | title: title.length > 0 ? `Preview · ${title}` : "Browser preview", | |
| 2294 | show: false, | |
| 2295 | alwaysOnTop: true, | |
| 2296 | autoHideMenuBar: true, | |
| 2297 | fullscreenable: false, | |
| 2298 | maximizable: false, | |
| 2299 | minimizable: false, | |
| 2300 | resizable: true, | |
| 2301 | skipTaskbar: true, | |
| 2302 | backgroundColor: "#111111", | |
| 2303 | ...(hostPlatform === "darwin" ? { type: "panel" as const } : {}), | |
| 2304 | webPreferences: { | |
| 2305 | preload: pictureInPicturePreloadPath, | |
| 2306 | backgroundThrottling: false, | |
| 2307 | contextIsolation: true, | |
| 2308 | nodeIntegration: false, | |
| 2309 | sandbox: true, | |
| 2310 | }, | |
| 2311 | }), | |
| 2312 | ); | |
| 2313 | const initializationScope = yield* Scope.fork(parentScope, "sequential"); | |
| 2314 | const ready = yield* Deferred.make<void, PreviewManagerError>(); | |
| 2315 | const session: PictureInPictureSession = { | |
| 2316 | window: pictureInPictureWindow, | |
| 2317 | webContentsId: wc.id, | |
| 2318 | ready, | |
| 2319 | initializationScope, | |
| 2320 | }; | |
| 2321 | const onClosed = () => { | |
| 2322 | runFork( | |
| 2323 | pictureInPictureMutationSemaphore.withPermit( | |
| 2324 | releasePictureInPicture(tabId, session, false), | |
| 2325 | ), | |
| 2326 | ); | |
| 2327 | }; | |
| 2328 | yield* attempt( | |
| 2329 | { | |
| 2330 | operation: "pictureInPicture.configure", | |
| 2331 | tabId, | |
| 2332 | webContentsId: wc.id, | |
| 2333 | }, | |
| 2334 | () => { | |
| 2335 | pictureInPictureWindow.once("closed", onClosed); | |
| 2336 | pictureInPictureWindow.setAlwaysOnTop( | |
| 2337 | true, | |
| 2338 | hostPlatform === "darwin" ? "floating" : "normal", | |
| 2339 | ); | |
| 2340 | if (hostPlatform === "darwin") { | |
| 2341 | pictureInPictureWindow.setVisibleOnAllWorkspaces(true, { | |
| 2342 | visibleOnFullScreen: true, | |
| 2343 | // Electron otherwise temporarily transforms the entire app into | |
| 2344 | // a UIElement process, which removes the owning app from the Dock. | |
| 2345 | skipTransformProcessType: true, | |
| 2346 | }); | |
| 2347 | } | |
| 2348 | }, | |
| 2349 | ).pipe( | |
| 2350 | Effect.onError(() => | |
| 2351 | Effect.all( | |
| 2352 | [ | |
| 2353 | Scope.close(initializationScope, Exit.void).pipe(Effect.ignore), | |
| 2354 | attempt({ operation: "pictureInPicture.close", tabId }, () => | |
| 2355 | pictureInPictureWindow.close(), | |
| 2356 | ).pipe(Effect.ignore), | |
| 2357 | ], | |
| 2358 | { discard: true }, | |
| 2359 | ), | |
| 2360 | ), | |
| 2361 | ); | |
| 2362 | yield* SynchronizedRef.update(pictureInPictureSessionsRef, (sessions) => | |
| 2363 | replaceMap(sessions, (copy) => { | |
| 2364 | copy.set(tabId, session); | |
| 2365 | }), | |
| 2366 | ); | |
| 2367 | return { kind: "created" as const, session }; | |
| 2368 | }), | |
| 2369 | ); | |
| 2370 | const pictureInPictureSession = claim.session; | |
| 2371 | if (claim.kind === "existing") { | |
| 2372 | yield* Deferred.await(pictureInPictureSession.ready); | |
| 2373 | return yield* pictureInPictureMutationSemaphore.withPermit( | |
| 2374 | Effect.gen(function* () { | |
| 2375 | const current = (yield* SynchronizedRef.get(pictureInPictureSessionsRef)).get(tabId); | |
| 2376 | if (current !== pictureInPictureSession || pictureInPictureSession.window.isDestroyed()) { | |
| 2377 | return yield* new PreviewOperationError({ | |
| 2378 | operation: "pictureInPicture.showExisting", | |
| 2379 | tabId, | |
| 2380 | webContentsId: pictureInPictureSession.webContentsId, | |
| 2381 | cause: new Error("Picture-in-picture session closed before it became visible."), | |
| 2382 | }); | |
| 2383 | } | |
| 2384 | yield* attempt( | |
| 2385 | { | |
| 2386 | operation: "pictureInPicture.showExisting", | |
| 2387 | tabId, | |
| 2388 | webContentsId: pictureInPictureSession.webContentsId, | |
| 2389 | }, | |
| 2390 | () => pictureInPictureSession.window.showInactive(), | |
| 2391 | ); | |
| 2392 | }), | |
| 2393 | ); | |
| 1824 | 2394 | } |
| 1825 | const wc = yield* requireWebContents(tabId); | |
| 1826 | yield* withControlSession(tabId, wc, "recording.start", startScreencast); | |
| 1827 | yield* Ref.set(recordingTabIdRef, Option.some(tabId)); | |
| 2395 | ||
| 2396 | const initialize = Effect.gen(function* () { | |
| 2397 | yield* attemptPromise( | |
| 2398 | { | |
| 2399 | operation: "pictureInPicture.load", | |
| 2400 | tabId, | |
| 2401 | webContentsId: pictureInPictureSession.webContentsId, | |
| 2402 | }, | |
| 2403 | () => pictureInPictureSession.window.loadURL(buildPreviewPictureInPictureDataUrl()), | |
| 2404 | ); | |
| 2405 | const currentWebContents = yield* requireWebContents(tabId); | |
| 2406 | if ( | |
| 2407 | currentWebContents.id !== pictureInPictureSession.webContentsId || | |
| 2408 | currentWebContents.isDestroyed() | |
| 2409 | ) { | |
| 2410 | return yield* new PreviewOperationError({ | |
| 2411 | operation: "pictureInPicture.validateWebContents", | |
| 2412 | tabId, | |
| 2413 | webContentsId: pictureInPictureSession.webContentsId, | |
| 2414 | cause: new Error("Preview webview changed while picture-in-picture was opening."), | |
| 2415 | }); | |
| 2416 | } | |
| 2417 | yield* startFrameCapture(tabId, "picture-in-picture"); | |
| 2418 | yield* attempt( | |
| 2419 | { | |
| 2420 | operation: "pictureInPicture.show", | |
| 2421 | tabId, | |
| 2422 | webContentsId: pictureInPictureSession.webContentsId, | |
| 2423 | }, | |
| 2424 | () => pictureInPictureSession.window.showInactive(), | |
| 2425 | ); | |
| 2426 | }); | |
| 2427 | const initializationExit = yield* Effect.gen(function* () { | |
| 2428 | const initializationFiber = yield* Effect.forkIn( | |
| 2429 | initialize, | |
| 2430 | pictureInPictureSession.initializationScope, | |
| 2431 | ); | |
| 2432 | return yield* Fiber.await(initializationFiber); | |
| 2433 | }).pipe( | |
| 2434 | Effect.onInterrupt(() => | |
| 2435 | pictureInPictureMutationSemaphore.withPermit( | |
| 2436 | releasePictureInPicture(tabId, pictureInPictureSession, true), | |
| 2437 | ), | |
| 2438 | ), | |
| 2439 | ); | |
| 2440 | if (Exit.isSuccess(initializationExit)) { | |
| 2441 | const published = yield* pictureInPictureMutationSemaphore.withPermit( | |
| 2442 | Effect.gen(function* () { | |
| 2443 | const current = (yield* SynchronizedRef.get(pictureInPictureSessionsRef)).get(tabId); | |
| 2444 | if (current !== pictureInPictureSession || pictureInPictureSession.window.isDestroyed()) { | |
| 2445 | if (current === pictureInPictureSession) { | |
| 2446 | yield* releasePictureInPicture(tabId, pictureInPictureSession, false); | |
| 2447 | } | |
| 2448 | return false; | |
| 2449 | } | |
| 2450 | yield* update(tabId, { pictureInPicture: true }); | |
| 2451 | yield* Deferred.done(pictureInPictureSession.ready, initializationExit); | |
| 2452 | return true; | |
| 2453 | }), | |
| 2454 | ); | |
| 2455 | if (published) return; | |
| 2456 | return yield* Deferred.await(pictureInPictureSession.ready); | |
| 2457 | } | |
| 2458 | yield* Deferred.done(pictureInPictureSession.ready, initializationExit); | |
| 2459 | const current = (yield* SynchronizedRef.get(pictureInPictureSessionsRef)).get(tabId); | |
| 2460 | if (current === pictureInPictureSession) { | |
| 2461 | yield* pictureInPictureMutationSemaphore.withPermit( | |
| 2462 | releasePictureInPicture(tabId, pictureInPictureSession, true), | |
| 2463 | ); | |
| 2464 | } | |
| 2465 | return yield* Effect.failCause(initializationExit.cause); | |
| 2466 | }); | |
| 2467 | ||
| 2468 | const startRecording = Effect.fn("PreviewManager.startRecording")(function* (tabId: string) { | |
| 2469 | yield* startFrameCapture(tabId, "recording"); | |
| 1828 | 2470 | }); |
| 1829 | 2471 | |
| 1830 | 2472 | const stopRecording = Effect.fn("PreviewManager.stopRecording")(function* (tabId: string) { |
| 1831 | const recordingTabId = yield* Ref.get(recordingTabIdRef); | |
| 1832 | if (Option.isNone(recordingTabId) || recordingTabId.value !== tabId) return; | |
| 1833 | const wc = yield* requireWebContents(tabId); | |
| 1834 | yield* withControlSession(tabId, wc, "recording.stop", (send) => | |
| 1835 | send("Page.stopScreencast").pipe(Effect.asVoid), | |
| 1836 | ); | |
| 1837 | yield* Ref.set(recordingTabIdRef, Option.none()); | |
| 2473 | yield* stopFrameCapture(tabId, "recording"); | |
| 1838 | 2474 | }); |
| 1839 | 2475 | |
| 1840 | 2476 | const saveRecording = Effect.fn("PreviewManager.saveRecording")(function* ( |
| ... | ... | @@ -2582,6 +3218,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 2582 | 3218 | goForward, |
| 2583 | 3219 | hardReload, |
| 2584 | 3220 | navigate, |
| 3221 | openPictureInPicture, | |
| 2585 | 3222 | openDevTools, |
| 2586 | 3223 | pickElement, |
| 2587 | 3224 | refresh, |
| ... | ... | @@ -2593,6 +3230,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 2593 | 3230 | setColorScheme, |
| 2594 | 3231 | setMainWindow, |
| 2595 | 3232 | startRecording, |
| 3233 | closePictureInPicture, | |
| 2596 | 3234 | stopRecording, |
| 2597 | 3235 | subscribePointerEvents: (listener: PointerEventListener) => |
| 2598 | 3236 | subscribe(pointerEventListenersRef, listener), |
| ... | ... | @@ -2678,18 +3316,6 @@ export class PreviewArtifactImageLoadError extends Schema.TaggedErrorClass<Previ |
| 2678 | 3316 | } |
| 2679 | 3317 | } |
| 2680 | 3318 | |
| 2681 | export class PreviewRecordingAlreadyActiveError extends Schema.TaggedErrorClass<PreviewRecordingAlreadyActiveError>()( | |
| 2682 | "PreviewRecordingAlreadyActiveError", | |
| 2683 | { | |
| 2684 | requestedTabId: Schema.String, | |
| 2685 | activeTabId: Schema.String, | |
| 2686 | }, | |
| 2687 | ) { | |
| 2688 | override get message(): string { | |
| 2689 | return `Cannot record preview tab ${this.requestedTabId} while tab ${this.activeTabId} is already recording`; | |
| 2690 | } | |
| 2691 | } | |
| 2692 | ||
| 2693 | 3319 | export class PreviewAutomationDevToolsOpenError extends Schema.TaggedErrorClass<PreviewAutomationDevToolsOpenError>()( |
| 2694 | 3320 | "PreviewAutomationDevToolsOpenError", |
| 2695 | 3321 | { webContentsId: Schema.Number }, |
| ... | ... | @@ -2852,7 +3478,6 @@ export const PreviewManagerError = Schema.Union([ |
| 2852 | 3478 | PreviewOperationError, |
| 2853 | 3479 | PreviewArtifactPathOutsideDirectoryError, |
| 2854 | 3480 | PreviewArtifactImageLoadError, |
| 2855 | PreviewRecordingAlreadyActiveError, | |
| 2856 | 3481 | PreviewAutomationDevToolsOpenError, |
| 2857 | 3482 | PreviewAutomationDebuggerAttachedError, |
| 2858 | 3483 | PreviewAutomationEvaluationError, |
| ... | ... | @@ -2915,6 +3540,8 @@ export class PreviewManager extends Context.Service< |
| 2915 | 3540 | ) => Effect.Effect<DesktopPreviewScreenshotArtifact, PreviewManagerError>; |
| 2916 | 3541 | readonly revealArtifact: (path: string) => Effect.Effect<void, PreviewManagerError>; |
| 2917 | 3542 | readonly copyArtifactToClipboard: (path: string) => Effect.Effect<void, PreviewManagerError>; |
| 3543 | readonly openPictureInPicture: (tabId: string) => Effect.Effect<void, PreviewManagerError>; | |
| 3544 | readonly closePictureInPicture: (tabId: string) => Effect.Effect<void, PreviewManagerError>; | |
| 2918 | 3545 | readonly startRecording: (tabId: string) => Effect.Effect<void, PreviewManagerError>; |
| 2919 | 3546 | readonly stopRecording: (tabId: string) => Effect.Effect<void, PreviewManagerError>; |
| 2920 | 3547 | readonly saveRecording: ( |
| ... | ... | @@ -2965,7 +3592,10 @@ export class PreviewManager extends Context.Service< |
| 2965 | 3592 | export const make = Effect.gen(function* PreviewManagerMake() { |
| 2966 | 3593 | const environment = yield* DesktopEnvironment.DesktopEnvironment; |
| 2967 | 3594 | const browserSession = yield* BrowserSession.BrowserSession; |
| 2968 | const operations = yield* makeNativeOperations(environment.browserArtifactsDir); | |
| 3595 | const operations = yield* makeNativeOperations( | |
| 3596 | environment.browserArtifactsDir, | |
| 3597 | environment.path.join(environment.dirname, "preview-pip-preload.cjs"), | |
| 3598 | ); | |
| 2969 | 3599 | |
| 2970 | 3600 | return PreviewManager.of({ |
| 2971 | 3601 | setMainWindow: operations.setMainWindow, |
| ... | ... | @@ -3023,6 +3653,8 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 3023 | 3653 | captureScreenshot: operations.captureScreenshot, |
| 3024 | 3654 | revealArtifact: operations.revealArtifact, |
| 3025 | 3655 | copyArtifactToClipboard: operations.copyArtifactToClipboard, |
| 3656 | openPictureInPicture: operations.openPictureInPicture, | |
| 3657 | closePictureInPicture: operations.closePictureInPicture, | |
| 3026 | 3658 | startRecording: operations.startRecording, |
| 3027 | 3659 | stopRecording: operations.stopRecording, |
| 3028 | 3660 | saveRecording: operations.saveRecording, |
apps/desktop/src/window/DesktopWindow.test.ts+2| ... | ... | @@ -315,6 +315,7 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n |
| 315 | 315 | webPreferences: { |
| 316 | 316 | preload: null, |
| 317 | 317 | partition: null, |
| 318 | backgroundThrottling: null, | |
| 318 | 319 | sandbox: null, |
| 319 | 320 | contextIsolation: null, |
| 320 | 321 | nodeIntegration: null, |
| ... | ... | @@ -429,6 +430,7 @@ describe("DesktopWindow", () => { |
| 429 | 430 | assert.isUndefined(createdWindowOptions[0]?.x); |
| 430 | 431 | assert.isUndefined(createdWindowOptions[0]?.y); |
| 431 | 432 | assert.isTrue(createdWindowOptions[0]?.disableAutoHideCursor); |
| 433 | assert.isFalse(createdWindowOptions[0]?.webPreferences?.backgroundThrottling); | |
| 432 | 434 | assert.deepEqual(fakeWindow.setAutoHideCursor.mock.calls, [[false]]); |
| 433 | 435 | assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["t3code-dev://app/"]); |
| 434 | 436 | assert.equal(fakeWindow.openDevTools.mock.calls.length, 1); |
apps/desktop/src/window/DesktopWindow.ts+1| ... | ... | @@ -331,6 +331,7 @@ export const make = Effect.gen(function* () { |
| 331 | 331 | ...getWindowTitleBarOptions(shouldUseDarkColors, environment.platform), |
| 332 | 332 | webPreferences: { |
| 333 | 333 | preload: environment.preloadPath, |
| 334 | backgroundThrottling: false, | |
| 334 | 335 | contextIsolation: true, |
| 335 | 336 | nodeIntegration: false, |
| 336 | 337 | sandbox: true, |
apps/desktop/vite.config.ts+7| ... | ... | @@ -73,5 +73,12 @@ export default defineConfig({ |
| 73 | 73 | alwaysBundle: (id) => id === "react-grab" || id.startsWith("react-grab/"), |
| 74 | 74 | }, |
| 75 | 75 | }, |
| 76 | { | |
| 77 | format: "cjs", | |
| 78 | outDir: "dist-electron", | |
| 79 | sourcemap: true, | |
| 80 | outExtensions: () => ({ js: ".cjs" }), | |
| 81 | entry: ["src/preview-pip-preload.ts"], | |
| 82 | }, | |
| 76 | 83 | ], |
| 77 | 84 | }); |
apps/server/src/auth/EnvironmentAuth.test.ts+12-15| ... | ... | @@ -8,9 +8,9 @@ import * as ServerConfig from "../config.ts"; |
| 8 | 8 | import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; |
| 9 | 9 | import * as PairingGrantStore from "./PairingGrantStore.ts"; |
| 10 | 10 | import * as EnvironmentAuth from "./EnvironmentAuth.ts"; |
| 11 | import { resolveSessionCookieName } from "./utils.ts"; | |
| 12 | 11 | |
| 13 | 12 | import * as ServerSecretStore from "./ServerSecretStore.ts"; |
| 13 | import * as SessionStore from "./SessionStore.ts"; | |
| 14 | 14 | |
| 15 | 15 | /** Pinned so dev-mode cookie tests can assert the port-scoped name. */ |
| 16 | 16 | const TEST_SERVER_PORT = 13_773; |
| ... | ... | @@ -23,11 +23,8 @@ const makeServerConfigLayer = (overrides?: Partial<ServerConfig.ServerConfig["Se |
| 23 | 23 | return { |
| 24 | 24 | ...config, |
| 25 | 25 | ...overrides, |
| 26 | // Last, so the port cannot be overridden out from under | |
| 27 | // makeCookieRequest — which builds the cookie name from this constant. | |
| 28 | // An override that changed it would leave the server reading | |
| 29 | // t3_session_<other> while every request still sent t3_session_13773, | |
| 30 | // and the tests would fail for a reason unrelated to what they assert. | |
| 26 | // Keep the test server deterministic even when the default test layer | |
| 27 | // changes its development port. | |
| 31 | 28 | port: TEST_SERVER_PORT, |
| 32 | 29 | } satisfies ServerConfig.ServerConfig["Service"]; |
| 33 | 30 | }), |
| ... | ... | @@ -41,15 +38,12 @@ const makeEnvironmentAuthLayer = (overrides?: Partial<ServerConfig.ServerConfig[ |
| 41 | 38 | ); |
| 42 | 39 | |
| 43 | 40 | const makeCookieRequest = ( |
| 41 | cookieName: string, | |
| 44 | 42 | sessionToken: string, |
| 45 | 43 | ): Parameters<EnvironmentAuth.EnvironmentAuth["Service"]["authenticateHttpRequest"]>[0] => |
| 46 | 44 | ({ |
| 47 | 45 | cookies: { |
| 48 | // Derived, not hardcoded: the name is port-scoped so concurrent servers | |
| 49 | // on one hostname don't share a cookie. Mode and devUrl mirror | |
| 50 | // ServerConfig.layerTest, so this resolves to whatever the server reads. | |
| 51 | [resolveSessionCookieName({ mode: "web", port: TEST_SERVER_PORT, devUrl: undefined })]: | |
| 52 | sessionToken, | |
| 46 | [cookieName]: sessionToken, | |
| 53 | 47 | }, |
| 54 | 48 | headers: {}, |
| 55 | 49 | }) as unknown as Parameters< |
| ... | ... | @@ -92,6 +86,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { |
| 92 | 86 | it.effect("issues standard pairing credentials by default", () => |
| 93 | 87 | Effect.gen(function* () { |
| 94 | 88 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; |
| 89 | const sessions = yield* SessionStore.SessionStore; | |
| 95 | 90 | |
| 96 | 91 | const pairingCredential = yield* serverAuth.issuePairingCredential(); |
| 97 | 92 | const exchanged = yield* serverAuth.createBrowserSession( |
| ... | ... | @@ -99,7 +94,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { |
| 99 | 94 | requestMetadata, |
| 100 | 95 | ); |
| 101 | 96 | const verified = yield* serverAuth.authenticateHttpRequest( |
| 102 | makeCookieRequest(exchanged.sessionToken), | |
| 97 | makeCookieRequest(sessions.cookieName, exchanged.sessionToken), | |
| 103 | 98 | ); |
| 104 | 99 | |
| 105 | 100 | expect(verified.sessionId.length).toBeGreaterThan(0); |
| ... | ... | @@ -165,6 +160,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { |
| 165 | 160 | it.effect("issues startup pairing URLs that bootstrap administrative sessions", () => |
| 166 | 161 | Effect.gen(function* () { |
| 167 | 162 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; |
| 163 | const sessions = yield* SessionStore.SessionStore; | |
| 168 | 164 | |
| 169 | 165 | const pairingUrl = yield* serverAuth.issueStartupPairingUrl("http://127.0.0.1:3773"); |
| 170 | 166 | const token = new URLSearchParams(new URL(pairingUrl).hash.slice(1)).get("token"); |
| ... | ... | @@ -178,7 +174,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { |
| 178 | 174 | |
| 179 | 175 | const exchanged = yield* serverAuth.createBrowserSession(token ?? "", requestMetadata); |
| 180 | 176 | const verified = yield* serverAuth.authenticateHttpRequest( |
| 181 | makeCookieRequest(exchanged.sessionToken), | |
| 177 | makeCookieRequest(sessions.cookieName, exchanged.sessionToken), | |
| 182 | 178 | ); |
| 183 | 179 | |
| 184 | 180 | expect(verified.scopes).toEqual([ |
| ... | ... | @@ -200,13 +196,14 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { |
| 200 | 196 | () => |
| 201 | 197 | Effect.gen(function* () { |
| 202 | 198 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; |
| 199 | const sessions = yield* SessionStore.SessionStore; | |
| 203 | 200 | |
| 204 | 201 | const administrativeExchange = yield* serverAuth.createBrowserSession( |
| 205 | 202 | "desktop-bootstrap-token", |
| 206 | 203 | requestMetadata, |
| 207 | 204 | ); |
| 208 | 205 | const administrativeSession = yield* serverAuth.authenticateHttpRequest( |
| 209 | makeCookieRequest(administrativeExchange.sessionToken), | |
| 206 | makeCookieRequest(sessions.cookieName, administrativeExchange.sessionToken), | |
| 210 | 207 | ); |
| 211 | 208 | const pairingCredential = yield* serverAuth.issuePairingCredential({ |
| 212 | 209 | label: "Julius iPhone", |
| ... | ... | @@ -223,7 +220,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { |
| 223 | 220 | }, |
| 224 | 221 | ); |
| 225 | 222 | const clientSession = yield* serverAuth.authenticateHttpRequest( |
| 226 | makeCookieRequest(clientExchange.sessionToken), | |
| 223 | makeCookieRequest(sessions.cookieName, clientExchange.sessionToken), | |
| 227 | 224 | ); |
| 228 | 225 | const clientsBeforeRevoke = yield* serverAuth.listClientSessions( |
| 229 | 226 | administrativeSession.sessionId, |
apps/server/src/auth/EnvironmentAuthPolicy.test.ts+12-8| ... | ... | @@ -88,47 +88,50 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => { |
| 88 | 88 | |
| 89 | 89 | expect(descriptor.policy).toBe("loopback-browser"); |
| 90 | 90 | expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]); |
| 91 | expect(descriptor.sessionCookieName).toBe("t3_session"); | |
| 91 | expect(descriptor.sessionCookieName).toMatch(/^t3_session_3773_[a-f0-9]{12}$/); | |
| 92 | 92 | }).pipe( |
| 93 | 93 | Effect.provide( |
| 94 | 94 | makeEnvironmentAuthPolicyLayer({ |
| 95 | 95 | mode: "web", |
| 96 | 96 | host: "127.0.0.1", |
| 97 | port: 13773, | |
| 97 | port: 3773, | |
| 98 | 98 | }), |
| 99 | 99 | ), |
| 100 | 100 | ), |
| 101 | 101 | ); |
| 102 | 102 | |
| 103 | it.effect("scopes web session cookies by port only in development", () => | |
| 103 | it.effect("uses remote-reachable policy for wildcard web hosts", () => | |
| 104 | 104 | Effect.gen(function* () { |
| 105 | 105 | const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; |
| 106 | 106 | const descriptor = yield* policy.getDescriptor(); |
| 107 | 107 | |
| 108 | expect(descriptor.sessionCookieName).toBe("t3_session_13773"); | |
| 108 | expect(descriptor.policy).toBe("remote-reachable"); | |
| 109 | expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]); | |
| 110 | expect(descriptor.sessionCookieName).toBe("t3_session"); | |
| 109 | 111 | }).pipe( |
| 110 | 112 | Effect.provide( |
| 111 | 113 | makeEnvironmentAuthPolicyLayer({ |
| 112 | 114 | mode: "web", |
| 113 | port: 13773, | |
| 114 | devUrl: new URL("http://127.0.0.1:5733"), | |
| 115 | host: "0.0.0.0", | |
| 115 | 116 | }), |
| 116 | 117 | ), |
| 117 | 118 | ), |
| 118 | 119 | ); |
| 119 | 120 | |
| 120 | it.effect("uses remote-reachable policy for wildcard web hosts", () => | |
| 121 | it.effect("isolates wildcard-bound web development sessions", () => | |
| 121 | 122 | Effect.gen(function* () { |
| 122 | 123 | const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; |
| 123 | 124 | const descriptor = yield* policy.getDescriptor(); |
| 124 | 125 | |
| 125 | 126 | expect(descriptor.policy).toBe("remote-reachable"); |
| 126 | expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]); | |
| 127 | expect(descriptor.sessionCookieName).toMatch(/^t3_session_5775_[a-f0-9]{12}$/); | |
| 127 | 128 | }).pipe( |
| 128 | 129 | Effect.provide( |
| 129 | 130 | makeEnvironmentAuthPolicyLayer({ |
| 130 | 131 | mode: "web", |
| 131 | 132 | host: "0.0.0.0", |
| 133 | port: 5775, | |
| 134 | devUrl: new URL("http://127.0.0.1:5736"), | |
| 132 | 135 | }), |
| 133 | 136 | ), |
| 134 | 137 | ), |
| ... | ... | @@ -140,6 +143,7 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => { |
| 140 | 143 | const descriptor = yield* policy.getDescriptor(); |
| 141 | 144 | |
| 142 | 145 | expect(descriptor.policy).toBe("remote-reachable"); |
| 146 | expect(descriptor.sessionCookieName).toBe("t3_session"); | |
| 143 | 147 | }).pipe( |
| 144 | 148 | Effect.provide( |
| 145 | 149 | makeEnvironmentAuthPolicyLayer({ |
apps/server/src/auth/EnvironmentAuthPolicy.ts+5-4| ... | ... | @@ -4,8 +4,7 @@ import * as Effect from "effect/Effect"; |
| 4 | 4 | import * as Layer from "effect/Layer"; |
| 5 | 5 | |
| 6 | 6 | import * as ServerConfig from "../config.ts"; |
| 7 | import { resolveSessionCookieName } from "./utils.ts"; | |
| 8 | import { isLoopbackHost, isWildcardHost } from "../startupAccess.ts"; | |
| 7 | import { isRemoteReachableHost, resolveSessionCookieName } from "./utils.ts"; | |
| 9 | 8 | |
| 10 | 9 | export class EnvironmentAuthPolicy extends Context.Service< |
| 11 | 10 | EnvironmentAuthPolicy, |
| ... | ... | @@ -16,7 +15,7 @@ export class EnvironmentAuthPolicy extends Context.Service< |
| 16 | 15 | |
| 17 | 16 | export const make = Effect.gen(function* () { |
| 18 | 17 | const config = yield* ServerConfig.ServerConfig; |
| 19 | const isRemoteReachable = isWildcardHost(config.host) || !isLoopbackHost(config.host); | |
| 18 | const isRemoteReachable = isRemoteReachableHost(config.host); | |
| 20 | 19 | |
| 21 | 20 | const policy = |
| 22 | 21 | config.mode === "desktop" |
| ... | ... | @@ -41,7 +40,9 @@ export const make = Effect.gen(function* () { |
| 41 | 40 | sessionCookieName: resolveSessionCookieName({ |
| 42 | 41 | mode: config.mode, |
| 43 | 42 | port: config.port, |
| 44 | devUrl: config.devUrl, | |
| 43 | host: config.host, | |
| 44 | instanceKey: config.stateDir, | |
| 45 | development: config.devUrl !== undefined, | |
| 45 | 46 | }), |
| 46 | 47 | }; |
| 47 | 48 |
apps/server/src/auth/SessionStore.ts+3-1| ... | ... | @@ -470,7 +470,9 @@ export const make = Effect.gen(function* () { |
| 470 | 470 | const cookieName = resolveSessionCookieName({ |
| 471 | 471 | mode: serverConfig.mode, |
| 472 | 472 | port: serverConfig.port, |
| 473 | devUrl: serverConfig.devUrl, | |
| 473 | host: serverConfig.host, | |
| 474 | instanceKey: serverConfig.stateDir, | |
| 475 | development: serverConfig.devUrl !== undefined, | |
| 474 | 476 | }); |
| 475 | 477 | |
| 476 | 478 | const emitUpsert = (clientSession: AuthClientSession) => |
apps/server/src/auth/utils.test.ts+82-1| ... | ... | @@ -1,6 +1,10 @@ |
| 1 | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | |
| 3 | import { deriveAuthClientMetadata } from "./utils.ts"; | |
| 3 | import { | |
| 4 | deriveAuthClientMetadata, | |
| 5 | isRemoteReachableHost, | |
| 6 | resolveSessionCookieName, | |
| 7 | } from "./utils.ts"; | |
| 4 | 8 | |
| 5 | 9 | describe("deriveAuthClientMetadata", () => { |
| 6 | 10 | it("labels Electron user agents as Electron instead of Chrome", () => { |
| ... | ... | @@ -52,3 +56,80 @@ describe("deriveAuthClientMetadata", () => { |
| 52 | 56 | expect(metadata.userAgent).toContain("Electron/36.3.2"); |
| 53 | 57 | }); |
| 54 | 58 | }); |
| 59 | ||
| 60 | describe("session cookie isolation", () => { | |
| 61 | it("isolates loopback web servers by port and server state", () => { | |
| 62 | const first = resolveSessionCookieName({ | |
| 63 | mode: "web", | |
| 64 | port: 5775, | |
| 65 | host: "127.0.0.1", | |
| 66 | instanceKey: "/tmp/t3-agent-one", | |
| 67 | development: true, | |
| 68 | }); | |
| 69 | const second = resolveSessionCookieName({ | |
| 70 | mode: "web", | |
| 71 | port: 5775, | |
| 72 | host: "127.0.0.1", | |
| 73 | instanceKey: "/tmp/t3-agent-two", | |
| 74 | development: true, | |
| 75 | }); | |
| 76 | ||
| 77 | expect(first).toMatch(/^t3_session_5775_[a-f0-9]{12}$/); | |
| 78 | expect(second).toMatch(/^t3_session_5775_[a-f0-9]{12}$/); | |
| 79 | expect(first).not.toBe(second); | |
| 80 | }); | |
| 81 | ||
| 82 | it("keeps the hosted web cookie stable across server instances", () => { | |
| 83 | expect( | |
| 84 | resolveSessionCookieName({ | |
| 85 | mode: "web", | |
| 86 | port: 8080, | |
| 87 | host: "0.0.0.0", | |
| 88 | instanceKey: "/srv/release-a", | |
| 89 | development: false, | |
| 90 | }), | |
| 91 | ).toBe("t3_session"); | |
| 92 | expect( | |
| 93 | resolveSessionCookieName({ | |
| 94 | mode: "web", | |
| 95 | port: 9090, | |
| 96 | host: "app.example.com", | |
| 97 | instanceKey: "/srv/release-b", | |
| 98 | development: false, | |
| 99 | }), | |
| 100 | ).toBe("t3_session"); | |
| 101 | }); | |
| 102 | ||
| 103 | it("retains desktop port scoping", () => { | |
| 104 | expect( | |
| 105 | resolveSessionCookieName({ | |
| 106 | mode: "desktop", | |
| 107 | port: 3773, | |
| 108 | host: "127.0.0.1", | |
| 109 | instanceKey: "/tmp/desktop", | |
| 110 | development: true, | |
| 111 | }), | |
| 112 | ).toBe("t3_session_3773"); | |
| 113 | }); | |
| 114 | ||
| 115 | it("isolates development servers even when they bind a wildcard host", () => { | |
| 116 | expect( | |
| 117 | resolveSessionCookieName({ | |
| 118 | mode: "web", | |
| 119 | port: 5775, | |
| 120 | host: "0.0.0.0", | |
| 121 | instanceKey: "/tmp/t3-wildcard-dev", | |
| 122 | development: true, | |
| 123 | }), | |
| 124 | ).toMatch(/^t3_session_5775_[a-f0-9]{12}$/); | |
| 125 | }); | |
| 126 | ||
| 127 | it("classifies loopback aliases separately from remotely reachable hosts", () => { | |
| 128 | expect(isRemoteReachableHost(undefined)).toBe(false); | |
| 129 | expect(isRemoteReachableHost("localhost")).toBe(false); | |
| 130 | expect(isRemoteReachableHost("127.12.0.1")).toBe(false); | |
| 131 | expect(isRemoteReachableHost("[::1]")).toBe(false); | |
| 132 | expect(isRemoteReachableHost("0.0.0.0")).toBe(true); | |
| 133 | expect(isRemoteReachableHost("192.168.1.50")).toBe(true); | |
| 134 | }); | |
| 135 | }); |
apps/server/src/auth/utils.ts+35-4| ... | ... | @@ -28,11 +28,42 @@ const SESSION_COOKIE_NAME = "t3_session"; |
| 28 | 28 | export function resolveSessionCookieName(input: { |
| 29 | 29 | readonly mode: "web" | "desktop"; |
| 30 | 30 | readonly port: number; |
| 31 | readonly devUrl: URL | undefined; | |
| 31 | readonly host: string | undefined; | |
| 32 | readonly instanceKey: string; | |
| 33 | readonly development: boolean; | |
| 32 | 34 | }): string { |
| 33 | return input.devUrl === undefined && input.mode !== "desktop" | |
| 34 | ? SESSION_COOKIE_NAME | |
| 35 | : `${SESSION_COOKIE_NAME}_${input.port}`; | |
| 35 | if (input.mode === "desktop") { | |
| 36 | return `${SESSION_COOKIE_NAME}_${input.port}`; | |
| 37 | } | |
| 38 | ||
| 39 | if (!input.development && isRemoteReachableHost(input.host)) { | |
| 40 | return SESSION_COOKIE_NAME; | |
| 41 | } | |
| 42 | ||
| 43 | // Cookies are scoped by host, not port. Loopback development servers need an | |
| 44 | // instance-specific name or parallel agents overwrite each other's session, | |
| 45 | // and a server that later reuses the port receives a token signed elsewhere. | |
| 46 | const instanceHash = NodeCrypto.createHash("sha256") | |
| 47 | .update(input.instanceKey) | |
| 48 | .digest("hex") | |
| 49 | .slice(0, 12); | |
| 50 | return `${SESSION_COOKIE_NAME}_${input.port}_${instanceHash}`; | |
| 51 | } | |
| 52 | ||
| 53 | export function isRemoteReachableHost(host: string | undefined): boolean { | |
| 54 | if (host === "0.0.0.0" || host === "::" || host === "[::]") { | |
| 55 | return true; | |
| 56 | } | |
| 57 | if (!host || host.length === 0) { | |
| 58 | return false; | |
| 59 | } | |
| 60 | return !( | |
| 61 | host === "localhost" || | |
| 62 | host === "127.0.0.1" || | |
| 63 | host === "::1" || | |
| 64 | host === "[::1]" || | |
| 65 | host.startsWith("127.") | |
| 66 | ); | |
| 36 | 67 | } |
| 37 | 68 | |
| 38 | 69 | export function base64UrlEncode(input: string | Uint8Array): string { |
apps/server/src/mcp/PreviewAutomationBroker.test.ts+2-2| ... | ... | @@ -174,7 +174,7 @@ it.effect("does not let an older response replace a newer explicit tab target", |
| 174 | 174 | ), |
| 175 | 175 | ); |
| 176 | 176 | |
| 177 | it.effect("does not replace the default tab with a globally stopped recording tab", () => | |
| 177 | it.effect("tracks the tab returned by a targeted recording stop", () => | |
| 178 | 178 | Effect.scoped( |
| 179 | 179 | Effect.gen(function* () { |
| 180 | 180 | const broker = yield* makeBroker; |
| ... | ... | @@ -203,7 +203,7 @@ it.effect("does not replace the default tab with a globally stopped recording ta |
| 203 | 203 | yield* broker.invoke({ scope, operation: "recordingStop", input: {} }); |
| 204 | 204 | yield* broker.invoke({ scope, operation: "snapshot", input: {} }); |
| 205 | 205 | |
| 206 | expect(routedRequests.at(-1)?.tabId).toBe(browsingTabId); | |
| 206 | expect(routedRequests.at(-1)?.tabId).toBe(recordingTabId); | |
| 207 | 207 | }), |
| 208 | 208 | ), |
| 209 | 209 | ); |
apps/server/src/mcp/PreviewAutomationBroker.ts+1-2| ... | ... | @@ -548,8 +548,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { |
| 548 | 548 | }); |
| 549 | 549 | }); |
| 550 | 550 | const result = yield* awaitResponse().pipe(Effect.ensuring(removePending)); |
| 551 | // A stop artifact identifies the globally recorded tab, not the caller's browsing target. | |
| 552 | const responseTabId = input.operation === "recordingStop" ? undefined : readResultTabId(result); | |
| 551 | const responseTabId = readResultTabId(result); | |
| 553 | 552 | const resultTabId = responseTabId === undefined ? input.tabId : responseTabId; |
| 554 | 553 | if (resultTabId === undefined) return result; |
| 555 | 554 | const assignmentKey = hostAssignmentKey(input.scope); |
apps/server/src/mcp/toolkits/preview/handlers.test.ts created+34| ... | ... | @@ -0,0 +1,34 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { normalizePreviewOpenInput } from "./handlers.ts"; | |
| 4 | ||
| 5 | describe("normalizePreviewOpenInput", () => { | |
| 6 | it("opens the inline preview and reuses the current tab by default", () => { | |
| 7 | expect(normalizePreviewOpenInput({})).toEqual({ | |
| 8 | open: true, | |
| 9 | reuseExistingTab: true, | |
| 10 | show: true, | |
| 11 | }); | |
| 12 | }); | |
| 13 | ||
| 14 | it("preserves an explicit background-only opt-out", () => { | |
| 15 | expect(normalizePreviewOpenInput({ open: false })).toEqual({ | |
| 16 | open: false, | |
| 17 | reuseExistingTab: true, | |
| 18 | show: false, | |
| 19 | }); | |
| 20 | }); | |
| 21 | ||
| 22 | it("supports show as a legacy alias while preferring open", () => { | |
| 23 | expect(normalizePreviewOpenInput({ show: false })).toEqual({ | |
| 24 | open: false, | |
| 25 | reuseExistingTab: true, | |
| 26 | show: false, | |
| 27 | }); | |
| 28 | expect(normalizePreviewOpenInput({ open: true, show: false })).toEqual({ | |
| 29 | open: true, | |
| 30 | reuseExistingTab: true, | |
| 31 | show: true, | |
| 32 | }); | |
| 33 | }); | |
| 34 | }); |
apps/server/src/mcp/toolkits/preview/handlers.ts+14-5| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import * as Effect from "effect/Effect"; |
| 2 | 2 | import type { |
| 3 | 3 | PreviewAutomationOperation, |
| 4 | PreviewAutomationOpenInput, | |
| 4 | 5 | PreviewAutomationRecordingArtifact, |
| 5 | 6 | PreviewAutomationRecordingStatus, |
| 6 | 7 | PreviewAutomationResizeResult, |
| ... | ... | @@ -14,6 +15,18 @@ import * as McpInvocationContext from "../../McpInvocationContext.ts"; |
| 14 | 15 | import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; |
| 15 | 16 | import { PreviewSnapshotToolkit, PreviewStandardToolkit, PreviewToolkit } from "./tools.ts"; |
| 16 | 17 | |
| 18 | export function normalizePreviewOpenInput( | |
| 19 | input: PreviewAutomationOpenInput, | |
| 20 | ): PreviewAutomationOpenInput { | |
| 21 | const open = input.open ?? input.show ?? true; | |
| 22 | return { | |
| 23 | ...input, | |
| 24 | open, | |
| 25 | show: open, | |
| 26 | reuseExistingTab: input.reuseExistingTab ?? true, | |
| 27 | }; | |
| 28 | } | |
| 29 | ||
| 17 | 30 | const invoke = Effect.fn("PreviewToolkit.invoke")(function* <A>( |
| 18 | 31 | operation: PreviewAutomationOperation, |
| 19 | 32 | input: unknown, |
| ... | ... | @@ -50,11 +63,7 @@ const invokeTargeted = <A>( |
| 50 | 63 | const handlers = { |
| 51 | 64 | preview_status: (input) => invokeTargeted<PreviewAutomationStatus>("status", input ?? {}), |
| 52 | 65 | preview_open: (input) => |
| 53 | invokeTargeted<PreviewAutomationStatus>("open", { | |
| 54 | ...input, | |
| 55 | show: input.show ?? true, | |
| 56 | reuseExistingTab: input.reuseExistingTab ?? true, | |
| 57 | }), | |
| 66 | invokeTargeted<PreviewAutomationStatus>("open", normalizePreviewOpenInput(input)), | |
| 58 | 67 | preview_navigate: (input) => |
| 59 | 68 | invokeTargeted<PreviewAutomationStatus>("navigate", input, input.timeoutMs), |
| 60 | 69 | preview_resize: (input) => |
apps/server/src/mcp/toolkits/preview/tools.ts+3-2| ... | ... | @@ -54,7 +54,7 @@ export const PreviewStatusTool = Tool.make("preview_status", { |
| 54 | 54 | export const PreviewOpenTool = browserTool( |
| 55 | 55 | Tool.make("preview_open", { |
| 56 | 56 | description: |
| 57 | "Show and initialize a collaborative browser tab. 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.", | |
| 57 | "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.", | |
| 58 | 58 | parameters: PreviewAutomationOpenInput, |
| 59 | 59 | success: PreviewAutomationStatus, |
| 60 | 60 | failure: PreviewAutomationError, |
| ... | ... | @@ -191,7 +191,8 @@ export const PreviewRecordingStartTool = safeBrowserTool( |
| 191 | 191 | |
| 192 | 192 | export const PreviewRecordingStopTool = safeBrowserTool( |
| 193 | 193 | Tool.make("preview_recording_stop", { |
| 194 | description: "Stop the active browser recording and save it as a local evidence artifact.", | |
| 194 | description: | |
| 195 | "Stop recording the collaborative browser tab selected by tabId, or this agent session's current tab when omitted, and save it as a local evidence artifact.", | |
| 195 | 196 | parameters: PreviewAutomationTabTargetInput, |
| 196 | 197 | success: PreviewAutomationRecordingArtifact, |
| 197 | 198 | failure: PreviewAutomationError, |
apps/server/src/preview/Manager.test.ts+46| ... | ... | @@ -67,6 +67,32 @@ it.layer(PreviewManager.layer)("PreviewManager", (it) => { |
| 67 | 67 | }), |
| 68 | 68 | ); |
| 69 | 69 | |
| 70 | it.effect("orders list snapshots and events with one monotonic revision", () => | |
| 71 | Effect.gen(function* () { | |
| 72 | const threadId = freshThreadId(); | |
| 73 | const manager = yield* PreviewManager.PreviewManager; | |
| 74 | const collector = yield* collectEvents; | |
| 75 | const before = yield* manager.list({ threadId }); | |
| 76 | ||
| 77 | const opened = yield* manager.open({ threadId, url: "http://localhost:5173" }); | |
| 78 | yield* manager.navigate({ | |
| 79 | threadId, | |
| 80 | tabId: opened.tabId, | |
| 81 | url: "http://localhost:5173/ready", | |
| 82 | }); | |
| 83 | ||
| 84 | const events = yield* collector.drain; | |
| 85 | const listed = yield* manager.list({ threadId }); | |
| 86 | expect(events).toHaveLength(2); | |
| 87 | expect(events[0]!.serverEpoch).toBe(listed.serverEpoch); | |
| 88 | expect(events[1]!.serverEpoch).toBe(listed.serverEpoch); | |
| 89 | expect(events[0]!.revision).toBeGreaterThan(before.revision); | |
| 90 | expect(events[1]!.revision).toBeGreaterThan(events[0]!.revision); | |
| 91 | expect(listed.revision).toBe(events[1]!.revision); | |
| 92 | expect(listed.sessions).toHaveLength(1); | |
| 93 | }), | |
| 94 | ); | |
| 95 | ||
| 70 | 96 | it.effect("treats bare hosts as https", () => |
| 71 | 97 | Effect.gen(function* () { |
| 72 | 98 | const threadId = freshThreadId(); |
| ... | ... | @@ -254,6 +280,26 @@ it.layer(PreviewManager.layer)("PreviewManager", (it) => { |
| 254 | 280 | }), |
| 255 | 281 | ); |
| 256 | 282 | |
| 283 | it.effect("gives every tab in a batch close its own monotonic revision", () => | |
| 284 | Effect.gen(function* () { | |
| 285 | const threadId = freshThreadId(); | |
| 286 | const manager = yield* PreviewManager.PreviewManager; | |
| 287 | yield* manager.open({ threadId, url: "http://localhost:5173" }); | |
| 288 | yield* manager.open({ threadId, url: "http://localhost:3000" }); | |
| 289 | const collector = yield* collectEvents; | |
| 290 | ||
| 291 | yield* manager.close({ threadId }); | |
| 292 | ||
| 293 | const events = yield* collector.drain; | |
| 294 | const listed = yield* manager.list({ threadId }); | |
| 295 | expect(events).toHaveLength(2); | |
| 296 | expect(events.every((event) => event.type === "closed")).toBe(true); | |
| 297 | expect(events[1]!.revision).toBeGreaterThan(events[0]!.revision); | |
| 298 | expect(listed.revision).toBe(events[1]!.revision); | |
| 299 | expect(listed.sessions).toHaveLength(0); | |
| 300 | }), | |
| 301 | ); | |
| 302 | ||
| 257 | 303 | it.effect("close is idempotent for unknown threads", () => |
| 258 | 304 | Effect.gen(function* () { |
| 259 | 305 | const threadId = freshThreadId(); |
apps/server/src/preview/Manager.ts+57-29| ... | ... | @@ -30,6 +30,7 @@ import { |
| 30 | 30 | newPreviewTabId, |
| 31 | 31 | normalizePreviewUrl, |
| 32 | 32 | } from "@t3tools/shared/preview"; |
| 33 | import * as NodeCrypto from "node:crypto"; | |
| 33 | 34 | import * as Context from "effect/Context"; |
| 34 | 35 | import * as DateTime from "effect/DateTime"; |
| 35 | 36 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -67,9 +68,17 @@ interface PreviewSessionState { |
| 67 | 68 | interface ManagerState { |
| 68 | 69 | /** All sessions across every thread, keyed by `${threadId}\u0000${tabId}`. */ |
| 69 | 70 | readonly sessions: ReadonlyMap<string, PreviewSessionState>; |
| 71 | /** Global monotonic revision establishing list/event ordering. */ | |
| 72 | readonly revision: number; | |
| 70 | 73 | } |
| 71 | 74 | |
| 72 | const initialState: ManagerState = { sessions: new Map() }; | |
| 75 | const initialState: ManagerState = { sessions: new Map(), revision: 0 }; | |
| 76 | ||
| 77 | type PreviewEventDraft = PreviewEvent extends infer Event | |
| 78 | ? Event extends { readonly revision: number } | |
| 79 | ? Omit<Event, "revision" | "serverEpoch"> | |
| 80 | : never | |
| 81 | : never; | |
| 73 | 82 | |
| 74 | 83 | const compositeKey = (threadId: string, tabId: string): string => `${threadId}\u0000${tabId}`; |
| 75 | 84 | |
| ... | ... | @@ -138,6 +147,7 @@ const buildIdleSnapshot = (input: { |
| 138 | 147 | }); |
| 139 | 148 | |
| 140 | 149 | export const make = Effect.gen(function* PreviewManagerMake() { |
| 150 | const serverEpoch = NodeCrypto.randomUUID(); | |
| 141 | 151 | const stateRef = yield* SynchronizedRef.make<ManagerState>(initialState); |
| 142 | 152 | // Unbounded PubSub is fine here — events are tiny and we don't want to |
| 143 | 153 | // block publishers if a subscriber is slow. WS clients backpressure on |
| ... | ... | @@ -160,7 +170,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 160 | 170 | tabId: string, |
| 161 | 171 | mutator: ( |
| 162 | 172 | session: PreviewSessionState, |
| 163 | ) => Effect.Effect<{ next: PreviewSessionState; emit: PreviewEvent | null; result: R }, E>, | |
| 173 | ) => Effect.Effect<{ next: PreviewSessionState; emit: PreviewEventDraft | null; result: R }, E>, | |
| 164 | 174 | ): Effect.Effect<R, E | PreviewSessionLookupError> => { |
| 165 | 175 | type ModifyResult = |
| 166 | 176 | | { kind: "fail"; error: PreviewSessionLookupError } |
| ... | ... | @@ -177,10 +187,17 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 177 | 187 | return mutator(session).pipe( |
| 178 | 188 | Effect.flatMap( |
| 179 | 189 | Effect.fn("PreviewManager.commitMutation")(function* ({ next, emit, result }) { |
| 180 | if (emit) yield* PubSub.publish(eventsPubSub, emit); | |
| 190 | const revision = emit ? state.revision + 1 : state.revision; | |
| 191 | if (emit) { | |
| 192 | yield* PubSub.publish(eventsPubSub, { | |
| 193 | ...emit, | |
| 194 | revision, | |
| 195 | serverEpoch, | |
| 196 | } as PreviewEvent); | |
| 197 | } | |
| 181 | 198 | const sessions = new Map(state.sessions); |
| 182 | 199 | sessions.set(compositeKey(threadId, tabId), next); |
| 183 | return [{ kind: "ok", result } as ModifyResult, { sessions }] as readonly [ | |
| 200 | return [{ kind: "ok", result } as ModifyResult, { sessions, revision }] as readonly [ | |
| 184 | 201 | ModifyResult, |
| 185 | 202 | ManagerState, |
| 186 | 203 | ]; |
| ... | ... | @@ -207,22 +224,27 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 207 | 224 | updatedAt, |
| 208 | 225 | }) |
| 209 | 226 | : buildIdleSnapshot({ threadId: input.threadId, tabId, updatedAt }); |
| 210 | yield* SynchronizedRef.update(stateRef, (state) => { | |
| 211 | const sessions = new Map(state.sessions); | |
| 212 | sessions.set(compositeKey(input.threadId, tabId), { | |
| 213 | threadId: input.threadId, | |
| 214 | tabId, | |
| 215 | snapshot, | |
| 216 | }); | |
| 217 | return { sessions }; | |
| 218 | }); | |
| 219 | yield* PubSub.publish(eventsPubSub, { | |
| 220 | type: "opened", | |
| 221 | threadId: input.threadId, | |
| 222 | tabId, | |
| 223 | createdAt: snapshot.updatedAt, | |
| 224 | snapshot, | |
| 225 | }); | |
| 227 | yield* SynchronizedRef.modifyEffect(stateRef, (state) => | |
| 228 | Effect.gen(function* () { | |
| 229 | const revision = state.revision + 1; | |
| 230 | const sessions = new Map(state.sessions); | |
| 231 | sessions.set(compositeKey(input.threadId, tabId), { | |
| 232 | threadId: input.threadId, | |
| 233 | tabId, | |
| 234 | snapshot, | |
| 235 | }); | |
| 236 | yield* PubSub.publish(eventsPubSub, { | |
| 237 | type: "opened", | |
| 238 | threadId: input.threadId, | |
| 239 | tabId, | |
| 240 | createdAt: snapshot.updatedAt, | |
| 241 | serverEpoch, | |
| 242 | revision, | |
| 243 | snapshot, | |
| 244 | }); | |
| 245 | return [snapshot, { sessions, revision }] as const; | |
| 246 | }), | |
| 247 | ); | |
| 226 | 248 | return snapshot; |
| 227 | 249 | }, |
| 228 | 250 | ); |
| ... | ... | @@ -280,7 +302,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 280 | 302 | viewport: session.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT, |
| 281 | 303 | updatedAt, |
| 282 | 304 | }; |
| 283 | const emit: PreviewEvent = | |
| 305 | const emit: PreviewEventDraft = | |
| 284 | 306 | input.navStatus._tag === "LoadFailed" |
| 285 | 307 | ? { |
| 286 | 308 | type: "failed", |
| ... | ... | @@ -349,7 +371,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 349 | 371 | const close: PreviewManager["Service"]["close"] = Effect.fn("PreviewManager.close")( |
| 350 | 372 | function* (input) { |
| 351 | 373 | const createdAt = yield* currentIsoTimestamp; |
| 352 | const events = yield* SynchronizedRef.modify(stateRef, (state) => { | |
| 374 | yield* SynchronizedRef.modifyEffect(stateRef, (state) => { | |
| 353 | 375 | const eventsToEmit: PreviewEvent[] = []; |
| 354 | 376 | const sessions = new Map(state.sessions); |
| 355 | 377 | const targets = input.tabId |
| ... | ... | @@ -357,25 +379,29 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 357 | 379 | (entry): entry is PreviewSessionState => entry !== undefined, |
| 358 | 380 | ) |
| 359 | 381 | : sessionsForThread(state, input.threadId); |
| 382 | let revision = state.revision; | |
| 360 | 383 | for (const target of targets) { |
| 384 | revision += 1; | |
| 361 | 385 | sessions.delete(compositeKey(target.threadId, target.tabId)); |
| 362 | 386 | eventsToEmit.push({ |
| 363 | 387 | type: "closed", |
| 364 | 388 | threadId: target.threadId, |
| 365 | 389 | tabId: target.tabId, |
| 366 | 390 | createdAt, |
| 391 | serverEpoch, | |
| 392 | revision, | |
| 367 | 393 | }); |
| 368 | 394 | } |
| 369 | 395 | if (eventsToEmit.length === 0) { |
| 370 | return [eventsToEmit, state] as const; | |
| 396 | return Effect.succeed([undefined, state] as const); | |
| 371 | 397 | } |
| 372 | return [eventsToEmit, { sessions }] as const; | |
| 398 | return Effect.as( | |
| 399 | Effect.forEach(eventsToEmit, (event) => PubSub.publish(eventsPubSub, event), { | |
| 400 | discard: true, | |
| 401 | }), | |
| 402 | [undefined, { sessions, revision }] as const, | |
| 403 | ); | |
| 373 | 404 | }); |
| 374 | if (events.length > 0) { | |
| 375 | yield* Effect.forEach(events, (event) => PubSub.publish(eventsPubSub, event), { | |
| 376 | discard: true, | |
| 377 | }); | |
| 378 | } | |
| 379 | 405 | }, |
| 380 | 406 | ); |
| 381 | 407 | |
| ... | ... | @@ -387,6 +413,8 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 387 | 413 | sessions: sessionsForThread(state, input.threadId) |
| 388 | 414 | .map((s) => s.snapshot) |
| 389 | 415 | .toSorted((a, b) => a.updatedAt.localeCompare(b.updatedAt)), |
| 416 | serverEpoch, | |
| 417 | revision: state.revision, | |
| 390 | 418 | }), |
| 391 | 419 | ), |
| 392 | 420 | ); |
apps/server/src/server.test.ts+1-1| ... | ... | @@ -667,7 +667,7 @@ const buildAppUnderTest = (options?: { |
| 667 | 667 | reportStatus: () => Effect.void, |
| 668 | 668 | refresh: () => Effect.void, |
| 669 | 669 | close: () => Effect.void, |
| 670 | list: () => Effect.succeed({ sessions: [] }), | |
| 670 | list: () => Effect.succeed({ sessions: [], serverEpoch: "test-server", revision: 0 }), | |
| 671 | 671 | events: Stream.empty, |
| 672 | 672 | subscribeEvents: Effect.flatMap(PubSub.unbounded<PreviewEvent>(), (pubsub) => |
| 673 | 673 | PubSub.subscribe(pubsub), |
apps/web/src/browser/BrowserSurfaceSlot.tsx+40-5| ... | ... | @@ -7,27 +7,56 @@ import { acquireBrowserSurface } from "./browserSurfaceStore"; |
| 7 | 7 | export function BrowserSurfaceSlot(props: { |
| 8 | 8 | readonly tabId: string; |
| 9 | 9 | readonly visible: boolean; |
| 10 | readonly cornerRadius?: number; | |
| 11 | readonly layoutVersion?: string | number; | |
| 10 | 12 | readonly className?: string; |
| 13 | readonly fitSourceContent?: boolean; | |
| 11 | 14 | }) { |
| 12 | const { tabId, visible, className } = props; | |
| 15 | const { | |
| 16 | tabId, | |
| 17 | visible, | |
| 18 | cornerRadius = 0, | |
| 19 | layoutVersion, | |
| 20 | className, | |
| 21 | fitSourceContent = false, | |
| 22 | } = props; | |
| 13 | 23 | const elementRef = useRef<HTMLDivElement | null>(null); |
| 24 | const presentationRef = useRef({ visible, cornerRadius }); | |
| 25 | const updateRef = useRef<(() => void) | null>(null); | |
| 14 | 26 | |
| 15 | 27 | useLayoutEffect(() => { |
| 16 | 28 | const element = elementRef.current; |
| 17 | 29 | if (!element) return; |
| 18 | const lease = acquireBrowserSurface(tabId); | |
| 30 | let lease = acquireBrowserSurface(tabId, fitSourceContent); | |
| 19 | 31 | const update = () => { |
| 20 | 32 | const rect = element.getBoundingClientRect(); |
| 21 | lease.present( | |
| 33 | const presentation = presentationRef.current; | |
| 34 | const presented = lease.present( | |
| 22 | 35 | { |
| 23 | 36 | x: Math.round(rect.x), |
| 24 | 37 | y: Math.round(rect.y), |
| 25 | 38 | width: Math.max(1, Math.round(rect.width)), |
| 26 | 39 | height: Math.max(1, Math.round(rect.height)), |
| 27 | 40 | }, |
| 28 | visible && rect.width > 0 && rect.height > 0, | |
| 41 | presentation.visible && rect.width > 0 && rect.height > 0, | |
| 42 | presentation.cornerRadius, | |
| 29 | 43 | ); |
| 44 | if (presentation.visible && !presented) { | |
| 45 | lease.release(); | |
| 46 | lease = acquireBrowserSurface(tabId, fitSourceContent); | |
| 47 | lease.present( | |
| 48 | { | |
| 49 | x: Math.round(rect.x), | |
| 50 | y: Math.round(rect.y), | |
| 51 | width: Math.max(1, Math.round(rect.width)), | |
| 52 | height: Math.max(1, Math.round(rect.height)), | |
| 53 | }, | |
| 54 | rect.width > 0 && rect.height > 0, | |
| 55 | presentation.cornerRadius, | |
| 56 | ); | |
| 57 | } | |
| 30 | 58 | }; |
| 59 | updateRef.current = update; | |
| 31 | 60 | update(); |
| 32 | 61 | const observer = new ResizeObserver(update); |
| 33 | 62 | observer.observe(element); |
| ... | ... | @@ -37,9 +66,15 @@ export function BrowserSurfaceSlot(props: { |
| 37 | 66 | observer.disconnect(); |
| 38 | 67 | window.removeEventListener("resize", update); |
| 39 | 68 | window.removeEventListener("scroll", update, true); |
| 69 | if (updateRef.current === update) updateRef.current = null; | |
| 40 | 70 | lease.release(); |
| 41 | 71 | }; |
| 42 | }, [tabId, visible]); | |
| 72 | }, [fitSourceContent, tabId]); | |
| 73 | ||
| 74 | useLayoutEffect(() => { | |
| 75 | presentationRef.current = { visible, cornerRadius }; | |
| 76 | updateRef.current?.(); | |
| 77 | }, [cornerRadius, layoutVersion, visible]); | |
| 43 | 78 | |
| 44 | 79 | return <div ref={elementRef} className={className} data-browser-surface-slot={tabId} />; |
| 45 | 80 | } |
apps/web/src/browser/HostedBrowserWebview.tsx+85-19| ... | ... | @@ -8,15 +8,19 @@ import { previewBridge } from "~/components/preview/previewBridge"; |
| 8 | 8 | import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; |
| 9 | 9 | import { cn } from "~/lib/utils"; |
| 10 | 10 | |
| 11 | import { stopBrowserRecording, useActiveBrowserRecordingTabId } from "./browserRecording"; | |
| 12 | 11 | import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore"; |
| 13 | import { browserViewportSettingKey } from "./browserViewportLayout"; | |
| 12 | import { browserViewportSettingKey, resolveBrowserViewportLayout } from "./browserViewportLayout"; | |
| 14 | 13 | import { BrowserDeviceToolbar } from "./BrowserDeviceToolbar"; |
| 15 | 14 | import { BrowserViewportResizeHandles } from "./BrowserViewportResizeHandles"; |
| 16 | 15 | import { acquireDesktopTab, type AcquiredDesktopTab } from "./desktopTabLifetime"; |
| 17 | 16 | import { resolveHostedBrowserWebviewWrapperStyle } from "./hostedBrowserWebviewStyle"; |
| 18 | 17 | import { usePreviewWebviewConfig } from "./previewWebviewConfigState"; |
| 19 | 18 | import { useBrowserViewportResize } from "./useBrowserViewportResize"; |
| 19 | import { | |
| 20 | INITIAL_WEBVIEW_CRASH_RECOVERY_STATE, | |
| 21 | planWebviewCrashRecovery, | |
| 22 | type WebviewCrashRecoveryState, | |
| 23 | } from "./webviewCrashRecovery"; | |
| 20 | 24 | |
| 21 | 25 | interface ElectronWebview extends HTMLElement { |
| 22 | 26 | src: string; |
| ... | ... | @@ -46,12 +50,15 @@ export function HostedBrowserWebview(props: { |
| 46 | 50 | const tabLeaseRef = useRef<AcquiredDesktopTab | null>(null); |
| 47 | 51 | const wrapperRef = useRef<HTMLDivElement | null>(null); |
| 48 | 52 | const webviewRef = useRef<ElectronWebview | null>(null); |
| 53 | const crashRecoveryRef = useRef<WebviewCrashRecoveryState>(INITIAL_WEBVIEW_CRASH_RECOVERY_STATE); | |
| 49 | 54 | const [aspectRatioLocked, setAspectRatioLocked] = useState(false); |
| 50 | const activeRecordingTabId = useActiveBrowserRecordingTabId(); | |
| 51 | 55 | const presentation = useBrowserSurfaceStore( |
| 52 | 56 | useShallow((state) => { |
| 53 | 57 | const current = state.byTabId[tabId]; |
| 54 | 58 | return { |
| 59 | cornerRadius: current?.cornerRadius ?? 0, | |
| 60 | fitSourceContent: current?.fitSourceContent ?? false, | |
| 61 | fittedSourceContent: current?.fittedSourceContent ?? null, | |
| 55 | 62 | rect: resolveBrowserSurfacePanelRect(state.byTabId, tabId), |
| 56 | 63 | visible: current?.visible ?? false, |
| 57 | 64 | }; |
| ... | ... | @@ -60,11 +67,7 @@ export function HostedBrowserWebview(props: { |
| 60 | 67 | usePreviewBridge({ threadRef, tabId }); |
| 61 | 68 | |
| 62 | 69 | useEffect(() => { |
| 63 | if (presentation.visible || activeRecordingTabId !== tabId) return; | |
| 64 | void stopBrowserRecording(tabId).catch(() => undefined); | |
| 65 | }, [activeRecordingTabId, presentation.visible, tabId]); | |
| 66 | ||
| 67 | useEffect(() => { | |
| 70 | crashRecoveryRef.current = INITIAL_WEBVIEW_CRASH_RECOVERY_STATE; | |
| 68 | 71 | const lease = acquireDesktopTab(tabId); |
| 69 | 72 | tabLeaseRef.current = lease; |
| 70 | 73 | return () => { |
| ... | ... | @@ -73,6 +76,14 @@ export function HostedBrowserWebview(props: { |
| 73 | 76 | }; |
| 74 | 77 | }, [tabId]); |
| 75 | 78 | |
| 79 | const [webviewGeneration, setWebviewGeneration] = useState(0); | |
| 80 | const [recoverySrc, setRecoverySrc] = useState(initialSrc); | |
| 81 | const latestUrlRef = useRef(initialUrl); | |
| 82 | ||
| 83 | useEffect(() => { | |
| 84 | latestUrlRef.current = initialUrl; | |
| 85 | }, [initialUrl]); | |
| 86 | ||
| 76 | 87 | const setWebviewRef = useCallback((node: HTMLElement | null) => { |
| 77 | 88 | webviewRef.current = node as ElectronWebview | null; |
| 78 | 89 | if (node && !node.hasAttribute("allowpopups")) node.setAttribute("allowpopups", "true"); |
| ... | ... | @@ -83,6 +94,7 @@ export function HostedBrowserWebview(props: { |
| 83 | 94 | const bridge = previewBridge; |
| 84 | 95 | if (!webview || !config || !bridge) return; |
| 85 | 96 | let disposed = false; |
| 97 | let recoveryTimeout: ReturnType<typeof setTimeout> | null = null; | |
| 86 | 98 | const register = () => { |
| 87 | 99 | const lease = tabLeaseRef.current; |
| 88 | 100 | if (!lease) return; |
| ... | ... | @@ -102,15 +114,31 @@ export function HostedBrowserWebview(props: { |
| 102 | 114 | } |
| 103 | 115 | })(); |
| 104 | 116 | }; |
| 117 | const recoverGuest = () => { | |
| 118 | if (disposed || recoveryTimeout !== null) return; | |
| 119 | const recovery = planWebviewCrashRecovery(crashRecoveryRef.current, Date.now()); | |
| 120 | if (!recovery) return; | |
| 121 | crashRecoveryRef.current = recovery.state; | |
| 122 | recoveryTimeout = setTimeout(() => { | |
| 123 | recoveryTimeout = null; | |
| 124 | if (!disposed) { | |
| 125 | setRecoverySrc(latestUrlRef.current ?? initialSrc); | |
| 126 | setWebviewGeneration((generation) => generation + 1); | |
| 127 | } | |
| 128 | }, recovery.delayMs); | |
| 129 | }; | |
| 105 | 130 | webview.addEventListener("did-attach", register); |
| 106 | 131 | webview.addEventListener("dom-ready", register); |
| 132 | webview.addEventListener("render-process-gone", recoverGuest); | |
| 107 | 133 | register(); |
| 108 | 134 | return () => { |
| 109 | 135 | disposed = true; |
| 136 | if (recoveryTimeout !== null) clearTimeout(recoveryTimeout); | |
| 110 | 137 | webview.removeEventListener("did-attach", register); |
| 111 | 138 | webview.removeEventListener("dom-ready", register); |
| 139 | webview.removeEventListener("render-process-gone", recoverGuest); | |
| 112 | 140 | }; |
| 113 | }, [config, tabId]); | |
| 141 | }, [config, initialSrc, tabId, webviewGeneration]); | |
| 114 | 142 | |
| 115 | 143 | const active = presentation.visible && presentation.rect !== null; |
| 116 | 144 | const lastRect = presentation.rect; |
| ... | ... | @@ -132,14 +160,14 @@ export function HostedBrowserWebview(props: { |
| 132 | 160 | } |
| 133 | 161 | : { width: lastRect?.width ?? 1280, height: lastRect?.height ?? 800 }; |
| 134 | 162 | const containerSize = active && lastRect ? lastRect : hiddenSize; |
| 135 | const deviceToolbarVisible = active && viewport._tag !== "fill"; | |
| 163 | const deviceToolbarVisible = active && viewport._tag !== "fill" && !presentation.fitSourceContent; | |
| 136 | 164 | const { |
| 137 | 165 | activeDrag, |
| 138 | 166 | commitViewportChange, |
| 139 | 167 | effectiveViewport, |
| 140 | 168 | handleResizeKeyDown, |
| 141 | 169 | handleResizePointerDown, |
| 142 | layout, | |
| 170 | layout: viewportLayout, | |
| 143 | 171 | } = useBrowserViewportResize({ |
| 144 | 172 | tabId, |
| 145 | 173 | viewport, |
| ... | ... | @@ -148,6 +176,38 @@ export function HostedBrowserWebview(props: { |
| 148 | 176 | deviceToolbarVisible, |
| 149 | 177 | aspectRatio: lockedAspectRatio, |
| 150 | 178 | }); |
| 179 | const fittedSourceViewport = | |
| 180 | presentation.fitSourceContent && lastRect | |
| 181 | ? presentation.fittedSourceContent | |
| 182 | ? { | |
| 183 | _tag: "freeform" as const, | |
| 184 | width: Math.max( | |
| 185 | 1, | |
| 186 | Math.round( | |
| 187 | presentation.fittedSourceContent.width / | |
| 188 | presentation.fittedSourceContent.scale / | |
| 189 | normalizedZoomFactor, | |
| 190 | ), | |
| 191 | ), | |
| 192 | height: Math.max( | |
| 193 | 1, | |
| 194 | Math.round( | |
| 195 | presentation.fittedSourceContent.height / | |
| 196 | presentation.fittedSourceContent.scale / | |
| 197 | normalizedZoomFactor, | |
| 198 | ), | |
| 199 | ), | |
| 200 | } | |
| 201 | : { | |
| 202 | _tag: "freeform" as const, | |
| 203 | width: viewport._tag === "fill" ? 1280 : viewport.width, | |
| 204 | height: viewport._tag === "fill" ? 800 : viewport.height, | |
| 205 | } | |
| 206 | : null; | |
| 207 | const layout = | |
| 208 | fittedSourceViewport && lastRect | |
| 209 | ? resolveBrowserViewportLayout(lastRect, fittedSourceViewport, normalizedZoomFactor) | |
| 210 | : viewportLayout; | |
| 151 | 211 | |
| 152 | 212 | const syncContentPresentation = useCallback(() => { |
| 153 | 213 | const wrapper = wrapperRef.current; |
| ... | ... | @@ -178,6 +238,7 @@ export function HostedBrowserWebview(props: { |
| 178 | 238 | |
| 179 | 239 | const wrapperStyle = resolveHostedBrowserWebviewWrapperStyle({ |
| 180 | 240 | active, |
| 241 | cornerRadius: presentation.cornerRadius, | |
| 181 | 242 | rect: lastRect, |
| 182 | 243 | hiddenSize, |
| 183 | 244 | }); |
| ... | ... | @@ -201,8 +262,9 @@ export function HostedBrowserWebview(props: { |
| 201 | 262 | /> |
| 202 | 263 | ) : null} |
| 203 | 264 | <webview |
| 265 | key={webviewGeneration} | |
| 204 | 266 | ref={setWebviewRef} |
| 205 | src={initialSrc} | |
| 267 | src={webviewGeneration === 0 ? initialSrc : recoverySrc} | |
| 206 | 268 | partition={config.partition} |
| 207 | 269 | webpreferences={config.webPreferences} |
| 208 | 270 | {...(config.preloadUrl ? { preload: config.preloadUrl } : {})} |
| ... | ... | @@ -210,14 +272,18 @@ export function HostedBrowserWebview(props: { |
| 210 | 272 | data-preview-viewport-mode={effectiveViewport._tag} |
| 211 | 273 | data-preview-viewport-key={browserViewportSettingKey(effectiveViewport)} |
| 212 | 274 | data-preview-css-width={ |
| 213 | effectiveViewport._tag === "fill" | |
| 214 | ? Math.max(1, Math.round(layout.viewportWidth / normalizedZoomFactor)) | |
| 215 | : effectiveViewport.width | |
| 275 | fittedSourceViewport | |
| 276 | ? fittedSourceViewport.width | |
| 277 | : effectiveViewport._tag === "fill" | |
| 278 | ? Math.max(1, Math.round(layout.viewportWidth / normalizedZoomFactor)) | |
| 279 | : effectiveViewport.width | |
| 216 | 280 | } |
| 217 | 281 | data-preview-css-height={ |
| 218 | effectiveViewport._tag === "fill" | |
| 219 | ? Math.max(1, Math.round(layout.viewportHeight / normalizedZoomFactor)) | |
| 220 | : effectiveViewport.height | |
| 282 | fittedSourceViewport | |
| 283 | ? fittedSourceViewport.height | |
| 284 | : effectiveViewport._tag === "fill" | |
| 285 | ? Math.max(1, Math.round(layout.viewportHeight / normalizedZoomFactor)) | |
| 286 | : effectiveViewport.height | |
| 221 | 287 | } |
| 222 | 288 | aria-hidden={active ? undefined : true} |
| 223 | 289 | className={cn( |
| ... | ... | @@ -233,7 +299,7 @@ export function HostedBrowserWebview(props: { |
| 233 | 299 | transformOrigin: "top left", |
| 234 | 300 | }} |
| 235 | 301 | /> |
| 236 | {active && effectiveViewport._tag !== "fill" ? ( | |
| 302 | {active && effectiveViewport._tag !== "fill" && !fittedSourceViewport ? ( | |
| 237 | 303 | <> |
| 238 | 304 | <BrowserViewportResizeHandles |
| 239 | 305 | layout={layout} |
apps/web/src/browser/browserRecording.test.ts+319-53| ... | ... | @@ -1,32 +1,73 @@ |
| 1 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | |
| 1 | 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 2 | 3 | |
| 3 | const { events, onFrame, registrySet, save, startScreencast, stopScreencast, surfaceState } = | |
| 4 | vi.hoisted(() => { | |
| 5 | const events: string[] = []; | |
| 6 | const surfaceState = { | |
| 7 | byTabId: {} as Record<string, unknown>, | |
| 8 | }; | |
| 9 | return { | |
| 10 | events, | |
| 11 | onFrame: vi.fn(() => vi.fn()), | |
| 12 | registrySet: vi.fn((_atom: unknown, value: string | null) => { | |
| 13 | events.push(value === null ? "clear" : `publish:${value}`); | |
| 14 | }), | |
| 15 | save: vi.fn(async () => ({ | |
| 16 | id: "recording-test", | |
| 17 | tabId: "recording-tab", | |
| 18 | path: "/tmp/recording-test.webm", | |
| 19 | mimeType: "video/webm" as const, | |
| 20 | sizeBytes: 0, | |
| 21 | createdAt: "2026-06-26T00:00:00.000Z", | |
| 22 | })), | |
| 23 | startScreencast: vi.fn(async () => { | |
| 24 | events.push("start-screencast"); | |
| 25 | }), | |
| 26 | stopScreencast: vi.fn(async () => undefined), | |
| 27 | surfaceState, | |
| 28 | }; | |
| 29 | }); | |
| 4 | const { | |
| 5 | events, | |
| 6 | frameSubscription, | |
| 7 | onFrame, | |
| 8 | registrySet, | |
| 9 | save, | |
| 10 | startScreencast, | |
| 11 | stopScreencast, | |
| 12 | surfaceState, | |
| 13 | } = vi.hoisted(() => { | |
| 14 | const events: string[] = []; | |
| 15 | type Frame = { | |
| 16 | readonly tabId: string; | |
| 17 | readonly data: string; | |
| 18 | readonly width: number; | |
| 19 | readonly height: number; | |
| 20 | readonly receivedAt: string; | |
| 21 | }; | |
| 22 | const frameSubscription: { listener: ((frame: Frame) => void) | null } = { | |
| 23 | listener: null, | |
| 24 | }; | |
| 25 | const surfaceState = { | |
| 26 | byTabId: {} as Record<string, unknown>, | |
| 27 | }; | |
| 28 | return { | |
| 29 | events, | |
| 30 | frameSubscription, | |
| 31 | onFrame: vi.fn((listener: (frame: Frame) => void) => { | |
| 32 | frameSubscription.listener = listener; | |
| 33 | return () => { | |
| 34 | if (frameSubscription.listener === listener) frameSubscription.listener = null; | |
| 35 | }; | |
| 36 | }), | |
| 37 | registrySet: vi.fn((_atom: unknown, value: { readonly tabIds: ReadonlySet<string> }) => { | |
| 38 | events.push( | |
| 39 | value.tabIds.size === 0 ? "clear" : `publish:${Array.from(value.tabIds).join(",")}`, | |
| 40 | ); | |
| 41 | }), | |
| 42 | save: vi.fn(async (tabId: string) => ({ | |
| 43 | id: "recording-test", | |
| 44 | tabId, | |
| 45 | path: "/tmp/recording-test.webm", | |
| 46 | mimeType: "video/webm" as const, | |
| 47 | sizeBytes: 0, | |
| 48 | createdAt: "2026-06-26T00:00:00.000Z", | |
| 49 | })), | |
| 50 | startScreencast: vi.fn(async (tabId: string) => { | |
| 51 | events.push("start-screencast"); | |
| 52 | const surface = surfaceState.byTabId[tabId] as | |
| 53 | | { | |
| 54 | readonly content?: { readonly width: number; readonly height: number }; | |
| 55 | readonly rect?: { readonly width: number; readonly height: number }; | |
| 56 | } | |
| 57 | | undefined; | |
| 58 | const size = surface?.content ?? surface?.rect; | |
| 59 | frameSubscription.listener?.({ | |
| 60 | tabId, | |
| 61 | data: "initial-frame", | |
| 62 | width: size?.width ?? 1280, | |
| 63 | height: size?.height ?? 800, | |
| 64 | receivedAt: "2026-06-26T00:00:00.000Z", | |
| 65 | }); | |
| 66 | }), | |
| 67 | stopScreencast: vi.fn(async () => undefined), | |
| 68 | surfaceState, | |
| 69 | }; | |
| 70 | }); | |
| 30 | 71 | |
| 31 | 72 | vi.mock("~/components/preview/previewBridge", () => ({ |
| 32 | 73 | previewBridge: { |
| ... | ... | @@ -45,10 +86,10 @@ vi.mock("./browserSurfaceStore", () => ({ |
| 45 | 86 | })); |
| 46 | 87 | |
| 47 | 88 | import { |
| 89 | BROWSER_RECORDING_FIRST_FRAME_SIZE_TIMEOUT_MS, | |
| 48 | 90 | BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS, |
| 49 | 91 | BrowserRecordingConflictError, |
| 50 | BrowserRecordingOperationError, | |
| 51 | BrowserRecordingRequiresVisibleTabError, | |
| 92 | readActiveBrowserRecordingTabIds, | |
| 52 | 93 | startBrowserRecording, |
| 53 | 94 | stopBrowserRecording, |
| 54 | 95 | } from "./browserRecording"; |
| ... | ... | @@ -80,9 +121,20 @@ class FakeMediaRecorder { |
| 80 | 121 | } |
| 81 | 122 | } |
| 82 | 123 | |
| 124 | const emitRecordingFrame = () => { | |
| 125 | frameSubscription.listener?.({ | |
| 126 | tabId: "recording-tab", | |
| 127 | data: "startup-frame", | |
| 128 | width: 800, | |
| 129 | height: 600, | |
| 130 | receivedAt: "2026-06-26T00:00:00.000Z", | |
| 131 | }); | |
| 132 | }; | |
| 133 | ||
| 83 | 134 | describe("browser recording", () => { |
| 84 | 135 | beforeEach(() => { |
| 85 | 136 | events.length = 0; |
| 137 | frameSubscription.listener = null; | |
| 86 | 138 | surfaceState.byTabId = { |
| 87 | 139 | "recording-tab": { |
| 88 | 140 | visible: true, |
| ... | ... | @@ -93,12 +145,26 @@ describe("browser recording", () => { |
| 93 | 145 | vi.clearAllMocks(); |
| 94 | 146 | vi.stubGlobal("window", globalThis); |
| 95 | 147 | vi.stubGlobal("MediaRecorder", FakeMediaRecorder as unknown as typeof MediaRecorder); |
| 148 | class ImmediateImage { | |
| 149 | private loadListener: EventListenerOrEventListenerObject | undefined; | |
| 150 | ||
| 151 | addEventListener(type: string, listener: EventListenerOrEventListenerObject): void { | |
| 152 | if (type === "load") this.loadListener = listener; | |
| 153 | } | |
| 154 | ||
| 155 | set src(_value: string) { | |
| 156 | const event = new Event("load"); | |
| 157 | if (typeof this.loadListener === "function") this.loadListener(event); | |
| 158 | else this.loadListener?.handleEvent(event); | |
| 159 | } | |
| 160 | } | |
| 161 | vi.stubGlobal("Image", ImmediateImage as unknown as typeof Image); | |
| 96 | 162 | vi.stubGlobal("document", { |
| 97 | 163 | createElement: () => ({ |
| 98 | 164 | width: 0, |
| 99 | 165 | height: 0, |
| 100 | 166 | captureStream: () => ({}), |
| 101 | getContext: () => ({ drawImage: vi.fn() }), | |
| 167 | getContext: () => ({ drawImage: vi.fn(), fillRect: vi.fn(), fillStyle: "" }), | |
| 102 | 168 | }), |
| 103 | 169 | }); |
| 104 | 170 | }); |
| ... | ... | @@ -116,7 +182,7 @@ describe("browser recording", () => { |
| 116 | 182 | await stopBrowserRecording("recording-tab"); |
| 117 | 183 | }); |
| 118 | 184 | |
| 119 | it("rejects recording for a hidden tab before starting screencast", async () => { | |
| 185 | it("records a hidden tab without requiring it to become visible", async () => { | |
| 120 | 186 | surfaceState.byTabId = { |
| 121 | 187 | "recording-tab": { |
| 122 | 188 | visible: false, |
| ... | ... | @@ -125,18 +191,189 @@ describe("browser recording", () => { |
| 125 | 191 | }, |
| 126 | 192 | }; |
| 127 | 193 | |
| 128 | await expect(startBrowserRecording("recording-tab")).rejects.toBeInstanceOf( | |
| 129 | BrowserRecordingRequiresVisibleTabError, | |
| 194 | await startBrowserRecording("recording-tab"); | |
| 195 | ||
| 196 | expect(startScreencast).toHaveBeenCalledWith("recording-tab"); | |
| 197 | expect(events).toEqual(["start-screencast", "publish:recording-tab"]); | |
| 198 | ||
| 199 | await stopBrowserRecording("recording-tab"); | |
| 200 | }); | |
| 201 | ||
| 202 | it("fails startup instead of locking a fallback size when no frame arrives", async () => { | |
| 203 | vi.useFakeTimers(); | |
| 204 | startScreencast.mockImplementationOnce(async () => { | |
| 205 | events.push("start-screencast"); | |
| 206 | }); | |
| 207 | ||
| 208 | const startPromise = startBrowserRecording("recording-tab"); | |
| 209 | const rejection = expect(startPromise).rejects.toMatchObject({ | |
| 210 | operation: "wait-first-frame", | |
| 211 | tabId: "recording-tab", | |
| 212 | }); | |
| 213 | await Promise.resolve(); | |
| 214 | await vi.advanceTimersByTimeAsync(BROWSER_RECORDING_FIRST_FRAME_SIZE_TIMEOUT_MS); | |
| 215 | ||
| 216 | await rejection; | |
| 217 | expect(stopScreencast).toHaveBeenCalledWith("recording-tab"); | |
| 218 | expect(events.at(-1)).toBe("clear"); | |
| 219 | }); | |
| 220 | ||
| 221 | it("fixes hidden recording dimensions before MediaRecorder starts", async () => { | |
| 222 | const drawImage = vi.fn(); | |
| 223 | const fillRect = vi.fn(); | |
| 224 | let capturedStreamSize: { readonly width: number; readonly height: number } | undefined; | |
| 225 | const canvas = { | |
| 226 | width: 0, | |
| 227 | height: 0, | |
| 228 | captureStream: () => { | |
| 229 | capturedStreamSize = { width: canvas.width, height: canvas.height }; | |
| 230 | return {}; | |
| 231 | }, | |
| 232 | getContext: () => ({ drawImage, fillRect, fillStyle: "" }), | |
| 233 | }; | |
| 234 | vi.stubGlobal("document", { | |
| 235 | createElement: () => canvas, | |
| 236 | }); | |
| 237 | surfaceState.byTabId = {}; | |
| 238 | startScreencast.mockImplementationOnce(async (tabId: string) => { | |
| 239 | events.push("start-screencast"); | |
| 240 | frameSubscription.listener?.({ | |
| 241 | tabId, | |
| 242 | data: "captured-frame", | |
| 243 | width: 390, | |
| 244 | height: 844, | |
| 245 | receivedAt: "2026-06-26T00:00:00.000Z", | |
| 246 | }); | |
| 247 | }); | |
| 248 | ||
| 249 | await startBrowserRecording("recording-tab"); | |
| 250 | ||
| 251 | expect(canvas).toMatchObject({ width: 390, height: 844 }); | |
| 252 | expect(capturedStreamSize).toEqual({ width: 390, height: 844 }); | |
| 253 | expect(drawImage).toHaveBeenCalledWith(expect.anything(), 0, 0, 390, 844); | |
| 254 | ||
| 255 | frameSubscription.listener?.({ | |
| 256 | tabId: "recording-tab", | |
| 257 | data: "different-sized-frame", | |
| 258 | width: 1280, | |
| 259 | height: 720, | |
| 260 | receivedAt: "2026-06-26T00:00:01.000Z", | |
| 261 | }); | |
| 262 | ||
| 263 | expect(canvas).toMatchObject({ width: 390, height: 844 }); | |
| 264 | expect(fillRect).toHaveBeenLastCalledWith(0, 0, 390, 844); | |
| 265 | ||
| 266 | await stopBrowserRecording("recording-tab"); | |
| 267 | }); | |
| 268 | ||
| 269 | it("draws the newest decoded frames without starving behind decode latency", async () => { | |
| 270 | const drawImage = vi.fn(); | |
| 271 | class DeferredImage { | |
| 272 | static readonly instances: DeferredImage[] = []; | |
| 273 | private loadListener: EventListenerOrEventListenerObject | undefined; | |
| 274 | ||
| 275 | constructor() { | |
| 276 | DeferredImage.instances.push(this); | |
| 277 | } | |
| 278 | ||
| 279 | addEventListener(type: string, listener: EventListenerOrEventListenerObject): void { | |
| 280 | if (type === "load") this.loadListener = listener; | |
| 281 | } | |
| 282 | ||
| 283 | set src(_value: string) {} | |
| 284 | ||
| 285 | finishLoading(): void { | |
| 286 | const event = new Event("load"); | |
| 287 | if (typeof this.loadListener === "function") this.loadListener(event); | |
| 288 | else this.loadListener?.handleEvent(event); | |
| 289 | } | |
| 290 | } | |
| 291 | vi.stubGlobal("Image", DeferredImage as unknown as typeof Image); | |
| 292 | vi.stubGlobal("document", { | |
| 293 | createElement: () => ({ | |
| 294 | width: 0, | |
| 295 | height: 0, | |
| 296 | captureStream: () => ({}), | |
| 297 | getContext: () => ({ drawImage, fillRect: vi.fn(), fillStyle: "" }), | |
| 298 | }), | |
| 299 | }); | |
| 300 | ||
| 301 | await startBrowserRecording("recording-tab"); | |
| 302 | frameSubscription.listener?.({ | |
| 303 | tabId: "recording-tab", | |
| 304 | data: "second-frame", | |
| 305 | width: 800, | |
| 306 | height: 600, | |
| 307 | receivedAt: "2026-06-26T00:00:01.000Z", | |
| 308 | }); | |
| 309 | frameSubscription.listener?.({ | |
| 310 | tabId: "recording-tab", | |
| 311 | data: "third-frame", | |
| 312 | width: 800, | |
| 313 | height: 600, | |
| 314 | receivedAt: "2026-06-26T00:00:02.000Z", | |
| 315 | }); | |
| 316 | ||
| 317 | DeferredImage.instances[1]?.finishLoading(); | |
| 318 | expect(drawImage).toHaveBeenCalledOnce(); | |
| 319 | DeferredImage.instances[2]?.finishLoading(); | |
| 320 | expect(drawImage).toHaveBeenCalledTimes(2); | |
| 321 | DeferredImage.instances[0]?.finishLoading(); | |
| 322 | expect(drawImage).toHaveBeenCalledTimes(2); | |
| 323 | ||
| 324 | await stopBrowserRecording("recording-tab"); | |
| 325 | }); | |
| 326 | ||
| 327 | it("records separate tabs concurrently", async () => { | |
| 328 | const firstThreadRef = { | |
| 329 | environmentId: EnvironmentId.make("environment-recording"), | |
| 330 | threadId: ThreadId.make("thread-recording-first"), | |
| 331 | }; | |
| 332 | const secondThreadRef = { | |
| 333 | environmentId: EnvironmentId.make("environment-recording"), | |
| 334 | threadId: ThreadId.make("thread-recording-second"), | |
| 335 | }; | |
| 336 | surfaceState.byTabId = { | |
| 337 | ...surfaceState.byTabId, | |
| 338 | "recording-tab-2": { | |
| 339 | visible: false, | |
| 340 | rect: { x: 0, y: 0, width: 390, height: 844 }, | |
| 341 | content: { x: 0, y: 0, width: 390, height: 844, scale: 1, scrollLeft: 0, scrollTop: 0 }, | |
| 342 | }, | |
| 343 | }; | |
| 344 | ||
| 345 | await Promise.all([ | |
| 346 | startBrowserRecording("recording-tab", firstThreadRef), | |
| 347 | startBrowserRecording("recording-tab-2", secondThreadRef), | |
| 348 | ]); | |
| 349 | ||
| 350 | expect(startScreencast).toHaveBeenCalledTimes(2); | |
| 351 | expect(onFrame).toHaveBeenCalledOnce(); | |
| 352 | expect(events).toContain("publish:recording-tab,recording-tab-2"); | |
| 353 | expect(readActiveBrowserRecordingTabIds()).toEqual( | |
| 354 | new Set(["recording-tab", "recording-tab-2"]), | |
| 130 | 355 | ); |
| 356 | expect(readActiveBrowserRecordingTabIds(firstThreadRef)).toEqual(new Set(["recording-tab"])); | |
| 357 | expect(readActiveBrowserRecordingTabIds(secondThreadRef)).toEqual(new Set(["recording-tab-2"])); | |
| 131 | 358 | |
| 132 | expect(startScreencast).not.toHaveBeenCalled(); | |
| 133 | expect(registrySet).not.toHaveBeenCalled(); | |
| 359 | await stopBrowserRecording("recording-tab"); | |
| 360 | expect(readActiveBrowserRecordingTabIds()).toEqual(new Set(["recording-tab-2"])); | |
| 361 | await stopBrowserRecording("recording-tab-2"); | |
| 362 | expect(readActiveBrowserRecordingTabIds()).toEqual(new Set()); | |
| 363 | expect(save).toHaveBeenCalledTimes(2); | |
| 134 | 364 | }); |
| 135 | 365 | |
| 136 | 366 | it("does not report success for a second start while the first is still starting", async () => { |
| 137 | 367 | let finishStartingScreencast: (() => void) | undefined; |
| 138 | startScreencast.mockImplementationOnce(async () => { | |
| 368 | startScreencast.mockImplementationOnce(async (tabId: string) => { | |
| 139 | 369 | events.push("start-screencast"); |
| 370 | frameSubscription.listener?.({ | |
| 371 | tabId, | |
| 372 | data: "initial-frame", | |
| 373 | width: 800, | |
| 374 | height: 600, | |
| 375 | receivedAt: "2026-06-26T00:00:00.000Z", | |
| 376 | }); | |
| 140 | 377 | await new Promise<void>((resolve) => { |
| 141 | 378 | finishStartingScreencast = resolve; |
| 142 | 379 | }); |
| ... | ... | @@ -197,28 +434,27 @@ describe("browser recording", () => { |
| 197 | 434 | expect(save).toHaveBeenCalledOnce(); |
| 198 | 435 | }); |
| 199 | 436 | |
| 200 | it("stops a screencast that finishes starting after cancellation", async () => { | |
| 437 | it("finishes startup before stopping so an active recording yields an artifact", async () => { | |
| 201 | 438 | let finishStartingScreencast: (() => void) | undefined; |
| 202 | 439 | startScreencast.mockImplementationOnce(async () => { |
| 203 | 440 | events.push("start-screencast"); |
| 204 | 441 | await new Promise<void>((resolve) => { |
| 205 | 442 | finishStartingScreencast = resolve; |
| 206 | 443 | }); |
| 444 | emitRecordingFrame(); | |
| 207 | 445 | }); |
| 208 | 446 | |
| 209 | 447 | const startPromise = startBrowserRecording("recording-tab"); |
| 210 | const rejectedStart = expect(startPromise).rejects.toBeInstanceOf( | |
| 211 | BrowserRecordingOperationError, | |
| 212 | ); | |
| 213 | 448 | await vi.waitFor(() => expect(startScreencast).toHaveBeenCalledOnce()); |
| 214 | 449 | |
| 215 | 450 | const stopPromise = stopBrowserRecording("recording-tab"); |
| 216 | await vi.waitFor(() => expect(stopScreencast).toHaveBeenCalledOnce()); | |
| 451 | expect(stopScreencast).not.toHaveBeenCalled(); | |
| 217 | 452 | finishStartingScreencast?.(); |
| 218 | 453 | |
| 219 | await rejectedStart; | |
| 220 | await stopPromise; | |
| 221 | expect(stopScreencast).toHaveBeenCalledTimes(2); | |
| 454 | await startPromise; | |
| 455 | await expect(stopPromise).resolves.toMatchObject({ tabId: "recording-tab" }); | |
| 456 | expect(stopScreencast).toHaveBeenCalledOnce(); | |
| 457 | expect(save).toHaveBeenCalledOnce(); | |
| 222 | 458 | expect(events.at(-1)).toBe("clear"); |
| 223 | 459 | }); |
| 224 | 460 | |
| ... | ... | @@ -229,12 +465,10 @@ describe("browser recording", () => { |
| 229 | 465 | await new Promise<void>((resolve) => { |
| 230 | 466 | finishStartingScreencast = resolve; |
| 231 | 467 | }); |
| 468 | emitRecordingFrame(); | |
| 232 | 469 | }); |
| 233 | 470 | |
| 234 | 471 | const firstStart = startBrowserRecording("recording-tab"); |
| 235 | const rejectedFirstStart = expect(firstStart).rejects.toBeInstanceOf( | |
| 236 | BrowserRecordingOperationError, | |
| 237 | ); | |
| 238 | 472 | await vi.waitFor(() => expect(startScreencast).toHaveBeenCalledOnce()); |
| 239 | 473 | |
| 240 | 474 | const stopPromise = stopBrowserRecording("recording-tab"); |
| ... | ... | @@ -243,7 +477,7 @@ describe("browser recording", () => { |
| 243 | 477 | const startCallsBeforeFirstSettled = startScreencast.mock.calls.length; |
| 244 | 478 | |
| 245 | 479 | finishStartingScreencast?.(); |
| 246 | await rejectedFirstStart; | |
| 480 | await firstStart; | |
| 247 | 481 | await stopPromise; |
| 248 | 482 | await restartAfterStop; |
| 249 | 483 | await stopBrowserRecording("recording-tab"); |
| ... | ... | @@ -251,6 +485,39 @@ describe("browser recording", () => { |
| 251 | 485 | expect(startCallsBeforeFirstSettled).toBe(1); |
| 252 | 486 | }); |
| 253 | 487 | |
| 488 | it("keeps the recording slot while a failed stop waits for startup", async () => { | |
| 489 | let finishStartingScreencast: (() => void) | undefined; | |
| 490 | startScreencast.mockImplementationOnce(async () => { | |
| 491 | events.push("start-screencast"); | |
| 492 | await new Promise<void>((resolve) => { | |
| 493 | finishStartingScreencast = resolve; | |
| 494 | }); | |
| 495 | emitRecordingFrame(); | |
| 496 | }); | |
| 497 | stopScreencast.mockRejectedValueOnce(new Error("initial stop failed")); | |
| 498 | ||
| 499 | const firstStart = startBrowserRecording("recording-tab"); | |
| 500 | await vi.waitFor(() => expect(startScreencast).toHaveBeenCalledOnce()); | |
| 501 | ||
| 502 | const stopPromise = stopBrowserRecording("recording-tab"); | |
| 503 | const rejectedStop = expect(stopPromise).rejects.toMatchObject({ | |
| 504 | operation: "stop-screencast", | |
| 505 | tabId: "recording-tab", | |
| 506 | }); | |
| 507 | expect(stopScreencast).not.toHaveBeenCalled(); | |
| 508 | await expect(startBrowserRecording("recording-tab")).rejects.toBeInstanceOf( | |
| 509 | BrowserRecordingConflictError, | |
| 510 | ); | |
| 511 | ||
| 512 | finishStartingScreencast?.(); | |
| 513 | await firstStart; | |
| 514 | await rejectedStop; | |
| 515 | expect(stopScreencast).toHaveBeenCalledOnce(); | |
| 516 | ||
| 517 | await startBrowserRecording("recording-tab"); | |
| 518 | await stopBrowserRecording("recording-tab"); | |
| 519 | }); | |
| 520 | ||
| 254 | 521 | it("fails a stop that waits too long for startup without freeing the recording slot", async () => { |
| 255 | 522 | vi.useFakeTimers(); |
| 256 | 523 | let finishStartingScreencast: (() => void) | undefined; |
| ... | ... | @@ -259,18 +526,16 @@ describe("browser recording", () => { |
| 259 | 526 | await new Promise<void>((resolve) => { |
| 260 | 527 | finishStartingScreencast = resolve; |
| 261 | 528 | }); |
| 529 | emitRecordingFrame(); | |
| 262 | 530 | }); |
| 263 | 531 | |
| 264 | 532 | const startPromise = startBrowserRecording("recording-tab"); |
| 265 | const rejectedStart = expect(startPromise).rejects.toBeInstanceOf( | |
| 266 | BrowserRecordingOperationError, | |
| 267 | ); | |
| 268 | 533 | expect(startScreencast).toHaveBeenCalledOnce(); |
| 269 | 534 | |
| 270 | 535 | const stopPromise = stopBrowserRecording("recording-tab"); |
| 271 | 536 | await Promise.resolve(); |
| 272 | 537 | await Promise.resolve(); |
| 273 | expect(stopScreencast).toHaveBeenCalledOnce(); | |
| 538 | expect(stopScreencast).not.toHaveBeenCalled(); | |
| 274 | 539 | |
| 275 | 540 | const rejection = expect(stopPromise).rejects.toMatchObject({ |
| 276 | 541 | operation: "wait-startup", |
| ... | ... | @@ -285,9 +550,10 @@ describe("browser recording", () => { |
| 285 | 550 | ); |
| 286 | 551 | |
| 287 | 552 | finishStartingScreencast?.(); |
| 288 | await rejectedStart; | |
| 553 | await startPromise; | |
| 289 | 554 | const cleanupResult = await stopBrowserRecording("recording-tab"); |
| 290 | 555 | expect(cleanupResult).toBeNull(); |
| 556 | expect(stopScreencast).toHaveBeenCalledOnce(); | |
| 291 | 557 | expect(save).not.toHaveBeenCalled(); |
| 292 | 558 | expect(events.at(-1)).toBe("clear"); |
| 293 | 559 | }); |
apps/web/src/browser/browserRecording.ts+253-121| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import type { |
| 2 | 2 | DesktopPreviewRecordingArtifact, |
| 3 | 3 | DesktopPreviewRecordingFrame, |
| 4 | ScopedThreadRef, | |
| 4 | 5 | } from "@t3tools/contracts"; |
| 5 | 6 | import { useAtomValue } from "@effect/atom-react"; |
| 6 | 7 | import * as Schema from "effect/Schema"; |
| ... | ... | @@ -46,17 +47,6 @@ export class BrowserRecordingCanvasUnavailableError extends Schema.TaggedErrorCl |
| 46 | 47 | } |
| 47 | 48 | } |
| 48 | 49 | |
| 49 | export class BrowserRecordingRequiresVisibleTabError extends Schema.TaggedErrorClass<BrowserRecordingRequiresVisibleTabError>()( | |
| 50 | "BrowserRecordingRequiresVisibleTabError", | |
| 51 | { | |
| 52 | tabId: Schema.String, | |
| 53 | }, | |
| 54 | ) { | |
| 55 | override get message(): string { | |
| 56 | return `Browser recording requires tab ${this.tabId} to be visible.`; | |
| 57 | } | |
| 58 | } | |
| 59 | ||
| 60 | 50 | export class BrowserRecordingOperationError extends Schema.TaggedErrorClass<BrowserRecordingOperationError>()( |
| 61 | 51 | "BrowserRecordingOperationError", |
| 62 | 52 | { |
| ... | ... | @@ -66,6 +56,7 @@ export class BrowserRecordingOperationError extends Schema.TaggedErrorClass<Brow |
| 66 | 56 | "start-media-recorder", |
| 67 | 57 | "start-screencast", |
| 68 | 58 | "stop-screencast", |
| 59 | "wait-first-frame", | |
| 69 | 60 | "wait-startup", |
| 70 | 61 | "stop-media-recorder", |
| 71 | 62 | "save-artifact", |
| ... | ... | @@ -92,32 +83,52 @@ type BrowserRecordingLifecycle = |
| 92 | 83 | |
| 93 | 84 | interface ActiveRecording { |
| 94 | 85 | readonly tabId: string; |
| 86 | readonly threadRef: ScopedThreadRef | null; | |
| 95 | 87 | readonly canvas: HTMLCanvasElement; |
| 96 | 88 | readonly context: CanvasRenderingContext2D; |
| 97 | readonly recorder: MediaRecorder; | |
| 98 | 89 | readonly chunks: Blob[]; |
| 99 | readonly mimeType: string; | |
| 100 | 90 | readonly startedAt: string; |
| 101 | 91 | readonly startupSettled: Promise<void>; |
| 92 | readonly firstFrameSize: Promise<"frame" | "cancelled">; | |
| 93 | readonly settleFirstFrameSize: (outcome: "frame" | "cancelled") => void; | |
| 94 | recorder: MediaRecorder | null; | |
| 95 | mimeType: string | null; | |
| 96 | frameSizeEstablished: boolean; | |
| 97 | frameSequence: number; | |
| 98 | lastDrawnFrameSequence: number; | |
| 102 | 99 | lifecycle: BrowserRecordingLifecycle; |
| 103 | 100 | } |
| 104 | 101 | |
| 105 | const activeBrowserRecordingTabIdAtom = Atom.make<string | null>(null).pipe( | |
| 106 | Atom.keepAlive, | |
| 107 | Atom.withLabel("preview:active-browser-recording-tab"), | |
| 108 | ); | |
| 102 | interface ActiveBrowserRecordingIndex { | |
| 103 | readonly tabIds: ReadonlySet<string>; | |
| 104 | } | |
| 109 | 105 | |
| 110 | export function useActiveBrowserRecordingTabId(): string | null { | |
| 111 | return useAtomValue(activeBrowserRecordingTabIdAtom); | |
| 106 | const activeBrowserRecordingTabIdsAtom = Atom.make<ActiveBrowserRecordingIndex>({ | |
| 107 | tabIds: new Set<string>(), | |
| 108 | }).pipe(Atom.keepAlive, Atom.withLabel("preview:active-browser-recording-tabs")); | |
| 109 | ||
| 110 | export function useActiveBrowserRecordingTabIds(): ReadonlySet<string> { | |
| 111 | return useAtomValue(activeBrowserRecordingTabIdsAtom).tabIds; | |
| 112 | 112 | } |
| 113 | 113 | |
| 114 | let active: ActiveRecording | null = null; | |
| 114 | const activeRecordings = new Map<string, ActiveRecording>(); | |
| 115 | 115 | let unsubscribeFrames: (() => void) | null = null; |
| 116 | 116 | |
| 117 | 117 | export const BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS = 5_000; |
| 118 | export const BROWSER_RECORDING_FIRST_FRAME_SIZE_TIMEOUT_MS = 5_000; | |
| 118 | 119 | |
| 119 | export function readActiveBrowserRecordingTabId(): string | null { | |
| 120 | return active?.tabId ?? null; | |
| 120 | export function readActiveBrowserRecordingTabIds(threadRef?: ScopedThreadRef): ReadonlySet<string> { | |
| 121 | const tabIds = new Set<string>(); | |
| 122 | for (const recording of activeRecordings.values()) { | |
| 123 | if ( | |
| 124 | threadRef === undefined || | |
| 125 | (recording.threadRef?.environmentId === threadRef.environmentId && | |
| 126 | recording.threadRef.threadId === threadRef.threadId) | |
| 127 | ) { | |
| 128 | tabIds.add(recording.tabId); | |
| 129 | } | |
| 130 | } | |
| 131 | return tabIds; | |
| 121 | 132 | } |
| 122 | 133 | |
| 123 | 134 | const preferredMimeType = (): string => { |
| ... | ... | @@ -126,22 +137,52 @@ const preferredMimeType = (): string => { |
| 126 | 137 | }; |
| 127 | 138 | |
| 128 | 139 | const drawFrame = (frame: DesktopPreviewRecordingFrame): void => { |
| 129 | const recording = active; | |
| 130 | if (!recording || recording.tabId !== frame.tabId) return; | |
| 140 | const recording = activeRecordings.get(frame.tabId); | |
| 141 | if (!recording) return; | |
| 142 | if ( | |
| 143 | !Number.isFinite(frame.width) || | |
| 144 | !Number.isFinite(frame.height) || | |
| 145 | frame.width <= 0 || | |
| 146 | frame.height <= 0 | |
| 147 | ) { | |
| 148 | return; | |
| 149 | } | |
| 150 | const width = Math.max(1, Math.round(frame.width)); | |
| 151 | const height = Math.max(1, Math.round(frame.height)); | |
| 152 | if (!recording.frameSizeEstablished) { | |
| 153 | recording.canvas.width = width; | |
| 154 | recording.canvas.height = height; | |
| 155 | recording.frameSizeEstablished = true; | |
| 156 | recording.settleFirstFrameSize("frame"); | |
| 157 | } | |
| 158 | const frameSequence = ++recording.frameSequence; | |
| 131 | 159 | const image = new Image(); |
| 132 | 160 | image.addEventListener( |
| 133 | 161 | "load", |
| 134 | 162 | () => { |
| 135 | if (active !== recording) return; | |
| 136 | recording.context.drawImage(image, 0, 0, recording.canvas.width, recording.canvas.height); | |
| 163 | if ( | |
| 164 | activeRecordings.get(frame.tabId) !== recording || | |
| 165 | frameSequence <= recording.lastDrawnFrameSequence | |
| 166 | ) { | |
| 167 | return; | |
| 168 | } | |
| 169 | recording.lastDrawnFrameSequence = frameSequence; | |
| 170 | const scale = Math.min(recording.canvas.width / width, recording.canvas.height / height); | |
| 171 | const targetWidth = width * scale; | |
| 172 | const targetHeight = height * scale; | |
| 173 | const targetX = (recording.canvas.width - targetWidth) / 2; | |
| 174 | const targetY = (recording.canvas.height - targetHeight) / 2; | |
| 175 | recording.context.fillStyle = "#000000"; | |
| 176 | recording.context.fillRect(0, 0, recording.canvas.width, recording.canvas.height); | |
| 177 | recording.context.drawImage(image, targetX, targetY, targetWidth, targetHeight); | |
| 137 | 178 | }, |
| 138 | 179 | { once: true }, |
| 139 | 180 | ); |
| 140 | 181 | image.src = `data:image/jpeg;base64,${frame.data}`; |
| 141 | 182 | }; |
| 142 | 183 | |
| 143 | const stopMediaRecorder = async (recorder: MediaRecorder): Promise<void> => { | |
| 144 | if (recorder.state === "inactive") return; | |
| 184 | const stopMediaRecorder = async (recorder: MediaRecorder | null): Promise<void> => { | |
| 185 | if (!recorder || recorder.state === "inactive") return; | |
| 145 | 186 | const stopped = new Promise<void>((resolve) => |
| 146 | 187 | recorder.addEventListener("stop", () => resolve(), { once: true }), |
| 147 | 188 | ); |
| ... | ... | @@ -150,11 +191,42 @@ const stopMediaRecorder = async (recorder: MediaRecorder): Promise<void> => { |
| 150 | 191 | }; |
| 151 | 192 | |
| 152 | 193 | const clearActiveRecording = (recording: ActiveRecording): void => { |
| 153 | if (active !== recording) return; | |
| 154 | active = null; | |
| 155 | unsubscribeFrames?.(); | |
| 156 | unsubscribeFrames = null; | |
| 157 | appAtomRegistry.set(activeBrowserRecordingTabIdAtom, null); | |
| 194 | if (activeRecordings.get(recording.tabId) !== recording) return; | |
| 195 | recording.settleFirstFrameSize("cancelled"); | |
| 196 | activeRecordings.delete(recording.tabId); | |
| 197 | if (activeRecordings.size === 0) { | |
| 198 | unsubscribeFrames?.(); | |
| 199 | unsubscribeFrames = null; | |
| 200 | } | |
| 201 | appAtomRegistry.set(activeBrowserRecordingTabIdsAtom, { | |
| 202 | tabIds: new Set(activeRecordings.keys()), | |
| 203 | }); | |
| 204 | }; | |
| 205 | ||
| 206 | const cleanupFailedRecordingStart = async ( | |
| 207 | bridge: NonNullable<typeof previewBridge>, | |
| 208 | recording: ActiveRecording, | |
| 209 | ): Promise<unknown | undefined> => { | |
| 210 | const errors: unknown[] = []; | |
| 211 | try { | |
| 212 | await bridge.recording.stopScreencast(recording.tabId); | |
| 213 | } catch (error) { | |
| 214 | errors.push(error); | |
| 215 | } | |
| 216 | try { | |
| 217 | await stopMediaRecorder(recording.recorder); | |
| 218 | } catch (error) { | |
| 219 | errors.push(error); | |
| 220 | } finally { | |
| 221 | clearActiveRecording(recording); | |
| 222 | } | |
| 223 | if (errors.length === 0) return undefined; | |
| 224 | if (errors.length === 1) return errors[0]; | |
| 225 | return new AggregateError( | |
| 226 | errors, | |
| 227 | `Browser recording startup cleanup failed for tab ${recording.tabId}.`, | |
| 228 | { cause: errors[0] }, | |
| 229 | ); | |
| 158 | 230 | }; |
| 159 | 231 | |
| 160 | 232 | const recordingStartupCancelledError = ( |
| ... | ... | @@ -168,7 +240,20 @@ const recordingStartupCancelledError = ( |
| 168 | 240 | }); |
| 169 | 241 | |
| 170 | 242 | const isRecordingStarting = (recording: ActiveRecording): boolean => |
| 171 | active === recording && recording.lifecycle.phase === "starting"; | |
| 243 | activeRecordings.get(recording.tabId) === recording && recording.lifecycle.phase === "starting"; | |
| 244 | ||
| 245 | const waitForFirstFrameSize = async (recording: ActiveRecording): Promise<boolean> => { | |
| 246 | if (recording.frameSizeEstablished) return true; | |
| 247 | let timeout: ReturnType<typeof setTimeout> | null = null; | |
| 248 | const outcome = await Promise.race([ | |
| 249 | recording.firstFrameSize, | |
| 250 | new Promise<"timeout">((resolve) => { | |
| 251 | timeout = setTimeout(() => resolve("timeout"), BROWSER_RECORDING_FIRST_FRAME_SIZE_TIMEOUT_MS); | |
| 252 | }), | |
| 253 | ]); | |
| 254 | if (timeout !== null) clearTimeout(timeout); | |
| 255 | return outcome === "frame"; | |
| 256 | }; | |
| 172 | 257 | |
| 173 | 258 | const waitForRecordingStartupToSettle = async (recording: ActiveRecording): Promise<void> => { |
| 174 | 259 | let timeout: ReturnType<typeof setTimeout> | null = null; |
| ... | ... | @@ -195,20 +280,23 @@ const waitForRecordingStartupToSettle = async (recording: ActiveRecording): Prom |
| 195 | 280 | const isStartupWaitTimeout = (error: unknown): error is BrowserRecordingOperationError => |
| 196 | 281 | isBrowserRecordingOperationError(error) && error.operation === "wait-startup"; |
| 197 | 282 | |
| 198 | export async function startBrowserRecording(tabId: string): Promise<string> { | |
| 283 | export async function startBrowserRecording( | |
| 284 | tabId: string, | |
| 285 | threadRef: ScopedThreadRef | null = null, | |
| 286 | ): Promise<string> { | |
| 199 | 287 | const bridge = previewBridge; |
| 200 | 288 | if (!bridge) throw new BrowserRecordingUnavailableError({ tabId }); |
| 201 | if (active) { | |
| 202 | if (active.tabId === tabId && active.lifecycle.phase === "recording") { | |
| 203 | return active.startedAt; | |
| 289 | const activeRecording = activeRecordings.get(tabId); | |
| 290 | if (activeRecording) { | |
| 291 | if (activeRecording.lifecycle.phase === "recording") { | |
| 292 | return activeRecording.startedAt; | |
| 204 | 293 | } |
| 205 | 294 | throw new BrowserRecordingConflictError({ |
| 206 | 295 | requestedTabId: tabId, |
| 207 | activeTabId: active.tabId, | |
| 296 | activeTabId: activeRecording.tabId, | |
| 208 | 297 | }); |
| 209 | 298 | } |
| 210 | 299 | const surface = useBrowserSurfaceStore.getState().byTabId[tabId]; |
| 211 | if (!surface?.visible) throw new BrowserRecordingRequiresVisibleTabError({ tabId }); | |
| 212 | 300 | const recordingSize = surface?.content ?? surface?.rect; |
| 213 | 301 | const canvas = document.createElement("canvas"); |
| 214 | 302 | canvas.width = Math.max(1, recordingSize?.width ?? 1280); |
| ... | ... | @@ -221,42 +309,34 @@ export async function startBrowserRecording(tabId: string): Promise<string> { |
| 221 | 309 | height: canvas.height, |
| 222 | 310 | }); |
| 223 | 311 | } |
| 224 | let mimeType: string; | |
| 225 | let recorder: MediaRecorder; | |
| 226 | try { | |
| 227 | mimeType = preferredMimeType(); | |
| 228 | recorder = new MediaRecorder(canvas.captureStream(12), { | |
| 229 | mimeType, | |
| 230 | videoBitsPerSecond: 4_000_000, | |
| 231 | }); | |
| 232 | } catch (cause) { | |
| 233 | throw new BrowserRecordingOperationError({ | |
| 234 | operation: "initialize-media-recorder", | |
| 235 | tabId, | |
| 236 | cause, | |
| 237 | }); | |
| 238 | } | |
| 239 | 312 | const startedAt = new Date().toISOString(); |
| 240 | 313 | const chunks: Blob[] = []; |
| 241 | 314 | let settleStartup: (() => void) | undefined; |
| 242 | 315 | const startupSettled = new Promise<void>((resolve) => { |
| 243 | 316 | settleStartup = resolve; |
| 244 | 317 | }); |
| 245 | recorder.addEventListener("dataavailable", (event) => { | |
| 246 | if (event.data.size > 0) chunks.push(event.data); | |
| 318 | let settleFirstFrameSize: ((outcome: "frame" | "cancelled") => void) | undefined; | |
| 319 | const firstFrameSize = new Promise<"frame" | "cancelled">((resolve) => { | |
| 320 | settleFirstFrameSize = resolve; | |
| 247 | 321 | }); |
| 248 | 322 | const recording: ActiveRecording = { |
| 249 | 323 | tabId, |
| 324 | threadRef, | |
| 250 | 325 | canvas, |
| 251 | 326 | context, |
| 252 | recorder, | |
| 253 | 327 | chunks, |
| 254 | mimeType, | |
| 255 | 328 | startedAt, |
| 256 | 329 | startupSettled, |
| 330 | firstFrameSize, | |
| 331 | settleFirstFrameSize: (outcome) => settleFirstFrameSize?.(outcome), | |
| 332 | recorder: null, | |
| 333 | mimeType: null, | |
| 334 | frameSizeEstablished: false, | |
| 335 | frameSequence: 0, | |
| 336 | lastDrawnFrameSequence: 0, | |
| 257 | 337 | lifecycle: { phase: "starting" }, |
| 258 | 338 | }; |
| 259 | active = recording; | |
| 339 | activeRecordings.set(tabId, recording); | |
| 260 | 340 | try { |
| 261 | 341 | try { |
| 262 | 342 | unsubscribeFrames ??= bridge.recording.onFrame(drawFrame); |
| ... | ... | @@ -268,47 +348,23 @@ export async function startBrowserRecording(tabId: string): Promise<string> { |
| 268 | 348 | cause, |
| 269 | 349 | }); |
| 270 | 350 | } |
| 271 | try { | |
| 272 | recorder.start(1_000); | |
| 273 | } catch (cause) { | |
| 274 | clearActiveRecording(recording); | |
| 275 | throw new BrowserRecordingOperationError({ | |
| 276 | operation: "start-media-recorder", | |
| 277 | tabId, | |
| 278 | cause, | |
| 279 | }); | |
| 280 | } | |
| 281 | if (!isRecordingStarting(recording)) { | |
| 282 | throw recordingStartupCancelledError(recording); | |
| 283 | } | |
| 284 | 351 | try { |
| 285 | 352 | await bridge.recording.startScreencast(tabId); |
| 286 | 353 | } catch (cause) { |
| 287 | 354 | if (!isRecordingStarting(recording)) { |
| 288 | 355 | throw recordingStartupCancelledError(recording, cause); |
| 289 | 356 | } |
| 290 | let cleanupCause: unknown; | |
| 291 | try { | |
| 292 | await stopMediaRecorder(recorder); | |
| 293 | } catch (error) { | |
| 294 | cleanupCause = error; | |
| 295 | } finally { | |
| 296 | clearActiveRecording(recording); | |
| 297 | } | |
| 357 | clearActiveRecording(recording); | |
| 298 | 358 | throw new BrowserRecordingOperationError({ |
| 299 | 359 | operation: "start-screencast", |
| 300 | 360 | tabId, |
| 301 | cause: | |
| 302 | cleanupCause === undefined | |
| 303 | ? cause | |
| 304 | : new AggregateError( | |
| 305 | [cause, cleanupCause], | |
| 306 | `Browser recording start and cleanup failed for tab ${tabId}.`, | |
| 307 | { cause }, | |
| 308 | ), | |
| 361 | cause, | |
| 309 | 362 | }); |
| 310 | 363 | } |
| 311 | if (!isRecordingStarting(recording)) { | |
| 364 | const throwIfStartupCancelled = async (): Promise<void> => { | |
| 365 | // A stop requested during startup should let startup finish so the | |
| 366 | // caller receives a real artifact. Only replacement/removal cancels it. | |
| 367 | if (activeRecordings.get(tabId) === recording) return; | |
| 312 | 368 | try { |
| 313 | 369 | await bridge.recording.stopScreencast(tabId); |
| 314 | 370 | } catch (cause) { |
| ... | ... | @@ -322,9 +378,78 @@ export async function startBrowserRecording(tabId: string): Promise<string> { |
| 322 | 378 | ); |
| 323 | 379 | } |
| 324 | 380 | throw recordingStartupCancelledError(recording); |
| 381 | }; | |
| 382 | await throwIfStartupCancelled(); | |
| 383 | const hasFirstFrame = await waitForFirstFrameSize(recording); | |
| 384 | await throwIfStartupCancelled(); | |
| 385 | if (!hasFirstFrame) { | |
| 386 | const cause = new Error(`No valid recording frame arrived for tab ${tabId}.`); | |
| 387 | const cleanupCause = await cleanupFailedRecordingStart(bridge, recording); | |
| 388 | throw new BrowserRecordingOperationError({ | |
| 389 | operation: "wait-first-frame", | |
| 390 | tabId, | |
| 391 | cause: | |
| 392 | cleanupCause === undefined | |
| 393 | ? cause | |
| 394 | : new AggregateError( | |
| 395 | [cause, cleanupCause], | |
| 396 | `Browser recording frame wait and cleanup failed for tab ${tabId}.`, | |
| 397 | { cause }, | |
| 398 | ), | |
| 399 | }); | |
| 400 | } | |
| 401 | ||
| 402 | let mimeType: string; | |
| 403 | let recorder: MediaRecorder; | |
| 404 | try { | |
| 405 | mimeType = preferredMimeType(); | |
| 406 | recorder = new MediaRecorder(canvas.captureStream(12), { | |
| 407 | mimeType, | |
| 408 | videoBitsPerSecond: 4_000_000, | |
| 409 | }); | |
| 410 | recording.mimeType = mimeType; | |
| 411 | recording.recorder = recorder; | |
| 412 | recorder.addEventListener("dataavailable", (event) => { | |
| 413 | if (event.data.size > 0) chunks.push(event.data); | |
| 414 | }); | |
| 415 | } catch (cause) { | |
| 416 | const cleanupCause = await cleanupFailedRecordingStart(bridge, recording); | |
| 417 | throw new BrowserRecordingOperationError({ | |
| 418 | operation: "initialize-media-recorder", | |
| 419 | tabId, | |
| 420 | cause: | |
| 421 | cleanupCause === undefined | |
| 422 | ? cause | |
| 423 | : new AggregateError( | |
| 424 | [cause, cleanupCause], | |
| 425 | `Browser recording initialization and cleanup failed for tab ${tabId}.`, | |
| 426 | { cause }, | |
| 427 | ), | |
| 428 | }); | |
| 325 | 429 | } |
| 326 | recording.lifecycle = { phase: "recording" }; | |
| 327 | appAtomRegistry.set(activeBrowserRecordingTabIdAtom, tabId); | |
| 430 | try { | |
| 431 | recorder.start(1_000); | |
| 432 | } catch (cause) { | |
| 433 | const cleanupCause = await cleanupFailedRecordingStart(bridge, recording); | |
| 434 | throw new BrowserRecordingOperationError({ | |
| 435 | operation: "start-media-recorder", | |
| 436 | tabId, | |
| 437 | cause: | |
| 438 | cleanupCause === undefined | |
| 439 | ? cause | |
| 440 | : new AggregateError( | |
| 441 | [cause, cleanupCause], | |
| 442 | `Browser media recorder start and cleanup failed for tab ${tabId}.`, | |
| 443 | { cause }, | |
| 444 | ), | |
| 445 | }); | |
| 446 | } | |
| 447 | if (recording.lifecycle.phase === "starting") { | |
| 448 | recording.lifecycle = { phase: "recording" }; | |
| 449 | } | |
| 450 | appAtomRegistry.set(activeBrowserRecordingTabIdsAtom, { | |
| 451 | tabIds: new Set(activeRecordings.keys()), | |
| 452 | }); | |
| 328 | 453 | return startedAt; |
| 329 | 454 | } finally { |
| 330 | 455 | settleStartup?.(); |
| ... | ... | @@ -334,12 +459,16 @@ export async function startBrowserRecording(tabId: string): Promise<string> { |
| 334 | 459 | const finalizeBrowserRecording = async ( |
| 335 | 460 | bridge: NonNullable<typeof previewBridge>, |
| 336 | 461 | recording: ActiveRecording, |
| 337 | ): Promise<DesktopPreviewRecordingArtifact> => { | |
| 462 | ): Promise<DesktopPreviewRecordingArtifact | null> => { | |
| 338 | 463 | const { tabId } = recording; |
| 339 | 464 | let result: |
| 340 | | { readonly _tag: "Success"; readonly artifact: DesktopPreviewRecordingArtifact } | |
| 465 | | { | |
| 466 | readonly _tag: "Success"; | |
| 467 | readonly artifact: DesktopPreviewRecordingArtifact | null; | |
| 468 | } | |
| 341 | 469 | | { readonly _tag: "Failure"; readonly error: unknown }; |
| 342 | 470 | try { |
| 471 | await waitForRecordingStartupToSettle(recording); | |
| 343 | 472 | try { |
| 344 | 473 | await bridge.recording.stopScreencast(tabId); |
| 345 | 474 | } catch (cause) { |
| ... | ... | @@ -349,30 +478,33 @@ const finalizeBrowserRecording = async ( |
| 349 | 478 | cause, |
| 350 | 479 | }); |
| 351 | 480 | } |
| 352 | await waitForRecordingStartupToSettle(recording); | |
| 353 | try { | |
| 354 | await stopMediaRecorder(recording.recorder); | |
| 355 | } catch (cause) { | |
| 356 | throw new BrowserRecordingOperationError({ | |
| 357 | operation: "stop-media-recorder", | |
| 358 | tabId, | |
| 359 | cause, | |
| 360 | }); | |
| 361 | } | |
| 362 | try { | |
| 363 | const blob = new Blob(recording.chunks, { type: recording.mimeType }); | |
| 364 | const artifact = await bridge.recording.save( | |
| 365 | tabId, | |
| 366 | recording.mimeType, | |
| 367 | new Uint8Array(await blob.arrayBuffer()), | |
| 368 | ); | |
| 369 | result = { _tag: "Success", artifact }; | |
| 370 | } catch (cause) { | |
| 371 | throw new BrowserRecordingOperationError({ | |
| 372 | operation: "save-artifact", | |
| 373 | tabId, | |
| 374 | cause, | |
| 375 | }); | |
| 481 | if (!recording.recorder || !recording.mimeType) { | |
| 482 | result = { _tag: "Success", artifact: null }; | |
| 483 | } else { | |
| 484 | try { | |
| 485 | await stopMediaRecorder(recording.recorder); | |
| 486 | } catch (cause) { | |
| 487 | throw new BrowserRecordingOperationError({ | |
| 488 | operation: "stop-media-recorder", | |
| 489 | tabId, | |
| 490 | cause, | |
| 491 | }); | |
| 492 | } | |
| 493 | try { | |
| 494 | const blob = new Blob(recording.chunks, { type: recording.mimeType }); | |
| 495 | const artifact = await bridge.recording.save( | |
| 496 | tabId, | |
| 497 | recording.mimeType, | |
| 498 | new Uint8Array(await blob.arrayBuffer()), | |
| 499 | ); | |
| 500 | result = { _tag: "Success", artifact }; | |
| 501 | } catch (cause) { | |
| 502 | throw new BrowserRecordingOperationError({ | |
| 503 | operation: "save-artifact", | |
| 504 | tabId, | |
| 505 | cause, | |
| 506 | }); | |
| 507 | } | |
| 376 | 508 | } |
| 377 | 509 | } catch (error) { |
| 378 | 510 | result = { _tag: "Failure", error }; |
| ... | ... | @@ -434,14 +566,14 @@ export function stopBrowserRecording( |
| 434 | 566 | tabId: string, |
| 435 | 567 | ): Promise<DesktopPreviewRecordingArtifact | null> { |
| 436 | 568 | const bridge = previewBridge; |
| 437 | const recording = active; | |
| 438 | if (!bridge || !recording || recording.tabId !== tabId) return Promise.resolve(null); | |
| 569 | const recording = activeRecordings.get(tabId); | |
| 570 | if (!bridge || !recording) return Promise.resolve(null); | |
| 439 | 571 | if (recording.lifecycle.phase === "stopping") return recording.lifecycle.stopPromise; |
| 440 | 572 | |
| 441 | 573 | const stopPromise = Promise.resolve() |
| 442 | 574 | .then(() => finalizeBrowserRecording(bridge, recording)) |
| 443 | 575 | .catch((error) => { |
| 444 | if (isStartupWaitTimeout(error) && active === recording) { | |
| 576 | if (isStartupWaitTimeout(error) && activeRecordings.get(recording.tabId) === recording) { | |
| 445 | 577 | const cleanupAfterStartup = recording.startupSettled.then(() => |
| 446 | 578 | discardBrowserRecording(bridge, recording), |
| 447 | 579 | ); |
apps/web/src/browser/browserRecordingScope.test.ts+34-7| ... | ... | @@ -3,14 +3,41 @@ import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | import { resolveBrowserRecordingStopTarget } from "./browserRecordingScope"; |
| 4 | 4 | |
| 5 | 5 | describe("resolveBrowserRecordingStopTarget", () => { |
| 6 | it("stops the active recording when no explicit tab was requested", () => { | |
| 7 | expect(resolveBrowserRecordingStopTarget("tab-a")).toBe("tab-a"); | |
| 8 | expect(resolveBrowserRecordingStopTarget("tab-b")).toBe("tab-b"); | |
| 9 | expect(resolveBrowserRecordingStopTarget(null)).toBeNull(); | |
| 6 | it("stops the only active recording when the implicit browser target changed", () => { | |
| 7 | expect(resolveBrowserRecordingStopTarget(new Set(["tab-recording"]), "tab-browsing")).toBe( | |
| 8 | "tab-recording", | |
| 9 | ); | |
| 10 | 10 | }); |
| 11 | 11 | |
| 12 | it("only stops an explicitly requested tab when it owns the recording", () => { | |
| 13 | expect(resolveBrowserRecordingStopTarget("tab-a", "tab-a")).toBe("tab-a"); | |
| 14 | expect(resolveBrowserRecordingStopTarget("tab-a", "tab-b")).toBeNull(); | |
| 12 | it("prefers an implicit target that is actively recording", () => { | |
| 13 | expect( | |
| 14 | resolveBrowserRecordingStopTarget( | |
| 15 | new Set(["tab-recording-a", "tab-recording-b"]), | |
| 16 | "tab-recording-b", | |
| 17 | ), | |
| 18 | ).toBe("tab-recording-b"); | |
| 19 | }); | |
| 20 | ||
| 21 | it("does not guess when multiple recordings are active and the implicit target is not one", () => { | |
| 22 | expect( | |
| 23 | resolveBrowserRecordingStopTarget( | |
| 24 | new Set(["tab-recording-a", "tab-recording-b"]), | |
| 25 | "tab-browsing", | |
| 26 | ), | |
| 27 | ).toBeNull(); | |
| 28 | }); | |
| 29 | ||
| 30 | it("only stops an explicitly requested tab when that tab is recording", () => { | |
| 31 | const activeTabIds = new Set(["tab-recording"]); | |
| 32 | expect(resolveBrowserRecordingStopTarget(activeTabIds, "tab-browsing", "tab-recording")).toBe( | |
| 33 | "tab-recording", | |
| 34 | ); | |
| 35 | expect(resolveBrowserRecordingStopTarget(activeTabIds, "tab-recording", "tab-browsing")).toBe( | |
| 36 | null, | |
| 37 | ); | |
| 38 | }); | |
| 39 | ||
| 40 | it("returns null when no matching recording is active", () => { | |
| 41 | expect(resolveBrowserRecordingStopTarget(new Set(), "tab-browsing")).toBeNull(); | |
| 15 | 42 | }); |
| 16 | 43 | }); |
apps/web/src/browser/browserRecordingScope.ts+11-4| ... | ... | @@ -1,7 +1,14 @@ |
| 1 | 1 | export function resolveBrowserRecordingStopTarget( |
| 2 | activeTabId: string | null, | |
| 3 | requestedTabId?: string, | |
| 2 | activeTabIds: ReadonlySet<string>, | |
| 3 | implicitTabId: string | null, | |
| 4 | explicitTabId?: string, | |
| 4 | 5 | ): string | null { |
| 5 | if (activeTabId === null) return null; | |
| 6 | return requestedTabId === undefined || requestedTabId === activeTabId ? activeTabId : null; | |
| 6 | if (explicitTabId !== undefined) { | |
| 7 | return activeTabIds.has(explicitTabId) ? explicitTabId : null; | |
| 8 | } | |
| 9 | if (implicitTabId !== null && activeTabIds.has(implicitTabId)) { | |
| 10 | return implicitTabId; | |
| 11 | } | |
| 12 | if (activeTabIds.size !== 1) return null; | |
| 13 | return activeTabIds.values().next().value ?? null; | |
| 7 | 14 | } |
apps/web/src/browser/browserSurfaceStore.test.ts+100-3| ... | ... | @@ -11,6 +11,62 @@ describe("browserSurfaceStore", () => { |
| 11 | 11 | useBrowserSurfaceStore.setState({ byTabId: {} }); |
| 12 | 12 | }); |
| 13 | 13 | |
| 14 | it("freezes the source content dimensions for a fitted presentation", () => { | |
| 15 | const tabId = "fitted-browser-surface"; | |
| 16 | const sourceOwner = Symbol("source"); | |
| 17 | const sourceContent = { | |
| 18 | x: 10, | |
| 19 | y: 20, | |
| 20 | width: 1_280, | |
| 21 | height: 720, | |
| 22 | scale: 1, | |
| 23 | scrollLeft: 0, | |
| 24 | scrollTop: 0, | |
| 25 | }; | |
| 26 | useBrowserSurfaceStore.getState().claim(tabId, sourceOwner, false); | |
| 27 | useBrowserSurfaceStore.getState().presentContent(tabId, sourceContent); | |
| 28 | ||
| 29 | const fittedLease = acquireBrowserSurface(tabId, true); | |
| 30 | useBrowserSurfaceStore.getState().presentContent(tabId, { | |
| 31 | ...sourceContent, | |
| 32 | width: 360, | |
| 33 | height: 203, | |
| 34 | scale: 0.28125, | |
| 35 | }); | |
| 36 | ||
| 37 | expect(useBrowserSurfaceStore.getState().byTabId[tabId]?.fittedSourceContent).toEqual( | |
| 38 | sourceContent, | |
| 39 | ); | |
| 40 | fittedLease.release(); | |
| 41 | }); | |
| 42 | ||
| 43 | it("freezes the first content dimensions when fitting starts before the browser is measured", () => { | |
| 44 | const tabId = "pending-fitted-browser-surface"; | |
| 45 | const fittedLease = acquireBrowserSurface(tabId, true); | |
| 46 | const sourceContent = { | |
| 47 | x: 0, | |
| 48 | y: 0, | |
| 49 | width: 1_280, | |
| 50 | height: 720, | |
| 51 | scale: 1, | |
| 52 | scrollLeft: 0, | |
| 53 | scrollTop: 0, | |
| 54 | }; | |
| 55 | ||
| 56 | useBrowserSurfaceStore.getState().presentContent(tabId, sourceContent); | |
| 57 | useBrowserSurfaceStore.getState().presentContent(tabId, { | |
| 58 | ...sourceContent, | |
| 59 | width: 320, | |
| 60 | height: 180, | |
| 61 | scale: 0.25, | |
| 62 | }); | |
| 63 | ||
| 64 | expect(useBrowserSurfaceStore.getState().byTabId[tabId]?.fittedSourceContent).toEqual( | |
| 65 | sourceContent, | |
| 66 | ); | |
| 67 | fittedLease.release(); | |
| 68 | }); | |
| 69 | ||
| 14 | 70 | it("tracks content dimensions for a browser that has never been visible", () => { |
| 15 | 71 | const tabId = "hidden-browser-surface-content-test"; |
| 16 | 72 | useBrowserSurfaceStore.getState().presentContent(tabId, { |
| ... | ... | @@ -36,8 +92,26 @@ describe("browserSurfaceStore", () => { |
| 36 | 92 | expect( |
| 37 | 93 | resolveBrowserSurfacePanelRect( |
| 38 | 94 | { |
| 39 | hidden: { rect: staleRect, visible: false, content: null, updatedAt: 1, owner: null }, | |
| 40 | active: { rect: liveRect, visible: true, content: null, updatedAt: 2, owner: null }, | |
| 95 | hidden: { | |
| 96 | rect: staleRect, | |
| 97 | visible: false, | |
| 98 | content: null, | |
| 99 | fittedSourceContent: null, | |
| 100 | fitSourceContent: false, | |
| 101 | cornerRadius: 0, | |
| 102 | updatedAt: 1, | |
| 103 | owner: null, | |
| 104 | }, | |
| 105 | active: { | |
| 106 | rect: liveRect, | |
| 107 | visible: true, | |
| 108 | content: null, | |
| 109 | fittedSourceContent: null, | |
| 110 | fitSourceContent: false, | |
| 111 | cornerRadius: 0, | |
| 112 | updatedAt: 2, | |
| 113 | owner: null, | |
| 114 | }, | |
| 41 | 115 | }, |
| 42 | 116 | "hidden", |
| 43 | 117 | ), |
| ... | ... | @@ -53,7 +127,7 @@ describe("browserSurfaceStore", () => { |
| 53 | 127 | |
| 54 | 128 | const liveLease = acquireBrowserSurface(tabId); |
| 55 | 129 | liveLease.present(liveRect, true); |
| 56 | staleLease.present(staleRect, true); | |
| 130 | expect(staleLease.present(staleRect, true)).toBe(false); | |
| 57 | 131 | staleLease.release(); |
| 58 | 132 | |
| 59 | 133 | expect(useBrowserSurfaceStore.getState().byTabId[tabId]).toMatchObject({ |
| ... | ... | @@ -75,4 +149,27 @@ describe("browserSurfaceStore", () => { |
| 75 | 149 | owner: null, |
| 76 | 150 | }); |
| 77 | 151 | }); |
| 152 | ||
| 153 | it("clears fitted presentation state when its lease is released", () => { | |
| 154 | const tabId = "released-fitted-browser-surface"; | |
| 155 | const fittedLease = acquireBrowserSurface(tabId, true); | |
| 156 | useBrowserSurfaceStore.getState().presentContent(tabId, { | |
| 157 | x: 0, | |
| 158 | y: 0, | |
| 159 | width: 1_280, | |
| 160 | height: 800, | |
| 161 | scale: 1, | |
| 162 | scrollLeft: 0, | |
| 163 | scrollTop: 0, | |
| 164 | }); | |
| 165 | ||
| 166 | fittedLease.release(); | |
| 167 | ||
| 168 | expect(useBrowserSurfaceStore.getState().byTabId[tabId]).toMatchObject({ | |
| 169 | fittedSourceContent: null, | |
| 170 | fitSourceContent: false, | |
| 171 | owner: null, | |
| 172 | visible: false, | |
| 173 | }); | |
| 174 | }); | |
| 78 | 175 | }); |
apps/web/src/browser/browserSurfaceStore.ts+50-13| ... | ... | @@ -11,6 +11,9 @@ export interface BrowserSurfacePresentation { |
| 11 | 11 | readonly rect: BrowserSurfaceRect | null; |
| 12 | 12 | readonly visible: boolean; |
| 13 | 13 | readonly content: BrowserSurfaceContentPresentation | null; |
| 14 | readonly fittedSourceContent: BrowserSurfaceContentPresentation | null; | |
| 15 | readonly fitSourceContent: boolean; | |
| 16 | readonly cornerRadius: number; | |
| 14 | 17 | readonly updatedAt: number; |
| 15 | 18 | readonly owner: symbol | null; |
| 16 | 19 | } |
| ... | ... | @@ -27,19 +30,20 @@ export interface BrowserSurfaceContentPresentation { |
| 27 | 30 | |
| 28 | 31 | interface BrowserSurfaceStoreState { |
| 29 | 32 | readonly byTabId: Record<string, BrowserSurfacePresentation>; |
| 30 | readonly claim: (tabId: string, owner: symbol) => void; | |
| 33 | readonly claim: (tabId: string, owner: symbol, fitSourceContent: boolean) => void; | |
| 31 | 34 | readonly present: ( |
| 32 | 35 | tabId: string, |
| 33 | 36 | owner: symbol, |
| 34 | 37 | rect: BrowserSurfaceRect, |
| 35 | 38 | visible: boolean, |
| 39 | cornerRadius: number, | |
| 36 | 40 | ) => void; |
| 37 | 41 | readonly presentContent: (tabId: string, content: BrowserSurfaceContentPresentation) => void; |
| 38 | 42 | readonly release: (tabId: string, owner: symbol) => void; |
| 39 | 43 | } |
| 40 | 44 | |
| 41 | 45 | export interface BrowserSurfaceLease { |
| 42 | readonly present: (rect: BrowserSurfaceRect, visible: boolean) => void; | |
| 46 | readonly present: (rect: BrowserSurfaceRect, visible: boolean, cornerRadius?: number) => boolean; | |
| 43 | 47 | readonly release: () => void; |
| 44 | 48 | } |
| 45 | 49 | |
| ... | ... | @@ -72,7 +76,7 @@ const rectEquals = (left: BrowserSurfaceRect | null, right: BrowserSurfaceRect): |
| 72 | 76 | |
| 73 | 77 | export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) => ({ |
| 74 | 78 | byTabId: {}, |
| 75 | claim: (tabId, owner) => | |
| 79 | claim: (tabId, owner, fitSourceContent) => | |
| 76 | 80 | set((state) => { |
| 77 | 81 | const current = state.byTabId[tabId]; |
| 78 | 82 | if (current?.owner === owner) return state; |
| ... | ... | @@ -83,21 +87,31 @@ export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) = |
| 83 | 87 | rect: current?.rect ?? null, |
| 84 | 88 | visible: false, |
| 85 | 89 | content: current?.content ?? null, |
| 90 | fittedSourceContent: fitSourceContent ? (current?.content ?? null) : null, | |
| 91 | fitSourceContent, | |
| 92 | cornerRadius: current?.cornerRadius ?? 0, | |
| 86 | 93 | updatedAt: Date.now(), |
| 87 | 94 | owner, |
| 88 | 95 | }, |
| 89 | 96 | }, |
| 90 | 97 | }; |
| 91 | 98 | }), |
| 92 | present: (tabId, owner, rect, visible) => | |
| 99 | present: (tabId, owner, rect, visible, cornerRadius) => | |
| 93 | 100 | set((state) => { |
| 94 | 101 | const current = state.byTabId[tabId]; |
| 95 | 102 | if (current?.owner !== owner) return state; |
| 96 | if (current && current.visible === visible && rectEquals(current.rect, rect)) return state; | |
| 103 | if ( | |
| 104 | current && | |
| 105 | current.visible === visible && | |
| 106 | current.cornerRadius === cornerRadius && | |
| 107 | rectEquals(current.rect, rect) | |
| 108 | ) { | |
| 109 | return state; | |
| 110 | } | |
| 97 | 111 | return { |
| 98 | 112 | byTabId: { |
| 99 | 113 | ...state.byTabId, |
| 100 | [tabId]: { ...current, rect, visible, updatedAt: Date.now() }, | |
| 114 | [tabId]: { ...current, rect, visible, cornerRadius, updatedAt: Date.now() }, | |
| 101 | 115 | }, |
| 102 | 116 | }; |
| 103 | 117 | }), |
| ... | ... | @@ -112,6 +126,9 @@ export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) = |
| 112 | 126 | rect: null, |
| 113 | 127 | visible: false, |
| 114 | 128 | content, |
| 129 | fittedSourceContent: null, | |
| 130 | fitSourceContent: false, | |
| 131 | cornerRadius: 0, | |
| 115 | 132 | updatedAt: Date.now(), |
| 116 | 133 | owner: null, |
| 117 | 134 | }, |
| ... | ... | @@ -134,7 +151,15 @@ export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) = |
| 134 | 151 | return { |
| 135 | 152 | byTabId: { |
| 136 | 153 | ...state.byTabId, |
| 137 | [tabId]: { ...current, content, updatedAt: Date.now() }, | |
| 154 | [tabId]: { | |
| 155 | ...current, | |
| 156 | content, | |
| 157 | fittedSourceContent: | |
| 158 | current.fitSourceContent && current.fittedSourceContent === null | |
| 159 | ? content | |
| 160 | : current.fittedSourceContent, | |
| 161 | updatedAt: Date.now(), | |
| 162 | }, | |
| 138 | 163 | }, |
| 139 | 164 | }; |
| 140 | 165 | }), |
| ... | ... | @@ -145,21 +170,33 @@ export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) = |
| 145 | 170 | return { |
| 146 | 171 | byTabId: { |
| 147 | 172 | ...state.byTabId, |
| 148 | [tabId]: { ...current, visible: false, updatedAt: Date.now(), owner: null }, | |
| 173 | [tabId]: { | |
| 174 | ...current, | |
| 175 | visible: false, | |
| 176 | fittedSourceContent: null, | |
| 177 | fitSourceContent: false, | |
| 178 | updatedAt: Date.now(), | |
| 179 | owner: null, | |
| 180 | }, | |
| 149 | 181 | }, |
| 150 | 182 | }; |
| 151 | 183 | }), |
| 152 | 184 | })); |
| 153 | 185 | |
| 154 | export function acquireBrowserSurface(tabId: string): BrowserSurfaceLease { | |
| 186 | export function acquireBrowserSurface( | |
| 187 | tabId: string, | |
| 188 | fitSourceContent = false, | |
| 189 | ): BrowserSurfaceLease { | |
| 155 | 190 | const owner = Symbol(`browser-surface:${tabId}`); |
| 156 | 191 | let released = false; |
| 157 | useBrowserSurfaceStore.getState().claim(tabId, owner); | |
| 192 | useBrowserSurfaceStore.getState().claim(tabId, owner, fitSourceContent); | |
| 158 | 193 | |
| 159 | 194 | return { |
| 160 | present: (rect, visible) => { | |
| 161 | if (released) return; | |
| 162 | useBrowserSurfaceStore.getState().present(tabId, owner, rect, visible); | |
| 195 | present: (rect, visible, cornerRadius = 0) => { | |
| 196 | if (released) return false; | |
| 197 | if (useBrowserSurfaceStore.getState().byTabId[tabId]?.owner !== owner) return false; | |
| 198 | useBrowserSurfaceStore.getState().present(tabId, owner, rect, visible, cornerRadius); | |
| 199 | return true; | |
| 163 | 200 | }, |
| 164 | 201 | release: () => { |
| 165 | 202 | if (released) return; |
apps/web/src/browser/desktopTabLifetime.test.ts+64-3| ... | ... | @@ -1,20 +1,32 @@ |
| 1 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 2 | 2 | |
| 3 | const { closeTab, createTab } = vi.hoisted(() => ({ | |
| 4 | closeTab: vi.fn(async () => undefined), | |
| 3 | const { closeTab, createTab, stopBrowserRecording } = vi.hoisted(() => ({ | |
| 4 | closeTab: vi.fn<(tabId: string) => Promise<void>>(async () => undefined), | |
| 5 | 5 | createTab: vi.fn<() => Promise<void>>(), |
| 6 | stopBrowserRecording: vi.fn(async () => null), | |
| 6 | 7 | })); |
| 7 | 8 | |
| 8 | 9 | vi.mock("~/components/preview/previewBridge", () => ({ |
| 9 | 10 | previewBridge: { closeTab, createTab }, |
| 10 | 11 | })); |
| 11 | 12 | |
| 13 | vi.mock("./browserRecording", () => ({ | |
| 14 | stopBrowserRecording, | |
| 15 | })); | |
| 16 | ||
| 12 | 17 | import { acquireDesktopTab } from "./desktopTabLifetime"; |
| 13 | 18 | |
| 14 | 19 | describe("desktopTabLifetime", () => { |
| 15 | 20 | beforeEach(() => { |
| 16 | 21 | closeTab.mockClear(); |
| 17 | 22 | createTab.mockClear(); |
| 23 | stopBrowserRecording.mockClear(); | |
| 24 | vi.stubGlobal("window", globalThis); | |
| 25 | }); | |
| 26 | ||
| 27 | afterEach(() => { | |
| 28 | vi.useRealTimers(); | |
| 29 | vi.unstubAllGlobals(); | |
| 18 | 30 | }); |
| 19 | 31 | |
| 20 | 32 | it("shares tab creation readiness across concurrent leases", async () => { |
| ... | ... | @@ -42,4 +54,53 @@ describe("desktopTabLifetime", () => { |
| 42 | 54 | await first.ready; |
| 43 | 55 | expect(ready).toBe(true); |
| 44 | 56 | }); |
| 57 | ||
| 58 | it("stops recording before closing the final desktop tab lease", async () => { | |
| 59 | vi.useFakeTimers(); | |
| 60 | let resolveStop: (() => void) | undefined; | |
| 61 | stopBrowserRecording.mockReturnValueOnce( | |
| 62 | new Promise<null>((resolve) => { | |
| 63 | resolveStop = () => resolve(null); | |
| 64 | }), | |
| 65 | ); | |
| 66 | createTab.mockResolvedValueOnce(undefined); | |
| 67 | ||
| 68 | const lease = acquireDesktopTab("tab_recording_cleanup"); | |
| 69 | await lease.ready; | |
| 70 | lease.release(); | |
| 71 | await vi.advanceTimersByTimeAsync(0); | |
| 72 | ||
| 73 | expect(stopBrowserRecording).toHaveBeenCalledWith("tab_recording_cleanup"); | |
| 74 | expect(closeTab).not.toHaveBeenCalled(); | |
| 75 | ||
| 76 | resolveStop?.(); | |
| 77 | await Promise.resolve(); | |
| 78 | await Promise.resolve(); | |
| 79 | expect(closeTab).toHaveBeenCalledWith("tab_recording_cleanup"); | |
| 80 | }); | |
| 81 | ||
| 82 | it("waits for an in-flight close before recreating a reacquired tab", async () => { | |
| 83 | vi.useFakeTimers(); | |
| 84 | let resolveClose: (() => void) | undefined; | |
| 85 | createTab.mockResolvedValue(undefined); | |
| 86 | closeTab.mockReturnValueOnce( | |
| 87 | new Promise<void>((resolve) => { | |
| 88 | resolveClose = resolve; | |
| 89 | }), | |
| 90 | ); | |
| 91 | ||
| 92 | const initial = acquireDesktopTab("tab_close_reacquire"); | |
| 93 | await initial.ready; | |
| 94 | initial.release(); | |
| 95 | await vi.advanceTimersByTimeAsync(0); | |
| 96 | ||
| 97 | expect(closeTab).toHaveBeenCalledWith("tab_close_reacquire"); | |
| 98 | ||
| 99 | const reacquired = acquireDesktopTab("tab_close_reacquire"); | |
| 100 | expect(createTab).toHaveBeenCalledTimes(1); | |
| 101 | ||
| 102 | resolveClose?.(); | |
| 103 | await reacquired.ready; | |
| 104 | expect(createTab).toHaveBeenCalledTimes(2); | |
| 105 | }); | |
| 45 | 106 | }); |
apps/web/src/browser/desktopTabLifetime.ts+27-2| ... | ... | @@ -1,5 +1,7 @@ |
| 1 | 1 | import { previewBridge } from "~/components/preview/previewBridge"; |
| 2 | 2 | |
| 3 | import { stopBrowserRecording } from "./browserRecording"; | |
| 4 | ||
| 3 | 5 | interface DesktopTabLease { |
| 4 | 6 | references: number; |
| 5 | 7 | closeTimer: number | null; |
| ... | ... | @@ -7,6 +9,26 @@ interface DesktopTabLease { |
| 7 | 9 | } |
| 8 | 10 | |
| 9 | 11 | const leases = new Map<string, DesktopTabLease>(); |
| 12 | const pendingTabOperations = new Map<string, Promise<void>>(); | |
| 13 | ||
| 14 | const enqueueDesktopTabOperation = ( | |
| 15 | tabId: string, | |
| 16 | operation: () => Promise<void> | void, | |
| 17 | ): Promise<void> => { | |
| 18 | const previous = pendingTabOperations.get(tabId); | |
| 19 | const pending = previous | |
| 20 | ? previous.catch(() => undefined).then(operation) | |
| 21 | : Promise.resolve(operation()); | |
| 22 | pendingTabOperations.set(tabId, pending); | |
| 23 | void pending | |
| 24 | .finally(() => { | |
| 25 | if (pendingTabOperations.get(tabId) === pending) { | |
| 26 | pendingTabOperations.delete(tabId); | |
| 27 | } | |
| 28 | }) | |
| 29 | .catch(() => undefined); | |
| 30 | return pending; | |
| 31 | }; | |
| 10 | 32 | |
| 11 | 33 | export interface AcquiredDesktopTab { |
| 12 | 34 | readonly ready: Promise<void>; |
| ... | ... | @@ -19,7 +41,7 @@ export function acquireDesktopTab(tabId: string): AcquiredDesktopTab { |
| 19 | 41 | ({ |
| 20 | 42 | references: 0, |
| 21 | 43 | closeTimer: null, |
| 22 | ready: previewBridge?.createTab(tabId) ?? Promise.resolve(), | |
| 44 | ready: enqueueDesktopTabOperation(tabId, () => previewBridge?.createTab(tabId)), | |
| 23 | 45 | } satisfies DesktopTabLease); |
| 24 | 46 | if (current.closeTimer !== null) window.clearTimeout(current.closeTimer); |
| 25 | 47 | current.references += 1; |
| ... | ... | @@ -37,7 +59,10 @@ export function acquireDesktopTab(tabId: string): AcquiredDesktopTab { |
| 37 | 59 | const latest = leases.get(tabId); |
| 38 | 60 | if (!latest || latest.references > 0) return; |
| 39 | 61 | leases.delete(tabId); |
| 40 | void previewBridge?.closeTab(tabId); | |
| 62 | void enqueueDesktopTabOperation(tabId, async () => { | |
| 63 | await stopBrowserRecording(tabId).catch(() => null); | |
| 64 | await previewBridge?.closeTab(tabId); | |
| 65 | }).catch(() => undefined); | |
| 41 | 66 | }, 0); |
| 42 | 67 | }, |
| 43 | 68 | }; |
apps/web/src/browser/hostedBrowserWebviewStyle.test.ts+17| ... | ... | @@ -23,6 +23,23 @@ describe("resolveHostedBrowserWebviewWrapperStyle", () => { |
| 23 | 23 | }); |
| 24 | 24 | }); |
| 25 | 25 | |
| 26 | it("clips a floating webview to the mini-player frame", () => { | |
| 27 | expect( | |
| 28 | resolveHostedBrowserWebviewWrapperStyle({ | |
| 29 | active: true, | |
| 30 | cornerRadius: 12, | |
| 31 | rect: { x: 12, y: 34, width: 360, height: 203 }, | |
| 32 | hiddenSize: { width: 1280, height: 800 }, | |
| 33 | }), | |
| 34 | ).toMatchObject({ | |
| 35 | left: 12, | |
| 36 | top: 34, | |
| 37 | width: 360, | |
| 38 | height: 203, | |
| 39 | borderRadius: 12, | |
| 40 | }); | |
| 41 | }); | |
| 42 | ||
| 26 | 43 | it("keeps an inactive webview paintable while moving it offscreen", () => { |
| 27 | 44 | const style = resolveHostedBrowserWebviewWrapperStyle({ |
| 28 | 45 | active: false, |
apps/web/src/browser/hostedBrowserWebviewStyle.ts+4-1| ... | ... | @@ -12,6 +12,7 @@ export interface HostedBrowserWebviewWrapperStyle { |
| 12 | 12 | readonly height: number; |
| 13 | 13 | readonly zIndex: number; |
| 14 | 14 | readonly pointerEvents: "auto" | "none"; |
| 15 | readonly borderRadius?: number; | |
| 15 | 16 | readonly visibility?: "visible"; |
| 16 | 17 | } |
| 17 | 18 | |
| ... | ... | @@ -19,10 +20,11 @@ export const HIDDEN_BROWSER_WEBVIEW_OFFSET = -100_000; |
| 19 | 20 | |
| 20 | 21 | export function resolveHostedBrowserWebviewWrapperStyle(input: { |
| 21 | 22 | readonly active: boolean; |
| 23 | readonly cornerRadius?: number; | |
| 22 | 24 | readonly rect: BrowserSurfaceRect | null; |
| 23 | 25 | readonly hiddenSize: HostedBrowserWebviewSize; |
| 24 | 26 | }): HostedBrowserWebviewWrapperStyle { |
| 25 | const { active, hiddenSize, rect } = input; | |
| 27 | const { active, cornerRadius = 0, hiddenSize, rect } = input; | |
| 26 | 28 | if (active && rect) { |
| 27 | 29 | return { |
| 28 | 30 | left: rect.x, |
| ... | ... | @@ -31,6 +33,7 @@ export function resolveHostedBrowserWebviewWrapperStyle(input: { |
| 31 | 33 | height: rect.height, |
| 32 | 34 | zIndex: 30, |
| 33 | 35 | pointerEvents: "auto", |
| 36 | ...(cornerRadius > 0 ? { borderRadius: cornerRadius } : {}), | |
| 34 | 37 | }; |
| 35 | 38 | } |
| 36 | 39 |
apps/web/src/browser/webviewCrashRecovery.test.ts created+41| ... | ... | @@ -0,0 +1,41 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { | |
| 4 | INITIAL_WEBVIEW_CRASH_RECOVERY_STATE, | |
| 5 | planWebviewCrashRecovery, | |
| 6 | WEBVIEW_CRASH_RECOVERY_WINDOW_MS, | |
| 7 | } from "./webviewCrashRecovery"; | |
| 8 | ||
| 9 | describe("planWebviewCrashRecovery", () => { | |
| 10 | it("backs off and stops after a bounded number of rapid crashes", () => { | |
| 11 | const first = planWebviewCrashRecovery(INITIAL_WEBVIEW_CRASH_RECOVERY_STATE, 1_000); | |
| 12 | expect(first).not.toBeNull(); | |
| 13 | expect(first?.delayMs).toBe(250); | |
| 14 | ||
| 15 | const second = planWebviewCrashRecovery(first!.state, 1_100); | |
| 16 | expect(second).not.toBeNull(); | |
| 17 | expect(second?.delayMs).toBe(500); | |
| 18 | ||
| 19 | const third = planWebviewCrashRecovery(second!.state, 1_200); | |
| 20 | expect(third).not.toBeNull(); | |
| 21 | expect(third?.delayMs).toBe(1_000); | |
| 22 | ||
| 23 | expect(planWebviewCrashRecovery(third!.state, 1_300)).toBeNull(); | |
| 24 | }); | |
| 25 | ||
| 26 | it("allows recovery again after the crash window expires", () => { | |
| 27 | const first = planWebviewCrashRecovery(INITIAL_WEBVIEW_CRASH_RECOVERY_STATE, 1_000)!; | |
| 28 | const second = planWebviewCrashRecovery(first.state, 1_100)!; | |
| 29 | const third = planWebviewCrashRecovery(second.state, 1_200)!; | |
| 30 | ||
| 31 | expect(planWebviewCrashRecovery(third.state, 1_000 + WEBVIEW_CRASH_RECOVERY_WINDOW_MS)).toEqual( | |
| 32 | { | |
| 33 | delayMs: 250, | |
| 34 | state: { | |
| 35 | attempts: 1, | |
| 36 | windowStartedAt: 1_000 + WEBVIEW_CRASH_RECOVERY_WINDOW_MS, | |
| 37 | }, | |
| 38 | }, | |
| 39 | ); | |
| 40 | }); | |
| 41 | }); |
apps/web/src/browser/webviewCrashRecovery.ts created+38| ... | ... | @@ -0,0 +1,38 @@ |
| 1 | export const WEBVIEW_CRASH_RECOVERY_WINDOW_MS = 30_000; | |
| 2 | export const WEBVIEW_CRASH_RECOVERY_MAX_ATTEMPTS = 3; | |
| 3 | export const WEBVIEW_CRASH_RECOVERY_BASE_DELAY_MS = 250; | |
| 4 | ||
| 5 | export interface WebviewCrashRecoveryState { | |
| 6 | readonly attempts: number; | |
| 7 | readonly windowStartedAt: number | null; | |
| 8 | } | |
| 9 | ||
| 10 | export interface WebviewCrashRecoveryPlan { | |
| 11 | readonly delayMs: number; | |
| 12 | readonly state: WebviewCrashRecoveryState; | |
| 13 | } | |
| 14 | ||
| 15 | export const INITIAL_WEBVIEW_CRASH_RECOVERY_STATE: WebviewCrashRecoveryState = { | |
| 16 | attempts: 0, | |
| 17 | windowStartedAt: null, | |
| 18 | }; | |
| 19 | ||
| 20 | export function planWebviewCrashRecovery( | |
| 21 | state: WebviewCrashRecoveryState, | |
| 22 | now: number, | |
| 23 | ): WebviewCrashRecoveryPlan | null { | |
| 24 | const startsNewWindow = | |
| 25 | state.windowStartedAt === null || | |
| 26 | now - state.windowStartedAt >= WEBVIEW_CRASH_RECOVERY_WINDOW_MS; | |
| 27 | const attempts = startsNewWindow ? 0 : state.attempts; | |
| 28 | if (attempts >= WEBVIEW_CRASH_RECOVERY_MAX_ATTEMPTS) return null; | |
| 29 | ||
| 30 | const nextAttempts = attempts + 1; | |
| 31 | return { | |
| 32 | delayMs: WEBVIEW_CRASH_RECOVERY_BASE_DELAY_MS * 2 ** attempts, | |
| 33 | state: { | |
| 34 | attempts: nextAttempts, | |
| 35 | windowStartedAt: startsNewWindow ? now : state.windowStartedAt, | |
| 36 | }, | |
| 37 | }; | |
| 38 | } |
apps/web/src/components/ChatView.tsx+35| ... | ... | @@ -131,8 +131,13 @@ import { |
| 131 | 131 | } from "../previewStateStore"; |
| 132 | 132 | import { addBrowserSurface } from "./preview/addBrowserSurface"; |
| 133 | 133 | import { closePreviewSession } from "./preview/closePreviewSession"; |
| 134 | import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer"; | |
| 134 | 135 | import { subscribePreviewAction } from "./preview/previewActionBus"; |
| 135 | 136 | import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; |
| 137 | import { | |
| 138 | selectThreadPreviewMiniPlayer, | |
| 139 | usePreviewMiniPlayerStore, | |
| 140 | } from "../previewMiniPlayerStore"; | |
| 136 | 141 | import { RightPanelTabs } from "./RightPanelTabs"; |
| 137 | 142 | import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; |
| 138 | 143 | import { BranchToolbar } from "./BranchToolbar"; |
| ... | ... | @@ -1484,6 +1489,9 @@ function ChatViewContent(props: ChatViewProps) { |
| 1484 | 1489 | const activeFileSurface = |
| 1485 | 1490 | activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null; |
| 1486 | 1491 | const activePreviewState = useThreadPreviewState(activeThreadRef); |
| 1492 | const activePreviewMiniPlayer = usePreviewMiniPlayerStore((state) => | |
| 1493 | selectThreadPreviewMiniPlayer(state.byThreadKey, activeThreadRef), | |
| 1494 | ); | |
| 1487 | 1495 | const panelTerminalIds = useMemo( |
| 1488 | 1496 | () => |
| 1489 | 1497 | new Set( |
| ... | ... | @@ -1507,6 +1515,24 @@ function ChatViewContent(props: ChatViewProps) { |
| 1507 | 1515 | .reconcileBrowserSurfaces(activeThreadRef, Object.keys(activePreviewState.sessions)); |
| 1508 | 1516 | }, [activePreviewState.sessions, activeThreadRef]); |
| 1509 | 1517 | |
| 1518 | useEffect(() => { | |
| 1519 | if (!activeThreadRef || !activePreviewMiniPlayer) return; | |
| 1520 | const miniTabStillExists = Boolean(activePreviewState.sessions[activePreviewMiniPlayer.tabId]); | |
| 1521 | const sameTabOpenInPanel = | |
| 1522 | previewPanelOpen && | |
| 1523 | activeRightPanelSurface?.kind === "preview" && | |
| 1524 | activeRightPanelSurface.resourceId === activePreviewMiniPlayer.tabId; | |
| 1525 | if (!miniTabStillExists || sameTabOpenInPanel) { | |
| 1526 | usePreviewMiniPlayerStore.getState().close(activeThreadRef); | |
| 1527 | } | |
| 1528 | }, [ | |
| 1529 | activePreviewMiniPlayer, | |
| 1530 | activePreviewState.sessions, | |
| 1531 | activeRightPanelSurface, | |
| 1532 | activeThreadRef, | |
| 1533 | previewPanelOpen, | |
| 1534 | ]); | |
| 1535 | ||
| 1510 | 1536 | const planSidebarOpen = activeRightPanelKind === "plan"; |
| 1511 | 1537 | |
| 1512 | 1538 | const existingOpenTerminalThreadKeys = useMemo(() => { |
| ... | ... | @@ -5907,6 +5933,15 @@ function ChatViewContent(props: ChatViewProps) { |
| 5907 | 5933 | </div> |
| 5908 | 5934 | </div> |
| 5909 | 5935 | |
| 5936 | {activeThreadRef && activePreviewMiniPlayer ? ( | |
| 5937 | <ThreadPreviewMiniPlayer | |
| 5938 | key={`${activeThreadKey}:${activePreviewMiniPlayer.tabId}`} | |
| 5939 | threadRef={activeThreadRef} | |
| 5940 | tabId={activePreviewMiniPlayer.tabId} | |
| 5941 | bottomInset={isDraftHeroState ? 0 : composerOverlayHeight} | |
| 5942 | /> | |
| 5943 | ) : null} | |
| 5944 | ||
| 5910 | 5945 | <AlertDialog open={branchRestoreConfirmOpen} onOpenChange={setBranchRestoreConfirmOpen}> |
| 5911 | 5946 | <AlertDialogPopup> |
| 5912 | 5947 | <AlertDialogHeader> |
apps/web/src/components/preview/PreviewAutomationHosts.tsx+14-9| ... | ... | @@ -29,10 +29,10 @@ import { |
| 29 | 29 | reconcilePreviewServerSessions, |
| 30 | 30 | updatePreviewServerSnapshot, |
| 31 | 31 | } from "~/previewStateStore"; |
| 32 | import { useRightPanelStore } from "~/rightPanelStore"; | |
| 32 | import { usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; | |
| 33 | 33 | import { resolveBrowserNavigationTarget } from "~/browser/browserTargetResolver"; |
| 34 | 34 | import { |
| 35 | readActiveBrowserRecordingTabId, | |
| 35 | readActiveBrowserRecordingTabIds, | |
| 36 | 36 | startBrowserRecording, |
| 37 | 37 | stopBrowserRecording, |
| 38 | 38 | } from "~/browser/browserRecording"; |
| ... | ... | @@ -53,7 +53,10 @@ import { |
| 53 | 53 | PreviewAutomationTargetUnavailableError, |
| 54 | 54 | PreviewAutomationViewportTimeoutError, |
| 55 | 55 | } from "./previewAutomationErrors"; |
| 56 | import { previewAutomationOpenNeedsOverlay } from "./previewAutomationOpenReadiness"; | |
| 56 | import { | |
| 57 | previewAutomationOpenNeedsOverlay, | |
| 58 | shouldOpenPreviewMiniPlayer, | |
| 59 | } from "./previewAutomationOpenReadiness"; | |
| 57 | 60 | import { createPreviewAutomationRequestConsumerAtom } from "./previewAutomationRequestConsumer"; |
| 58 | 61 | import { createPreviewAutomationClientId } from "./previewAutomationClientId"; |
| 59 | 62 | import { |
| ... | ... | @@ -319,7 +322,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) |
| 319 | 322 | if (result._tag === "Failure") { |
| 320 | 323 | return raiseAtomCommandFailure(result); |
| 321 | 324 | } |
| 322 | reconcilePreviewServerSessions(threadRef, result.value.sessions); | |
| 325 | reconcilePreviewServerSessions(threadRef, result.value); | |
| 323 | 326 | state = readThreadPreviewState(threadRef); |
| 324 | 327 | } |
| 325 | 328 | tabId = request.tabId ?? state.snapshot?.tabId ?? null; |
| ... | ... | @@ -378,8 +381,8 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) |
| 378 | 381 | activeSnapshot = snapshot; |
| 379 | 382 | tabId = activeTabId; |
| 380 | 383 | } |
| 381 | if (input.show ?? true) { | |
| 382 | useRightPanelStore.getState().openBrowser(threadRef, activeTabId); | |
| 384 | if (shouldOpenPreviewMiniPlayer(input)) { | |
| 385 | usePreviewMiniPlayerStore.getState().open(threadRef, activeTabId); | |
| 383 | 386 | } |
| 384 | 387 | if (activeSnapshot && previewAutomationOpenNeedsOverlay(input, activeSnapshot)) { |
| 385 | 388 | await waitForDesktopOverlay( |
| ... | ... | @@ -510,7 +513,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) |
| 510 | 513 | } |
| 511 | 514 | case "recordingStart": { |
| 512 | 515 | const ready = await requireReadyTab(); |
| 513 | const startedAt = await startBrowserRecording(ready.tabId); | |
| 516 | const startedAt = await startBrowserRecording(ready.tabId, threadRef); | |
| 514 | 517 | return { |
| 515 | 518 | tabId: ready.tabId, |
| 516 | 519 | recording: true, |
| ... | ... | @@ -518,11 +521,13 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) |
| 518 | 521 | }; |
| 519 | 522 | } |
| 520 | 523 | case "recordingStop": { |
| 521 | const recordingTabId = readActiveBrowserRecordingTabId(); | |
| 524 | const activeTabIds = readActiveBrowserRecordingTabIds(threadRef); | |
| 522 | 525 | const stopTabId = resolveBrowserRecordingStopTarget( |
| 523 | recordingTabId, | |
| 526 | activeTabIds, | |
| 527 | tabId, | |
| 524 | 528 | request.tabIdExplicit ? request.tabId : undefined, |
| 525 | 529 | ); |
| 530 | tabId = stopTabId ?? tabId; | |
| 526 | 531 | const artifact = stopTabId ? await stopBrowserRecording(stopTabId) : null; |
| 527 | 532 | if (!artifact) { |
| 528 | 533 | return raisePreviewAutomationHostError( |
apps/web/src/components/preview/PreviewChromeRow.tsx+31| ... | ... | @@ -4,6 +4,7 @@ import { |
| 4 | 4 | Camera, |
| 5 | 5 | ExternalLink, |
| 6 | 6 | MousePointerClick, |
| 7 | PictureInPicture2, | |
| 7 | 8 | RotateCw, |
| 8 | 9 | } from "lucide-react"; |
| 9 | 10 | import { |
| ... | ... | @@ -40,6 +41,9 @@ interface Props { |
| 40 | 41 | onCapture?: ((record: boolean) => void) | undefined; |
| 41 | 42 | captureDisabled?: boolean | undefined; |
| 42 | 43 | recording?: boolean | undefined; |
| 44 | onPictureInPicture?: (() => void) | undefined; | |
| 45 | pictureInPicture?: boolean | undefined; | |
| 46 | pictureInPictureDisabled?: boolean | undefined; | |
| 43 | 47 | /** |
| 44 | 48 | * When provided, renders an annotation-mode toggle button to the right of |
| 45 | 49 | * the URL input. Pressed while annotation mode is active (button shows in `pressed` |
| ... | ... | @@ -77,6 +81,9 @@ export function PreviewChromeRow({ |
| 77 | 81 | onCapture, |
| 78 | 82 | captureDisabled, |
| 79 | 83 | recording, |
| 84 | onPictureInPicture, | |
| 85 | pictureInPicture, | |
| 86 | pictureInPictureDisabled, | |
| 80 | 87 | onPickElement, |
| 81 | 88 | pickActive, |
| 82 | 89 | pickDisabled, |
| ... | ... | @@ -274,6 +281,30 @@ export function PreviewChromeRow({ |
| 274 | 281 | </TooltipPopup> |
| 275 | 282 | </Tooltip> |
| 276 | 283 | ) : null} |
| 284 | {onPictureInPicture ? ( | |
| 285 | <Tooltip> | |
| 286 | <TooltipTrigger | |
| 287 | render={ | |
| 288 | <Button | |
| 289 | variant={pictureInPicture ? "secondary" : "ghost"} | |
| 290 | size="icon-xs" | |
| 291 | onClick={onPictureInPicture} | |
| 292 | aria-label={ | |
| 293 | pictureInPicture ? "Close floating preview" : "Float preview over chat" | |
| 294 | } | |
| 295 | aria-pressed={pictureInPicture ? "true" : "false"} | |
| 296 | type="button" | |
| 297 | disabled={pictureInPictureDisabled} | |
| 298 | /> | |
| 299 | } | |
| 300 | > | |
| 301 | <PictureInPicture2 className={cn(pictureInPicture && "text-primary")} /> | |
| 302 | </TooltipTrigger> | |
| 303 | <TooltipPopup> | |
| 304 | {pictureInPicture ? "Close floating preview" : "Float preview over chat"} | |
| 305 | </TooltipPopup> | |
| 306 | </Tooltip> | |
| 307 | ) : null} | |
| 277 | 308 | {trailingActions} |
| 278 | 309 | </form> |
| 279 | 310 | {loadProgress > 0 ? ( |
apps/web/src/components/preview/PreviewMoreMenu.tsx+11| ... | ... | @@ -46,6 +46,10 @@ interface Props { |
| 46 | 46 | deviceToolbarVisible: boolean; |
| 47 | 47 | /** Switches between fill-panel mode and a fixed responsive viewport. */ |
| 48 | 48 | onToggleDeviceToolbar: () => void; |
| 49 | /** Whether the separate native always-on-top preview window is open. */ | |
| 50 | nativePictureInPicture: boolean; | |
| 51 | /** Toggles the optional native always-on-top preview window. */ | |
| 52 | onNativePictureInPicture: () => void; | |
| 49 | 53 | } |
| 50 | 54 | |
| 51 | 55 | /** |
| ... | ... | @@ -60,6 +64,8 @@ export function PreviewMoreMenu({ |
| 60 | 64 | colorScheme, |
| 61 | 65 | deviceToolbarVisible, |
| 62 | 66 | onToggleDeviceToolbar, |
| 67 | nativePictureInPicture, | |
| 68 | onNativePictureInPicture, | |
| 63 | 69 | }: Props) { |
| 64 | 70 | if (!previewBridge) return null; |
| 65 | 71 | const bridge = previewBridge; |
| ... | ... | @@ -93,6 +99,11 @@ export function PreviewMoreMenu({ |
| 93 | 99 | <MenuItem onClick={callTab(bridge.openDevTools)} disabled={tabDisabled}> |
| 94 | 100 | Open DevTools |
| 95 | 101 | </MenuItem> |
| 102 | <MenuItem onClick={onNativePictureInPicture} disabled={tabDisabled}> | |
| 103 | {nativePictureInPicture | |
| 104 | ? "Close separate preview window" | |
| 105 | : "Open separate preview window"} | |
| 106 | </MenuItem> | |
| 96 | 107 | <MenuItem onClick={onToggleDeviceToolbar} disabled={tabDisabled}> |
| 97 | 108 | {deviceToolbarVisible ? "Hide device toolbar" : "Show device toolbar"} |
| 98 | 109 | </MenuItem> |
apps/web/src/components/preview/PreviewView.test.tsx+125-4| ... | ... | @@ -8,6 +8,16 @@ const mocks = vi.hoisted(() => ({ |
| 8 | 8 | readPreparedConnection: vi.fn(() => ({ httpBaseUrl: "http://172.25.85.75:3773" })), |
| 9 | 9 | submittedUrl: null as ((url: string) => void) | null, |
| 10 | 10 | emptyStateUrl: null as ((url: string) => void) | null, |
| 11 | togglePictureInPicture: null as (() => void) | null, | |
| 12 | toggleNativePictureInPicture: null as (() => void) | null, | |
| 13 | pictureInPicturePressed: false, | |
| 14 | miniPlayerTabId: null as string | null, | |
| 15 | openMiniPlayer: vi.fn(), | |
| 16 | closeMiniPlayer: vi.fn(), | |
| 17 | closeRightPanel: vi.fn(), | |
| 18 | openPictureInPicture: vi.fn(async (_tabId: string): Promise<void> => undefined), | |
| 19 | closePictureInPicture: vi.fn(async (_tabId: string): Promise<void> => undefined), | |
| 20 | pictureInPicture: false, | |
| 11 | 21 | showEmptyState: false, |
| 12 | 22 | })); |
| 13 | 23 | |
| ... | ... | @@ -36,10 +46,12 @@ vi.mock("~/previewStateStore", () => ({ |
| 36 | 46 | activeTabId: "tab-1", |
| 37 | 47 | desktopByTabId: { |
| 38 | 48 | "tab-1": { |
| 49 | hasWebContents: true, | |
| 39 | 50 | canGoBack: false, |
| 40 | 51 | canGoForward: false, |
| 41 | 52 | loading: false, |
| 42 | 53 | zoomFactor: 1, |
| 54 | pictureInPicture: mocks.pictureInPicture, | |
| 43 | 55 | colorScheme: "system", |
| 44 | 56 | controller: "none", |
| 45 | 57 | }, |
| ... | ... | @@ -80,7 +92,7 @@ vi.mock("~/state/use-atom-command", () => ({ |
| 80 | 92 | vi.mock("~/browser/browserRecording", () => ({ |
| 81 | 93 | startBrowserRecording: vi.fn(), |
| 82 | 94 | stopBrowserRecording: vi.fn(), |
| 83 | useActiveBrowserRecordingTabId: () => null, | |
| 95 | useActiveBrowserRecordingTabIds: () => new Set(), | |
| 84 | 96 | })); |
| 85 | 97 | |
| 86 | 98 | vi.mock("~/browser/browserSurfaceStore", () => ({ |
| ... | ... | @@ -89,18 +101,69 @@ vi.mock("~/browser/browserSurfaceStore", () => ({ |
| 89 | 101 | ) => select({ byTabId: {} }), |
| 90 | 102 | })); |
| 91 | 103 | |
| 104 | vi.mock("~/previewMiniPlayerStore", () => { | |
| 105 | const usePreviewMiniPlayerStore = Object.assign( | |
| 106 | (select: (state: unknown) => unknown) => | |
| 107 | select({ | |
| 108 | byThreadKey: mocks.miniPlayerTabId | |
| 109 | ? { | |
| 110 | "environment-1:thread-1": { | |
| 111 | tabId: mocks.miniPlayerTabId, | |
| 112 | position: null, | |
| 113 | }, | |
| 114 | } | |
| 115 | : {}, | |
| 116 | }), | |
| 117 | { | |
| 118 | getState: () => ({ | |
| 119 | open: mocks.openMiniPlayer, | |
| 120 | close: mocks.closeMiniPlayer, | |
| 121 | }), | |
| 122 | }, | |
| 123 | ); | |
| 124 | return { | |
| 125 | selectThreadPreviewMiniPlayer: ( | |
| 126 | byThreadKey: Record<string, { tabId: string; position: null }>, | |
| 127 | ) => byThreadKey["environment-1:thread-1"] ?? null, | |
| 128 | usePreviewMiniPlayerStore, | |
| 129 | }; | |
| 130 | }); | |
| 131 | ||
| 132 | vi.mock("~/rightPanelStore", () => ({ | |
| 133 | useRightPanelStore: { | |
| 134 | getState: () => ({ close: mocks.closeRightPanel }), | |
| 135 | }, | |
| 136 | })); | |
| 137 | ||
| 92 | 138 | vi.mock("~/components/ui/toast", () => ({ |
| 93 | 139 | stackedThreadToast: vi.fn(), |
| 94 | 140 | toastManager: { add: vi.fn() }, |
| 95 | 141 | })); |
| 96 | 142 | |
| 97 | 143 | vi.mock("./previewBridge", () => ({ |
| 98 | previewBridge: { navigate: mocks.navigate }, | |
| 144 | previewBridge: { | |
| 145 | navigate: mocks.navigate, | |
| 146 | pictureInPicture: { | |
| 147 | open: mocks.openPictureInPicture, | |
| 148 | close: mocks.closePictureInPicture, | |
| 149 | }, | |
| 150 | }, | |
| 99 | 151 | })); |
| 100 | 152 | |
| 101 | 153 | vi.mock("./PreviewChromeRow", () => ({ |
| 102 | PreviewChromeRow: (props: { onSubmit: (url: string) => void }) => { | |
| 154 | PreviewChromeRow: (props: { | |
| 155 | onSubmit: (url: string) => void; | |
| 156 | onPictureInPicture?: () => void; | |
| 157 | pictureInPicture?: boolean; | |
| 158 | trailingActions?: { | |
| 159 | props: { onNativePictureInPicture?: () => void }; | |
| 160 | }; | |
| 161 | }) => { | |
| 103 | 162 | mocks.submittedUrl = props.onSubmit; |
| 163 | mocks.togglePictureInPicture = props.onPictureInPicture ?? null; | |
| 164 | mocks.toggleNativePictureInPicture = | |
| 165 | props.trailingActions?.props.onNativePictureInPicture ?? null; | |
| 166 | mocks.pictureInPicturePressed = props.pictureInPicture ?? false; | |
| 104 | 167 | return null; |
| 105 | 168 | }, |
| 106 | 169 | })); |
| ... | ... | @@ -111,7 +174,12 @@ vi.mock("./PreviewEmptyState", () => ({ |
| 111 | 174 | return null; |
| 112 | 175 | }, |
| 113 | 176 | })); |
| 114 | vi.mock("./PreviewMoreMenu", () => ({ PreviewMoreMenu: () => null })); | |
| 177 | vi.mock("./PreviewMoreMenu", () => ({ | |
| 178 | PreviewMoreMenu: (props: { onNativePictureInPicture: () => void }) => { | |
| 179 | mocks.toggleNativePictureInPicture = props.onNativePictureInPicture; | |
| 180 | return null; | |
| 181 | }, | |
| 182 | })); | |
| 115 | 183 | vi.mock("./PreviewUnreachable", () => ({ PreviewUnreachable: () => null })); |
| 116 | 184 | vi.mock("./ZoomIndicator", () => ({ ZoomIndicator: () => null })); |
| 117 | 185 | vi.mock("./AgentBrowserCursor", () => ({ AgentBrowserCursor: () => null })); |
| ... | ... | @@ -128,6 +196,16 @@ describe("PreviewView navigation", () => { |
| 128 | 196 | mocks.readPreparedConnection.mockClear(); |
| 129 | 197 | mocks.submittedUrl = null; |
| 130 | 198 | mocks.emptyStateUrl = null; |
| 199 | mocks.togglePictureInPicture = null; | |
| 200 | mocks.toggleNativePictureInPicture = null; | |
| 201 | mocks.pictureInPicturePressed = false; | |
| 202 | mocks.miniPlayerTabId = null; | |
| 203 | mocks.openMiniPlayer.mockClear(); | |
| 204 | mocks.closeMiniPlayer.mockClear(); | |
| 205 | mocks.closeRightPanel.mockClear(); | |
| 206 | mocks.openPictureInPicture.mockClear(); | |
| 207 | mocks.closePictureInPicture.mockClear(); | |
| 208 | mocks.pictureInPicture = false; | |
| 131 | 209 | mocks.showEmptyState = false; |
| 132 | 210 | }); |
| 133 | 211 | |
| ... | ... | @@ -192,4 +270,47 @@ describe("PreviewView navigation", () => { |
| 192 | 270 | "http://172.25.85.75:5173/app?mode=test#top", |
| 193 | 271 | ); |
| 194 | 272 | }); |
| 273 | ||
| 274 | it("opens and closes a thread-scoped floating preview for the active tab", async () => { | |
| 275 | const props = { | |
| 276 | threadRef: { | |
| 277 | environmentId: EnvironmentId.make("environment-1"), | |
| 278 | threadId: ThreadId.make("thread-1"), | |
| 279 | }, | |
| 280 | tabId: "tab-1", | |
| 281 | visible: true, | |
| 282 | } as const; | |
| 283 | ||
| 284 | renderToStaticMarkup(<PreviewView {...props} />); | |
| 285 | expect(mocks.pictureInPicturePressed).toBe(false); | |
| 286 | mocks.togglePictureInPicture?.(); | |
| 287 | expect(mocks.openMiniPlayer).toHaveBeenCalledWith(props.threadRef, "tab-1"); | |
| 288 | expect(mocks.closeRightPanel).toHaveBeenCalledWith(props.threadRef); | |
| 289 | ||
| 290 | mocks.miniPlayerTabId = "tab-1"; | |
| 291 | renderToStaticMarkup(<PreviewView {...props} />); | |
| 292 | expect(mocks.pictureInPicturePressed).toBe(true); | |
| 293 | mocks.togglePictureInPicture?.(); | |
| 294 | expect(mocks.closeMiniPlayer).toHaveBeenCalledWith(props.threadRef); | |
| 295 | }); | |
| 296 | ||
| 297 | it("keeps the native preview window as a secondary action", async () => { | |
| 298 | const props = { | |
| 299 | threadRef: { | |
| 300 | environmentId: EnvironmentId.make("environment-1"), | |
| 301 | threadId: ThreadId.make("thread-1"), | |
| 302 | }, | |
| 303 | tabId: "tab-1", | |
| 304 | visible: true, | |
| 305 | } as const; | |
| 306 | ||
| 307 | renderToStaticMarkup(<PreviewView {...props} />); | |
| 308 | mocks.toggleNativePictureInPicture?.(); | |
| 309 | await vi.waitFor(() => expect(mocks.openPictureInPicture).toHaveBeenCalledWith("tab-1")); | |
| 310 | ||
| 311 | mocks.pictureInPicture = true; | |
| 312 | renderToStaticMarkup(<PreviewView {...props} />); | |
| 313 | mocks.toggleNativePictureInPicture?.(); | |
| 314 | await vi.waitFor(() => expect(mocks.closePictureInPicture).toHaveBeenCalledWith("tab-1")); | |
| 315 | }); | |
| 195 | 316 | }); |
apps/web/src/components/preview/PreviewView.tsx+41-15| ... | ... | @@ -22,6 +22,8 @@ import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; |
| 22 | 22 | import { useEnvironment, useEnvironmentHttpBaseUrl } from "~/state/environments"; |
| 23 | 23 | import { previewEnvironment } from "~/state/preview"; |
| 24 | 24 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 25 | import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; | |
| 26 | import { useRightPanelStore } from "~/rightPanelStore"; | |
| 25 | 27 | |
| 26 | 28 | import { previewBridge } from "./previewBridge"; |
| 27 | 29 | import { subscribePreviewAction } from "./previewActionBus"; |
| ... | ... | @@ -47,7 +49,7 @@ import { AgentBrowserCursor } from "./AgentBrowserCursor"; |
| 47 | 49 | import { |
| 48 | 50 | startBrowserRecording, |
| 49 | 51 | stopBrowserRecording, |
| 50 | useActiveBrowserRecordingTabId, | |
| 52 | useActiveBrowserRecordingTabIds, | |
| 51 | 53 | } from "~/browser/browserRecording"; |
| 52 | 54 | import { stackedThreadToast, toastManager } from "~/components/ui/toast"; |
| 53 | 55 | |
| ... | ... | @@ -67,10 +69,13 @@ const localApi = typeof window === "undefined" ? null : ensureLocalApi(); |
| 67 | 69 | export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, visible }: Props) { |
| 68 | 70 | const [focusUrlNonce, setFocusUrlNonce] = useState<number | undefined>(undefined); |
| 69 | 71 | const [pickActive, setPickActive] = useState(false); |
| 70 | const activeRecordingTabId = useActiveBrowserRecordingTabId(); | |
| 72 | const activeRecordingTabIds = useActiveBrowserRecordingTabIds(); | |
| 71 | 73 | const pickActiveRef = useRef(false); |
| 72 | 74 | const isMountedRef = useRef(true); |
| 73 | 75 | const previewState = useThreadPreviewState(threadRef); |
| 76 | const miniPlayer = usePreviewMiniPlayerStore((state) => | |
| 77 | selectThreadPreviewMiniPlayer(state.byThreadKey, threadRef), | |
| 78 | ); | |
| 74 | 79 | const addPreviewAnnotation = useComposerDraftStore((store) => store.addPreviewAnnotation); |
| 75 | 80 | const addImage = useComposerDraftStore((store) => store.addImage); |
| 76 | 81 | const environment = useEnvironment(threadRef.environmentId); |
| ... | ... | @@ -227,11 +232,35 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, |
| 227 | 232 | void localApi.shell.openExternal(url).catch(() => undefined); |
| 228 | 233 | }, [url]); |
| 229 | 234 | |
| 235 | const handlePictureInPicture = useCallback(() => { | |
| 236 | if (!tabId) return; | |
| 237 | if (miniPlayer?.tabId === tabId) { | |
| 238 | usePreviewMiniPlayerStore.getState().close(threadRef); | |
| 239 | return; | |
| 240 | } | |
| 241 | usePreviewMiniPlayerStore.getState().open(threadRef, tabId); | |
| 242 | useRightPanelStore.getState().close(threadRef); | |
| 243 | }, [miniPlayer?.tabId, tabId, threadRef]); | |
| 244 | ||
| 245 | const handleNativePictureInPicture = useCallback(() => { | |
| 246 | if (!previewBridge || !tabId) return; | |
| 247 | const operation = desktopOverlay?.pictureInPicture | |
| 248 | ? previewBridge.pictureInPicture.close | |
| 249 | : previewBridge.pictureInPicture.open; | |
| 250 | void operation(tabId).catch((error) => { | |
| 251 | toastManager.add({ | |
| 252 | type: "error", | |
| 253 | title: "Unable to update popped-out preview", | |
| 254 | description: error instanceof Error ? error.message : "An error occurred.", | |
| 255 | }); | |
| 256 | }); | |
| 257 | }, [desktopOverlay?.pictureInPicture, tabId]); | |
| 258 | ||
| 230 | 259 | const handleCapture = useCallback( |
| 231 | 260 | (record: boolean) => { |
| 232 | 261 | if (!previewBridge || !tabId) return; |
| 233 | 262 | const bridge = previewBridge; |
| 234 | const recordingThisTab = activeRecordingTabId === tabId; | |
| 263 | const recordingThisTab = activeRecordingTabIds.has(tabId); | |
| 235 | 264 | if (recordingThisTab) { |
| 236 | 265 | void stopBrowserRecording(tabId).then( |
| 237 | 266 | (artifact) => { |
| ... | ... | @@ -325,15 +354,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, |
| 325 | 354 | return; |
| 326 | 355 | } |
| 327 | 356 | if (record) { |
| 328 | if (activeRecordingTabId !== null) { | |
| 329 | toastManager.add({ | |
| 330 | type: "warning", | |
| 331 | title: "Another preview is recording", | |
| 332 | description: "Stop the active recording before starting a new one.", | |
| 333 | }); | |
| 334 | return; | |
| 335 | } | |
| 336 | void startBrowserRecording(tabId).catch((error) => { | |
| 357 | void startBrowserRecording(tabId, threadRef).catch((error) => { | |
| 337 | 358 | toastManager.add({ |
| 338 | 359 | type: "error", |
| 339 | 360 | title: "Unable to start recording", |
| ... | ... | @@ -472,7 +493,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, |
| 472 | 493 | }, |
| 473 | 494 | ); |
| 474 | 495 | }, |
| 475 | [activeRecordingTabId, tabId], | |
| 496 | [activeRecordingTabIds, tabId, threadRef], | |
| 476 | 497 | ); |
| 477 | 498 | |
| 478 | 499 | const handlePickElement = useCallback(() => { |
| ... | ... | @@ -594,7 +615,10 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, |
| 594 | 615 | onOpenInBrowser={tabId ? handleOpenInBrowser : undefined} |
| 595 | 616 | onCapture={previewBridge && tabId ? handleCapture : undefined} |
| 596 | 617 | captureDisabled={!desktopOverlay || isUnreachable} |
| 597 | recording={tabId !== null && activeRecordingTabId === tabId} | |
| 618 | recording={tabId !== null && activeRecordingTabIds.has(tabId)} | |
| 619 | onPictureInPicture={previewBridge && tabId ? handlePictureInPicture : undefined} | |
| 620 | pictureInPicture={miniPlayer?.tabId === tabId} | |
| 621 | pictureInPictureDisabled={!desktopOverlay?.hasWebContents || isUnreachable} | |
| 598 | 622 | onPickElement={previewBridge && tabId ? handlePickElement : undefined} |
| 599 | 623 | pickActive={pickActive} |
| 600 | 624 | // Disable when there's no tab (nothing to pick on) OR the page |
| ... | ... | @@ -608,11 +632,13 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, |
| 608 | 632 | previewBridge ? ( |
| 609 | 633 | <PreviewMoreMenu |
| 610 | 634 | tabId={tabId} |
| 611 | hasWebContents={desktopOverlay !== null} | |
| 635 | hasWebContents={desktopOverlay?.hasWebContents ?? false} | |
| 612 | 636 | zoomFactor={desktopOverlay?.zoomFactor ?? 1} |
| 613 | 637 | colorScheme={desktopOverlay?.colorScheme ?? "system"} |
| 614 | 638 | deviceToolbarVisible={viewport._tag !== "fill"} |
| 615 | 639 | onToggleDeviceToolbar={handleToggleDeviceToolbar} |
| 640 | nativePictureInPicture={desktopOverlay?.pictureInPicture ?? false} | |
| 641 | onNativePictureInPicture={handleNativePictureInPicture} | |
| 616 | 642 | /> |
| 617 | 643 | ) : null |
| 618 | 644 | } |
apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx created+313| ... | ... | @@ -0,0 +1,313 @@ |
| 1 | "use client"; | |
| 2 | ||
| 3 | import type { ScopedThreadRef } from "@t3tools/contracts"; | |
| 4 | import { PanelRightIcon, PictureInPicture2, XIcon } from "lucide-react"; | |
| 5 | import { type PointerEvent as ReactPointerEvent, useLayoutEffect, useRef } from "react"; | |
| 6 | ||
| 7 | import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot"; | |
| 8 | import { Button } from "~/components/ui/button"; | |
| 9 | import { toastManager } from "~/components/ui/toast"; | |
| 10 | import { useThreadPreviewState } from "~/previewStateStore"; | |
| 11 | import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; | |
| 12 | import { useRightPanelStore } from "~/rightPanelStore"; | |
| 13 | ||
| 14 | import { previewBridge } from "./previewBridge"; | |
| 15 | import { | |
| 16 | clampPreviewMiniPlayerPosition, | |
| 17 | clampPreviewMiniPlayerSize, | |
| 18 | PREVIEW_MINI_PLAYER_DEFAULT_SIZE, | |
| 19 | } from "./previewMiniPlayerLayout"; | |
| 20 | ||
| 21 | interface DragState { | |
| 22 | readonly pointerId: number; | |
| 23 | readonly pointerX: number; | |
| 24 | readonly pointerY: number; | |
| 25 | readonly playerX: number; | |
| 26 | readonly playerY: number; | |
| 27 | } | |
| 28 | ||
| 29 | interface ResizeState { | |
| 30 | readonly pointerId: number; | |
| 31 | readonly pointerX: number; | |
| 32 | readonly pointerY: number; | |
| 33 | readonly width: number; | |
| 34 | readonly height: number; | |
| 35 | } | |
| 36 | ||
| 37 | interface Props { | |
| 38 | readonly threadRef: ScopedThreadRef; | |
| 39 | readonly tabId: string; | |
| 40 | readonly bottomInset: number; | |
| 41 | } | |
| 42 | ||
| 43 | export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props) { | |
| 44 | const rootRef = useRef<HTMLElement | null>(null); | |
| 45 | const dragRef = useRef<DragState | null>(null); | |
| 46 | const resizeRef = useRef<ResizeState | null>(null); | |
| 47 | const miniPlayer = usePreviewMiniPlayerStore((state) => | |
| 48 | selectThreadPreviewMiniPlayer(state.byThreadKey, threadRef), | |
| 49 | ); | |
| 50 | const previewState = useThreadPreviewState(threadRef); | |
| 51 | const snapshot = previewState.sessions[tabId] ?? null; | |
| 52 | const desktopOverlay = previewState.desktopByTabId[tabId] ?? null; | |
| 53 | const position = miniPlayer?.tabId === tabId ? miniPlayer.position : null; | |
| 54 | const size = | |
| 55 | miniPlayer?.tabId === tabId && miniPlayer.size | |
| 56 | ? miniPlayer.size | |
| 57 | : PREVIEW_MINI_PLAYER_DEFAULT_SIZE; | |
| 58 | const close = () => { | |
| 59 | usePreviewMiniPlayerStore.getState().close(threadRef); | |
| 60 | }; | |
| 61 | ||
| 62 | const openInPanel = () => { | |
| 63 | usePreviewMiniPlayerStore.getState().close(threadRef); | |
| 64 | useRightPanelStore.getState().openBrowser(threadRef, tabId); | |
| 65 | }; | |
| 66 | ||
| 67 | const toggleNativePictureInPicture = () => { | |
| 68 | if (!previewBridge) return; | |
| 69 | const operation = desktopOverlay?.pictureInPicture | |
| 70 | ? previewBridge.pictureInPicture.close | |
| 71 | : previewBridge.pictureInPicture.open; | |
| 72 | void operation(tabId).catch((error) => { | |
| 73 | toastManager.add({ | |
| 74 | type: "error", | |
| 75 | title: "Unable to update popped-out preview", | |
| 76 | description: error instanceof Error ? error.message : "An error occurred.", | |
| 77 | }); | |
| 78 | }); | |
| 79 | }; | |
| 80 | ||
| 81 | useLayoutEffect(() => { | |
| 82 | const clampAndMove = () => { | |
| 83 | const root = rootRef.current; | |
| 84 | const parent = root?.offsetParent; | |
| 85 | if (!root || !(parent instanceof HTMLElement)) return; | |
| 86 | const nextSize = clampPreviewMiniPlayerSize( | |
| 87 | { width: root.offsetWidth, height: root.offsetHeight }, | |
| 88 | { width: parent.clientWidth, height: parent.clientHeight }, | |
| 89 | bottomInset, | |
| 90 | ); | |
| 91 | usePreviewMiniPlayerStore.getState().resize(threadRef, tabId, nextSize); | |
| 92 | const next = clampPreviewMiniPlayerPosition( | |
| 93 | position ?? { x: root.offsetLeft, y: root.offsetTop }, | |
| 94 | { width: parent.clientWidth, height: parent.clientHeight }, | |
| 95 | nextSize, | |
| 96 | bottomInset, | |
| 97 | ); | |
| 98 | usePreviewMiniPlayerStore.getState().move(threadRef, tabId, next); | |
| 99 | }; | |
| 100 | clampAndMove(); | |
| 101 | const root = rootRef.current; | |
| 102 | const parent = root?.offsetParent; | |
| 103 | if (!root || !(parent instanceof HTMLElement) || typeof ResizeObserver === "undefined") { | |
| 104 | return; | |
| 105 | } | |
| 106 | const observer = new ResizeObserver(clampAndMove); | |
| 107 | observer.observe(root); | |
| 108 | observer.observe(parent); | |
| 109 | return () => observer.disconnect(); | |
| 110 | }, [bottomInset, position, tabId, threadRef]); | |
| 111 | ||
| 112 | const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => { | |
| 113 | if (event.button !== 0) return; | |
| 114 | const root = rootRef.current; | |
| 115 | const parent = root?.offsetParent; | |
| 116 | if (!root || !(parent instanceof HTMLElement)) return; | |
| 117 | const rootRect = root.getBoundingClientRect(); | |
| 118 | const parentRect = parent.getBoundingClientRect(); | |
| 119 | dragRef.current = { | |
| 120 | pointerId: event.pointerId, | |
| 121 | pointerX: event.clientX, | |
| 122 | pointerY: event.clientY, | |
| 123 | playerX: rootRect.left - parentRect.left, | |
| 124 | playerY: rootRect.top - parentRect.top, | |
| 125 | }; | |
| 126 | event.currentTarget.setPointerCapture(event.pointerId); | |
| 127 | event.preventDefault(); | |
| 128 | }; | |
| 129 | ||
| 130 | const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => { | |
| 131 | const drag = dragRef.current; | |
| 132 | const root = rootRef.current; | |
| 133 | const parent = root?.offsetParent; | |
| 134 | if (!drag || drag.pointerId !== event.pointerId || !root || !(parent instanceof HTMLElement)) { | |
| 135 | return; | |
| 136 | } | |
| 137 | const next = clampPreviewMiniPlayerPosition( | |
| 138 | { | |
| 139 | x: drag.playerX + event.clientX - drag.pointerX, | |
| 140 | y: drag.playerY + event.clientY - drag.pointerY, | |
| 141 | }, | |
| 142 | { width: parent.clientWidth, height: parent.clientHeight }, | |
| 143 | { width: root.offsetWidth, height: root.offsetHeight }, | |
| 144 | bottomInset, | |
| 145 | ); | |
| 146 | usePreviewMiniPlayerStore.getState().move(threadRef, tabId, next); | |
| 147 | }; | |
| 148 | ||
| 149 | const endDrag = (event: ReactPointerEvent<HTMLDivElement>) => { | |
| 150 | if (dragRef.current?.pointerId !== event.pointerId) return; | |
| 151 | dragRef.current = null; | |
| 152 | if (event.currentTarget.hasPointerCapture(event.pointerId)) { | |
| 153 | event.currentTarget.releasePointerCapture(event.pointerId); | |
| 154 | } | |
| 155 | }; | |
| 156 | ||
| 157 | const handleResizePointerDown = (event: ReactPointerEvent<HTMLButtonElement>) => { | |
| 158 | if (event.button !== 0) return; | |
| 159 | const root = rootRef.current; | |
| 160 | if (!root) return; | |
| 161 | resizeRef.current = { | |
| 162 | pointerId: event.pointerId, | |
| 163 | pointerX: event.clientX, | |
| 164 | pointerY: event.clientY, | |
| 165 | width: root.offsetWidth, | |
| 166 | height: root.offsetHeight, | |
| 167 | }; | |
| 168 | event.currentTarget.setPointerCapture(event.pointerId); | |
| 169 | event.preventDefault(); | |
| 170 | event.stopPropagation(); | |
| 171 | }; | |
| 172 | ||
| 173 | const handleResizePointerMove = (event: ReactPointerEvent<HTMLButtonElement>) => { | |
| 174 | const resize = resizeRef.current; | |
| 175 | const root = rootRef.current; | |
| 176 | const parent = root?.offsetParent; | |
| 177 | if ( | |
| 178 | !resize || | |
| 179 | resize.pointerId !== event.pointerId || | |
| 180 | !root || | |
| 181 | !(parent instanceof HTMLElement) | |
| 182 | ) { | |
| 183 | return; | |
| 184 | } | |
| 185 | const nextSize = clampPreviewMiniPlayerSize( | |
| 186 | { | |
| 187 | width: resize.width + event.clientX - resize.pointerX, | |
| 188 | height: resize.height + event.clientY - resize.pointerY, | |
| 189 | }, | |
| 190 | { width: parent.clientWidth, height: parent.clientHeight }, | |
| 191 | bottomInset, | |
| 192 | ); | |
| 193 | usePreviewMiniPlayerStore.getState().resize(threadRef, tabId, nextSize); | |
| 194 | const nextPosition = clampPreviewMiniPlayerPosition( | |
| 195 | position ?? { x: root.offsetLeft, y: root.offsetTop }, | |
| 196 | { width: parent.clientWidth, height: parent.clientHeight }, | |
| 197 | nextSize, | |
| 198 | bottomInset, | |
| 199 | ); | |
| 200 | usePreviewMiniPlayerStore.getState().move(threadRef, tabId, nextPosition); | |
| 201 | }; | |
| 202 | ||
| 203 | const endResize = (event: ReactPointerEvent<HTMLButtonElement>) => { | |
| 204 | if (resizeRef.current?.pointerId !== event.pointerId) return; | |
| 205 | resizeRef.current = null; | |
| 206 | if (event.currentTarget.hasPointerCapture(event.pointerId)) { | |
| 207 | event.currentTarget.releasePointerCapture(event.pointerId); | |
| 208 | } | |
| 209 | }; | |
| 210 | ||
| 211 | if (!snapshot || miniPlayer?.tabId !== tabId) return null; | |
| 212 | ||
| 213 | return ( | |
| 214 | <section | |
| 215 | ref={rootRef} | |
| 216 | aria-label="Floating browser preview" | |
| 217 | data-preview-mini-player={tabId} | |
| 218 | className="pointer-events-none absolute select-none" | |
| 219 | style={ | |
| 220 | position | |
| 221 | ? { left: position.x, top: position.y, width: size.width, height: size.height } | |
| 222 | : { | |
| 223 | right: 16, | |
| 224 | top: 16, | |
| 225 | width: size.width, | |
| 226 | height: size.height, | |
| 227 | } | |
| 228 | } | |
| 229 | > | |
| 230 | <div className="group pointer-events-auto absolute right-2 top-2 z-[34] size-3"> | |
| 231 | <div | |
| 232 | aria-hidden="true" | |
| 233 | className="absolute right-0 top-0 size-2 rounded-full bg-foreground/25 shadow-sm ring-1 ring-background/70 transition-opacity group-hover:opacity-0 group-focus-within:opacity-0" | |
| 234 | /> | |
| 235 | <div | |
| 236 | 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" | |
| 237 | onPointerDown={handlePointerDown} | |
| 238 | onPointerMove={handlePointerMove} | |
| 239 | onPointerUp={endDrag} | |
| 240 | onPointerCancel={endDrag} | |
| 241 | > | |
| 242 | <Button | |
| 243 | variant="ghost" | |
| 244 | size="icon-xs" | |
| 245 | aria-label="Open preview in right panel" | |
| 246 | title="Open in right panel" | |
| 247 | onPointerDown={(event) => event.stopPropagation()} | |
| 248 | onClick={openInPanel} | |
| 249 | > | |
| 250 | <PanelRightIcon /> | |
| 251 | </Button> | |
| 252 | <Button | |
| 253 | variant={desktopOverlay?.pictureInPicture ? "secondary" : "ghost"} | |
| 254 | size="icon-xs" | |
| 255 | aria-label={ | |
| 256 | desktopOverlay?.pictureInPicture | |
| 257 | ? "Close popped-out preview" | |
| 258 | : "Pop preview into separate window" | |
| 259 | } | |
| 260 | title={ | |
| 261 | desktopOverlay?.pictureInPicture | |
| 262 | ? "Close separate window" | |
| 263 | : "Pop into separate window" | |
| 264 | } | |
| 265 | disabled={!desktopOverlay?.hasWebContents} | |
| 266 | onPointerDown={(event) => event.stopPropagation()} | |
| 267 | onClick={toggleNativePictureInPicture} | |
| 268 | > | |
| 269 | <PictureInPicture2 /> | |
| 270 | </Button> | |
| 271 | <Button | |
| 272 | variant="ghost" | |
| 273 | size="icon-xs" | |
| 274 | aria-label="Close floating preview" | |
| 275 | title="Close floating preview" | |
| 276 | onPointerDown={(event) => event.stopPropagation()} | |
| 277 | onClick={close} | |
| 278 | > | |
| 279 | <XIcon /> | |
| 280 | </Button> | |
| 281 | </div> | |
| 282 | </div> | |
| 283 | ||
| 284 | <div className="relative h-full min-h-0"> | |
| 285 | <div className="absolute inset-0 z-[29] rounded-xl bg-muted shadow-2xl/35" /> | |
| 286 | <BrowserSurfaceSlot | |
| 287 | tabId={tabId} | |
| 288 | visible={Boolean(desktopOverlay?.hasWebContents)} | |
| 289 | cornerRadius={12} | |
| 290 | fitSourceContent | |
| 291 | layoutVersion={position ? `${position.x}:${position.y}` : `initial:${bottomInset}`} | |
| 292 | className="absolute inset-0" | |
| 293 | /> | |
| 294 | <div className="pointer-events-none absolute inset-0 z-[31] rounded-xl ring-1 ring-inset ring-border/80" /> | |
| 295 | {!desktopOverlay?.hasWebContents ? ( | |
| 296 | <div className="pointer-events-none absolute inset-0 z-[32] flex items-center justify-center rounded-xl bg-muted text-xs text-muted-foreground"> | |
| 297 | Reconnecting preview… | |
| 298 | </div> | |
| 299 | ) : null} | |
| 300 | <button | |
| 301 | type="button" | |
| 302 | aria-label="Resize floating preview" | |
| 303 | title="Resize floating preview" | |
| 304 | className="pointer-events-auto absolute bottom-0 right-0 z-[33] size-5 cursor-nwse-resize rounded-br-xl after:absolute after:bottom-1 after:right-1 after:size-2 after:border-b after:border-r after:border-foreground/45" | |
| 305 | onPointerDown={handleResizePointerDown} | |
| 306 | onPointerMove={handleResizePointerMove} | |
| 307 | onPointerUp={endResize} | |
| 308 | onPointerCancel={endResize} | |
| 309 | /> | |
| 310 | </div> | |
| 311 | </section> | |
| 312 | ); | |
| 313 | } |
apps/web/src/components/preview/previewAutomationOpenReadiness.test.ts+16-1| ... | ... | @@ -1,7 +1,10 @@ |
| 1 | 1 | import type { PreviewAutomationOpenInput, PreviewSessionSnapshot } from "@t3tools/contracts"; |
| 2 | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | |
| 4 | import { previewAutomationOpenNeedsOverlay } from "./previewAutomationOpenReadiness"; | |
| 4 | import { | |
| 5 | previewAutomationOpenNeedsOverlay, | |
| 6 | shouldOpenPreviewMiniPlayer, | |
| 7 | } from "./previewAutomationOpenReadiness"; | |
| 5 | 8 | |
| 6 | 9 | const snapshot = (navStatus: PreviewSessionSnapshot["navStatus"]): PreviewSessionSnapshot => ({ |
| 7 | 10 | threadId: "thread-1", |
| ... | ... | @@ -13,6 +16,18 @@ const snapshot = (navStatus: PreviewSessionSnapshot["navStatus"]): PreviewSessio |
| 13 | 16 | }); |
| 14 | 17 | |
| 15 | 18 | describe("preview automation open readiness", () => { |
| 19 | it("opens the inline preview by default", () => { | |
| 20 | expect(shouldOpenPreviewMiniPlayer({} as PreviewAutomationOpenInput)).toBe(true); | |
| 21 | }); | |
| 22 | ||
| 23 | it("supports explicit opt-out and the legacy show alias", () => { | |
| 24 | expect(shouldOpenPreviewMiniPlayer({ open: false } as PreviewAutomationOpenInput)).toBe(false); | |
| 25 | expect(shouldOpenPreviewMiniPlayer({ show: false } as PreviewAutomationOpenInput)).toBe(false); | |
| 26 | expect( | |
| 27 | shouldOpenPreviewMiniPlayer({ open: true, show: false } as PreviewAutomationOpenInput), | |
| 28 | ).toBe(true); | |
| 29 | }); | |
| 30 | ||
| 16 | 31 | it("does not wait for a desktop overlay when opening an empty tab", () => { |
| 17 | 32 | expect( |
| 18 | 33 | previewAutomationOpenNeedsOverlay( |
apps/web/src/components/preview/previewAutomationOpenReadiness.ts+4| ... | ... | @@ -1,5 +1,9 @@ |
| 1 | 1 | import type { PreviewAutomationOpenInput, PreviewSessionSnapshot } from "@t3tools/contracts"; |
| 2 | 2 | |
| 3 | export function shouldOpenPreviewMiniPlayer(input: PreviewAutomationOpenInput): boolean { | |
| 4 | return input.open ?? input.show ?? true; | |
| 5 | } | |
| 6 | ||
| 3 | 7 | export function previewAutomationOpenNeedsOverlay( |
| 4 | 8 | input: PreviewAutomationOpenInput, |
| 5 | 9 | snapshot: PreviewSessionSnapshot, |
apps/web/src/components/preview/previewMiniPlayerLayout.test.ts created+73| ... | ... | @@ -0,0 +1,73 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { | |
| 4 | clampPreviewMiniPlayerPosition, | |
| 5 | clampPreviewMiniPlayerSize, | |
| 6 | PREVIEW_MINI_PLAYER_EDGE_GAP, | |
| 7 | } from "./previewMiniPlayerLayout"; | |
| 8 | ||
| 9 | describe("clampPreviewMiniPlayerPosition", () => { | |
| 10 | it("keeps a dragged player within the chat viewport", () => { | |
| 11 | expect( | |
| 12 | clampPreviewMiniPlayerPosition( | |
| 13 | { x: 900, y: -40 }, | |
| 14 | { width: 1_000, height: 700 }, | |
| 15 | { width: 360, height: 240 }, | |
| 16 | ), | |
| 17 | ).toEqual({ | |
| 18 | x: 628, | |
| 19 | y: PREVIEW_MINI_PLAYER_EDGE_GAP, | |
| 20 | }); | |
| 21 | }); | |
| 22 | ||
| 23 | it("keeps an edge gap when the player is larger than its container", () => { | |
| 24 | expect( | |
| 25 | clampPreviewMiniPlayerPosition( | |
| 26 | { x: 20, y: 30 }, | |
| 27 | { width: 200, height: 160 }, | |
| 28 | { width: 360, height: 240 }, | |
| 29 | ), | |
| 30 | ).toEqual({ | |
| 31 | x: PREVIEW_MINI_PLAYER_EDGE_GAP, | |
| 32 | y: PREVIEW_MINI_PLAYER_EDGE_GAP, | |
| 33 | }); | |
| 34 | }); | |
| 35 | ||
| 36 | it("keeps the player above a growing composer inset", () => { | |
| 37 | expect( | |
| 38 | clampPreviewMiniPlayerPosition( | |
| 39 | { x: 500, y: 448 }, | |
| 40 | { width: 1_000, height: 700 }, | |
| 41 | { width: 360, height: 240 }, | |
| 42 | 160, | |
| 43 | ), | |
| 44 | ).toEqual({ | |
| 45 | x: 500, | |
| 46 | y: 288, | |
| 47 | }); | |
| 48 | }); | |
| 49 | }); | |
| 50 | ||
| 51 | describe("clampPreviewMiniPlayerSize", () => { | |
| 52 | it("allows resizing within the available chat viewport", () => { | |
| 53 | expect( | |
| 54 | clampPreviewMiniPlayerSize({ width: 520, height: 360 }, { width: 1_000, height: 700 }, 120), | |
| 55 | ).toEqual({ width: 520, height: 360 }); | |
| 56 | }); | |
| 57 | ||
| 58 | it("bounds oversized players above the composer", () => { | |
| 59 | expect( | |
| 60 | clampPreviewMiniPlayerSize( | |
| 61 | { width: 2_000, height: 2_000 }, | |
| 62 | { width: 1_000, height: 700 }, | |
| 63 | 120, | |
| 64 | ), | |
| 65 | ).toEqual({ width: 976, height: 556 }); | |
| 66 | }); | |
| 67 | ||
| 68 | it("lets a tiny container win over the preferred minimum", () => { | |
| 69 | expect( | |
| 70 | clampPreviewMiniPlayerSize({ width: 360, height: 239 }, { width: 250, height: 180 }, 20), | |
| 71 | ).toEqual({ width: 226, height: 136 }); | |
| 72 | }); | |
| 73 | }); |
apps/web/src/components/preview/previewMiniPlayerLayout.ts created+46| ... | ... | @@ -0,0 +1,46 @@ |
| 1 | import type { PreviewMiniPlayerPosition, PreviewMiniPlayerSize } from "~/previewMiniPlayerStore"; | |
| 2 | ||
| 3 | export const PREVIEW_MINI_PLAYER_EDGE_GAP = 12; | |
| 4 | export const PREVIEW_MINI_PLAYER_DEFAULT_SIZE = { width: 320, height: 200 } as const; | |
| 5 | export const PREVIEW_MINI_PLAYER_MIN_SIZE = { width: 240, height: 150 } as const; | |
| 6 | ||
| 7 | export function clampPreviewMiniPlayerSize( | |
| 8 | size: PreviewMiniPlayerSize, | |
| 9 | container: PreviewMiniPlayerSize, | |
| 10 | bottomInset = 0, | |
| 11 | ): PreviewMiniPlayerSize { | |
| 12 | const availableWidth = Math.max(1, container.width - PREVIEW_MINI_PLAYER_EDGE_GAP * 2); | |
| 13 | const availableHeight = Math.max( | |
| 14 | 1, | |
| 15 | container.height - Math.max(0, bottomInset) - PREVIEW_MINI_PLAYER_EDGE_GAP * 2, | |
| 16 | ); | |
| 17 | return { | |
| 18 | width: Math.round( | |
| 19 | Math.min(Math.max(PREVIEW_MINI_PLAYER_MIN_SIZE.width, size.width), availableWidth), | |
| 20 | ), | |
| 21 | height: Math.round( | |
| 22 | Math.min(Math.max(PREVIEW_MINI_PLAYER_MIN_SIZE.height, size.height), availableHeight), | |
| 23 | ), | |
| 24 | }; | |
| 25 | } | |
| 26 | ||
| 27 | export function clampPreviewMiniPlayerPosition( | |
| 28 | position: PreviewMiniPlayerPosition, | |
| 29 | container: PreviewMiniPlayerSize, | |
| 30 | player: PreviewMiniPlayerSize, | |
| 31 | bottomInset = 0, | |
| 32 | ): PreviewMiniPlayerPosition { | |
| 33 | const reservedBottomSpace = Math.max(0, bottomInset); | |
| 34 | const maxX = Math.max( | |
| 35 | PREVIEW_MINI_PLAYER_EDGE_GAP, | |
| 36 | container.width - player.width - PREVIEW_MINI_PLAYER_EDGE_GAP, | |
| 37 | ); | |
| 38 | const maxY = Math.max( | |
| 39 | PREVIEW_MINI_PLAYER_EDGE_GAP, | |
| 40 | container.height - reservedBottomSpace - player.height - PREVIEW_MINI_PLAYER_EDGE_GAP, | |
| 41 | ); | |
| 42 | return { | |
| 43 | x: Math.min(Math.max(position.x, PREVIEW_MINI_PLAYER_EDGE_GAP), maxX), | |
| 44 | y: Math.min(Math.max(position.y, PREVIEW_MINI_PLAYER_EDGE_GAP), maxY), | |
| 45 | }; | |
| 46 | } |
apps/web/src/components/preview/usePreviewBridge.ts+2| ... | ... | @@ -73,10 +73,12 @@ function shouldClearBrowserPointer( |
| 73 | 73 | |
| 74 | 74 | function projectDesktopState(state: DesktopPreviewTabState): DesktopPreviewOverlay { |
| 75 | 75 | return { |
| 76 | hasWebContents: state.webContentsId !== null, | |
| 76 | 77 | canGoBack: state.canGoBack, |
| 77 | 78 | canGoForward: state.canGoForward, |
| 78 | 79 | loading: state.navStatus.kind === "Loading", |
| 79 | 80 | zoomFactor: state.zoomFactor, |
| 81 | pictureInPicture: state.pictureInPicture, | |
| 80 | 82 | colorScheme: state.colorScheme, |
| 81 | 83 | controller: state.controller, |
| 82 | 84 | }; |
apps/web/src/components/preview/usePreviewSession.ts+5-42| ... | ... | @@ -2,14 +2,12 @@ |
| 2 | 2 | |
| 3 | 3 | import { useAtomValue } from "@effect/atom-react"; |
| 4 | 4 | import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime/environment"; |
| 5 | import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; | |
| 6 | 5 | import type { ScopedThreadRef } from "@t3tools/contracts"; |
| 7 | 6 | import * as Schema from "effect/Schema"; |
| 8 | 7 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 9 | 8 | |
| 10 | 9 | import { |
| 11 | 10 | applyPreviewServerEvent, |
| 12 | applyPreviewServerSnapshot, | |
| 13 | 11 | readThreadPreviewState, |
| 14 | 12 | reconcilePreviewServerSessions, |
| 15 | 13 | } from "~/previewStateStore"; |
| ... | ... | @@ -41,61 +39,26 @@ const previewSessionSyncAtom = Atom.family((threadKey: string) => { |
| 41 | 39 | |
| 42 | 40 | return Atom.make((get) => { |
| 43 | 41 | let disposed = false; |
| 44 | let recoveryId = 0; | |
| 45 | let recoveringUrl: string | null = null; | |
| 46 | 42 | let sessionsVersion = 0; |
| 47 | 43 | let eventsVersion = 0; |
| 48 | 44 | |
| 49 | 45 | const reconcileSessions = (result: Atom.Type<typeof sessionsAtom>) => { |
| 50 | 46 | if (!AsyncResult.isSuccess(result)) return; |
| 51 | if (result.value.sessions.length > 0) { | |
| 52 | recoveringUrl = null; | |
| 53 | recoveryId += 1; | |
| 54 | reconcilePreviewServerSessions(threadRef, result.value.sessions); | |
| 55 | return; | |
| 56 | } | |
| 57 | ||
| 58 | const localSnapshot = readThreadPreviewState(threadRef).snapshot; | |
| 59 | const recoverableUrl = | |
| 60 | localSnapshot && localSnapshot.navStatus._tag !== "Idle" | |
| 61 | ? localSnapshot.navStatus.url | |
| 62 | : null; | |
| 63 | if (!recoverableUrl) { | |
| 64 | applyPreviewServerSnapshot(threadRef, null); | |
| 65 | return; | |
| 66 | } | |
| 67 | if (recoveringUrl === recoverableUrl) return; | |
| 68 | ||
| 69 | recoveringUrl = recoverableUrl; | |
| 70 | const currentRecoveryId = ++recoveryId; | |
| 71 | void runAtomCommand( | |
| 72 | get.registry, | |
| 73 | previewEnvironment.open, | |
| 74 | { | |
| 75 | environmentId: threadRef.environmentId, | |
| 76 | input: { threadId: threadRef.threadId, url: recoverableUrl }, | |
| 77 | }, | |
| 78 | { reportDefect: false, reportFailure: false }, | |
| 79 | ).then((openResult) => { | |
| 80 | if (disposed || currentRecoveryId !== recoveryId) return; | |
| 81 | recoveringUrl = null; | |
| 82 | if (openResult._tag === "Failure") return; | |
| 83 | applyPreviewServerSnapshot(threadRef, openResult.value); | |
| 84 | get.refresh(sessionsAtom); | |
| 85 | }); | |
| 47 | reconcilePreviewServerSessions(threadRef, result.value); | |
| 86 | 48 | }; |
| 87 | 49 | |
| 88 | 50 | const applyLatestEvent = (result: Atom.Type<typeof eventsAtom>) => { |
| 89 | 51 | if (!AsyncResult.isSuccess(result) || result.value.threadId !== threadRef.threadId) return; |
| 90 | applyPreviewServerEvent(threadRef, result.value); | |
| 91 | if (result.value.type === "opened" || result.value.type === "closed") { | |
| 52 | const currentEpoch = readThreadPreviewState(threadRef).serverEpoch; | |
| 53 | if (currentEpoch !== null && currentEpoch !== result.value.serverEpoch) { | |
| 92 | 54 | get.refresh(sessionsAtom); |
| 55 | return; | |
| 93 | 56 | } |
| 57 | applyPreviewServerEvent(threadRef, result.value); | |
| 94 | 58 | }; |
| 95 | 59 | |
| 96 | 60 | get.addFinalizer(() => { |
| 97 | 61 | disposed = true; |
| 98 | recoveryId += 1; | |
| 99 | 62 | }); |
| 100 | 63 | const initialSessions = get.once(sessionsAtom); |
| 101 | 64 | const initialEvent = get.once(eventsAtom); |
apps/web/src/previewMiniPlayerStore.test.ts created+64| ... | ... | @@ -0,0 +1,64 @@ |
| 1 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; | |
| 2 | import { type EnvironmentId, ThreadId } from "@t3tools/contracts"; | |
| 3 | import { beforeEach, describe, expect, it } from "vite-plus/test"; | |
| 4 | ||
| 5 | import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "./previewMiniPlayerStore"; | |
| 6 | ||
| 7 | const refA = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-A")); | |
| 8 | const refB = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-B")); | |
| 9 | ||
| 10 | beforeEach(() => { | |
| 11 | usePreviewMiniPlayerStore.setState({ byThreadKey: {} }); | |
| 12 | }); | |
| 13 | ||
| 14 | describe("previewMiniPlayerStore", () => { | |
| 15 | it("keeps floating previews scoped to their thread", () => { | |
| 16 | usePreviewMiniPlayerStore.getState().open(refA, "tab-a"); | |
| 17 | usePreviewMiniPlayerStore.getState().open(refB, "tab-b"); | |
| 18 | ||
| 19 | expect( | |
| 20 | selectThreadPreviewMiniPlayer(usePreviewMiniPlayerStore.getState().byThreadKey, refA), | |
| 21 | ).toMatchObject({ tabId: "tab-a" }); | |
| 22 | expect( | |
| 23 | selectThreadPreviewMiniPlayer(usePreviewMiniPlayerStore.getState().byThreadKey, refB), | |
| 24 | ).toMatchObject({ tabId: "tab-b" }); | |
| 25 | }); | |
| 26 | ||
| 27 | it("preserves position when switching the floating tab within one thread", () => { | |
| 28 | usePreviewMiniPlayerStore.getState().open(refA, "tab-a"); | |
| 29 | usePreviewMiniPlayerStore.getState().move(refA, "tab-a", { x: 24, y: 48 }); | |
| 30 | usePreviewMiniPlayerStore.getState().open(refA, "tab-b"); | |
| 31 | ||
| 32 | expect( | |
| 33 | selectThreadPreviewMiniPlayer(usePreviewMiniPlayerStore.getState().byThreadKey, refA), | |
| 34 | ).toEqual({ | |
| 35 | tabId: "tab-b", | |
| 36 | position: { x: 24, y: 48 }, | |
| 37 | size: null, | |
| 38 | }); | |
| 39 | }); | |
| 40 | ||
| 41 | it("ignores stale drag updates after the floating tab changes", () => { | |
| 42 | usePreviewMiniPlayerStore.getState().open(refA, "tab-a"); | |
| 43 | usePreviewMiniPlayerStore.getState().open(refA, "tab-b"); | |
| 44 | usePreviewMiniPlayerStore.getState().move(refA, "tab-a", { x: 100, y: 100 }); | |
| 45 | ||
| 46 | expect( | |
| 47 | selectThreadPreviewMiniPlayer(usePreviewMiniPlayerStore.getState().byThreadKey, refA), | |
| 48 | ).toEqual({ | |
| 49 | tabId: "tab-b", | |
| 50 | position: null, | |
| 51 | size: null, | |
| 52 | }); | |
| 53 | }); | |
| 54 | ||
| 55 | it("preserves a thread-bound size while switching tabs", () => { | |
| 56 | usePreviewMiniPlayerStore.getState().open(refA, "tab-a"); | |
| 57 | usePreviewMiniPlayerStore.getState().resize(refA, "tab-a", { width: 480, height: 320 }); | |
| 58 | usePreviewMiniPlayerStore.getState().open(refA, "tab-b"); | |
| 59 | ||
| 60 | expect( | |
| 61 | selectThreadPreviewMiniPlayer(usePreviewMiniPlayerStore.getState().byThreadKey, refA), | |
| 62 | ).toMatchObject({ tabId: "tab-b", size: { width: 480, height: 320 } }); | |
| 63 | }); | |
| 64 | }); |
apps/web/src/previewMiniPlayerStore.ts created+96| ... | ... | @@ -0,0 +1,96 @@ |
| 1 | import { scopedThreadKey } from "@t3tools/client-runtime/environment"; | |
| 2 | import type { ScopedThreadRef } from "@t3tools/contracts"; | |
| 3 | import { create } from "zustand"; | |
| 4 | ||
| 5 | export interface PreviewMiniPlayerPosition { | |
| 6 | readonly x: number; | |
| 7 | readonly y: number; | |
| 8 | } | |
| 9 | ||
| 10 | export interface PreviewMiniPlayerSize { | |
| 11 | readonly width: number; | |
| 12 | readonly height: number; | |
| 13 | } | |
| 14 | ||
| 15 | export interface PreviewMiniPlayerState { | |
| 16 | readonly tabId: string; | |
| 17 | readonly position: PreviewMiniPlayerPosition | null; | |
| 18 | readonly size: PreviewMiniPlayerSize | null; | |
| 19 | } | |
| 20 | ||
| 21 | interface PreviewMiniPlayerStoreState { | |
| 22 | readonly byThreadKey: Record<string, PreviewMiniPlayerState>; | |
| 23 | readonly open: (ref: ScopedThreadRef, tabId: string) => void; | |
| 24 | readonly close: (ref: ScopedThreadRef) => void; | |
| 25 | readonly move: (ref: ScopedThreadRef, tabId: string, position: PreviewMiniPlayerPosition) => void; | |
| 26 | readonly resize: (ref: ScopedThreadRef, tabId: string, size: PreviewMiniPlayerSize) => void; | |
| 27 | readonly removeThread: (ref: ScopedThreadRef) => void; | |
| 28 | } | |
| 29 | ||
| 30 | export const usePreviewMiniPlayerStore = create<PreviewMiniPlayerStoreState>()((set) => ({ | |
| 31 | byThreadKey: {}, | |
| 32 | open: (ref, tabId) => | |
| 33 | set((state) => { | |
| 34 | const threadKey = scopedThreadKey(ref); | |
| 35 | const current = state.byThreadKey[threadKey]; | |
| 36 | if (current?.tabId === tabId) return state; | |
| 37 | return { | |
| 38 | byThreadKey: { | |
| 39 | ...state.byThreadKey, | |
| 40 | [threadKey]: { | |
| 41 | tabId, | |
| 42 | position: current?.position ?? null, | |
| 43 | size: current?.size ?? null, | |
| 44 | }, | |
| 45 | }, | |
| 46 | }; | |
| 47 | }), | |
| 48 | close: (ref) => | |
| 49 | set((state) => { | |
| 50 | const threadKey = scopedThreadKey(ref); | |
| 51 | if (!(threadKey in state.byThreadKey)) return state; | |
| 52 | const { [threadKey]: _closed, ...byThreadKey } = state.byThreadKey; | |
| 53 | return { byThreadKey }; | |
| 54 | }), | |
| 55 | move: (ref, tabId, position) => | |
| 56 | set((state) => { | |
| 57 | const threadKey = scopedThreadKey(ref); | |
| 58 | const current = state.byThreadKey[threadKey]; | |
| 59 | if (!current || current.tabId !== tabId) return state; | |
| 60 | if (current.position?.x === position.x && current.position.y === position.y) return state; | |
| 61 | return { | |
| 62 | byThreadKey: { | |
| 63 | ...state.byThreadKey, | |
| 64 | [threadKey]: { ...current, position }, | |
| 65 | }, | |
| 66 | }; | |
| 67 | }), | |
| 68 | resize: (ref, tabId, size) => | |
| 69 | set((state) => { | |
| 70 | const threadKey = scopedThreadKey(ref); | |
| 71 | const current = state.byThreadKey[threadKey]; | |
| 72 | if (!current || current.tabId !== tabId) return state; | |
| 73 | if (current.size?.width === size.width && current.size.height === size.height) return state; | |
| 74 | return { | |
| 75 | byThreadKey: { | |
| 76 | ...state.byThreadKey, | |
| 77 | [threadKey]: { ...current, size }, | |
| 78 | }, | |
| 79 | }; | |
| 80 | }), | |
| 81 | removeThread: (ref) => | |
| 82 | set((state) => { | |
| 83 | const threadKey = scopedThreadKey(ref); | |
| 84 | if (!(threadKey in state.byThreadKey)) return state; | |
| 85 | const { [threadKey]: _removed, ...byThreadKey } = state.byThreadKey; | |
| 86 | return { byThreadKey }; | |
| 87 | }), | |
| 88 | })); | |
| 89 | ||
| 90 | export function selectThreadPreviewMiniPlayer( | |
| 91 | byThreadKey: Record<string, PreviewMiniPlayerState>, | |
| 92 | ref: ScopedThreadRef | null | undefined, | |
| 93 | ): PreviewMiniPlayerState | null { | |
| 94 | if (!ref) return null; | |
| 95 | return byThreadKey[scopedThreadKey(ref)] ?? null; | |
| 96 | } |
apps/web/src/previewStateStore.test.ts+101-4| ... | ... | @@ -1,11 +1,16 @@ |
| 1 | 1 | import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 2 | import { type EnvironmentId, type PreviewSessionSnapshot, ThreadId } from "@t3tools/contracts"; | |
| 2 | import { | |
| 3 | type EnvironmentId, | |
| 4 | type PreviewEvent, | |
| 5 | type PreviewSessionSnapshot, | |
| 6 | ThreadId, | |
| 7 | } from "@t3tools/contracts"; | |
| 3 | 8 | import { beforeEach, describe, expect, it } from "vite-plus/test"; |
| 4 | 9 | |
| 5 | 10 | import { |
| 6 | 11 | __testing, |
| 7 | 12 | applyPreviewDesktopState, |
| 8 | applyPreviewServerEvent, | |
| 13 | applyPreviewServerEvent as applyPreviewServerEventImpl, | |
| 9 | 14 | applyPreviewServerSnapshot, |
| 10 | 15 | beginPreviewSessionClose, |
| 11 | 16 | cancelPreviewSessionClose, |
| ... | ... | @@ -33,7 +38,25 @@ const makeSnapshot = (overrides: Partial<PreviewSessionSnapshot> = {}): PreviewS |
| 33 | 38 | ...overrides, |
| 34 | 39 | }); |
| 35 | 40 | |
| 41 | type PreviewEventDraft = PreviewEvent extends infer Event | |
| 42 | ? Event extends { readonly revision: number } | |
| 43 | ? Omit<Event, "revision" | "serverEpoch"> | |
| 44 | : never | |
| 45 | : never; | |
| 46 | ||
| 47 | const serverEpoch = "server-a"; | |
| 48 | let nextServerRevision = 0; | |
| 49 | const applyPreviewServerEvent = (eventRef: typeof ref, event: PreviewEventDraft): void => { | |
| 50 | nextServerRevision += 1; | |
| 51 | applyPreviewServerEventImpl(eventRef, { | |
| 52 | ...event, | |
| 53 | serverEpoch, | |
| 54 | revision: nextServerRevision, | |
| 55 | } as PreviewEvent); | |
| 56 | }; | |
| 57 | ||
| 36 | 58 | beforeEach(() => { |
| 59 | nextServerRevision = 0; | |
| 37 | 60 | resetPreviewStateForTests(); |
| 38 | 61 | }); |
| 39 | 62 | |
| ... | ... | @@ -291,10 +314,12 @@ describe("previewStateStore (single-tab)", () => { |
| 291 | 314 | snapshot, |
| 292 | 315 | }); |
| 293 | 316 | applyPreviewDesktopState(ref, snapshot.tabId, { |
| 317 | hasWebContents: true, | |
| 294 | 318 | canGoBack: true, |
| 295 | 319 | canGoForward: false, |
| 296 | 320 | loading: false, |
| 297 | 321 | zoomFactor: 1, |
| 322 | pictureInPicture: false, | |
| 298 | 323 | colorScheme: "system", |
| 299 | 324 | controller: "none", |
| 300 | 325 | }); |
| ... | ... | @@ -309,10 +334,12 @@ describe("previewStateStore (single-tab)", () => { |
| 309 | 334 | applyPreviewServerSnapshot(ref, first); |
| 310 | 335 | applyPreviewServerSnapshot(ref, second); |
| 311 | 336 | applyPreviewDesktopState(ref, first.tabId, { |
| 337 | hasWebContents: true, | |
| 312 | 338 | canGoBack: true, |
| 313 | 339 | canGoForward: false, |
| 314 | 340 | loading: false, |
| 315 | 341 | zoomFactor: 1, |
| 342 | pictureInPicture: false, | |
| 316 | 343 | colorScheme: "system", |
| 317 | 344 | controller: "none", |
| 318 | 345 | }); |
| ... | ... | @@ -355,15 +382,17 @@ describe("previewStateStore (single-tab)", () => { |
| 355 | 382 | applyPreviewServerSnapshot(ref, stale); |
| 356 | 383 | applyPreviewServerSnapshot(ref, active); |
| 357 | 384 | applyPreviewDesktopState(ref, stale.tabId, { |
| 385 | hasWebContents: true, | |
| 358 | 386 | canGoBack: false, |
| 359 | 387 | canGoForward: false, |
| 360 | 388 | loading: false, |
| 361 | 389 | zoomFactor: 1, |
| 390 | pictureInPicture: false, | |
| 362 | 391 | colorScheme: "system", |
| 363 | 392 | controller: "none", |
| 364 | 393 | }); |
| 365 | 394 | |
| 366 | reconcilePreviewServerSessions(ref, [active]); | |
| 395 | reconcilePreviewServerSessions(ref, { sessions: [active], serverEpoch, revision: 1 }); | |
| 367 | 396 | |
| 368 | 397 | const state = readThreadPreviewState(ref); |
| 369 | 398 | expect(Object.keys(state.sessions)).toEqual([active.tabId]); |
| ... | ... | @@ -375,7 +404,7 @@ describe("previewStateStore (single-tab)", () => { |
| 375 | 404 | it("clears stale sessions when an authoritative list is empty", () => { |
| 376 | 405 | applyPreviewServerSnapshot(ref, makeSnapshot()); |
| 377 | 406 | |
| 378 | reconcilePreviewServerSessions(ref, []); | |
| 407 | reconcilePreviewServerSessions(ref, { sessions: [], serverEpoch, revision: 1 }); | |
| 379 | 408 | |
| 380 | 409 | const state = readThreadPreviewState(ref); |
| 381 | 410 | expect(state.sessions).toEqual({}); |
| ... | ... | @@ -383,6 +412,74 @@ describe("previewStateStore (single-tab)", () => { |
| 383 | 412 | expect(state.snapshot).toBeNull(); |
| 384 | 413 | }); |
| 385 | 414 | |
| 415 | it("ignores a list response older than the latest server event", () => { | |
| 416 | const snapshot = makeSnapshot(); | |
| 417 | applyPreviewServerEvent(ref, { | |
| 418 | type: "opened", | |
| 419 | threadId: "thread-1", | |
| 420 | tabId: snapshot.tabId, | |
| 421 | createdAt: snapshot.updatedAt, | |
| 422 | snapshot, | |
| 423 | }); | |
| 424 | ||
| 425 | reconcilePreviewServerSessions(ref, { sessions: [], serverEpoch, revision: 0 }); | |
| 426 | ||
| 427 | expect(readThreadPreviewState(ref).sessions).toEqual({ [snapshot.tabId]: snapshot }); | |
| 428 | }); | |
| 429 | ||
| 430 | it("does not resurrect a tab from an event older than its close", () => { | |
| 431 | const snapshot = makeSnapshot(); | |
| 432 | applyPreviewServerEvent(ref, { | |
| 433 | type: "opened", | |
| 434 | threadId: "thread-1", | |
| 435 | tabId: snapshot.tabId, | |
| 436 | createdAt: snapshot.updatedAt, | |
| 437 | snapshot, | |
| 438 | }); | |
| 439 | applyPreviewServerEvent(ref, { | |
| 440 | type: "closed", | |
| 441 | threadId: "thread-1", | |
| 442 | tabId: snapshot.tabId, | |
| 443 | createdAt: "2026-01-01T00:00:01.000Z", | |
| 444 | }); | |
| 445 | ||
| 446 | applyPreviewServerEventImpl(ref, { | |
| 447 | type: "opened", | |
| 448 | threadId: "thread-1", | |
| 449 | tabId: snapshot.tabId, | |
| 450 | createdAt: snapshot.updatedAt, | |
| 451 | serverEpoch, | |
| 452 | revision: 1, | |
| 453 | snapshot, | |
| 454 | }); | |
| 455 | ||
| 456 | expect(readThreadPreviewState(ref).sessions).toEqual({}); | |
| 457 | }); | |
| 458 | ||
| 459 | it("accepts a lower revision from a newly restarted server", () => { | |
| 460 | const snapshot = makeSnapshot(); | |
| 461 | applyPreviewServerEventImpl(ref, { | |
| 462 | type: "opened", | |
| 463 | threadId: "thread-1", | |
| 464 | tabId: snapshot.tabId, | |
| 465 | createdAt: snapshot.updatedAt, | |
| 466 | serverEpoch, | |
| 467 | revision: 12, | |
| 468 | snapshot, | |
| 469 | }); | |
| 470 | ||
| 471 | reconcilePreviewServerSessions(ref, { | |
| 472 | sessions: [], | |
| 473 | serverEpoch: "server-b", | |
| 474 | revision: 0, | |
| 475 | }); | |
| 476 | ||
| 477 | const state = readThreadPreviewState(ref); | |
| 478 | expect(state.sessions).toEqual({}); | |
| 479 | expect(state.serverEpoch).toBe("server-b"); | |
| 480 | expect(state.serverRevision).toBe(0); | |
| 481 | }); | |
| 482 | ||
| 386 | 483 | it("applyServerSnapshot null clears snapshot for a thread that had one", () => { |
| 387 | 484 | const snapshot = makeSnapshot(); |
| 388 | 485 | applyPreviewServerSnapshot(ref, snapshot); |
apps/web/src/previewStateStore.ts+81-45| ... | ... | @@ -10,6 +10,7 @@ import { scopedThreadKey } from "@t3tools/client-runtime/environment"; |
| 10 | 10 | import { |
| 11 | 11 | type DesktopPreviewColorScheme, |
| 12 | 12 | type PreviewEvent, |
| 13 | type PreviewListResult, | |
| 13 | 14 | type PreviewSessionSnapshot, |
| 14 | 15 | type ScopedThreadRef, |
| 15 | 16 | } from "@t3tools/contracts"; |
| ... | ... | @@ -19,10 +20,12 @@ import { PREVIEW_RECENT_URL_LIMIT } from "./components/preview/previewConstants" |
| 19 | 20 | import { appAtomRegistry } from "./rpc/atomRegistry"; |
| 20 | 21 | |
| 21 | 22 | export interface DesktopPreviewOverlay { |
| 23 | hasWebContents: boolean; | |
| 22 | 24 | canGoBack: boolean; |
| 23 | 25 | canGoForward: boolean; |
| 24 | 26 | loading: boolean; |
| 25 | 27 | zoomFactor: number; |
| 28 | pictureInPicture: boolean; | |
| 26 | 29 | colorScheme: DesktopPreviewColorScheme; |
| 27 | 30 | controller: "human" | "agent" | "none"; |
| 28 | 31 | } |
| ... | ... | @@ -36,6 +39,10 @@ export interface ThreadPreviewState { |
| 36 | 39 | desktopOverlay: DesktopPreviewOverlay | null; |
| 37 | 40 | desktopByTabId: Record<string, DesktopPreviewOverlay>; |
| 38 | 41 | recentlySeenUrls: string[]; |
| 42 | /** Server process currently authoritative for revision ordering. */ | |
| 43 | serverEpoch: string | null; | |
| 44 | /** Latest ordered server revision applied from a list response or event. */ | |
| 45 | serverRevision: number; | |
| 39 | 46 | } |
| 40 | 47 | |
| 41 | 48 | const EMPTY_THREAD_PREVIEW_STATE: ThreadPreviewState = Object.freeze({ |
| ... | ... | @@ -46,6 +53,8 @@ const EMPTY_THREAD_PREVIEW_STATE: ThreadPreviewState = Object.freeze({ |
| 46 | 53 | desktopOverlay: null, |
| 47 | 54 | desktopByTabId: {}, |
| 48 | 55 | recentlySeenUrls: [] as string[], |
| 56 | serverEpoch: null, | |
| 57 | serverRevision: 0, | |
| 49 | 58 | }); |
| 50 | 59 | |
| 51 | 60 | const emptyPreviewStateAtom = Atom.make<ThreadPreviewState>(EMPTY_THREAD_PREVIEW_STATE).pipe( |
| ... | ... | @@ -175,52 +184,68 @@ export function subscribeThreadPreviewState( |
| 175 | 184 | |
| 176 | 185 | export function applyPreviewServerEvent(ref: ScopedThreadRef, event: PreviewEvent): void { |
| 177 | 186 | updateThreadPreviewState(ref, (current) => { |
| 178 | switch (event.type) { | |
| 179 | case "opened": | |
| 180 | case "navigated": | |
| 181 | case "resized": { | |
| 182 | const snapshot = event.snapshot; | |
| 183 | if (current.suppressedTabIds.has(snapshot.tabId)) return current; | |
| 184 | const recentlySeenUrls = | |
| 185 | snapshot.navStatus._tag === "Idle" | |
| 186 | ? current.recentlySeenUrls | |
| 187 | : dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url); | |
| 188 | const sessions = { ...current.sessions, [snapshot.tabId]: snapshot }; | |
| 189 | const activeTabId = event.type === "opened" ? snapshot.tabId : current.activeTabId; | |
| 190 | const activeSnapshot = sessions[activeTabId ?? snapshot.tabId] ?? snapshot; | |
| 191 | return { | |
| 192 | ...current, | |
| 193 | sessions, | |
| 194 | activeTabId: activeTabId ?? snapshot.tabId, | |
| 195 | snapshot: activeSnapshot, | |
| 196 | desktopOverlay: current.desktopByTabId[activeSnapshot.tabId] ?? null, | |
| 197 | recentlySeenUrls, | |
| 198 | }; | |
| 187 | if (current.serverEpoch !== null && event.serverEpoch !== current.serverEpoch) return current; | |
| 188 | if (event.revision < current.serverRevision) return current; | |
| 189 | const next = (() => { | |
| 190 | switch (event.type) { | |
| 191 | case "opened": | |
| 192 | case "navigated": | |
| 193 | case "resized": { | |
| 194 | const snapshot = event.snapshot; | |
| 195 | if (current.suppressedTabIds.has(snapshot.tabId)) return current; | |
| 196 | const recentlySeenUrls = | |
| 197 | snapshot.navStatus._tag === "Idle" | |
| 198 | ? current.recentlySeenUrls | |
| 199 | : dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url); | |
| 200 | const sessions = { ...current.sessions, [snapshot.tabId]: snapshot }; | |
| 201 | const activeTabId = event.type === "opened" ? snapshot.tabId : current.activeTabId; | |
| 202 | const activeSnapshot = sessions[activeTabId ?? snapshot.tabId] ?? snapshot; | |
| 203 | return { | |
| 204 | ...current, | |
| 205 | sessions, | |
| 206 | activeTabId: activeTabId ?? snapshot.tabId, | |
| 207 | snapshot: activeSnapshot, | |
| 208 | desktopOverlay: current.desktopByTabId[activeSnapshot.tabId] ?? null, | |
| 209 | recentlySeenUrls, | |
| 210 | }; | |
| 211 | } | |
| 212 | case "failed": { | |
| 213 | const existing = current.sessions[event.tabId]; | |
| 214 | if (!existing) return current; | |
| 215 | const failedSnapshot = { | |
| 216 | ...existing, | |
| 217 | navStatus: { | |
| 218 | _tag: "LoadFailed" as const, | |
| 219 | url: event.url, | |
| 220 | title: event.title, | |
| 221 | code: event.code, | |
| 222 | description: event.description, | |
| 223 | }, | |
| 224 | updatedAt: event.createdAt, | |
| 225 | }; | |
| 226 | const sessions = { ...current.sessions, [event.tabId]: failedSnapshot }; | |
| 227 | return { | |
| 228 | ...current, | |
| 229 | sessions, | |
| 230 | snapshot: current.activeTabId === event.tabId ? failedSnapshot : current.snapshot, | |
| 231 | }; | |
| 232 | } | |
| 233 | case "closed": { | |
| 234 | const closed = removeSession(current, event.tabId); | |
| 235 | if (!closed.suppressedTabIds.has(event.tabId)) return closed; | |
| 236 | const suppressedTabIds = new Set(closed.suppressedTabIds); | |
| 237 | suppressedTabIds.delete(event.tabId); | |
| 238 | return { ...closed, suppressedTabIds }; | |
| 239 | } | |
| 199 | 240 | } |
| 200 | case "failed": { | |
| 201 | const existing = current.sessions[event.tabId]; | |
| 202 | if (!existing) return current; | |
| 203 | const failedSnapshot = { | |
| 204 | ...existing, | |
| 205 | navStatus: { | |
| 206 | _tag: "LoadFailed" as const, | |
| 207 | url: event.url, | |
| 208 | title: event.title, | |
| 209 | code: event.code, | |
| 210 | description: event.description, | |
| 211 | }, | |
| 212 | updatedAt: event.createdAt, | |
| 213 | }; | |
| 214 | const sessions = { ...current.sessions, [event.tabId]: failedSnapshot }; | |
| 215 | return { | |
| 216 | ...current, | |
| 217 | sessions, | |
| 218 | snapshot: current.activeTabId === event.tabId ? failedSnapshot : current.snapshot, | |
| 241 | })(); | |
| 242 | return next.serverRevision === event.revision && next.serverEpoch === event.serverEpoch | |
| 243 | ? next | |
| 244 | : { | |
| 245 | ...next, | |
| 246 | serverEpoch: event.serverEpoch, | |
| 247 | serverRevision: event.revision, | |
| 219 | 248 | }; |
| 220 | } | |
| 221 | case "closed": | |
| 222 | return removeSession(current, event.tabId); | |
| 223 | } | |
| 224 | 249 | }); |
| 225 | 250 | } |
| 226 | 251 | |
| ... | ... | @@ -291,9 +316,12 @@ export function updatePreviewServerSnapshot( |
| 291 | 316 | */ |
| 292 | 317 | export function reconcilePreviewServerSessions( |
| 293 | 318 | ref: ScopedThreadRef, |
| 294 | snapshots: ReadonlyArray<PreviewSessionSnapshot>, | |
| 319 | result: PreviewListResult, | |
| 295 | 320 | ): void { |
| 296 | 321 | updateThreadPreviewState(ref, (current) => { |
| 322 | const sameServer = current.serverEpoch === result.serverEpoch; | |
| 323 | if (sameServer && result.revision < current.serverRevision) return current; | |
| 324 | const snapshots = result.sessions; | |
| 297 | 325 | const sessions: Record<string, PreviewSessionSnapshot> = {}; |
| 298 | 326 | let recentlySeenUrls = current.recentlySeenUrls; |
| 299 | 327 | for (const snapshot of snapshots) { |
| ... | ... | @@ -313,14 +341,22 @@ export function reconcilePreviewServerSessions( |
| 313 | 341 | const desktopByTabId = Object.fromEntries( |
| 314 | 342 | Object.entries(current.desktopByTabId).filter(([tabId]) => sessions[tabId] !== undefined), |
| 315 | 343 | ); |
| 344 | const suppressedTabIds = new Set( | |
| 345 | [...current.suppressedTabIds].filter((tabId) => | |
| 346 | snapshots.some((snapshot) => snapshot.tabId === tabId), | |
| 347 | ), | |
| 348 | ); | |
| 316 | 349 | return { |
| 317 | 350 | ...current, |
| 318 | 351 | sessions, |
| 352 | suppressedTabIds, | |
| 319 | 353 | activeTabId, |
| 320 | 354 | snapshot, |
| 321 | 355 | desktopByTabId, |
| 322 | 356 | desktopOverlay: activeTabId ? (desktopByTabId[activeTabId] ?? null) : null, |
| 323 | 357 | recentlySeenUrls, |
| 358 | serverEpoch: result.serverEpoch, | |
| 359 | serverRevision: result.revision, | |
| 324 | 360 | }; |
| 325 | 361 | }); |
| 326 | 362 | } |
packages/contracts/src/ipc.ts+7| ... | ... | @@ -527,6 +527,8 @@ export interface DesktopPreviewTabState { |
| 527 | 527 | canGoForward: boolean; |
| 528 | 528 | /** Current zoom factor (1.0 = 100%). */ |
| 529 | 529 | zoomFactor: number; |
| 530 | /** Whether this tab is currently mirrored into a desktop picture-in-picture window. */ | |
| 531 | pictureInPicture: boolean; | |
| 530 | 532 | colorScheme: DesktopPreviewColorScheme; |
| 531 | 533 | controller: "human" | "agent" | "none"; |
| 532 | 534 | updatedAt: string; |
| ... | ... | @@ -564,6 +566,7 @@ export const DesktopPreviewTabStateSchema: Schema.Codec<DesktopPreviewTabState> |
| 564 | 566 | canGoBack: Schema.Boolean, |
| 565 | 567 | canGoForward: Schema.Boolean, |
| 566 | 568 | zoomFactor: Schema.Number, |
| 569 | pictureInPicture: Schema.Boolean, | |
| 567 | 570 | colorScheme: DesktopPreviewColorSchemeSchema, |
| 568 | 571 | controller: Schema.Literals(["human", "agent", "none"]), |
| 569 | 572 | updatedAt: Schema.String, |
| ... | ... | @@ -1080,6 +1083,10 @@ export interface DesktopPreviewBridge { |
| 1080 | 1083 | captureScreenshot: (tabId: string) => Promise<DesktopPreviewScreenshotArtifact>; |
| 1081 | 1084 | revealArtifact: (path: string) => Promise<void>; |
| 1082 | 1085 | copyArtifactToClipboard: (path: string) => Promise<void>; |
| 1086 | pictureInPicture: { | |
| 1087 | open: (tabId: string) => Promise<void>; | |
| 1088 | close: (tabId: string) => Promise<void>; | |
| 1089 | }; | |
| 1083 | 1090 | recording: { |
| 1084 | 1091 | startScreencast: (tabId: string) => Promise<void>; |
| 1085 | 1092 | stopScreencast: (tabId: string) => Promise<void>; |
packages/contracts/src/preview.test.ts+18| ... | ... | @@ -29,6 +29,16 @@ const decodeAutomationHost = Schema.decodeUnknownSync(PreviewAutomationHost); |
| 29 | 29 | const decodeAutomationError = Schema.decodeUnknownSync(PreviewAutomationError); |
| 30 | 30 | const decodeAutomationStatus = Schema.decodeUnknownSync(PreviewAutomationStatus); |
| 31 | 31 | |
| 32 | describe("PreviewAutomationOpenInput", () => { | |
| 33 | it("accepts the inline preview visibility flag", () => { | |
| 34 | expect(decodeOpenInput({ open: false })).toEqual({ open: false }); | |
| 35 | }); | |
| 36 | ||
| 37 | it("retains the legacy show visibility alias", () => { | |
| 38 | expect(decodeOpenInput({ show: false })).toEqual({ show: false }); | |
| 39 | }); | |
| 40 | }); | |
| 41 | ||
| 32 | 42 | describe("PreviewNavStatus", () => { |
| 33 | 43 | it("decodes Idle", () => { |
| 34 | 44 | expect(decodeNavStatus({ _tag: "Idle" })).toEqual({ _tag: "Idle" }); |
| ... | ... | @@ -216,6 +226,8 @@ describe("PreviewEvent", () => { |
| 216 | 226 | threadId: "t", |
| 217 | 227 | tabId: "preview-t", |
| 218 | 228 | createdAt: "2026-01-01T00:00:00.000Z", |
| 229 | serverEpoch: "server-a", | |
| 230 | revision: 1, | |
| 219 | 231 | snapshot: { |
| 220 | 232 | threadId: "t", |
| 221 | 233 | tabId: "preview-t", |
| ... | ... | @@ -234,6 +246,8 @@ describe("PreviewEvent", () => { |
| 234 | 246 | threadId: "t", |
| 235 | 247 | tabId: "preview-t", |
| 236 | 248 | createdAt: "2026-01-01T00:00:00.000Z", |
| 249 | serverEpoch: "server-a", | |
| 250 | revision: 1, | |
| 237 | 251 | url: "https://example.com/", |
| 238 | 252 | title: "", |
| 239 | 253 | code: -105, |
| ... | ... | @@ -251,6 +265,8 @@ describe("PreviewEvent", () => { |
| 251 | 265 | threadId: "t", |
| 252 | 266 | tabId: "preview-t", |
| 253 | 267 | createdAt: "2026-01-01T00:00:00.000Z", |
| 268 | serverEpoch: "server-a", | |
| 269 | revision: 1, | |
| 254 | 270 | snapshot: { |
| 255 | 271 | threadId: "t", |
| 256 | 272 | tabId: "preview-t", |
| ... | ... | @@ -270,6 +286,8 @@ describe("PreviewEvent", () => { |
| 270 | 286 | threadId: "t", |
| 271 | 287 | tabId: "preview-t", |
| 272 | 288 | createdAt: "2026-01-01T00:00:00.000Z", |
| 289 | serverEpoch: "server-a", | |
| 290 | revision: 1, | |
| 273 | 291 | }); |
| 274 | 292 | expect(event.type).toBe("closed"); |
| 275 | 293 | }); |
packages/contracts/src/preview.ts+9-1| ... | ... | @@ -9,7 +9,7 @@ |
| 9 | 9 | * @module Preview |
| 10 | 10 | */ |
| 11 | 11 | import { Schema } from "effect"; |
| 12 | import { ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; | |
| 12 | import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; | |
| 13 | 13 | |
| 14 | 14 | const Url = TrimmedNonEmptyString.check(Schema.isMaxLength(2048)); |
| 15 | 15 | const Title = Schema.String.check(Schema.isMaxLength(512)); |
| ... | ... | @@ -192,6 +192,10 @@ export type PreviewListInput = typeof PreviewListInput.Type; |
| 192 | 192 | |
| 193 | 193 | export const PreviewListResult = Schema.Struct({ |
| 194 | 194 | sessions: Schema.Array(PreviewSessionSnapshot), |
| 195 | /** Identifies the current server process so revision resets are safe. */ | |
| 196 | serverEpoch: TrimmedNonEmptyString, | |
| 197 | /** Monotonic server state revision used to reject stale list responses. */ | |
| 198 | revision: NonNegativeInt, | |
| 195 | 199 | }); |
| 196 | 200 | export type PreviewListResult = typeof PreviewListResult.Type; |
| 197 | 201 | |
| ... | ... | @@ -199,6 +203,10 @@ const PreviewEventBaseSchema = Schema.Struct({ |
| 199 | 203 | threadId: TrimmedNonEmptyString, |
| 200 | 204 | tabId: PreviewTabId, |
| 201 | 205 | createdAt: Schema.String, |
| 206 | /** Identifies the server process that emitted this event. */ | |
| 207 | serverEpoch: TrimmedNonEmptyString, | |
| 208 | /** Monotonic server state revision shared with PreviewListResult. */ | |
| 209 | revision: PositiveInt, | |
| 202 | 210 | }); |
| 203 | 211 | |
| 204 | 212 | const PreviewOpenedEvent = Schema.Struct({ |
packages/contracts/src/previewAutomation.ts+8-1| ... | ... | @@ -86,9 +86,16 @@ export const PreviewAutomationOpenInput = Schema.Struct({ |
| 86 | 86 | description: |
| 87 | 87 | "Optional initial page URL, for example https://t3.chat or localhost:5173. Omit to open a blank tab.", |
| 88 | 88 | }), |
| 89 | open: Schema.optional( | |
| 90 | Schema.Boolean.annotate({ | |
| 91 | description: | |
| 92 | "Whether to open the thread-bound inline preview for the human. Defaults to true; set false for background-only automation.", | |
| 93 | }), | |
| 94 | ), | |
| 89 | 95 | show: Schema.optional( |
| 90 | 96 | Schema.Boolean.annotate({ |
| 91 | description: "Whether to reveal the preview panel to the human. Defaults to true.", | |
| 97 | description: | |
| 98 | "Deprecated alias for open. Whether to reveal the thread-bound inline preview to the human.", | |
| 92 | 99 | }), |
| 93 | 100 | ), |
| 94 | 101 | reuseExistingTab: Schema.optional( |