| 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,6 +94,7 @@ describe("ElectronWindow", () => { |
| 94 | webPreferences: { | 94 | webPreferences: { |
| 95 | preload: "/tmp/preload.js", | 95 | preload: "/tmp/preload.js", |
| 96 | partition: "persist:t3code-preview-test", | 96 | partition: "persist:t3code-preview-test", |
| 97 | backgroundThrottling: null, | ||
| 97 | sandbox: true, | 98 | sandbox: true, |
| 98 | contextIsolation: true, | 99 | contextIsolation: true, |
| 99 | nodeIntegration: false, | 100 | nodeIntegration: false, |
apps/desktop/src/electron/ElectronWindow.ts+2| ... | @@ -22,6 +22,7 @@ const ElectronWindowCreateOptions = Schema.Struct({ | ... | @@ -22,6 +22,7 @@ const ElectronWindowCreateOptions = Schema.Struct({ |
| 22 | webPreferences: Schema.Struct({ | 22 | webPreferences: Schema.Struct({ |
| 23 | preload: Schema.NullOr(Schema.String), | 23 | preload: Schema.NullOr(Schema.String), |
| 24 | partition: Schema.NullOr(Schema.String), | 24 | partition: Schema.NullOr(Schema.String), |
| 25 | backgroundThrottling: Schema.NullOr(Schema.Boolean), | ||
| 25 | sandbox: Schema.NullOr(Schema.Boolean), | 26 | sandbox: Schema.NullOr(Schema.Boolean), |
| 26 | contextIsolation: Schema.NullOr(Schema.Boolean), | 27 | contextIsolation: Schema.NullOr(Schema.Boolean), |
| 27 | nodeIntegration: Schema.NullOr(Schema.Boolean), | 28 | nodeIntegration: Schema.NullOr(Schema.Boolean), |
| ... | @@ -179,6 +180,7 @@ export const make = Effect.gen(function* () { | ... | @@ -179,6 +180,7 @@ export const make = Effect.gen(function* () { |
| 179 | webPreferences: { | 180 | webPreferences: { |
| 180 | preload: webPreferences?.preload ?? null, | 181 | preload: webPreferences?.preload ?? null, |
| 181 | partition: webPreferences?.partition ?? null, | 182 | partition: webPreferences?.partition ?? null, |
| 183 | backgroundThrottling: webPreferences?.backgroundThrottling ?? null, | ||
| 182 | sandbox: webPreferences?.sandbox ?? null, | 184 | sandbox: webPreferences?.sandbox ?? null, |
| 183 | contextIsolation: webPreferences?.contextIsolation ?? null, | 185 | contextIsolation: webPreferences?.contextIsolation ?? null, |
| 184 | nodeIntegration: webPreferences?.nodeIntegration ?? null, | 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,6 +61,9 @@ export const PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL = "desktop:preview-cancel-pick- |
| 61 | export const PREVIEW_CAPTURE_SCREENSHOT_CHANNEL = "desktop:preview-capture-screenshot"; | 61 | export const PREVIEW_CAPTURE_SCREENSHOT_CHANNEL = "desktop:preview-capture-screenshot"; |
| 62 | export const PREVIEW_REVEAL_ARTIFACT_CHANNEL = "desktop:preview-reveal-artifact"; | 62 | export const PREVIEW_REVEAL_ARTIFACT_CHANNEL = "desktop:preview-reveal-artifact"; |
| 63 | export const PREVIEW_COPY_ARTIFACT_CHANNEL = "desktop:preview-copy-artifact"; | 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 | export const PREVIEW_AUTOMATION_STATUS_CHANNEL = "desktop:preview-automation-status"; | 67 | export const PREVIEW_AUTOMATION_STATUS_CHANNEL = "desktop:preview-automation-status"; |
| 65 | export const PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL = "desktop:preview-automation-snapshot"; | 68 | export const PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL = "desktop:preview-automation-snapshot"; |
| 66 | export const PREVIEW_AUTOMATION_CLICK_CHANNEL = "desktop:preview-automation-click"; | 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,6 +168,16 @@ export const stopRecording = tabMethod( |
| 168 | "desktop.ipc.preview.stopRecording", | 168 | "desktop.ipc.preview.stopRecording", |
| 169 | (manager, tabId) => manager.stopRecording(tabId), | 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 | export const clearCookies = DesktopIpc.makeIpcMethod({ | 182 | export const clearCookies = DesktopIpc.makeIpcMethod({ |
| 173 | channel: IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL, | 183 | channel: IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL, |
| ... | @@ -367,6 +377,8 @@ export const methods = [ | ... | @@ -367,6 +377,8 @@ export const methods = [ |
| 367 | captureScreenshot, | 377 | captureScreenshot, |
| 368 | revealArtifact, | 378 | revealArtifact, |
| 369 | copyArtifactToClipboard, | 379 | copyArtifactToClipboard, |
| 380 | openPictureInPicture, | ||
| 381 | closePictureInPicture, | ||
| 370 | automationStatus, | 382 | automationStatus, |
| 371 | automationSnapshot, | 383 | automationSnapshot, |
| 372 | automationClick, | 384 | automationClick, |
apps/desktop/src/preload.ts+6| ... | @@ -179,6 +179,12 @@ contextBridge.exposeInMainWorld("desktopBridge", { | ... | @@ -179,6 +179,12 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 179 | ipcRenderer.invoke(IpcChannels.PREVIEW_REVEAL_ARTIFACT_CHANNEL, { path }), | 179 | ipcRenderer.invoke(IpcChannels.PREVIEW_REVEAL_ARTIFACT_CHANNEL, { path }), |
| 180 | copyArtifactToClipboard: (path) => | 180 | copyArtifactToClipboard: (path) => |
| 181 | ipcRenderer.invoke(IpcChannels.PREVIEW_COPY_ARTIFACT_CHANNEL, { path }), | 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 | recording: { | 188 | recording: { |
| 183 | startScreencast: (tabId) => | 189 | startScreencast: (tabId) => |
| 184 | ipcRenderer.invoke(IpcChannels.PREVIEW_RECORDING_START_CHANNEL, { tabId }), | 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,6 +1,8 @@ |
| 1 | import { it as effectIt } from "@effect/vitest"; | 1 | import { it as effectIt } from "@effect/vitest"; |
| 2 | import type { DesktopPreviewRecordingFrame } from "@t3tools/contracts"; | ||
| 2 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | 3 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 3 | import * as Cause from "effect/Cause"; | 4 | import * as Cause from "effect/Cause"; |
| 5 | import * as Deferred from "effect/Deferred"; | ||
| 4 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| 5 | import * as Exit from "effect/Exit"; | 7 | import * as Exit from "effect/Exit"; |
| 6 | import * as FileSystem from "effect/FileSystem"; | 8 | import * as FileSystem from "effect/FileSystem"; |
| ... | @@ -19,7 +21,15 @@ import * as ElectronWindow from "../electron/ElectronWindow.ts"; | ... | @@ -19,7 +21,15 @@ import * as ElectronWindow from "../electron/ElectronWindow.ts"; |
| 19 | import * as BrowserSession from "./BrowserSession.ts"; | 21 | import * as BrowserSession from "./BrowserSession.ts"; |
| 20 | import * as PreviewManager from "./Manager.ts"; | 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 | const { | 31 | const { |
| 32 | browserWindowConstructor, | ||
| 23 | createFromPath, | 33 | createFromPath, |
| 24 | fromId, | 34 | fromId, |
| 25 | getFocusedWebContents, | 35 | getFocusedWebContents, |
| ... | @@ -29,8 +39,9 @@ const { | ... | @@ -29,8 +39,9 @@ const { |
| 29 | writeFile, | 39 | writeFile, |
| 30 | writeImage, | 40 | writeImage, |
| 31 | } = vi.hoisted(() => ({ | 41 | } = vi.hoisted(() => ({ |
| 42 | browserWindowConstructor: vi.fn(), | ||
| 32 | createFromPath: vi.fn((): { readonly isEmpty: () => boolean } => ({ isEmpty: () => false })), | 43 | createFromPath: vi.fn((): { readonly isEmpty: () => boolean } => ({ isEmpty: () => false })), |
| 33 | fromId: vi.fn(() => null), | 44 | fromId: vi.fn((_id?: number) => null), |
| 34 | getFocusedWebContents: vi.fn(() => null), | 45 | getFocusedWebContents: vi.fn(() => null), |
| 35 | mkdir: vi.fn((_path: string) => undefined), | 46 | mkdir: vi.fn((_path: string) => undefined), |
| 36 | showItemInFolder: vi.fn(), | 47 | showItemInFolder: vi.fn(), |
| ... | @@ -40,6 +51,7 @@ const { | ... | @@ -40,6 +51,7 @@ const { |
| 40 | })); | 51 | })); |
| 41 | 52 | ||
| 42 | vi.mock("electron", () => ({ | 53 | vi.mock("electron", () => ({ |
| 54 | BrowserWindow: browserWindowConstructor, | ||
| 43 | clipboard: { | 55 | clipboard: { |
| 44 | writeImage, | 56 | writeImage, |
| 45 | }, | 57 | }, |
| ... | @@ -73,6 +85,10 @@ const environmentLayer = Layer.succeed( | ... | @@ -73,6 +85,10 @@ const environmentLayer = Layer.succeed( |
| 73 | DesktopEnvironment.DesktopEnvironment, | 85 | DesktopEnvironment.DesktopEnvironment, |
| 74 | DesktopEnvironment.DesktopEnvironment.of({ | 86 | DesktopEnvironment.DesktopEnvironment.of({ |
| 75 | browserArtifactsDir: "/tmp/t3/dev/browser-artifacts", | 87 | browserArtifactsDir: "/tmp/t3/dev/browser-artifacts", |
| 88 | dirname: "/tmp/t3/desktop", | ||
| 89 | path: { | ||
| 90 | join: (...parts: ReadonlyArray<string>) => parts.join("/"), | ||
| 91 | }, | ||
| 76 | } as DesktopEnvironment.DesktopEnvironment["Service"]), | 92 | } as DesktopEnvironment.DesktopEnvironment["Service"]), |
| 77 | ); | 93 | ); |
| 78 | 94 | ||
| ... | @@ -92,7 +108,7 @@ const layer = PreviewManager.layer.pipe( | ... | @@ -92,7 +108,7 @@ const layer = PreviewManager.layer.pipe( |
| 92 | Layer.provideMerge(environmentLayer), | 108 | Layer.provideMerge(environmentLayer), |
| 93 | Layer.provideMerge(fileSystemLayer), | 109 | Layer.provideMerge(fileSystemLayer), |
| 94 | Layer.provideMerge(Path.layer), | 110 | Layer.provideMerge(Path.layer), |
| 95 | Layer.provideMerge(Layer.succeed(HostProcessPlatform, "linux")), | 111 | Layer.provideMerge(Layer.succeed(HostProcessPlatform, "darwin")), |
| 96 | ); | 112 | ); |
| 97 | const encodePreviewManagerError = Schema.encodeSync(PreviewManager.PreviewManagerError); | 113 | const encodePreviewManagerError = Schema.encodeSync(PreviewManager.PreviewManagerError); |
| 98 | 114 | ||
| ... | @@ -106,8 +122,73 @@ const withManager = <A>( | ... | @@ -106,8 +122,73 @@ const withManager = <A>( |
| 106 | return yield* use(manager); | 122 | return yield* use(manager); |
| 107 | }).pipe(Effect.provide(layer), Effect.scoped); | 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 | describe("PreviewManager", () => { | 189 | describe("PreviewManager", () => { |
| 110 | beforeEach(() => { | 190 | beforeEach(() => { |
| 191 | browserWindowConstructor.mockReset(); | ||
| 111 | fromId.mockClear(); | 192 | fromId.mockClear(); |
| 112 | getFocusedWebContents.mockReset(); | 193 | getFocusedWebContents.mockReset(); |
| 113 | getFocusedWebContents.mockReturnValue(null); | 194 | getFocusedWebContents.mockReturnValue(null); |
| ... | @@ -438,6 +519,80 @@ describe("PreviewManager", () => { | ... | @@ -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 | effectIt.effect("keeps a main-frame load failure visible until a retry starts", () => | 596 | effectIt.effect("keeps a main-frame load failure visible until a retry starts", () => |
| 442 | withManager((manager) => | 597 | withManager((manager) => |
| 443 | Effect.gen(function* () { | 598 | Effect.gen(function* () { |
| ... | @@ -603,6 +758,614 @@ describe("PreviewManager", () => { | ... | @@ -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 | effectIt.effect("keeps element picking active during subframe navigation", () => | 1369 | effectIt.effect("keeps element picking active during subframe navigation", () => |
| 607 | withManager((manager) => | 1370 | withManager((manager) => |
| 608 | Effect.gen(function* () { | 1371 | Effect.gen(function* () { |
apps/desktop/src/preview/Manager.ts+797-165| ... | @@ -28,21 +28,16 @@ import type { | ... | @@ -28,21 +28,16 @@ import type { |
| 28 | } from "@t3tools/contracts"; | 28 | } from "@t3tools/contracts"; |
| 29 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | 29 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 30 | import { normalizePreviewUrl } from "@t3tools/shared/preview"; | 30 | import { normalizePreviewUrl } from "@t3tools/shared/preview"; |
| 31 | import { | 31 | import { BrowserWindow, type Session, clipboard, nativeImage, shell, webContents } from "electron"; |
| 32 | type BrowserWindow, | ||
| 33 | type Session, | ||
| 34 | clipboard, | ||
| 35 | nativeImage, | ||
| 36 | shell, | ||
| 37 | webContents, | ||
| 38 | } from "electron"; | ||
| 39 | import * as Cause from "effect/Cause"; | 32 | import * as Cause from "effect/Cause"; |
| 40 | import * as Clock from "effect/Clock"; | 33 | import * as Clock from "effect/Clock"; |
| 41 | import * as Context from "effect/Context"; | 34 | import * as Context from "effect/Context"; |
| 42 | import * as DateTime from "effect/DateTime"; | 35 | import * as DateTime from "effect/DateTime"; |
| 36 | import * as Deferred from "effect/Deferred"; | ||
| 43 | import * as Effect from "effect/Effect"; | 37 | import * as Effect from "effect/Effect"; |
| 44 | import * as Exit from "effect/Exit"; | 38 | import * as Exit from "effect/Exit"; |
| 45 | import * as FileSystem from "effect/FileSystem"; | 39 | import * as FileSystem from "effect/FileSystem"; |
| 40 | import * as Fiber from "effect/Fiber"; | ||
| 46 | import * as Layer from "effect/Layer"; | 41 | import * as Layer from "effect/Layer"; |
| 47 | import * as Option from "effect/Option"; | 42 | import * as Option from "effect/Option"; |
| 48 | import * as Path from "effect/Path"; | 43 | import * as Path from "effect/Path"; |
| ... | @@ -53,6 +48,7 @@ import * as Scope from "effect/Scope"; | ... | @@ -53,6 +48,7 @@ import * as Scope from "effect/Scope"; |
| 53 | import * as SynchronizedRef from "effect/SynchronizedRef"; | 48 | import * as SynchronizedRef from "effect/SynchronizedRef"; |
| 54 | 49 | ||
| 55 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | 50 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 51 | import { PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL } from "../ipc/channels.ts"; | ||
| 56 | import * as BrowserSession from "./BrowserSession.ts"; | 52 | import * as BrowserSession from "./BrowserSession.ts"; |
| 57 | import { | 53 | import { |
| 58 | ANNOTATION_CAPTURED_CHANNEL, | 54 | ANNOTATION_CAPTURED_CHANNEL, |
| ... | @@ -85,6 +81,7 @@ export interface PreviewTabState { | ... | @@ -85,6 +81,7 @@ export interface PreviewTabState { |
| 85 | canGoBack: boolean; | 81 | canGoBack: boolean; |
| 86 | canGoForward: boolean; | 82 | canGoForward: boolean; |
| 87 | zoomFactor: number; | 83 | zoomFactor: number; |
| 84 | pictureInPicture: boolean; | ||
| 88 | colorScheme: DesktopPreviewColorScheme; | 85 | colorScheme: DesktopPreviewColorScheme; |
| 89 | controller: "human" | "agent" | "none"; | 86 | controller: "human" | "agent" | "none"; |
| 90 | updatedAt: string; | 87 | updatedAt: string; |
| ... | @@ -101,6 +98,12 @@ const MAX_EVALUATION_BYTES = 64_000; | ... | @@ -101,6 +98,12 @@ const MAX_EVALUATION_BYTES = 64_000; |
| 101 | const MAX_VISIBLE_TEXT_LENGTH = 20_000; | 98 | const MAX_VISIBLE_TEXT_LENGTH = 20_000; |
| 102 | const MAX_INTERACTIVE_ELEMENTS = 200; | 99 | const MAX_INTERACTIVE_ELEMENTS = 200; |
| 103 | const MAX_SCREENSHOT_WIDTH = 1280; | 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 | const DIAGNOSTIC_BUFFER_LIMIT = 200; | 107 | const DIAGNOSTIC_BUFFER_LIMIT = 200; |
| 105 | const MAX_ARTIFACT_SITE_SLUG_LENGTH = 80; | 108 | const MAX_ARTIFACT_SITE_SLUG_LENGTH = 80; |
| 106 | const AGENT_CURSOR_MOVE_MS = 160; | 109 | const AGENT_CURSOR_MOVE_MS = 160; |
| ... | @@ -126,6 +129,54 @@ const DEFAULT_ANNOTATION_THEME: DesktopPreviewAnnotationTheme = { | ... | @@ -126,6 +129,54 @@ const DEFAULT_ANNOTATION_THEME: DesktopPreviewAnnotationTheme = { |
| 126 | fontMono: "ui-monospace, monospace", | 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 | const artifactSiteSlug = (rawUrl: string): string => { | 180 | const artifactSiteSlug = (rawUrl: string): string => { |
| 130 | try { | 181 | try { |
| 131 | const url = new URL(rawUrl); | 182 | const url = new URL(rawUrl); |
| ... | @@ -296,6 +347,20 @@ interface ManagedListeners { | ... | @@ -296,6 +347,20 @@ interface ManagedListeners { |
| 296 | readonly scope: Scope.Closeable; | 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 | interface PickSession { | 364 | interface PickSession { |
| 300 | readonly cancel: Effect.Effect<void>; | 365 | readonly cancel: Effect.Effect<void>; |
| 301 | } | 366 | } |
| ... | @@ -380,6 +445,7 @@ const inputSignalsMatch = (left: PreviewInputSignal, right: PreviewInputSignal): | ... | @@ -380,6 +445,7 @@ const inputSignalsMatch = (left: PreviewInputSignal, right: PreviewInputSignal): |
| 380 | 445 | ||
| 381 | const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function* ( | 446 | const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function* ( |
| 382 | artifactDirectory: string, | 447 | artifactDirectory: string, |
| 448 | pictureInPicturePreloadPath: string, | ||
| 383 | ) { | 449 | ) { |
| 384 | const fileSystem = yield* FileSystem.FileSystem; | 450 | const fileSystem = yield* FileSystem.FileSystem; |
| 385 | const hostPlatform = yield* HostProcessPlatform; | 451 | const hostPlatform = yield* HostProcessPlatform; |
| ... | @@ -415,7 +481,20 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -415,7 +481,20 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 415 | >(new Map()); | 481 | >(new Map()); |
| 416 | const actionSequenceRef = yield* Ref.make(0); | 482 | const actionSequenceRef = yield* Ref.make(0); |
| 417 | const pointerSequenceRef = yield* Ref.make(0); | 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 | const attempt = <A>(errorContext: PreviewOperationContext, evaluate: () => A) => | 499 | const attempt = <A>(errorContext: PreviewOperationContext, evaluate: () => A) => |
| 421 | Effect.try({ | 500 | Effect.try({ |
| ... | @@ -446,6 +525,58 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -446,6 +525,58 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 446 | update(copy); | 525 | update(copy); |
| 447 | return copy; | 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 | const deliverEvent = ( | 581 | const deliverEvent = ( |
| 451 | eventKind: "state-change" | "recording-frame" | "pointer-event", | 582 | eventKind: "state-change" | "recording-frame" | "pointer-event", |
| ... | @@ -539,15 +670,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -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 | const pushBounded = <A>(buffer: ReadonlyArray<A>, entry: A): ReadonlyArray<A> => | 673 | const pushBounded = <A>(buffer: ReadonlyArray<A>, entry: A): ReadonlyArray<A> => |
| 552 | [...buffer, entry].slice(-DIAGNOSTIC_BUFFER_LIMIT); | 674 | [...buffer, entry].slice(-DIAGNOSTIC_BUFFER_LIMIT); |
| 553 | 675 | ||
| ... | @@ -731,42 +853,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -731,42 +853,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 731 | const scope = yield* Scope.fork(parentScope, "sequential"); | 853 | const scope = yield* Scope.fork(parentScope, "sequential"); |
| 732 | const handleDebuggerMessage = Effect.fn("PreviewManager.handleDebuggerMessage")( | 854 | const handleDebuggerMessage = Effect.fn("PreviewManager.handleDebuggerMessage")( |
| 733 | function* (method: string, params: Record<string, unknown>) { | 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 | yield* captureDiagnosticMessage(wc.id, method, params); | 856 | yield* captureDiagnosticMessage(wc.id, method, params); |
| 771 | }, | 857 | }, |
| 772 | ); | 858 | ); |
| ... | @@ -1276,71 +1362,132 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -1276,71 +1362,132 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1276 | window: BrowserWindow, | 1362 | window: BrowserWindow, |
| 1277 | ) { | 1363 | ) { |
| 1278 | yield* Ref.set(mainWindowRef, Option.some(window)); | 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 | const updatedAt = yield* currentIso; | 1373 | const updatedAt = yield* currentIso; |
| 1283 | const state = yield* SynchronizedRef.modify(tabsRef, (tabs) => { | 1374 | const result = yield* SynchronizedRef.modify( |
| 1284 | const existing = tabs.get(tabId); | 1375 | tabsRef, |
| 1285 | if (existing) return [existing, tabs] as const; | 1376 | ( |
| 1286 | const initial: PreviewTabState = { | 1377 | tabs, |
| 1287 | tabId, | 1378 | ): readonly [ |
| 1288 | webContentsId: null, | 1379 | { readonly state: PreviewTabState; readonly created: boolean }, |
| 1289 | navStatus: { kind: "Idle" }, | 1380 | ReadonlyMap<string, PreviewTabState>, |
| 1290 | canGoBack: false, | 1381 | ] => { |
| 1291 | canGoForward: false, | 1382 | const existing = tabs.get(tabId); |
| 1292 | zoomFactor: DEFAULT_ZOOM_FACTOR, | 1383 | if (existing) return [{ state: existing, created: false }, tabs] as const; |
| 1293 | colorScheme: "system", | 1384 | const initial: PreviewTabState = { |
| 1294 | controller: "none", | 1385 | tabId, |
| 1295 | updatedAt, | 1386 | webContentsId: null, |
| 1296 | }; | 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 | return [ | 1431 | return [ |
| 1298 | initial, | 1432 | Option.some(current), |
| 1299 | replaceMap(tabs, (copy) => { | 1433 | replaceMap(tabs, (copy) => { |
| 1300 | copy.set(tabId, initial); | 1434 | copy.delete(tabId); |
| 1301 | }), | 1435 | }), |
| 1302 | ] as const; | 1436 | ] as const; |
| 1303 | }); | 1437 | }); |
| 1304 | yield* emit(tabId, state); | 1438 | if (Option.isNone(tab)) return; |
| 1305 | return state; | 1439 | const closedTab = tab.value; |
| 1306 | }); | 1440 | if (closedTab.webContentsId != null) { |
| 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) { | ||
| 1313 | yield* Effect.all( | 1441 | yield* Effect.all( |
| 1314 | [detachControlSession(tab.webContentsId), detachListeners(tab.webContentsId)], | 1442 | [detachControlSession(closedTab.webContentsId), detachListeners(closedTab.webContentsId)], |
| 1315 | { concurrency: 2, discard: true }, | 1443 | { concurrency: 2, discard: true }, |
| 1316 | ); | 1444 | ); |
| 1317 | } | 1445 | } |
| 1318 | const updatedAt = yield* currentIso; | 1446 | const updatedAt = yield* currentIso; |
| 1319 | const closed: PreviewTabState = { | 1447 | const closed: PreviewTabState = { |
| 1320 | ...tab, | 1448 | ...closedTab, |
| 1321 | webContentsId: null, | 1449 | webContentsId: null, |
| 1322 | navStatus: { kind: "Idle" }, | 1450 | navStatus: { kind: "Idle" }, |
| 1323 | canGoBack: false, | 1451 | canGoBack: false, |
| 1324 | canGoForward: false, | 1452 | canGoForward: false, |
| 1325 | zoomFactor: DEFAULT_ZOOM_FACTOR, | 1453 | zoomFactor: DEFAULT_ZOOM_FACTOR, |
| 1454 | pictureInPicture: false, | ||
| 1326 | colorScheme: "system", | 1455 | colorScheme: "system", |
| 1327 | controller: "none", | 1456 | controller: "none", |
| 1328 | updatedAt, | 1457 | updatedAt, |
| 1329 | }; | 1458 | }; |
| 1330 | yield* SynchronizedRef.update(tabsRef, (tabs) => | ||
| 1331 | replaceMap(tabs, (copy) => { | ||
| 1332 | copy.delete(tabId); | ||
| 1333 | }), | ||
| 1334 | ); | ||
| 1335 | yield* emit(tabId, closed); | 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 | tabId: string, | 1481 | tabId: string, |
| 1340 | webContentsId: number, | 1482 | webContentsId: number, |
| 1483 | expectedGeneration: number | undefined, | ||
| 1341 | ) { | 1484 | ) { |
| 1342 | const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); | 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 | return yield* new PreviewTabNotFoundError({ tabId }); | 1491 | return yield* new PreviewTabNotFoundError({ tabId }); |
| 1345 | } | 1492 | } |
| 1346 | const wc = webContents.fromId(webContentsId); | 1493 | const wc = webContents.fromId(webContentsId); |
| ... | @@ -1377,53 +1524,71 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -1377,53 +1524,71 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1377 | { concurrency: 3, discard: true }, | 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 | const zoomFactor = | 1535 | const zoomFactor = |
| 1381 | replacedWebContentsId !== null | 1536 | replacedWebContentsId !== null |
| 1382 | ? yield* attempt( | 1537 | ? yield* attempt( |
| 1383 | { operation: "registerWebview.restoreZoomFactor", tabId, webContentsId }, | 1538 | { operation: "registerWebview.restoreZoomFactor", tabId, webContentsId }, |
| 1384 | () => { | 1539 | () => { |
| 1385 | wc.setZoomFactor(tab.zoomFactor); | 1540 | wc.setZoomFactor(currentTab.zoomFactor); |
| 1386 | return tab.zoomFactor; | 1541 | return currentTab.zoomFactor; |
| 1387 | }, | 1542 | }, |
| 1388 | ) | 1543 | ) |
| 1389 | : yield* attempt({ operation: "registerWebview.getZoomFactor", tabId, webContentsId }, () => | 1544 | : yield* attempt({ operation: "registerWebview.getZoomFactor", tabId, webContentsId }, () => |
| 1390 | wc.getZoomFactor(), | 1545 | wc.getZoomFactor(), |
| 1391 | ); | 1546 | ); |
| 1392 | yield* attachListeners(tabId, wc); | 1547 | yield* attachListeners(tabId, wc); |
| 1393 | runFork(restoreControlSession(tabId, wc)); | ||
| 1394 | const registeredAt = yield* currentIso; | 1548 | const registeredAt = yield* currentIso; |
| 1395 | const registration = yield* SynchronizedRef.modify(tabsRef, (tabs) => { | 1549 | const registration = yield* SynchronizedRef.modifyEffect(tabsRef, (tabs) => |
| 1396 | const current = tabs.get(tabId); | 1550 | Effect.gen(function* () { |
| 1397 | if (!current) { | 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 | return [ | 1572 | return [ |
| 1399 | Option.none<{ readonly state: PreviewTabState; readonly pendingUrl: string | null }>(), | 1573 | Option.some({ |
| 1400 | tabs, | 1574 | state: next, |
| 1575 | pendingUrl, | ||
| 1576 | }), | ||
| 1577 | replaceMap(tabs, (copy) => { | ||
| 1578 | copy.set(tabId, next); | ||
| 1579 | }), | ||
| 1401 | ] as const; | 1580 | ] as const; |
| 1402 | } | 1581 | }), |
| 1403 | const pendingUrl = current.navStatus.kind === "Loading" ? current.navStatus.url : null; | 1582 | ); |
| 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 | }); | ||
| 1423 | if (Option.isNone(registration)) { | 1583 | if (Option.isNone(registration)) { |
| 1584 | yield* Effect.all([detachControlSession(webContentsId), detachListeners(webContentsId)], { | ||
| 1585 | concurrency: 2, | ||
| 1586 | discard: true, | ||
| 1587 | }); | ||
| 1424 | return yield* new PreviewTabNotFoundError({ tabId }); | 1588 | return yield* new PreviewTabNotFoundError({ tabId }); |
| 1425 | } | 1589 | } |
| 1426 | const { state: registered, pendingUrl } = registration.value; | 1590 | const { state: registered, pendingUrl } = registration.value; |
| 1591 | runFork(restoreControlSession(tabId, wc)); | ||
| 1427 | yield* emit(tabId, registered); | 1592 | yield* emit(tabId, registered); |
| 1428 | yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => | 1593 | yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => |
| 1429 | wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme), | 1594 | wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme), |
| ... | @@ -1443,6 +1608,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -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 | const navigate = Effect.fn("PreviewManager.navigate")(function* (tabId: string, rawUrl: string) { | 1622 | const navigate = Effect.fn("PreviewManager.navigate")(function* (tabId: string, rawUrl: string) { |
| 1447 | const url = yield* attempt({ operation: "navigate.normalizeUrl", tabId }, () => | 1623 | const url = yield* attempt({ operation: "navigate.normalizeUrl", tabId }, () => |
| 1448 | normalizePreviewUrl(rawUrl), | 1624 | normalizePreviewUrl(rawUrl), |
| ... | @@ -1461,6 +1637,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -1461,6 +1637,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1461 | canGoBack: current?.canGoBack ?? false, | 1637 | canGoBack: current?.canGoBack ?? false, |
| 1462 | canGoForward: current?.canGoForward ?? false, | 1638 | canGoForward: current?.canGoForward ?? false, |
| 1463 | zoomFactor: current?.zoomFactor ?? DEFAULT_ZOOM_FACTOR, | 1639 | zoomFactor: current?.zoomFactor ?? DEFAULT_ZOOM_FACTOR, |
| 1640 | pictureInPicture: current?.pictureInPicture ?? false, | ||
| 1464 | colorScheme: current?.colorScheme ?? "system", | 1641 | colorScheme: current?.colorScheme ?? "system", |
| 1465 | controller: current?.controller ?? "none", | 1642 | controller: current?.controller ?? "none", |
| 1466 | updatedAt, | 1643 | updatedAt, |
| ... | @@ -1716,14 +1893,28 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -1716,14 +1893,28 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1716 | // session attaches so a concurrent setColorScheme is not overwritten with | 1893 | // session attaches so a concurrent setColorScheme is not overwritten with |
| 1717 | // a stale snapshot. | 1894 | // a stale snapshot. |
| 1718 | const restoreControlSession = (tabId: string, wc: Electron.WebContents) => | 1895 | const restoreControlSession = (tabId: string, wc: Electron.WebContents) => |
| 1719 | ensureControlSession(wc).pipe( | 1896 | Effect.gen(function* () { |
| 1720 | Effect.andThen(SynchronizedRef.get(tabsRef)), | 1897 | const beforeAttach = (yield* SynchronizedRef.get(tabsRef)).get(tabId); |
| 1721 | Effect.flatMap((tabs) => { | 1898 | if (beforeAttach?.webContentsId !== wc.id) return; |
| 1722 | const colorScheme = tabs.get(tabId)?.colorScheme ?? "system"; | 1899 | yield* ensureControlSession(wc); |
| 1723 | return colorScheme === "system" ? Effect.void : applyColorScheme(tabId, wc, colorScheme); | 1900 | const afterAttach = (yield* SynchronizedRef.get(tabsRef)).get(tabId); |
| 1724 | }), | 1901 | if (afterAttach?.webContentsId !== wc.id) { |
| 1725 | Effect.ignore, | 1902 | yield* detachControlSession(wc.id); |
| 1726 | ); | 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 | const setColorScheme = Effect.fn("PreviewManager.setColorScheme")(function* ( | 1919 | const setColorScheme = Effect.fn("PreviewManager.setColorScheme")(function* ( |
| 1729 | tabId: string, | 1920 | tabId: string, |
| ... | @@ -1801,40 +1992,485 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -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* ( | 1995 | const capturePreviewFrame = Effect.fn("PreviewManager.capturePreviewFrame")(function* ( |
| 1805 | send: SendCommand, | 1996 | tabId: string, |
| 1806 | ) { | 1997 | ) { |
| 1807 | yield* send("Page.enable"); | 1998 | const captureSession = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(tabId); |
| 1808 | yield* send("Page.startScreencast", { | 1999 | if (!captureSession) return; |
| 1809 | format: "jpeg", | 2000 | const wc = yield* requireWebContents(tabId); |
| 1810 | quality: 80, | 2001 | const image = yield* attemptPromise( |
| 1811 | maxWidth: 1600, | 2002 | { |
| 1812 | maxHeight: 1200, | 2003 | operation: "frameCapture.capturePage", |
| 1813 | everyNthFrame: 1, | 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) { | 2227 | const closePictureInPictureUnlocked = Effect.fn("PreviewManager.closePictureInPictureUnlocked")( |
| 1818 | const recordingTabId = yield* Ref.get(recordingTabIdRef); | 2228 | function* (tabId: string) { |
| 1819 | if (Option.isSome(recordingTabId) && recordingTabId.value !== tabId) { | 2229 | const pictureInPictureSession = (yield* SynchronizedRef.get(pictureInPictureSessionsRef)).get( |
| 1820 | return yield* new PreviewRecordingAlreadyActiveError({ | 2230 | tabId, |
| 1821 | requestedTabId: tabId, | 2231 | ); |
| 1822 | activeTabId: recordingTabId.value, | 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); | 2395 | |
| 1826 | yield* withControlSession(tabId, wc, "recording.start", startScreencast); | 2396 | const initialize = Effect.gen(function* () { |
| 1827 | yield* Ref.set(recordingTabIdRef, Option.some(tabId)); | 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 | const stopRecording = Effect.fn("PreviewManager.stopRecording")(function* (tabId: string) { | 2472 | const stopRecording = Effect.fn("PreviewManager.stopRecording")(function* (tabId: string) { |
| 1831 | const recordingTabId = yield* Ref.get(recordingTabIdRef); | 2473 | yield* stopFrameCapture(tabId, "recording"); |
| 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()); | ||
| 1838 | }); | 2474 | }); |
| 1839 | 2475 | ||
| 1840 | const saveRecording = Effect.fn("PreviewManager.saveRecording")(function* ( | 2476 | const saveRecording = Effect.fn("PreviewManager.saveRecording")(function* ( |
| ... | @@ -2582,6 +3218,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -2582,6 +3218,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 2582 | goForward, | 3218 | goForward, |
| 2583 | hardReload, | 3219 | hardReload, |
| 2584 | navigate, | 3220 | navigate, |
| 3221 | openPictureInPicture, | ||
| 2585 | openDevTools, | 3222 | openDevTools, |
| 2586 | pickElement, | 3223 | pickElement, |
| 2587 | refresh, | 3224 | refresh, |
| ... | @@ -2593,6 +3230,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -2593,6 +3230,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 2593 | setColorScheme, | 3230 | setColorScheme, |
| 2594 | setMainWindow, | 3231 | setMainWindow, |
| 2595 | startRecording, | 3232 | startRecording, |
| 3233 | closePictureInPicture, | ||
| 2596 | stopRecording, | 3234 | stopRecording, |
| 2597 | subscribePointerEvents: (listener: PointerEventListener) => | 3235 | subscribePointerEvents: (listener: PointerEventListener) => |
| 2598 | subscribe(pointerEventListenersRef, listener), | 3236 | subscribe(pointerEventListenersRef, listener), |
| ... | @@ -2678,18 +3316,6 @@ export class PreviewArtifactImageLoadError extends Schema.TaggedErrorClass<Previ | ... | @@ -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 | export class PreviewAutomationDevToolsOpenError extends Schema.TaggedErrorClass<PreviewAutomationDevToolsOpenError>()( | 3319 | export class PreviewAutomationDevToolsOpenError extends Schema.TaggedErrorClass<PreviewAutomationDevToolsOpenError>()( |
| 2694 | "PreviewAutomationDevToolsOpenError", | 3320 | "PreviewAutomationDevToolsOpenError", |
| 2695 | { webContentsId: Schema.Number }, | 3321 | { webContentsId: Schema.Number }, |
| ... | @@ -2852,7 +3478,6 @@ export const PreviewManagerError = Schema.Union([ | ... | @@ -2852,7 +3478,6 @@ export const PreviewManagerError = Schema.Union([ |
| 2852 | PreviewOperationError, | 3478 | PreviewOperationError, |
| 2853 | PreviewArtifactPathOutsideDirectoryError, | 3479 | PreviewArtifactPathOutsideDirectoryError, |
| 2854 | PreviewArtifactImageLoadError, | 3480 | PreviewArtifactImageLoadError, |
| 2855 | PreviewRecordingAlreadyActiveError, | ||
| 2856 | PreviewAutomationDevToolsOpenError, | 3481 | PreviewAutomationDevToolsOpenError, |
| 2857 | PreviewAutomationDebuggerAttachedError, | 3482 | PreviewAutomationDebuggerAttachedError, |
| 2858 | PreviewAutomationEvaluationError, | 3483 | PreviewAutomationEvaluationError, |
| ... | @@ -2915,6 +3540,8 @@ export class PreviewManager extends Context.Service< | ... | @@ -2915,6 +3540,8 @@ export class PreviewManager extends Context.Service< |
| 2915 | ) => Effect.Effect<DesktopPreviewScreenshotArtifact, PreviewManagerError>; | 3540 | ) => Effect.Effect<DesktopPreviewScreenshotArtifact, PreviewManagerError>; |
| 2916 | readonly revealArtifact: (path: string) => Effect.Effect<void, PreviewManagerError>; | 3541 | readonly revealArtifact: (path: string) => Effect.Effect<void, PreviewManagerError>; |
| 2917 | readonly copyArtifactToClipboard: (path: string) => Effect.Effect<void, PreviewManagerError>; | 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 | readonly startRecording: (tabId: string) => Effect.Effect<void, PreviewManagerError>; | 3545 | readonly startRecording: (tabId: string) => Effect.Effect<void, PreviewManagerError>; |
| 2919 | readonly stopRecording: (tabId: string) => Effect.Effect<void, PreviewManagerError>; | 3546 | readonly stopRecording: (tabId: string) => Effect.Effect<void, PreviewManagerError>; |
| 2920 | readonly saveRecording: ( | 3547 | readonly saveRecording: ( |
| ... | @@ -2965,7 +3592,10 @@ export class PreviewManager extends Context.Service< | ... | @@ -2965,7 +3592,10 @@ export class PreviewManager extends Context.Service< |
| 2965 | export const make = Effect.gen(function* PreviewManagerMake() { | 3592 | export const make = Effect.gen(function* PreviewManagerMake() { |
| 2966 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | 3593 | const environment = yield* DesktopEnvironment.DesktopEnvironment; |
| 2967 | const browserSession = yield* BrowserSession.BrowserSession; | 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 | return PreviewManager.of({ | 3600 | return PreviewManager.of({ |
| 2971 | setMainWindow: operations.setMainWindow, | 3601 | setMainWindow: operations.setMainWindow, |
| ... | @@ -3023,6 +3653,8 @@ export const make = Effect.gen(function* PreviewManagerMake() { | ... | @@ -3023,6 +3653,8 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 3023 | captureScreenshot: operations.captureScreenshot, | 3653 | captureScreenshot: operations.captureScreenshot, |
| 3024 | revealArtifact: operations.revealArtifact, | 3654 | revealArtifact: operations.revealArtifact, |
| 3025 | copyArtifactToClipboard: operations.copyArtifactToClipboard, | 3655 | copyArtifactToClipboard: operations.copyArtifactToClipboard, |
| 3656 | openPictureInPicture: operations.openPictureInPicture, | ||
| 3657 | closePictureInPicture: operations.closePictureInPicture, | ||
| 3026 | startRecording: operations.startRecording, | 3658 | startRecording: operations.startRecording, |
| 3027 | stopRecording: operations.stopRecording, | 3659 | stopRecording: operations.stopRecording, |
| 3028 | saveRecording: operations.saveRecording, | 3660 | saveRecording: operations.saveRecording, |
apps/desktop/src/window/DesktopWindow.test.ts+2| ... | @@ -315,6 +315,7 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n | ... | @@ -315,6 +315,7 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n |
| 315 | webPreferences: { | 315 | webPreferences: { |
| 316 | preload: null, | 316 | preload: null, |
| 317 | partition: null, | 317 | partition: null, |
| 318 | backgroundThrottling: null, | ||
| 318 | sandbox: null, | 319 | sandbox: null, |
| 319 | contextIsolation: null, | 320 | contextIsolation: null, |
| 320 | nodeIntegration: null, | 321 | nodeIntegration: null, |
| ... | @@ -429,6 +430,7 @@ describe("DesktopWindow", () => { | ... | @@ -429,6 +430,7 @@ describe("DesktopWindow", () => { |
| 429 | assert.isUndefined(createdWindowOptions[0]?.x); | 430 | assert.isUndefined(createdWindowOptions[0]?.x); |
| 430 | assert.isUndefined(createdWindowOptions[0]?.y); | 431 | assert.isUndefined(createdWindowOptions[0]?.y); |
| 431 | assert.isTrue(createdWindowOptions[0]?.disableAutoHideCursor); | 432 | assert.isTrue(createdWindowOptions[0]?.disableAutoHideCursor); |
| 433 | assert.isFalse(createdWindowOptions[0]?.webPreferences?.backgroundThrottling); | ||
| 432 | assert.deepEqual(fakeWindow.setAutoHideCursor.mock.calls, [[false]]); | 434 | assert.deepEqual(fakeWindow.setAutoHideCursor.mock.calls, [[false]]); |
| 433 | assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["t3code-dev://app/"]); | 435 | assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["t3code-dev://app/"]); |
| 434 | assert.equal(fakeWindow.openDevTools.mock.calls.length, 1); | 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,6 +331,7 @@ export const make = Effect.gen(function* () { |
| 331 | ...getWindowTitleBarOptions(shouldUseDarkColors, environment.platform), | 331 | ...getWindowTitleBarOptions(shouldUseDarkColors, environment.platform), |
| 332 | webPreferences: { | 332 | webPreferences: { |
| 333 | preload: environment.preloadPath, | 333 | preload: environment.preloadPath, |
| 334 | backgroundThrottling: false, | ||
| 334 | contextIsolation: true, | 335 | contextIsolation: true, |
| 335 | nodeIntegration: false, | 336 | nodeIntegration: false, |
| 336 | sandbox: true, | 337 | sandbox: true, |
apps/desktop/vite.config.ts+7| ... | @@ -73,5 +73,12 @@ export default defineConfig({ | ... | @@ -73,5 +73,12 @@ export default defineConfig({ |
| 73 | alwaysBundle: (id) => id === "react-grab" || id.startsWith("react-grab/"), | 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,9 +8,9 @@ import * as ServerConfig from "../config.ts"; |
| 8 | import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; | 8 | import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; |
| 9 | import * as PairingGrantStore from "./PairingGrantStore.ts"; | 9 | import * as PairingGrantStore from "./PairingGrantStore.ts"; |
| 10 | import * as EnvironmentAuth from "./EnvironmentAuth.ts"; | 10 | import * as EnvironmentAuth from "./EnvironmentAuth.ts"; |
| 11 | import { resolveSessionCookieName } from "./utils.ts"; | ||
| 12 | 11 | ||
| 13 | import * as ServerSecretStore from "./ServerSecretStore.ts"; | 12 | import * as ServerSecretStore from "./ServerSecretStore.ts"; |
| 13 | import * as SessionStore from "./SessionStore.ts"; | ||
| 14 | 14 | ||
| 15 | /** Pinned so dev-mode cookie tests can assert the port-scoped name. */ | 15 | /** Pinned so dev-mode cookie tests can assert the port-scoped name. */ |
| 16 | const TEST_SERVER_PORT = 13_773; | 16 | const TEST_SERVER_PORT = 13_773; |
| ... | @@ -23,11 +23,8 @@ const makeServerConfigLayer = (overrides?: Partial<ServerConfig.ServerConfig["Se | ... | @@ -23,11 +23,8 @@ const makeServerConfigLayer = (overrides?: Partial<ServerConfig.ServerConfig["Se |
| 23 | return { | 23 | return { |
| 24 | ...config, | 24 | ...config, |
| 25 | ...overrides, | 25 | ...overrides, |
| 26 | // Last, so the port cannot be overridden out from under | 26 | // Keep the test server deterministic even when the default test layer |
| 27 | // makeCookieRequest — which builds the cookie name from this constant. | 27 | // changes its development port. |
| 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. | ||
| 31 | port: TEST_SERVER_PORT, | 28 | port: TEST_SERVER_PORT, |
| 32 | } satisfies ServerConfig.ServerConfig["Service"]; | 29 | } satisfies ServerConfig.ServerConfig["Service"]; |
| 33 | }), | 30 | }), |
| ... | @@ -41,15 +38,12 @@ const makeEnvironmentAuthLayer = (overrides?: Partial<ServerConfig.ServerConfig[ | ... | @@ -41,15 +38,12 @@ const makeEnvironmentAuthLayer = (overrides?: Partial<ServerConfig.ServerConfig[ |
| 41 | ); | 38 | ); |
| 42 | 39 | ||
| 43 | const makeCookieRequest = ( | 40 | const makeCookieRequest = ( |
| 41 | cookieName: string, | ||
| 44 | sessionToken: string, | 42 | sessionToken: string, |
| 45 | ): Parameters<EnvironmentAuth.EnvironmentAuth["Service"]["authenticateHttpRequest"]>[0] => | 43 | ): Parameters<EnvironmentAuth.EnvironmentAuth["Service"]["authenticateHttpRequest"]>[0] => |
| 46 | ({ | 44 | ({ |
| 47 | cookies: { | 45 | cookies: { |
| 48 | // Derived, not hardcoded: the name is port-scoped so concurrent servers | 46 | [cookieName]: sessionToken, |
| 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, | ||
| 53 | }, | 47 | }, |
| 54 | headers: {}, | 48 | headers: {}, |
| 55 | }) as unknown as Parameters< | 49 | }) as unknown as Parameters< |
| ... | @@ -92,6 +86,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { | ... | @@ -92,6 +86,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { |
| 92 | it.effect("issues standard pairing credentials by default", () => | 86 | it.effect("issues standard pairing credentials by default", () => |
| 93 | Effect.gen(function* () { | 87 | Effect.gen(function* () { |
| 94 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; | 88 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; |
| 89 | const sessions = yield* SessionStore.SessionStore; | ||
| 95 | 90 | ||
| 96 | const pairingCredential = yield* serverAuth.issuePairingCredential(); | 91 | const pairingCredential = yield* serverAuth.issuePairingCredential(); |
| 97 | const exchanged = yield* serverAuth.createBrowserSession( | 92 | const exchanged = yield* serverAuth.createBrowserSession( |
| ... | @@ -99,7 +94,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { | ... | @@ -99,7 +94,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { |
| 99 | requestMetadata, | 94 | requestMetadata, |
| 100 | ); | 95 | ); |
| 101 | const verified = yield* serverAuth.authenticateHttpRequest( | 96 | const verified = yield* serverAuth.authenticateHttpRequest( |
| 102 | makeCookieRequest(exchanged.sessionToken), | 97 | makeCookieRequest(sessions.cookieName, exchanged.sessionToken), |
| 103 | ); | 98 | ); |
| 104 | 99 | ||
| 105 | expect(verified.sessionId.length).toBeGreaterThan(0); | 100 | expect(verified.sessionId.length).toBeGreaterThan(0); |
| ... | @@ -165,6 +160,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { | ... | @@ -165,6 +160,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { |
| 165 | it.effect("issues startup pairing URLs that bootstrap administrative sessions", () => | 160 | it.effect("issues startup pairing URLs that bootstrap administrative sessions", () => |
| 166 | Effect.gen(function* () { | 161 | Effect.gen(function* () { |
| 167 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; | 162 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; |
| 163 | const sessions = yield* SessionStore.SessionStore; | ||
| 168 | 164 | ||
| 169 | const pairingUrl = yield* serverAuth.issueStartupPairingUrl("http://127.0.0.1:3773"); | 165 | const pairingUrl = yield* serverAuth.issueStartupPairingUrl("http://127.0.0.1:3773"); |
| 170 | const token = new URLSearchParams(new URL(pairingUrl).hash.slice(1)).get("token"); | 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,7 +174,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { |
| 178 | 174 | ||
| 179 | const exchanged = yield* serverAuth.createBrowserSession(token ?? "", requestMetadata); | 175 | const exchanged = yield* serverAuth.createBrowserSession(token ?? "", requestMetadata); |
| 180 | const verified = yield* serverAuth.authenticateHttpRequest( | 176 | const verified = yield* serverAuth.authenticateHttpRequest( |
| 181 | makeCookieRequest(exchanged.sessionToken), | 177 | makeCookieRequest(sessions.cookieName, exchanged.sessionToken), |
| 182 | ); | 178 | ); |
| 183 | 179 | ||
| 184 | expect(verified.scopes).toEqual([ | 180 | expect(verified.scopes).toEqual([ |
| ... | @@ -200,13 +196,14 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { | ... | @@ -200,13 +196,14 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { |
| 200 | () => | 196 | () => |
| 201 | Effect.gen(function* () { | 197 | Effect.gen(function* () { |
| 202 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; | 198 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; |
| 199 | const sessions = yield* SessionStore.SessionStore; | ||
| 203 | 200 | ||
| 204 | const administrativeExchange = yield* serverAuth.createBrowserSession( | 201 | const administrativeExchange = yield* serverAuth.createBrowserSession( |
| 205 | "desktop-bootstrap-token", | 202 | "desktop-bootstrap-token", |
| 206 | requestMetadata, | 203 | requestMetadata, |
| 207 | ); | 204 | ); |
| 208 | const administrativeSession = yield* serverAuth.authenticateHttpRequest( | 205 | const administrativeSession = yield* serverAuth.authenticateHttpRequest( |
| 209 | makeCookieRequest(administrativeExchange.sessionToken), | 206 | makeCookieRequest(sessions.cookieName, administrativeExchange.sessionToken), |
| 210 | ); | 207 | ); |
| 211 | const pairingCredential = yield* serverAuth.issuePairingCredential({ | 208 | const pairingCredential = yield* serverAuth.issuePairingCredential({ |
| 212 | label: "Julius iPhone", | 209 | label: "Julius iPhone", |
| ... | @@ -223,7 +220,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { | ... | @@ -223,7 +220,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { |
| 223 | }, | 220 | }, |
| 224 | ); | 221 | ); |
| 225 | const clientSession = yield* serverAuth.authenticateHttpRequest( | 222 | const clientSession = yield* serverAuth.authenticateHttpRequest( |
| 226 | makeCookieRequest(clientExchange.sessionToken), | 223 | makeCookieRequest(sessions.cookieName, clientExchange.sessionToken), |
| 227 | ); | 224 | ); |
| 228 | const clientsBeforeRevoke = yield* serverAuth.listClientSessions( | 225 | const clientsBeforeRevoke = yield* serverAuth.listClientSessions( |
| 229 | administrativeSession.sessionId, | 226 | administrativeSession.sessionId, |
apps/server/src/auth/EnvironmentAuthPolicy.test.ts+12-8| ... | @@ -88,47 +88,50 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => { | ... | @@ -88,47 +88,50 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => { |
| 88 | 88 | ||
| 89 | expect(descriptor.policy).toBe("loopback-browser"); | 89 | expect(descriptor.policy).toBe("loopback-browser"); |
| 90 | expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]); | 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 | }).pipe( | 92 | }).pipe( |
| 93 | Effect.provide( | 93 | Effect.provide( |
| 94 | makeEnvironmentAuthPolicyLayer({ | 94 | makeEnvironmentAuthPolicyLayer({ |
| 95 | mode: "web", | 95 | mode: "web", |
| 96 | host: "127.0.0.1", | 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 | Effect.gen(function* () { | 104 | Effect.gen(function* () { |
| 105 | const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; | 105 | const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; |
| 106 | const descriptor = yield* policy.getDescriptor(); | 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 | }).pipe( | 111 | }).pipe( |
| 110 | Effect.provide( | 112 | Effect.provide( |
| 111 | makeEnvironmentAuthPolicyLayer({ | 113 | makeEnvironmentAuthPolicyLayer({ |
| 112 | mode: "web", | 114 | mode: "web", |
| 113 | port: 13773, | 115 | host: "0.0.0.0", |
| 114 | devUrl: new URL("http://127.0.0.1:5733"), | ||
| 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 | Effect.gen(function* () { | 122 | Effect.gen(function* () { |
| 122 | const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; | 123 | const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; |
| 123 | const descriptor = yield* policy.getDescriptor(); | 124 | const descriptor = yield* policy.getDescriptor(); |
| 124 | 125 | ||
| 125 | expect(descriptor.policy).toBe("remote-reachable"); | 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 | }).pipe( | 128 | }).pipe( |
| 128 | Effect.provide( | 129 | Effect.provide( |
| 129 | makeEnvironmentAuthPolicyLayer({ | 130 | makeEnvironmentAuthPolicyLayer({ |
| 130 | mode: "web", | 131 | mode: "web", |
| 131 | host: "0.0.0.0", | 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,6 +143,7 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => { |
| 140 | const descriptor = yield* policy.getDescriptor(); | 143 | const descriptor = yield* policy.getDescriptor(); |
| 141 | 144 | ||
| 142 | expect(descriptor.policy).toBe("remote-reachable"); | 145 | expect(descriptor.policy).toBe("remote-reachable"); |
| 146 | expect(descriptor.sessionCookieName).toBe("t3_session"); | ||
| 143 | }).pipe( | 147 | }).pipe( |
| 144 | Effect.provide( | 148 | Effect.provide( |
| 145 | makeEnvironmentAuthPolicyLayer({ | 149 | makeEnvironmentAuthPolicyLayer({ |
apps/server/src/auth/EnvironmentAuthPolicy.ts+5-4| ... | @@ -4,8 +4,7 @@ import * as Effect from "effect/Effect"; | ... | @@ -4,8 +4,7 @@ import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; | 4 | import * as Layer from "effect/Layer"; |
| 5 | 5 | ||
| 6 | import * as ServerConfig from "../config.ts"; | 6 | import * as ServerConfig from "../config.ts"; |
| 7 | import { resolveSessionCookieName } from "./utils.ts"; | 7 | import { isRemoteReachableHost, resolveSessionCookieName } from "./utils.ts"; |
| 8 | import { isLoopbackHost, isWildcardHost } from "../startupAccess.ts"; | ||
| 9 | 8 | ||
| 10 | export class EnvironmentAuthPolicy extends Context.Service< | 9 | export class EnvironmentAuthPolicy extends Context.Service< |
| 11 | EnvironmentAuthPolicy, | 10 | EnvironmentAuthPolicy, |
| ... | @@ -16,7 +15,7 @@ export class EnvironmentAuthPolicy extends Context.Service< | ... | @@ -16,7 +15,7 @@ export class EnvironmentAuthPolicy extends Context.Service< |
| 16 | 15 | ||
| 17 | export const make = Effect.gen(function* () { | 16 | export const make = Effect.gen(function* () { |
| 18 | const config = yield* ServerConfig.ServerConfig; | 17 | const config = yield* ServerConfig.ServerConfig; |
| 19 | const isRemoteReachable = isWildcardHost(config.host) || !isLoopbackHost(config.host); | 18 | const isRemoteReachable = isRemoteReachableHost(config.host); |
| 20 | 19 | ||
| 21 | const policy = | 20 | const policy = |
| 22 | config.mode === "desktop" | 21 | config.mode === "desktop" |
| ... | @@ -41,7 +40,9 @@ export const make = Effect.gen(function* () { | ... | @@ -41,7 +40,9 @@ export const make = Effect.gen(function* () { |
| 41 | sessionCookieName: resolveSessionCookieName({ | 40 | sessionCookieName: resolveSessionCookieName({ |
| 42 | mode: config.mode, | 41 | mode: config.mode, |
| 43 | port: config.port, | 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,7 +470,9 @@ export const make = Effect.gen(function* () { |
| 470 | const cookieName = resolveSessionCookieName({ | 470 | const cookieName = resolveSessionCookieName({ |
| 471 | mode: serverConfig.mode, | 471 | mode: serverConfig.mode, |
| 472 | port: serverConfig.port, | 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 | const emitUpsert = (clientSession: AuthClientSession) => | 478 | const emitUpsert = (clientSession: AuthClientSession) => |
apps/server/src/auth/utils.test.ts+82-1| ... | @@ -1,6 +1,10 @@ | ... | @@ -1,6 +1,10 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 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 | describe("deriveAuthClientMetadata", () => { | 9 | describe("deriveAuthClientMetadata", () => { |
| 6 | it("labels Electron user agents as Electron instead of Chrome", () => { | 10 | it("labels Electron user agents as Electron instead of Chrome", () => { |
| ... | @@ -52,3 +56,80 @@ describe("deriveAuthClientMetadata", () => { | ... | @@ -52,3 +56,80 @@ describe("deriveAuthClientMetadata", () => { |
| 52 | expect(metadata.userAgent).toContain("Electron/36.3.2"); | 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,11 +28,42 @@ const SESSION_COOKIE_NAME = "t3_session"; |
| 28 | export function resolveSessionCookieName(input: { | 28 | export function resolveSessionCookieName(input: { |
| 29 | readonly mode: "web" | "desktop"; | 29 | readonly mode: "web" | "desktop"; |
| 30 | readonly port: number; | 30 | readonly port: number; |
| 31 | readonly devUrl: URL | undefined; | 31 | readonly host: string | undefined; |
| 32 | readonly instanceKey: string; | ||
| 33 | readonly development: boolean; | ||
| 32 | }): string { | 34 | }): string { |
| 33 | return input.devUrl === undefined && input.mode !== "desktop" | 35 | if (input.mode === "desktop") { |
| 34 | ? SESSION_COOKIE_NAME | 36 | return `${SESSION_COOKIE_NAME}_${input.port}`; |
| 35 | : `${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 | export function base64UrlEncode(input: string | Uint8Array): string { | 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,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 | Effect.scoped( | 178 | Effect.scoped( |
| 179 | Effect.gen(function* () { | 179 | Effect.gen(function* () { |
| 180 | const broker = yield* makeBroker; | 180 | const broker = yield* makeBroker; |
| ... | @@ -203,7 +203,7 @@ it.effect("does not replace the default tab with a globally stopped recording ta | ... | @@ -203,7 +203,7 @@ it.effect("does not replace the default tab with a globally stopped recording ta |
| 203 | yield* broker.invoke({ scope, operation: "recordingStop", input: {} }); | 203 | yield* broker.invoke({ scope, operation: "recordingStop", input: {} }); |
| 204 | yield* broker.invoke({ scope, operation: "snapshot", input: {} }); | 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,8 +548,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { |
| 548 | }); | 548 | }); |
| 549 | }); | 549 | }); |
| 550 | const result = yield* awaitResponse().pipe(Effect.ensuring(removePending)); | 550 | const result = yield* awaitResponse().pipe(Effect.ensuring(removePending)); |
| 551 | // A stop artifact identifies the globally recorded tab, not the caller's browsing target. | 551 | const responseTabId = readResultTabId(result); |
| 552 | const responseTabId = input.operation === "recordingStop" ? undefined : readResultTabId(result); | ||
| 553 | const resultTabId = responseTabId === undefined ? input.tabId : responseTabId; | 552 | const resultTabId = responseTabId === undefined ? input.tabId : responseTabId; |
| 554 | if (resultTabId === undefined) return result; | 553 | if (resultTabId === undefined) return result; |
| 555 | const assignmentKey = hostAssignmentKey(input.scope); | 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,6 +1,7 @@ |
| 1 | import * as Effect from "effect/Effect"; | 1 | import * as Effect from "effect/Effect"; |
| 2 | import type { | 2 | import type { |
| 3 | PreviewAutomationOperation, | 3 | PreviewAutomationOperation, |
| 4 | PreviewAutomationOpenInput, | ||
| 4 | PreviewAutomationRecordingArtifact, | 5 | PreviewAutomationRecordingArtifact, |
| 5 | PreviewAutomationRecordingStatus, | 6 | PreviewAutomationRecordingStatus, |
| 6 | PreviewAutomationResizeResult, | 7 | PreviewAutomationResizeResult, |
| ... | @@ -14,6 +15,18 @@ import * as McpInvocationContext from "../../McpInvocationContext.ts"; | ... | @@ -14,6 +15,18 @@ import * as McpInvocationContext from "../../McpInvocationContext.ts"; |
| 14 | import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; | 15 | import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; |
| 15 | import { PreviewSnapshotToolkit, PreviewStandardToolkit, PreviewToolkit } from "./tools.ts"; | 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 | const invoke = Effect.fn("PreviewToolkit.invoke")(function* <A>( | 30 | const invoke = Effect.fn("PreviewToolkit.invoke")(function* <A>( |
| 18 | operation: PreviewAutomationOperation, | 31 | operation: PreviewAutomationOperation, |
| 19 | input: unknown, | 32 | input: unknown, |
| ... | @@ -50,11 +63,7 @@ const invokeTargeted = <A>( | ... | @@ -50,11 +63,7 @@ const invokeTargeted = <A>( |
| 50 | const handlers = { | 63 | const handlers = { |
| 51 | preview_status: (input) => invokeTargeted<PreviewAutomationStatus>("status", input ?? {}), | 64 | preview_status: (input) => invokeTargeted<PreviewAutomationStatus>("status", input ?? {}), |
| 52 | preview_open: (input) => | 65 | preview_open: (input) => |
| 53 | invokeTargeted<PreviewAutomationStatus>("open", { | 66 | invokeTargeted<PreviewAutomationStatus>("open", normalizePreviewOpenInput(input)), |
| 54 | ...input, | ||
| 55 | show: input.show ?? true, | ||
| 56 | reuseExistingTab: input.reuseExistingTab ?? true, | ||
| 57 | }), | ||
| 58 | preview_navigate: (input) => | 67 | preview_navigate: (input) => |
| 59 | invokeTargeted<PreviewAutomationStatus>("navigate", input, input.timeoutMs), | 68 | invokeTargeted<PreviewAutomationStatus>("navigate", input, input.timeoutMs), |
| 60 | preview_resize: (input) => | 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,7 +54,7 @@ export const PreviewStatusTool = Tool.make("preview_status", { |
| 54 | export const PreviewOpenTool = browserTool( | 54 | export const PreviewOpenTool = browserTool( |
| 55 | Tool.make("preview_open", { | 55 | Tool.make("preview_open", { |
| 56 | description: | 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 | parameters: PreviewAutomationOpenInput, | 58 | parameters: PreviewAutomationOpenInput, |
| 59 | success: PreviewAutomationStatus, | 59 | success: PreviewAutomationStatus, |
| 60 | failure: PreviewAutomationError, | 60 | failure: PreviewAutomationError, |
| ... | @@ -191,7 +191,8 @@ export const PreviewRecordingStartTool = safeBrowserTool( | ... | @@ -191,7 +191,8 @@ export const PreviewRecordingStartTool = safeBrowserTool( |
| 191 | 191 | ||
| 192 | export const PreviewRecordingStopTool = safeBrowserTool( | 192 | export const PreviewRecordingStopTool = safeBrowserTool( |
| 193 | Tool.make("preview_recording_stop", { | 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 | parameters: PreviewAutomationTabTargetInput, | 196 | parameters: PreviewAutomationTabTargetInput, |
| 196 | success: PreviewAutomationRecordingArtifact, | 197 | success: PreviewAutomationRecordingArtifact, |
| 197 | failure: PreviewAutomationError, | 198 | failure: PreviewAutomationError, |
apps/server/src/preview/Manager.test.ts+46| ... | @@ -67,6 +67,32 @@ it.layer(PreviewManager.layer)("PreviewManager", (it) => { | ... | @@ -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 | it.effect("treats bare hosts as https", () => | 96 | it.effect("treats bare hosts as https", () => |
| 71 | Effect.gen(function* () { | 97 | Effect.gen(function* () { |
| 72 | const threadId = freshThreadId(); | 98 | const threadId = freshThreadId(); |
| ... | @@ -254,6 +280,26 @@ it.layer(PreviewManager.layer)("PreviewManager", (it) => { | ... | @@ -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 | it.effect("close is idempotent for unknown threads", () => | 303 | it.effect("close is idempotent for unknown threads", () => |
| 258 | Effect.gen(function* () { | 304 | Effect.gen(function* () { |
| 259 | const threadId = freshThreadId(); | 305 | const threadId = freshThreadId(); |
apps/server/src/preview/Manager.ts+57-29| ... | @@ -30,6 +30,7 @@ import { | ... | @@ -30,6 +30,7 @@ import { |
| 30 | newPreviewTabId, | 30 | newPreviewTabId, |
| 31 | normalizePreviewUrl, | 31 | normalizePreviewUrl, |
| 32 | } from "@t3tools/shared/preview"; | 32 | } from "@t3tools/shared/preview"; |
| 33 | import * as NodeCrypto from "node:crypto"; | ||
| 33 | import * as Context from "effect/Context"; | 34 | import * as Context from "effect/Context"; |
| 34 | import * as DateTime from "effect/DateTime"; | 35 | import * as DateTime from "effect/DateTime"; |
| 35 | import * as Effect from "effect/Effect"; | 36 | import * as Effect from "effect/Effect"; |
| ... | @@ -67,9 +68,17 @@ interface PreviewSessionState { | ... | @@ -67,9 +68,17 @@ interface PreviewSessionState { |
| 67 | interface ManagerState { | 68 | interface ManagerState { |
| 68 | /** All sessions across every thread, keyed by `${threadId}\u0000${tabId}`. */ | 69 | /** All sessions across every thread, keyed by `${threadId}\u0000${tabId}`. */ |
| 69 | readonly sessions: ReadonlyMap<string, PreviewSessionState>; | 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 | const compositeKey = (threadId: string, tabId: string): string => `${threadId}\u0000${tabId}`; | 83 | const compositeKey = (threadId: string, tabId: string): string => `${threadId}\u0000${tabId}`; |
| 75 | 84 | ||
| ... | @@ -138,6 +147,7 @@ const buildIdleSnapshot = (input: { | ... | @@ -138,6 +147,7 @@ const buildIdleSnapshot = (input: { |
| 138 | }); | 147 | }); |
| 139 | 148 | ||
| 140 | export const make = Effect.gen(function* PreviewManagerMake() { | 149 | export const make = Effect.gen(function* PreviewManagerMake() { |
| 150 | const serverEpoch = NodeCrypto.randomUUID(); | ||
| 141 | const stateRef = yield* SynchronizedRef.make<ManagerState>(initialState); | 151 | const stateRef = yield* SynchronizedRef.make<ManagerState>(initialState); |
| 142 | // Unbounded PubSub is fine here — events are tiny and we don't want to | 152 | // Unbounded PubSub is fine here — events are tiny and we don't want to |
| 143 | // block publishers if a subscriber is slow. WS clients backpressure on | 153 | // block publishers if a subscriber is slow. WS clients backpressure on |
| ... | @@ -160,7 +170,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { | ... | @@ -160,7 +170,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 160 | tabId: string, | 170 | tabId: string, |
| 161 | mutator: ( | 171 | mutator: ( |
| 162 | session: PreviewSessionState, | 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 | ): Effect.Effect<R, E | PreviewSessionLookupError> => { | 174 | ): Effect.Effect<R, E | PreviewSessionLookupError> => { |
| 165 | type ModifyResult = | 175 | type ModifyResult = |
| 166 | | { kind: "fail"; error: PreviewSessionLookupError } | 176 | | { kind: "fail"; error: PreviewSessionLookupError } |
| ... | @@ -177,10 +187,17 @@ export const make = Effect.gen(function* PreviewManagerMake() { | ... | @@ -177,10 +187,17 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 177 | return mutator(session).pipe( | 187 | return mutator(session).pipe( |
| 178 | Effect.flatMap( | 188 | Effect.flatMap( |
| 179 | Effect.fn("PreviewManager.commitMutation")(function* ({ next, emit, result }) { | 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 | const sessions = new Map(state.sessions); | 198 | const sessions = new Map(state.sessions); |
| 182 | sessions.set(compositeKey(threadId, tabId), next); | 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 | ModifyResult, | 201 | ModifyResult, |
| 185 | ManagerState, | 202 | ManagerState, |
| 186 | ]; | 203 | ]; |
| ... | @@ -207,22 +224,27 @@ export const make = Effect.gen(function* PreviewManagerMake() { | ... | @@ -207,22 +224,27 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 207 | updatedAt, | 224 | updatedAt, |
| 208 | }) | 225 | }) |
| 209 | : buildIdleSnapshot({ threadId: input.threadId, tabId, updatedAt }); | 226 | : buildIdleSnapshot({ threadId: input.threadId, tabId, updatedAt }); |
| 210 | yield* SynchronizedRef.update(stateRef, (state) => { | 227 | yield* SynchronizedRef.modifyEffect(stateRef, (state) => |
| 211 | const sessions = new Map(state.sessions); | 228 | Effect.gen(function* () { |
| 212 | sessions.set(compositeKey(input.threadId, tabId), { | 229 | const revision = state.revision + 1; |
| 213 | threadId: input.threadId, | 230 | const sessions = new Map(state.sessions); |
| 214 | tabId, | 231 | sessions.set(compositeKey(input.threadId, tabId), { |
| 215 | snapshot, | 232 | threadId: input.threadId, |
| 216 | }); | 233 | tabId, |
| 217 | return { sessions }; | 234 | snapshot, |
| 218 | }); | 235 | }); |
| 219 | yield* PubSub.publish(eventsPubSub, { | 236 | yield* PubSub.publish(eventsPubSub, { |
| 220 | type: "opened", | 237 | type: "opened", |
| 221 | threadId: input.threadId, | 238 | threadId: input.threadId, |
| 222 | tabId, | 239 | tabId, |
| 223 | createdAt: snapshot.updatedAt, | 240 | createdAt: snapshot.updatedAt, |
| 224 | snapshot, | 241 | serverEpoch, |
| 225 | }); | 242 | revision, |
| 243 | snapshot, | ||
| 244 | }); | ||
| 245 | return [snapshot, { sessions, revision }] as const; | ||
| 246 | }), | ||
| 247 | ); | ||
| 226 | return snapshot; | 248 | return snapshot; |
| 227 | }, | 249 | }, |
| 228 | ); | 250 | ); |
| ... | @@ -280,7 +302,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { | ... | @@ -280,7 +302,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 280 | viewport: session.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT, | 302 | viewport: session.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT, |
| 281 | updatedAt, | 303 | updatedAt, |
| 282 | }; | 304 | }; |
| 283 | const emit: PreviewEvent = | 305 | const emit: PreviewEventDraft = |
| 284 | input.navStatus._tag === "LoadFailed" | 306 | input.navStatus._tag === "LoadFailed" |
| 285 | ? { | 307 | ? { |
| 286 | type: "failed", | 308 | type: "failed", |
| ... | @@ -349,7 +371,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { | ... | @@ -349,7 +371,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 349 | const close: PreviewManager["Service"]["close"] = Effect.fn("PreviewManager.close")( | 371 | const close: PreviewManager["Service"]["close"] = Effect.fn("PreviewManager.close")( |
| 350 | function* (input) { | 372 | function* (input) { |
| 351 | const createdAt = yield* currentIsoTimestamp; | 373 | const createdAt = yield* currentIsoTimestamp; |
| 352 | const events = yield* SynchronizedRef.modify(stateRef, (state) => { | 374 | yield* SynchronizedRef.modifyEffect(stateRef, (state) => { |
| 353 | const eventsToEmit: PreviewEvent[] = []; | 375 | const eventsToEmit: PreviewEvent[] = []; |
| 354 | const sessions = new Map(state.sessions); | 376 | const sessions = new Map(state.sessions); |
| 355 | const targets = input.tabId | 377 | const targets = input.tabId |
| ... | @@ -357,25 +379,29 @@ export const make = Effect.gen(function* PreviewManagerMake() { | ... | @@ -357,25 +379,29 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 357 | (entry): entry is PreviewSessionState => entry !== undefined, | 379 | (entry): entry is PreviewSessionState => entry !== undefined, |
| 358 | ) | 380 | ) |
| 359 | : sessionsForThread(state, input.threadId); | 381 | : sessionsForThread(state, input.threadId); |
| 382 | let revision = state.revision; | ||
| 360 | for (const target of targets) { | 383 | for (const target of targets) { |
| 384 | revision += 1; | ||
| 361 | sessions.delete(compositeKey(target.threadId, target.tabId)); | 385 | sessions.delete(compositeKey(target.threadId, target.tabId)); |
| 362 | eventsToEmit.push({ | 386 | eventsToEmit.push({ |
| 363 | type: "closed", | 387 | type: "closed", |
| 364 | threadId: target.threadId, | 388 | threadId: target.threadId, |
| 365 | tabId: target.tabId, | 389 | tabId: target.tabId, |
| 366 | createdAt, | 390 | createdAt, |
| 391 | serverEpoch, | ||
| 392 | revision, | ||
| 367 | }); | 393 | }); |
| 368 | } | 394 | } |
| 369 | if (eventsToEmit.length === 0) { | 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,6 +413,8 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 387 | sessions: sessionsForThread(state, input.threadId) | 413 | sessions: sessionsForThread(state, input.threadId) |
| 388 | .map((s) => s.snapshot) | 414 | .map((s) => s.snapshot) |
| 389 | .toSorted((a, b) => a.updatedAt.localeCompare(b.updatedAt)), | 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,7 +667,7 @@ const buildAppUnderTest = (options?: { |
| 667 | reportStatus: () => Effect.void, | 667 | reportStatus: () => Effect.void, |
| 668 | refresh: () => Effect.void, | 668 | refresh: () => Effect.void, |
| 669 | close: () => Effect.void, | 669 | close: () => Effect.void, |
| 670 | list: () => Effect.succeed({ sessions: [] }), | 670 | list: () => Effect.succeed({ sessions: [], serverEpoch: "test-server", revision: 0 }), |
| 671 | events: Stream.empty, | 671 | events: Stream.empty, |
| 672 | subscribeEvents: Effect.flatMap(PubSub.unbounded<PreviewEvent>(), (pubsub) => | 672 | subscribeEvents: Effect.flatMap(PubSub.unbounded<PreviewEvent>(), (pubsub) => |
| 673 | PubSub.subscribe(pubsub), | 673 | PubSub.subscribe(pubsub), |
apps/web/src/browser/BrowserSurfaceSlot.tsx+40-5| ... | @@ -7,27 +7,56 @@ import { acquireBrowserSurface } from "./browserSurfaceStore"; | ... | @@ -7,27 +7,56 @@ import { acquireBrowserSurface } from "./browserSurfaceStore"; |
| 7 | export function BrowserSurfaceSlot(props: { | 7 | export function BrowserSurfaceSlot(props: { |
| 8 | readonly tabId: string; | 8 | readonly tabId: string; |
| 9 | readonly visible: boolean; | 9 | readonly visible: boolean; |
| 10 | readonly cornerRadius?: number; | ||
| 11 | readonly layoutVersion?: string | number; | ||
| 10 | readonly className?: string; | 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 | const elementRef = useRef<HTMLDivElement | null>(null); | 23 | const elementRef = useRef<HTMLDivElement | null>(null); |
| 24 | const presentationRef = useRef({ visible, cornerRadius }); | ||
| 25 | const updateRef = useRef<(() => void) | null>(null); | ||
| 14 | 26 | ||
| 15 | useLayoutEffect(() => { | 27 | useLayoutEffect(() => { |
| 16 | const element = elementRef.current; | 28 | const element = elementRef.current; |
| 17 | if (!element) return; | 29 | if (!element) return; |
| 18 | const lease = acquireBrowserSurface(tabId); | 30 | let lease = acquireBrowserSurface(tabId, fitSourceContent); |
| 19 | const update = () => { | 31 | const update = () => { |
| 20 | const rect = element.getBoundingClientRect(); | 32 | const rect = element.getBoundingClientRect(); |
| 21 | lease.present( | 33 | const presentation = presentationRef.current; |
| 34 | const presented = lease.present( | ||
| 22 | { | 35 | { |
| 23 | x: Math.round(rect.x), | 36 | x: Math.round(rect.x), |
| 24 | y: Math.round(rect.y), | 37 | y: Math.round(rect.y), |
| 25 | width: Math.max(1, Math.round(rect.width)), | 38 | width: Math.max(1, Math.round(rect.width)), |
| 26 | height: Math.max(1, Math.round(rect.height)), | 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 | update(); | 60 | update(); |
| 32 | const observer = new ResizeObserver(update); | 61 | const observer = new ResizeObserver(update); |
| 33 | observer.observe(element); | 62 | observer.observe(element); |
| ... | @@ -37,9 +66,15 @@ export function BrowserSurfaceSlot(props: { | ... | @@ -37,9 +66,15 @@ export function BrowserSurfaceSlot(props: { |
| 37 | observer.disconnect(); | 66 | observer.disconnect(); |
| 38 | window.removeEventListener("resize", update); | 67 | window.removeEventListener("resize", update); |
| 39 | window.removeEventListener("scroll", update, true); | 68 | window.removeEventListener("scroll", update, true); |
| 69 | if (updateRef.current === update) updateRef.current = null; | ||
| 40 | lease.release(); | 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 | return <div ref={elementRef} className={className} data-browser-surface-slot={tabId} />; | 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,15 +8,19 @@ import { previewBridge } from "~/components/preview/previewBridge"; |
| 8 | import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; | 8 | import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; |
| 9 | import { cn } from "~/lib/utils"; | 9 | import { cn } from "~/lib/utils"; |
| 10 | 10 | ||
| 11 | import { stopBrowserRecording, useActiveBrowserRecordingTabId } from "./browserRecording"; | ||
| 12 | import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore"; | 11 | import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore"; |
| 13 | import { browserViewportSettingKey } from "./browserViewportLayout"; | 12 | import { browserViewportSettingKey, resolveBrowserViewportLayout } from "./browserViewportLayout"; |
| 14 | import { BrowserDeviceToolbar } from "./BrowserDeviceToolbar"; | 13 | import { BrowserDeviceToolbar } from "./BrowserDeviceToolbar"; |
| 15 | import { BrowserViewportResizeHandles } from "./BrowserViewportResizeHandles"; | 14 | import { BrowserViewportResizeHandles } from "./BrowserViewportResizeHandles"; |
| 16 | import { acquireDesktopTab, type AcquiredDesktopTab } from "./desktopTabLifetime"; | 15 | import { acquireDesktopTab, type AcquiredDesktopTab } from "./desktopTabLifetime"; |
| 17 | import { resolveHostedBrowserWebviewWrapperStyle } from "./hostedBrowserWebviewStyle"; | 16 | import { resolveHostedBrowserWebviewWrapperStyle } from "./hostedBrowserWebviewStyle"; |
| 18 | import { usePreviewWebviewConfig } from "./previewWebviewConfigState"; | 17 | import { usePreviewWebviewConfig } from "./previewWebviewConfigState"; |
| 19 | import { useBrowserViewportResize } from "./useBrowserViewportResize"; | 18 | import { useBrowserViewportResize } from "./useBrowserViewportResize"; |
| 19 | import { | ||
| 20 | INITIAL_WEBVIEW_CRASH_RECOVERY_STATE, | ||
| 21 | planWebviewCrashRecovery, | ||
| 22 | type WebviewCrashRecoveryState, | ||
| 23 | } from "./webviewCrashRecovery"; | ||
| 20 | 24 | ||
| 21 | interface ElectronWebview extends HTMLElement { | 25 | interface ElectronWebview extends HTMLElement { |
| 22 | src: string; | 26 | src: string; |
| ... | @@ -46,12 +50,15 @@ export function HostedBrowserWebview(props: { | ... | @@ -46,12 +50,15 @@ export function HostedBrowserWebview(props: { |
| 46 | const tabLeaseRef = useRef<AcquiredDesktopTab | null>(null); | 50 | const tabLeaseRef = useRef<AcquiredDesktopTab | null>(null); |
| 47 | const wrapperRef = useRef<HTMLDivElement | null>(null); | 51 | const wrapperRef = useRef<HTMLDivElement | null>(null); |
| 48 | const webviewRef = useRef<ElectronWebview | null>(null); | 52 | const webviewRef = useRef<ElectronWebview | null>(null); |
| 53 | const crashRecoveryRef = useRef<WebviewCrashRecoveryState>(INITIAL_WEBVIEW_CRASH_RECOVERY_STATE); | ||
| 49 | const [aspectRatioLocked, setAspectRatioLocked] = useState(false); | 54 | const [aspectRatioLocked, setAspectRatioLocked] = useState(false); |
| 50 | const activeRecordingTabId = useActiveBrowserRecordingTabId(); | ||
| 51 | const presentation = useBrowserSurfaceStore( | 55 | const presentation = useBrowserSurfaceStore( |
| 52 | useShallow((state) => { | 56 | useShallow((state) => { |
| 53 | const current = state.byTabId[tabId]; | 57 | const current = state.byTabId[tabId]; |
| 54 | return { | 58 | return { |
| 59 | cornerRadius: current?.cornerRadius ?? 0, | ||
| 60 | fitSourceContent: current?.fitSourceContent ?? false, | ||
| 61 | fittedSourceContent: current?.fittedSourceContent ?? null, | ||
| 55 | rect: resolveBrowserSurfacePanelRect(state.byTabId, tabId), | 62 | rect: resolveBrowserSurfacePanelRect(state.byTabId, tabId), |
| 56 | visible: current?.visible ?? false, | 63 | visible: current?.visible ?? false, |
| 57 | }; | 64 | }; |
| ... | @@ -60,11 +67,7 @@ export function HostedBrowserWebview(props: { | ... | @@ -60,11 +67,7 @@ export function HostedBrowserWebview(props: { |
| 60 | usePreviewBridge({ threadRef, tabId }); | 67 | usePreviewBridge({ threadRef, tabId }); |
| 61 | 68 | ||
| 62 | useEffect(() => { | 69 | useEffect(() => { |
| 63 | if (presentation.visible || activeRecordingTabId !== tabId) return; | 70 | crashRecoveryRef.current = INITIAL_WEBVIEW_CRASH_RECOVERY_STATE; |
| 64 | void stopBrowserRecording(tabId).catch(() => undefined); | ||
| 65 | }, [activeRecordingTabId, presentation.visible, tabId]); | ||
| 66 | |||
| 67 | useEffect(() => { | ||
| 68 | const lease = acquireDesktopTab(tabId); | 71 | const lease = acquireDesktopTab(tabId); |
| 69 | tabLeaseRef.current = lease; | 72 | tabLeaseRef.current = lease; |
| 70 | return () => { | 73 | return () => { |
| ... | @@ -73,6 +76,14 @@ export function HostedBrowserWebview(props: { | ... | @@ -73,6 +76,14 @@ export function HostedBrowserWebview(props: { |
| 73 | }; | 76 | }; |
| 74 | }, [tabId]); | 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 | const setWebviewRef = useCallback((node: HTMLElement | null) => { | 87 | const setWebviewRef = useCallback((node: HTMLElement | null) => { |
| 77 | webviewRef.current = node as ElectronWebview | null; | 88 | webviewRef.current = node as ElectronWebview | null; |
| 78 | if (node && !node.hasAttribute("allowpopups")) node.setAttribute("allowpopups", "true"); | 89 | if (node && !node.hasAttribute("allowpopups")) node.setAttribute("allowpopups", "true"); |
| ... | @@ -83,6 +94,7 @@ export function HostedBrowserWebview(props: { | ... | @@ -83,6 +94,7 @@ export function HostedBrowserWebview(props: { |
| 83 | const bridge = previewBridge; | 94 | const bridge = previewBridge; |
| 84 | if (!webview || !config || !bridge) return; | 95 | if (!webview || !config || !bridge) return; |
| 85 | let disposed = false; | 96 | let disposed = false; |
| 97 | let recoveryTimeout: ReturnType<typeof setTimeout> | null = null; | ||
| 86 | const register = () => { | 98 | const register = () => { |
| 87 | const lease = tabLeaseRef.current; | 99 | const lease = tabLeaseRef.current; |
| 88 | if (!lease) return; | 100 | if (!lease) return; |
| ... | @@ -102,15 +114,31 @@ export function HostedBrowserWebview(props: { | ... | @@ -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 | webview.addEventListener("did-attach", register); | 130 | webview.addEventListener("did-attach", register); |
| 106 | webview.addEventListener("dom-ready", register); | 131 | webview.addEventListener("dom-ready", register); |
| 132 | webview.addEventListener("render-process-gone", recoverGuest); | ||
| 107 | register(); | 133 | register(); |
| 108 | return () => { | 134 | return () => { |
| 109 | disposed = true; | 135 | disposed = true; |
| 136 | if (recoveryTimeout !== null) clearTimeout(recoveryTimeout); | ||
| 110 | webview.removeEventListener("did-attach", register); | 137 | webview.removeEventListener("did-attach", register); |
| 111 | webview.removeEventListener("dom-ready", register); | 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 | const active = presentation.visible && presentation.rect !== null; | 143 | const active = presentation.visible && presentation.rect !== null; |
| 116 | const lastRect = presentation.rect; | 144 | const lastRect = presentation.rect; |
| ... | @@ -132,14 +160,14 @@ export function HostedBrowserWebview(props: { | ... | @@ -132,14 +160,14 @@ export function HostedBrowserWebview(props: { |
| 132 | } | 160 | } |
| 133 | : { width: lastRect?.width ?? 1280, height: lastRect?.height ?? 800 }; | 161 | : { width: lastRect?.width ?? 1280, height: lastRect?.height ?? 800 }; |
| 134 | const containerSize = active && lastRect ? lastRect : hiddenSize; | 162 | const containerSize = active && lastRect ? lastRect : hiddenSize; |
| 135 | const deviceToolbarVisible = active && viewport._tag !== "fill"; | 163 | const deviceToolbarVisible = active && viewport._tag !== "fill" && !presentation.fitSourceContent; |
| 136 | const { | 164 | const { |
| 137 | activeDrag, | 165 | activeDrag, |
| 138 | commitViewportChange, | 166 | commitViewportChange, |
| 139 | effectiveViewport, | 167 | effectiveViewport, |
| 140 | handleResizeKeyDown, | 168 | handleResizeKeyDown, |
| 141 | handleResizePointerDown, | 169 | handleResizePointerDown, |
| 142 | layout, | 170 | layout: viewportLayout, |
| 143 | } = useBrowserViewportResize({ | 171 | } = useBrowserViewportResize({ |
| 144 | tabId, | 172 | tabId, |
| 145 | viewport, | 173 | viewport, |
| ... | @@ -148,6 +176,38 @@ export function HostedBrowserWebview(props: { | ... | @@ -148,6 +176,38 @@ export function HostedBrowserWebview(props: { |
| 148 | deviceToolbarVisible, | 176 | deviceToolbarVisible, |
| 149 | aspectRatio: lockedAspectRatio, | 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 | const syncContentPresentation = useCallback(() => { | 212 | const syncContentPresentation = useCallback(() => { |
| 153 | const wrapper = wrapperRef.current; | 213 | const wrapper = wrapperRef.current; |
| ... | @@ -178,6 +238,7 @@ export function HostedBrowserWebview(props: { | ... | @@ -178,6 +238,7 @@ export function HostedBrowserWebview(props: { |
| 178 | 238 | ||
| 179 | const wrapperStyle = resolveHostedBrowserWebviewWrapperStyle({ | 239 | const wrapperStyle = resolveHostedBrowserWebviewWrapperStyle({ |
| 180 | active, | 240 | active, |
| 241 | cornerRadius: presentation.cornerRadius, | ||
| 181 | rect: lastRect, | 242 | rect: lastRect, |
| 182 | hiddenSize, | 243 | hiddenSize, |
| 183 | }); | 244 | }); |
| ... | @@ -201,8 +262,9 @@ export function HostedBrowserWebview(props: { | ... | @@ -201,8 +262,9 @@ export function HostedBrowserWebview(props: { |
| 201 | /> | 262 | /> |
| 202 | ) : null} | 263 | ) : null} |
| 203 | <webview | 264 | <webview |
| 265 | key={webviewGeneration} | ||
| 204 | ref={setWebviewRef} | 266 | ref={setWebviewRef} |
| 205 | src={initialSrc} | 267 | src={webviewGeneration === 0 ? initialSrc : recoverySrc} |
| 206 | partition={config.partition} | 268 | partition={config.partition} |
| 207 | webpreferences={config.webPreferences} | 269 | webpreferences={config.webPreferences} |
| 208 | {...(config.preloadUrl ? { preload: config.preloadUrl } : {})} | 270 | {...(config.preloadUrl ? { preload: config.preloadUrl } : {})} |
| ... | @@ -210,14 +272,18 @@ export function HostedBrowserWebview(props: { | ... | @@ -210,14 +272,18 @@ export function HostedBrowserWebview(props: { |
| 210 | data-preview-viewport-mode={effectiveViewport._tag} | 272 | data-preview-viewport-mode={effectiveViewport._tag} |
| 211 | data-preview-viewport-key={browserViewportSettingKey(effectiveViewport)} | 273 | data-preview-viewport-key={browserViewportSettingKey(effectiveViewport)} |
| 212 | data-preview-css-width={ | 274 | data-preview-css-width={ |
| 213 | effectiveViewport._tag === "fill" | 275 | fittedSourceViewport |
| 214 | ? Math.max(1, Math.round(layout.viewportWidth / normalizedZoomFactor)) | 276 | ? fittedSourceViewport.width |
| 215 | : effectiveViewport.width | 277 | : effectiveViewport._tag === "fill" |
| 278 | ? Math.max(1, Math.round(layout.viewportWidth / normalizedZoomFactor)) | ||
| 279 | : effectiveViewport.width | ||
| 216 | } | 280 | } |
| 217 | data-preview-css-height={ | 281 | data-preview-css-height={ |
| 218 | effectiveViewport._tag === "fill" | 282 | fittedSourceViewport |
| 219 | ? Math.max(1, Math.round(layout.viewportHeight / normalizedZoomFactor)) | 283 | ? fittedSourceViewport.height |
| 220 | : effectiveViewport.height | 284 | : effectiveViewport._tag === "fill" |
| 285 | ? Math.max(1, Math.round(layout.viewportHeight / normalizedZoomFactor)) | ||
| 286 | : effectiveViewport.height | ||
| 221 | } | 287 | } |
| 222 | aria-hidden={active ? undefined : true} | 288 | aria-hidden={active ? undefined : true} |
| 223 | className={cn( | 289 | className={cn( |
| ... | @@ -233,7 +299,7 @@ export function HostedBrowserWebview(props: { | ... | @@ -233,7 +299,7 @@ export function HostedBrowserWebview(props: { |
| 233 | transformOrigin: "top left", | 299 | transformOrigin: "top left", |
| 234 | }} | 300 | }} |
| 235 | /> | 301 | /> |
| 236 | {active && effectiveViewport._tag !== "fill" ? ( | 302 | {active && effectiveViewport._tag !== "fill" && !fittedSourceViewport ? ( |
| 237 | <> | 303 | <> |
| 238 | <BrowserViewportResizeHandles | 304 | <BrowserViewportResizeHandles |
| 239 | layout={layout} | 305 | layout={layout} |
apps/web/src/browser/browserRecording.test.ts+319-53| ... | @@ -1,32 +1,73 @@ | ... | @@ -1,32 +1,73 @@ |
| 1 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 2 | 3 | ||
| 3 | const { events, onFrame, registrySet, save, startScreencast, stopScreencast, surfaceState } = | 4 | const { |
| 4 | vi.hoisted(() => { | 5 | events, |
| 5 | const events: string[] = []; | 6 | frameSubscription, |
| 6 | const surfaceState = { | 7 | onFrame, |
| 7 | byTabId: {} as Record<string, unknown>, | 8 | registrySet, |
| 8 | }; | 9 | save, |
| 9 | return { | 10 | startScreencast, |
| 10 | events, | 11 | stopScreencast, |
| 11 | onFrame: vi.fn(() => vi.fn()), | 12 | surfaceState, |
| 12 | registrySet: vi.fn((_atom: unknown, value: string | null) => { | 13 | } = vi.hoisted(() => { |
| 13 | events.push(value === null ? "clear" : `publish:${value}`); | 14 | const events: string[] = []; |
| 14 | }), | 15 | type Frame = { |
| 15 | save: vi.fn(async () => ({ | 16 | readonly tabId: string; |
| 16 | id: "recording-test", | 17 | readonly data: string; |
| 17 | tabId: "recording-tab", | 18 | readonly width: number; |
| 18 | path: "/tmp/recording-test.webm", | 19 | readonly height: number; |
| 19 | mimeType: "video/webm" as const, | 20 | readonly receivedAt: string; |
| 20 | sizeBytes: 0, | 21 | }; |
| 21 | createdAt: "2026-06-26T00:00:00.000Z", | 22 | const frameSubscription: { listener: ((frame: Frame) => void) | null } = { |
| 22 | })), | 23 | listener: null, |
| 23 | startScreencast: vi.fn(async () => { | 24 | }; |
| 24 | events.push("start-screencast"); | 25 | const surfaceState = { |
| 25 | }), | 26 | byTabId: {} as Record<string, unknown>, |
| 26 | stopScreencast: vi.fn(async () => undefined), | 27 | }; |
| 27 | surfaceState, | 28 | return { |
| 28 | }; | 29 | events, |
| 29 | }); | 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 | vi.mock("~/components/preview/previewBridge", () => ({ | 72 | vi.mock("~/components/preview/previewBridge", () => ({ |
| 32 | previewBridge: { | 73 | previewBridge: { |
| ... | @@ -45,10 +86,10 @@ vi.mock("./browserSurfaceStore", () => ({ | ... | @@ -45,10 +86,10 @@ vi.mock("./browserSurfaceStore", () => ({ |
| 45 | })); | 86 | })); |
| 46 | 87 | ||
| 47 | import { | 88 | import { |
| 89 | BROWSER_RECORDING_FIRST_FRAME_SIZE_TIMEOUT_MS, | ||
| 48 | BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS, | 90 | BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS, |
| 49 | BrowserRecordingConflictError, | 91 | BrowserRecordingConflictError, |
| 50 | BrowserRecordingOperationError, | 92 | readActiveBrowserRecordingTabIds, |
| 51 | BrowserRecordingRequiresVisibleTabError, | ||
| 52 | startBrowserRecording, | 93 | startBrowserRecording, |
| 53 | stopBrowserRecording, | 94 | stopBrowserRecording, |
| 54 | } from "./browserRecording"; | 95 | } from "./browserRecording"; |
| ... | @@ -80,9 +121,20 @@ class FakeMediaRecorder { | ... | @@ -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 | describe("browser recording", () => { | 134 | describe("browser recording", () => { |
| 84 | beforeEach(() => { | 135 | beforeEach(() => { |
| 85 | events.length = 0; | 136 | events.length = 0; |
| 137 | frameSubscription.listener = null; | ||
| 86 | surfaceState.byTabId = { | 138 | surfaceState.byTabId = { |
| 87 | "recording-tab": { | 139 | "recording-tab": { |
| 88 | visible: true, | 140 | visible: true, |
| ... | @@ -93,12 +145,26 @@ describe("browser recording", () => { | ... | @@ -93,12 +145,26 @@ describe("browser recording", () => { |
| 93 | vi.clearAllMocks(); | 145 | vi.clearAllMocks(); |
| 94 | vi.stubGlobal("window", globalThis); | 146 | vi.stubGlobal("window", globalThis); |
| 95 | vi.stubGlobal("MediaRecorder", FakeMediaRecorder as unknown as typeof MediaRecorder); | 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 | vi.stubGlobal("document", { | 162 | vi.stubGlobal("document", { |
| 97 | createElement: () => ({ | 163 | createElement: () => ({ |
| 98 | width: 0, | 164 | width: 0, |
| 99 | height: 0, | 165 | height: 0, |
| 100 | captureStream: () => ({}), | 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,7 +182,7 @@ describe("browser recording", () => { |
| 116 | await stopBrowserRecording("recording-tab"); | 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 | surfaceState.byTabId = { | 186 | surfaceState.byTabId = { |
| 121 | "recording-tab": { | 187 | "recording-tab": { |
| 122 | visible: false, | 188 | visible: false, |
| ... | @@ -125,18 +191,189 @@ describe("browser recording", () => { | ... | @@ -125,18 +191,189 @@ describe("browser recording", () => { |
| 125 | }, | 191 | }, |
| 126 | }; | 192 | }; |
| 127 | 193 | ||
| 128 | await expect(startBrowserRecording("recording-tab")).rejects.toBeInstanceOf( | 194 | await startBrowserRecording("recording-tab"); |
| 129 | BrowserRecordingRequiresVisibleTabError, | 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(); | 359 | await stopBrowserRecording("recording-tab"); |
| 133 | expect(registrySet).not.toHaveBeenCalled(); | 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 | it("does not report success for a second start while the first is still starting", async () => { | 366 | it("does not report success for a second start while the first is still starting", async () => { |
| 137 | let finishStartingScreencast: (() => void) | undefined; | 367 | let finishStartingScreencast: (() => void) | undefined; |
| 138 | startScreencast.mockImplementationOnce(async () => { | 368 | startScreencast.mockImplementationOnce(async (tabId: string) => { |
| 139 | events.push("start-screencast"); | 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 | await new Promise<void>((resolve) => { | 377 | await new Promise<void>((resolve) => { |
| 141 | finishStartingScreencast = resolve; | 378 | finishStartingScreencast = resolve; |
| 142 | }); | 379 | }); |
| ... | @@ -197,28 +434,27 @@ describe("browser recording", () => { | ... | @@ -197,28 +434,27 @@ describe("browser recording", () => { |
| 197 | expect(save).toHaveBeenCalledOnce(); | 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 | let finishStartingScreencast: (() => void) | undefined; | 438 | let finishStartingScreencast: (() => void) | undefined; |
| 202 | startScreencast.mockImplementationOnce(async () => { | 439 | startScreencast.mockImplementationOnce(async () => { |
| 203 | events.push("start-screencast"); | 440 | events.push("start-screencast"); |
| 204 | await new Promise<void>((resolve) => { | 441 | await new Promise<void>((resolve) => { |
| 205 | finishStartingScreencast = resolve; | 442 | finishStartingScreencast = resolve; |
| 206 | }); | 443 | }); |
| 444 | emitRecordingFrame(); | ||
| 207 | }); | 445 | }); |
| 208 | 446 | ||
| 209 | const startPromise = startBrowserRecording("recording-tab"); | 447 | const startPromise = startBrowserRecording("recording-tab"); |
| 210 | const rejectedStart = expect(startPromise).rejects.toBeInstanceOf( | ||
| 211 | BrowserRecordingOperationError, | ||
| 212 | ); | ||
| 213 | await vi.waitFor(() => expect(startScreencast).toHaveBeenCalledOnce()); | 448 | await vi.waitFor(() => expect(startScreencast).toHaveBeenCalledOnce()); |
| 214 | 449 | ||
| 215 | const stopPromise = stopBrowserRecording("recording-tab"); | 450 | const stopPromise = stopBrowserRecording("recording-tab"); |
| 216 | await vi.waitFor(() => expect(stopScreencast).toHaveBeenCalledOnce()); | 451 | expect(stopScreencast).not.toHaveBeenCalled(); |
| 217 | finishStartingScreencast?.(); | 452 | finishStartingScreencast?.(); |
| 218 | 453 | ||
| 219 | await rejectedStart; | 454 | await startPromise; |
| 220 | await stopPromise; | 455 | await expect(stopPromise).resolves.toMatchObject({ tabId: "recording-tab" }); |
| 221 | expect(stopScreencast).toHaveBeenCalledTimes(2); | 456 | expect(stopScreencast).toHaveBeenCalledOnce(); |
| 457 | expect(save).toHaveBeenCalledOnce(); | ||
| 222 | expect(events.at(-1)).toBe("clear"); | 458 | expect(events.at(-1)).toBe("clear"); |
| 223 | }); | 459 | }); |
| 224 | 460 | ||
| ... | @@ -229,12 +465,10 @@ describe("browser recording", () => { | ... | @@ -229,12 +465,10 @@ describe("browser recording", () => { |
| 229 | await new Promise<void>((resolve) => { | 465 | await new Promise<void>((resolve) => { |
| 230 | finishStartingScreencast = resolve; | 466 | finishStartingScreencast = resolve; |
| 231 | }); | 467 | }); |
| 468 | emitRecordingFrame(); | ||
| 232 | }); | 469 | }); |
| 233 | 470 | ||
| 234 | const firstStart = startBrowserRecording("recording-tab"); | 471 | const firstStart = startBrowserRecording("recording-tab"); |
| 235 | const rejectedFirstStart = expect(firstStart).rejects.toBeInstanceOf( | ||
| 236 | BrowserRecordingOperationError, | ||
| 237 | ); | ||
| 238 | await vi.waitFor(() => expect(startScreencast).toHaveBeenCalledOnce()); | 472 | await vi.waitFor(() => expect(startScreencast).toHaveBeenCalledOnce()); |
| 239 | 473 | ||
| 240 | const stopPromise = stopBrowserRecording("recording-tab"); | 474 | const stopPromise = stopBrowserRecording("recording-tab"); |
| ... | @@ -243,7 +477,7 @@ describe("browser recording", () => { | ... | @@ -243,7 +477,7 @@ describe("browser recording", () => { |
| 243 | const startCallsBeforeFirstSettled = startScreencast.mock.calls.length; | 477 | const startCallsBeforeFirstSettled = startScreencast.mock.calls.length; |
| 244 | 478 | ||
| 245 | finishStartingScreencast?.(); | 479 | finishStartingScreencast?.(); |
| 246 | await rejectedFirstStart; | 480 | await firstStart; |
| 247 | await stopPromise; | 481 | await stopPromise; |
| 248 | await restartAfterStop; | 482 | await restartAfterStop; |
| 249 | await stopBrowserRecording("recording-tab"); | 483 | await stopBrowserRecording("recording-tab"); |
| ... | @@ -251,6 +485,39 @@ describe("browser recording", () => { | ... | @@ -251,6 +485,39 @@ describe("browser recording", () => { |
| 251 | expect(startCallsBeforeFirstSettled).toBe(1); | 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 | it("fails a stop that waits too long for startup without freeing the recording slot", async () => { | 521 | it("fails a stop that waits too long for startup without freeing the recording slot", async () => { |
| 255 | vi.useFakeTimers(); | 522 | vi.useFakeTimers(); |
| 256 | let finishStartingScreencast: (() => void) | undefined; | 523 | let finishStartingScreencast: (() => void) | undefined; |
| ... | @@ -259,18 +526,16 @@ describe("browser recording", () => { | ... | @@ -259,18 +526,16 @@ describe("browser recording", () => { |
| 259 | await new Promise<void>((resolve) => { | 526 | await new Promise<void>((resolve) => { |
| 260 | finishStartingScreencast = resolve; | 527 | finishStartingScreencast = resolve; |
| 261 | }); | 528 | }); |
| 529 | emitRecordingFrame(); | ||
| 262 | }); | 530 | }); |
| 263 | 531 | ||
| 264 | const startPromise = startBrowserRecording("recording-tab"); | 532 | const startPromise = startBrowserRecording("recording-tab"); |
| 265 | const rejectedStart = expect(startPromise).rejects.toBeInstanceOf( | ||
| 266 | BrowserRecordingOperationError, | ||
| 267 | ); | ||
| 268 | expect(startScreencast).toHaveBeenCalledOnce(); | 533 | expect(startScreencast).toHaveBeenCalledOnce(); |
| 269 | 534 | ||
| 270 | const stopPromise = stopBrowserRecording("recording-tab"); | 535 | const stopPromise = stopBrowserRecording("recording-tab"); |
| 271 | await Promise.resolve(); | 536 | await Promise.resolve(); |
| 272 | await Promise.resolve(); | 537 | await Promise.resolve(); |
| 273 | expect(stopScreencast).toHaveBeenCalledOnce(); | 538 | expect(stopScreencast).not.toHaveBeenCalled(); |
| 274 | 539 | ||
| 275 | const rejection = expect(stopPromise).rejects.toMatchObject({ | 540 | const rejection = expect(stopPromise).rejects.toMatchObject({ |
| 276 | operation: "wait-startup", | 541 | operation: "wait-startup", |
| ... | @@ -285,9 +550,10 @@ describe("browser recording", () => { | ... | @@ -285,9 +550,10 @@ describe("browser recording", () => { |
| 285 | ); | 550 | ); |
| 286 | 551 | ||
| 287 | finishStartingScreencast?.(); | 552 | finishStartingScreencast?.(); |
| 288 | await rejectedStart; | 553 | await startPromise; |
| 289 | const cleanupResult = await stopBrowserRecording("recording-tab"); | 554 | const cleanupResult = await stopBrowserRecording("recording-tab"); |
| 290 | expect(cleanupResult).toBeNull(); | 555 | expect(cleanupResult).toBeNull(); |
| 556 | expect(stopScreencast).toHaveBeenCalledOnce(); | ||
| 291 | expect(save).not.toHaveBeenCalled(); | 557 | expect(save).not.toHaveBeenCalled(); |
| 292 | expect(events.at(-1)).toBe("clear"); | 558 | expect(events.at(-1)).toBe("clear"); |
| 293 | }); | 559 | }); |
apps/web/src/browser/browserRecording.ts+253-121| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import type { | 1 | import type { |
| 2 | DesktopPreviewRecordingArtifact, | 2 | DesktopPreviewRecordingArtifact, |
| 3 | DesktopPreviewRecordingFrame, | 3 | DesktopPreviewRecordingFrame, |
| 4 | ScopedThreadRef, | ||
| 4 | } from "@t3tools/contracts"; | 5 | } from "@t3tools/contracts"; |
| 5 | import { useAtomValue } from "@effect/atom-react"; | 6 | import { useAtomValue } from "@effect/atom-react"; |
| 6 | import * as Schema from "effect/Schema"; | 7 | import * as Schema from "effect/Schema"; |
| ... | @@ -46,17 +47,6 @@ export class BrowserRecordingCanvasUnavailableError extends Schema.TaggedErrorCl | ... | @@ -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 | export class BrowserRecordingOperationError extends Schema.TaggedErrorClass<BrowserRecordingOperationError>()( | 50 | export class BrowserRecordingOperationError extends Schema.TaggedErrorClass<BrowserRecordingOperationError>()( |
| 61 | "BrowserRecordingOperationError", | 51 | "BrowserRecordingOperationError", |
| 62 | { | 52 | { |
| ... | @@ -66,6 +56,7 @@ export class BrowserRecordingOperationError extends Schema.TaggedErrorClass<Brow | ... | @@ -66,6 +56,7 @@ export class BrowserRecordingOperationError extends Schema.TaggedErrorClass<Brow |
| 66 | "start-media-recorder", | 56 | "start-media-recorder", |
| 67 | "start-screencast", | 57 | "start-screencast", |
| 68 | "stop-screencast", | 58 | "stop-screencast", |
| 59 | "wait-first-frame", | ||
| 69 | "wait-startup", | 60 | "wait-startup", |
| 70 | "stop-media-recorder", | 61 | "stop-media-recorder", |
| 71 | "save-artifact", | 62 | "save-artifact", |
| ... | @@ -92,32 +83,52 @@ type BrowserRecordingLifecycle = | ... | @@ -92,32 +83,52 @@ type BrowserRecordingLifecycle = |
| 92 | 83 | ||
| 93 | interface ActiveRecording { | 84 | interface ActiveRecording { |
| 94 | readonly tabId: string; | 85 | readonly tabId: string; |
| 86 | readonly threadRef: ScopedThreadRef | null; | ||
| 95 | readonly canvas: HTMLCanvasElement; | 87 | readonly canvas: HTMLCanvasElement; |
| 96 | readonly context: CanvasRenderingContext2D; | 88 | readonly context: CanvasRenderingContext2D; |
| 97 | readonly recorder: MediaRecorder; | ||
| 98 | readonly chunks: Blob[]; | 89 | readonly chunks: Blob[]; |
| 99 | readonly mimeType: string; | ||
| 100 | readonly startedAt: string; | 90 | readonly startedAt: string; |
| 101 | readonly startupSettled: Promise<void>; | 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 | lifecycle: BrowserRecordingLifecycle; | 99 | lifecycle: BrowserRecordingLifecycle; |
| 103 | } | 100 | } |
| 104 | 101 | ||
| 105 | const activeBrowserRecordingTabIdAtom = Atom.make<string | null>(null).pipe( | 102 | interface ActiveBrowserRecordingIndex { |
| 106 | Atom.keepAlive, | 103 | readonly tabIds: ReadonlySet<string>; |
| 107 | Atom.withLabel("preview:active-browser-recording-tab"), | 104 | } |
| 108 | ); | ||
| 109 | 105 | ||
| 110 | export function useActiveBrowserRecordingTabId(): string | null { | 106 | const activeBrowserRecordingTabIdsAtom = Atom.make<ActiveBrowserRecordingIndex>({ |
| 111 | return useAtomValue(activeBrowserRecordingTabIdAtom); | 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 | let unsubscribeFrames: (() => void) | null = null; | 115 | let unsubscribeFrames: (() => void) | null = null; |
| 116 | 116 | ||
| 117 | export const BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS = 5_000; | 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 | export function readActiveBrowserRecordingTabIds(threadRef?: ScopedThreadRef): ReadonlySet<string> { |
| 120 | return active?.tabId ?? null; | 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 | const preferredMimeType = (): string => { | 134 | const preferredMimeType = (): string => { |
| ... | @@ -126,22 +137,52 @@ const preferredMimeType = (): string => { | ... | @@ -126,22 +137,52 @@ const preferredMimeType = (): string => { |
| 126 | }; | 137 | }; |
| 127 | 138 | ||
| 128 | const drawFrame = (frame: DesktopPreviewRecordingFrame): void => { | 139 | const drawFrame = (frame: DesktopPreviewRecordingFrame): void => { |
| 129 | const recording = active; | 140 | const recording = activeRecordings.get(frame.tabId); |
| 130 | if (!recording || recording.tabId !== frame.tabId) return; | 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 | const image = new Image(); | 159 | const image = new Image(); |
| 132 | image.addEventListener( | 160 | image.addEventListener( |
| 133 | "load", | 161 | "load", |
| 134 | () => { | 162 | () => { |
| 135 | if (active !== recording) return; | 163 | if ( |
| 136 | recording.context.drawImage(image, 0, 0, recording.canvas.width, recording.canvas.height); | 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 | { once: true }, | 179 | { once: true }, |
| 139 | ); | 180 | ); |
| 140 | image.src = `data:image/jpeg;base64,${frame.data}`; | 181 | image.src = `data:image/jpeg;base64,${frame.data}`; |
| 141 | }; | 182 | }; |
| 142 | 183 | ||
| 143 | const stopMediaRecorder = async (recorder: MediaRecorder): Promise<void> => { | 184 | const stopMediaRecorder = async (recorder: MediaRecorder | null): Promise<void> => { |
| 144 | if (recorder.state === "inactive") return; | 185 | if (!recorder || recorder.state === "inactive") return; |
| 145 | const stopped = new Promise<void>((resolve) => | 186 | const stopped = new Promise<void>((resolve) => |
| 146 | recorder.addEventListener("stop", () => resolve(), { once: true }), | 187 | recorder.addEventListener("stop", () => resolve(), { once: true }), |
| 147 | ); | 188 | ); |
| ... | @@ -150,11 +191,42 @@ const stopMediaRecorder = async (recorder: MediaRecorder): Promise<void> => { | ... | @@ -150,11 +191,42 @@ const stopMediaRecorder = async (recorder: MediaRecorder): Promise<void> => { |
| 150 | }; | 191 | }; |
| 151 | 192 | ||
| 152 | const clearActiveRecording = (recording: ActiveRecording): void => { | 193 | const clearActiveRecording = (recording: ActiveRecording): void => { |
| 153 | if (active !== recording) return; | 194 | if (activeRecordings.get(recording.tabId) !== recording) return; |
| 154 | active = null; | 195 | recording.settleFirstFrameSize("cancelled"); |
| 155 | unsubscribeFrames?.(); | 196 | activeRecordings.delete(recording.tabId); |
| 156 | unsubscribeFrames = null; | 197 | if (activeRecordings.size === 0) { |
| 157 | appAtomRegistry.set(activeBrowserRecordingTabIdAtom, null); | 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 | const recordingStartupCancelledError = ( | 232 | const recordingStartupCancelledError = ( |
| ... | @@ -168,7 +240,20 @@ const recordingStartupCancelledError = ( | ... | @@ -168,7 +240,20 @@ const recordingStartupCancelledError = ( |
| 168 | }); | 240 | }); |
| 169 | 241 | ||
| 170 | const isRecordingStarting = (recording: ActiveRecording): boolean => | 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 | const waitForRecordingStartupToSettle = async (recording: ActiveRecording): Promise<void> => { | 258 | const waitForRecordingStartupToSettle = async (recording: ActiveRecording): Promise<void> => { |
| 174 | let timeout: ReturnType<typeof setTimeout> | null = null; | 259 | let timeout: ReturnType<typeof setTimeout> | null = null; |
| ... | @@ -195,20 +280,23 @@ const waitForRecordingStartupToSettle = async (recording: ActiveRecording): Prom | ... | @@ -195,20 +280,23 @@ const waitForRecordingStartupToSettle = async (recording: ActiveRecording): Prom |
| 195 | const isStartupWaitTimeout = (error: unknown): error is BrowserRecordingOperationError => | 280 | const isStartupWaitTimeout = (error: unknown): error is BrowserRecordingOperationError => |
| 196 | isBrowserRecordingOperationError(error) && error.operation === "wait-startup"; | 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 | const bridge = previewBridge; | 287 | const bridge = previewBridge; |
| 200 | if (!bridge) throw new BrowserRecordingUnavailableError({ tabId }); | 288 | if (!bridge) throw new BrowserRecordingUnavailableError({ tabId }); |
| 201 | if (active) { | 289 | const activeRecording = activeRecordings.get(tabId); |
| 202 | if (active.tabId === tabId && active.lifecycle.phase === "recording") { | 290 | if (activeRecording) { |
| 203 | return active.startedAt; | 291 | if (activeRecording.lifecycle.phase === "recording") { |
| 292 | return activeRecording.startedAt; | ||
| 204 | } | 293 | } |
| 205 | throw new BrowserRecordingConflictError({ | 294 | throw new BrowserRecordingConflictError({ |
| 206 | requestedTabId: tabId, | 295 | requestedTabId: tabId, |
| 207 | activeTabId: active.tabId, | 296 | activeTabId: activeRecording.tabId, |
| 208 | }); | 297 | }); |
| 209 | } | 298 | } |
| 210 | const surface = useBrowserSurfaceStore.getState().byTabId[tabId]; | 299 | const surface = useBrowserSurfaceStore.getState().byTabId[tabId]; |
| 211 | if (!surface?.visible) throw new BrowserRecordingRequiresVisibleTabError({ tabId }); | ||
| 212 | const recordingSize = surface?.content ?? surface?.rect; | 300 | const recordingSize = surface?.content ?? surface?.rect; |
| 213 | const canvas = document.createElement("canvas"); | 301 | const canvas = document.createElement("canvas"); |
| 214 | canvas.width = Math.max(1, recordingSize?.width ?? 1280); | 302 | canvas.width = Math.max(1, recordingSize?.width ?? 1280); |
| ... | @@ -221,42 +309,34 @@ export async function startBrowserRecording(tabId: string): Promise<string> { | ... | @@ -221,42 +309,34 @@ export async function startBrowserRecording(tabId: string): Promise<string> { |
| 221 | height: canvas.height, | 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 | const startedAt = new Date().toISOString(); | 312 | const startedAt = new Date().toISOString(); |
| 240 | const chunks: Blob[] = []; | 313 | const chunks: Blob[] = []; |
| 241 | let settleStartup: (() => void) | undefined; | 314 | let settleStartup: (() => void) | undefined; |
| 242 | const startupSettled = new Promise<void>((resolve) => { | 315 | const startupSettled = new Promise<void>((resolve) => { |
| 243 | settleStartup = resolve; | 316 | settleStartup = resolve; |
| 244 | }); | 317 | }); |
| 245 | recorder.addEventListener("dataavailable", (event) => { | 318 | let settleFirstFrameSize: ((outcome: "frame" | "cancelled") => void) | undefined; |
| 246 | if (event.data.size > 0) chunks.push(event.data); | 319 | const firstFrameSize = new Promise<"frame" | "cancelled">((resolve) => { |
| 320 | settleFirstFrameSize = resolve; | ||
| 247 | }); | 321 | }); |
| 248 | const recording: ActiveRecording = { | 322 | const recording: ActiveRecording = { |
| 249 | tabId, | 323 | tabId, |
| 324 | threadRef, | ||
| 250 | canvas, | 325 | canvas, |
| 251 | context, | 326 | context, |
| 252 | recorder, | ||
| 253 | chunks, | 327 | chunks, |
| 254 | mimeType, | ||
| 255 | startedAt, | 328 | startedAt, |
| 256 | startupSettled, | 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 | lifecycle: { phase: "starting" }, | 337 | lifecycle: { phase: "starting" }, |
| 258 | }; | 338 | }; |
| 259 | active = recording; | 339 | activeRecordings.set(tabId, recording); |
| 260 | try { | 340 | try { |
| 261 | try { | 341 | try { |
| 262 | unsubscribeFrames ??= bridge.recording.onFrame(drawFrame); | 342 | unsubscribeFrames ??= bridge.recording.onFrame(drawFrame); |
| ... | @@ -268,47 +348,23 @@ export async function startBrowserRecording(tabId: string): Promise<string> { | ... | @@ -268,47 +348,23 @@ export async function startBrowserRecording(tabId: string): Promise<string> { |
| 268 | cause, | 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 | try { | 351 | try { |
| 285 | await bridge.recording.startScreencast(tabId); | 352 | await bridge.recording.startScreencast(tabId); |
| 286 | } catch (cause) { | 353 | } catch (cause) { |
| 287 | if (!isRecordingStarting(recording)) { | 354 | if (!isRecordingStarting(recording)) { |
| 288 | throw recordingStartupCancelledError(recording, cause); | 355 | throw recordingStartupCancelledError(recording, cause); |
| 289 | } | 356 | } |
| 290 | let cleanupCause: unknown; | 357 | clearActiveRecording(recording); |
| 291 | try { | ||
| 292 | await stopMediaRecorder(recorder); | ||
| 293 | } catch (error) { | ||
| 294 | cleanupCause = error; | ||
| 295 | } finally { | ||
| 296 | clearActiveRecording(recording); | ||
| 297 | } | ||
| 298 | throw new BrowserRecordingOperationError({ | 358 | throw new BrowserRecordingOperationError({ |
| 299 | operation: "start-screencast", | 359 | operation: "start-screencast", |
| 300 | tabId, | 360 | tabId, |
| 301 | cause: | 361 | 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 | ), | ||
| 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 | try { | 368 | try { |
| 313 | await bridge.recording.stopScreencast(tabId); | 369 | await bridge.recording.stopScreencast(tabId); |
| 314 | } catch (cause) { | 370 | } catch (cause) { |
| ... | @@ -322,9 +378,78 @@ export async function startBrowserRecording(tabId: string): Promise<string> { | ... | @@ -322,9 +378,78 @@ export async function startBrowserRecording(tabId: string): Promise<string> { |
| 322 | ); | 378 | ); |
| 323 | } | 379 | } |
| 324 | throw recordingStartupCancelledError(recording); | 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" }; | 430 | try { |
| 327 | appAtomRegistry.set(activeBrowserRecordingTabIdAtom, tabId); | 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 | return startedAt; | 453 | return startedAt; |
| 329 | } finally { | 454 | } finally { |
| 330 | settleStartup?.(); | 455 | settleStartup?.(); |
| ... | @@ -334,12 +459,16 @@ export async function startBrowserRecording(tabId: string): Promise<string> { | ... | @@ -334,12 +459,16 @@ export async function startBrowserRecording(tabId: string): Promise<string> { |
| 334 | const finalizeBrowserRecording = async ( | 459 | const finalizeBrowserRecording = async ( |
| 335 | bridge: NonNullable<typeof previewBridge>, | 460 | bridge: NonNullable<typeof previewBridge>, |
| 336 | recording: ActiveRecording, | 461 | recording: ActiveRecording, |
| 337 | ): Promise<DesktopPreviewRecordingArtifact> => { | 462 | ): Promise<DesktopPreviewRecordingArtifact | null> => { |
| 338 | const { tabId } = recording; | 463 | const { tabId } = recording; |
| 339 | let result: | 464 | let result: |
| 340 | | { readonly _tag: "Success"; readonly artifact: DesktopPreviewRecordingArtifact } | 465 | | { |
| 466 | readonly _tag: "Success"; | ||
| 467 | readonly artifact: DesktopPreviewRecordingArtifact | null; | ||
| 468 | } | ||
| 341 | | { readonly _tag: "Failure"; readonly error: unknown }; | 469 | | { readonly _tag: "Failure"; readonly error: unknown }; |
| 342 | try { | 470 | try { |
| 471 | await waitForRecordingStartupToSettle(recording); | ||
| 343 | try { | 472 | try { |
| 344 | await bridge.recording.stopScreencast(tabId); | 473 | await bridge.recording.stopScreencast(tabId); |
| 345 | } catch (cause) { | 474 | } catch (cause) { |
| ... | @@ -349,30 +478,33 @@ const finalizeBrowserRecording = async ( | ... | @@ -349,30 +478,33 @@ const finalizeBrowserRecording = async ( |
| 349 | cause, | 478 | cause, |
| 350 | }); | 479 | }); |
| 351 | } | 480 | } |
| 352 | await waitForRecordingStartupToSettle(recording); | 481 | if (!recording.recorder || !recording.mimeType) { |
| 353 | try { | 482 | result = { _tag: "Success", artifact: null }; |
| 354 | await stopMediaRecorder(recording.recorder); | 483 | } else { |
| 355 | } catch (cause) { | 484 | try { |
| 356 | throw new BrowserRecordingOperationError({ | 485 | await stopMediaRecorder(recording.recorder); |
| 357 | operation: "stop-media-recorder", | 486 | } catch (cause) { |
| 358 | tabId, | 487 | throw new BrowserRecordingOperationError({ |
| 359 | cause, | 488 | operation: "stop-media-recorder", |
| 360 | }); | 489 | tabId, |
| 361 | } | 490 | cause, |
| 362 | try { | 491 | }); |
| 363 | const blob = new Blob(recording.chunks, { type: recording.mimeType }); | 492 | } |
| 364 | const artifact = await bridge.recording.save( | 493 | try { |
| 365 | tabId, | 494 | const blob = new Blob(recording.chunks, { type: recording.mimeType }); |
| 366 | recording.mimeType, | 495 | const artifact = await bridge.recording.save( |
| 367 | new Uint8Array(await blob.arrayBuffer()), | 496 | tabId, |
| 368 | ); | 497 | recording.mimeType, |
| 369 | result = { _tag: "Success", artifact }; | 498 | new Uint8Array(await blob.arrayBuffer()), |
| 370 | } catch (cause) { | 499 | ); |
| 371 | throw new BrowserRecordingOperationError({ | 500 | result = { _tag: "Success", artifact }; |
| 372 | operation: "save-artifact", | 501 | } catch (cause) { |
| 373 | tabId, | 502 | throw new BrowserRecordingOperationError({ |
| 374 | cause, | 503 | operation: "save-artifact", |
| 375 | }); | 504 | tabId, |
| 505 | cause, | ||
| 506 | }); | ||
| 507 | } | ||
| 376 | } | 508 | } |
| 377 | } catch (error) { | 509 | } catch (error) { |
| 378 | result = { _tag: "Failure", error }; | 510 | result = { _tag: "Failure", error }; |
| ... | @@ -434,14 +566,14 @@ export function stopBrowserRecording( | ... | @@ -434,14 +566,14 @@ export function stopBrowserRecording( |
| 434 | tabId: string, | 566 | tabId: string, |
| 435 | ): Promise<DesktopPreviewRecordingArtifact | null> { | 567 | ): Promise<DesktopPreviewRecordingArtifact | null> { |
| 436 | const bridge = previewBridge; | 568 | const bridge = previewBridge; |
| 437 | const recording = active; | 569 | const recording = activeRecordings.get(tabId); |
| 438 | if (!bridge || !recording || recording.tabId !== tabId) return Promise.resolve(null); | 570 | if (!bridge || !recording) return Promise.resolve(null); |
| 439 | if (recording.lifecycle.phase === "stopping") return recording.lifecycle.stopPromise; | 571 | if (recording.lifecycle.phase === "stopping") return recording.lifecycle.stopPromise; |
| 440 | 572 | ||
| 441 | const stopPromise = Promise.resolve() | 573 | const stopPromise = Promise.resolve() |
| 442 | .then(() => finalizeBrowserRecording(bridge, recording)) | 574 | .then(() => finalizeBrowserRecording(bridge, recording)) |
| 443 | .catch((error) => { | 575 | .catch((error) => { |
| 444 | if (isStartupWaitTimeout(error) && active === recording) { | 576 | if (isStartupWaitTimeout(error) && activeRecordings.get(recording.tabId) === recording) { |
| 445 | const cleanupAfterStartup = recording.startupSettled.then(() => | 577 | const cleanupAfterStartup = recording.startupSettled.then(() => |
| 446 | discardBrowserRecording(bridge, recording), | 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,14 +3,41 @@ import { describe, expect, it } from "vite-plus/test"; |
| 3 | import { resolveBrowserRecordingStopTarget } from "./browserRecordingScope"; | 3 | import { resolveBrowserRecordingStopTarget } from "./browserRecordingScope"; |
| 4 | 4 | ||
| 5 | describe("resolveBrowserRecordingStopTarget", () => { | 5 | describe("resolveBrowserRecordingStopTarget", () => { |
| 6 | it("stops the active recording when no explicit tab was requested", () => { | 6 | it("stops the only active recording when the implicit browser target changed", () => { |
| 7 | expect(resolveBrowserRecordingStopTarget("tab-a")).toBe("tab-a"); | 7 | expect(resolveBrowserRecordingStopTarget(new Set(["tab-recording"]), "tab-browsing")).toBe( |
| 8 | expect(resolveBrowserRecordingStopTarget("tab-b")).toBe("tab-b"); | 8 | "tab-recording", |
| 9 | expect(resolveBrowserRecordingStopTarget(null)).toBeNull(); | 9 | ); |
| 10 | }); | 10 | }); |
| 11 | 11 | ||
| 12 | it("only stops an explicitly requested tab when it owns the recording", () => { | 12 | it("prefers an implicit target that is actively recording", () => { |
| 13 | expect(resolveBrowserRecordingStopTarget("tab-a", "tab-a")).toBe("tab-a"); | 13 | expect( |
| 14 | expect(resolveBrowserRecordingStopTarget("tab-a", "tab-b")).toBeNull(); | 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,7 +1,14 @@ |
| 1 | export function resolveBrowserRecordingStopTarget( | 1 | export function resolveBrowserRecordingStopTarget( |
| 2 | activeTabId: string | null, | 2 | activeTabIds: ReadonlySet<string>, |
| 3 | requestedTabId?: string, | 3 | implicitTabId: string | null, |
| 4 | explicitTabId?: string, | ||
| 4 | ): string | null { | 5 | ): string | null { |
| 5 | if (activeTabId === null) return null; | 6 | if (explicitTabId !== undefined) { |
| 6 | return requestedTabId === undefined || requestedTabId === activeTabId ? activeTabId : null; | 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,6 +11,62 @@ describe("browserSurfaceStore", () => { |
| 11 | useBrowserSurfaceStore.setState({ byTabId: {} }); | 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 | it("tracks content dimensions for a browser that has never been visible", () => { | 70 | it("tracks content dimensions for a browser that has never been visible", () => { |
| 15 | const tabId = "hidden-browser-surface-content-test"; | 71 | const tabId = "hidden-browser-surface-content-test"; |
| 16 | useBrowserSurfaceStore.getState().presentContent(tabId, { | 72 | useBrowserSurfaceStore.getState().presentContent(tabId, { |
| ... | @@ -36,8 +92,26 @@ describe("browserSurfaceStore", () => { | ... | @@ -36,8 +92,26 @@ describe("browserSurfaceStore", () => { |
| 36 | expect( | 92 | expect( |
| 37 | resolveBrowserSurfacePanelRect( | 93 | resolveBrowserSurfacePanelRect( |
| 38 | { | 94 | { |
| 39 | hidden: { rect: staleRect, visible: false, content: null, updatedAt: 1, owner: null }, | 95 | hidden: { |
| 40 | active: { rect: liveRect, visible: true, content: null, updatedAt: 2, owner: null }, | 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 | "hidden", | 116 | "hidden", |
| 43 | ), | 117 | ), |
| ... | @@ -53,7 +127,7 @@ describe("browserSurfaceStore", () => { | ... | @@ -53,7 +127,7 @@ describe("browserSurfaceStore", () => { |
| 53 | 127 | ||
| 54 | const liveLease = acquireBrowserSurface(tabId); | 128 | const liveLease = acquireBrowserSurface(tabId); |
| 55 | liveLease.present(liveRect, true); | 129 | liveLease.present(liveRect, true); |
| 56 | staleLease.present(staleRect, true); | 130 | expect(staleLease.present(staleRect, true)).toBe(false); |
| 57 | staleLease.release(); | 131 | staleLease.release(); |
| 58 | 132 | ||
| 59 | expect(useBrowserSurfaceStore.getState().byTabId[tabId]).toMatchObject({ | 133 | expect(useBrowserSurfaceStore.getState().byTabId[tabId]).toMatchObject({ |
| ... | @@ -75,4 +149,27 @@ describe("browserSurfaceStore", () => { | ... | @@ -75,4 +149,27 @@ describe("browserSurfaceStore", () => { |
| 75 | owner: null, | 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,6 +11,9 @@ export interface BrowserSurfacePresentation { |
| 11 | readonly rect: BrowserSurfaceRect | null; | 11 | readonly rect: BrowserSurfaceRect | null; |
| 12 | readonly visible: boolean; | 12 | readonly visible: boolean; |
| 13 | readonly content: BrowserSurfaceContentPresentation | null; | 13 | readonly content: BrowserSurfaceContentPresentation | null; |
| 14 | readonly fittedSourceContent: BrowserSurfaceContentPresentation | null; | ||
| 15 | readonly fitSourceContent: boolean; | ||
| 16 | readonly cornerRadius: number; | ||
| 14 | readonly updatedAt: number; | 17 | readonly updatedAt: number; |
| 15 | readonly owner: symbol | null; | 18 | readonly owner: symbol | null; |
| 16 | } | 19 | } |
| ... | @@ -27,19 +30,20 @@ export interface BrowserSurfaceContentPresentation { | ... | @@ -27,19 +30,20 @@ export interface BrowserSurfaceContentPresentation { |
| 27 | 30 | ||
| 28 | interface BrowserSurfaceStoreState { | 31 | interface BrowserSurfaceStoreState { |
| 29 | readonly byTabId: Record<string, BrowserSurfacePresentation>; | 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 | readonly present: ( | 34 | readonly present: ( |
| 32 | tabId: string, | 35 | tabId: string, |
| 33 | owner: symbol, | 36 | owner: symbol, |
| 34 | rect: BrowserSurfaceRect, | 37 | rect: BrowserSurfaceRect, |
| 35 | visible: boolean, | 38 | visible: boolean, |
| 39 | cornerRadius: number, | ||
| 36 | ) => void; | 40 | ) => void; |
| 37 | readonly presentContent: (tabId: string, content: BrowserSurfaceContentPresentation) => void; | 41 | readonly presentContent: (tabId: string, content: BrowserSurfaceContentPresentation) => void; |
| 38 | readonly release: (tabId: string, owner: symbol) => void; | 42 | readonly release: (tabId: string, owner: symbol) => void; |
| 39 | } | 43 | } |
| 40 | 44 | ||
| 41 | export interface BrowserSurfaceLease { | 45 | export interface BrowserSurfaceLease { |
| 42 | readonly present: (rect: BrowserSurfaceRect, visible: boolean) => void; | 46 | readonly present: (rect: BrowserSurfaceRect, visible: boolean, cornerRadius?: number) => boolean; |
| 43 | readonly release: () => void; | 47 | readonly release: () => void; |
| 44 | } | 48 | } |
| 45 | 49 | ||
| ... | @@ -72,7 +76,7 @@ const rectEquals = (left: BrowserSurfaceRect | null, right: BrowserSurfaceRect): | ... | @@ -72,7 +76,7 @@ const rectEquals = (left: BrowserSurfaceRect | null, right: BrowserSurfaceRect): |
| 72 | 76 | ||
| 73 | export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) => ({ | 77 | export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) => ({ |
| 74 | byTabId: {}, | 78 | byTabId: {}, |
| 75 | claim: (tabId, owner) => | 79 | claim: (tabId, owner, fitSourceContent) => |
| 76 | set((state) => { | 80 | set((state) => { |
| 77 | const current = state.byTabId[tabId]; | 81 | const current = state.byTabId[tabId]; |
| 78 | if (current?.owner === owner) return state; | 82 | if (current?.owner === owner) return state; |
| ... | @@ -83,21 +87,31 @@ export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) = | ... | @@ -83,21 +87,31 @@ export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) = |
| 83 | rect: current?.rect ?? null, | 87 | rect: current?.rect ?? null, |
| 84 | visible: false, | 88 | visible: false, |
| 85 | content: current?.content ?? null, | 89 | content: current?.content ?? null, |
| 90 | fittedSourceContent: fitSourceContent ? (current?.content ?? null) : null, | ||
| 91 | fitSourceContent, | ||
| 92 | cornerRadius: current?.cornerRadius ?? 0, | ||
| 86 | updatedAt: Date.now(), | 93 | updatedAt: Date.now(), |
| 87 | owner, | 94 | owner, |
| 88 | }, | 95 | }, |
| 89 | }, | 96 | }, |
| 90 | }; | 97 | }; |
| 91 | }), | 98 | }), |
| 92 | present: (tabId, owner, rect, visible) => | 99 | present: (tabId, owner, rect, visible, cornerRadius) => |
| 93 | set((state) => { | 100 | set((state) => { |
| 94 | const current = state.byTabId[tabId]; | 101 | const current = state.byTabId[tabId]; |
| 95 | if (current?.owner !== owner) return state; | 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 | return { | 111 | return { |
| 98 | byTabId: { | 112 | byTabId: { |
| 99 | ...state.byTabId, | 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,6 +126,9 @@ export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) = |
| 112 | rect: null, | 126 | rect: null, |
| 113 | visible: false, | 127 | visible: false, |
| 114 | content, | 128 | content, |
| 129 | fittedSourceContent: null, | ||
| 130 | fitSourceContent: false, | ||
| 131 | cornerRadius: 0, | ||
| 115 | updatedAt: Date.now(), | 132 | updatedAt: Date.now(), |
| 116 | owner: null, | 133 | owner: null, |
| 117 | }, | 134 | }, |
| ... | @@ -134,7 +151,15 @@ export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) = | ... | @@ -134,7 +151,15 @@ export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) = |
| 134 | return { | 151 | return { |
| 135 | byTabId: { | 152 | byTabId: { |
| 136 | ...state.byTabId, | 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,21 +170,33 @@ export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) = |
| 145 | return { | 170 | return { |
| 146 | byTabId: { | 171 | byTabId: { |
| 147 | ...state.byTabId, | 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 | const owner = Symbol(`browser-surface:${tabId}`); | 190 | const owner = Symbol(`browser-surface:${tabId}`); |
| 156 | let released = false; | 191 | let released = false; |
| 157 | useBrowserSurfaceStore.getState().claim(tabId, owner); | 192 | useBrowserSurfaceStore.getState().claim(tabId, owner, fitSourceContent); |
| 158 | 193 | ||
| 159 | return { | 194 | return { |
| 160 | present: (rect, visible) => { | 195 | present: (rect, visible, cornerRadius = 0) => { |
| 161 | if (released) return; | 196 | if (released) return false; |
| 162 | useBrowserSurfaceStore.getState().present(tabId, owner, rect, visible); | 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 | release: () => { | 201 | release: () => { |
| 165 | if (released) return; | 202 | if (released) return; |
apps/web/src/browser/desktopTabLifetime.test.ts+64-3| ... | @@ -1,20 +1,32 @@ | ... | @@ -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(() => ({ | 3 | const { closeTab, createTab, stopBrowserRecording } = vi.hoisted(() => ({ |
| 4 | closeTab: vi.fn(async () => undefined), | 4 | closeTab: vi.fn<(tabId: string) => Promise<void>>(async () => undefined), |
| 5 | createTab: vi.fn<() => Promise<void>>(), | 5 | createTab: vi.fn<() => Promise<void>>(), |
| 6 | stopBrowserRecording: vi.fn(async () => null), | ||
| 6 | })); | 7 | })); |
| 7 | 8 | ||
| 8 | vi.mock("~/components/preview/previewBridge", () => ({ | 9 | vi.mock("~/components/preview/previewBridge", () => ({ |
| 9 | previewBridge: { closeTab, createTab }, | 10 | previewBridge: { closeTab, createTab }, |
| 10 | })); | 11 | })); |
| 11 | 12 | ||
| 13 | vi.mock("./browserRecording", () => ({ | ||
| 14 | stopBrowserRecording, | ||
| 15 | })); | ||
| 16 | |||
| 12 | import { acquireDesktopTab } from "./desktopTabLifetime"; | 17 | import { acquireDesktopTab } from "./desktopTabLifetime"; |
| 13 | 18 | ||
| 14 | describe("desktopTabLifetime", () => { | 19 | describe("desktopTabLifetime", () => { |
| 15 | beforeEach(() => { | 20 | beforeEach(() => { |
| 16 | closeTab.mockClear(); | 21 | closeTab.mockClear(); |
| 17 | createTab.mockClear(); | 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 | it("shares tab creation readiness across concurrent leases", async () => { | 32 | it("shares tab creation readiness across concurrent leases", async () => { |
| ... | @@ -42,4 +54,53 @@ describe("desktopTabLifetime", () => { | ... | @@ -42,4 +54,53 @@ describe("desktopTabLifetime", () => { |
| 42 | await first.ready; | 54 | await first.ready; |
| 43 | expect(ready).toBe(true); | 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,5 +1,7 @@ |
| 1 | import { previewBridge } from "~/components/preview/previewBridge"; | 1 | import { previewBridge } from "~/components/preview/previewBridge"; |
| 2 | 2 | ||
| 3 | import { stopBrowserRecording } from "./browserRecording"; | ||
| 4 | |||
| 3 | interface DesktopTabLease { | 5 | interface DesktopTabLease { |
| 4 | references: number; | 6 | references: number; |
| 5 | closeTimer: number | null; | 7 | closeTimer: number | null; |
| ... | @@ -7,6 +9,26 @@ interface DesktopTabLease { | ... | @@ -7,6 +9,26 @@ interface DesktopTabLease { |
| 7 | } | 9 | } |
| 8 | 10 | ||
| 9 | const leases = new Map<string, DesktopTabLease>(); | 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 | export interface AcquiredDesktopTab { | 33 | export interface AcquiredDesktopTab { |
| 12 | readonly ready: Promise<void>; | 34 | readonly ready: Promise<void>; |
| ... | @@ -19,7 +41,7 @@ export function acquireDesktopTab(tabId: string): AcquiredDesktopTab { | ... | @@ -19,7 +41,7 @@ export function acquireDesktopTab(tabId: string): AcquiredDesktopTab { |
| 19 | ({ | 41 | ({ |
| 20 | references: 0, | 42 | references: 0, |
| 21 | closeTimer: null, | 43 | closeTimer: null, |
| 22 | ready: previewBridge?.createTab(tabId) ?? Promise.resolve(), | 44 | ready: enqueueDesktopTabOperation(tabId, () => previewBridge?.createTab(tabId)), |
| 23 | } satisfies DesktopTabLease); | 45 | } satisfies DesktopTabLease); |
| 24 | if (current.closeTimer !== null) window.clearTimeout(current.closeTimer); | 46 | if (current.closeTimer !== null) window.clearTimeout(current.closeTimer); |
| 25 | current.references += 1; | 47 | current.references += 1; |
| ... | @@ -37,7 +59,10 @@ export function acquireDesktopTab(tabId: string): AcquiredDesktopTab { | ... | @@ -37,7 +59,10 @@ export function acquireDesktopTab(tabId: string): AcquiredDesktopTab { |
| 37 | const latest = leases.get(tabId); | 59 | const latest = leases.get(tabId); |
| 38 | if (!latest || latest.references > 0) return; | 60 | if (!latest || latest.references > 0) return; |
| 39 | leases.delete(tabId); | 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 | }, 0); | 66 | }, 0); |
| 42 | }, | 67 | }, |
| 43 | }; | 68 | }; |
apps/web/src/browser/hostedBrowserWebviewStyle.test.ts+17| ... | @@ -23,6 +23,23 @@ describe("resolveHostedBrowserWebviewWrapperStyle", () => { | ... | @@ -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 | it("keeps an inactive webview paintable while moving it offscreen", () => { | 43 | it("keeps an inactive webview paintable while moving it offscreen", () => { |
| 27 | const style = resolveHostedBrowserWebviewWrapperStyle({ | 44 | const style = resolveHostedBrowserWebviewWrapperStyle({ |
| 28 | active: false, | 45 | active: false, |
apps/web/src/browser/hostedBrowserWebviewStyle.ts+4-1| ... | @@ -12,6 +12,7 @@ export interface HostedBrowserWebviewWrapperStyle { | ... | @@ -12,6 +12,7 @@ export interface HostedBrowserWebviewWrapperStyle { |
| 12 | readonly height: number; | 12 | readonly height: number; |
| 13 | readonly zIndex: number; | 13 | readonly zIndex: number; |
| 14 | readonly pointerEvents: "auto" | "none"; | 14 | readonly pointerEvents: "auto" | "none"; |
| 15 | readonly borderRadius?: number; | ||
| 15 | readonly visibility?: "visible"; | 16 | readonly visibility?: "visible"; |
| 16 | } | 17 | } |
| 17 | 18 | ||
| ... | @@ -19,10 +20,11 @@ export const HIDDEN_BROWSER_WEBVIEW_OFFSET = -100_000; | ... | @@ -19,10 +20,11 @@ export const HIDDEN_BROWSER_WEBVIEW_OFFSET = -100_000; |
| 19 | 20 | ||
| 20 | export function resolveHostedBrowserWebviewWrapperStyle(input: { | 21 | export function resolveHostedBrowserWebviewWrapperStyle(input: { |
| 21 | readonly active: boolean; | 22 | readonly active: boolean; |
| 23 | readonly cornerRadius?: number; | ||
| 22 | readonly rect: BrowserSurfaceRect | null; | 24 | readonly rect: BrowserSurfaceRect | null; |
| 23 | readonly hiddenSize: HostedBrowserWebviewSize; | 25 | readonly hiddenSize: HostedBrowserWebviewSize; |
| 24 | }): HostedBrowserWebviewWrapperStyle { | 26 | }): HostedBrowserWebviewWrapperStyle { |
| 25 | const { active, hiddenSize, rect } = input; | 27 | const { active, cornerRadius = 0, hiddenSize, rect } = input; |
| 26 | if (active && rect) { | 28 | if (active && rect) { |
| 27 | return { | 29 | return { |
| 28 | left: rect.x, | 30 | left: rect.x, |
| ... | @@ -31,6 +33,7 @@ export function resolveHostedBrowserWebviewWrapperStyle(input: { | ... | @@ -31,6 +33,7 @@ export function resolveHostedBrowserWebviewWrapperStyle(input: { |
| 31 | height: rect.height, | 33 | height: rect.height, |
| 32 | zIndex: 30, | 34 | zIndex: 30, |
| 33 | pointerEvents: "auto", | 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,8 +131,13 @@ import { |
| 131 | } from "../previewStateStore"; | 131 | } from "../previewStateStore"; |
| 132 | import { addBrowserSurface } from "./preview/addBrowserSurface"; | 132 | import { addBrowserSurface } from "./preview/addBrowserSurface"; |
| 133 | import { closePreviewSession } from "./preview/closePreviewSession"; | 133 | import { closePreviewSession } from "./preview/closePreviewSession"; |
| 134 | import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer"; | ||
| 134 | import { subscribePreviewAction } from "./preview/previewActionBus"; | 135 | import { subscribePreviewAction } from "./preview/previewActionBus"; |
| 135 | import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; | 136 | import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; |
| 137 | import { | ||
| 138 | selectThreadPreviewMiniPlayer, | ||
| 139 | usePreviewMiniPlayerStore, | ||
| 140 | } from "../previewMiniPlayerStore"; | ||
| 136 | import { RightPanelTabs } from "./RightPanelTabs"; | 141 | import { RightPanelTabs } from "./RightPanelTabs"; |
| 137 | import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; | 142 | import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; |
| 138 | import { BranchToolbar } from "./BranchToolbar"; | 143 | import { BranchToolbar } from "./BranchToolbar"; |
| ... | @@ -1484,6 +1489,9 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -1484,6 +1489,9 @@ function ChatViewContent(props: ChatViewProps) { |
| 1484 | const activeFileSurface = | 1489 | const activeFileSurface = |
| 1485 | activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null; | 1490 | activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null; |
| 1486 | const activePreviewState = useThreadPreviewState(activeThreadRef); | 1491 | const activePreviewState = useThreadPreviewState(activeThreadRef); |
| 1492 | const activePreviewMiniPlayer = usePreviewMiniPlayerStore((state) => | ||
| 1493 | selectThreadPreviewMiniPlayer(state.byThreadKey, activeThreadRef), | ||
| 1494 | ); | ||
| 1487 | const panelTerminalIds = useMemo( | 1495 | const panelTerminalIds = useMemo( |
| 1488 | () => | 1496 | () => |
| 1489 | new Set( | 1497 | new Set( |
| ... | @@ -1507,6 +1515,24 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -1507,6 +1515,24 @@ function ChatViewContent(props: ChatViewProps) { |
| 1507 | .reconcileBrowserSurfaces(activeThreadRef, Object.keys(activePreviewState.sessions)); | 1515 | .reconcileBrowserSurfaces(activeThreadRef, Object.keys(activePreviewState.sessions)); |
| 1508 | }, [activePreviewState.sessions, activeThreadRef]); | 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 | const planSidebarOpen = activeRightPanelKind === "plan"; | 1536 | const planSidebarOpen = activeRightPanelKind === "plan"; |
| 1511 | 1537 | ||
| 1512 | const existingOpenTerminalThreadKeys = useMemo(() => { | 1538 | const existingOpenTerminalThreadKeys = useMemo(() => { |
| ... | @@ -5907,6 +5933,15 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -5907,6 +5933,15 @@ function ChatViewContent(props: ChatViewProps) { |
| 5907 | </div> | 5933 | </div> |
| 5908 | </div> | 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 | <AlertDialog open={branchRestoreConfirmOpen} onOpenChange={setBranchRestoreConfirmOpen}> | 5945 | <AlertDialog open={branchRestoreConfirmOpen} onOpenChange={setBranchRestoreConfirmOpen}> |
| 5911 | <AlertDialogPopup> | 5946 | <AlertDialogPopup> |
| 5912 | <AlertDialogHeader> | 5947 | <AlertDialogHeader> |
apps/web/src/components/preview/PreviewAutomationHosts.tsx+14-9| ... | @@ -29,10 +29,10 @@ import { | ... | @@ -29,10 +29,10 @@ import { |
| 29 | reconcilePreviewServerSessions, | 29 | reconcilePreviewServerSessions, |
| 30 | updatePreviewServerSnapshot, | 30 | updatePreviewServerSnapshot, |
| 31 | } from "~/previewStateStore"; | 31 | } from "~/previewStateStore"; |
| 32 | import { useRightPanelStore } from "~/rightPanelStore"; | 32 | import { usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; |
| 33 | import { resolveBrowserNavigationTarget } from "~/browser/browserTargetResolver"; | 33 | import { resolveBrowserNavigationTarget } from "~/browser/browserTargetResolver"; |
| 34 | import { | 34 | import { |
| 35 | readActiveBrowserRecordingTabId, | 35 | readActiveBrowserRecordingTabIds, |
| 36 | startBrowserRecording, | 36 | startBrowserRecording, |
| 37 | stopBrowserRecording, | 37 | stopBrowserRecording, |
| 38 | } from "~/browser/browserRecording"; | 38 | } from "~/browser/browserRecording"; |
| ... | @@ -53,7 +53,10 @@ import { | ... | @@ -53,7 +53,10 @@ import { |
| 53 | PreviewAutomationTargetUnavailableError, | 53 | PreviewAutomationTargetUnavailableError, |
| 54 | PreviewAutomationViewportTimeoutError, | 54 | PreviewAutomationViewportTimeoutError, |
| 55 | } from "./previewAutomationErrors"; | 55 | } from "./previewAutomationErrors"; |
| 56 | import { previewAutomationOpenNeedsOverlay } from "./previewAutomationOpenReadiness"; | 56 | import { |
| 57 | previewAutomationOpenNeedsOverlay, | ||
| 58 | shouldOpenPreviewMiniPlayer, | ||
| 59 | } from "./previewAutomationOpenReadiness"; | ||
| 57 | import { createPreviewAutomationRequestConsumerAtom } from "./previewAutomationRequestConsumer"; | 60 | import { createPreviewAutomationRequestConsumerAtom } from "./previewAutomationRequestConsumer"; |
| 58 | import { createPreviewAutomationClientId } from "./previewAutomationClientId"; | 61 | import { createPreviewAutomationClientId } from "./previewAutomationClientId"; |
| 59 | import { | 62 | import { |
| ... | @@ -319,7 +322,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) | ... | @@ -319,7 +322,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) |
| 319 | if (result._tag === "Failure") { | 322 | if (result._tag === "Failure") { |
| 320 | return raiseAtomCommandFailure(result); | 323 | return raiseAtomCommandFailure(result); |
| 321 | } | 324 | } |
| 322 | reconcilePreviewServerSessions(threadRef, result.value.sessions); | 325 | reconcilePreviewServerSessions(threadRef, result.value); |
| 323 | state = readThreadPreviewState(threadRef); | 326 | state = readThreadPreviewState(threadRef); |
| 324 | } | 327 | } |
| 325 | tabId = request.tabId ?? state.snapshot?.tabId ?? null; | 328 | tabId = request.tabId ?? state.snapshot?.tabId ?? null; |
| ... | @@ -378,8 +381,8 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) | ... | @@ -378,8 +381,8 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) |
| 378 | activeSnapshot = snapshot; | 381 | activeSnapshot = snapshot; |
| 379 | tabId = activeTabId; | 382 | tabId = activeTabId; |
| 380 | } | 383 | } |
| 381 | if (input.show ?? true) { | 384 | if (shouldOpenPreviewMiniPlayer(input)) { |
| 382 | useRightPanelStore.getState().openBrowser(threadRef, activeTabId); | 385 | usePreviewMiniPlayerStore.getState().open(threadRef, activeTabId); |
| 383 | } | 386 | } |
| 384 | if (activeSnapshot && previewAutomationOpenNeedsOverlay(input, activeSnapshot)) { | 387 | if (activeSnapshot && previewAutomationOpenNeedsOverlay(input, activeSnapshot)) { |
| 385 | await waitForDesktopOverlay( | 388 | await waitForDesktopOverlay( |
| ... | @@ -510,7 +513,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) | ... | @@ -510,7 +513,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) |
| 510 | } | 513 | } |
| 511 | case "recordingStart": { | 514 | case "recordingStart": { |
| 512 | const ready = await requireReadyTab(); | 515 | const ready = await requireReadyTab(); |
| 513 | const startedAt = await startBrowserRecording(ready.tabId); | 516 | const startedAt = await startBrowserRecording(ready.tabId, threadRef); |
| 514 | return { | 517 | return { |
| 515 | tabId: ready.tabId, | 518 | tabId: ready.tabId, |
| 516 | recording: true, | 519 | recording: true, |
| ... | @@ -518,11 +521,13 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) | ... | @@ -518,11 +521,13 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) |
| 518 | }; | 521 | }; |
| 519 | } | 522 | } |
| 520 | case "recordingStop": { | 523 | case "recordingStop": { |
| 521 | const recordingTabId = readActiveBrowserRecordingTabId(); | 524 | const activeTabIds = readActiveBrowserRecordingTabIds(threadRef); |
| 522 | const stopTabId = resolveBrowserRecordingStopTarget( | 525 | const stopTabId = resolveBrowserRecordingStopTarget( |
| 523 | recordingTabId, | 526 | activeTabIds, |
| 527 | tabId, | ||
| 524 | request.tabIdExplicit ? request.tabId : undefined, | 528 | request.tabIdExplicit ? request.tabId : undefined, |
| 525 | ); | 529 | ); |
| 530 | tabId = stopTabId ?? tabId; | ||
| 526 | const artifact = stopTabId ? await stopBrowserRecording(stopTabId) : null; | 531 | const artifact = stopTabId ? await stopBrowserRecording(stopTabId) : null; |
| 527 | if (!artifact) { | 532 | if (!artifact) { |
| 528 | return raisePreviewAutomationHostError( | 533 | return raisePreviewAutomationHostError( |
apps/web/src/components/preview/PreviewChromeRow.tsx+31| ... | @@ -4,6 +4,7 @@ import { | ... | @@ -4,6 +4,7 @@ import { |
| 4 | Camera, | 4 | Camera, |
| 5 | ExternalLink, | 5 | ExternalLink, |
| 6 | MousePointerClick, | 6 | MousePointerClick, |
| 7 | PictureInPicture2, | ||
| 7 | RotateCw, | 8 | RotateCw, |
| 8 | } from "lucide-react"; | 9 | } from "lucide-react"; |
| 9 | import { | 10 | import { |
| ... | @@ -40,6 +41,9 @@ interface Props { | ... | @@ -40,6 +41,9 @@ interface Props { |
| 40 | onCapture?: ((record: boolean) => void) | undefined; | 41 | onCapture?: ((record: boolean) => void) | undefined; |
| 41 | captureDisabled?: boolean | undefined; | 42 | captureDisabled?: boolean | undefined; |
| 42 | recording?: boolean | undefined; | 43 | recording?: boolean | undefined; |
| 44 | onPictureInPicture?: (() => void) | undefined; | ||
| 45 | pictureInPicture?: boolean | undefined; | ||
| 46 | pictureInPictureDisabled?: boolean | undefined; | ||
| 43 | /** | 47 | /** |
| 44 | * When provided, renders an annotation-mode toggle button to the right of | 48 | * When provided, renders an annotation-mode toggle button to the right of |
| 45 | * the URL input. Pressed while annotation mode is active (button shows in `pressed` | 49 | * the URL input. Pressed while annotation mode is active (button shows in `pressed` |
| ... | @@ -77,6 +81,9 @@ export function PreviewChromeRow({ | ... | @@ -77,6 +81,9 @@ export function PreviewChromeRow({ |
| 77 | onCapture, | 81 | onCapture, |
| 78 | captureDisabled, | 82 | captureDisabled, |
| 79 | recording, | 83 | recording, |
| 84 | onPictureInPicture, | ||
| 85 | pictureInPicture, | ||
| 86 | pictureInPictureDisabled, | ||
| 80 | onPickElement, | 87 | onPickElement, |
| 81 | pickActive, | 88 | pickActive, |
| 82 | pickDisabled, | 89 | pickDisabled, |
| ... | @@ -274,6 +281,30 @@ export function PreviewChromeRow({ | ... | @@ -274,6 +281,30 @@ export function PreviewChromeRow({ |
| 274 | </TooltipPopup> | 281 | </TooltipPopup> |
| 275 | </Tooltip> | 282 | </Tooltip> |
| 276 | ) : null} | 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 | {trailingActions} | 308 | {trailingActions} |
| 278 | </form> | 309 | </form> |
| 279 | {loadProgress > 0 ? ( | 310 | {loadProgress > 0 ? ( |
apps/web/src/components/preview/PreviewMoreMenu.tsx+11| ... | @@ -46,6 +46,10 @@ interface Props { | ... | @@ -46,6 +46,10 @@ interface Props { |
| 46 | deviceToolbarVisible: boolean; | 46 | deviceToolbarVisible: boolean; |
| 47 | /** Switches between fill-panel mode and a fixed responsive viewport. */ | 47 | /** Switches between fill-panel mode and a fixed responsive viewport. */ |
| 48 | onToggleDeviceToolbar: () => void; | 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,6 +64,8 @@ export function PreviewMoreMenu({ |
| 60 | colorScheme, | 64 | colorScheme, |
| 61 | deviceToolbarVisible, | 65 | deviceToolbarVisible, |
| 62 | onToggleDeviceToolbar, | 66 | onToggleDeviceToolbar, |
| 67 | nativePictureInPicture, | ||
| 68 | onNativePictureInPicture, | ||
| 63 | }: Props) { | 69 | }: Props) { |
| 64 | if (!previewBridge) return null; | 70 | if (!previewBridge) return null; |
| 65 | const bridge = previewBridge; | 71 | const bridge = previewBridge; |
| ... | @@ -93,6 +99,11 @@ export function PreviewMoreMenu({ | ... | @@ -93,6 +99,11 @@ export function PreviewMoreMenu({ |
| 93 | <MenuItem onClick={callTab(bridge.openDevTools)} disabled={tabDisabled}> | 99 | <MenuItem onClick={callTab(bridge.openDevTools)} disabled={tabDisabled}> |
| 94 | Open DevTools | 100 | Open DevTools |
| 95 | </MenuItem> | 101 | </MenuItem> |
| 102 | <MenuItem onClick={onNativePictureInPicture} disabled={tabDisabled}> | ||
| 103 | {nativePictureInPicture | ||
| 104 | ? "Close separate preview window" | ||
| 105 | : "Open separate preview window"} | ||
| 106 | </MenuItem> | ||
| 96 | <MenuItem onClick={onToggleDeviceToolbar} disabled={tabDisabled}> | 107 | <MenuItem onClick={onToggleDeviceToolbar} disabled={tabDisabled}> |
| 97 | {deviceToolbarVisible ? "Hide device toolbar" : "Show device toolbar"} | 108 | {deviceToolbarVisible ? "Hide device toolbar" : "Show device toolbar"} |
| 98 | </MenuItem> | 109 | </MenuItem> |
apps/web/src/components/preview/PreviewView.test.tsx+125-4| ... | @@ -8,6 +8,16 @@ const mocks = vi.hoisted(() => ({ | ... | @@ -8,6 +8,16 @@ const mocks = vi.hoisted(() => ({ |
| 8 | readPreparedConnection: vi.fn(() => ({ httpBaseUrl: "http://172.25.85.75:3773" })), | 8 | readPreparedConnection: vi.fn(() => ({ httpBaseUrl: "http://172.25.85.75:3773" })), |
| 9 | submittedUrl: null as ((url: string) => void) | null, | 9 | submittedUrl: null as ((url: string) => void) | null, |
| 10 | emptyStateUrl: null as ((url: string) => void) | null, | 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 | showEmptyState: false, | 21 | showEmptyState: false, |
| 12 | })); | 22 | })); |
| 13 | 23 | ||
| ... | @@ -36,10 +46,12 @@ vi.mock("~/previewStateStore", () => ({ | ... | @@ -36,10 +46,12 @@ vi.mock("~/previewStateStore", () => ({ |
| 36 | activeTabId: "tab-1", | 46 | activeTabId: "tab-1", |
| 37 | desktopByTabId: { | 47 | desktopByTabId: { |
| 38 | "tab-1": { | 48 | "tab-1": { |
| 49 | hasWebContents: true, | ||
| 39 | canGoBack: false, | 50 | canGoBack: false, |
| 40 | canGoForward: false, | 51 | canGoForward: false, |
| 41 | loading: false, | 52 | loading: false, |
| 42 | zoomFactor: 1, | 53 | zoomFactor: 1, |
| 54 | pictureInPicture: mocks.pictureInPicture, | ||
| 43 | colorScheme: "system", | 55 | colorScheme: "system", |
| 44 | controller: "none", | 56 | controller: "none", |
| 45 | }, | 57 | }, |
| ... | @@ -80,7 +92,7 @@ vi.mock("~/state/use-atom-command", () => ({ | ... | @@ -80,7 +92,7 @@ vi.mock("~/state/use-atom-command", () => ({ |
| 80 | vi.mock("~/browser/browserRecording", () => ({ | 92 | vi.mock("~/browser/browserRecording", () => ({ |
| 81 | startBrowserRecording: vi.fn(), | 93 | startBrowserRecording: vi.fn(), |
| 82 | stopBrowserRecording: vi.fn(), | 94 | stopBrowserRecording: vi.fn(), |
| 83 | useActiveBrowserRecordingTabId: () => null, | 95 | useActiveBrowserRecordingTabIds: () => new Set(), |
| 84 | })); | 96 | })); |
| 85 | 97 | ||
| 86 | vi.mock("~/browser/browserSurfaceStore", () => ({ | 98 | vi.mock("~/browser/browserSurfaceStore", () => ({ |
| ... | @@ -89,18 +101,69 @@ vi.mock("~/browser/browserSurfaceStore", () => ({ | ... | @@ -89,18 +101,69 @@ vi.mock("~/browser/browserSurfaceStore", () => ({ |
| 89 | ) => select({ byTabId: {} }), | 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 | vi.mock("~/components/ui/toast", () => ({ | 138 | vi.mock("~/components/ui/toast", () => ({ |
| 93 | stackedThreadToast: vi.fn(), | 139 | stackedThreadToast: vi.fn(), |
| 94 | toastManager: { add: vi.fn() }, | 140 | toastManager: { add: vi.fn() }, |
| 95 | })); | 141 | })); |
| 96 | 142 | ||
| 97 | vi.mock("./previewBridge", () => ({ | 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 | vi.mock("./PreviewChromeRow", () => ({ | 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 | mocks.submittedUrl = props.onSubmit; | 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 | return null; | 167 | return null; |
| 105 | }, | 168 | }, |
| 106 | })); | 169 | })); |
| ... | @@ -111,7 +174,12 @@ vi.mock("./PreviewEmptyState", () => ({ | ... | @@ -111,7 +174,12 @@ vi.mock("./PreviewEmptyState", () => ({ |
| 111 | return null; | 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 | vi.mock("./PreviewUnreachable", () => ({ PreviewUnreachable: () => null })); | 183 | vi.mock("./PreviewUnreachable", () => ({ PreviewUnreachable: () => null })); |
| 116 | vi.mock("./ZoomIndicator", () => ({ ZoomIndicator: () => null })); | 184 | vi.mock("./ZoomIndicator", () => ({ ZoomIndicator: () => null })); |
| 117 | vi.mock("./AgentBrowserCursor", () => ({ AgentBrowserCursor: () => null })); | 185 | vi.mock("./AgentBrowserCursor", () => ({ AgentBrowserCursor: () => null })); |
| ... | @@ -128,6 +196,16 @@ describe("PreviewView navigation", () => { | ... | @@ -128,6 +196,16 @@ describe("PreviewView navigation", () => { |
| 128 | mocks.readPreparedConnection.mockClear(); | 196 | mocks.readPreparedConnection.mockClear(); |
| 129 | mocks.submittedUrl = null; | 197 | mocks.submittedUrl = null; |
| 130 | mocks.emptyStateUrl = null; | 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 | mocks.showEmptyState = false; | 209 | mocks.showEmptyState = false; |
| 132 | }); | 210 | }); |
| 133 | 211 | ||
| ... | @@ -192,4 +270,47 @@ describe("PreviewView navigation", () => { | ... | @@ -192,4 +270,47 @@ describe("PreviewView navigation", () => { |
| 192 | "http://172.25.85.75:5173/app?mode=test#top", | 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,6 +22,8 @@ import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; |
| 22 | import { useEnvironment, useEnvironmentHttpBaseUrl } from "~/state/environments"; | 22 | import { useEnvironment, useEnvironmentHttpBaseUrl } from "~/state/environments"; |
| 23 | import { previewEnvironment } from "~/state/preview"; | 23 | import { previewEnvironment } from "~/state/preview"; |
| 24 | import { useAtomCommand } from "~/state/use-atom-command"; | 24 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 25 | import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; | ||
| 26 | import { useRightPanelStore } from "~/rightPanelStore"; | ||
| 25 | 27 | ||
| 26 | import { previewBridge } from "./previewBridge"; | 28 | import { previewBridge } from "./previewBridge"; |
| 27 | import { subscribePreviewAction } from "./previewActionBus"; | 29 | import { subscribePreviewAction } from "./previewActionBus"; |
| ... | @@ -47,7 +49,7 @@ import { AgentBrowserCursor } from "./AgentBrowserCursor"; | ... | @@ -47,7 +49,7 @@ import { AgentBrowserCursor } from "./AgentBrowserCursor"; |
| 47 | import { | 49 | import { |
| 48 | startBrowserRecording, | 50 | startBrowserRecording, |
| 49 | stopBrowserRecording, | 51 | stopBrowserRecording, |
| 50 | useActiveBrowserRecordingTabId, | 52 | useActiveBrowserRecordingTabIds, |
| 51 | } from "~/browser/browserRecording"; | 53 | } from "~/browser/browserRecording"; |
| 52 | import { stackedThreadToast, toastManager } from "~/components/ui/toast"; | 54 | import { stackedThreadToast, toastManager } from "~/components/ui/toast"; |
| 53 | 55 | ||
| ... | @@ -67,10 +69,13 @@ const localApi = typeof window === "undefined" ? null : ensureLocalApi(); | ... | @@ -67,10 +69,13 @@ const localApi = typeof window === "undefined" ? null : ensureLocalApi(); |
| 67 | export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, visible }: Props) { | 69 | export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, visible }: Props) { |
| 68 | const [focusUrlNonce, setFocusUrlNonce] = useState<number | undefined>(undefined); | 70 | const [focusUrlNonce, setFocusUrlNonce] = useState<number | undefined>(undefined); |
| 69 | const [pickActive, setPickActive] = useState(false); | 71 | const [pickActive, setPickActive] = useState(false); |
| 70 | const activeRecordingTabId = useActiveBrowserRecordingTabId(); | 72 | const activeRecordingTabIds = useActiveBrowserRecordingTabIds(); |
| 71 | const pickActiveRef = useRef(false); | 73 | const pickActiveRef = useRef(false); |
| 72 | const isMountedRef = useRef(true); | 74 | const isMountedRef = useRef(true); |
| 73 | const previewState = useThreadPreviewState(threadRef); | 75 | const previewState = useThreadPreviewState(threadRef); |
| 76 | const miniPlayer = usePreviewMiniPlayerStore((state) => | ||
| 77 | selectThreadPreviewMiniPlayer(state.byThreadKey, threadRef), | ||
| 78 | ); | ||
| 74 | const addPreviewAnnotation = useComposerDraftStore((store) => store.addPreviewAnnotation); | 79 | const addPreviewAnnotation = useComposerDraftStore((store) => store.addPreviewAnnotation); |
| 75 | const addImage = useComposerDraftStore((store) => store.addImage); | 80 | const addImage = useComposerDraftStore((store) => store.addImage); |
| 76 | const environment = useEnvironment(threadRef.environmentId); | 81 | const environment = useEnvironment(threadRef.environmentId); |
| ... | @@ -227,11 +232,35 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, | ... | @@ -227,11 +232,35 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, |
| 227 | void localApi.shell.openExternal(url).catch(() => undefined); | 232 | void localApi.shell.openExternal(url).catch(() => undefined); |
| 228 | }, [url]); | 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 | const handleCapture = useCallback( | 259 | const handleCapture = useCallback( |
| 231 | (record: boolean) => { | 260 | (record: boolean) => { |
| 232 | if (!previewBridge || !tabId) return; | 261 | if (!previewBridge || !tabId) return; |
| 233 | const bridge = previewBridge; | 262 | const bridge = previewBridge; |
| 234 | const recordingThisTab = activeRecordingTabId === tabId; | 263 | const recordingThisTab = activeRecordingTabIds.has(tabId); |
| 235 | if (recordingThisTab) { | 264 | if (recordingThisTab) { |
| 236 | void stopBrowserRecording(tabId).then( | 265 | void stopBrowserRecording(tabId).then( |
| 237 | (artifact) => { | 266 | (artifact) => { |
| ... | @@ -325,15 +354,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, | ... | @@ -325,15 +354,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, |
| 325 | return; | 354 | return; |
| 326 | } | 355 | } |
| 327 | if (record) { | 356 | if (record) { |
| 328 | if (activeRecordingTabId !== null) { | 357 | void startBrowserRecording(tabId, threadRef).catch((error) => { |
| 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) => { | ||
| 337 | toastManager.add({ | 358 | toastManager.add({ |
| 338 | type: "error", | 359 | type: "error", |
| 339 | title: "Unable to start recording", | 360 | title: "Unable to start recording", |
| ... | @@ -472,7 +493,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, | ... | @@ -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 | const handlePickElement = useCallback(() => { | 499 | const handlePickElement = useCallback(() => { |
| ... | @@ -594,7 +615,10 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, | ... | @@ -594,7 +615,10 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, |
| 594 | onOpenInBrowser={tabId ? handleOpenInBrowser : undefined} | 615 | onOpenInBrowser={tabId ? handleOpenInBrowser : undefined} |
| 595 | onCapture={previewBridge && tabId ? handleCapture : undefined} | 616 | onCapture={previewBridge && tabId ? handleCapture : undefined} |
| 596 | captureDisabled={!desktopOverlay || isUnreachable} | 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 | onPickElement={previewBridge && tabId ? handlePickElement : undefined} | 622 | onPickElement={previewBridge && tabId ? handlePickElement : undefined} |
| 599 | pickActive={pickActive} | 623 | pickActive={pickActive} |
| 600 | // Disable when there's no tab (nothing to pick on) OR the page | 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,11 +632,13 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, |
| 608 | previewBridge ? ( | 632 | previewBridge ? ( |
| 609 | <PreviewMoreMenu | 633 | <PreviewMoreMenu |
| 610 | tabId={tabId} | 634 | tabId={tabId} |
| 611 | hasWebContents={desktopOverlay !== null} | 635 | hasWebContents={desktopOverlay?.hasWebContents ?? false} |
| 612 | zoomFactor={desktopOverlay?.zoomFactor ?? 1} | 636 | zoomFactor={desktopOverlay?.zoomFactor ?? 1} |
| 613 | colorScheme={desktopOverlay?.colorScheme ?? "system"} | 637 | colorScheme={desktopOverlay?.colorScheme ?? "system"} |
| 614 | deviceToolbarVisible={viewport._tag !== "fill"} | 638 | deviceToolbarVisible={viewport._tag !== "fill"} |
| 615 | onToggleDeviceToolbar={handleToggleDeviceToolbar} | 639 | onToggleDeviceToolbar={handleToggleDeviceToolbar} |
| 640 | nativePictureInPicture={desktopOverlay?.pictureInPicture ?? false} | ||
| 641 | onNativePictureInPicture={handleNativePictureInPicture} | ||
| 616 | /> | 642 | /> |
| 617 | ) : null | 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,7 +1,10 @@ |
| 1 | import type { PreviewAutomationOpenInput, PreviewSessionSnapshot } from "@t3tools/contracts"; | 1 | import type { PreviewAutomationOpenInput, PreviewSessionSnapshot } from "@t3tools/contracts"; |
| 2 | import { describe, expect, it } from "vite-plus/test"; | 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 | const snapshot = (navStatus: PreviewSessionSnapshot["navStatus"]): PreviewSessionSnapshot => ({ | 9 | const snapshot = (navStatus: PreviewSessionSnapshot["navStatus"]): PreviewSessionSnapshot => ({ |
| 7 | threadId: "thread-1", | 10 | threadId: "thread-1", |
| ... | @@ -13,6 +16,18 @@ const snapshot = (navStatus: PreviewSessionSnapshot["navStatus"]): PreviewSessio | ... | @@ -13,6 +16,18 @@ const snapshot = (navStatus: PreviewSessionSnapshot["navStatus"]): PreviewSessio |
| 13 | }); | 16 | }); |
| 14 | 17 | ||
| 15 | describe("preview automation open readiness", () => { | 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 | it("does not wait for a desktop overlay when opening an empty tab", () => { | 31 | it("does not wait for a desktop overlay when opening an empty tab", () => { |
| 17 | expect( | 32 | expect( |
| 18 | previewAutomationOpenNeedsOverlay( | 33 | previewAutomationOpenNeedsOverlay( |
apps/web/src/components/preview/previewAutomationOpenReadiness.ts+4| ... | @@ -1,5 +1,9 @@ | ... | @@ -1,5 +1,9 @@ |
| 1 | import type { PreviewAutomationOpenInput, PreviewSessionSnapshot } from "@t3tools/contracts"; | 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 | export function previewAutomationOpenNeedsOverlay( | 7 | export function previewAutomationOpenNeedsOverlay( |
| 4 | input: PreviewAutomationOpenInput, | 8 | input: PreviewAutomationOpenInput, |
| 5 | snapshot: PreviewSessionSnapshot, | 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,10 +73,12 @@ function shouldClearBrowserPointer( |
| 73 | 73 | ||
| 74 | function projectDesktopState(state: DesktopPreviewTabState): DesktopPreviewOverlay { | 74 | function projectDesktopState(state: DesktopPreviewTabState): DesktopPreviewOverlay { |
| 75 | return { | 75 | return { |
| 76 | hasWebContents: state.webContentsId !== null, | ||
| 76 | canGoBack: state.canGoBack, | 77 | canGoBack: state.canGoBack, |
| 77 | canGoForward: state.canGoForward, | 78 | canGoForward: state.canGoForward, |
| 78 | loading: state.navStatus.kind === "Loading", | 79 | loading: state.navStatus.kind === "Loading", |
| 79 | zoomFactor: state.zoomFactor, | 80 | zoomFactor: state.zoomFactor, |
| 81 | pictureInPicture: state.pictureInPicture, | ||
| 80 | colorScheme: state.colorScheme, | 82 | colorScheme: state.colorScheme, |
| 81 | controller: state.controller, | 83 | controller: state.controller, |
| 82 | }; | 84 | }; |
apps/web/src/components/preview/usePreviewSession.ts+5-42| ... | @@ -2,14 +2,12 @@ | ... | @@ -2,14 +2,12 @@ |
| 2 | 2 | ||
| 3 | import { useAtomValue } from "@effect/atom-react"; | 3 | import { useAtomValue } from "@effect/atom-react"; |
| 4 | import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime/environment"; | 4 | import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime/environment"; |
| 5 | import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; | ||
| 6 | import type { ScopedThreadRef } from "@t3tools/contracts"; | 5 | import type { ScopedThreadRef } from "@t3tools/contracts"; |
| 7 | import * as Schema from "effect/Schema"; | 6 | import * as Schema from "effect/Schema"; |
| 8 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | 7 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 9 | 8 | ||
| 10 | import { | 9 | import { |
| 11 | applyPreviewServerEvent, | 10 | applyPreviewServerEvent, |
| 12 | applyPreviewServerSnapshot, | ||
| 13 | readThreadPreviewState, | 11 | readThreadPreviewState, |
| 14 | reconcilePreviewServerSessions, | 12 | reconcilePreviewServerSessions, |
| 15 | } from "~/previewStateStore"; | 13 | } from "~/previewStateStore"; |
| ... | @@ -41,61 +39,26 @@ const previewSessionSyncAtom = Atom.family((threadKey: string) => { | ... | @@ -41,61 +39,26 @@ const previewSessionSyncAtom = Atom.family((threadKey: string) => { |
| 41 | 39 | ||
| 42 | return Atom.make((get) => { | 40 | return Atom.make((get) => { |
| 43 | let disposed = false; | 41 | let disposed = false; |
| 44 | let recoveryId = 0; | ||
| 45 | let recoveringUrl: string | null = null; | ||
| 46 | let sessionsVersion = 0; | 42 | let sessionsVersion = 0; |
| 47 | let eventsVersion = 0; | 43 | let eventsVersion = 0; |
| 48 | 44 | ||
| 49 | const reconcileSessions = (result: Atom.Type<typeof sessionsAtom>) => { | 45 | const reconcileSessions = (result: Atom.Type<typeof sessionsAtom>) => { |
| 50 | if (!AsyncResult.isSuccess(result)) return; | 46 | if (!AsyncResult.isSuccess(result)) return; |
| 51 | if (result.value.sessions.length > 0) { | 47 | reconcilePreviewServerSessions(threadRef, result.value); |
| 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 | }); | ||
| 86 | }; | 48 | }; |
| 87 | 49 | ||
| 88 | const applyLatestEvent = (result: Atom.Type<typeof eventsAtom>) => { | 50 | const applyLatestEvent = (result: Atom.Type<typeof eventsAtom>) => { |
| 89 | if (!AsyncResult.isSuccess(result) || result.value.threadId !== threadRef.threadId) return; | 51 | if (!AsyncResult.isSuccess(result) || result.value.threadId !== threadRef.threadId) return; |
| 90 | applyPreviewServerEvent(threadRef, result.value); | 52 | const currentEpoch = readThreadPreviewState(threadRef).serverEpoch; |
| 91 | if (result.value.type === "opened" || result.value.type === "closed") { | 53 | if (currentEpoch !== null && currentEpoch !== result.value.serverEpoch) { |
| 92 | get.refresh(sessionsAtom); | 54 | get.refresh(sessionsAtom); |
| 55 | return; | ||
| 93 | } | 56 | } |
| 57 | applyPreviewServerEvent(threadRef, result.value); | ||
| 94 | }; | 58 | }; |
| 95 | 59 | ||
| 96 | get.addFinalizer(() => { | 60 | get.addFinalizer(() => { |
| 97 | disposed = true; | 61 | disposed = true; |
| 98 | recoveryId += 1; | ||
| 99 | }); | 62 | }); |
| 100 | const initialSessions = get.once(sessionsAtom); | 63 | const initialSessions = get.once(sessionsAtom); |
| 101 | const initialEvent = get.once(eventsAtom); | 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,11 +1,16 @@ |
| 1 | import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; | 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 | import { beforeEach, describe, expect, it } from "vite-plus/test"; | 8 | import { beforeEach, describe, expect, it } from "vite-plus/test"; |
| 4 | 9 | ||
| 5 | import { | 10 | import { |
| 6 | __testing, | 11 | __testing, |
| 7 | applyPreviewDesktopState, | 12 | applyPreviewDesktopState, |
| 8 | applyPreviewServerEvent, | 13 | applyPreviewServerEvent as applyPreviewServerEventImpl, |
| 9 | applyPreviewServerSnapshot, | 14 | applyPreviewServerSnapshot, |
| 10 | beginPreviewSessionClose, | 15 | beginPreviewSessionClose, |
| 11 | cancelPreviewSessionClose, | 16 | cancelPreviewSessionClose, |
| ... | @@ -33,7 +38,25 @@ const makeSnapshot = (overrides: Partial<PreviewSessionSnapshot> = {}): PreviewS | ... | @@ -33,7 +38,25 @@ const makeSnapshot = (overrides: Partial<PreviewSessionSnapshot> = {}): PreviewS |
| 33 | ...overrides, | 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 | beforeEach(() => { | 58 | beforeEach(() => { |
| 59 | nextServerRevision = 0; | ||
| 37 | resetPreviewStateForTests(); | 60 | resetPreviewStateForTests(); |
| 38 | }); | 61 | }); |
| 39 | 62 | ||
| ... | @@ -291,10 +314,12 @@ describe("previewStateStore (single-tab)", () => { | ... | @@ -291,10 +314,12 @@ describe("previewStateStore (single-tab)", () => { |
| 291 | snapshot, | 314 | snapshot, |
| 292 | }); | 315 | }); |
| 293 | applyPreviewDesktopState(ref, snapshot.tabId, { | 316 | applyPreviewDesktopState(ref, snapshot.tabId, { |
| 317 | hasWebContents: true, | ||
| 294 | canGoBack: true, | 318 | canGoBack: true, |
| 295 | canGoForward: false, | 319 | canGoForward: false, |
| 296 | loading: false, | 320 | loading: false, |
| 297 | zoomFactor: 1, | 321 | zoomFactor: 1, |
| 322 | pictureInPicture: false, | ||
| 298 | colorScheme: "system", | 323 | colorScheme: "system", |
| 299 | controller: "none", | 324 | controller: "none", |
| 300 | }); | 325 | }); |
| ... | @@ -309,10 +334,12 @@ describe("previewStateStore (single-tab)", () => { | ... | @@ -309,10 +334,12 @@ describe("previewStateStore (single-tab)", () => { |
| 309 | applyPreviewServerSnapshot(ref, first); | 334 | applyPreviewServerSnapshot(ref, first); |
| 310 | applyPreviewServerSnapshot(ref, second); | 335 | applyPreviewServerSnapshot(ref, second); |
| 311 | applyPreviewDesktopState(ref, first.tabId, { | 336 | applyPreviewDesktopState(ref, first.tabId, { |
| 337 | hasWebContents: true, | ||
| 312 | canGoBack: true, | 338 | canGoBack: true, |
| 313 | canGoForward: false, | 339 | canGoForward: false, |
| 314 | loading: false, | 340 | loading: false, |
| 315 | zoomFactor: 1, | 341 | zoomFactor: 1, |
| 342 | pictureInPicture: false, | ||
| 316 | colorScheme: "system", | 343 | colorScheme: "system", |
| 317 | controller: "none", | 344 | controller: "none", |
| 318 | }); | 345 | }); |
| ... | @@ -355,15 +382,17 @@ describe("previewStateStore (single-tab)", () => { | ... | @@ -355,15 +382,17 @@ describe("previewStateStore (single-tab)", () => { |
| 355 | applyPreviewServerSnapshot(ref, stale); | 382 | applyPreviewServerSnapshot(ref, stale); |
| 356 | applyPreviewServerSnapshot(ref, active); | 383 | applyPreviewServerSnapshot(ref, active); |
| 357 | applyPreviewDesktopState(ref, stale.tabId, { | 384 | applyPreviewDesktopState(ref, stale.tabId, { |
| 385 | hasWebContents: true, | ||
| 358 | canGoBack: false, | 386 | canGoBack: false, |
| 359 | canGoForward: false, | 387 | canGoForward: false, |
| 360 | loading: false, | 388 | loading: false, |
| 361 | zoomFactor: 1, | 389 | zoomFactor: 1, |
| 390 | pictureInPicture: false, | ||
| 362 | colorScheme: "system", | 391 | colorScheme: "system", |
| 363 | controller: "none", | 392 | controller: "none", |
| 364 | }); | 393 | }); |
| 365 | 394 | ||
| 366 | reconcilePreviewServerSessions(ref, [active]); | 395 | reconcilePreviewServerSessions(ref, { sessions: [active], serverEpoch, revision: 1 }); |
| 367 | 396 | ||
| 368 | const state = readThreadPreviewState(ref); | 397 | const state = readThreadPreviewState(ref); |
| 369 | expect(Object.keys(state.sessions)).toEqual([active.tabId]); | 398 | expect(Object.keys(state.sessions)).toEqual([active.tabId]); |
| ... | @@ -375,7 +404,7 @@ describe("previewStateStore (single-tab)", () => { | ... | @@ -375,7 +404,7 @@ describe("previewStateStore (single-tab)", () => { |
| 375 | it("clears stale sessions when an authoritative list is empty", () => { | 404 | it("clears stale sessions when an authoritative list is empty", () => { |
| 376 | applyPreviewServerSnapshot(ref, makeSnapshot()); | 405 | applyPreviewServerSnapshot(ref, makeSnapshot()); |
| 377 | 406 | ||
| 378 | reconcilePreviewServerSessions(ref, []); | 407 | reconcilePreviewServerSessions(ref, { sessions: [], serverEpoch, revision: 1 }); |
| 379 | 408 | ||
| 380 | const state = readThreadPreviewState(ref); | 409 | const state = readThreadPreviewState(ref); |
| 381 | expect(state.sessions).toEqual({}); | 410 | expect(state.sessions).toEqual({}); |
| ... | @@ -383,6 +412,74 @@ describe("previewStateStore (single-tab)", () => { | ... | @@ -383,6 +412,74 @@ describe("previewStateStore (single-tab)", () => { |
| 383 | expect(state.snapshot).toBeNull(); | 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 | it("applyServerSnapshot null clears snapshot for a thread that had one", () => { | 483 | it("applyServerSnapshot null clears snapshot for a thread that had one", () => { |
| 387 | const snapshot = makeSnapshot(); | 484 | const snapshot = makeSnapshot(); |
| 388 | applyPreviewServerSnapshot(ref, snapshot); | 485 | applyPreviewServerSnapshot(ref, snapshot); |
apps/web/src/previewStateStore.ts+81-45| ... | @@ -10,6 +10,7 @@ import { scopedThreadKey } from "@t3tools/client-runtime/environment"; | ... | @@ -10,6 +10,7 @@ import { scopedThreadKey } from "@t3tools/client-runtime/environment"; |
| 10 | import { | 10 | import { |
| 11 | type DesktopPreviewColorScheme, | 11 | type DesktopPreviewColorScheme, |
| 12 | type PreviewEvent, | 12 | type PreviewEvent, |
| 13 | type PreviewListResult, | ||
| 13 | type PreviewSessionSnapshot, | 14 | type PreviewSessionSnapshot, |
| 14 | type ScopedThreadRef, | 15 | type ScopedThreadRef, |
| 15 | } from "@t3tools/contracts"; | 16 | } from "@t3tools/contracts"; |
| ... | @@ -19,10 +20,12 @@ import { PREVIEW_RECENT_URL_LIMIT } from "./components/preview/previewConstants" | ... | @@ -19,10 +20,12 @@ import { PREVIEW_RECENT_URL_LIMIT } from "./components/preview/previewConstants" |
| 19 | import { appAtomRegistry } from "./rpc/atomRegistry"; | 20 | import { appAtomRegistry } from "./rpc/atomRegistry"; |
| 20 | 21 | ||
| 21 | export interface DesktopPreviewOverlay { | 22 | export interface DesktopPreviewOverlay { |
| 23 | hasWebContents: boolean; | ||
| 22 | canGoBack: boolean; | 24 | canGoBack: boolean; |
| 23 | canGoForward: boolean; | 25 | canGoForward: boolean; |
| 24 | loading: boolean; | 26 | loading: boolean; |
| 25 | zoomFactor: number; | 27 | zoomFactor: number; |
| 28 | pictureInPicture: boolean; | ||
| 26 | colorScheme: DesktopPreviewColorScheme; | 29 | colorScheme: DesktopPreviewColorScheme; |
| 27 | controller: "human" | "agent" | "none"; | 30 | controller: "human" | "agent" | "none"; |
| 28 | } | 31 | } |
| ... | @@ -36,6 +39,10 @@ export interface ThreadPreviewState { | ... | @@ -36,6 +39,10 @@ export interface ThreadPreviewState { |
| 36 | desktopOverlay: DesktopPreviewOverlay | null; | 39 | desktopOverlay: DesktopPreviewOverlay | null; |
| 37 | desktopByTabId: Record<string, DesktopPreviewOverlay>; | 40 | desktopByTabId: Record<string, DesktopPreviewOverlay>; |
| 38 | recentlySeenUrls: string[]; | 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 | const EMPTY_THREAD_PREVIEW_STATE: ThreadPreviewState = Object.freeze({ | 48 | const EMPTY_THREAD_PREVIEW_STATE: ThreadPreviewState = Object.freeze({ |
| ... | @@ -46,6 +53,8 @@ const EMPTY_THREAD_PREVIEW_STATE: ThreadPreviewState = Object.freeze({ | ... | @@ -46,6 +53,8 @@ const EMPTY_THREAD_PREVIEW_STATE: ThreadPreviewState = Object.freeze({ |
| 46 | desktopOverlay: null, | 53 | desktopOverlay: null, |
| 47 | desktopByTabId: {}, | 54 | desktopByTabId: {}, |
| 48 | recentlySeenUrls: [] as string[], | 55 | recentlySeenUrls: [] as string[], |
| 56 | serverEpoch: null, | ||
| 57 | serverRevision: 0, | ||
| 49 | }); | 58 | }); |
| 50 | 59 | ||
| 51 | const emptyPreviewStateAtom = Atom.make<ThreadPreviewState>(EMPTY_THREAD_PREVIEW_STATE).pipe( | 60 | const emptyPreviewStateAtom = Atom.make<ThreadPreviewState>(EMPTY_THREAD_PREVIEW_STATE).pipe( |
| ... | @@ -175,52 +184,68 @@ export function subscribeThreadPreviewState( | ... | @@ -175,52 +184,68 @@ export function subscribeThreadPreviewState( |
| 175 | 184 | ||
| 176 | export function applyPreviewServerEvent(ref: ScopedThreadRef, event: PreviewEvent): void { | 185 | export function applyPreviewServerEvent(ref: ScopedThreadRef, event: PreviewEvent): void { |
| 177 | updateThreadPreviewState(ref, (current) => { | 186 | updateThreadPreviewState(ref, (current) => { |
| 178 | switch (event.type) { | 187 | if (current.serverEpoch !== null && event.serverEpoch !== current.serverEpoch) return current; |
| 179 | case "opened": | 188 | if (event.revision < current.serverRevision) return current; |
| 180 | case "navigated": | 189 | const next = (() => { |
| 181 | case "resized": { | 190 | switch (event.type) { |
| 182 | const snapshot = event.snapshot; | 191 | case "opened": |
| 183 | if (current.suppressedTabIds.has(snapshot.tabId)) return current; | 192 | case "navigated": |
| 184 | const recentlySeenUrls = | 193 | case "resized": { |
| 185 | snapshot.navStatus._tag === "Idle" | 194 | const snapshot = event.snapshot; |
| 186 | ? current.recentlySeenUrls | 195 | if (current.suppressedTabIds.has(snapshot.tabId)) return current; |
| 187 | : dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url); | 196 | const recentlySeenUrls = |
| 188 | const sessions = { ...current.sessions, [snapshot.tabId]: snapshot }; | 197 | snapshot.navStatus._tag === "Idle" |
| 189 | const activeTabId = event.type === "opened" ? snapshot.tabId : current.activeTabId; | 198 | ? current.recentlySeenUrls |
| 190 | const activeSnapshot = sessions[activeTabId ?? snapshot.tabId] ?? snapshot; | 199 | : dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url); |
| 191 | return { | 200 | const sessions = { ...current.sessions, [snapshot.tabId]: snapshot }; |
| 192 | ...current, | 201 | const activeTabId = event.type === "opened" ? snapshot.tabId : current.activeTabId; |
| 193 | sessions, | 202 | const activeSnapshot = sessions[activeTabId ?? snapshot.tabId] ?? snapshot; |
| 194 | activeTabId: activeTabId ?? snapshot.tabId, | 203 | return { |
| 195 | snapshot: activeSnapshot, | 204 | ...current, |
| 196 | desktopOverlay: current.desktopByTabId[activeSnapshot.tabId] ?? null, | 205 | sessions, |
| 197 | recentlySeenUrls, | 206 | activeTabId: activeTabId ?? snapshot.tabId, |
| 198 | }; | 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": { | 241 | })(); |
| 201 | const existing = current.sessions[event.tabId]; | 242 | return next.serverRevision === event.revision && next.serverEpoch === event.serverEpoch |
| 202 | if (!existing) return current; | 243 | ? next |
| 203 | const failedSnapshot = { | 244 | : { |
| 204 | ...existing, | 245 | ...next, |
| 205 | navStatus: { | 246 | serverEpoch: event.serverEpoch, |
| 206 | _tag: "LoadFailed" as const, | 247 | serverRevision: event.revision, |
| 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, | ||
| 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,9 +316,12 @@ export function updatePreviewServerSnapshot( |
| 291 | */ | 316 | */ |
| 292 | export function reconcilePreviewServerSessions( | 317 | export function reconcilePreviewServerSessions( |
| 293 | ref: ScopedThreadRef, | 318 | ref: ScopedThreadRef, |
| 294 | snapshots: ReadonlyArray<PreviewSessionSnapshot>, | 319 | result: PreviewListResult, |
| 295 | ): void { | 320 | ): void { |
| 296 | updateThreadPreviewState(ref, (current) => { | 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 | const sessions: Record<string, PreviewSessionSnapshot> = {}; | 325 | const sessions: Record<string, PreviewSessionSnapshot> = {}; |
| 298 | let recentlySeenUrls = current.recentlySeenUrls; | 326 | let recentlySeenUrls = current.recentlySeenUrls; |
| 299 | for (const snapshot of snapshots) { | 327 | for (const snapshot of snapshots) { |
| ... | @@ -313,14 +341,22 @@ export function reconcilePreviewServerSessions( | ... | @@ -313,14 +341,22 @@ export function reconcilePreviewServerSessions( |
| 313 | const desktopByTabId = Object.fromEntries( | 341 | const desktopByTabId = Object.fromEntries( |
| 314 | Object.entries(current.desktopByTabId).filter(([tabId]) => sessions[tabId] !== undefined), | 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 | return { | 349 | return { |
| 317 | ...current, | 350 | ...current, |
| 318 | sessions, | 351 | sessions, |
| 352 | suppressedTabIds, | ||
| 319 | activeTabId, | 353 | activeTabId, |
| 320 | snapshot, | 354 | snapshot, |
| 321 | desktopByTabId, | 355 | desktopByTabId, |
| 322 | desktopOverlay: activeTabId ? (desktopByTabId[activeTabId] ?? null) : null, | 356 | desktopOverlay: activeTabId ? (desktopByTabId[activeTabId] ?? null) : null, |
| 323 | recentlySeenUrls, | 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,6 +527,8 @@ export interface DesktopPreviewTabState { |
| 527 | canGoForward: boolean; | 527 | canGoForward: boolean; |
| 528 | /** Current zoom factor (1.0 = 100%). */ | 528 | /** Current zoom factor (1.0 = 100%). */ |
| 529 | zoomFactor: number; | 529 | zoomFactor: number; |
| 530 | /** Whether this tab is currently mirrored into a desktop picture-in-picture window. */ | ||
| 531 | pictureInPicture: boolean; | ||
| 530 | colorScheme: DesktopPreviewColorScheme; | 532 | colorScheme: DesktopPreviewColorScheme; |
| 531 | controller: "human" | "agent" | "none"; | 533 | controller: "human" | "agent" | "none"; |
| 532 | updatedAt: string; | 534 | updatedAt: string; |
| ... | @@ -564,6 +566,7 @@ export const DesktopPreviewTabStateSchema: Schema.Codec<DesktopPreviewTabState> | ... | @@ -564,6 +566,7 @@ export const DesktopPreviewTabStateSchema: Schema.Codec<DesktopPreviewTabState> |
| 564 | canGoBack: Schema.Boolean, | 566 | canGoBack: Schema.Boolean, |
| 565 | canGoForward: Schema.Boolean, | 567 | canGoForward: Schema.Boolean, |
| 566 | zoomFactor: Schema.Number, | 568 | zoomFactor: Schema.Number, |
| 569 | pictureInPicture: Schema.Boolean, | ||
| 567 | colorScheme: DesktopPreviewColorSchemeSchema, | 570 | colorScheme: DesktopPreviewColorSchemeSchema, |
| 568 | controller: Schema.Literals(["human", "agent", "none"]), | 571 | controller: Schema.Literals(["human", "agent", "none"]), |
| 569 | updatedAt: Schema.String, | 572 | updatedAt: Schema.String, |
| ... | @@ -1080,6 +1083,10 @@ export interface DesktopPreviewBridge { | ... | @@ -1080,6 +1083,10 @@ export interface DesktopPreviewBridge { |
| 1080 | captureScreenshot: (tabId: string) => Promise<DesktopPreviewScreenshotArtifact>; | 1083 | captureScreenshot: (tabId: string) => Promise<DesktopPreviewScreenshotArtifact>; |
| 1081 | revealArtifact: (path: string) => Promise<void>; | 1084 | revealArtifact: (path: string) => Promise<void>; |
| 1082 | copyArtifactToClipboard: (path: string) => Promise<void>; | 1085 | copyArtifactToClipboard: (path: string) => Promise<void>; |
| 1086 | pictureInPicture: { | ||
| 1087 | open: (tabId: string) => Promise<void>; | ||
| 1088 | close: (tabId: string) => Promise<void>; | ||
| 1089 | }; | ||
| 1083 | recording: { | 1090 | recording: { |
| 1084 | startScreencast: (tabId: string) => Promise<void>; | 1091 | startScreencast: (tabId: string) => Promise<void>; |
| 1085 | stopScreencast: (tabId: string) => Promise<void>; | 1092 | stopScreencast: (tabId: string) => Promise<void>; |
packages/contracts/src/preview.test.ts+18| ... | @@ -29,6 +29,16 @@ const decodeAutomationHost = Schema.decodeUnknownSync(PreviewAutomationHost); | ... | @@ -29,6 +29,16 @@ const decodeAutomationHost = Schema.decodeUnknownSync(PreviewAutomationHost); |
| 29 | const decodeAutomationError = Schema.decodeUnknownSync(PreviewAutomationError); | 29 | const decodeAutomationError = Schema.decodeUnknownSync(PreviewAutomationError); |
| 30 | const decodeAutomationStatus = Schema.decodeUnknownSync(PreviewAutomationStatus); | 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 | describe("PreviewNavStatus", () => { | 42 | describe("PreviewNavStatus", () => { |
| 33 | it("decodes Idle", () => { | 43 | it("decodes Idle", () => { |
| 34 | expect(decodeNavStatus({ _tag: "Idle" })).toEqual({ _tag: "Idle" }); | 44 | expect(decodeNavStatus({ _tag: "Idle" })).toEqual({ _tag: "Idle" }); |
| ... | @@ -216,6 +226,8 @@ describe("PreviewEvent", () => { | ... | @@ -216,6 +226,8 @@ describe("PreviewEvent", () => { |
| 216 | threadId: "t", | 226 | threadId: "t", |
| 217 | tabId: "preview-t", | 227 | tabId: "preview-t", |
| 218 | createdAt: "2026-01-01T00:00:00.000Z", | 228 | createdAt: "2026-01-01T00:00:00.000Z", |
| 229 | serverEpoch: "server-a", | ||
| 230 | revision: 1, | ||
| 219 | snapshot: { | 231 | snapshot: { |
| 220 | threadId: "t", | 232 | threadId: "t", |
| 221 | tabId: "preview-t", | 233 | tabId: "preview-t", |
| ... | @@ -234,6 +246,8 @@ describe("PreviewEvent", () => { | ... | @@ -234,6 +246,8 @@ describe("PreviewEvent", () => { |
| 234 | threadId: "t", | 246 | threadId: "t", |
| 235 | tabId: "preview-t", | 247 | tabId: "preview-t", |
| 236 | createdAt: "2026-01-01T00:00:00.000Z", | 248 | createdAt: "2026-01-01T00:00:00.000Z", |
| 249 | serverEpoch: "server-a", | ||
| 250 | revision: 1, | ||
| 237 | url: "https://example.com/", | 251 | url: "https://example.com/", |
| 238 | title: "", | 252 | title: "", |
| 239 | code: -105, | 253 | code: -105, |
| ... | @@ -251,6 +265,8 @@ describe("PreviewEvent", () => { | ... | @@ -251,6 +265,8 @@ describe("PreviewEvent", () => { |
| 251 | threadId: "t", | 265 | threadId: "t", |
| 252 | tabId: "preview-t", | 266 | tabId: "preview-t", |
| 253 | createdAt: "2026-01-01T00:00:00.000Z", | 267 | createdAt: "2026-01-01T00:00:00.000Z", |
| 268 | serverEpoch: "server-a", | ||
| 269 | revision: 1, | ||
| 254 | snapshot: { | 270 | snapshot: { |
| 255 | threadId: "t", | 271 | threadId: "t", |
| 256 | tabId: "preview-t", | 272 | tabId: "preview-t", |
| ... | @@ -270,6 +286,8 @@ describe("PreviewEvent", () => { | ... | @@ -270,6 +286,8 @@ describe("PreviewEvent", () => { |
| 270 | threadId: "t", | 286 | threadId: "t", |
| 271 | tabId: "preview-t", | 287 | tabId: "preview-t", |
| 272 | createdAt: "2026-01-01T00:00:00.000Z", | 288 | createdAt: "2026-01-01T00:00:00.000Z", |
| 289 | serverEpoch: "server-a", | ||
| 290 | revision: 1, | ||
| 273 | }); | 291 | }); |
| 274 | expect(event.type).toBe("closed"); | 292 | expect(event.type).toBe("closed"); |
| 275 | }); | 293 | }); |
packages/contracts/src/preview.ts+9-1| ... | @@ -9,7 +9,7 @@ | ... | @@ -9,7 +9,7 @@ |
| 9 | * @module Preview | 9 | * @module Preview |
| 10 | */ | 10 | */ |
| 11 | import { Schema } from "effect"; | 11 | import { Schema } from "effect"; |
| 12 | import { ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; | 12 | import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; |
| 13 | 13 | ||
| 14 | const Url = TrimmedNonEmptyString.check(Schema.isMaxLength(2048)); | 14 | const Url = TrimmedNonEmptyString.check(Schema.isMaxLength(2048)); |
| 15 | const Title = Schema.String.check(Schema.isMaxLength(512)); | 15 | const Title = Schema.String.check(Schema.isMaxLength(512)); |
| ... | @@ -192,6 +192,10 @@ export type PreviewListInput = typeof PreviewListInput.Type; | ... | @@ -192,6 +192,10 @@ export type PreviewListInput = typeof PreviewListInput.Type; |
| 192 | 192 | ||
| 193 | export const PreviewListResult = Schema.Struct({ | 193 | export const PreviewListResult = Schema.Struct({ |
| 194 | sessions: Schema.Array(PreviewSessionSnapshot), | 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 | export type PreviewListResult = typeof PreviewListResult.Type; | 200 | export type PreviewListResult = typeof PreviewListResult.Type; |
| 197 | 201 | ||
| ... | @@ -199,6 +203,10 @@ const PreviewEventBaseSchema = Schema.Struct({ | ... | @@ -199,6 +203,10 @@ const PreviewEventBaseSchema = Schema.Struct({ |
| 199 | threadId: TrimmedNonEmptyString, | 203 | threadId: TrimmedNonEmptyString, |
| 200 | tabId: PreviewTabId, | 204 | tabId: PreviewTabId, |
| 201 | createdAt: Schema.String, | 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 | const PreviewOpenedEvent = Schema.Struct({ | 212 | const PreviewOpenedEvent = Schema.Struct({ |
packages/contracts/src/previewAutomation.ts+8-1| ... | @@ -86,9 +86,16 @@ export const PreviewAutomationOpenInput = Schema.Struct({ | ... | @@ -86,9 +86,16 @@ export const PreviewAutomationOpenInput = Schema.Struct({ |
| 86 | description: | 86 | description: |
| 87 | "Optional initial page URL, for example https://t3.chat or localhost:5173. Omit to open a blank tab.", | 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 | show: Schema.optional( | 95 | show: Schema.optional( |
| 90 | Schema.Boolean.annotate({ | 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 | reuseExistingTab: Schema.optional( | 101 | reuseExistingTab: Schema.optional( |