Skip to content
Source
Sign inSign up

browserRecording.ts

843 lines26.8 KB

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