1import { DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER } from "@t3tools/contracts";
2import type { DesktopPreviewRecordingArtifact, ScopedThreadRef } from "@t3tools/contracts";
3import { useAtomValue } from "@effect/atom-react";
4import * as Schema from "effect/Schema";
5import { Atom } from "effect/reactivity";
6
7import { previewBridge } from "~/components/preview/previewBridge";
8import { ensureClientSettingsHydrated, getClientSettings } from "~/hooks/useSettings";
9import { appAtomRegistry } from "~/rpc/atomRegistry";
10
11import { createRecordingCompositor } from "./recordingCompositor";
12
13import { acquireBrowserSurfaceActivity } from "./browserSurfaceStore";
14
15export class BrowserRecordingUnavailableError extends Schema.TaggedError<BrowserRecordingUnavailableError>()(
16 "BrowserRecordingUnavailableError",
17 {
18 tabId: Schema.String,
19 },
20) {
21 override get message(): string {
22 return `Browser recording is unavailable for tab ${this.tabId}.`;
23 }
24}
25
26export class BrowserRecordingConflictError extends Schema.TaggedError<BrowserRecordingConflictError>()(
27 "BrowserRecordingConflictError",
28 {
29 requestedTabId: Schema.String,
30 activeTabId: Schema.String,
31 },
32) {
33 override get message(): string {
34 return `Cannot record tab ${this.requestedTabId} while tab ${this.activeTabId} is already being recorded.`;
35 }
36}
37
38export class BrowserRecordingStartCancelledError extends Schema.TaggedError<BrowserRecordingStartCancelledError>()(
39 "BrowserRecordingStartCancelledError",
40 {
41 tabId: Schema.String,
42 },
43) {
44 override get message(): string {
45 return `Browser recording start was cancelled for tab ${this.tabId}.`;
46 }
47}
48
49export class BrowserRecordingFormatUnavailableError extends Schema.TaggedError<BrowserRecordingFormatUnavailableError>()(
50 "BrowserRecordingFormatUnavailableError",
51 { tabId: Schema.String },
52) {
53 override get message(): string {
54 return `MediaRecorder did not report an output format for tab ${this.tabId}.`;
55 }
56}
57
58export class BrowserRecordingCaptureTimeoutError extends Schema.TaggedError<BrowserRecordingCaptureTimeoutError>()(
59 "BrowserRecordingCaptureTimeoutError",
60 {
61 tabId: Schema.String,
62 timeoutMs: Schema.Number,
63 },
64) {
65 override get message(): string {
66 return `Browser recording media capture for tab ${this.tabId} did not settle within ${this.timeoutMs}ms.`;
67 }
68}
69
70export class BrowserRecordingOperationError extends Schema.TaggedError<BrowserRecordingOperationError>()(
71 "BrowserRecordingOperationError",
72 {
73 operation: Schema.Literals([
74 "initialize-media-recorder",
75 "capture-media-stream",
76 "start-media-recorder",
77 "start-screencast",
78 "stop-screencast",
79 "wait-startup",
80 "stop-media-recorder",
81 "save-artifact",
82 "cleanup",
83 ]),
84 tabId: Schema.String,
85 cause: Schema.Defect(),
86 },
87) {
88 override get message(): string {
89 return `Browser recording operation ${this.operation} failed for tab ${this.tabId}.`;
90 }
91}
92
93const isBrowserRecordingOperationError = Schema.is(BrowserRecordingOperationError);
94const isBrowserRecordingCaptureTimeoutError = Schema.is(BrowserRecordingCaptureTimeoutError);
95export const isBrowserRecordingStartCancelledError = Schema.is(BrowserRecordingStartCancelledError);
96
97interface StartingBrowserRecordingLifecycle {
98 readonly phase: "starting";
99 queuedForGrant: boolean | null;
100 grantStarted: boolean;
101 stopRequestedBeforeGrant: boolean;
102 cancelledBeforeGrant: boolean;
103 readonly cancelledBeforeGrantSignal: Promise<void>;
104 readonly cancelBeforeGrant: () => void;
105 readonly setQueuedForGrant: (queued: boolean) => void;
106}
107
108type BrowserRecordingLifecycle =
109 | StartingBrowserRecordingLifecycle
110 | { readonly phase: "recording" }
111 | {
112 readonly phase: "stopping";
113 readonly stopPromise: Promise<DesktopPreviewRecordingArtifact | null>;
114 };
115
116interface ActiveRecording {
117 /** Desktop-scoped identity used by the native capture lease. */
118 readonly tabId: string;
119 /** Server-local identity returned by preview automation tools. */
120 readonly serverTabId: string;
121 readonly threadRef: ScopedThreadRef | null;
122 readonly chunks: Blob[];
123 readonly startedAt: string;
124 readonly startupSettled: Promise<void>;
125 releaseSurfaceActivity: (() => void) | null;
126 stream: MediaStream | null;
127 recorder: MediaRecorder | null;
128 compositor: Awaited<ReturnType<typeof createRecordingCompositor>>;
129 savedBlob?: Blob;
130 uploadPromise?: Promise<string>;
131 lifecycle: BrowserRecordingLifecycle;
132}
133
134export interface ActiveBrowserRecordingTarget {
135 readonly runtimeTabId: string;
136 readonly serverTabId: string;
137}
138
139interface ActiveBrowserRecordingIndex {
140 readonly tabIds: ReadonlySet<string>;
141}
142
143const activeBrowserRecordingTabIdsAtom = Atom.make<ActiveBrowserRecordingIndex>({
144 tabIds: new Set<string>(),
145}).pipe(Atom.keepAlive, Atom.withLabel("preview:active-browser-recording-tabs"));
146
147export function useActiveBrowserRecordingTabIds(): ReadonlySet<string> {
148 return useAtomValue(activeBrowserRecordingTabIdsAtom).tabIds;
149}
150
151const activeRecordings = new Map<string, ActiveRecording>();
152let displayMediaGrantTail = Promise.resolve();
153let displayMediaGrantQueueDepth = 0;
154
155const makeStartingBrowserRecordingLifecycle = (): StartingBrowserRecordingLifecycle => {
156 let signalCancellation!: () => void;
157 const cancelledBeforeGrantSignal = new Promise<void>((resolve) => {
158 signalCancellation = resolve;
159 });
160 const lifecycle: StartingBrowserRecordingLifecycle = {
161 phase: "starting",
162 queuedForGrant: null,
163 grantStarted: false,
164 stopRequestedBeforeGrant: false,
165 cancelledBeforeGrant: false,
166 cancelledBeforeGrantSignal,
167 cancelBeforeGrant: () => {
168 // Queue position is unknown during paint/settings warmup. Keep the stop request so a start
169 // that later turns out to be contended can still be cancelled before native capture.
170 lifecycle.stopRequestedBeforeGrant = true;
171 if (lifecycle.queuedForGrant && !lifecycle.grantStarted && !lifecycle.cancelledBeforeGrant) {
172 lifecycle.cancelledBeforeGrant = true;
173 signalCancellation();
174 }
175 },
176 setQueuedForGrant: (queued) => {
177 lifecycle.queuedForGrant = queued;
178 if (queued && lifecycle.stopRequestedBeforeGrant) lifecycle.cancelBeforeGrant();
179 },
180 };
181 return lifecycle;
182};
183
184const queueDisplayMediaGrant = <T>(
185 useGrant: () => Promise<T>,
186): { readonly queued: boolean; readonly result: Promise<T> } => {
187 const queued = displayMediaGrantQueueDepth > 0;
188 displayMediaGrantQueueDepth += 1;
189 const result = displayMediaGrantTail.then(useGrant);
190 const settleGrant = () => {
191 displayMediaGrantQueueDepth -= 1;
192 };
193 displayMediaGrantTail = result.then(
194 () => settleGrant(),
195 () => settleGrant(),
196 );
197 return { queued, result };
198};
199
200const publishActiveRecordingTabIds = (): void => {
201 appAtomRegistry.set(activeBrowserRecordingTabIdsAtom, {
202 tabIds: new Set(activeRecordings.keys()),
203 });
204};
205
206export const BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS = 5_000;
207export const BROWSER_RECORDING_PAINT_SETTLE_TIMEOUT_MS = 250;
208
209export function readActiveBrowserRecordingTabIds(threadRef?: ScopedThreadRef): ReadonlySet<string> {
210 const tabIds = new Set<string>();
211 for (const recording of activeRecordings.values()) {
212 if (
213 threadRef === undefined ||
214 (recording.threadRef?.environmentId === threadRef.environmentId &&
215 recording.threadRef.threadId === threadRef.threadId)
216 ) {
217 tabIds.add(recording.tabId);
218 }
219 }
220 return tabIds;
221}
222
223export function readActiveBrowserRecordingTargets(
224 threadRef: ScopedThreadRef,
225): ReadonlyArray<ActiveBrowserRecordingTarget> {
226 return Array.from(activeRecordings.values()).flatMap((recording) =>
227 recording.threadRef?.environmentId === threadRef.environmentId &&
228 recording.threadRef.threadId === threadRef.threadId
229 ? [{ runtimeTabId: recording.tabId, serverTabId: recording.serverTabId }]
230 : [],
231 );
232}
233
234export function findActiveBrowserRecordingRuntimeTabId(
235 threadRef: ScopedThreadRef,
236 serverTabId: string,
237): string | null {
238 return (
239 readActiveBrowserRecordingTargets(threadRef).find(
240 (recording) => recording.serverTabId === serverTabId,
241 )?.runtimeTabId ?? null
242 );
243}
244
245const preferredMimeTypes = [
246 "video/mp4;codecs=avc1",
247 "video/mp4;codecs=avc1.640028",
248 "video/mp4;codecs=avc1.42e01e",
249 "video/webm;codecs=vp9",
250 "video/webm;codecs=vp8",
251 "video/webm",
252] as const;
253
254const createMediaRecorder = (stream: MediaStream): MediaRecorder => {
255 const mimeType = preferredMimeTypes.find((candidate) => MediaRecorder.isTypeSupported(candidate));
256 const settings = stream.getVideoTracks()[0]?.getSettings();
257 // Browser defaults under-budget native-resolution text and motion. Scale with captured pixels
258 // and frames, while bounding storage and encoder load for very large displays.
259 const videoBitsPerSecond = Math.round(
260 Math.min(
261 50_000_000,
262 Math.max(
263 2_500_000,
264 (settings?.width ?? 1920) * (settings?.height ?? 1080) * (settings?.frameRate ?? 30) * 0.05,
265 ),
266 ),
267 );
268 return new MediaRecorder(stream, { ...(mimeType ? { mimeType } : {}), videoBitsPerSecond });
269};
270
271const captureTabMediaStream = (frameRate: number): Promise<MediaStream> =>
272 // The desktop main process routes this request to the tab that `startScreencast` armed, so the
273 // stream already arrives at that tab's native size and needs no source or dimension constraints.
274 navigator.mediaDevices.getDisplayMedia({
275 audio: false,
276 video: { frameRate: { ideal: frameRate, max: frameRate } },
277 });
278
279const stopMediaRecorder = async (recorder: MediaRecorder | null): Promise<void> => {
280 if (!recorder || recorder.state === "inactive") return;
281 const stopped = new Promise<void>((resolve) =>
282 recorder.addEventListener("stop", () => resolve(), { once: true }),
283 );
284 recorder.stop();
285 await stopped;
286};
287
288const stopMediaStream = (stream: MediaStream | null): void => {
289 for (const track of stream?.getTracks() ?? []) track.stop();
290};
291
292interface PendingTabMediaCapture {
293 readonly start: () => void;
294}
295
296const pendingTabMediaCaptures = new Map<string, PendingTabMediaCapture>();
297
298const prepareTabMediaCapture = (tabId: string, frameRate: number) => {
299 let acceptStream = true;
300 let capturedStream: MediaStream | null = null;
301 let resolveCapture!: (stream: MediaStream | PromiseLike<MediaStream>) => void;
302 let rejectCapture!: (cause: unknown) => void;
303 const capturePromise = new Promise<MediaStream>((resolve, reject) => {
304 resolveCapture = resolve;
305 rejectCapture = reject;
306 }).then((stream) => {
307 capturedStream = stream;
308 if (!acceptStream) {
309 stopMediaStream(stream);
310 capturedStream = null;
311 }
312 return stream;
313 });
314 const pending: PendingTabMediaCapture = {
315 start: () => {
316 try {
317 // Electron invokes this callback through executeJavaScript(..., true), so even automated
318 // and delayed queued starts satisfy getDisplayMedia's transient-activation requirement.
319 resolveCapture(captureTabMediaStream(frameRate));
320 } catch (cause) {
321 rejectCapture(cause);
322 }
323 },
324 };
325 pendingTabMediaCaptures.set(tabId, pending);
326 return {
327 capturePromise,
328 cancel: () => {
329 acceptStream = false;
330 if (capturedStream) {
331 stopMediaStream(capturedStream);
332 capturedStream = null;
333 }
334 if (pendingTabMediaCaptures.get(tabId) === pending) pendingTabMediaCaptures.delete(tabId);
335 void capturePromise.catch(() => undefined);
336 },
337 };
338};
339
340const triggerTabMediaCapture = (tabId: unknown): boolean => {
341 if (typeof tabId !== "string") return false;
342 const pending = pendingTabMediaCaptures.get(tabId);
343 if (!pending) return false;
344 pendingTabMediaCaptures.delete(tabId);
345 pending.start();
346 return true;
347};
348
349Object.defineProperty(globalThis, DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER, {
350 configurable: true,
351 value: triggerTabMediaCapture,
352});
353
354const captureTabMediaStreamWithTimeout = async (
355 tabId: string,
356 capturePromise: Promise<MediaStream>,
357): Promise<MediaStream> => {
358 let acceptStream = true;
359 let timeoutId: number | null = null;
360 const streamPromise = capturePromise.then((stream) => {
361 if (!acceptStream) stopMediaStream(stream);
362 return stream;
363 });
364 try {
365 return await Promise.race([
366 streamPromise,
367 new Promise<never>((_, reject) => {
368 timeoutId = window.setTimeout(
369 () =>
370 reject(
371 new BrowserRecordingCaptureTimeoutError({
372 tabId,
373 timeoutMs: BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS,
374 }),
375 ),
376 BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS,
377 );
378 }),
379 ]);
380 } finally {
381 acceptStream = false;
382 if (timeoutId !== null) window.clearTimeout(timeoutId);
383 }
384};
385
386const clearActiveRecording = (recording: ActiveRecording): void => {
387 recording.compositor?.dispose();
388 recording.compositor = null;
389 recording.releaseSurfaceActivity?.();
390 recording.releaseSurfaceActivity = null;
391 if (activeRecordings.get(recording.tabId) !== recording) return;
392 activeRecordings.delete(recording.tabId);
393 publishActiveRecordingTabIds();
394};
395
396const waitForBrowserRecordingPaint = async (): Promise<void> => {
397 let firstFrameId: number | null = null;
398 let secondFrameId: number | null = null;
399 let timeoutId: number | null = null;
400 const painted = new Promise<void>((resolve) => {
401 firstFrameId = window.requestAnimationFrame(() => {
402 firstFrameId = null;
403 secondFrameId = window.requestAnimationFrame(() => {
404 secondFrameId = null;
405 resolve();
406 });
407 });
408 });
409 const timedOut = new Promise<void>((resolve) => {
410 timeoutId = window.setTimeout(resolve, BROWSER_RECORDING_PAINT_SETTLE_TIMEOUT_MS);
411 });
412 try {
413 await Promise.race([painted, timedOut]);
414 } finally {
415 if (timeoutId !== null) window.clearTimeout(timeoutId);
416 if (firstFrameId !== null) window.cancelAnimationFrame(firstFrameId);
417 if (secondFrameId !== null) window.cancelAnimationFrame(secondFrameId);
418 }
419};
420
421const cleanupFailedRecordingStart = async (
422 bridge: NonNullable<typeof previewBridge>,
423 recording: ActiveRecording,
424): Promise<unknown | undefined> => {
425 const errors: unknown[] = [];
426 try {
427 await bridge.recording.stopScreencast(recording.tabId);
428 } catch (error) {
429 errors.push(error);
430 }
431 try {
432 await stopMediaRecorder(recording.recorder);
433 } catch (error) {
434 errors.push(error);
435 }
436 try {
437 stopMediaStream(recording.stream);
438 } catch (error) {
439 errors.push(error);
440 } finally {
441 clearActiveRecording(recording);
442 }
443 if (errors.length === 0) return undefined;
444 if (errors.length === 1) return errors[0];
445 return new AggregateError(
446 errors,
447 `Browser recording startup cleanup failed for tab ${recording.tabId}.`,
448 { cause: errors[0] },
449 );
450};
451
452const recordingStartupCancelledError = (
453 recording: ActiveRecording,
454 cause: unknown = new Error(`Browser recording startup was cancelled for tab ${recording.tabId}.`),
455): BrowserRecordingOperationError =>
456 new BrowserRecordingOperationError({
457 operation: "start-screencast",
458 tabId: recording.tabId,
459 cause,
460 });
461
462const isRecordingStarting = (recording: ActiveRecording): boolean =>
463 activeRecordings.get(recording.tabId) === recording && recording.lifecycle.phase === "starting";
464
465const waitForRecordingStartupToSettle = async (recording: ActiveRecording): Promise<void> => {
466 let timeout: ReturnType<typeof setTimeout> | null = null;
467 try {
468 await Promise.race([
469 recording.startupSettled,
470 new Promise<void>((_, reject) => {
471 timeout = setTimeout(() => {
472 reject(new Error(`Browser recording startup did not settle for tab ${recording.tabId}.`));
473 }, BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS);
474 }),
475 ]);
476 } catch (cause) {
477 throw new BrowserRecordingOperationError({
478 operation: "wait-startup",
479 tabId: recording.tabId,
480 cause,
481 });
482 } finally {
483 if (timeout !== null) clearTimeout(timeout);
484 }
485};
486
487const isStartupWaitTimeout = (error: unknown): error is BrowserRecordingOperationError =>
488 isBrowserRecordingOperationError(error) && error.operation === "wait-startup";
489
490export async function startBrowserRecording(
491 tabId: string,
492 threadRef: ScopedThreadRef | null = null,
493 serverTabId = tabId,
494): Promise<string> {
495 const bridge = previewBridge;
496 if (!bridge) throw new BrowserRecordingUnavailableError({ tabId });
497 const activeRecording = activeRecordings.get(tabId);
498 if (activeRecording) {
499 if (activeRecording.lifecycle.phase === "recording") {
500 return activeRecording.startedAt;
501 }
502 throw new BrowserRecordingConflictError({
503 requestedTabId: tabId,
504 activeTabId: activeRecording.tabId,
505 });
506 }
507 const activeLogicalRecording =
508 threadRef === null ? null : findActiveBrowserRecordingRuntimeTabId(threadRef, serverTabId);
509 if (activeLogicalRecording !== null) {
510 throw new BrowserRecordingConflictError({
511 requestedTabId: tabId,
512 activeTabId: activeLogicalRecording,
513 });
514 }
515 const startedAt = new Date().toISOString();
516 const chunks: Blob[] = [];
517 let settleStartup: (() => void) | undefined;
518 const startupSettled = new Promise<void>((resolve) => {
519 settleStartup = resolve;
520 });
521 const startingLifecycle = makeStartingBrowserRecordingLifecycle();
522 const releaseSurfaceActivity = acquireBrowserSurfaceActivity(tabId);
523 const recording: ActiveRecording = {
524 tabId,
525 serverTabId,
526 threadRef,
527 chunks,
528 startedAt,
529 startupSettled,
530 releaseSurfaceActivity,
531 stream: null,
532 recorder: null,
533 compositor: null,
534 lifecycle: startingLifecycle,
535 };
536 activeRecordings.set(tabId, recording);
537 publishActiveRecordingTabIds();
538 try {
539 await ensureClientSettingsHydrated().catch((cause: unknown) => {
540 clearActiveRecording(recording);
541 throw cause;
542 });
543 const settings = getClientSettings();
544 const frameRate = settings.browserRecordingFrameRate;
545 await waitForBrowserRecordingPaint();
546 const throwIfStartupCancelled = async (): Promise<void> => {
547 // Once a grant starts, a stop lets startup finish so the caller receives an artifact.
548 // Only a contended start can be cancelled before it reaches native capture.
549 if (activeRecordings.get(tabId) === recording) return;
550 try {
551 await bridge.recording.stopScreencast(tabId);
552 } catch (cause) {
553 throw recordingStartupCancelledError(
554 recording,
555 new AggregateError(
556 [new Error(`Browser recording startup was cancelled for tab ${tabId}.`), cause],
557 `Browser recording startup cancellation failed for tab ${tabId}.`,
558 { cause },
559 ),
560 );
561 }
562 throw recordingStartupCancelledError(recording);
563 };
564 // The desktop process exposes one display-media grant at a time. Keep only the
565 // arm-to-capture handoff exclusive; acquired streams can record concurrently.
566 const grant = queueDisplayMediaGrant(async () => {
567 if (startingLifecycle.cancelledBeforeGrant) {
568 throw new BrowserRecordingStartCancelledError({ tabId });
569 }
570 startingLifecycle.grantStarted = true;
571 await throwIfStartupCancelled();
572 const capture = prepareTabMediaCapture(tabId, frameRate);
573 try {
574 await bridge.recording.startScreencast(tabId);
575 } catch (cause) {
576 capture.cancel();
577 if (!isRecordingStarting(recording)) {
578 throw recordingStartupCancelledError(recording, cause);
579 }
580 clearActiveRecording(recording);
581 throw new BrowserRecordingOperationError({
582 operation: "start-screencast",
583 tabId,
584 cause,
585 });
586 }
587 try {
588 await throwIfStartupCancelled();
589 } catch (cause) {
590 capture.cancel();
591 throw cause;
592 }
593 try {
594 recording.stream = await captureTabMediaStreamWithTimeout(tabId, capture.capturePromise);
595 return recording.stream;
596 } catch (cause) {
597 const cleanupCause = await cleanupFailedRecordingStart(bridge, recording);
598 if (isBrowserRecordingCaptureTimeoutError(cause) && cleanupCause === undefined) throw cause;
599 throw new BrowserRecordingOperationError({
600 operation: "capture-media-stream",
601 tabId,
602 cause:
603 cleanupCause === undefined
604 ? cause
605 : new AggregateError(
606 [cause, cleanupCause],
607 `Browser media capture and cleanup failed for tab ${tabId}.`,
608 { cause },
609 ),
610 });
611 }
612 });
613 startingLifecycle.setQueuedForGrant(grant.queued);
614 const stream = await Promise.race([
615 grant.result,
616 startingLifecycle.cancelledBeforeGrantSignal.then(() => {
617 throw new BrowserRecordingStartCancelledError({ tabId });
618 }),
619 ]);
620 await throwIfStartupCancelled();
621
622 let recorder: MediaRecorder;
623 try {
624 recording.compositor = await createRecordingCompositor(
625 stream,
626 {
627 showKeyPresses: settings.browserRecordingShowKeyPresses,
628 showMousePresses: settings.browserRecordingShowMousePresses,
629 frameRate,
630 },
631 (listener) =>
632 bridge.recording.onInput((event) => {
633 if (event.tabId === tabId) listener(event.input);
634 }),
635 );
636 recorder = createMediaRecorder(recording.compositor?.stream ?? stream);
637 recording.recorder = recorder;
638 recorder.addEventListener("dataavailable", (event) => {
639 if (event.data.size > 0) chunks.push(event.data);
640 });
641 } catch (cause) {
642 const cleanupCause = await cleanupFailedRecordingStart(bridge, recording);
643 throw new BrowserRecordingOperationError({
644 operation: "initialize-media-recorder",
645 tabId,
646 cause:
647 cleanupCause === undefined
648 ? cause
649 : new AggregateError(
650 [cause, cleanupCause],
651 `Browser recording initialization and cleanup failed for tab ${tabId}.`,
652 { cause },
653 ),
654 });
655 }
656 try {
657 recorder.start(1_000);
658 } catch (cause) {
659 const cleanupCause = await cleanupFailedRecordingStart(bridge, recording);
660 throw new BrowserRecordingOperationError({
661 operation: "start-media-recorder",
662 tabId,
663 cause:
664 cleanupCause === undefined
665 ? cause
666 : new AggregateError(
667 [cause, cleanupCause],
668 `Browser media recorder start and cleanup failed for tab ${tabId}.`,
669 { cause },
670 ),
671 });
672 }
673 if (recording.lifecycle.phase === "starting") {
674 recording.lifecycle = { phase: "recording" };
675 }
676 return startedAt;
677 } finally {
678 settleStartup?.();
679 }
680}
681
682const finalizeBrowserRecording = async (
683 bridge: NonNullable<typeof previewBridge>,
684 recording: ActiveRecording,
685): Promise<DesktopPreviewRecordingArtifact | null> => {
686 const { tabId } = recording;
687 let result:
688 | {
689 readonly _tag: "Success";
690 readonly artifact: DesktopPreviewRecordingArtifact | null;
691 }
692 | { readonly _tag: "Failure"; readonly error: unknown };
693 try {
694 await waitForRecordingStartupToSettle(recording);
695 try {
696 await bridge.recording.stopScreencast(tabId);
697 } catch (cause) {
698 throw new BrowserRecordingOperationError({
699 operation: "stop-screencast",
700 tabId,
701 cause,
702 });
703 }
704 if (!recording.recorder) {
705 result = { _tag: "Success", artifact: null };
706 } else {
707 try {
708 await stopMediaRecorder(recording.recorder);
709 } catch (cause) {
710 throw new BrowserRecordingOperationError({
711 operation: "stop-media-recorder",
712 tabId,
713 cause,
714 });
715 }
716 recording.compositor?.dispose();
717 recording.compositor = null;
718 // Encoding has flushed; release native capture before materializing and saving the file.
719 stopMediaStream(recording.stream);
720 recording.stream = null;
721 const mimeType =
722 recording.recorder.mimeType ||
723 recording.chunks.find((chunk) => chunk.type.length > 0)?.type;
724 if (!mimeType) {
725 throw new BrowserRecordingFormatUnavailableError({ tabId });
726 }
727 try {
728 const blob = new Blob(recording.chunks, { type: mimeType });
729 const artifact = await bridge.recording.save(
730 tabId,
731 mimeType,
732 new Uint8Array(await blob.arrayBuffer()),
733 );
734 recording.savedBlob = blob;
735 result = { _tag: "Success", artifact };
736 } catch (cause) {
737 throw new BrowserRecordingOperationError({
738 operation: "save-artifact",
739 tabId,
740 cause,
741 });
742 }
743 }
744 } catch (error) {
745 result = { _tag: "Failure", error };
746 }
747
748 if (result._tag === "Failure" && isStartupWaitTimeout(result.error)) {
749 // Do not clear `active` yet. The renderer-side start promise can still
750 // resolve later, and its cancellation path will call `stopScreencast`.
751 // Keeping the slot reserved prevents a newer recording for this tab from
752 // being started and then accidentally stopped by the older late cleanup.
753 throw result.error;
754 }
755
756 const cleanupErrors: unknown[] = [];
757 try {
758 await stopMediaRecorder(recording.recorder);
759 } catch (cause) {
760 cleanupErrors.push(cause);
761 }
762 try {
763 stopMediaStream(recording.stream);
764 } catch (cause) {
765 cleanupErrors.push(cause);
766 } finally {
767 clearActiveRecording(recording);
768 }
769 const cleanupError =
770 cleanupErrors.length === 0
771 ? undefined
772 : new BrowserRecordingOperationError({
773 operation: "cleanup",
774 tabId,
775 cause:
776 cleanupErrors.length === 1
777 ? cleanupErrors[0]
778 : new AggregateError(
779 cleanupErrors,
780 `Browser recording media cleanup failed for tab ${tabId}.`,
781 { cause: cleanupErrors[0] },
782 ),
783 });
784
785 if (result._tag === "Failure") {
786 if (cleanupError) {
787 throw new BrowserRecordingOperationError({
788 operation: "cleanup",
789 tabId,
790 cause: new AggregateError(
791 [result.error, cleanupError],
792 `Browser recording stop and cleanup failed for tab ${tabId}.`,
793 { cause: result.error },
794 ),
795 });
796 }
797 throw result.error;
798 }
799 if (cleanupError) throw cleanupError;
800 return result.artifact;
801};
802
803const discardBrowserRecording = async (
804 bridge: NonNullable<typeof previewBridge>,
805 recording: ActiveRecording,
806): Promise<null> => {
807 try {
808 await bridge.recording.stopScreencast(recording.tabId).catch(() => undefined);
809 await stopMediaRecorder(recording.recorder).catch(() => undefined);
810 stopMediaStream(recording.stream);
811 return null;
812 } finally {
813 clearActiveRecording(recording);
814 }
815};
816
817export function stopBrowserRecording(
818 tabId: string,
819): Promise<DesktopPreviewRecordingArtifact | null> {
820 const bridge = previewBridge;
821 const recording = activeRecordings.get(tabId);
822 if (!bridge || !recording) return Promise.resolve(null);
823 if (recording.lifecycle.phase === "stopping") return recording.lifecycle.stopPromise;
824 if (recording.lifecycle.phase === "starting") recording.lifecycle.cancelBeforeGrant();
825
826 const stopPromise = Promise.resolve()
827 .then(() => finalizeBrowserRecording(bridge, recording))
828 .catch((error) => {
829 if (isStartupWaitTimeout(error) && activeRecordings.get(recording.tabId) === recording) {
830 const cleanupAfterStartup = recording.startupSettled.then(() =>
831 discardBrowserRecording(bridge, recording),
832 );
833 recording.lifecycle = { phase: "stopping", stopPromise: cleanupAfterStartup };
834 void cleanupAfterStartup.catch(() => undefined);
835 }
836 throw error;
837 });
838 recording.lifecycle = { phase: "stopping", stopPromise };
839 return stopPromise;
840}
841
842/** Joins local stops and shares one upload among concurrent automation requests. */
843export async function stopBrowserRecordingForUpload(
844 tabId: string,
845 upload: (artifact: DesktopPreviewRecordingArtifact, blob: Blob) => Promise<string>,
846): Promise<(DesktopPreviewRecordingArtifact & { uploadedAttachmentId: string }) | null> {
847 const recording = activeRecordings.get(tabId);
848 if (!recording) return null;
849 const artifact = await stopBrowserRecording(tabId);
850 if (!artifact || !recording.savedBlob) return null;
851 recording.uploadPromise ??= upload(artifact, recording.savedBlob);
852 return { ...artifact, uploadedAttachmentId: await recording.uploadPromise };
853}