| 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 | 11 | DesktopPreviewConfigInputSchema, |
| 12 | 12 | DesktopPreviewNavigateInputSchema, |
| 13 | 13 | DesktopPreviewRecordingArtifactSchema, |
| 14 | DesktopPreviewRecordingSourceSchema, | |
| 15 | 14 | DesktopPreviewRecordingSaveInputSchema, |
| 16 | 15 | DesktopPreviewRegisterWebviewInputSchema, |
| 17 | 16 | DesktopPreviewScreenshotArtifactSchema, |
| ... | ... | @@ -174,15 +173,11 @@ export const cancelPickElement = tabMethod( |
| 174 | 173 | "desktop.ipc.preview.cancelPickElement", |
| 175 | 174 | (manager, tabId) => manager.cancelPickElement(tabId), |
| 176 | 175 | ); |
| 177 | export const startRecording = DesktopIpc.makeIpcMethod({ | |
| 178 | channel: IpcChannels.PREVIEW_RECORDING_START_CHANNEL, | |
| 179 | payload: DesktopPreviewTabInputSchema, | |
| 180 | result: DesktopPreviewRecordingSourceSchema, | |
| 181 | handler: Effect.fn("desktop.ipc.preview.startRecording")(function* ({ tabId }) { | |
| 182 | const manager = yield* PreviewManager.PreviewManager; | |
| 183 | return yield* manager.startRecording(tabId); | |
| 184 | }), | |
| 185 | }); | |
| 176 | export const startRecording = tabMethod( | |
| 177 | IpcChannels.PREVIEW_RECORDING_START_CHANNEL, | |
| 178 | "desktop.ipc.preview.startRecording", | |
| 179 | (manager, tabId) => manager.startRecording(tabId), | |
| 180 | ); | |
| 186 | 181 | export const stopRecording = tabMethod( |
| 187 | 182 | IpcChannels.PREVIEW_RECORDING_STOP_CHANNEL, |
| 188 | 183 | "desktop.ipc.preview.stopRecording", |
apps/desktop/src/preview/Manager.test.ts+375-136| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { it as effectIt } from "@effect/vitest"; |
| 2 | import { DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER } from "@t3tools/contracts"; | |
| 2 | 3 | import type { DesktopPreviewRecordingFrame } from "@t3tools/contracts"; |
| 3 | 4 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 4 | 5 | import * as Cause from "effect/Cause"; |
| ... | ... | @@ -123,7 +124,7 @@ const { |
| 123 | 124 | } = vi.hoisted(() => ({ |
| 124 | 125 | browserWindowConstructor: vi.fn(), |
| 125 | 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 | 128 | getFocusedWebContents: vi.fn(() => null), |
| 128 | 129 | mkdir: vi.fn((_path: string) => undefined), |
| 129 | 130 | showItemInFolder: vi.fn(), |
| ... | ... | @@ -209,14 +210,52 @@ interface TestCapturedPreviewImage { |
| 209 | 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 | 249 | const makeTestPreviewWebContents = ( |
| 213 | 250 | capturePage: () => Promise<TestCapturedPreviewImage>, |
| 214 | 251 | id = 42, |
| 215 | ) => | |
| 216 | ({ | |
| 252 | hostWebContents: TestHostWebContents = makeTestHostWebContents(), | |
| 253 | ) => { | |
| 254 | const setBackgroundThrottling = vi.fn<(enabled: boolean) => void>(); | |
| 255 | return { | |
| 217 | 256 | id, |
| 218 | hostWebContents: { id: 7 }, | |
| 219 | getMediaSourceId: vi.fn(() => `tab:${id}`), | |
| 257 | mainFrame: { routingId: id }, | |
| 258 | hostWebContents, | |
| 220 | 259 | executeJavaScript: vi.fn(async () => ({ width: 1280, height: 720 })), |
| 221 | 260 | isDestroyed: () => false, |
| 222 | 261 | getType: () => "webview", |
| ... | ... | @@ -226,6 +265,7 @@ const makeTestPreviewWebContents = ( |
| 226 | 265 | getZoomFactor: () => 1, |
| 227 | 266 | setZoomFactor: vi.fn(), |
| 228 | 267 | setAudioMuted: vi.fn(), |
| 268 | setBackgroundThrottling, | |
| 229 | 269 | isCurrentlyAudible: () => false, |
| 230 | 270 | on: vi.fn(), |
| 231 | 271 | off: vi.fn(), |
| ... | ... | @@ -241,7 +281,45 @@ const makeTestPreviewWebContents = ( |
| 241 | 281 | off: vi.fn(), |
| 242 | 282 | }, |
| 243 | 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 | 324 | const TEST_FAVICON = "data:image/png;base64,cG5n"; |
| 247 | 325 | |
| ... | ... | @@ -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 | 1947 | withManager((manager) => |
| 1870 | 1948 | Effect.gen(function* () { |
| 1871 | 1949 | const setBackgroundThrottling = vi.fn(); |
| ... | ... | @@ -1873,9 +1951,12 @@ describe("PreviewManager", () => { |
| 1873 | 1951 | toJPEG: () => Buffer.from("recording-frame"), |
| 1874 | 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 | 1957 | const webContentsById = new Map([ |
| 1877 | [41, makeTestPreviewWebContents(capturePage, 41)], | |
| 1878 | [42, makeTestPreviewWebContents(capturePage, 42)], | |
| 1958 | [41, firstWebContents], | |
| 1959 | [42, secondWebContents], | |
| 1879 | 1960 | ]); |
| 1880 | 1961 | fromId.mockImplementation((id) => |
| 1881 | 1962 | id === undefined ? null : (webContentsById.get(id) ?? null), |
| ... | ... | @@ -1892,14 +1973,21 @@ describe("PreviewManager", () => { |
| 1892 | 1973 | } as never); |
| 1893 | 1974 | |
| 1894 | 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 | 1978 | yield* manager.startRecording("tab_capture_throttling_2"); |
| 1896 | 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 | 1983 | yield* manager.stopRecording("tab_capture_throttling_1"); |
| 1899 | 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 | 1988 | yield* manager.stopRecording("tab_capture_throttling_2"); |
| 1902 | 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 | 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 | 2086 | effectIt.effect("does not publish a replacement window when capture reconciliation fails", () => |
| 1957 | 2087 | withManager((manager) => |
| 1958 | 2088 | Effect.gen(function* () { |
| ... | ... | @@ -2031,9 +2161,10 @@ describe("PreviewManager", () => { |
| 2031 | 2161 | toJPEG: () => Buffer.from("recording-frame"), |
| 2032 | 2162 | getSize: () => ({ width: 1280, height: 720 }), |
| 2033 | 2163 | })); |
| 2164 | const host = makeTestHostWebContents(); | |
| 2034 | 2165 | const webContentsById = new Map([ |
| 2035 | [42, makeTestPreviewWebContents(capturePage, 42)], | |
| 2036 | [43, makeTestPreviewWebContents(capturePage, 43)], | |
| 2166 | [42, makeTestPreviewWebContents(capturePage, 42, host)], | |
| 2167 | [43, makeTestPreviewWebContents(capturePage, 43, host)], | |
| 2037 | 2168 | ]); |
| 2038 | 2169 | fromId.mockImplementation((id) => |
| 2039 | 2170 | id === undefined ? null : (webContentsById.get(id) ?? null), |
| ... | ... | @@ -2065,6 +2196,10 @@ describe("PreviewManager", () => { |
| 2065 | 2196 | yield* Effect.yieldNow; |
| 2066 | 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 | 2203 | yield* manager.setMainWindow({ |
| 2069 | 2204 | isDestroyed: () => false, |
| 2070 | 2205 | once: vi.fn(), |
| ... | ... | @@ -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 | 2267 | withManager((manager) => |
| 2080 | 2268 | Effect.gen(function* () { |
| 2081 | 2269 | const firstJpeg = Buffer.from("first-recording-frame"); |
| ... | ... | @@ -2090,6 +2278,8 @@ describe("PreviewManager", () => { |
| 2090 | 2278 | })); |
| 2091 | 2279 | const firstSendCommand = vi.fn(async () => undefined); |
| 2092 | 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 | 2283 | const makeWebContents = ( |
| 2094 | 2284 | id: number, |
| 2095 | 2285 | capturePage: typeof firstCapturePage, |
| ... | ... | @@ -2097,8 +2287,8 @@ describe("PreviewManager", () => { |
| 2097 | 2287 | ) => |
| 2098 | 2288 | ({ |
| 2099 | 2289 | id, |
| 2100 | hostWebContents: { id: 7 }, | |
| 2101 | getMediaSourceId: vi.fn(() => `tab:${id}`), | |
| 2290 | mainFrame: { routingId: id }, | |
| 2291 | hostWebContents: host, | |
| 2102 | 2292 | executeJavaScript: vi.fn(async () => |
| 2103 | 2293 | id === 41 ? { width: 800, height: 600 } : { width: 390, height: 844 }, |
| 2104 | 2294 | ), |
| ... | ... | @@ -2110,6 +2300,7 @@ describe("PreviewManager", () => { |
| 2110 | 2300 | getZoomFactor: () => 1, |
| 2111 | 2301 | setZoomFactor: vi.fn(), |
| 2112 | 2302 | setAudioMuted: vi.fn(), |
| 2303 | setBackgroundThrottling: vi.fn(), | |
| 2113 | 2304 | isCurrentlyAudible: () => false, |
| 2114 | 2305 | on: vi.fn(), |
| 2115 | 2306 | off: vi.fn(), |
| ... | ... | @@ -2137,15 +2328,19 @@ describe("PreviewManager", () => { |
| 2137 | 2328 | yield* manager.createTab("tab_2"); |
| 2138 | 2329 | yield* manager.registerWebview("tab_1", 41); |
| 2139 | 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([ | |
| 2146 | { sourceId: "tab:41", width: 800, height: 600 }, | |
| 2147 | { sourceId: "tab:42", width: 390, height: 844 }, | |
| 2148 | ]); | |
| 2332 | const grants: Array<{ video?: unknown }> = []; | |
| 2333 | const takeGrant = () => | |
| 2334 | host.displayMediaHandler()?.({ frame: host.mainFrame }, (value) => { | |
| 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 | 2344 | expect(firstCapturePage).toHaveBeenCalledOnce(); |
| 2150 | 2345 | expect(secondCapturePage).toHaveBeenCalledOnce(); |
| 2151 | 2346 | expect(firstSendCommand).not.toHaveBeenCalledWith( |
| ... | ... | @@ -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 | 2364 | withManager((manager) => |
| 2170 | 2365 | Effect.gen(function* () { |
| 2171 | const capturePage = vi.fn(async () => { | |
| 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); | |
| 2366 | const { host, takeGrant } = yield* setupRecordingRaceTabs(manager); | |
| 2180 | 2367 | |
| 2181 | yield* manager.createTab("tab_recording_warmup_failure"); | |
| 2182 | yield* manager.registerWebview("tab_recording_warmup_failure", 42); | |
| 2368 | yield* manager.startRecording("tab_race_a"); | |
| 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({ | |
| 2185 | sourceId: "tab:42", | |
| 2186 | width: 1280, | |
| 2187 | height: 720, | |
| 2384 | // Runs on the real clock: an earlier queueing design only settled under TestClock and stalled the | |
| 2385 | // losing start forever in the desktop app. | |
| 2386 | effectIt.live("settles both starts when two tabs race for the capture stream", () => | |
| 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 | 2417 | withManager((manager) => |
| 2199 | 2418 | Effect.gen(function* () { |
| 2200 | const capturePage = vi.fn(async () => ({ | |
| 2201 | toJPEG: () => Buffer.from("unused-recording-frame"), | |
| 2202 | getSize: () => ({ width: 1280, height: 720 }), | |
| 2203 | })); | |
| 2204 | const getMediaSourceId = vi.fn(() => "tab:42"); | |
| 2205 | const webContents = Object.assign(makeTestPreviewWebContents(capturePage), { | |
| 2206 | executeJavaScript: vi.fn(async () => ({ width: 0, height: 720 })), | |
| 2207 | getMediaSourceId, | |
| 2419 | const { grants, takeGrant } = yield* setupRecordingRaceTabs(manager); | |
| 2420 | ||
| 2421 | yield* manager.startRecording("tab_race_a"); | |
| 2422 | const blocked = yield* Effect.exit(manager.startRecording("tab_race_b")); | |
| 2423 | if (Exit.isSuccess(blocked)) throw new Error("expected the second tab to be refused"); | |
| 2424 | expect(Option.getOrThrow(Cause.findErrorOption(blocked.cause))).toMatchObject({ | |
| 2425 | _tag: "PreviewRecordingArmConflictError", | |
| 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"); | |
| 2212 | yield* manager.registerWebview("tab_invalid_recording_size", 42); | |
| 2213 | const exit = yield* Effect.exit(manager.startRecording("tab_invalid_recording_size")); | |
| 2430 | // Nothing ever captured the armed tab, so the slot goes stale and stops blocking starts. | |
| 2431 | yield* TestClock.adjust(10_000); | |
| 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); | |
| 2216 | if (Exit.isSuccess(exit)) return; | |
| 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(); | |
| 2436 | yield* manager.stopRecording("tab_race_a"); | |
| 2437 | yield* manager.stopRecording("tab_race_b"); | |
| 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 | 2443 | withManager((manager) => |
| 2229 | 2444 | Effect.gen(function* () { |
| 2230 | const setBackgroundThrottling = vi.fn(); | |
| 2231 | const capturePage = vi.fn(async () => ({ | |
| 2232 | toJPEG: () => Buffer.from("unused-recording-frame"), | |
| 2233 | getSize: () => ({ width: 1280, height: 720 }), | |
| 2234 | })); | |
| 2235 | let markMeasurementStarted!: () => void; | |
| 2236 | const measurementStarted = new Promise<void>((resolve) => { | |
| 2237 | markMeasurementStarted = resolve; | |
| 2238 | }); | |
| 2239 | let rejectFirstMeasurement!: (error: Error) => void; | |
| 2240 | const executeJavaScript = vi | |
| 2241 | .fn<(expression: string, userGesture?: boolean) => Promise<unknown>>() | |
| 2242 | .mockImplementationOnce(() => { | |
| 2243 | markMeasurementStarted(); | |
| 2244 | return new Promise((_, reject) => { | |
| 2245 | rejectFirstMeasurement = reject; | |
| 2246 | }); | |
| 2247 | }) | |
| 2248 | .mockResolvedValue({ width: 1280, height: 720 }); | |
| 2249 | const webContents = Object.assign(makeTestPreviewWebContents(capturePage), { | |
| 2250 | executeJavaScript, | |
| 2445 | const { grants, takeGrant } = yield* setupRecordingRaceTabs(manager); | |
| 2446 | ||
| 2447 | yield* manager.startRecording("tab_race_a"); | |
| 2448 | yield* TestClock.adjust(10_000); | |
| 2449 | // The handler cannot read a clock, so the expiry fiber must have dropped the frame. | |
| 2450 | takeGrant(); | |
| 2451 | expect(grants).toEqual([{}]); | |
| 2452 | ||
| 2453 | yield* manager.stopRecording("tab_race_a"); | |
| 2454 | }), | |
| 2455 | ), | |
| 2456 | ); | |
| 2457 | ||
| 2458 | effectIt.effect("only lets the host frame that armed a recording claim its stream", () => | |
| 2459 | withManager((manager) => | |
| 2460 | Effect.gen(function* () { | |
| 2461 | const { grants, takeGrant } = yield* setupRecordingRaceTabs(manager); | |
| 2462 | ||
| 2463 | yield* manager.startRecording("tab_race_a"); | |
| 2464 | takeGrant({ frameTreeNodeId: 999 }); | |
| 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 | 2499 | fromId.mockReturnValue(webContents); |
| 2253 | 2500 | |
| 2254 | yield* manager.createTab("tab_recording_start_race"); | |
| 2255 | yield* manager.registerWebview("tab_recording_start_race", 42); | |
| 2256 | yield* manager.setMainWindow({ | |
| 2257 | isDestroyed: () => false, | |
| 2258 | once: vi.fn(), | |
| 2259 | webContents: { setBackgroundThrottling }, | |
| 2260 | } as never); | |
| 2501 | yield* manager.createTab("tab_recording_warmup_failure"); | |
| 2502 | yield* manager.registerWebview("tab_recording_warmup_failure", 42); | |
| 2261 | 2503 | |
| 2262 | const firstStart = yield* manager | |
| 2263 | .startRecording("tab_recording_start_race") | |
| 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(); | |
| 2504 | yield* manager.startRecording("tab_recording_warmup_failure"); | |
| 2505 | expect(capturePage).toHaveBeenCalledTimes(2); | |
| 2271 | 2506 | |
| 2272 | rejectFirstMeasurement(new Error("first measurement failed")); | |
| 2273 | const firstExit = yield* Fiber.await(firstStart); | |
| 2274 | expect(Exit.isFailure(firstExit)).toBe(true); | |
| 2275 | expect(yield* Fiber.join(secondStart)).toEqual({ | |
| 2276 | sourceId: "tab:42", | |
| 2277 | width: 1280, | |
| 2278 | height: 720, | |
| 2279 | }); | |
| 2507 | // The armed tab answers exactly one display-media request, then further requests are denied. | |
| 2508 | const handler = host.displayMediaHandler(); | |
| 2509 | const streams: Array<{ video?: unknown }> = []; | |
| 2510 | handler?.({ frame: host.mainFrame }, (value) => streams.push(value)); | |
| 2511 | handler?.({ frame: host.mainFrame }, (value) => streams.push(value)); | |
| 2512 | expect(streams).toEqual([{ video: { routingId: 42 } }, {}]); | |
| 2280 | 2513 | |
| 2281 | yield* manager.stopRecording("tab_recording_start_race"); | |
| 2282 | expect(setBackgroundThrottling.mock.calls).toEqual([[false], [true], [false], [true]]); | |
| 2514 | yield* manager.stopRecording("tab_recording_warmup_failure"); | |
| 2283 | 2515 | }), |
| 2284 | 2516 | ), |
| 2285 | 2517 | ); |
| ... | ... | @@ -2287,25 +2519,26 @@ describe("PreviewManager", () => { |
| 2287 | 2519 | effectIt.effect("serializes recording source acquisition with webview replacement", () => |
| 2288 | 2520 | withManager((manager) => |
| 2289 | 2521 | Effect.gen(function* () { |
| 2290 | const capturePage = vi.fn(async () => ({ | |
| 2522 | const capturedImage = { | |
| 2291 | 2523 | toJPEG: () => Buffer.from("unused-recording-frame"), |
| 2292 | 2524 | getSize: () => ({ width: 1280, height: 720 }), |
| 2293 | })); | |
| 2294 | let markMeasurementStarted!: () => void; | |
| 2295 | const measurementStarted = new Promise<void>((resolve) => { | |
| 2296 | markMeasurementStarted = resolve; | |
| 2297 | }); | |
| 2298 | let finishMeasurement!: (size: { readonly width: number; readonly height: number }) => void; | |
| 2299 | const executeJavaScript = vi.fn( | |
| 2300 | () => | |
| 2301 | new Promise<{ readonly width: number; readonly height: number }>((resolve) => { | |
| 2302 | markMeasurementStarted(); | |
| 2303 | finishMeasurement = resolve; | |
| 2304 | }), | |
| 2305 | ); | |
| 2306 | const initialWebContents = Object.assign(makeTestPreviewWebContents(capturePage, 42), { | |
| 2307 | executeJavaScript, | |
| 2308 | }); | |
| 2525 | }; | |
| 2526 | let markWarmupStarted!: () => void; | |
| 2527 | const warmupStarted = new Promise<void>((resolve) => { | |
| 2528 | markWarmupStarted = resolve; | |
| 2529 | }); | |
| 2530 | let finishWarmup!: (image: TestCapturedPreviewImage) => void; | |
| 2531 | const capturePage = vi | |
| 2532 | .fn<() => Promise<TestCapturedPreviewImage>>() | |
| 2533 | .mockImplementationOnce( | |
| 2534 | () => | |
| 2535 | new Promise<TestCapturedPreviewImage>((resolve) => { | |
| 2536 | markWarmupStarted(); | |
| 2537 | finishWarmup = resolve; | |
| 2538 | }), | |
| 2539 | ) | |
| 2540 | .mockResolvedValue(capturedImage); | |
| 2541 | const initialWebContents = makeTestPreviewWebContents(capturePage, 42); | |
| 2309 | 2542 | const replacementOn = vi.fn(); |
| 2310 | 2543 | const replacementWebContents = Object.assign(makeTestPreviewWebContents(capturePage, 43), { |
| 2311 | 2544 | on: replacementOn, |
| ... | ... | @@ -2321,22 +2554,25 @@ describe("PreviewManager", () => { |
| 2321 | 2554 | const start = yield* manager |
| 2322 | 2555 | .startRecording("tab_recording_replacement_race") |
| 2323 | 2556 | .pipe(Effect.forkChild({ startImmediately: true })); |
| 2324 | yield* Effect.promise(() => measurementStarted); | |
| 2557 | yield* Effect.promise(() => warmupStarted); | |
| 2325 | 2558 | const replacement = yield* manager |
| 2326 | 2559 | .registerWebview("tab_recording_replacement_race", 43) |
| 2327 | 2560 | .pipe(Effect.forkChild({ startImmediately: true })); |
| 2328 | 2561 | yield* Effect.yieldNow; |
| 2329 | 2562 | expect(replacementOn).not.toHaveBeenCalled(); |
| 2330 | 2563 | |
| 2331 | finishMeasurement({ width: 1280, height: 720 }); | |
| 2332 | expect(yield* Fiber.join(start)).toEqual({ | |
| 2333 | sourceId: "tab:42", | |
| 2334 | width: 1280, | |
| 2335 | height: 720, | |
| 2336 | }); | |
| 2564 | finishWarmup(capturedImage); | |
| 2565 | yield* Fiber.join(start); | |
| 2337 | 2566 | yield* Fiber.join(replacement); |
| 2338 | 2567 | expect(replacementOn).toHaveBeenCalled(); |
| 2568 | expect(initialWebContents.setBackgroundThrottling.mock.calls).toEqual([[false]]); | |
| 2569 | expect(replacementWebContents.setBackgroundThrottling.mock.calls).toEqual([[false]]); | |
| 2339 | 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 | 2581 | withManager((manager) => |
| 2346 | 2582 | Effect.gen(function* () { |
| 2347 | 2583 | const setBackgroundThrottling = vi.fn(); |
| 2348 | const mainWindowWebContents = { setBackgroundThrottling }; | |
| 2584 | const mainWindowWebContents = Object.assign(makeTestHostWebContents(), { | |
| 2585 | setBackgroundThrottling, | |
| 2586 | }); | |
| 2349 | 2587 | const jpeg = Buffer.from("shared-preview-frame"); |
| 2350 | 2588 | const capturePage = vi.fn(async () => ({ |
| 2351 | 2589 | toJPEG: () => jpeg, |
| ... | ... | @@ -2353,8 +2591,8 @@ describe("PreviewManager", () => { |
| 2353 | 2591 | })); |
| 2354 | 2592 | fromId.mockReturnValue({ |
| 2355 | 2593 | id: 42, |
| 2594 | mainFrame: { routingId: 42 }, | |
| 2356 | 2595 | hostWebContents: mainWindowWebContents, |
| 2357 | getMediaSourceId: vi.fn(() => "tab:42"), | |
| 2358 | 2596 | executeJavaScript: vi.fn(async () => ({ width: 1280, height: 720 })), |
| 2359 | 2597 | isDestroyed: () => false, |
| 2360 | 2598 | getType: () => "webview", |
| ... | ... | @@ -2364,6 +2602,7 @@ describe("PreviewManager", () => { |
| 2364 | 2602 | getZoomFactor: () => 1, |
| 2365 | 2603 | setZoomFactor: vi.fn(), |
| 2366 | 2604 | setAudioMuted: vi.fn(), |
| 2605 | setBackgroundThrottling: vi.fn(), | |
| 2367 | 2606 | isCurrentlyAudible: () => false, |
| 2368 | 2607 | on: vi.fn(), |
| 2369 | 2608 | off: vi.fn(), |
apps/desktop/src/preview/Manager.ts+229-48| ... | ... | @@ -5,6 +5,7 @@ |
| 5 | 5 | * elements live in the renderer; we only attach listeners and forward state |
| 6 | 6 | * here). Single layer-scoped browser session partition. |
| 7 | 7 | */ |
| 8 | import { DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER } from "@t3tools/contracts"; | |
| 8 | 9 | import type { |
| 9 | 10 | DesktopPreviewAnnotationTheme, |
| 10 | 11 | DesktopPreviewAutomationStatus, |
| ... | ... | @@ -16,7 +17,6 @@ import type { |
| 16 | 17 | PreviewAnnotationSubmissionResult, |
| 17 | 18 | DesktopPreviewRecordingArtifact, |
| 18 | 19 | DesktopPreviewRecordingFrame, |
| 19 | DesktopPreviewRecordingSource, | |
| 20 | 20 | DesktopPreviewScreenshotArtifact, |
| 21 | 21 | DesktopPreviewTabDefaults, |
| 22 | 22 | PreviewAutomationClickInput, |
| ... | ... | @@ -109,8 +109,8 @@ const MAX_EVALUATION_BYTES = 64_000; |
| 109 | 109 | const MAX_VISIBLE_TEXT_LENGTH = 20_000; |
| 110 | 110 | const MAX_INTERACTIVE_ELEMENTS = 200; |
| 111 | 111 | const MAX_SCREENSHOT_WIDTH = 1280; |
| 112 | const RECORDING_SOURCE_SIZE_EXPRESSION = | |
| 113 | "({ width: Math.round(globalThis.innerWidth), height: Math.round(globalThis.innerHeight) })"; | |
| 112 | /** How long an armed tab keeps the exclusive display-media slot before another tab may take it. */ | |
| 113 | const RECORDING_ARM_GRACE_MS = 10_000; | |
| 114 | 114 | const PICTURE_IN_PICTURE_FRAME_INTERVAL_MS = Math.ceil(1_000 / 12); |
| 115 | 115 | const PICTURE_IN_PICTURE_JPEG_QUALITY = 80; |
| 116 | 116 | const PICTURE_IN_PICTURE_INITIAL_WIDTH = 480; |
| ... | ... | @@ -122,6 +122,8 @@ const DIAGNOSTIC_BUFFER_LIMIT = 200; |
| 122 | 122 | const MAX_ARTIFACT_SITE_SLUG_LENGTH = 80; |
| 123 | 123 | const AGENT_CURSOR_MOVE_MS = 160; |
| 124 | 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 | 127 | const encodeUnknownJson = Schema.encodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); |
| 126 | 128 | const DEFAULT_ANNOTATION_THEME: DesktopPreviewAnnotationTheme = { |
| 127 | 129 | colorScheme: "light", |
| ... | ... | @@ -390,6 +392,7 @@ type FrameCaptureConsumer = "picture-in-picture" | "recording"; |
| 390 | 392 | interface FrameCaptureSession { |
| 391 | 393 | readonly scope: Scope.Closeable | null; |
| 392 | 394 | readonly consumers: ReadonlySet<FrameCaptureConsumer>; |
| 395 | readonly unthrottledWebContentsIds: ReadonlySet<number>; | |
| 393 | 396 | readonly lastPictureInPictureFrame: Buffer | null; |
| 394 | 397 | } |
| 395 | 398 | |
| ... | ... | @@ -400,6 +403,14 @@ interface PictureInPictureSession { |
| 400 | 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 | 414 | interface PickSession { |
| 404 | 415 | readonly cancel: Effect.Effect<void>; |
| 405 | 416 | } |
| ... | ... | @@ -591,6 +602,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 591 | 602 | const pictureInPictureAspectRatiosRef = yield* Ref.make<ReadonlyMap<string, number>>(new Map()); |
| 592 | 603 | const pictureInPictureMutationSemaphore = yield* Semaphore.make(1); |
| 593 | 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 | 610 | let frameCaptureWindowOpen = true; |
| 595 | 611 | let currentMainWindow: BrowserWindow | undefined; |
| 596 | 612 | let mainWindowCleanupFiber: Fiber.Fiber<void, never> | undefined; |
| ... | ... | @@ -665,6 +681,65 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 665 | 681 | if (Option.isNone(mainWindow)) return; |
| 666 | 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 | 743 | const stopFrameCapture = Effect.fn("PreviewManager.stopFrameCapture")(function* ( |
| 669 | 744 | tabId: string, |
| 670 | 745 | consumer: FrameCaptureConsumer, |
| ... | ... | @@ -694,6 +769,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 694 | 769 | const remainingSessions = replaceMap(sessions, (copy) => { |
| 695 | 770 | copy.delete(tabId); |
| 696 | 771 | }); |
| 772 | yield* restoreFrameCaptureWebContentsBackgroundThrottling( | |
| 773 | current.unthrottledWebContentsIds, | |
| 774 | ); | |
| 697 | 775 | if (remainingSessions.size === 0) { |
| 698 | 776 | yield* setFrameCaptureBackgroundThrottling(true).pipe( |
| 699 | 777 | Effect.retry({ times: 2 }), |
| ... | ... | @@ -713,6 +791,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 713 | 791 | }); |
| 714 | 792 | |
| 715 | 793 | const stopAllRecordings = Effect.fn("PreviewManager.stopAllRecordings")(function* () { |
| 794 | pendingRecording = null; | |
| 716 | 795 | const sessions = yield* SynchronizedRef.get(frameCaptureSessionsRef); |
| 717 | 796 | yield* Effect.forEach(sessions.keys(), (tabId) => stopFrameCapture(tabId, "recording"), { |
| 718 | 797 | concurrency: "unbounded", |
| ... | ... | @@ -1889,6 +1968,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1889 | 1968 | |
| 1890 | 1969 | const closeTabUnlocked = Effect.fn("PreviewManager.closeTabUnlocked")(function* (tabId: string) { |
| 1891 | 1970 | if (!(yield* SynchronizedRef.get(tabsRef)).has(tabId)) return; |
| 1971 | clearPendingRecording(tabId); | |
| 1892 | 1972 | yield* Effect.all( |
| 1893 | 1973 | [ |
| 1894 | 1974 | cancelPickElement(tabId), |
| ... | ... | @@ -1980,6 +2060,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1980 | 2060 | const attached = yield* Ref.get(attachedRef); |
| 1981 | 2061 | const annotationTheme = yield* Ref.get(annotationThemeRef); |
| 1982 | 2062 | const currentAttachment = attached.get(webContentsId); |
| 2063 | yield* keepFrameCaptureWebContentsUnthrottled(tabId, wc); | |
| 1983 | 2064 | if (tab.webContentsId === webContentsId && currentAttachment?.webContents === wc) { |
| 1984 | 2065 | // The guest we already own re-announced itself, so nothing about the tab |
| 1985 | 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 | 2077 | ? tab.webContentsId |
| 1997 | 2078 | : null; |
| 1998 | 2079 | if (replacedWebContentsId !== null) { |
| 2080 | // The replaced guest can no longer redeem a display-media grant. | |
| 2081 | clearPendingRecording(tabId); | |
| 1999 | 2082 | yield* Effect.all( |
| 2000 | 2083 | [ |
| 2001 | 2084 | detachControlSession(replacedWebContentsId), |
| ... | ... | @@ -2728,7 +2811,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 2728 | 2811 | ) { |
| 2729 | 2812 | // Recording keeps only the activity lease. Picture-in-picture owns the |
| 2730 | 2813 | // capturePage loop and tolerates transient compositor warmup failures. |
| 2731 | yield* requireWebContents(tabId); | |
| 2732 | 2814 | const captureNextFrame = Effect.sleep(PICTURE_IN_PICTURE_FRAME_INTERVAL_MS).pipe( |
| 2733 | 2815 | Effect.andThen(capturePreviewFrame(tabId)), |
| 2734 | 2816 | Effect.catch((error) => |
| ... | ... | @@ -2749,11 +2831,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 2749 | 2831 | if (!tab || (yield* Ref.get(closingTabIdsRef)).has(tabId)) { |
| 2750 | 2832 | return yield* new PreviewTabNotFoundError({ tabId }); |
| 2751 | 2833 | } |
| 2834 | const wc = yield* requireWebContents(tabId); | |
| 2752 | 2835 | const current = sessions.get(tabId); |
| 2753 | 2836 | if (current) { |
| 2754 | 2837 | if (current.consumers.has(consumer)) { |
| 2755 | 2838 | return [false, sessions] as const; |
| 2756 | 2839 | } |
| 2840 | if (!current.unthrottledWebContentsIds.has(wc.id)) { | |
| 2841 | yield* setFrameCaptureWebContentsBackgroundThrottling(wc, false); | |
| 2842 | } | |
| 2757 | 2843 | let scope = current.scope; |
| 2758 | 2844 | if (consumer === "picture-in-picture" && scope === null) { |
| 2759 | 2845 | scope = yield* Scope.fork(parentScope, "sequential"); |
| ... | ... | @@ -2766,6 +2852,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 2766 | 2852 | ...current, |
| 2767 | 2853 | scope, |
| 2768 | 2854 | consumers: new Set([...current.consumers, consumer]), |
| 2855 | unthrottledWebContentsIds: new Set([...current.unthrottledWebContentsIds, wc.id]), | |
| 2769 | 2856 | }); |
| 2770 | 2857 | }), |
| 2771 | 2858 | ] as const; |
| ... | ... | @@ -2773,6 +2860,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 2773 | 2860 | if (sessions.size === 0) { |
| 2774 | 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 | 2870 | const scope = |
| 2777 | 2871 | consumer === "picture-in-picture" ? yield* Scope.fork(parentScope, "sequential") : null; |
| 2778 | 2872 | if (scope !== null) { |
| ... | ... | @@ -2784,6 +2878,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 2784 | 2878 | copy.set(tabId, { |
| 2785 | 2879 | scope, |
| 2786 | 2880 | consumers: new Set([consumer]), |
| 2881 | unthrottledWebContentsIds: new Set([wc.id]), | |
| 2787 | 2882 | lastPictureInPictureFrame: null, |
| 2788 | 2883 | }); |
| 2789 | 2884 | }), |
| ... | ... | @@ -3099,6 +3194,83 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 3099 | 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 | 3274 | const startRecording = Effect.fn("PreviewManager.startRecording")(function* (tabId: string) { |
| 3103 | 3275 | if ((yield* Ref.get(closingTabIdsRef)).has(tabId)) { |
| 3104 | 3276 | return yield* new PreviewTabNotFoundError({ tabId }); |
| ... | ... | @@ -3112,31 +3284,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 3112 | 3284 | if (requestWebContents === null) { |
| 3113 | 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 | 3287 | yield* attemptPromise( |
| 3141 | 3288 | { |
| 3142 | 3289 | operation: "recording.warmSource", |
| ... | ... | @@ -3152,25 +3299,44 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 3152 | 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 | 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 { | |
| 3164 | sourceId, | |
| 3165 | width: measuredSize.width, | |
| 3166 | height: measuredSize.height, | |
| 3167 | } satisfies DesktopPreviewRecordingSource; | |
| 3168 | }).pipe(Effect.onError(() => stopFrameCapture(tabId, "recording").pipe(Effect.ignore))), | |
| 3316 | if (captureRequested !== true) { | |
| 3317 | return yield* new PreviewRecordingCaptureUnavailableError({ | |
| 3318 | tabId, | |
| 3319 | webContentsId: requestWebContents.id, | |
| 3320 | }); | |
| 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 | 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 | 3342 | const saveRecording = Effect.fn("PreviewManager.saveRecording")(function* ( |
| ... | ... | @@ -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>()( | |
| 3984 | "PreviewRecordingSourceSizeUnavailableError", | |
| 3985 | { tabId: Schema.String, webContentsId: Schema.Number }, | |
| 4149 | export class PreviewRecordingArmConflictError extends Schema.TaggedErrorClass<PreviewRecordingArmConflictError>()( | |
| 4150 | "PreviewRecordingArmConflictError", | |
| 4151 | { | |
| 4152 | tabId: Schema.String, | |
| 4153 | webContentsId: Schema.Number, | |
| 4154 | armedTabId: Schema.String, | |
| 4155 | }, | |
| 3986 | 4156 | ) { |
| 3987 | 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 | 4378 | PreviewWebContentsNotFoundError, |
| 4197 | 4379 | PreviewWebviewNotInitializedError, |
| 4198 | 4380 | PreviewMainWindowClosedError, |
| 4199 | PreviewRecordingSourceSizeUnavailableError, | |
| 4381 | PreviewRecordingArmConflictError, | |
| 4382 | PreviewRecordingCaptureUnavailableError, | |
| 4200 | 4383 | PreviewOperationError, |
| 4201 | 4384 | PreviewArtifactPathOutsideDirectoryError, |
| 4202 | 4385 | PreviewArtifactImageLoadError, |
| ... | ... | @@ -4274,9 +4457,7 @@ export class PreviewManager extends Context.Service< |
| 4274 | 4457 | readonly copyArtifactToClipboard: (path: string) => Effect.Effect<void, PreviewManagerError>; |
| 4275 | 4458 | readonly openPictureInPicture: (tabId: string) => Effect.Effect<void, PreviewManagerError>; |
| 4276 | 4459 | readonly closePictureInPicture: (tabId: string) => Effect.Effect<void, PreviewManagerError>; |
| 4277 | readonly startRecording: ( | |
| 4278 | tabId: string, | |
| 4279 | ) => Effect.Effect<DesktopPreviewRecordingSource, PreviewManagerError>; | |
| 4460 | readonly startRecording: (tabId: string) => Effect.Effect<void, PreviewManagerError>; | |
| 4280 | 4461 | readonly stopRecording: (tabId: string) => Effect.Effect<void, PreviewManagerError>; |
| 4281 | 4462 | readonly saveRecording: ( |
| 4282 | 4463 | tabId: string, |
apps/web/src/browser/HostedBrowserWebview.tsx+5-1| ... | ... | @@ -6,7 +6,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; |
| 6 | 6 | |
| 7 | 7 | import { previewBridge } from "~/components/preview/previewBridge"; |
| 8 | 8 | import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; |
| 9 | import { cn } from "~/lib/utils"; | |
| 9 | import { cn, isMacPlatform } from "~/lib/utils"; | |
| 10 | 10 | |
| 11 | 11 | import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore"; |
| 12 | 12 | import { useActiveBrowserRecordingTabIds } from "./browserRecording"; |
| ... | ... | @@ -241,6 +241,10 @@ export function HostedBrowserWebview(props: { |
| 241 | 241 | const wrapperStyle = resolveHostedBrowserWebviewWrapperStyle({ |
| 242 | 242 | active, |
| 243 | 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 | 248 | cornerRadius: presentation.cornerRadius, |
| 245 | 249 | rect: lastRect, |
| 246 | 250 | hiddenSize, |
apps/web/src/browser/browserRecording.test.ts+195-60| ... | ... | @@ -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 | 6 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 3 | 7 | |
| 4 | const { clientSettings, events, getUserMedia, registrySet, save, startScreencast, stopScreencast } = | |
| 5 | vi.hoisted(() => { | |
| 6 | const events: string[] = []; | |
| 7 | return { | |
| 8 | clientSettings: { browserRecordingFrameRate: 30 as 30 | 60 }, | |
| 9 | events, | |
| 10 | getUserMedia: vi.fn(), | |
| 11 | registrySet: vi.fn((_atom: unknown, value: { readonly tabIds: ReadonlySet<string> }) => { | |
| 12 | events.push( | |
| 13 | value.tabIds.size === 0 ? "clear" : `publish:${Array.from(value.tabIds).join(",")}`, | |
| 14 | ); | |
| 15 | }), | |
| 16 | save: vi.fn(async (tabId: string) => ({ | |
| 17 | id: "recording-test", | |
| 18 | tabId, | |
| 19 | path: "/tmp/recording-test.webm", | |
| 20 | mimeType: "video/webm" as const, | |
| 21 | sizeBytes: 0, | |
| 22 | createdAt: "2026-06-26T00:00:00.000Z", | |
| 23 | })), | |
| 24 | startScreencast: vi.fn(async (tabId: string) => { | |
| 25 | events.push("start-screencast"); | |
| 26 | return { sourceId: `source:${tabId}`, width: 1000, height: 620 }; | |
| 27 | }), | |
| 28 | stopScreencast: vi.fn(async () => undefined), | |
| 29 | }; | |
| 30 | }); | |
| 8 | const { | |
| 9 | clientSettings, | |
| 10 | events, | |
| 11 | getDisplayMedia, | |
| 12 | registrySet, | |
| 13 | requestDisplayMediaCapture, | |
| 14 | save, | |
| 15 | startScreencast, | |
| 16 | stopScreencast, | |
| 17 | } = vi.hoisted(() => { | |
| 18 | const events: string[] = []; | |
| 19 | return { | |
| 20 | clientSettings: { browserRecordingFrameRate: 30 as 30 | 60 }, | |
| 21 | events, | |
| 22 | getDisplayMedia: vi.fn(), | |
| 23 | requestDisplayMediaCapture: vi.fn((_tabId: string) => undefined), | |
| 24 | registrySet: vi.fn((_atom: unknown, value: { readonly tabIds: ReadonlySet<string> }) => { | |
| 25 | events.push( | |
| 26 | value.tabIds.size === 0 ? "clear" : `publish:${Array.from(value.tabIds).join(",")}`, | |
| 27 | ); | |
| 28 | }), | |
| 29 | save: vi.fn(async (tabId: string) => ({ | |
| 30 | id: "recording-test", | |
| 31 | tabId, | |
| 32 | path: "/tmp/recording-test.webm", | |
| 33 | mimeType: "video/webm" as const, | |
| 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 | 44 | vi.mock("~/components/preview/previewBridge", () => ({ |
| 33 | 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 | 70 | BrowserRecordingCaptureTimeoutError, |
| 51 | 71 | BrowserRecordingConflictError, |
| 52 | 72 | BrowserRecordingFormatUnavailableError, |
| 73 | BrowserRecordingStartCancelledError, | |
| 53 | 74 | findActiveBrowserRecordingRuntimeTabId, |
| 54 | 75 | readActiveBrowserRecordingTabIds, |
| 55 | 76 | readActiveBrowserRecordingTargets, |
| ... | ... | @@ -122,10 +143,16 @@ describe("browser recording", () => { |
| 122 | 143 | }); |
| 123 | 144 | vi.stubGlobal("cancelAnimationFrame", vi.fn()); |
| 124 | 145 | vi.stubGlobal("MediaRecorder", FakeMediaRecorder as unknown as typeof MediaRecorder); |
| 125 | getUserMedia.mockResolvedValue({ | |
| 146 | getDisplayMedia.mockResolvedValue({ | |
| 126 | 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 | 156 | useBrowserSurfaceStore.setState({ activityByTabId: {}, byTabId: {} }); |
| 130 | 157 | }); |
| 131 | 158 | |
| ... | ... | @@ -142,13 +169,20 @@ describe("browser recording", () => { |
| 142 | 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 | 180 | it("paints and holds a hidden browser surface for the recording lifetime", async () => { |
| 146 | 181 | startScreencast.mockImplementationOnce(async (tabId: string) => { |
| 147 | 182 | expect(animationFrameCount).toBe(2); |
| 148 | 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 | 186 | expect(animationFrameCount).toBe(2); |
| 153 | 187 | expect(useBrowserSurfaceStore.getState().activityByTabId["background-tab"]).toBe(1); |
| 154 | 188 | return { getTracks: () => [{ stop: vi.fn() }] }; |
| ... | ... | @@ -178,26 +212,16 @@ describe("browser recording", () => { |
| 178 | 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 | 216 | const stopTrack = vi.fn(); |
| 183 | 217 | const stream = { getTracks: () => [{ stop: stopTrack }] } as unknown as MediaStream; |
| 184 | getUserMedia.mockResolvedValueOnce(stream); | |
| 218 | getDisplayMedia.mockResolvedValueOnce(stream); | |
| 185 | 219 | |
| 186 | 220 | await startBrowserRecording("recording-tab"); |
| 187 | 221 | |
| 188 | expect(getUserMedia).toHaveBeenCalledWith({ | |
| 222 | expect(getDisplayMedia).toHaveBeenCalledWith({ | |
| 189 | 223 | audio: false, |
| 190 | video: { | |
| 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 | }, | |
| 224 | video: { frameRate: { max: 30 } }, | |
| 201 | 225 | }); |
| 202 | 226 | expect(FakeMediaRecorder.instances[0]?.stream).toBe(stream); |
| 203 | 227 | |
| ... | ... | @@ -210,18 +234,16 @@ describe("browser recording", () => { |
| 210 | 234 | |
| 211 | 235 | await startBrowserRecording("recording-tab"); |
| 212 | 236 | |
| 213 | expect(getUserMedia).toHaveBeenCalledWith({ | |
| 237 | expect(getDisplayMedia).toHaveBeenCalledWith({ | |
| 214 | 238 | audio: false, |
| 215 | video: { | |
| 216 | mandatory: expect.objectContaining({ maxFrameRate: 60 }), | |
| 217 | }, | |
| 239 | video: { frameRate: { max: 60 } }, | |
| 218 | 240 | }); |
| 219 | 241 | await stopBrowserRecording("recording-tab"); |
| 220 | 242 | }); |
| 221 | 243 | |
| 222 | 244 | it("stops the native stream when MediaRecorder cleanup fails", async () => { |
| 223 | 245 | const stopTrack = vi.fn(); |
| 224 | getUserMedia.mockResolvedValueOnce({ | |
| 246 | getDisplayMedia.mockResolvedValueOnce({ | |
| 225 | 247 | getTracks: () => [{ stop: stopTrack }], |
| 226 | 248 | }); |
| 227 | 249 | |
| ... | ... | @@ -285,7 +307,7 @@ describe("browser recording", () => { |
| 285 | 307 | }); |
| 286 | 308 | |
| 287 | 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 | 312 | await expect(startBrowserRecording("recording-tab")).rejects.toMatchObject({ |
| 291 | 313 | operation: "capture-media-stream", |
| ... | ... | @@ -300,7 +322,7 @@ describe("browser recording", () => { |
| 300 | 322 | vi.useFakeTimers(); |
| 301 | 323 | let finishCapture!: (stream: MediaStream) => void; |
| 302 | 324 | const stopTrack = vi.fn(); |
| 303 | getUserMedia.mockImplementationOnce( | |
| 325 | getDisplayMedia.mockImplementationOnce( | |
| 304 | 326 | () => |
| 305 | 327 | new Promise<MediaStream>((resolve) => { |
| 306 | 328 | finishCapture = resolve; |
| ... | ... | @@ -308,7 +330,7 @@ describe("browser recording", () => { |
| 308 | 330 | ); |
| 309 | 331 | |
| 310 | 332 | const startPromise = startBrowserRecording("recording-tab"); |
| 311 | await vi.waitFor(() => expect(getUserMedia).toHaveBeenCalledOnce()); | |
| 333 | await vi.waitFor(() => expect(getDisplayMedia).toHaveBeenCalledOnce()); | |
| 312 | 334 | const rejection = expect(startPromise).rejects.toMatchObject({ |
| 313 | 335 | _tag: "BrowserRecordingCaptureTimeoutError", |
| 314 | 336 | tabId: "recording-tab", |
| ... | ... | @@ -356,6 +378,124 @@ describe("browser recording", () => { |
| 356 | 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 | 499 | it("keeps a recording reachable through its runtime id after a server epoch changes", async () => { |
| 360 | 500 | const threadRef = { |
| 361 | 501 | environmentId: EnvironmentId.make("environment-recording"), |
| ... | ... | @@ -387,7 +527,6 @@ describe("browser recording", () => { |
| 387 | 527 | await new Promise<void>((resolve) => { |
| 388 | 528 | finishStartingScreencast = resolve; |
| 389 | 529 | }); |
| 390 | return { sourceId: "source:recording-tab", width: 1000, height: 620 }; | |
| 391 | 530 | }); |
| 392 | 531 | |
| 393 | 532 | const firstStart = startBrowserRecording("recording-tab"); |
| ... | ... | @@ -452,7 +591,6 @@ describe("browser recording", () => { |
| 452 | 591 | await new Promise<void>((resolve) => { |
| 453 | 592 | finishStartingScreencast = resolve; |
| 454 | 593 | }); |
| 455 | return { sourceId: "source:recording-tab", width: 1000, height: 620 }; | |
| 456 | 594 | }); |
| 457 | 595 | |
| 458 | 596 | const startPromise = startBrowserRecording("recording-tab"); |
| ... | ... | @@ -476,7 +614,6 @@ describe("browser recording", () => { |
| 476 | 614 | await new Promise<void>((resolve) => { |
| 477 | 615 | finishStartingScreencast = resolve; |
| 478 | 616 | }); |
| 479 | return { sourceId: "source:recording-tab", width: 1000, height: 620 }; | |
| 480 | 617 | }); |
| 481 | 618 | |
| 482 | 619 | const firstStart = startBrowserRecording("recording-tab"); |
| ... | ... | @@ -503,7 +640,6 @@ describe("browser recording", () => { |
| 503 | 640 | await new Promise<void>((resolve) => { |
| 504 | 641 | finishStartingScreencast = resolve; |
| 505 | 642 | }); |
| 506 | return { sourceId: "source:recording-tab", width: 1000, height: 620 }; | |
| 507 | 643 | }); |
| 508 | 644 | stopScreencast.mockRejectedValueOnce(new Error("initial stop failed")); |
| 509 | 645 | |
| ... | ... | @@ -537,7 +673,6 @@ describe("browser recording", () => { |
| 537 | 673 | await new Promise<void>((resolve) => { |
| 538 | 674 | finishStartingScreencast = resolve; |
| 539 | 675 | }); |
| 540 | return { sourceId: "source:recording-tab", width: 1000, height: 620 }; | |
| 541 | 676 | }); |
| 542 | 677 | |
| 543 | 678 | const startPromise = startBrowserRecording("recording-tab"); |
apps/web/src/browser/browserRecording.ts+204-62| ... | ... | @@ -1,8 +1,5 @@ |
| 1 | import type { | |
| 2 | DesktopPreviewRecordingArtifact, | |
| 3 | DesktopPreviewRecordingSource, | |
| 4 | ScopedThreadRef, | |
| 5 | } from "@t3tools/contracts"; | |
| 1 | import { DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER } from "@t3tools/contracts"; | |
| 2 | import type { DesktopPreviewRecordingArtifact, ScopedThreadRef } from "@t3tools/contracts"; | |
| 6 | 3 | import { useAtomValue } from "@effect/atom-react"; |
| 7 | 4 | import * as Schema from "effect/Schema"; |
| 8 | 5 | import { Atom } from "effect/unstable/reactivity"; |
| ... | ... | @@ -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 | 47 | export class BrowserRecordingFormatUnavailableError extends Schema.TaggedErrorClass<BrowserRecordingFormatUnavailableError>()( |
| 40 | 48 | "BrowserRecordingFormatUnavailableError", |
| 41 | 49 | { tabId: Schema.String }, |
| ... | ... | @@ -82,9 +90,21 @@ export class BrowserRecordingOperationError extends Schema.TaggedErrorClass<Brow |
| 82 | 90 | |
| 83 | 91 | const isBrowserRecordingOperationError = Schema.is(BrowserRecordingOperationError); |
| 84 | 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 | 106 | type BrowserRecordingLifecycle = |
| 87 | | { readonly phase: "starting" } | |
| 107 | | StartingBrowserRecordingLifecycle | |
| 88 | 108 | | { readonly phase: "recording" } |
| 89 | 109 | | { |
| 90 | 110 | readonly phase: "stopping"; |
| ... | ... | @@ -124,6 +144,53 @@ export function useActiveBrowserRecordingTabIds(): ReadonlySet<string> { |
| 124 | 144 | } |
| 125 | 145 | |
| 126 | 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 | 195 | const publishActiveRecordingTabIds = (): void => { |
| 129 | 196 | appAtomRegistry.set(activeBrowserRecordingTabIdsAtom, { |
| ... | ... | @@ -184,23 +251,12 @@ const createMediaRecorder = (stream: MediaStream): MediaRecorder => { |
| 184 | 251 | return mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream); |
| 185 | 252 | }; |
| 186 | 253 | |
| 187 | const captureTabMediaStream = ( | |
| 188 | source: DesktopPreviewRecordingSource, | |
| 189 | frameRate: number, | |
| 190 | ): Promise<MediaStream> => | |
| 191 | navigator.mediaDevices.getUserMedia({ | |
| 254 | const captureTabMediaStream = (frameRate: number): Promise<MediaStream> => | |
| 255 | // The desktop main process routes this request to the tab that `startScreencast` armed, so the | |
| 256 | // stream already arrives at that tab's native size and needs no source or dimension constraints. | |
| 257 | navigator.mediaDevices.getDisplayMedia({ | |
| 192 | 258 | audio: false, |
| 193 | video: { | |
| 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, | |
| 259 | video: { frameRate: { max: frameRate } }, | |
| 204 | 260 | }); |
| 205 | 261 | |
| 206 | 262 | const stopMediaRecorder = async (recorder: MediaRecorder | null): Promise<void> => { |
| ... | ... | @@ -216,14 +272,75 @@ const stopMediaStream = (stream: MediaStream | null): void => { |
| 216 | 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 | 337 | const captureTabMediaStreamWithTimeout = async ( |
| 220 | 338 | tabId: string, |
| 221 | source: DesktopPreviewRecordingSource, | |
| 222 | frameRate: number, | |
| 339 | capturePromise: Promise<MediaStream>, | |
| 223 | 340 | ): Promise<MediaStream> => { |
| 224 | 341 | let acceptStream = true; |
| 225 | 342 | let timeoutId: number | null = null; |
| 226 | const streamPromise = captureTabMediaStream(source, frameRate).then((stream) => { | |
| 343 | const streamPromise = capturePromise.then((stream) => { | |
| 227 | 344 | if (!acceptStream) stopMediaStream(stream); |
| 228 | 345 | return stream; |
| 229 | 346 | }); |
| ... | ... | @@ -382,6 +499,7 @@ export async function startBrowserRecording( |
| 382 | 499 | const startupSettled = new Promise<void>((resolve) => { |
| 383 | 500 | settleStartup = resolve; |
| 384 | 501 | }); |
| 502 | const startingLifecycle = makeStartingBrowserRecordingLifecycle(); | |
| 385 | 503 | const releaseSurfaceActivity = acquireBrowserSurfaceActivity(tabId); |
| 386 | 504 | const recording: ActiveRecording = { |
| 387 | 505 | tabId, |
| ... | ... | @@ -393,7 +511,7 @@ export async function startBrowserRecording( |
| 393 | 511 | releaseSurfaceActivity, |
| 394 | 512 | stream: null, |
| 395 | 513 | recorder: null, |
| 396 | lifecycle: { phase: "starting" }, | |
| 514 | lifecycle: startingLifecycle, | |
| 397 | 515 | }; |
| 398 | 516 | activeRecordings.set(tabId, recording); |
| 399 | 517 | publishActiveRecordingTabIds(); |
| ... | ... | @@ -402,23 +520,9 @@ export async function startBrowserRecording( |
| 402 | 520 | () => getClientSettings().browserRecordingFrameRate, |
| 403 | 521 | ); |
| 404 | 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 | 523 | const throwIfStartupCancelled = async (): Promise<void> => { |
| 420 | // A stop requested during startup should let startup finish so the | |
| 421 | // caller receives a real artifact. Only replacement/removal cancels it. | |
| 524 | // Once a grant starts, a stop lets startup finish so the caller receives an artifact. | |
| 525 | // Only a contended start can be cancelled before it reaches native capture. | |
| 422 | 526 | if (activeRecordings.get(tabId) === recording) return; |
| 423 | 527 | try { |
| 424 | 528 | await bridge.recording.stopScreencast(tabId); |
| ... | ... | @@ -434,30 +538,67 @@ export async function startBrowserRecording( |
| 434 | 538 | } |
| 435 | 539 | throw recordingStartupCancelledError(recording); |
| 436 | 540 | }; |
| 437 | await throwIfStartupCancelled(); | |
| 438 | try { | |
| 439 | recording.stream = await captureTabMediaStreamWithTimeout(tabId, source, frameRate); | |
| 440 | } catch (cause) { | |
| 441 | const cleanupCause = await cleanupFailedRecordingStart(bridge, recording); | |
| 442 | if (isBrowserRecordingCaptureTimeoutError(cause) && cleanupCause === undefined) throw cause; | |
| 443 | throw new BrowserRecordingOperationError({ | |
| 444 | operation: "capture-media-stream", | |
| 445 | tabId, | |
| 446 | cause: | |
| 447 | cleanupCause === undefined | |
| 448 | ? cause | |
| 449 | : new AggregateError( | |
| 450 | [cause, cleanupCause], | |
| 451 | `Browser media capture and cleanup failed for tab ${tabId}.`, | |
| 452 | { cause }, | |
| 453 | ), | |
| 454 | }); | |
| 455 | } | |
| 541 | // The desktop process exposes one display-media grant at a time. Keep only the | |
| 542 | // arm-to-capture handoff exclusive; acquired streams can record concurrently. | |
| 543 | const grant = queueDisplayMediaGrant(async () => { | |
| 544 | if (startingLifecycle.cancelledBeforeGrant) { | |
| 545 | throw new BrowserRecordingStartCancelledError({ tabId }); | |
| 546 | } | |
| 547 | startingLifecycle.grantStarted = true; | |
| 548 | await throwIfStartupCancelled(); | |
| 549 | const capture = prepareTabMediaCapture(tabId, frameRate); | |
| 550 | try { | |
| 551 | await bridge.recording.startScreencast(tabId); | |
| 552 | } catch (cause) { | |
| 553 | capture.cancel(); | |
| 554 | if (!isRecordingStarting(recording)) { | |
| 555 | throw recordingStartupCancelledError(recording, cause); | |
| 556 | } | |
| 557 | clearActiveRecording(recording); | |
| 558 | throw new BrowserRecordingOperationError({ | |
| 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 | 597 | await throwIfStartupCancelled(); |
| 457 | 598 | |
| 458 | 599 | let recorder: MediaRecorder; |
| 459 | 600 | try { |
| 460 | recorder = createMediaRecorder(recording.stream); | |
| 601 | recorder = createMediaRecorder(stream); | |
| 461 | 602 | recording.recorder = recorder; |
| 462 | 603 | recorder.addEventListener("dataavailable", (event) => { |
| 463 | 604 | if (event.data.size > 0) chunks.push(event.data); |
| ... | ... | @@ -639,6 +780,7 @@ export function stopBrowserRecording( |
| 639 | 780 | const recording = activeRecordings.get(tabId); |
| 640 | 781 | if (!bridge || !recording) return Promise.resolve(null); |
| 641 | 782 | if (recording.lifecycle.phase === "stopping") return recording.lifecycle.stopPromise; |
| 783 | if (recording.lifecycle.phase === "starting") recording.lifecycle.cancelBeforeGrant(); | |
| 642 | 784 | |
| 643 | 785 | const stopPromise = Promise.resolve() |
| 644 | 786 | .then(() => finalizeBrowserRecording(bridge, recording)) |
apps/web/src/browser/hostedBrowserWebviewStyle.test.ts+21-1| ... | ... | @@ -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 | 65 | const style = resolveHostedBrowserWebviewWrapperStyle({ |
| 66 | 66 | active: false, |
| 67 | 67 | renderingActive: true, |
| ... | ... | @@ -69,6 +69,26 @@ describe("resolveHostedBrowserWebviewWrapperStyle", () => { |
| 69 | 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 | 92 | expect(style).toEqual({ |
| 73 | 93 | left: HIDDEN_BROWSER_WEBVIEW_OFFSET, |
| 74 | 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 | 21 | export function resolveHostedBrowserWebviewWrapperStyle(input: { |
| 22 | 22 | readonly active: boolean; |
| 23 | 23 | readonly renderingActive: boolean; |
| 24 | readonly keepPaintableWhenInactive?: boolean; | |
| 24 | 25 | readonly cornerRadius?: number; |
| 25 | 26 | readonly rect: BrowserSurfaceRect | null; |
| 26 | 27 | readonly hiddenSize: HostedBrowserWebviewSize; |
| 27 | 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 | 37 | if (active && rect) { |
| 30 | 38 | return { |
| 31 | 39 | left: rect.x, |
| ... | ... | @@ -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 | 64 | return { |
| 42 | 65 | left: HIDDEN_BROWSER_WEBVIEW_OFFSET, |
| 43 | 66 | top: HIDDEN_BROWSER_WEBVIEW_OFFSET, |
| ... | ... | @@ -45,6 +68,6 @@ export function resolveHostedBrowserWebviewWrapperStyle(input: { |
| 45 | 68 | height: hiddenSize.height, |
| 46 | 69 | zIndex: -1, |
| 47 | 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 | 149 | |
| 150 | 150 | vi.mock("~/browser/browserRecording", () => ({ |
| 151 | 151 | findActiveBrowserRecordingRuntimeTabId: vi.fn(() => null), |
| 152 | isBrowserRecordingStartCancelledError: vi.fn(() => false), | |
| 152 | 153 | startBrowserRecording: vi.fn(), |
| 153 | 154 | stopBrowserRecording: vi.fn(), |
| 154 | 155 | useActiveBrowserRecordingTabIds: () => new Set(), |
apps/web/src/components/preview/PreviewView.tsx+4-1| ... | ... | @@ -55,6 +55,7 @@ import { ZoomIndicator } from "./ZoomIndicator"; |
| 55 | 55 | import { AgentBrowserCursor } from "./AgentBrowserCursor"; |
| 56 | 56 | import { |
| 57 | 57 | findActiveBrowserRecordingRuntimeTabId, |
| 58 | isBrowserRecordingStartCancelledError, | |
| 58 | 59 | startBrowserRecording, |
| 59 | 60 | stopBrowserRecording, |
| 60 | 61 | useActiveBrowserRecordingTabIds, |
| ... | ... | @@ -398,10 +399,12 @@ export function PreviewView({ |
| 398 | 399 | } |
| 399 | 400 | if (record) { |
| 400 | 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 | 404 | toastManager.add({ |
| 402 | 405 | type: "error", |
| 403 | 406 | title: "Unable to start recording", |
| 404 | description: error instanceof Error ? error.message : "An error occurred.", | |
| 407 | description, | |
| 405 | 408 | }); |
| 406 | 409 | }); |
| 407 | 410 | return; |
packages/contracts/src/ipc.ts+4-14| ... | ... | @@ -734,19 +734,6 @@ export const DesktopPreviewRecordingFrameSchema: Schema.Codec<DesktopPreviewReco |
| 734 | 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 | 737 | export interface DesktopPreviewRecordingArtifact { |
| 751 | 738 | id: string; |
| 752 | 739 | tabId: string; |
| ... | ... | @@ -1173,6 +1160,9 @@ export interface DesktopBridge { |
| 1173 | 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 | 1166 | export interface DesktopPreviewBridge { |
| 1177 | 1167 | createTab: (tabId: string, defaults?: DesktopPreviewTabDefaults) => Promise<void>; |
| 1178 | 1168 | closeTab: (tabId: string) => Promise<void>; |
| ... | ... | @@ -1228,7 +1218,7 @@ export interface DesktopPreviewBridge { |
| 1228 | 1218 | close: (tabId: string) => Promise<void>; |
| 1229 | 1219 | }; |
| 1230 | 1220 | recording: { |
| 1231 | startScreencast: (tabId: string) => Promise<DesktopPreviewRecordingSource>; | |
| 1221 | startScreencast: (tabId: string) => Promise<void>; | |
| 1232 | 1222 | stopScreencast: (tabId: string) => Promise<void>; |
| 1233 | 1223 | save: ( |
| 1234 | 1224 | tabId: string, |