| author | |
| committer | |
| log | ef7014d851f56bb037a9da963095ffd883c7fa08 |
| tree | d237fbf682bdb17fe762bc39340cb597543cc935 |
| parent | c17d02cff98f2e7b590d4c3a5775d1faa47c2e7b |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Guillermo Casanova <guillermo.casanova.b@gmail.com>11 files changed, 1068 insertions(+), 335 deletions(-)
apps/desktop/src/ipc/methods/preview.ts+5-10| ... | @@ -11,7 +11,6 @@ import { | ... | @@ -11,7 +11,6 @@ import { |
| 11 | DesktopPreviewConfigInputSchema, | 11 | DesktopPreviewConfigInputSchema, |
| 12 | DesktopPreviewNavigateInputSchema, | 12 | DesktopPreviewNavigateInputSchema, |
| 13 | DesktopPreviewRecordingArtifactSchema, | 13 | DesktopPreviewRecordingArtifactSchema, |
| 14 | DesktopPreviewRecordingSourceSchema, | ||
| 15 | DesktopPreviewRecordingSaveInputSchema, | 14 | DesktopPreviewRecordingSaveInputSchema, |
| 16 | DesktopPreviewRegisterWebviewInputSchema, | 15 | DesktopPreviewRegisterWebviewInputSchema, |
| 17 | DesktopPreviewScreenshotArtifactSchema, | 16 | DesktopPreviewScreenshotArtifactSchema, |
| ... | @@ -174,15 +173,11 @@ export const cancelPickElement = tabMethod( | ... | @@ -174,15 +173,11 @@ export const cancelPickElement = tabMethod( |
| 174 | "desktop.ipc.preview.cancelPickElement", | 173 | "desktop.ipc.preview.cancelPickElement", |
| 175 | (manager, tabId) => manager.cancelPickElement(tabId), | 174 | (manager, tabId) => manager.cancelPickElement(tabId), |
| 176 | ); | 175 | ); |
| 177 | export const startRecording = DesktopIpc.makeIpcMethod({ | 176 | export const startRecording = tabMethod( |
| 178 | channel: IpcChannels.PREVIEW_RECORDING_START_CHANNEL, | 177 | IpcChannels.PREVIEW_RECORDING_START_CHANNEL, |
| 179 | payload: DesktopPreviewTabInputSchema, | 178 | "desktop.ipc.preview.startRecording", |
| 180 | result: DesktopPreviewRecordingSourceSchema, | 179 | (manager, tabId) => manager.startRecording(tabId), |
| 181 | handler: Effect.fn("desktop.ipc.preview.startRecording")(function* ({ tabId }) { | 180 | ); |
| 182 | const manager = yield* PreviewManager.PreviewManager; | ||
| 183 | return yield* manager.startRecording(tabId); | ||
| 184 | }), | ||
| 185 | }); | ||
| 186 | export const stopRecording = tabMethod( | 181 | export const stopRecording = tabMethod( |
| 187 | IpcChannels.PREVIEW_RECORDING_STOP_CHANNEL, | 182 | IpcChannels.PREVIEW_RECORDING_STOP_CHANNEL, |
| 188 | "desktop.ipc.preview.stopRecording", | 183 | "desktop.ipc.preview.stopRecording", |
apps/desktop/src/preview/Manager.test.ts+375-136| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { it as effectIt } from "@effect/vitest"; | 1 | import { it as effectIt } from "@effect/vitest"; |
| 2 | import { DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER } from "@t3tools/contracts"; | ||
| 2 | import type { DesktopPreviewRecordingFrame } from "@t3tools/contracts"; | 3 | import type { DesktopPreviewRecordingFrame } from "@t3tools/contracts"; |
| 3 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | 4 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 4 | import * as Cause from "effect/Cause"; | 5 | import * as Cause from "effect/Cause"; |
| ... | @@ -123,7 +124,7 @@ const { | ... | @@ -123,7 +124,7 @@ const { |
| 123 | } = vi.hoisted(() => ({ | 124 | } = vi.hoisted(() => ({ |
| 124 | browserWindowConstructor: vi.fn(), | 125 | browserWindowConstructor: vi.fn(), |
| 125 | createFromPath: vi.fn((): { readonly isEmpty: () => boolean } => ({ isEmpty: () => false })), | 126 | createFromPath: vi.fn((): { readonly isEmpty: () => boolean } => ({ isEmpty: () => false })), |
| 126 | fromId: vi.fn((_id?: number) => null), | 127 | fromId: vi.fn<(_id?: number) => Electron.WebContents | null>((_id?: number) => null), |
| 127 | getFocusedWebContents: vi.fn(() => null), | 128 | getFocusedWebContents: vi.fn(() => null), |
| 128 | mkdir: vi.fn((_path: string) => undefined), | 129 | mkdir: vi.fn((_path: string) => undefined), |
| 129 | showItemInFolder: vi.fn(), | 130 | showItemInFolder: vi.fn(), |
| ... | @@ -209,14 +210,52 @@ interface TestCapturedPreviewImage { | ... | @@ -209,14 +210,52 @@ interface TestCapturedPreviewImage { |
| 209 | readonly getSize: () => { readonly width: number; readonly height: number }; | 210 | readonly getSize: () => { readonly width: number; readonly height: number }; |
| 210 | } | 211 | } |
| 211 | 212 | ||
| 213 | type TestDisplayMediaHandler = ( | ||
| 214 | request: { readonly frame: { readonly frameTreeNodeId: number } | null }, | ||
| 215 | callback: (streams: { video?: unknown }) => void, | ||
| 216 | ) => void; | ||
| 217 | |||
| 218 | interface TestHostWebContents { | ||
| 219 | readonly id: number; | ||
| 220 | readonly mainFrame: { readonly frameTreeNodeId: number }; | ||
| 221 | readonly executeJavaScript: ReturnType<typeof vi.fn>; | ||
| 222 | readonly isDestroyed: () => boolean; | ||
| 223 | readonly session: { | ||
| 224 | readonly setDisplayMediaRequestHandler: ReturnType<typeof vi.fn>; | ||
| 225 | }; | ||
| 226 | readonly displayMediaHandler: () => TestDisplayMediaHandler | undefined; | ||
| 227 | } | ||
| 228 | |||
| 229 | type TestPreviewWebContents = Electron.WebContents & { | ||
| 230 | readonly setBackgroundThrottling: ReturnType<typeof vi.fn<(enabled: boolean) => void>>; | ||
| 231 | }; | ||
| 232 | |||
| 233 | const makeTestHostWebContents = (): TestHostWebContents => { | ||
| 234 | let handler: TestDisplayMediaHandler | undefined; | ||
| 235 | return { | ||
| 236 | id: 7, | ||
| 237 | mainFrame: { frameTreeNodeId: 7 }, | ||
| 238 | executeJavaScript: vi.fn(async () => true), | ||
| 239 | isDestroyed: () => false, | ||
| 240 | session: { | ||
| 241 | setDisplayMediaRequestHandler: vi.fn((next: TestDisplayMediaHandler) => { | ||
| 242 | handler = next; | ||
| 243 | }), | ||
| 244 | }, | ||
| 245 | displayMediaHandler: () => handler, | ||
| 246 | }; | ||
| 247 | }; | ||
| 248 | |||
| 212 | const makeTestPreviewWebContents = ( | 249 | const makeTestPreviewWebContents = ( |
| 213 | capturePage: () => Promise<TestCapturedPreviewImage>, | 250 | capturePage: () => Promise<TestCapturedPreviewImage>, |
| 214 | id = 42, | 251 | id = 42, |
| 215 | ) => | 252 | hostWebContents: TestHostWebContents = makeTestHostWebContents(), |
| 216 | ({ | 253 | ) => { |
| 254 | const setBackgroundThrottling = vi.fn<(enabled: boolean) => void>(); | ||
| 255 | return { | ||
| 217 | id, | 256 | id, |
| 218 | hostWebContents: { id: 7 }, | 257 | mainFrame: { routingId: id }, |
| 219 | getMediaSourceId: vi.fn(() => `tab:${id}`), | 258 | hostWebContents, |
| 220 | executeJavaScript: vi.fn(async () => ({ width: 1280, height: 720 })), | 259 | executeJavaScript: vi.fn(async () => ({ width: 1280, height: 720 })), |
| 221 | isDestroyed: () => false, | 260 | isDestroyed: () => false, |
| 222 | getType: () => "webview", | 261 | getType: () => "webview", |
| ... | @@ -226,6 +265,7 @@ const makeTestPreviewWebContents = ( | ... | @@ -226,6 +265,7 @@ const makeTestPreviewWebContents = ( |
| 226 | getZoomFactor: () => 1, | 265 | getZoomFactor: () => 1, |
| 227 | setZoomFactor: vi.fn(), | 266 | setZoomFactor: vi.fn(), |
| 228 | setAudioMuted: vi.fn(), | 267 | setAudioMuted: vi.fn(), |
| 268 | setBackgroundThrottling, | ||
| 229 | isCurrentlyAudible: () => false, | 269 | isCurrentlyAudible: () => false, |
| 230 | on: vi.fn(), | 270 | on: vi.fn(), |
| 231 | off: vi.fn(), | 271 | off: vi.fn(), |
| ... | @@ -241,7 +281,45 @@ const makeTestPreviewWebContents = ( | ... | @@ -241,7 +281,45 @@ const makeTestPreviewWebContents = ( |
| 241 | off: vi.fn(), | 281 | off: vi.fn(), |
| 242 | }, | 282 | }, |
| 243 | capturePage, | 283 | capturePage, |
| 244 | }) as never; | 284 | } as unknown as TestPreviewWebContents; |
| 285 | }; | ||
| 286 | |||
| 287 | /** Two ready tabs (41, 42) sharing one window, so they contend for the single display-media slot. */ | ||
| 288 | const setupRecordingRaceTabs = (manager: PreviewManager.PreviewManager["Service"]) => | ||
| 289 | Effect.gen(function* () { | ||
| 290 | const capturePage = vi.fn(async () => ({ | ||
| 291 | toJPEG: () => Buffer.from("unused-recording-frame"), | ||
| 292 | getSize: () => ({ width: 1280, height: 720 }), | ||
| 293 | })); | ||
| 294 | const host = makeTestHostWebContents(); | ||
| 295 | const destroyedIds = new Set<number>(); | ||
| 296 | const makeWebContents = (id: number) => | ||
| 297 | Object.assign(makeTestPreviewWebContents(capturePage, id, host), { | ||
| 298 | executeJavaScript: vi.fn(async () => ({ width: 1280, height: 720 })), | ||
| 299 | isDestroyed: () => destroyedIds.has(id), | ||
| 300 | }); | ||
| 301 | const webContentsById = new Map([ | ||
| 302 | [41, makeWebContents(41)], | ||
| 303 | [42, makeWebContents(42)], | ||
| 304 | ]); | ||
| 305 | fromId.mockImplementation((id) => | ||
| 306 | id === undefined ? null : (webContentsById.get(id) ?? null), | ||
| 307 | ); | ||
| 308 | yield* manager.createTab("tab_race_a"); | ||
| 309 | yield* manager.createTab("tab_race_b"); | ||
| 310 | yield* manager.registerWebview("tab_race_a", 41); | ||
| 311 | yield* manager.registerWebview("tab_race_b", 42); | ||
| 312 | const grants: Array<{ video?: unknown }> = []; | ||
| 313 | return { | ||
| 314 | host, | ||
| 315 | grants, | ||
| 316 | destroy: (id: number) => destroyedIds.add(id), | ||
| 317 | takeGrant: (frame = host.mainFrame) => | ||
| 318 | host.displayMediaHandler()?.({ frame }, (value) => { | ||
| 319 | grants.push(value); | ||
| 320 | }), | ||
| 321 | }; | ||
| 322 | }); | ||
| 245 | 323 | ||
| 246 | const TEST_FAVICON = "data:image/png;base64,cG5n"; | 324 | const TEST_FAVICON = "data:image/png;base64,cG5n"; |
| 247 | 325 | ||
| ... | @@ -1865,7 +1943,7 @@ describe("PreviewManager", () => { | ... | @@ -1865,7 +1943,7 @@ describe("PreviewManager", () => { |
| 1865 | ), | 1943 | ), |
| 1866 | ); | 1944 | ); |
| 1867 | 1945 | ||
| 1868 | effectIt.effect("keeps window unthrottled until the final frame capture stops", () => | 1946 | effectIt.effect("keeps every recorded guest unthrottled until its frame capture stops", () => |
| 1869 | withManager((manager) => | 1947 | withManager((manager) => |
| 1870 | Effect.gen(function* () { | 1948 | Effect.gen(function* () { |
| 1871 | const setBackgroundThrottling = vi.fn(); | 1949 | const setBackgroundThrottling = vi.fn(); |
| ... | @@ -1873,9 +1951,12 @@ describe("PreviewManager", () => { | ... | @@ -1873,9 +1951,12 @@ describe("PreviewManager", () => { |
| 1873 | toJPEG: () => Buffer.from("recording-frame"), | 1951 | toJPEG: () => Buffer.from("recording-frame"), |
| 1874 | getSize: () => ({ width: 1280, height: 720 }), | 1952 | getSize: () => ({ width: 1280, height: 720 }), |
| 1875 | })); | 1953 | })); |
| 1954 | const host = makeTestHostWebContents(); | ||
| 1955 | const firstWebContents = makeTestPreviewWebContents(capturePage, 41, host); | ||
| 1956 | const secondWebContents = makeTestPreviewWebContents(capturePage, 42, host); | ||
| 1876 | const webContentsById = new Map([ | 1957 | const webContentsById = new Map([ |
| 1877 | [41, makeTestPreviewWebContents(capturePage, 41)], | 1958 | [41, firstWebContents], |
| 1878 | [42, makeTestPreviewWebContents(capturePage, 42)], | 1959 | [42, secondWebContents], |
| 1879 | ]); | 1960 | ]); |
| 1880 | fromId.mockImplementation((id) => | 1961 | fromId.mockImplementation((id) => |
| 1881 | id === undefined ? null : (webContentsById.get(id) ?? null), | 1962 | id === undefined ? null : (webContentsById.get(id) ?? null), |
| ... | @@ -1892,14 +1973,21 @@ describe("PreviewManager", () => { | ... | @@ -1892,14 +1973,21 @@ describe("PreviewManager", () => { |
| 1892 | } as never); | 1973 | } as never); |
| 1893 | 1974 | ||
| 1894 | yield* manager.startRecording("tab_capture_throttling_1"); | 1975 | yield* manager.startRecording("tab_capture_throttling_1"); |
| 1976 | // The first renderer takes its grant, freeing the arm slot for the second tab. | ||
| 1977 | host.displayMediaHandler()?.({ frame: host.mainFrame }, () => {}); | ||
| 1895 | yield* manager.startRecording("tab_capture_throttling_2"); | 1978 | yield* manager.startRecording("tab_capture_throttling_2"); |
| 1896 | expect(setBackgroundThrottling.mock.calls).toEqual([[false]]); | 1979 | expect(setBackgroundThrottling.mock.calls).toEqual([[false]]); |
| 1980 | expect(firstWebContents.setBackgroundThrottling.mock.calls).toEqual([[false]]); | ||
| 1981 | expect(secondWebContents.setBackgroundThrottling.mock.calls).toEqual([[false]]); | ||
| 1897 | 1982 | ||
| 1898 | yield* manager.stopRecording("tab_capture_throttling_1"); | 1983 | yield* manager.stopRecording("tab_capture_throttling_1"); |
| 1899 | expect(setBackgroundThrottling.mock.calls).toEqual([[false]]); | 1984 | expect(setBackgroundThrottling.mock.calls).toEqual([[false]]); |
| 1985 | expect(firstWebContents.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); | ||
| 1986 | expect(secondWebContents.setBackgroundThrottling.mock.calls).toEqual([[false]]); | ||
| 1900 | 1987 | ||
| 1901 | yield* manager.stopRecording("tab_capture_throttling_2"); | 1988 | yield* manager.stopRecording("tab_capture_throttling_2"); |
| 1902 | expect(setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); | 1989 | expect(setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); |
| 1990 | expect(secondWebContents.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); | ||
| 1903 | }), | 1991 | }), |
| 1904 | ), | 1992 | ), |
| 1905 | ); | 1993 | ); |
| ... | @@ -1953,6 +2041,48 @@ describe("PreviewManager", () => { | ... | @@ -1953,6 +2041,48 @@ describe("PreviewManager", () => { |
| 1953 | ), | 2041 | ), |
| 1954 | ); | 2042 | ); |
| 1955 | 2043 | ||
| 2044 | effectIt.effect("rolls back window throttling when a recorded guest cannot be unthrottled", () => | ||
| 2045 | withManager((manager) => | ||
| 2046 | Effect.gen(function* () { | ||
| 2047 | const setWindowBackgroundThrottling = vi.fn(); | ||
| 2048 | const capturePage = vi.fn(async () => ({ | ||
| 2049 | toJPEG: () => Buffer.from("recording-frame"), | ||
| 2050 | getSize: () => ({ width: 1280, height: 720 }), | ||
| 2051 | })); | ||
| 2052 | const wc = makeTestPreviewWebContents(capturePage); | ||
| 2053 | fromId.mockReturnValue(wc); | ||
| 2054 | |||
| 2055 | yield* manager.createTab("tab_guest_throttling_failure"); | ||
| 2056 | yield* manager.registerWebview("tab_guest_throttling_failure", 42); | ||
| 2057 | yield* manager.setMainWindow({ | ||
| 2058 | isDestroyed: () => false, | ||
| 2059 | once: vi.fn(), | ||
| 2060 | webContents: { setBackgroundThrottling: setWindowBackgroundThrottling }, | ||
| 2061 | } as never); | ||
| 2062 | |||
| 2063 | wc.setBackgroundThrottling.mockImplementationOnce(() => { | ||
| 2064 | throw new Error("guest throttling update failed"); | ||
| 2065 | }); | ||
| 2066 | const failedStart = yield* Effect.exit( | ||
| 2067 | manager.startRecording("tab_guest_throttling_failure"), | ||
| 2068 | ); | ||
| 2069 | expect(Exit.isFailure(failedStart)).toBe(true); | ||
| 2070 | expect(setWindowBackgroundThrottling.mock.calls).toEqual([[false], [true]]); | ||
| 2071 | expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false]]); | ||
| 2072 | |||
| 2073 | yield* manager.startRecording("tab_guest_throttling_failure"); | ||
| 2074 | yield* manager.stopRecording("tab_guest_throttling_failure"); | ||
| 2075 | expect(setWindowBackgroundThrottling.mock.calls).toEqual([ | ||
| 2076 | [false], | ||
| 2077 | [true], | ||
| 2078 | [false], | ||
| 2079 | [true], | ||
| 2080 | ]); | ||
| 2081 | expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false], [false], [true]]); | ||
| 2082 | }), | ||
| 2083 | ), | ||
| 2084 | ); | ||
| 2085 | |||
| 1956 | effectIt.effect("does not publish a replacement window when capture reconciliation fails", () => | 2086 | effectIt.effect("does not publish a replacement window when capture reconciliation fails", () => |
| 1957 | withManager((manager) => | 2087 | withManager((manager) => |
| 1958 | Effect.gen(function* () { | 2088 | Effect.gen(function* () { |
| ... | @@ -2031,9 +2161,10 @@ describe("PreviewManager", () => { | ... | @@ -2031,9 +2161,10 @@ describe("PreviewManager", () => { |
| 2031 | toJPEG: () => Buffer.from("recording-frame"), | 2161 | toJPEG: () => Buffer.from("recording-frame"), |
| 2032 | getSize: () => ({ width: 1280, height: 720 }), | 2162 | getSize: () => ({ width: 1280, height: 720 }), |
| 2033 | })); | 2163 | })); |
| 2164 | const host = makeTestHostWebContents(); | ||
| 2034 | const webContentsById = new Map([ | 2165 | const webContentsById = new Map([ |
| 2035 | [42, makeTestPreviewWebContents(capturePage, 42)], | 2166 | [42, makeTestPreviewWebContents(capturePage, 42, host)], |
| 2036 | [43, makeTestPreviewWebContents(capturePage, 43)], | 2167 | [43, makeTestPreviewWebContents(capturePage, 43, host)], |
| 2037 | ]); | 2168 | ]); |
| 2038 | fromId.mockImplementation((id) => | 2169 | fromId.mockImplementation((id) => |
| 2039 | id === undefined ? null : (webContentsById.get(id) ?? null), | 2170 | id === undefined ? null : (webContentsById.get(id) ?? null), |
| ... | @@ -2065,6 +2196,10 @@ describe("PreviewManager", () => { | ... | @@ -2065,6 +2196,10 @@ describe("PreviewManager", () => { |
| 2065 | yield* Effect.yieldNow; | 2196 | yield* Effect.yieldNow; |
| 2066 | yield* Effect.yieldNow; | 2197 | yield* Effect.yieldNow; |
| 2067 | 2198 | ||
| 2199 | const grants: Array<{ video?: unknown }> = []; | ||
| 2200 | host.displayMediaHandler()?.({ frame: host.mainFrame }, (value) => grants.push(value)); | ||
| 2201 | expect(grants).toEqual([{}]); | ||
| 2202 | |||
| 2068 | yield* manager.setMainWindow({ | 2203 | yield* manager.setMainWindow({ |
| 2069 | isDestroyed: () => false, | 2204 | isDestroyed: () => false, |
| 2070 | once: vi.fn(), | 2205 | once: vi.fn(), |
| ... | @@ -2075,7 +2210,60 @@ describe("PreviewManager", () => { | ... | @@ -2075,7 +2210,60 @@ describe("PreviewManager", () => { |
| 2075 | ), | 2210 | ), |
| 2076 | ); | 2211 | ); |
| 2077 | 2212 | ||
| 2078 | effectIt.effect("returns native media sources for concurrent preview recordings", () => | 2213 | effectIt.effect("does not arm recording after the main window closes during warmup", () => |
| 2214 | withManager((manager) => | ||
| 2215 | Effect.gen(function* () { | ||
| 2216 | let closeMainWindow: (() => void) | undefined; | ||
| 2217 | let finishWarmup!: (image: TestCapturedPreviewImage) => void; | ||
| 2218 | let markWarmupStarted!: () => void; | ||
| 2219 | const warmupStarted = new Promise<void>((resolve) => { | ||
| 2220 | markWarmupStarted = resolve; | ||
| 2221 | }); | ||
| 2222 | const capturedImage = { | ||
| 2223 | toJPEG: () => Buffer.from("recording-frame"), | ||
| 2224 | getSize: () => ({ width: 1280, height: 720 }), | ||
| 2225 | }; | ||
| 2226 | const capturePage = vi.fn( | ||
| 2227 | () => | ||
| 2228 | new Promise<TestCapturedPreviewImage>((resolve) => { | ||
| 2229 | markWarmupStarted(); | ||
| 2230 | finishWarmup = resolve; | ||
| 2231 | }), | ||
| 2232 | ); | ||
| 2233 | const host = makeTestHostWebContents(); | ||
| 2234 | fromId.mockReturnValue(makeTestPreviewWebContents(capturePage, 42, host)); | ||
| 2235 | |||
| 2236 | yield* manager.createTab("tab_window_close_warmup"); | ||
| 2237 | yield* manager.registerWebview("tab_window_close_warmup", 42); | ||
| 2238 | yield* manager.setMainWindow({ | ||
| 2239 | isDestroyed: () => false, | ||
| 2240 | once: vi.fn((event: string, listener: () => void) => { | ||
| 2241 | if (event === "closed") closeMainWindow = listener; | ||
| 2242 | }), | ||
| 2243 | webContents: { setBackgroundThrottling: vi.fn() }, | ||
| 2244 | } as never); | ||
| 2245 | |||
| 2246 | const start = yield* manager | ||
| 2247 | .startRecording("tab_window_close_warmup") | ||
| 2248 | .pipe(Effect.forkChild({ startImmediately: true })); | ||
| 2249 | yield* Effect.promise(() => warmupStarted); | ||
| 2250 | closeMainWindow?.(); | ||
| 2251 | finishWarmup(capturedImage); | ||
| 2252 | |||
| 2253 | const exit = yield* Fiber.await(start); | ||
| 2254 | expect(Exit.isFailure(exit)).toBe(true); | ||
| 2255 | if (Exit.isFailure(exit)) { | ||
| 2256 | expect(Option.getOrThrow(Cause.findErrorOption(exit.cause))).toMatchObject({ | ||
| 2257 | _tag: "PreviewMainWindowClosedError", | ||
| 2258 | tabId: "tab_window_close_warmup", | ||
| 2259 | }); | ||
| 2260 | } | ||
| 2261 | expect(host.session.setDisplayMediaRequestHandler).not.toHaveBeenCalled(); | ||
| 2262 | }), | ||
| 2263 | ), | ||
| 2264 | ); | ||
| 2265 | |||
| 2266 | effectIt.effect("grants each concurrent preview recording its own tab frame", () => | ||
| 2079 | withManager((manager) => | 2267 | withManager((manager) => |
| 2080 | Effect.gen(function* () { | 2268 | Effect.gen(function* () { |
| 2081 | const firstJpeg = Buffer.from("first-recording-frame"); | 2269 | const firstJpeg = Buffer.from("first-recording-frame"); |
| ... | @@ -2090,6 +2278,8 @@ describe("PreviewManager", () => { | ... | @@ -2090,6 +2278,8 @@ describe("PreviewManager", () => { |
| 2090 | })); | 2278 | })); |
| 2091 | const firstSendCommand = vi.fn(async () => undefined); | 2279 | const firstSendCommand = vi.fn(async () => undefined); |
| 2092 | const secondSendCommand = vi.fn(async () => undefined); | 2280 | const secondSendCommand = vi.fn(async () => undefined); |
| 2281 | // Both webviews live in the same window, so they share one display-media handler. | ||
| 2282 | const host = makeTestHostWebContents(); | ||
| 2093 | const makeWebContents = ( | 2283 | const makeWebContents = ( |
| 2094 | id: number, | 2284 | id: number, |
| 2095 | capturePage: typeof firstCapturePage, | 2285 | capturePage: typeof firstCapturePage, |
| ... | @@ -2097,8 +2287,8 @@ describe("PreviewManager", () => { | ... | @@ -2097,8 +2287,8 @@ describe("PreviewManager", () => { |
| 2097 | ) => | 2287 | ) => |
| 2098 | ({ | 2288 | ({ |
| 2099 | id, | 2289 | id, |
| 2100 | hostWebContents: { id: 7 }, | 2290 | mainFrame: { routingId: id }, |
| 2101 | getMediaSourceId: vi.fn(() => `tab:${id}`), | 2291 | hostWebContents: host, |
| 2102 | executeJavaScript: vi.fn(async () => | 2292 | executeJavaScript: vi.fn(async () => |
| 2103 | id === 41 ? { width: 800, height: 600 } : { width: 390, height: 844 }, | 2293 | id === 41 ? { width: 800, height: 600 } : { width: 390, height: 844 }, |
| 2104 | ), | 2294 | ), |
| ... | @@ -2110,6 +2300,7 @@ describe("PreviewManager", () => { | ... | @@ -2110,6 +2300,7 @@ describe("PreviewManager", () => { |
| 2110 | getZoomFactor: () => 1, | 2300 | getZoomFactor: () => 1, |
| 2111 | setZoomFactor: vi.fn(), | 2301 | setZoomFactor: vi.fn(), |
| 2112 | setAudioMuted: vi.fn(), | 2302 | setAudioMuted: vi.fn(), |
| 2303 | setBackgroundThrottling: vi.fn(), | ||
| 2113 | isCurrentlyAudible: () => false, | 2304 | isCurrentlyAudible: () => false, |
| 2114 | on: vi.fn(), | 2305 | on: vi.fn(), |
| 2115 | off: vi.fn(), | 2306 | off: vi.fn(), |
| ... | @@ -2137,15 +2328,19 @@ describe("PreviewManager", () => { | ... | @@ -2137,15 +2328,19 @@ describe("PreviewManager", () => { |
| 2137 | yield* manager.createTab("tab_2"); | 2328 | yield* manager.createTab("tab_2"); |
| 2138 | yield* manager.registerWebview("tab_1", 41); | 2329 | yield* manager.registerWebview("tab_1", 41); |
| 2139 | yield* manager.registerWebview("tab_2", 42); | 2330 | yield* manager.registerWebview("tab_2", 42); |
| 2140 | const sources = yield* Effect.all( | ||
| 2141 | [manager.startRecording("tab_1"), manager.startRecording("tab_2")], | ||
| 2142 | { concurrency: 2 }, | ||
| 2143 | ); | ||
| 2144 | 2331 | ||
| 2145 | expect(sources).toEqual([ | 2332 | const grants: Array<{ video?: unknown }> = []; |
| 2146 | { sourceId: "tab:41", width: 800, height: 600 }, | 2333 | const takeGrant = () => |
| 2147 | { sourceId: "tab:42", width: 390, height: 844 }, | 2334 | host.displayMediaHandler()?.({ frame: host.mainFrame }, (value) => { |
| 2148 | ]); | 2335 | grants.push(value); |
| 2336 | }); | ||
| 2337 | |||
| 2338 | yield* manager.startRecording("tab_1"); | ||
| 2339 | takeGrant(); | ||
| 2340 | yield* manager.startRecording("tab_2"); | ||
| 2341 | takeGrant(); | ||
| 2342 | expect(grants).toEqual([{ video: { routingId: 41 } }, { video: { routingId: 42 } }]); | ||
| 2343 | |||
| 2149 | expect(firstCapturePage).toHaveBeenCalledOnce(); | 2344 | expect(firstCapturePage).toHaveBeenCalledOnce(); |
| 2150 | expect(secondCapturePage).toHaveBeenCalledOnce(); | 2345 | expect(secondCapturePage).toHaveBeenCalledOnce(); |
| 2151 | expect(firstSendCommand).not.toHaveBeenCalledWith( | 2346 | expect(firstSendCommand).not.toHaveBeenCalledWith( |
| ... | @@ -2165,121 +2360,158 @@ describe("PreviewManager", () => { | ... | @@ -2165,121 +2360,158 @@ describe("PreviewManager", () => { |
| 2165 | ), | 2360 | ), |
| 2166 | ); | 2361 | ); |
| 2167 | 2362 | ||
| 2168 | effectIt.effect("continues native recording when the source warmup fails", () => | 2363 | effectIt.effect("requests display media with a fresh renderer gesture", () => |
| 2169 | withManager((manager) => | 2364 | withManager((manager) => |
| 2170 | Effect.gen(function* () { | 2365 | Effect.gen(function* () { |
| 2171 | const capturePage = vi.fn(async () => { | 2366 | const { host, takeGrant } = yield* setupRecordingRaceTabs(manager); |
| 2172 | throw new Error("source is not ready"); | ||
| 2173 | }); | ||
| 2174 | const getMediaSourceId = vi.fn(() => "tab:42"); | ||
| 2175 | const webContents = Object.assign(makeTestPreviewWebContents(capturePage), { | ||
| 2176 | executeJavaScript: vi.fn(async () => ({ width: 1280, height: 720 })), | ||
| 2177 | getMediaSourceId, | ||
| 2178 | }); | ||
| 2179 | fromId.mockReturnValue(webContents); | ||
| 2180 | 2367 | ||
| 2181 | yield* manager.createTab("tab_recording_warmup_failure"); | 2368 | yield* manager.startRecording("tab_race_a"); |
| 2182 | yield* manager.registerWebview("tab_recording_warmup_failure", 42); | 2369 | |
| 2370 | expect(host.executeJavaScript).toHaveBeenCalledWith( | ||
| 2371 | expect.stringContaining(DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER), | ||
| 2372 | true, | ||
| 2373 | ); | ||
| 2374 | expect(host.executeJavaScript).toHaveBeenCalledWith( | ||
| 2375 | expect.stringContaining("tab_race_a"), | ||
| 2376 | true, | ||
| 2377 | ); | ||
| 2378 | takeGrant(); | ||
| 2379 | yield* manager.stopRecording("tab_race_a"); | ||
| 2380 | }), | ||
| 2381 | ), | ||
| 2382 | ); | ||
| 2183 | 2383 | ||
| 2184 | expect(yield* manager.startRecording("tab_recording_warmup_failure")).toEqual({ | 2384 | // Runs on the real clock: an earlier queueing design only settled under TestClock and stalled the |
| 2185 | sourceId: "tab:42", | 2385 | // losing start forever in the desktop app. |
| 2186 | width: 1280, | 2386 | effectIt.live("settles both starts when two tabs race for the capture stream", () => |
| 2187 | height: 720, | 2387 | withManager((manager) => |
| 2388 | Effect.gen(function* () { | ||
| 2389 | const { host, grants, takeGrant } = yield* setupRecordingRaceTabs(manager); | ||
| 2390 | |||
| 2391 | const exits = yield* Effect.all( | ||
| 2392 | [ | ||
| 2393 | Effect.exit(manager.startRecording("tab_race_a")), | ||
| 2394 | Effect.exit(manager.startRecording("tab_race_b")), | ||
| 2395 | ], | ||
| 2396 | { concurrency: 2 }, | ||
| 2397 | ); | ||
| 2398 | |||
| 2399 | const [exitA, exitB] = exits; | ||
| 2400 | // Exactly one start owns the stream; the other fails fast instead of hanging. | ||
| 2401 | expect(exits.filter(Exit.isSuccess)).toHaveLength(1); | ||
| 2402 | const loserExit = Exit.isSuccess(exitA) ? exitB : exitA; | ||
| 2403 | if (Exit.isSuccess(loserExit)) return; | ||
| 2404 | expect(Option.getOrThrow(Cause.findErrorOption(loserExit.cause))).toMatchObject({ | ||
| 2405 | _tag: "PreviewRecordingArmConflictError", | ||
| 2188 | }); | 2406 | }); |
| 2189 | expect(capturePage).toHaveBeenCalledTimes(2); | ||
| 2190 | expect(getMediaSourceId).toHaveBeenCalledOnce(); | ||
| 2191 | 2407 | ||
| 2192 | yield* manager.stopRecording("tab_recording_warmup_failure"); | 2408 | // The single grant goes to the tab that actually won the slot, never the other one. |
| 2409 | takeGrant(); | ||
| 2410 | expect(grants).toEqual([{ video: { routingId: Exit.isSuccess(exitA) ? 41 : 42 } }]); | ||
| 2411 | expect(host.session.setDisplayMediaRequestHandler).toHaveBeenCalledOnce(); | ||
| 2193 | }), | 2412 | }), |
| 2194 | ), | 2413 | ), |
| 2195 | ); | 2414 | ); |
| 2196 | 2415 | ||
| 2197 | effectIt.effect("reports invalid native recording dimensions as a structured error", () => | 2416 | effectIt.effect("releases an armed slot that the renderer never redeemed", () => |
| 2198 | withManager((manager) => | 2417 | withManager((manager) => |
| 2199 | Effect.gen(function* () { | 2418 | Effect.gen(function* () { |
| 2200 | const capturePage = vi.fn(async () => ({ | 2419 | const { grants, takeGrant } = yield* setupRecordingRaceTabs(manager); |
| 2201 | toJPEG: () => Buffer.from("unused-recording-frame"), | 2420 | |
| 2202 | getSize: () => ({ width: 1280, height: 720 }), | 2421 | yield* manager.startRecording("tab_race_a"); |
| 2203 | })); | 2422 | const blocked = yield* Effect.exit(manager.startRecording("tab_race_b")); |
| 2204 | const getMediaSourceId = vi.fn(() => "tab:42"); | 2423 | if (Exit.isSuccess(blocked)) throw new Error("expected the second tab to be refused"); |
| 2205 | const webContents = Object.assign(makeTestPreviewWebContents(capturePage), { | 2424 | expect(Option.getOrThrow(Cause.findErrorOption(blocked.cause))).toMatchObject({ |
| 2206 | executeJavaScript: vi.fn(async () => ({ width: 0, height: 720 })), | 2425 | _tag: "PreviewRecordingArmConflictError", |
| 2207 | getMediaSourceId, | 2426 | tabId: "tab_race_b", |
| 2427 | armedTabId: "tab_race_a", | ||
| 2208 | }); | 2428 | }); |
| 2209 | fromId.mockReturnValue(webContents); | ||
| 2210 | 2429 | ||
| 2211 | yield* manager.createTab("tab_invalid_recording_size"); | 2430 | // Nothing ever captured the armed tab, so the slot goes stale and stops blocking starts. |
| 2212 | yield* manager.registerWebview("tab_invalid_recording_size", 42); | 2431 | yield* TestClock.adjust(10_000); |
| 2213 | const exit = yield* Effect.exit(manager.startRecording("tab_invalid_recording_size")); | 2432 | yield* manager.startRecording("tab_race_b"); |
| 2433 | takeGrant(); | ||
| 2434 | expect(grants).toEqual([{ video: { routingId: 42 } }]); | ||
| 2214 | 2435 | ||
| 2215 | expect(Exit.isFailure(exit)).toBe(true); | 2436 | yield* manager.stopRecording("tab_race_a"); |
| 2216 | if (Exit.isSuccess(exit)) return; | 2437 | yield* manager.stopRecording("tab_race_b"); |
| 2217 | expect(Option.getOrThrow(Cause.findErrorOption(exit.cause))).toMatchObject({ | ||
| 2218 | _tag: "PreviewRecordingSourceSizeUnavailableError", | ||
| 2219 | tabId: "tab_invalid_recording_size", | ||
| 2220 | webContentsId: 42, | ||
| 2221 | }); | ||
| 2222 | expect(getMediaSourceId).not.toHaveBeenCalled(); | ||
| 2223 | }), | 2438 | }), |
| 2224 | ), | 2439 | ), |
| 2225 | ); | 2440 | ); |
| 2226 | 2441 | ||
| 2227 | effectIt.effect("keeps a newer recording lease when an earlier start fails", () => | 2442 | effectIt.effect("denies a display-media request that arrives after the arm went stale", () => |
| 2228 | withManager((manager) => | 2443 | withManager((manager) => |
| 2229 | Effect.gen(function* () { | 2444 | Effect.gen(function* () { |
| 2230 | const setBackgroundThrottling = vi.fn(); | 2445 | const { grants, takeGrant } = yield* setupRecordingRaceTabs(manager); |
| 2231 | const capturePage = vi.fn(async () => ({ | 2446 | |
| 2232 | toJPEG: () => Buffer.from("unused-recording-frame"), | 2447 | yield* manager.startRecording("tab_race_a"); |
| 2233 | getSize: () => ({ width: 1280, height: 720 }), | 2448 | yield* TestClock.adjust(10_000); |
| 2234 | })); | 2449 | // The handler cannot read a clock, so the expiry fiber must have dropped the frame. |
| 2235 | let markMeasurementStarted!: () => void; | 2450 | takeGrant(); |
| 2236 | const measurementStarted = new Promise<void>((resolve) => { | 2451 | expect(grants).toEqual([{}]); |
| 2237 | markMeasurementStarted = resolve; | 2452 | |
| 2238 | }); | 2453 | yield* manager.stopRecording("tab_race_a"); |
| 2239 | let rejectFirstMeasurement!: (error: Error) => void; | 2454 | }), |
| 2240 | const executeJavaScript = vi | 2455 | ), |
| 2241 | .fn<(expression: string, userGesture?: boolean) => Promise<unknown>>() | 2456 | ); |
| 2242 | .mockImplementationOnce(() => { | 2457 | |
| 2243 | markMeasurementStarted(); | 2458 | effectIt.effect("only lets the host frame that armed a recording claim its stream", () => |
| 2244 | return new Promise((_, reject) => { | 2459 | withManager((manager) => |
| 2245 | rejectFirstMeasurement = reject; | 2460 | Effect.gen(function* () { |
| 2246 | }); | 2461 | const { grants, takeGrant } = yield* setupRecordingRaceTabs(manager); |
| 2247 | }) | 2462 | |
| 2248 | .mockResolvedValue({ width: 1280, height: 720 }); | 2463 | yield* manager.startRecording("tab_race_a"); |
| 2249 | const webContents = Object.assign(makeTestPreviewWebContents(capturePage), { | 2464 | takeGrant({ frameTreeNodeId: 999 }); |
| 2250 | executeJavaScript, | 2465 | takeGrant(); |
| 2466 | |||
| 2467 | expect(grants).toEqual([{}, { video: { routingId: 41 } }]); | ||
| 2468 | yield* manager.stopRecording("tab_race_a"); | ||
| 2469 | }), | ||
| 2470 | ), | ||
| 2471 | ); | ||
| 2472 | |||
| 2473 | effectIt.effect("reclaims the arm slot from a destroyed webContents", () => | ||
| 2474 | withManager((manager) => | ||
| 2475 | Effect.gen(function* () { | ||
| 2476 | const { grants, takeGrant, destroy } = yield* setupRecordingRaceTabs(manager); | ||
| 2477 | |||
| 2478 | yield* manager.startRecording("tab_race_a"); | ||
| 2479 | destroy(41); | ||
| 2480 | yield* manager.startRecording("tab_race_b"); | ||
| 2481 | takeGrant(); | ||
| 2482 | expect(grants).toEqual([{ video: { routingId: 42 } }]); | ||
| 2483 | |||
| 2484 | yield* manager.stopRecording("tab_race_b"); | ||
| 2485 | }), | ||
| 2486 | ), | ||
| 2487 | ); | ||
| 2488 | |||
| 2489 | effectIt.effect("continues native recording when the source warmup fails", () => | ||
| 2490 | withManager((manager) => | ||
| 2491 | Effect.gen(function* () { | ||
| 2492 | const capturePage = vi.fn(async () => { | ||
| 2493 | throw new Error("source is not ready"); | ||
| 2494 | }); | ||
| 2495 | const host = makeTestHostWebContents(); | ||
| 2496 | const webContents = Object.assign(makeTestPreviewWebContents(capturePage, 42, host), { | ||
| 2497 | executeJavaScript: vi.fn(async () => ({ width: 1280, height: 720 })), | ||
| 2251 | }); | 2498 | }); |
| 2252 | fromId.mockReturnValue(webContents); | 2499 | fromId.mockReturnValue(webContents); |
| 2253 | 2500 | ||
| 2254 | yield* manager.createTab("tab_recording_start_race"); | 2501 | yield* manager.createTab("tab_recording_warmup_failure"); |
| 2255 | yield* manager.registerWebview("tab_recording_start_race", 42); | 2502 | yield* manager.registerWebview("tab_recording_warmup_failure", 42); |
| 2256 | yield* manager.setMainWindow({ | ||
| 2257 | isDestroyed: () => false, | ||
| 2258 | once: vi.fn(), | ||
| 2259 | webContents: { setBackgroundThrottling }, | ||
| 2260 | } as never); | ||
| 2261 | 2503 | ||
| 2262 | const firstStart = yield* manager | 2504 | yield* manager.startRecording("tab_recording_warmup_failure"); |
| 2263 | .startRecording("tab_recording_start_race") | 2505 | expect(capturePage).toHaveBeenCalledTimes(2); |
| 2264 | .pipe(Effect.forkChild({ startImmediately: true })); | ||
| 2265 | yield* Effect.promise(() => measurementStarted); | ||
| 2266 | const secondStart = yield* manager | ||
| 2267 | .startRecording("tab_recording_start_race") | ||
| 2268 | .pipe(Effect.forkChild({ startImmediately: true })); | ||
| 2269 | yield* Effect.yieldNow; | ||
| 2270 | expect(executeJavaScript).toHaveBeenCalledOnce(); | ||
| 2271 | 2506 | ||
| 2272 | rejectFirstMeasurement(new Error("first measurement failed")); | 2507 | // The armed tab answers exactly one display-media request, then further requests are denied. |
| 2273 | const firstExit = yield* Fiber.await(firstStart); | 2508 | const handler = host.displayMediaHandler(); |
| 2274 | expect(Exit.isFailure(firstExit)).toBe(true); | 2509 | const streams: Array<{ video?: unknown }> = []; |
| 2275 | expect(yield* Fiber.join(secondStart)).toEqual({ | 2510 | handler?.({ frame: host.mainFrame }, (value) => streams.push(value)); |
| 2276 | sourceId: "tab:42", | 2511 | handler?.({ frame: host.mainFrame }, (value) => streams.push(value)); |
| 2277 | width: 1280, | 2512 | expect(streams).toEqual([{ video: { routingId: 42 } }, {}]); |
| 2278 | height: 720, | ||
| 2279 | }); | ||
| 2280 | 2513 | ||
| 2281 | yield* manager.stopRecording("tab_recording_start_race"); | 2514 | yield* manager.stopRecording("tab_recording_warmup_failure"); |
| 2282 | expect(setBackgroundThrottling.mock.calls).toEqual([[false], [true], [false], [true]]); | ||
| 2283 | }), | 2515 | }), |
| 2284 | ), | 2516 | ), |
| 2285 | ); | 2517 | ); |
| ... | @@ -2287,25 +2519,26 @@ describe("PreviewManager", () => { | ... | @@ -2287,25 +2519,26 @@ describe("PreviewManager", () => { |
| 2287 | effectIt.effect("serializes recording source acquisition with webview replacement", () => | 2519 | effectIt.effect("serializes recording source acquisition with webview replacement", () => |
| 2288 | withManager((manager) => | 2520 | withManager((manager) => |
| 2289 | Effect.gen(function* () { | 2521 | Effect.gen(function* () { |
| 2290 | const capturePage = vi.fn(async () => ({ | 2522 | const capturedImage = { |
| 2291 | toJPEG: () => Buffer.from("unused-recording-frame"), | 2523 | toJPEG: () => Buffer.from("unused-recording-frame"), |
| 2292 | getSize: () => ({ width: 1280, height: 720 }), | 2524 | getSize: () => ({ width: 1280, height: 720 }), |
| 2293 | })); | 2525 | }; |
| 2294 | let markMeasurementStarted!: () => void; | 2526 | let markWarmupStarted!: () => void; |
| 2295 | const measurementStarted = new Promise<void>((resolve) => { | 2527 | const warmupStarted = new Promise<void>((resolve) => { |
| 2296 | markMeasurementStarted = resolve; | 2528 | markWarmupStarted = resolve; |
| 2297 | }); | 2529 | }); |
| 2298 | let finishMeasurement!: (size: { readonly width: number; readonly height: number }) => void; | 2530 | let finishWarmup!: (image: TestCapturedPreviewImage) => void; |
| 2299 | const executeJavaScript = vi.fn( | 2531 | const capturePage = vi |
| 2300 | () => | 2532 | .fn<() => Promise<TestCapturedPreviewImage>>() |
| 2301 | new Promise<{ readonly width: number; readonly height: number }>((resolve) => { | 2533 | .mockImplementationOnce( |
| 2302 | markMeasurementStarted(); | 2534 | () => |
| 2303 | finishMeasurement = resolve; | 2535 | new Promise<TestCapturedPreviewImage>((resolve) => { |
| 2304 | }), | 2536 | markWarmupStarted(); |
| 2305 | ); | 2537 | finishWarmup = resolve; |
| 2306 | const initialWebContents = Object.assign(makeTestPreviewWebContents(capturePage, 42), { | 2538 | }), |
| 2307 | executeJavaScript, | 2539 | ) |
| 2308 | }); | 2540 | .mockResolvedValue(capturedImage); |
| 2541 | const initialWebContents = makeTestPreviewWebContents(capturePage, 42); | ||
| 2309 | const replacementOn = vi.fn(); | 2542 | const replacementOn = vi.fn(); |
| 2310 | const replacementWebContents = Object.assign(makeTestPreviewWebContents(capturePage, 43), { | 2543 | const replacementWebContents = Object.assign(makeTestPreviewWebContents(capturePage, 43), { |
| 2311 | on: replacementOn, | 2544 | on: replacementOn, |
| ... | @@ -2321,22 +2554,25 @@ describe("PreviewManager", () => { | ... | @@ -2321,22 +2554,25 @@ describe("PreviewManager", () => { |
| 2321 | const start = yield* manager | 2554 | const start = yield* manager |
| 2322 | .startRecording("tab_recording_replacement_race") | 2555 | .startRecording("tab_recording_replacement_race") |
| 2323 | .pipe(Effect.forkChild({ startImmediately: true })); | 2556 | .pipe(Effect.forkChild({ startImmediately: true })); |
| 2324 | yield* Effect.promise(() => measurementStarted); | 2557 | yield* Effect.promise(() => warmupStarted); |
| 2325 | const replacement = yield* manager | 2558 | const replacement = yield* manager |
| 2326 | .registerWebview("tab_recording_replacement_race", 43) | 2559 | .registerWebview("tab_recording_replacement_race", 43) |
| 2327 | .pipe(Effect.forkChild({ startImmediately: true })); | 2560 | .pipe(Effect.forkChild({ startImmediately: true })); |
| 2328 | yield* Effect.yieldNow; | 2561 | yield* Effect.yieldNow; |
| 2329 | expect(replacementOn).not.toHaveBeenCalled(); | 2562 | expect(replacementOn).not.toHaveBeenCalled(); |
| 2330 | 2563 | ||
| 2331 | finishMeasurement({ width: 1280, height: 720 }); | 2564 | finishWarmup(capturedImage); |
| 2332 | expect(yield* Fiber.join(start)).toEqual({ | 2565 | yield* Fiber.join(start); |
| 2333 | sourceId: "tab:42", | ||
| 2334 | width: 1280, | ||
| 2335 | height: 720, | ||
| 2336 | }); | ||
| 2337 | yield* Fiber.join(replacement); | 2566 | yield* Fiber.join(replacement); |
| 2338 | expect(replacementOn).toHaveBeenCalled(); | 2567 | expect(replacementOn).toHaveBeenCalled(); |
| 2568 | expect(initialWebContents.setBackgroundThrottling.mock.calls).toEqual([[false]]); | ||
| 2569 | expect(replacementWebContents.setBackgroundThrottling.mock.calls).toEqual([[false]]); | ||
| 2339 | yield* manager.stopRecording("tab_recording_replacement_race"); | 2570 | yield* manager.stopRecording("tab_recording_replacement_race"); |
| 2571 | expect(initialWebContents.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); | ||
| 2572 | expect(replacementWebContents.setBackgroundThrottling.mock.calls).toEqual([ | ||
| 2573 | [false], | ||
| 2574 | [true], | ||
| 2575 | ]); | ||
| 2340 | }), | 2576 | }), |
| 2341 | ), | 2577 | ), |
| 2342 | ); | 2578 | ); |
| ... | @@ -2345,7 +2581,9 @@ describe("PreviewManager", () => { | ... | @@ -2345,7 +2581,9 @@ describe("PreviewManager", () => { |
| 2345 | withManager((manager) => | 2581 | withManager((manager) => |
| 2346 | Effect.gen(function* () { | 2582 | Effect.gen(function* () { |
| 2347 | const setBackgroundThrottling = vi.fn(); | 2583 | const setBackgroundThrottling = vi.fn(); |
| 2348 | const mainWindowWebContents = { setBackgroundThrottling }; | 2584 | const mainWindowWebContents = Object.assign(makeTestHostWebContents(), { |
| 2585 | setBackgroundThrottling, | ||
| 2586 | }); | ||
| 2349 | const jpeg = Buffer.from("shared-preview-frame"); | 2587 | const jpeg = Buffer.from("shared-preview-frame"); |
| 2350 | const capturePage = vi.fn(async () => ({ | 2588 | const capturePage = vi.fn(async () => ({ |
| 2351 | toJPEG: () => jpeg, | 2589 | toJPEG: () => jpeg, |
| ... | @@ -2353,8 +2591,8 @@ describe("PreviewManager", () => { | ... | @@ -2353,8 +2591,8 @@ describe("PreviewManager", () => { |
| 2353 | })); | 2591 | })); |
| 2354 | fromId.mockReturnValue({ | 2592 | fromId.mockReturnValue({ |
| 2355 | id: 42, | 2593 | id: 42, |
| 2594 | mainFrame: { routingId: 42 }, | ||
| 2356 | hostWebContents: mainWindowWebContents, | 2595 | hostWebContents: mainWindowWebContents, |
| 2357 | getMediaSourceId: vi.fn(() => "tab:42"), | ||
| 2358 | executeJavaScript: vi.fn(async () => ({ width: 1280, height: 720 })), | 2596 | executeJavaScript: vi.fn(async () => ({ width: 1280, height: 720 })), |
| 2359 | isDestroyed: () => false, | 2597 | isDestroyed: () => false, |
| 2360 | getType: () => "webview", | 2598 | getType: () => "webview", |
| ... | @@ -2364,6 +2602,7 @@ describe("PreviewManager", () => { | ... | @@ -2364,6 +2602,7 @@ describe("PreviewManager", () => { |
| 2364 | getZoomFactor: () => 1, | 2602 | getZoomFactor: () => 1, |
| 2365 | setZoomFactor: vi.fn(), | 2603 | setZoomFactor: vi.fn(), |
| 2366 | setAudioMuted: vi.fn(), | 2604 | setAudioMuted: vi.fn(), |
| 2605 | setBackgroundThrottling: vi.fn(), | ||
| 2367 | isCurrentlyAudible: () => false, | 2606 | isCurrentlyAudible: () => false, |
| 2368 | on: vi.fn(), | 2607 | on: vi.fn(), |
| 2369 | off: vi.fn(), | 2608 | off: vi.fn(), |
apps/desktop/src/preview/Manager.ts+229-48| ... | @@ -5,6 +5,7 @@ | ... | @@ -5,6 +5,7 @@ |
| 5 | * elements live in the renderer; we only attach listeners and forward state | 5 | * elements live in the renderer; we only attach listeners and forward state |
| 6 | * here). Single layer-scoped browser session partition. | 6 | * here). Single layer-scoped browser session partition. |
| 7 | */ | 7 | */ |
| 8 | import { DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER } from "@t3tools/contracts"; | ||
| 8 | import type { | 9 | import type { |
| 9 | DesktopPreviewAnnotationTheme, | 10 | DesktopPreviewAnnotationTheme, |
| 10 | DesktopPreviewAutomationStatus, | 11 | DesktopPreviewAutomationStatus, |
| ... | @@ -16,7 +17,6 @@ import type { | ... | @@ -16,7 +17,6 @@ import type { |
| 16 | PreviewAnnotationSubmissionResult, | 17 | PreviewAnnotationSubmissionResult, |
| 17 | DesktopPreviewRecordingArtifact, | 18 | DesktopPreviewRecordingArtifact, |
| 18 | DesktopPreviewRecordingFrame, | 19 | DesktopPreviewRecordingFrame, |
| 19 | DesktopPreviewRecordingSource, | ||
| 20 | DesktopPreviewScreenshotArtifact, | 20 | DesktopPreviewScreenshotArtifact, |
| 21 | DesktopPreviewTabDefaults, | 21 | DesktopPreviewTabDefaults, |
| 22 | PreviewAutomationClickInput, | 22 | PreviewAutomationClickInput, |
| ... | @@ -109,8 +109,8 @@ const MAX_EVALUATION_BYTES = 64_000; | ... | @@ -109,8 +109,8 @@ const MAX_EVALUATION_BYTES = 64_000; |
| 109 | const MAX_VISIBLE_TEXT_LENGTH = 20_000; | 109 | const MAX_VISIBLE_TEXT_LENGTH = 20_000; |
| 110 | const MAX_INTERACTIVE_ELEMENTS = 200; | 110 | const MAX_INTERACTIVE_ELEMENTS = 200; |
| 111 | const MAX_SCREENSHOT_WIDTH = 1280; | 111 | const MAX_SCREENSHOT_WIDTH = 1280; |
| 112 | const RECORDING_SOURCE_SIZE_EXPRESSION = | 112 | /** How long an armed tab keeps the exclusive display-media slot before another tab may take it. */ |
| 113 | "({ width: Math.round(globalThis.innerWidth), height: Math.round(globalThis.innerHeight) })"; | 113 | const RECORDING_ARM_GRACE_MS = 10_000; |
| 114 | const PICTURE_IN_PICTURE_FRAME_INTERVAL_MS = Math.ceil(1_000 / 12); | 114 | const PICTURE_IN_PICTURE_FRAME_INTERVAL_MS = Math.ceil(1_000 / 12); |
| 115 | const PICTURE_IN_PICTURE_JPEG_QUALITY = 80; | 115 | const PICTURE_IN_PICTURE_JPEG_QUALITY = 80; |
| 116 | const PICTURE_IN_PICTURE_INITIAL_WIDTH = 480; | 116 | const PICTURE_IN_PICTURE_INITIAL_WIDTH = 480; |
| ... | @@ -122,6 +122,8 @@ const DIAGNOSTIC_BUFFER_LIMIT = 200; | ... | @@ -122,6 +122,8 @@ const DIAGNOSTIC_BUFFER_LIMIT = 200; |
| 122 | const MAX_ARTIFACT_SITE_SLUG_LENGTH = 80; | 122 | const MAX_ARTIFACT_SITE_SLUG_LENGTH = 80; |
| 123 | const AGENT_CURSOR_MOVE_MS = 160; | 123 | const AGENT_CURSOR_MOVE_MS = 160; |
| 124 | const AGENT_CURSOR_CLICK_LEAD_MS = 40; | 124 | const AGENT_CURSOR_CLICK_LEAD_MS = 40; |
| 125 | const requestRecordingCaptureExpression = (tabId: string): string => | ||
| 126 | `globalThis[${JSON.stringify(DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER)}]?.(${JSON.stringify(tabId)}) === true`; | ||
| 125 | const encodeUnknownJson = Schema.encodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); | 127 | const encodeUnknownJson = Schema.encodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); |
| 126 | const DEFAULT_ANNOTATION_THEME: DesktopPreviewAnnotationTheme = { | 128 | const DEFAULT_ANNOTATION_THEME: DesktopPreviewAnnotationTheme = { |
| 127 | colorScheme: "light", | 129 | colorScheme: "light", |
| ... | @@ -390,6 +392,7 @@ type FrameCaptureConsumer = "picture-in-picture" | "recording"; | ... | @@ -390,6 +392,7 @@ type FrameCaptureConsumer = "picture-in-picture" | "recording"; |
| 390 | interface FrameCaptureSession { | 392 | interface FrameCaptureSession { |
| 391 | readonly scope: Scope.Closeable | null; | 393 | readonly scope: Scope.Closeable | null; |
| 392 | readonly consumers: ReadonlySet<FrameCaptureConsumer>; | 394 | readonly consumers: ReadonlySet<FrameCaptureConsumer>; |
| 395 | readonly unthrottledWebContentsIds: ReadonlySet<number>; | ||
| 393 | readonly lastPictureInPictureFrame: Buffer | null; | 396 | readonly lastPictureInPictureFrame: Buffer | null; |
| 394 | } | 397 | } |
| 395 | 398 | ||
| ... | @@ -400,6 +403,14 @@ interface PictureInPictureSession { | ... | @@ -400,6 +403,14 @@ interface PictureInPictureSession { |
| 400 | readonly initializationScope: Scope.Closeable; | 403 | readonly initializationScope: Scope.Closeable; |
| 401 | } | 404 | } |
| 402 | 405 | ||
| 406 | /** The tab whose frame the next `getDisplayMedia()` request is allowed to capture. */ | ||
| 407 | interface PendingRecording { | ||
| 408 | readonly tabId: string; | ||
| 409 | readonly webContents: Electron.WebContents; | ||
| 410 | readonly requestingFrameTreeNodeId: number; | ||
| 411 | readonly armedAtMillis: number; | ||
| 412 | } | ||
| 413 | |||
| 403 | interface PickSession { | 414 | interface PickSession { |
| 404 | readonly cancel: Effect.Effect<void>; | 415 | readonly cancel: Effect.Effect<void>; |
| 405 | } | 416 | } |
| ... | @@ -591,6 +602,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -591,6 +602,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 591 | const pictureInPictureAspectRatiosRef = yield* Ref.make<ReadonlyMap<string, number>>(new Map()); | 602 | const pictureInPictureAspectRatiosRef = yield* Ref.make<ReadonlyMap<string, number>>(new Map()); |
| 592 | const pictureInPictureMutationSemaphore = yield* Semaphore.make(1); | 603 | const pictureInPictureMutationSemaphore = yield* Semaphore.make(1); |
| 593 | const closingTabIdsRef = yield* Ref.make<ReadonlySet<string>>(new Set()); | 604 | const closingTabIdsRef = yield* Ref.make<ReadonlySet<string>>(new Set()); |
| 605 | // Tab recording uses `setDisplayMediaRequestHandler` because Electron's legacy | ||
| 606 | // `getMediaSourceId` + `chromeMediaSource: "tab"` capture path was removed upstream | ||
| 607 | // (electron#44618) and now always rejects with NotAllowedError. | ||
| 608 | let pendingRecording: PendingRecording | null = null; | ||
| 609 | const displayMediaHandlerSessions = new WeakSet<Session>(); | ||
| 594 | let frameCaptureWindowOpen = true; | 610 | let frameCaptureWindowOpen = true; |
| 595 | let currentMainWindow: BrowserWindow | undefined; | 611 | let currentMainWindow: BrowserWindow | undefined; |
| 596 | let mainWindowCleanupFiber: Fiber.Fiber<void, never> | undefined; | 612 | let mainWindowCleanupFiber: Fiber.Fiber<void, never> | undefined; |
| ... | @@ -665,6 +681,65 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -665,6 +681,65 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 665 | if (Option.isNone(mainWindow)) return; | 681 | if (Option.isNone(mainWindow)) return; |
| 666 | yield* setWindowBackgroundThrottling(mainWindow.value, enabled); | 682 | yield* setWindowBackgroundThrottling(mainWindow.value, enabled); |
| 667 | }); | 683 | }); |
| 684 | const setFrameCaptureWebContentsBackgroundThrottling = Effect.fnUntraced(function* ( | ||
| 685 | wc: Electron.WebContents, | ||
| 686 | enabled: boolean, | ||
| 687 | ) { | ||
| 688 | if (wc.isDestroyed()) return; | ||
| 689 | yield* attempt( | ||
| 690 | { | ||
| 691 | operation: "frameCapture.setBackgroundThrottling", | ||
| 692 | webContentsId: wc.id, | ||
| 693 | }, | ||
| 694 | () => wc.setBackgroundThrottling(enabled), | ||
| 695 | ); | ||
| 696 | }); | ||
| 697 | const restoreFrameCaptureWebContentsBackgroundThrottling = Effect.fnUntraced(function* ( | ||
| 698 | webContentsIds: ReadonlySet<number>, | ||
| 699 | ) { | ||
| 700 | yield* Effect.forEach( | ||
| 701 | webContentsIds, | ||
| 702 | (webContentsId) => { | ||
| 703 | const wc = webContents.fromId(webContentsId); | ||
| 704 | if (!wc || wc.isDestroyed()) return Effect.void; | ||
| 705 | return setFrameCaptureWebContentsBackgroundThrottling(wc, true).pipe( | ||
| 706 | Effect.retry({ times: 2 }), | ||
| 707 | Effect.catch((error) => | ||
| 708 | Effect.logWarning("Failed to restore preview webview frame capture throttling.", { | ||
| 709 | webContentsId, | ||
| 710 | error, | ||
| 711 | }), | ||
| 712 | ), | ||
| 713 | ); | ||
| 714 | }, | ||
| 715 | { concurrency: "unbounded", discard: true }, | ||
| 716 | ); | ||
| 717 | }); | ||
| 718 | const keepFrameCaptureWebContentsUnthrottled = Effect.fnUntraced(function* ( | ||
| 719 | tabId: string, | ||
| 720 | wc: Electron.WebContents, | ||
| 721 | ) { | ||
| 722 | yield* SynchronizedRef.modifyEffect(frameCaptureSessionsRef, (sessions) => { | ||
| 723 | const current = sessions.get(tabId); | ||
| 724 | if (!current || current.unthrottledWebContentsIds.has(wc.id)) { | ||
| 725 | return Effect.succeed([undefined, sessions] as const); | ||
| 726 | } | ||
| 727 | return setFrameCaptureWebContentsBackgroundThrottling(wc, false).pipe( | ||
| 728 | Effect.map( | ||
| 729 | () => | ||
| 730 | [ | ||
| 731 | undefined, | ||
| 732 | replaceMap(sessions, (copy) => { | ||
| 733 | copy.set(tabId, { | ||
| 734 | ...current, | ||
| 735 | unthrottledWebContentsIds: new Set([...current.unthrottledWebContentsIds, wc.id]), | ||
| 736 | }); | ||
| 737 | }), | ||
| 738 | ] as const, | ||
| 739 | ), | ||
| 740 | ); | ||
| 741 | }); | ||
| 742 | }); | ||
| 668 | const stopFrameCapture = Effect.fn("PreviewManager.stopFrameCapture")(function* ( | 743 | const stopFrameCapture = Effect.fn("PreviewManager.stopFrameCapture")(function* ( |
| 669 | tabId: string, | 744 | tabId: string, |
| 670 | consumer: FrameCaptureConsumer, | 745 | consumer: FrameCaptureConsumer, |
| ... | @@ -694,6 +769,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -694,6 +769,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 694 | const remainingSessions = replaceMap(sessions, (copy) => { | 769 | const remainingSessions = replaceMap(sessions, (copy) => { |
| 695 | copy.delete(tabId); | 770 | copy.delete(tabId); |
| 696 | }); | 771 | }); |
| 772 | yield* restoreFrameCaptureWebContentsBackgroundThrottling( | ||
| 773 | current.unthrottledWebContentsIds, | ||
| 774 | ); | ||
| 697 | if (remainingSessions.size === 0) { | 775 | if (remainingSessions.size === 0) { |
| 698 | yield* setFrameCaptureBackgroundThrottling(true).pipe( | 776 | yield* setFrameCaptureBackgroundThrottling(true).pipe( |
| 699 | Effect.retry({ times: 2 }), | 777 | Effect.retry({ times: 2 }), |
| ... | @@ -713,6 +791,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -713,6 +791,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 713 | }); | 791 | }); |
| 714 | 792 | ||
| 715 | const stopAllRecordings = Effect.fn("PreviewManager.stopAllRecordings")(function* () { | 793 | const stopAllRecordings = Effect.fn("PreviewManager.stopAllRecordings")(function* () { |
| 794 | pendingRecording = null; | ||
| 716 | const sessions = yield* SynchronizedRef.get(frameCaptureSessionsRef); | 795 | const sessions = yield* SynchronizedRef.get(frameCaptureSessionsRef); |
| 717 | yield* Effect.forEach(sessions.keys(), (tabId) => stopFrameCapture(tabId, "recording"), { | 796 | yield* Effect.forEach(sessions.keys(), (tabId) => stopFrameCapture(tabId, "recording"), { |
| 718 | concurrency: "unbounded", | 797 | concurrency: "unbounded", |
| ... | @@ -1889,6 +1968,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -1889,6 +1968,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1889 | 1968 | ||
| 1890 | const closeTabUnlocked = Effect.fn("PreviewManager.closeTabUnlocked")(function* (tabId: string) { | 1969 | const closeTabUnlocked = Effect.fn("PreviewManager.closeTabUnlocked")(function* (tabId: string) { |
| 1891 | if (!(yield* SynchronizedRef.get(tabsRef)).has(tabId)) return; | 1970 | if (!(yield* SynchronizedRef.get(tabsRef)).has(tabId)) return; |
| 1971 | clearPendingRecording(tabId); | ||
| 1892 | yield* Effect.all( | 1972 | yield* Effect.all( |
| 1893 | [ | 1973 | [ |
| 1894 | cancelPickElement(tabId), | 1974 | cancelPickElement(tabId), |
| ... | @@ -1980,6 +2060,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -1980,6 +2060,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1980 | const attached = yield* Ref.get(attachedRef); | 2060 | const attached = yield* Ref.get(attachedRef); |
| 1981 | const annotationTheme = yield* Ref.get(annotationThemeRef); | 2061 | const annotationTheme = yield* Ref.get(annotationThemeRef); |
| 1982 | const currentAttachment = attached.get(webContentsId); | 2062 | const currentAttachment = attached.get(webContentsId); |
| 2063 | yield* keepFrameCaptureWebContentsUnthrottled(tabId, wc); | ||
| 1983 | if (tab.webContentsId === webContentsId && currentAttachment?.webContents === wc) { | 2064 | if (tab.webContentsId === webContentsId && currentAttachment?.webContents === wc) { |
| 1984 | // The guest we already own re-announced itself, so nothing about the tab | 2065 | // The guest we already own re-announced itself, so nothing about the tab |
| 1985 | // changed. Only push its zoom back down — Chromium may have just handed | 2066 | // changed. Only push its zoom back down — Chromium may have just handed |
| ... | @@ -1996,6 +2077,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -1996,6 +2077,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1996 | ? tab.webContentsId | 2077 | ? tab.webContentsId |
| 1997 | : null; | 2078 | : null; |
| 1998 | if (replacedWebContentsId !== null) { | 2079 | if (replacedWebContentsId !== null) { |
| 2080 | // The replaced guest can no longer redeem a display-media grant. | ||
| 2081 | clearPendingRecording(tabId); | ||
| 1999 | yield* Effect.all( | 2082 | yield* Effect.all( |
| 2000 | [ | 2083 | [ |
| 2001 | detachControlSession(replacedWebContentsId), | 2084 | detachControlSession(replacedWebContentsId), |
| ... | @@ -2728,7 +2811,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -2728,7 +2811,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 2728 | ) { | 2811 | ) { |
| 2729 | // Recording keeps only the activity lease. Picture-in-picture owns the | 2812 | // Recording keeps only the activity lease. Picture-in-picture owns the |
| 2730 | // capturePage loop and tolerates transient compositor warmup failures. | 2813 | // capturePage loop and tolerates transient compositor warmup failures. |
| 2731 | yield* requireWebContents(tabId); | ||
| 2732 | const captureNextFrame = Effect.sleep(PICTURE_IN_PICTURE_FRAME_INTERVAL_MS).pipe( | 2814 | const captureNextFrame = Effect.sleep(PICTURE_IN_PICTURE_FRAME_INTERVAL_MS).pipe( |
| 2733 | Effect.andThen(capturePreviewFrame(tabId)), | 2815 | Effect.andThen(capturePreviewFrame(tabId)), |
| 2734 | Effect.catch((error) => | 2816 | Effect.catch((error) => |
| ... | @@ -2749,11 +2831,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -2749,11 +2831,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 2749 | if (!tab || (yield* Ref.get(closingTabIdsRef)).has(tabId)) { | 2831 | if (!tab || (yield* Ref.get(closingTabIdsRef)).has(tabId)) { |
| 2750 | return yield* new PreviewTabNotFoundError({ tabId }); | 2832 | return yield* new PreviewTabNotFoundError({ tabId }); |
| 2751 | } | 2833 | } |
| 2834 | const wc = yield* requireWebContents(tabId); | ||
| 2752 | const current = sessions.get(tabId); | 2835 | const current = sessions.get(tabId); |
| 2753 | if (current) { | 2836 | if (current) { |
| 2754 | if (current.consumers.has(consumer)) { | 2837 | if (current.consumers.has(consumer)) { |
| 2755 | return [false, sessions] as const; | 2838 | return [false, sessions] as const; |
| 2756 | } | 2839 | } |
| 2840 | if (!current.unthrottledWebContentsIds.has(wc.id)) { | ||
| 2841 | yield* setFrameCaptureWebContentsBackgroundThrottling(wc, false); | ||
| 2842 | } | ||
| 2757 | let scope = current.scope; | 2843 | let scope = current.scope; |
| 2758 | if (consumer === "picture-in-picture" && scope === null) { | 2844 | if (consumer === "picture-in-picture" && scope === null) { |
| 2759 | scope = yield* Scope.fork(parentScope, "sequential"); | 2845 | scope = yield* Scope.fork(parentScope, "sequential"); |
| ... | @@ -2766,6 +2852,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -2766,6 +2852,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 2766 | ...current, | 2852 | ...current, |
| 2767 | scope, | 2853 | scope, |
| 2768 | consumers: new Set([...current.consumers, consumer]), | 2854 | consumers: new Set([...current.consumers, consumer]), |
| 2855 | unthrottledWebContentsIds: new Set([...current.unthrottledWebContentsIds, wc.id]), | ||
| 2769 | }); | 2856 | }); |
| 2770 | }), | 2857 | }), |
| 2771 | ] as const; | 2858 | ] as const; |
| ... | @@ -2773,6 +2860,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -2773,6 +2860,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 2773 | if (sessions.size === 0) { | 2860 | if (sessions.size === 0) { |
| 2774 | yield* setFrameCaptureBackgroundThrottling(false); | 2861 | yield* setFrameCaptureBackgroundThrottling(false); |
| 2775 | } | 2862 | } |
| 2863 | yield* setFrameCaptureWebContentsBackgroundThrottling(wc, false).pipe( | ||
| 2864 | Effect.onError(() => | ||
| 2865 | sessions.size === 0 | ||
| 2866 | ? setFrameCaptureBackgroundThrottling(true).pipe(Effect.ignore) | ||
| 2867 | : Effect.void, | ||
| 2868 | ), | ||
| 2869 | ); | ||
| 2776 | const scope = | 2870 | const scope = |
| 2777 | consumer === "picture-in-picture" ? yield* Scope.fork(parentScope, "sequential") : null; | 2871 | consumer === "picture-in-picture" ? yield* Scope.fork(parentScope, "sequential") : null; |
| 2778 | if (scope !== null) { | 2872 | if (scope !== null) { |
| ... | @@ -2784,6 +2878,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -2784,6 +2878,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 2784 | copy.set(tabId, { | 2878 | copy.set(tabId, { |
| 2785 | scope, | 2879 | scope, |
| 2786 | consumers: new Set([consumer]), | 2880 | consumers: new Set([consumer]), |
| 2881 | unthrottledWebContentsIds: new Set([wc.id]), | ||
| 2787 | lastPictureInPictureFrame: null, | 2882 | lastPictureInPictureFrame: null, |
| 2788 | }); | 2883 | }); |
| 2789 | }), | 2884 | }), |
| ... | @@ -3099,6 +3194,83 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -3099,6 +3194,83 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 3099 | return yield* Effect.failCause(initializationExit.cause); | 3194 | return yield* Effect.failCause(initializationExit.cause); |
| 3100 | }); | 3195 | }); |
| 3101 | 3196 | ||
| 3197 | /** Only drops the armed target when it still belongs to `tabId`, so tabs cannot clobber each other. */ | ||
| 3198 | const clearPendingRecording = (tabId: string) => { | ||
| 3199 | if (pendingRecording?.tabId === tabId) pendingRecording = null; | ||
| 3200 | }; | ||
| 3201 | |||
| 3202 | /** | ||
| 3203 | * Claims the single arm slot for `tabId`. A display-media request carries no tab identity, so the | ||
| 3204 | * slot is exclusive: a second tab arming before the first request lands would redirect the first | ||
| 3205 | * renderer's stream. Rather than queue (which can only ever stall a start), a colliding start | ||
| 3206 | * fails fast and the renderer can retry. An arm the renderer never redeemed goes stale after | ||
| 3207 | * `RECORDING_ARM_GRACE_MS` so it cannot hold the slot forever. | ||
| 3208 | */ | ||
| 3209 | const armPendingRecording = Effect.fn("PreviewManager.armPendingRecording")(function* ( | ||
| 3210 | tabId: string, | ||
| 3211 | wc: Electron.WebContents, | ||
| 3212 | requestingFrameTreeNodeId: number, | ||
| 3213 | ) { | ||
| 3214 | const now = yield* Clock.currentTimeMillis; | ||
| 3215 | const previous = pendingRecording; | ||
| 3216 | if ( | ||
| 3217 | previous !== null && | ||
| 3218 | previous.tabId !== tabId && | ||
| 3219 | !previous.webContents.isDestroyed() && | ||
| 3220 | now - previous.armedAtMillis < RECORDING_ARM_GRACE_MS | ||
| 3221 | ) { | ||
| 3222 | return yield* new PreviewRecordingArmConflictError({ | ||
| 3223 | tabId, | ||
| 3224 | webContentsId: wc.id, | ||
| 3225 | armedTabId: previous.tabId, | ||
| 3226 | }); | ||
| 3227 | } | ||
| 3228 | const armed: PendingRecording = { | ||
| 3229 | tabId, | ||
| 3230 | webContents: wc, | ||
| 3231 | requestingFrameTreeNodeId, | ||
| 3232 | armedAtMillis: now, | ||
| 3233 | }; | ||
| 3234 | pendingRecording = armed; | ||
| 3235 | // The handler callback is sync and cannot read a clock, so expiry is driven from here. | ||
| 3236 | // Identity compare: a re-arm replaces the object, and this fiber must not clobber it. | ||
| 3237 | yield* Effect.forkIn( | ||
| 3238 | Effect.sleep(RECORDING_ARM_GRACE_MS).pipe( | ||
| 3239 | Effect.andThen( | ||
| 3240 | Effect.sync(() => { | ||
| 3241 | if (pendingRecording === armed) pendingRecording = null; | ||
| 3242 | }), | ||
| 3243 | ), | ||
| 3244 | ), | ||
| 3245 | parentScope, | ||
| 3246 | ); | ||
| 3247 | }); | ||
| 3248 | |||
| 3249 | // Installed once per session: answers the renderer's `getDisplayMedia()` with the tab that | ||
| 3250 | // `startRecording` armed, and denies anything else so pages cannot capture on their own. | ||
| 3251 | const installDisplayMediaRequestHandler = (session: Session) => { | ||
| 3252 | if (displayMediaHandlerSessions.has(session)) return; | ||
| 3253 | displayMediaHandlerSessions.add(session); | ||
| 3254 | session.setDisplayMediaRequestHandler((request, callback) => { | ||
| 3255 | const armed = pendingRecording; | ||
| 3256 | if (!armed) { | ||
| 3257 | callback({}); | ||
| 3258 | return; | ||
| 3259 | } | ||
| 3260 | if (armed.webContents.isDestroyed()) { | ||
| 3261 | pendingRecording = null; | ||
| 3262 | callback({}); | ||
| 3263 | return; | ||
| 3264 | } | ||
| 3265 | if (request.frame?.frameTreeNodeId !== armed.requestingFrameTreeNodeId) { | ||
| 3266 | callback({}); | ||
| 3267 | return; | ||
| 3268 | } | ||
| 3269 | pendingRecording = null; | ||
| 3270 | callback({ video: armed.webContents.mainFrame }); | ||
| 3271 | }); | ||
| 3272 | }; | ||
| 3273 | |||
| 3102 | const startRecording = Effect.fn("PreviewManager.startRecording")(function* (tabId: string) { | 3274 | const startRecording = Effect.fn("PreviewManager.startRecording")(function* (tabId: string) { |
| 3103 | if ((yield* Ref.get(closingTabIdsRef)).has(tabId)) { | 3275 | if ((yield* Ref.get(closingTabIdsRef)).has(tabId)) { |
| 3104 | return yield* new PreviewTabNotFoundError({ tabId }); | 3276 | return yield* new PreviewTabNotFoundError({ tabId }); |
| ... | @@ -3112,31 +3284,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -3112,31 +3284,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 3112 | if (requestWebContents === null) { | 3284 | if (requestWebContents === null) { |
| 3113 | return yield* new PreviewMainWindowClosedError({ tabId }); | 3285 | return yield* new PreviewMainWindowClosedError({ tabId }); |
| 3114 | } | 3286 | } |
| 3115 | const measuredSize = yield* attemptPromise( | ||
| 3116 | { | ||
| 3117 | operation: "recording.measureSource", | ||
| 3118 | tabId, | ||
| 3119 | webContentsId: wc.id, | ||
| 3120 | }, | ||
| 3121 | () => wc.executeJavaScript(RECORDING_SOURCE_SIZE_EXPRESSION, true), | ||
| 3122 | ); | ||
| 3123 | if ( | ||
| 3124 | typeof measuredSize !== "object" || | ||
| 3125 | measuredSize === null || | ||
| 3126 | !("width" in measuredSize) || | ||
| 3127 | !("height" in measuredSize) || | ||
| 3128 | typeof measuredSize.width !== "number" || | ||
| 3129 | typeof measuredSize.height !== "number" || | ||
| 3130 | !Number.isInteger(measuredSize.width) || | ||
| 3131 | !Number.isInteger(measuredSize.height) || | ||
| 3132 | measuredSize.width <= 0 || | ||
| 3133 | measuredSize.height <= 0 | ||
| 3134 | ) { | ||
| 3135 | return yield* new PreviewRecordingSourceSizeUnavailableError({ | ||
| 3136 | tabId, | ||
| 3137 | webContentsId: wc.id, | ||
| 3138 | }); | ||
| 3139 | } | ||
| 3140 | yield* attemptPromise( | 3287 | yield* attemptPromise( |
| 3141 | { | 3288 | { |
| 3142 | operation: "recording.warmSource", | 3289 | operation: "recording.warmSource", |
| ... | @@ -3152,25 +3299,44 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -3152,25 +3299,44 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 3152 | webContentsId: wc.id, | 3299 | webContentsId: wc.id, |
| 3153 | }); | 3300 | }); |
| 3154 | } | 3301 | } |
| 3155 | const sourceId = yield* attempt( | 3302 | if (!frameCaptureWindowOpen || requestWebContents.isDestroyed()) { |
| 3303 | return yield* new PreviewMainWindowClosedError({ tabId }); | ||
| 3304 | } | ||
| 3305 | installDisplayMediaRequestHandler(requestWebContents.session); | ||
| 3306 | yield* armPendingRecording(tabId, wc, requestWebContents.mainFrame.frameTreeNodeId); | ||
| 3307 | const captureRequested = yield* attemptPromise( | ||
| 3156 | { | 3308 | { |
| 3157 | operation: "recording.getMediaSourceId", | 3309 | operation: "recording.requestCapture", |
| 3158 | tabId, | 3310 | tabId, |
| 3159 | webContentsId: wc.id, | 3311 | webContentsId: requestWebContents.id, |
| 3160 | }, | 3312 | }, |
| 3161 | () => wc.getMediaSourceId(requestWebContents), | 3313 | () => |
| 3314 | requestWebContents.executeJavaScript(requestRecordingCaptureExpression(tabId), true), | ||
| 3162 | ); | 3315 | ); |
| 3163 | return { | 3316 | if (captureRequested !== true) { |
| 3164 | sourceId, | 3317 | return yield* new PreviewRecordingCaptureUnavailableError({ |
| 3165 | width: measuredSize.width, | 3318 | tabId, |
| 3166 | height: measuredSize.height, | 3319 | webContentsId: requestWebContents.id, |
| 3167 | } satisfies DesktopPreviewRecordingSource; | 3320 | }); |
| 3168 | }).pipe(Effect.onError(() => stopFrameCapture(tabId, "recording").pipe(Effect.ignore))), | 3321 | } |
| 3322 | }).pipe( | ||
| 3323 | Effect.onError(() => { | ||
| 3324 | clearPendingRecording(tabId); | ||
| 3325 | return stopFrameCapture(tabId, "recording").pipe(Effect.ignore); | ||
| 3326 | }), | ||
| 3327 | ), | ||
| 3169 | ); | 3328 | ); |
| 3170 | }); | 3329 | }); |
| 3171 | 3330 | ||
| 3172 | const stopRecording = Effect.fn("PreviewManager.stopRecording")(function* (tabId: string) { | 3331 | const stopRecording = Effect.fn("PreviewManager.stopRecording")(function* (tabId: string) { |
| 3173 | yield* withTabLifecycleLock(tabId, stopFrameCapture(tabId, "recording")); | 3332 | // Clearing runs under the tab lock so it cannot land before an in-flight start arms. |
| 3333 | yield* withTabLifecycleLock( | ||
| 3334 | tabId, | ||
| 3335 | Effect.suspend(() => { | ||
| 3336 | clearPendingRecording(tabId); | ||
| 3337 | return stopFrameCapture(tabId, "recording"); | ||
| 3338 | }), | ||
| 3339 | ); | ||
| 3174 | }); | 3340 | }); |
| 3175 | 3341 | ||
| 3176 | const saveRecording = Effect.fn("PreviewManager.saveRecording")(function* ( | 3342 | const saveRecording = Effect.fn("PreviewManager.saveRecording")(function* ( |
| ... | @@ -3980,12 +4146,28 @@ export class PreviewMainWindowClosedError extends Schema.TaggedErrorClass<Previe | ... | @@ -3980,12 +4146,28 @@ export class PreviewMainWindowClosedError extends Schema.TaggedErrorClass<Previe |
| 3980 | } | 4146 | } |
| 3981 | } | 4147 | } |
| 3982 | 4148 | ||
| 3983 | export class PreviewRecordingSourceSizeUnavailableError extends Schema.TaggedErrorClass<PreviewRecordingSourceSizeUnavailableError>()( | 4149 | export class PreviewRecordingArmConflictError extends Schema.TaggedErrorClass<PreviewRecordingArmConflictError>()( |
| 3984 | "PreviewRecordingSourceSizeUnavailableError", | 4150 | "PreviewRecordingArmConflictError", |
| 3985 | { tabId: Schema.String, webContentsId: Schema.Number }, | 4151 | { |
| 4152 | tabId: Schema.String, | ||
| 4153 | webContentsId: Schema.Number, | ||
| 4154 | armedTabId: Schema.String, | ||
| 4155 | }, | ||
| 3986 | ) { | 4156 | ) { |
| 3987 | override get message(): string { | 4157 | override get message(): string { |
| 3988 | return `Preview media source dimensions are unavailable for tab ${this.tabId}`; | 4158 | return `Preview tab ${this.armedTabId} is still claiming the capture stream, so recording could not start for tab ${this.tabId}`; |
| 4159 | } | ||
| 4160 | } | ||
| 4161 | |||
| 4162 | export class PreviewRecordingCaptureUnavailableError extends Schema.TaggedErrorClass<PreviewRecordingCaptureUnavailableError>()( | ||
| 4163 | "PreviewRecordingCaptureUnavailableError", | ||
| 4164 | { | ||
| 4165 | tabId: Schema.String, | ||
| 4166 | webContentsId: Schema.Number, | ||
| 4167 | }, | ||
| 4168 | ) { | ||
| 4169 | override get message(): string { | ||
| 4170 | return `Preview recording capture is unavailable for tab ${this.tabId} in WebContents ${this.webContentsId}`; | ||
| 3989 | } | 4171 | } |
| 3990 | } | 4172 | } |
| 3991 | 4173 | ||
| ... | @@ -4196,7 +4378,8 @@ export const PreviewManagerError = Schema.Union([ | ... | @@ -4196,7 +4378,8 @@ export const PreviewManagerError = Schema.Union([ |
| 4196 | PreviewWebContentsNotFoundError, | 4378 | PreviewWebContentsNotFoundError, |
| 4197 | PreviewWebviewNotInitializedError, | 4379 | PreviewWebviewNotInitializedError, |
| 4198 | PreviewMainWindowClosedError, | 4380 | PreviewMainWindowClosedError, |
| 4199 | PreviewRecordingSourceSizeUnavailableError, | 4381 | PreviewRecordingArmConflictError, |
| 4382 | PreviewRecordingCaptureUnavailableError, | ||
| 4200 | PreviewOperationError, | 4383 | PreviewOperationError, |
| 4201 | PreviewArtifactPathOutsideDirectoryError, | 4384 | PreviewArtifactPathOutsideDirectoryError, |
| 4202 | PreviewArtifactImageLoadError, | 4385 | PreviewArtifactImageLoadError, |
| ... | @@ -4274,9 +4457,7 @@ export class PreviewManager extends Context.Service< | ... | @@ -4274,9 +4457,7 @@ export class PreviewManager extends Context.Service< |
| 4274 | readonly copyArtifactToClipboard: (path: string) => Effect.Effect<void, PreviewManagerError>; | 4457 | readonly copyArtifactToClipboard: (path: string) => Effect.Effect<void, PreviewManagerError>; |
| 4275 | readonly openPictureInPicture: (tabId: string) => Effect.Effect<void, PreviewManagerError>; | 4458 | readonly openPictureInPicture: (tabId: string) => Effect.Effect<void, PreviewManagerError>; |
| 4276 | readonly closePictureInPicture: (tabId: string) => Effect.Effect<void, PreviewManagerError>; | 4459 | readonly closePictureInPicture: (tabId: string) => Effect.Effect<void, PreviewManagerError>; |
| 4277 | readonly startRecording: ( | 4460 | readonly startRecording: (tabId: string) => Effect.Effect<void, PreviewManagerError>; |
| 4278 | tabId: string, | ||
| 4279 | ) => Effect.Effect<DesktopPreviewRecordingSource, PreviewManagerError>; | ||
| 4280 | readonly stopRecording: (tabId: string) => Effect.Effect<void, PreviewManagerError>; | 4461 | readonly stopRecording: (tabId: string) => Effect.Effect<void, PreviewManagerError>; |
| 4281 | readonly saveRecording: ( | 4462 | readonly saveRecording: ( |
| 4282 | tabId: string, | 4463 | tabId: string, |
apps/web/src/browser/HostedBrowserWebview.tsx+5-1| ... | @@ -6,7 +6,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; | ... | @@ -6,7 +6,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; |
| 6 | 6 | ||
| 7 | import { previewBridge } from "~/components/preview/previewBridge"; | 7 | 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, isMacPlatform } from "~/lib/utils"; |
| 10 | 10 | ||
| 11 | import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore"; | 11 | import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore"; |
| 12 | import { useActiveBrowserRecordingTabIds } from "./browserRecording"; | 12 | import { useActiveBrowserRecordingTabIds } from "./browserRecording"; |
| ... | @@ -241,6 +241,10 @@ export function HostedBrowserWebview(props: { | ... | @@ -241,6 +241,10 @@ export function HostedBrowserWebview(props: { |
| 241 | const wrapperStyle = resolveHostedBrowserWebviewWrapperStyle({ | 241 | const wrapperStyle = resolveHostedBrowserWebviewWrapperStyle({ |
| 242 | active, | 242 | active, |
| 243 | renderingActive, | 243 | renderingActive, |
| 244 | // Electron 43 can permanently blank a macOS webview after `visibility: hidden`. | ||
| 245 | // Inactive macOS guests intentionally remain paintable offscreen; other platforms still | ||
| 246 | // suspend them, and automation continues to see the macOS guests as inactive. | ||
| 247 | keepPaintableWhenInactive: isMacPlatform(navigator.platform), | ||
| 244 | cornerRadius: presentation.cornerRadius, | 248 | cornerRadius: presentation.cornerRadius, |
| 245 | rect: lastRect, | 249 | rect: lastRect, |
| 246 | hiddenSize, | 250 | hiddenSize, |
apps/web/src/browser/browserRecording.test.ts+195-60| ... | @@ -1,37 +1,57 @@ | ... | @@ -1,37 +1,57 @@ |
| 1 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | 1 | import { |
| 2 | DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER, | ||
| 3 | EnvironmentId, | ||
| 4 | ThreadId, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | 6 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 3 | 7 | ||
| 4 | const { clientSettings, events, getUserMedia, registrySet, save, startScreencast, stopScreencast } = | 8 | const { |
| 5 | vi.hoisted(() => { | 9 | clientSettings, |
| 6 | const events: string[] = []; | 10 | events, |
| 7 | return { | 11 | getDisplayMedia, |
| 8 | clientSettings: { browserRecordingFrameRate: 30 as 30 | 60 }, | 12 | registrySet, |
| 9 | events, | 13 | requestDisplayMediaCapture, |
| 10 | getUserMedia: vi.fn(), | 14 | save, |
| 11 | registrySet: vi.fn((_atom: unknown, value: { readonly tabIds: ReadonlySet<string> }) => { | 15 | startScreencast, |
| 12 | events.push( | 16 | stopScreencast, |
| 13 | value.tabIds.size === 0 ? "clear" : `publish:${Array.from(value.tabIds).join(",")}`, | 17 | } = vi.hoisted(() => { |
| 14 | ); | 18 | const events: string[] = []; |
| 15 | }), | 19 | return { |
| 16 | save: vi.fn(async (tabId: string) => ({ | 20 | clientSettings: { browserRecordingFrameRate: 30 as 30 | 60 }, |
| 17 | id: "recording-test", | 21 | events, |
| 18 | tabId, | 22 | getDisplayMedia: vi.fn(), |
| 19 | path: "/tmp/recording-test.webm", | 23 | requestDisplayMediaCapture: vi.fn((_tabId: string) => undefined), |
| 20 | mimeType: "video/webm" as const, | 24 | registrySet: vi.fn((_atom: unknown, value: { readonly tabIds: ReadonlySet<string> }) => { |
| 21 | sizeBytes: 0, | 25 | events.push( |
| 22 | createdAt: "2026-06-26T00:00:00.000Z", | 26 | value.tabIds.size === 0 ? "clear" : `publish:${Array.from(value.tabIds).join(",")}`, |
| 23 | })), | 27 | ); |
| 24 | startScreencast: vi.fn(async (tabId: string) => { | 28 | }), |
| 25 | events.push("start-screencast"); | 29 | save: vi.fn(async (tabId: string) => ({ |
| 26 | return { sourceId: `source:${tabId}`, width: 1000, height: 620 }; | 30 | id: "recording-test", |
| 27 | }), | 31 | tabId, |
| 28 | stopScreencast: vi.fn(async () => undefined), | 32 | path: "/tmp/recording-test.webm", |
| 29 | }; | 33 | mimeType: "video/webm" as const, |
| 30 | }); | 34 | sizeBytes: 0, |
| 35 | createdAt: "2026-06-26T00:00:00.000Z", | ||
| 36 | })), | ||
| 37 | startScreencast: vi.fn(async (_tabId: string) => { | ||
| 38 | events.push("start-screencast"); | ||
| 39 | }), | ||
| 40 | stopScreencast: vi.fn(async () => undefined), | ||
| 41 | }; | ||
| 42 | }); | ||
| 31 | 43 | ||
| 32 | vi.mock("~/components/preview/previewBridge", () => ({ | 44 | vi.mock("~/components/preview/previewBridge", () => ({ |
| 33 | previewBridge: { | 45 | previewBridge: { |
| 34 | recording: { onFrame: vi.fn(), save, startScreencast, stopScreencast }, | 46 | recording: { |
| 47 | onFrame: vi.fn(), | ||
| 48 | save, | ||
| 49 | startScreencast: async (tabId: string) => { | ||
| 50 | await startScreencast(tabId); | ||
| 51 | requestDisplayMediaCapture(tabId); | ||
| 52 | }, | ||
| 53 | stopScreencast, | ||
| 54 | }, | ||
| 35 | }, | 55 | }, |
| 36 | })); | 56 | })); |
| 37 | 57 | ||
| ... | @@ -50,6 +70,7 @@ import { | ... | @@ -50,6 +70,7 @@ import { |
| 50 | BrowserRecordingCaptureTimeoutError, | 70 | BrowserRecordingCaptureTimeoutError, |
| 51 | BrowserRecordingConflictError, | 71 | BrowserRecordingConflictError, |
| 52 | BrowserRecordingFormatUnavailableError, | 72 | BrowserRecordingFormatUnavailableError, |
| 73 | BrowserRecordingStartCancelledError, | ||
| 53 | findActiveBrowserRecordingRuntimeTabId, | 74 | findActiveBrowserRecordingRuntimeTabId, |
| 54 | readActiveBrowserRecordingTabIds, | 75 | readActiveBrowserRecordingTabIds, |
| 55 | readActiveBrowserRecordingTargets, | 76 | readActiveBrowserRecordingTargets, |
| ... | @@ -122,10 +143,16 @@ describe("browser recording", () => { | ... | @@ -122,10 +143,16 @@ describe("browser recording", () => { |
| 122 | }); | 143 | }); |
| 123 | vi.stubGlobal("cancelAnimationFrame", vi.fn()); | 144 | vi.stubGlobal("cancelAnimationFrame", vi.fn()); |
| 124 | vi.stubGlobal("MediaRecorder", FakeMediaRecorder as unknown as typeof MediaRecorder); | 145 | vi.stubGlobal("MediaRecorder", FakeMediaRecorder as unknown as typeof MediaRecorder); |
| 125 | getUserMedia.mockResolvedValue({ | 146 | getDisplayMedia.mockResolvedValue({ |
| 126 | getTracks: () => [{ stop: vi.fn() }], | 147 | getTracks: () => [{ stop: vi.fn() }], |
| 127 | }); | 148 | }); |
| 128 | vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } }); | 149 | requestDisplayMediaCapture.mockImplementation((tabId: string) => { |
| 150 | const trigger = Reflect.get(globalThis, DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER); | ||
| 151 | if (typeof trigger !== "function" || trigger(tabId) !== true) { | ||
| 152 | throw new Error(`No pending display-media capture for ${tabId}.`); | ||
| 153 | } | ||
| 154 | }); | ||
| 155 | vi.stubGlobal("navigator", { mediaDevices: { getDisplayMedia } }); | ||
| 129 | useBrowserSurfaceStore.setState({ activityByTabId: {}, byTabId: {} }); | 156 | useBrowserSurfaceStore.setState({ activityByTabId: {}, byTabId: {} }); |
| 130 | }); | 157 | }); |
| 131 | 158 | ||
| ... | @@ -142,13 +169,20 @@ describe("browser recording", () => { | ... | @@ -142,13 +169,20 @@ describe("browser recording", () => { |
| 142 | expect(startupEvents).toEqual(["publish:recording-tab", "start-screencast"]); | 169 | expect(startupEvents).toEqual(["publish:recording-tab", "start-screencast"]); |
| 143 | }); | 170 | }); |
| 144 | 171 | ||
| 172 | it("routes gesture-free starts through the desktop capture trigger", async () => { | ||
| 173 | await startBrowserRecording("automation-recording-tab"); | ||
| 174 | |||
| 175 | expect(requestDisplayMediaCapture).toHaveBeenCalledWith("automation-recording-tab"); | ||
| 176 | expect(getDisplayMedia).toHaveBeenCalledOnce(); | ||
| 177 | await stopBrowserRecording("automation-recording-tab"); | ||
| 178 | }); | ||
| 179 | |||
| 145 | it("paints and holds a hidden browser surface for the recording lifetime", async () => { | 180 | it("paints and holds a hidden browser surface for the recording lifetime", async () => { |
| 146 | startScreencast.mockImplementationOnce(async (tabId: string) => { | 181 | startScreencast.mockImplementationOnce(async (tabId: string) => { |
| 147 | expect(animationFrameCount).toBe(2); | 182 | expect(animationFrameCount).toBe(2); |
| 148 | expect(useBrowserSurfaceStore.getState().activityByTabId[tabId]).toBe(1); | 183 | expect(useBrowserSurfaceStore.getState().activityByTabId[tabId]).toBe(1); |
| 149 | return { sourceId: `source:${tabId}`, width: 1000, height: 620 }; | ||
| 150 | }); | 184 | }); |
| 151 | getUserMedia.mockImplementationOnce(async () => { | 185 | getDisplayMedia.mockImplementationOnce(async () => { |
| 152 | expect(animationFrameCount).toBe(2); | 186 | expect(animationFrameCount).toBe(2); |
| 153 | expect(useBrowserSurfaceStore.getState().activityByTabId["background-tab"]).toBe(1); | 187 | expect(useBrowserSurfaceStore.getState().activityByTabId["background-tab"]).toBe(1); |
| 154 | return { getTracks: () => [{ stop: vi.fn() }] }; | 188 | return { getTracks: () => [{ stop: vi.fn() }] }; |
| ... | @@ -178,26 +212,16 @@ describe("browser recording", () => { | ... | @@ -178,26 +212,16 @@ describe("browser recording", () => { |
| 178 | await stopBrowserRecording("hidden-window-tab"); | 212 | await stopBrowserRecording("hidden-window-tab"); |
| 179 | }); | 213 | }); |
| 180 | 214 | ||
| 181 | it("records the native tab stream at the preview's current dimensions", async () => { | 215 | it("records the native tab stream armed by the main process", async () => { |
| 182 | const stopTrack = vi.fn(); | 216 | const stopTrack = vi.fn(); |
| 183 | const stream = { getTracks: () => [{ stop: stopTrack }] } as unknown as MediaStream; | 217 | const stream = { getTracks: () => [{ stop: stopTrack }] } as unknown as MediaStream; |
| 184 | getUserMedia.mockResolvedValueOnce(stream); | 218 | getDisplayMedia.mockResolvedValueOnce(stream); |
| 185 | 219 | ||
| 186 | await startBrowserRecording("recording-tab"); | 220 | await startBrowserRecording("recording-tab"); |
| 187 | 221 | ||
| 188 | expect(getUserMedia).toHaveBeenCalledWith({ | 222 | expect(getDisplayMedia).toHaveBeenCalledWith({ |
| 189 | audio: false, | 223 | audio: false, |
| 190 | video: { | 224 | video: { frameRate: { max: 30 } }, |
| 191 | mandatory: { | ||
| 192 | chromeMediaSource: "tab", | ||
| 193 | chromeMediaSourceId: "source:recording-tab", | ||
| 194 | minWidth: 1000, | ||
| 195 | maxWidth: 1000, | ||
| 196 | minHeight: 620, | ||
| 197 | maxHeight: 620, | ||
| 198 | maxFrameRate: 30, | ||
| 199 | }, | ||
| 200 | }, | ||
| 201 | }); | 225 | }); |
| 202 | expect(FakeMediaRecorder.instances[0]?.stream).toBe(stream); | 226 | expect(FakeMediaRecorder.instances[0]?.stream).toBe(stream); |
| 203 | 227 | ||
| ... | @@ -210,18 +234,16 @@ describe("browser recording", () => { | ... | @@ -210,18 +234,16 @@ describe("browser recording", () => { |
| 210 | 234 | ||
| 211 | await startBrowserRecording("recording-tab"); | 235 | await startBrowserRecording("recording-tab"); |
| 212 | 236 | ||
| 213 | expect(getUserMedia).toHaveBeenCalledWith({ | 237 | expect(getDisplayMedia).toHaveBeenCalledWith({ |
| 214 | audio: false, | 238 | audio: false, |
| 215 | video: { | 239 | video: { frameRate: { max: 60 } }, |
| 216 | mandatory: expect.objectContaining({ maxFrameRate: 60 }), | ||
| 217 | }, | ||
| 218 | }); | 240 | }); |
| 219 | await stopBrowserRecording("recording-tab"); | 241 | await stopBrowserRecording("recording-tab"); |
| 220 | }); | 242 | }); |
| 221 | 243 | ||
| 222 | it("stops the native stream when MediaRecorder cleanup fails", async () => { | 244 | it("stops the native stream when MediaRecorder cleanup fails", async () => { |
| 223 | const stopTrack = vi.fn(); | 245 | const stopTrack = vi.fn(); |
| 224 | getUserMedia.mockResolvedValueOnce({ | 246 | getDisplayMedia.mockResolvedValueOnce({ |
| 225 | getTracks: () => [{ stop: stopTrack }], | 247 | getTracks: () => [{ stop: stopTrack }], |
| 226 | }); | 248 | }); |
| 227 | 249 | ||
| ... | @@ -285,7 +307,7 @@ describe("browser recording", () => { | ... | @@ -285,7 +307,7 @@ describe("browser recording", () => { |
| 285 | }); | 307 | }); |
| 286 | 308 | ||
| 287 | it("releases the native capture lease when stream acquisition fails", async () => { | 309 | it("releases the native capture lease when stream acquisition fails", async () => { |
| 288 | getUserMedia.mockRejectedValueOnce(new Error("capture failed")); | 310 | getDisplayMedia.mockRejectedValueOnce(new Error("capture failed")); |
| 289 | 311 | ||
| 290 | await expect(startBrowserRecording("recording-tab")).rejects.toMatchObject({ | 312 | await expect(startBrowserRecording("recording-tab")).rejects.toMatchObject({ |
| 291 | operation: "capture-media-stream", | 313 | operation: "capture-media-stream", |
| ... | @@ -300,7 +322,7 @@ describe("browser recording", () => { | ... | @@ -300,7 +322,7 @@ describe("browser recording", () => { |
| 300 | vi.useFakeTimers(); | 322 | vi.useFakeTimers(); |
| 301 | let finishCapture!: (stream: MediaStream) => void; | 323 | let finishCapture!: (stream: MediaStream) => void; |
| 302 | const stopTrack = vi.fn(); | 324 | const stopTrack = vi.fn(); |
| 303 | getUserMedia.mockImplementationOnce( | 325 | getDisplayMedia.mockImplementationOnce( |
| 304 | () => | 326 | () => |
| 305 | new Promise<MediaStream>((resolve) => { | 327 | new Promise<MediaStream>((resolve) => { |
| 306 | finishCapture = resolve; | 328 | finishCapture = resolve; |
| ... | @@ -308,7 +330,7 @@ describe("browser recording", () => { | ... | @@ -308,7 +330,7 @@ describe("browser recording", () => { |
| 308 | ); | 330 | ); |
| 309 | 331 | ||
| 310 | const startPromise = startBrowserRecording("recording-tab"); | 332 | const startPromise = startBrowserRecording("recording-tab"); |
| 311 | await vi.waitFor(() => expect(getUserMedia).toHaveBeenCalledOnce()); | 333 | await vi.waitFor(() => expect(getDisplayMedia).toHaveBeenCalledOnce()); |
| 312 | const rejection = expect(startPromise).rejects.toMatchObject({ | 334 | const rejection = expect(startPromise).rejects.toMatchObject({ |
| 313 | _tag: "BrowserRecordingCaptureTimeoutError", | 335 | _tag: "BrowserRecordingCaptureTimeoutError", |
| 314 | tabId: "recording-tab", | 336 | tabId: "recording-tab", |
| ... | @@ -356,6 +378,124 @@ describe("browser recording", () => { | ... | @@ -356,6 +378,124 @@ describe("browser recording", () => { |
| 356 | expect(save).toHaveBeenCalledTimes(2); | 378 | expect(save).toHaveBeenCalledTimes(2); |
| 357 | }); | 379 | }); |
| 358 | 380 | ||
| 381 | it("serializes display media grants for concurrent recording starts", async () => { | ||
| 382 | let finishFirstCapture!: (stream: MediaStream) => void; | ||
| 383 | const stream = { getTracks: () => [{ stop: vi.fn() }] } as unknown as MediaStream; | ||
| 384 | getDisplayMedia | ||
| 385 | .mockImplementationOnce( | ||
| 386 | () => | ||
| 387 | new Promise<MediaStream>((resolve) => { | ||
| 388 | finishFirstCapture = resolve; | ||
| 389 | }), | ||
| 390 | ) | ||
| 391 | .mockResolvedValueOnce(stream); | ||
| 392 | |||
| 393 | const firstStart = startBrowserRecording("recording-tab"); | ||
| 394 | await vi.waitFor(() => expect(getDisplayMedia).toHaveBeenCalledOnce()); | ||
| 395 | const secondStart = startBrowserRecording("recording-tab-2"); | ||
| 396 | await vi.waitFor(() => expect(readActiveBrowserRecordingTabIds().size).toBe(2)); | ||
| 397 | |||
| 398 | expect(startScreencast).toHaveBeenCalledTimes(1); | ||
| 399 | finishFirstCapture(stream); | ||
| 400 | await Promise.all([firstStart, secondStart]); | ||
| 401 | |||
| 402 | expect(startScreencast.mock.calls).toEqual([["recording-tab"], ["recording-tab-2"]]); | ||
| 403 | expect(getDisplayMedia).toHaveBeenCalledTimes(2); | ||
| 404 | await Promise.all([ | ||
| 405 | stopBrowserRecording("recording-tab"), | ||
| 406 | stopBrowserRecording("recording-tab-2"), | ||
| 407 | ]); | ||
| 408 | }); | ||
| 409 | |||
| 410 | it("cancels a queued recording when stopped before its media grant", async () => { | ||
| 411 | let finishFirstCapture!: (stream: MediaStream) => void; | ||
| 412 | const stream = { getTracks: () => [{ stop: vi.fn() }] } as unknown as MediaStream; | ||
| 413 | getDisplayMedia.mockImplementationOnce( | ||
| 414 | () => | ||
| 415 | new Promise<MediaStream>((resolve) => { | ||
| 416 | finishFirstCapture = resolve; | ||
| 417 | }), | ||
| 418 | ); | ||
| 419 | |||
| 420 | const firstStart = startBrowserRecording("recording-tab"); | ||
| 421 | await vi.waitFor(() => expect(getDisplayMedia).toHaveBeenCalledOnce()); | ||
| 422 | const secondStart = startBrowserRecording("recording-tab-2"); | ||
| 423 | await vi.waitFor(() => expect(readActiveBrowserRecordingTabIds().size).toBe(2)); | ||
| 424 | |||
| 425 | const secondStop = stopBrowserRecording("recording-tab-2"); | ||
| 426 | await expect(secondStart).rejects.toBeInstanceOf(BrowserRecordingStartCancelledError); | ||
| 427 | await expect(secondStop).resolves.toBeNull(); | ||
| 428 | expect(startScreencast).toHaveBeenCalledTimes(1); | ||
| 429 | |||
| 430 | finishFirstCapture(stream); | ||
| 431 | await firstStart; | ||
| 432 | await stopBrowserRecording("recording-tab"); | ||
| 433 | expect(getDisplayMedia).toHaveBeenCalledOnce(); | ||
| 434 | }); | ||
| 435 | |||
| 436 | it("latches a stop that arrives before the start becomes queued", async () => { | ||
| 437 | let releaseDelayedPaint!: (timestamp: number) => void; | ||
| 438 | let frameId = 0; | ||
| 439 | vi.stubGlobal( | ||
| 440 | "requestAnimationFrame", | ||
| 441 | vi.fn((callback: FrameRequestCallback) => { | ||
| 442 | frameId += 1; | ||
| 443 | if (frameId === 1) releaseDelayedPaint = callback; | ||
| 444 | else callback(frameId); | ||
| 445 | return frameId; | ||
| 446 | }), | ||
| 447 | ); | ||
| 448 | let finishBlockingCapture!: (stream: MediaStream) => void; | ||
| 449 | const stream = { getTracks: () => [{ stop: vi.fn() }] } as unknown as MediaStream; | ||
| 450 | getDisplayMedia.mockImplementationOnce( | ||
| 451 | () => | ||
| 452 | new Promise<MediaStream>((resolve) => { | ||
| 453 | finishBlockingCapture = resolve; | ||
| 454 | }), | ||
| 455 | ); | ||
| 456 | |||
| 457 | const delayedStart = startBrowserRecording("delayed-tab"); | ||
| 458 | await vi.waitFor(() => | ||
| 459 | expect(readActiveBrowserRecordingTabIds().has("delayed-tab")).toBe(true), | ||
| 460 | ); | ||
| 461 | const blockingStart = startBrowserRecording("blocking-tab"); | ||
| 462 | await vi.waitFor(() => expect(getDisplayMedia).toHaveBeenCalledOnce()); | ||
| 463 | |||
| 464 | const delayedStop = stopBrowserRecording("delayed-tab"); | ||
| 465 | releaseDelayedPaint(1); | ||
| 466 | |||
| 467 | await expect(delayedStart).rejects.toBeInstanceOf(BrowserRecordingStartCancelledError); | ||
| 468 | await expect(delayedStop).resolves.toBeNull(); | ||
| 469 | expect(startScreencast).toHaveBeenCalledOnce(); | ||
| 470 | |||
| 471 | finishBlockingCapture(stream); | ||
| 472 | await blockingStart; | ||
| 473 | await stopBrowserRecording("blocking-tab"); | ||
| 474 | }); | ||
| 475 | |||
| 476 | it("finishes an uncontended pre-grant start before stopping", async () => { | ||
| 477 | const animationFrames: FrameRequestCallback[] = []; | ||
| 478 | vi.stubGlobal( | ||
| 479 | "requestAnimationFrame", | ||
| 480 | vi.fn((callback: FrameRequestCallback) => { | ||
| 481 | animationFrames.push(callback); | ||
| 482 | return animationFrames.length; | ||
| 483 | }), | ||
| 484 | ); | ||
| 485 | |||
| 486 | const startPromise = startBrowserRecording("recording-tab"); | ||
| 487 | await vi.waitFor(() => | ||
| 488 | expect(readActiveBrowserRecordingTabIds().has("recording-tab")).toBe(true), | ||
| 489 | ); | ||
| 490 | const stopPromise = stopBrowserRecording("recording-tab"); | ||
| 491 | expect(startScreencast).not.toHaveBeenCalled(); | ||
| 492 | |||
| 493 | animationFrames.shift()?.(1); | ||
| 494 | animationFrames.shift()?.(2); | ||
| 495 | await startPromise; | ||
| 496 | await expect(stopPromise).resolves.toMatchObject({ tabId: "recording-tab" }); | ||
| 497 | }); | ||
| 498 | |||
| 359 | it("keeps a recording reachable through its runtime id after a server epoch changes", async () => { | 499 | it("keeps a recording reachable through its runtime id after a server epoch changes", async () => { |
| 360 | const threadRef = { | 500 | const threadRef = { |
| 361 | environmentId: EnvironmentId.make("environment-recording"), | 501 | environmentId: EnvironmentId.make("environment-recording"), |
| ... | @@ -387,7 +527,6 @@ describe("browser recording", () => { | ... | @@ -387,7 +527,6 @@ describe("browser recording", () => { |
| 387 | await new Promise<void>((resolve) => { | 527 | await new Promise<void>((resolve) => { |
| 388 | finishStartingScreencast = resolve; | 528 | finishStartingScreencast = resolve; |
| 389 | }); | 529 | }); |
| 390 | return { sourceId: "source:recording-tab", width: 1000, height: 620 }; | ||
| 391 | }); | 530 | }); |
| 392 | 531 | ||
| 393 | const firstStart = startBrowserRecording("recording-tab"); | 532 | const firstStart = startBrowserRecording("recording-tab"); |
| ... | @@ -452,7 +591,6 @@ describe("browser recording", () => { | ... | @@ -452,7 +591,6 @@ describe("browser recording", () => { |
| 452 | await new Promise<void>((resolve) => { | 591 | await new Promise<void>((resolve) => { |
| 453 | finishStartingScreencast = resolve; | 592 | finishStartingScreencast = resolve; |
| 454 | }); | 593 | }); |
| 455 | return { sourceId: "source:recording-tab", width: 1000, height: 620 }; | ||
| 456 | }); | 594 | }); |
| 457 | 595 | ||
| 458 | const startPromise = startBrowserRecording("recording-tab"); | 596 | const startPromise = startBrowserRecording("recording-tab"); |
| ... | @@ -476,7 +614,6 @@ describe("browser recording", () => { | ... | @@ -476,7 +614,6 @@ describe("browser recording", () => { |
| 476 | await new Promise<void>((resolve) => { | 614 | await new Promise<void>((resolve) => { |
| 477 | finishStartingScreencast = resolve; | 615 | finishStartingScreencast = resolve; |
| 478 | }); | 616 | }); |
| 479 | return { sourceId: "source:recording-tab", width: 1000, height: 620 }; | ||
| 480 | }); | 617 | }); |
| 481 | 618 | ||
| 482 | const firstStart = startBrowserRecording("recording-tab"); | 619 | const firstStart = startBrowserRecording("recording-tab"); |
| ... | @@ -503,7 +640,6 @@ describe("browser recording", () => { | ... | @@ -503,7 +640,6 @@ describe("browser recording", () => { |
| 503 | await new Promise<void>((resolve) => { | 640 | await new Promise<void>((resolve) => { |
| 504 | finishStartingScreencast = resolve; | 641 | finishStartingScreencast = resolve; |
| 505 | }); | 642 | }); |
| 506 | return { sourceId: "source:recording-tab", width: 1000, height: 620 }; | ||
| 507 | }); | 643 | }); |
| 508 | stopScreencast.mockRejectedValueOnce(new Error("initial stop failed")); | 644 | stopScreencast.mockRejectedValueOnce(new Error("initial stop failed")); |
| 509 | 645 | ||
| ... | @@ -537,7 +673,6 @@ describe("browser recording", () => { | ... | @@ -537,7 +673,6 @@ describe("browser recording", () => { |
| 537 | await new Promise<void>((resolve) => { | 673 | await new Promise<void>((resolve) => { |
| 538 | finishStartingScreencast = resolve; | 674 | finishStartingScreencast = resolve; |
| 539 | }); | 675 | }); |
| 540 | return { sourceId: "source:recording-tab", width: 1000, height: 620 }; | ||
| 541 | }); | 676 | }); |
| 542 | 677 | ||
| 543 | const startPromise = startBrowserRecording("recording-tab"); | 678 | const startPromise = startBrowserRecording("recording-tab"); |
apps/web/src/browser/browserRecording.ts+204-62| ... | @@ -1,8 +1,5 @@ | ... | @@ -1,8 +1,5 @@ |
| 1 | import type { | 1 | import { DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER } from "@t3tools/contracts"; |
| 2 | DesktopPreviewRecordingArtifact, | 2 | import type { DesktopPreviewRecordingArtifact, ScopedThreadRef } from "@t3tools/contracts"; |
| 3 | DesktopPreviewRecordingSource, | ||
| 4 | ScopedThreadRef, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import { useAtomValue } from "@effect/atom-react"; | 3 | import { useAtomValue } from "@effect/atom-react"; |
| 7 | import * as Schema from "effect/Schema"; | 4 | import * as Schema from "effect/Schema"; |
| 8 | import { Atom } from "effect/unstable/reactivity"; | 5 | import { Atom } from "effect/unstable/reactivity"; |
| ... | @@ -36,6 +33,17 @@ export class BrowserRecordingConflictError extends Schema.TaggedErrorClass<Brows | ... | @@ -36,6 +33,17 @@ export class BrowserRecordingConflictError extends Schema.TaggedErrorClass<Brows |
| 36 | } | 33 | } |
| 37 | } | 34 | } |
| 38 | 35 | ||
| 36 | export class BrowserRecordingStartCancelledError extends Schema.TaggedErrorClass<BrowserRecordingStartCancelledError>()( | ||
| 37 | "BrowserRecordingStartCancelledError", | ||
| 38 | { | ||
| 39 | tabId: Schema.String, | ||
| 40 | }, | ||
| 41 | ) { | ||
| 42 | override get message(): string { | ||
| 43 | return `Browser recording start was cancelled for tab ${this.tabId}.`; | ||
| 44 | } | ||
| 45 | } | ||
| 46 | |||
| 39 | export class BrowserRecordingFormatUnavailableError extends Schema.TaggedErrorClass<BrowserRecordingFormatUnavailableError>()( | 47 | export class BrowserRecordingFormatUnavailableError extends Schema.TaggedErrorClass<BrowserRecordingFormatUnavailableError>()( |
| 40 | "BrowserRecordingFormatUnavailableError", | 48 | "BrowserRecordingFormatUnavailableError", |
| 41 | { tabId: Schema.String }, | 49 | { tabId: Schema.String }, |
| ... | @@ -82,9 +90,21 @@ export class BrowserRecordingOperationError extends Schema.TaggedErrorClass<Brow | ... | @@ -82,9 +90,21 @@ export class BrowserRecordingOperationError extends Schema.TaggedErrorClass<Brow |
| 82 | 90 | ||
| 83 | const isBrowserRecordingOperationError = Schema.is(BrowserRecordingOperationError); | 91 | const isBrowserRecordingOperationError = Schema.is(BrowserRecordingOperationError); |
| 84 | const isBrowserRecordingCaptureTimeoutError = Schema.is(BrowserRecordingCaptureTimeoutError); | 92 | const isBrowserRecordingCaptureTimeoutError = Schema.is(BrowserRecordingCaptureTimeoutError); |
| 93 | export const isBrowserRecordingStartCancelledError = Schema.is(BrowserRecordingStartCancelledError); | ||
| 94 | |||
| 95 | interface StartingBrowserRecordingLifecycle { | ||
| 96 | readonly phase: "starting"; | ||
| 97 | queuedForGrant: boolean | null; | ||
| 98 | grantStarted: boolean; | ||
| 99 | stopRequestedBeforeGrant: boolean; | ||
| 100 | cancelledBeforeGrant: boolean; | ||
| 101 | readonly cancelledBeforeGrantSignal: Promise<void>; | ||
| 102 | readonly cancelBeforeGrant: () => void; | ||
| 103 | readonly setQueuedForGrant: (queued: boolean) => void; | ||
| 104 | } | ||
| 85 | 105 | ||
| 86 | type BrowserRecordingLifecycle = | 106 | type BrowserRecordingLifecycle = |
| 87 | | { readonly phase: "starting" } | 107 | | StartingBrowserRecordingLifecycle |
| 88 | | { readonly phase: "recording" } | 108 | | { readonly phase: "recording" } |
| 89 | | { | 109 | | { |
| 90 | readonly phase: "stopping"; | 110 | readonly phase: "stopping"; |
| ... | @@ -124,6 +144,53 @@ export function useActiveBrowserRecordingTabIds(): ReadonlySet<string> { | ... | @@ -124,6 +144,53 @@ export function useActiveBrowserRecordingTabIds(): ReadonlySet<string> { |
| 124 | } | 144 | } |
| 125 | 145 | ||
| 126 | const activeRecordings = new Map<string, ActiveRecording>(); | 146 | const activeRecordings = new Map<string, ActiveRecording>(); |
| 147 | let displayMediaGrantTail = Promise.resolve(); | ||
| 148 | let displayMediaGrantQueueDepth = 0; | ||
| 149 | |||
| 150 | const makeStartingBrowserRecordingLifecycle = (): StartingBrowserRecordingLifecycle => { | ||
| 151 | let signalCancellation!: () => void; | ||
| 152 | const cancelledBeforeGrantSignal = new Promise<void>((resolve) => { | ||
| 153 | signalCancellation = resolve; | ||
| 154 | }); | ||
| 155 | const lifecycle: StartingBrowserRecordingLifecycle = { | ||
| 156 | phase: "starting", | ||
| 157 | queuedForGrant: null, | ||
| 158 | grantStarted: false, | ||
| 159 | stopRequestedBeforeGrant: false, | ||
| 160 | cancelledBeforeGrant: false, | ||
| 161 | cancelledBeforeGrantSignal, | ||
| 162 | cancelBeforeGrant: () => { | ||
| 163 | // Queue position is unknown during paint/settings warmup. Keep the stop request so a start | ||
| 164 | // that later turns out to be contended can still be cancelled before native capture. | ||
| 165 | lifecycle.stopRequestedBeforeGrant = true; | ||
| 166 | if (lifecycle.queuedForGrant && !lifecycle.grantStarted && !lifecycle.cancelledBeforeGrant) { | ||
| 167 | lifecycle.cancelledBeforeGrant = true; | ||
| 168 | signalCancellation(); | ||
| 169 | } | ||
| 170 | }, | ||
| 171 | setQueuedForGrant: (queued) => { | ||
| 172 | lifecycle.queuedForGrant = queued; | ||
| 173 | if (queued && lifecycle.stopRequestedBeforeGrant) lifecycle.cancelBeforeGrant(); | ||
| 174 | }, | ||
| 175 | }; | ||
| 176 | return lifecycle; | ||
| 177 | }; | ||
| 178 | |||
| 179 | const queueDisplayMediaGrant = <T>( | ||
| 180 | useGrant: () => Promise<T>, | ||
| 181 | ): { readonly queued: boolean; readonly result: Promise<T> } => { | ||
| 182 | const queued = displayMediaGrantQueueDepth > 0; | ||
| 183 | displayMediaGrantQueueDepth += 1; | ||
| 184 | const result = displayMediaGrantTail.then(useGrant); | ||
| 185 | const settleGrant = () => { | ||
| 186 | displayMediaGrantQueueDepth -= 1; | ||
| 187 | }; | ||
| 188 | displayMediaGrantTail = result.then( | ||
| 189 | () => settleGrant(), | ||
| 190 | () => settleGrant(), | ||
| 191 | ); | ||
| 192 | return { queued, result }; | ||
| 193 | }; | ||
| 127 | 194 | ||
| 128 | const publishActiveRecordingTabIds = (): void => { | 195 | const publishActiveRecordingTabIds = (): void => { |
| 129 | appAtomRegistry.set(activeBrowserRecordingTabIdsAtom, { | 196 | appAtomRegistry.set(activeBrowserRecordingTabIdsAtom, { |
| ... | @@ -184,23 +251,12 @@ const createMediaRecorder = (stream: MediaStream): MediaRecorder => { | ... | @@ -184,23 +251,12 @@ const createMediaRecorder = (stream: MediaStream): MediaRecorder => { |
| 184 | return mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream); | 251 | return mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream); |
| 185 | }; | 252 | }; |
| 186 | 253 | ||
| 187 | const captureTabMediaStream = ( | 254 | const captureTabMediaStream = (frameRate: number): Promise<MediaStream> => |
| 188 | source: DesktopPreviewRecordingSource, | 255 | // The desktop main process routes this request to the tab that `startScreencast` armed, so the |
| 189 | frameRate: number, | 256 | // stream already arrives at that tab's native size and needs no source or dimension constraints. |
| 190 | ): Promise<MediaStream> => | 257 | navigator.mediaDevices.getDisplayMedia({ |
| 191 | navigator.mediaDevices.getUserMedia({ | ||
| 192 | audio: false, | 258 | audio: false, |
| 193 | video: { | 259 | video: { frameRate: { max: frameRate } }, |
| 194 | mandatory: { | ||
| 195 | chromeMediaSource: "tab", | ||
| 196 | chromeMediaSourceId: source.sourceId, | ||
| 197 | minWidth: source.width, | ||
| 198 | maxWidth: source.width, | ||
| 199 | minHeight: source.height, | ||
| 200 | maxHeight: source.height, | ||
| 201 | maxFrameRate: frameRate, | ||
| 202 | }, | ||
| 203 | } as unknown as MediaTrackConstraints, | ||
| 204 | }); | 260 | }); |
| 205 | 261 | ||
| 206 | const stopMediaRecorder = async (recorder: MediaRecorder | null): Promise<void> => { | 262 | const stopMediaRecorder = async (recorder: MediaRecorder | null): Promise<void> => { |
| ... | @@ -216,14 +272,75 @@ const stopMediaStream = (stream: MediaStream | null): void => { | ... | @@ -216,14 +272,75 @@ const stopMediaStream = (stream: MediaStream | null): void => { |
| 216 | for (const track of stream?.getTracks() ?? []) track.stop(); | 272 | for (const track of stream?.getTracks() ?? []) track.stop(); |
| 217 | }; | 273 | }; |
| 218 | 274 | ||
| 275 | interface PendingTabMediaCapture { | ||
| 276 | readonly start: () => void; | ||
| 277 | } | ||
| 278 | |||
| 279 | const pendingTabMediaCaptures = new Map<string, PendingTabMediaCapture>(); | ||
| 280 | |||
| 281 | const prepareTabMediaCapture = (tabId: string, frameRate: number) => { | ||
| 282 | let acceptStream = true; | ||
| 283 | let capturedStream: MediaStream | null = null; | ||
| 284 | let resolveCapture!: (stream: MediaStream | PromiseLike<MediaStream>) => void; | ||
| 285 | let rejectCapture!: (cause: unknown) => void; | ||
| 286 | const capturePromise = new Promise<MediaStream>((resolve, reject) => { | ||
| 287 | resolveCapture = resolve; | ||
| 288 | rejectCapture = reject; | ||
| 289 | }).then((stream) => { | ||
| 290 | capturedStream = stream; | ||
| 291 | if (!acceptStream) { | ||
| 292 | stopMediaStream(stream); | ||
| 293 | capturedStream = null; | ||
| 294 | } | ||
| 295 | return stream; | ||
| 296 | }); | ||
| 297 | const pending: PendingTabMediaCapture = { | ||
| 298 | start: () => { | ||
| 299 | try { | ||
| 300 | // Electron invokes this callback through executeJavaScript(..., true), so even automated | ||
| 301 | // and delayed queued starts satisfy getDisplayMedia's transient-activation requirement. | ||
| 302 | resolveCapture(captureTabMediaStream(frameRate)); | ||
| 303 | } catch (cause) { | ||
| 304 | rejectCapture(cause); | ||
| 305 | } | ||
| 306 | }, | ||
| 307 | }; | ||
| 308 | pendingTabMediaCaptures.set(tabId, pending); | ||
| 309 | return { | ||
| 310 | capturePromise, | ||
| 311 | cancel: () => { | ||
| 312 | acceptStream = false; | ||
| 313 | if (capturedStream) { | ||
| 314 | stopMediaStream(capturedStream); | ||
| 315 | capturedStream = null; | ||
| 316 | } | ||
| 317 | if (pendingTabMediaCaptures.get(tabId) === pending) pendingTabMediaCaptures.delete(tabId); | ||
| 318 | void capturePromise.catch(() => undefined); | ||
| 319 | }, | ||
| 320 | }; | ||
| 321 | }; | ||
| 322 | |||
| 323 | const triggerTabMediaCapture = (tabId: unknown): boolean => { | ||
| 324 | if (typeof tabId !== "string") return false; | ||
| 325 | const pending = pendingTabMediaCaptures.get(tabId); | ||
| 326 | if (!pending) return false; | ||
| 327 | pendingTabMediaCaptures.delete(tabId); | ||
| 328 | pending.start(); | ||
| 329 | return true; | ||
| 330 | }; | ||
| 331 | |||
| 332 | Object.defineProperty(globalThis, DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER, { | ||
| 333 | configurable: true, | ||
| 334 | value: triggerTabMediaCapture, | ||
| 335 | }); | ||
| 336 | |||
| 219 | const captureTabMediaStreamWithTimeout = async ( | 337 | const captureTabMediaStreamWithTimeout = async ( |
| 220 | tabId: string, | 338 | tabId: string, |
| 221 | source: DesktopPreviewRecordingSource, | 339 | capturePromise: Promise<MediaStream>, |
| 222 | frameRate: number, | ||
| 223 | ): Promise<MediaStream> => { | 340 | ): Promise<MediaStream> => { |
| 224 | let acceptStream = true; | 341 | let acceptStream = true; |
| 225 | let timeoutId: number | null = null; | 342 | let timeoutId: number | null = null; |
| 226 | const streamPromise = captureTabMediaStream(source, frameRate).then((stream) => { | 343 | const streamPromise = capturePromise.then((stream) => { |
| 227 | if (!acceptStream) stopMediaStream(stream); | 344 | if (!acceptStream) stopMediaStream(stream); |
| 228 | return stream; | 345 | return stream; |
| 229 | }); | 346 | }); |
| ... | @@ -382,6 +499,7 @@ export async function startBrowserRecording( | ... | @@ -382,6 +499,7 @@ export async function startBrowserRecording( |
| 382 | const startupSettled = new Promise<void>((resolve) => { | 499 | const startupSettled = new Promise<void>((resolve) => { |
| 383 | settleStartup = resolve; | 500 | settleStartup = resolve; |
| 384 | }); | 501 | }); |
| 502 | const startingLifecycle = makeStartingBrowserRecordingLifecycle(); | ||
| 385 | const releaseSurfaceActivity = acquireBrowserSurfaceActivity(tabId); | 503 | const releaseSurfaceActivity = acquireBrowserSurfaceActivity(tabId); |
| 386 | const recording: ActiveRecording = { | 504 | const recording: ActiveRecording = { |
| 387 | tabId, | 505 | tabId, |
| ... | @@ -393,7 +511,7 @@ export async function startBrowserRecording( | ... | @@ -393,7 +511,7 @@ export async function startBrowserRecording( |
| 393 | releaseSurfaceActivity, | 511 | releaseSurfaceActivity, |
| 394 | stream: null, | 512 | stream: null, |
| 395 | recorder: null, | 513 | recorder: null, |
| 396 | lifecycle: { phase: "starting" }, | 514 | lifecycle: startingLifecycle, |
| 397 | }; | 515 | }; |
| 398 | activeRecordings.set(tabId, recording); | 516 | activeRecordings.set(tabId, recording); |
| 399 | publishActiveRecordingTabIds(); | 517 | publishActiveRecordingTabIds(); |
| ... | @@ -402,23 +520,9 @@ export async function startBrowserRecording( | ... | @@ -402,23 +520,9 @@ export async function startBrowserRecording( |
| 402 | () => getClientSettings().browserRecordingFrameRate, | 520 | () => getClientSettings().browserRecordingFrameRate, |
| 403 | ); | 521 | ); |
| 404 | const [frameRate] = await Promise.all([frameRatePromise, waitForBrowserRecordingPaint()]); | 522 | const [frameRate] = await Promise.all([frameRatePromise, waitForBrowserRecordingPaint()]); |
| 405 | let source: DesktopPreviewRecordingSource; | ||
| 406 | try { | ||
| 407 | source = await bridge.recording.startScreencast(tabId); | ||
| 408 | } catch (cause) { | ||
| 409 | if (!isRecordingStarting(recording)) { | ||
| 410 | throw recordingStartupCancelledError(recording, cause); | ||
| 411 | } | ||
| 412 | clearActiveRecording(recording); | ||
| 413 | throw new BrowserRecordingOperationError({ | ||
| 414 | operation: "start-screencast", | ||
| 415 | tabId, | ||
| 416 | cause, | ||
| 417 | }); | ||
| 418 | } | ||
| 419 | const throwIfStartupCancelled = async (): Promise<void> => { | 523 | const throwIfStartupCancelled = async (): Promise<void> => { |
| 420 | // A stop requested during startup should let startup finish so the | 524 | // Once a grant starts, a stop lets startup finish so the caller receives an artifact. |
| 421 | // caller receives a real artifact. Only replacement/removal cancels it. | 525 | // Only a contended start can be cancelled before it reaches native capture. |
| 422 | if (activeRecordings.get(tabId) === recording) return; | 526 | if (activeRecordings.get(tabId) === recording) return; |
| 423 | try { | 527 | try { |
| 424 | await bridge.recording.stopScreencast(tabId); | 528 | await bridge.recording.stopScreencast(tabId); |
| ... | @@ -434,30 +538,67 @@ export async function startBrowserRecording( | ... | @@ -434,30 +538,67 @@ export async function startBrowserRecording( |
| 434 | } | 538 | } |
| 435 | throw recordingStartupCancelledError(recording); | 539 | throw recordingStartupCancelledError(recording); |
| 436 | }; | 540 | }; |
| 437 | await throwIfStartupCancelled(); | 541 | // The desktop process exposes one display-media grant at a time. Keep only the |
| 438 | try { | 542 | // arm-to-capture handoff exclusive; acquired streams can record concurrently. |
| 439 | recording.stream = await captureTabMediaStreamWithTimeout(tabId, source, frameRate); | 543 | const grant = queueDisplayMediaGrant(async () => { |
| 440 | } catch (cause) { | 544 | if (startingLifecycle.cancelledBeforeGrant) { |
| 441 | const cleanupCause = await cleanupFailedRecordingStart(bridge, recording); | 545 | throw new BrowserRecordingStartCancelledError({ tabId }); |
| 442 | if (isBrowserRecordingCaptureTimeoutError(cause) && cleanupCause === undefined) throw cause; | 546 | } |
| 443 | throw new BrowserRecordingOperationError({ | 547 | startingLifecycle.grantStarted = true; |
| 444 | operation: "capture-media-stream", | 548 | await throwIfStartupCancelled(); |
| 445 | tabId, | 549 | const capture = prepareTabMediaCapture(tabId, frameRate); |
| 446 | cause: | 550 | try { |
| 447 | cleanupCause === undefined | 551 | await bridge.recording.startScreencast(tabId); |
| 448 | ? cause | 552 | } catch (cause) { |
| 449 | : new AggregateError( | 553 | capture.cancel(); |
| 450 | [cause, cleanupCause], | 554 | if (!isRecordingStarting(recording)) { |
| 451 | `Browser media capture and cleanup failed for tab ${tabId}.`, | 555 | throw recordingStartupCancelledError(recording, cause); |
| 452 | { cause }, | 556 | } |
| 453 | ), | 557 | clearActiveRecording(recording); |
| 454 | }); | 558 | throw new BrowserRecordingOperationError({ |
| 455 | } | 559 | operation: "start-screencast", |
| 560 | tabId, | ||
| 561 | cause, | ||
| 562 | }); | ||
| 563 | } | ||
| 564 | try { | ||
| 565 | await throwIfStartupCancelled(); | ||
| 566 | } catch (cause) { | ||
| 567 | capture.cancel(); | ||
| 568 | throw cause; | ||
| 569 | } | ||
| 570 | try { | ||
| 571 | recording.stream = await captureTabMediaStreamWithTimeout(tabId, capture.capturePromise); | ||
| 572 | return recording.stream; | ||
| 573 | } catch (cause) { | ||
| 574 | const cleanupCause = await cleanupFailedRecordingStart(bridge, recording); | ||
| 575 | if (isBrowserRecordingCaptureTimeoutError(cause) && cleanupCause === undefined) throw cause; | ||
| 576 | throw new BrowserRecordingOperationError({ | ||
| 577 | operation: "capture-media-stream", | ||
| 578 | tabId, | ||
| 579 | cause: | ||
| 580 | cleanupCause === undefined | ||
| 581 | ? cause | ||
| 582 | : new AggregateError( | ||
| 583 | [cause, cleanupCause], | ||
| 584 | `Browser media capture and cleanup failed for tab ${tabId}.`, | ||
| 585 | { cause }, | ||
| 586 | ), | ||
| 587 | }); | ||
| 588 | } | ||
| 589 | }); | ||
| 590 | startingLifecycle.setQueuedForGrant(grant.queued); | ||
| 591 | const stream = await Promise.race([ | ||
| 592 | grant.result, | ||
| 593 | startingLifecycle.cancelledBeforeGrantSignal.then(() => { | ||
| 594 | throw new BrowserRecordingStartCancelledError({ tabId }); | ||
| 595 | }), | ||
| 596 | ]); | ||
| 456 | await throwIfStartupCancelled(); | 597 | await throwIfStartupCancelled(); |
| 457 | 598 | ||
| 458 | let recorder: MediaRecorder; | 599 | let recorder: MediaRecorder; |
| 459 | try { | 600 | try { |
| 460 | recorder = createMediaRecorder(recording.stream); | 601 | recorder = createMediaRecorder(stream); |
| 461 | recording.recorder = recorder; | 602 | recording.recorder = recorder; |
| 462 | recorder.addEventListener("dataavailable", (event) => { | 603 | recorder.addEventListener("dataavailable", (event) => { |
| 463 | if (event.data.size > 0) chunks.push(event.data); | 604 | if (event.data.size > 0) chunks.push(event.data); |
| ... | @@ -639,6 +780,7 @@ export function stopBrowserRecording( | ... | @@ -639,6 +780,7 @@ export function stopBrowserRecording( |
| 639 | const recording = activeRecordings.get(tabId); | 780 | const recording = activeRecordings.get(tabId); |
| 640 | if (!bridge || !recording) return Promise.resolve(null); | 781 | if (!bridge || !recording) return Promise.resolve(null); |
| 641 | if (recording.lifecycle.phase === "stopping") return recording.lifecycle.stopPromise; | 782 | if (recording.lifecycle.phase === "stopping") return recording.lifecycle.stopPromise; |
| 783 | if (recording.lifecycle.phase === "starting") recording.lifecycle.cancelBeforeGrant(); | ||
| 642 | 784 | ||
| 643 | const stopPromise = Promise.resolve() | 785 | const stopPromise = Promise.resolve() |
| 644 | .then(() => finalizeBrowserRecording(bridge, recording)) | 786 | .then(() => finalizeBrowserRecording(bridge, recording)) |
apps/web/src/browser/hostedBrowserWebviewStyle.test.ts+21-1| ... | @@ -61,7 +61,7 @@ describe("resolveHostedBrowserWebviewWrapperStyle", () => { | ... | @@ -61,7 +61,7 @@ describe("resolveHostedBrowserWebviewWrapperStyle", () => { |
| 61 | }); | 61 | }); |
| 62 | }); | 62 | }); |
| 63 | 63 | ||
| 64 | it("keeps an active background task paintable offscreen", () => { | 64 | it("keeps an active background task paintable behind the app", () => { |
| 65 | const style = resolveHostedBrowserWebviewWrapperStyle({ | 65 | const style = resolveHostedBrowserWebviewWrapperStyle({ |
| 66 | active: false, | 66 | active: false, |
| 67 | renderingActive: true, | 67 | renderingActive: true, |
| ... | @@ -69,6 +69,26 @@ describe("resolveHostedBrowserWebviewWrapperStyle", () => { | ... | @@ -69,6 +69,26 @@ describe("resolveHostedBrowserWebviewWrapperStyle", () => { |
| 69 | hiddenSize: { width: 1280, height: 800 }, | 69 | hiddenSize: { width: 1280, height: 800 }, |
| 70 | }); | 70 | }); |
| 71 | 71 | ||
| 72 | expect(style).toEqual({ | ||
| 73 | left: 0, | ||
| 74 | top: 0, | ||
| 75 | width: 1280, | ||
| 76 | height: 800, | ||
| 77 | zIndex: -1, | ||
| 78 | pointerEvents: "none", | ||
| 79 | visibility: "visible", | ||
| 80 | }); | ||
| 81 | }); | ||
| 82 | |||
| 83 | it("keeps an inactive webview paintable without marking it as rendering-active", () => { | ||
| 84 | const style = resolveHostedBrowserWebviewWrapperStyle({ | ||
| 85 | active: false, | ||
| 86 | renderingActive: false, | ||
| 87 | keepPaintableWhenInactive: true, | ||
| 88 | rect: null, | ||
| 89 | hiddenSize: { width: 1280, height: 800 }, | ||
| 90 | }); | ||
| 91 | |||
| 72 | expect(style).toEqual({ | 92 | expect(style).toEqual({ |
| 73 | left: HIDDEN_BROWSER_WEBVIEW_OFFSET, | 93 | left: HIDDEN_BROWSER_WEBVIEW_OFFSET, |
| 74 | top: HIDDEN_BROWSER_WEBVIEW_OFFSET, | 94 | top: HIDDEN_BROWSER_WEBVIEW_OFFSET, |
apps/web/src/browser/hostedBrowserWebviewStyle.ts+25-2| ... | @@ -21,11 +21,19 @@ export const HIDDEN_BROWSER_WEBVIEW_OFFSET = -100_000; | ... | @@ -21,11 +21,19 @@ export const HIDDEN_BROWSER_WEBVIEW_OFFSET = -100_000; |
| 21 | export function resolveHostedBrowserWebviewWrapperStyle(input: { | 21 | export function resolveHostedBrowserWebviewWrapperStyle(input: { |
| 22 | readonly active: boolean; | 22 | readonly active: boolean; |
| 23 | readonly renderingActive: boolean; | 23 | readonly renderingActive: boolean; |
| 24 | readonly keepPaintableWhenInactive?: boolean; | ||
| 24 | readonly cornerRadius?: number; | 25 | readonly cornerRadius?: number; |
| 25 | readonly rect: BrowserSurfaceRect | null; | 26 | readonly rect: BrowserSurfaceRect | null; |
| 26 | readonly hiddenSize: HostedBrowserWebviewSize; | 27 | readonly hiddenSize: HostedBrowserWebviewSize; |
| 27 | }): HostedBrowserWebviewWrapperStyle { | 28 | }): HostedBrowserWebviewWrapperStyle { |
| 28 | const { active, cornerRadius = 0, hiddenSize, rect, renderingActive } = input; | 29 | const { |
| 30 | active, | ||
| 31 | cornerRadius = 0, | ||
| 32 | hiddenSize, | ||
| 33 | keepPaintableWhenInactive = false, | ||
| 34 | rect, | ||
| 35 | renderingActive, | ||
| 36 | } = input; | ||
| 29 | if (active && rect) { | 37 | if (active && rect) { |
| 30 | return { | 38 | return { |
| 31 | left: rect.x, | 39 | left: rect.x, |
| ... | @@ -38,6 +46,21 @@ export function resolveHostedBrowserWebviewWrapperStyle(input: { | ... | @@ -38,6 +46,21 @@ export function resolveHostedBrowserWebviewWrapperStyle(input: { |
| 38 | }; | 46 | }; |
| 39 | } | 47 | } |
| 40 | 48 | ||
| 49 | if (renderingActive) { | ||
| 50 | // Electron stops compositing a guest that is fully outside the window, even | ||
| 51 | // when background throttling is disabled. Keep capture-active guests inside | ||
| 52 | // the viewport but behind the app so recordings receive complete frames. | ||
| 53 | return { | ||
| 54 | left: 0, | ||
| 55 | top: 0, | ||
| 56 | width: hiddenSize.width, | ||
| 57 | height: hiddenSize.height, | ||
| 58 | zIndex: -1, | ||
| 59 | pointerEvents: "none", | ||
| 60 | visibility: "visible", | ||
| 61 | }; | ||
| 62 | } | ||
| 63 | |||
| 41 | return { | 64 | return { |
| 42 | left: HIDDEN_BROWSER_WEBVIEW_OFFSET, | 65 | left: HIDDEN_BROWSER_WEBVIEW_OFFSET, |
| 43 | top: HIDDEN_BROWSER_WEBVIEW_OFFSET, | 66 | top: HIDDEN_BROWSER_WEBVIEW_OFFSET, |
| ... | @@ -45,6 +68,6 @@ export function resolveHostedBrowserWebviewWrapperStyle(input: { | ... | @@ -45,6 +68,6 @@ export function resolveHostedBrowserWebviewWrapperStyle(input: { |
| 45 | height: hiddenSize.height, | 68 | height: hiddenSize.height, |
| 46 | zIndex: -1, | 69 | zIndex: -1, |
| 47 | pointerEvents: "none", | 70 | pointerEvents: "none", |
| 48 | visibility: renderingActive ? "visible" : "hidden", | 71 | visibility: keepPaintableWhenInactive ? "visible" : "hidden", |
| 49 | }; | 72 | }; |
| 50 | } | 73 | } |
apps/web/src/components/preview/PreviewView.test.tsx+1| ... | @@ -149,6 +149,7 @@ vi.mock("~/state/use-atom-command", () => ({ | ... | @@ -149,6 +149,7 @@ vi.mock("~/state/use-atom-command", () => ({ |
| 149 | 149 | ||
| 150 | vi.mock("~/browser/browserRecording", () => ({ | 150 | vi.mock("~/browser/browserRecording", () => ({ |
| 151 | findActiveBrowserRecordingRuntimeTabId: vi.fn(() => null), | 151 | findActiveBrowserRecordingRuntimeTabId: vi.fn(() => null), |
| 152 | isBrowserRecordingStartCancelledError: vi.fn(() => false), | ||
| 152 | startBrowserRecording: vi.fn(), | 153 | startBrowserRecording: vi.fn(), |
| 153 | stopBrowserRecording: vi.fn(), | 154 | stopBrowserRecording: vi.fn(), |
| 154 | useActiveBrowserRecordingTabIds: () => new Set(), | 155 | useActiveBrowserRecordingTabIds: () => new Set(), |
apps/web/src/components/preview/PreviewView.tsx+4-1| ... | @@ -55,6 +55,7 @@ import { ZoomIndicator } from "./ZoomIndicator"; | ... | @@ -55,6 +55,7 @@ import { ZoomIndicator } from "./ZoomIndicator"; |
| 55 | import { AgentBrowserCursor } from "./AgentBrowserCursor"; | 55 | import { AgentBrowserCursor } from "./AgentBrowserCursor"; |
| 56 | import { | 56 | import { |
| 57 | findActiveBrowserRecordingRuntimeTabId, | 57 | findActiveBrowserRecordingRuntimeTabId, |
| 58 | isBrowserRecordingStartCancelledError, | ||
| 58 | startBrowserRecording, | 59 | startBrowserRecording, |
| 59 | stopBrowserRecording, | 60 | stopBrowserRecording, |
| 60 | useActiveBrowserRecordingTabIds, | 61 | useActiveBrowserRecordingTabIds, |
| ... | @@ -398,10 +399,12 @@ export function PreviewView({ | ... | @@ -398,10 +399,12 @@ export function PreviewView({ |
| 398 | } | 399 | } |
| 399 | if (record) { | 400 | if (record) { |
| 400 | void startBrowserRecording(runtimeTabId, threadRef, tabId).catch((error) => { | 401 | void startBrowserRecording(runtimeTabId, threadRef, tabId).catch((error) => { |
| 402 | const description = error instanceof Error ? error.message : "An error occurred."; | ||
| 403 | if (isBrowserRecordingStartCancelledError(error)) return; | ||
| 401 | toastManager.add({ | 404 | toastManager.add({ |
| 402 | type: "error", | 405 | type: "error", |
| 403 | title: "Unable to start recording", | 406 | title: "Unable to start recording", |
| 404 | description: error instanceof Error ? error.message : "An error occurred.", | 407 | description, |
| 405 | }); | 408 | }); |
| 406 | }); | 409 | }); |
| 407 | return; | 410 | return; |
packages/contracts/src/ipc.ts+4-14| ... | @@ -734,19 +734,6 @@ export const DesktopPreviewRecordingFrameSchema: Schema.Codec<DesktopPreviewReco | ... | @@ -734,19 +734,6 @@ export const DesktopPreviewRecordingFrameSchema: Schema.Codec<DesktopPreviewReco |
| 734 | receivedAt: Schema.String, | 734 | receivedAt: Schema.String, |
| 735 | }); | 735 | }); |
| 736 | 736 | ||
| 737 | export interface DesktopPreviewRecordingSource { | ||
| 738 | sourceId: string; | ||
| 739 | width: number; | ||
| 740 | height: number; | ||
| 741 | } | ||
| 742 | |||
| 743 | export const DesktopPreviewRecordingSourceSchema: Schema.Codec<DesktopPreviewRecordingSource> = | ||
| 744 | Schema.Struct({ | ||
| 745 | sourceId: Schema.String, | ||
| 746 | width: Schema.Int.check(Schema.isGreaterThan(0)), | ||
| 747 | height: Schema.Int.check(Schema.isGreaterThan(0)), | ||
| 748 | }); | ||
| 749 | |||
| 750 | export interface DesktopPreviewRecordingArtifact { | 737 | export interface DesktopPreviewRecordingArtifact { |
| 751 | id: string; | 738 | id: string; |
| 752 | tabId: string; | 739 | tabId: string; |
| ... | @@ -1173,6 +1160,9 @@ export interface DesktopBridge { | ... | @@ -1173,6 +1160,9 @@ export interface DesktopBridge { |
| 1173 | preview?: DesktopPreviewBridge; | 1160 | preview?: DesktopPreviewBridge; |
| 1174 | } | 1161 | } |
| 1175 | 1162 | ||
| 1163 | /** Renderer callback invoked by Electron with a fresh user gesture before display-media capture. */ | ||
| 1164 | export const DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER = "__t3DesktopPreviewRecordingCapture"; | ||
| 1165 | |||
| 1176 | export interface DesktopPreviewBridge { | 1166 | export interface DesktopPreviewBridge { |
| 1177 | createTab: (tabId: string, defaults?: DesktopPreviewTabDefaults) => Promise<void>; | 1167 | createTab: (tabId: string, defaults?: DesktopPreviewTabDefaults) => Promise<void>; |
| 1178 | closeTab: (tabId: string) => Promise<void>; | 1168 | closeTab: (tabId: string) => Promise<void>; |
| ... | @@ -1228,7 +1218,7 @@ export interface DesktopPreviewBridge { | ... | @@ -1228,7 +1218,7 @@ export interface DesktopPreviewBridge { |
| 1228 | close: (tabId: string) => Promise<void>; | 1218 | close: (tabId: string) => Promise<void>; |
| 1229 | }; | 1219 | }; |
| 1230 | recording: { | 1220 | recording: { |
| 1231 | startScreencast: (tabId: string) => Promise<DesktopPreviewRecordingSource>; | 1221 | startScreencast: (tabId: string) => Promise<void>; |
| 1232 | stopScreencast: (tabId: string) => Promise<void>; | 1222 | stopScreencast: (tabId: string) => Promise<void>; |
| 1233 | save: ( | 1223 | save: ( |
| 1234 | tabId: string, | 1224 | tabId: string, |