diff --git a/apps/chrome-extension/.gitignore b/apps/chrome-extension/.gitignore
new file mode 100644
index 00000000000..aaa9103e44a
--- /dev/null
+++ b/apps/chrome-extension/.gitignore
@@ -0,0 +1,2 @@
+test-results/
+playwright-report/
diff --git a/apps/chrome-extension/STORE_LISTING.md b/apps/chrome-extension/STORE_LISTING.md
new file mode 100644
index 00000000000..314c176b231
--- /dev/null
+++ b/apps/chrome-extension/STORE_LISTING.md
@@ -0,0 +1,145 @@
+# Chrome Web Store Listing
+
+Everything below is ready to paste into the Chrome Web Store Developer Dashboard. Keep claims in sync with the extension version being submitted.
+
+## Store Fields
+
+Name: Cap - Screen Recorder & Screen Capture
+
+Short name: Cap
+
+Summary (132 char max, mirrors manifest description): Free, open source screen recorder. Capture your screen, tab, camera & mic in Chrome and share a video link the moment you stop.
+
+Category: Productivity → Communication
+
+Language: English
+
+Website: https://cap.so
+
+Support: https://cap.so/docs
+
+Privacy policy: https://cap.so/privacy
+
+## Detailed Description (paste as plain text)
+
+Cap is the open source screen recorder for Chrome. Record your screen, a window, the current browser tab, or camera-only video. Your recording uploads while you record, so a shareable link is ready the moment you stop. No exports, no waiting, no switching tools.
+
+WHY PEOPLE SWITCH TO CAP
+
+Cap is a Loom alternative built on a simple idea: your recordings belong to you. Cap is fully open source, lets you connect your own storage, and gives you a fast, lightweight recorder that stays out of your way.
+
+• Truly open source: inspect every line of code, contribute, or self-host the entire stack.
+• Own your recordings: use Cap Cloud or connect your own S3 bucket. No vendor lock-in, ever.
+• Instant share links: video uploads as you record, so the link is live the second you stop.
+• Cap AI: auto-generated titles, summaries, clickable chapters, and searchable transcripts for every recording.
+• Built for teams: comments, reactions, viewer analytics, password-protected shares, custom domains, and team workspaces.
+• Switching from Loom? Import your existing Loom videos directly into Cap with the built-in importer.
+
+WHAT YOU CAN RECORD
+
+• Current browser tab: perfect for web app demos and bug reports.
+• Full screen or a single window.
+• Camera-only video for quick personal updates.
+• Microphone audio, system audio (where Chrome supports it), and a webcam overlay for walkthroughs.
+
+MADE FOR EVERYDAY VIDEO
+
+Screen recording for async standups, code reviews, bug reports, product demos, customer support, onboarding, tutorials, design feedback, and sales outreach. Record once, share a link, and skip the meeting.
+
+HOW IT WORKS
+
+1. Click the Cap icon and pick tab, screen, window, or camera.
+2. Choose your microphone and camera, then hit record.
+3. Stop recording. Your video is already uploaded to your Cap workspace and the share link is ready.
+
+CAP EVERYWHERE
+
+The extension is part of the Cap platform: native desktop apps for macOS and Windows, a web recorder, and a shared library at cap.so. Recordings stay connected to your Cap account wherever you capture them.
+
+Cap is free to get started. Upgrade to Cap Pro for unlimited recording length, Cap AI, custom domains, custom S3 storage, and team features.
+
+Open source on GitHub: https://github.com/CapSoftware/Cap
+
+## SEO Positioning
+
+Primary keywords (use naturally in the description, never stuffed):
+
+- screen recorder / screen recorder for Chrome
+- screen capture / screen recording
+- record browser tab
+- screen recorder with camera and microphone
+- webcam recorder
+- video messaging / async video
+- Loom alternative (used exactly once in the detailed description; never in the name, summary, or screenshot text)
+
+Name strategy: "Cap - Screen Recorder & Screen Capture" leads with the brand and covers the two highest-volume queries, matching how the top competitor titles its listing without keyword spam.
+
+Summary strategy: front-loads "free, open source screen recorder" (differentiator + keyword), lists capture surfaces, and ends on the instant-share-link benefit.
+
+Review-safety notes:
+
+- Keep every claim true for the submitted build (system audio is "where Chrome supports it").
+- Do not mention competitor names in metadata fields other than the single description mention.
+- Do not claim "unlimited free": free tier has limits; Pro removes them.
+
+## Icons
+
+`public/icons/` is generated from the brand mark `apps/web/public/logos/logo-solo.svg`:
+
+- icon-16/32/48: full-bleed, used for toolbar and favicon contexts.
+- icon-128: 96x96 artwork centered with 16px transparent padding, per Chrome Web Store icon guidelines (this is the store listing icon).
+- icon-256: same padded treatment at 2x.
+
+Regenerate with rsvg-convert:
+
+```sh
+cd apps/chrome-extension/public/icons
+for s in 16 32 48; do rsvg-convert -w $s -h $s ../../../web/public/logos/logo-solo.svg -o icon-$s.png; done
+rsvg-convert -w 96 -h 96 --page-width 128 --page-height 128 --top 16 --left 16 ../../../web/public/logos/logo-solo.svg -o icon-128.png
+rsvg-convert -w 192 -h 192 --page-width 256 --page-height 256 --top 32 --left 32 ../../../web/public/logos/logo-solo.svg -o icon-256.png
+```
+
+## Promotional Images
+
+Store sources live in `store-assets/`. PNGs are full-bleed with square corners, as the store requires.
+
+- promo-small.png: 440x280 small promo tile (required).
+- promo-marquee.png: 1400x560 marquee tile (optional, needed for feature placement).
+
+The SVG sources use the Neue Montreal fonts bundled in `public/fonts`. Regenerate with a fontconfig file that points at that directory:
+
+```sh
+cat > /tmp/cap-fonts.conf <<'EOF'
+
+
+
+ /path/to/Cap/apps/chrome-extension/public/fonts
+ /System/Library/Fonts
+ /tmp/cap-fc-cache
+
+EOF
+cd apps/chrome-extension/store-assets
+FONTCONFIG_FILE=/tmp/cap-fonts.conf rsvg-convert -w 440 -h 280 promo-small.svg -o promo-small.png
+FONTCONFIG_FILE=/tmp/cap-fonts.conf rsvg-convert -w 1400 -h 560 promo-marquee.svg -o promo-marquee.png
+```
+
+## Screenshot Plan
+
+Use 1280x800 PNG screenshots, full bleed, square corners, no padding. Capture against a real page, not a blank tab.
+
+1. In-page recorder panel open with the recording mode selector (tab / screen / camera) visible.
+2. Recording setup showing microphone and camera selectors plus the system audio toggle.
+3. Recording in progress with the floating recording bar and webcam overlay on a real page.
+4. Upload/completion state with the share-link handoff.
+5. The Cap share page (player, transcript/chapters) after a recording is available.
+
+## Review Notes: Permission Justifications
+
+- activeTab and scripting: show the recording controls overlay on the page the user is recording.
+- tabCapture: record the current browser tab when the user selects tab recording.
+- offscreen: run capture, encoding, and upload outside the recorder panel so recordings survive closing it.
+- storage: remember account session, selected devices, and recording preferences.
+- identity: sign the user in to their Cap account.
+- host permissions: inject the recording overlay and camera preview on pages the user chooses to record.
+
+Submission checklist: icon present (128px padded), summary under 132 chars, at least one 1280x800 screenshot, small promo tile uploaded, privacy policy URL set, permission justifications filled in.
diff --git a/apps/chrome-extension/camera-permission.html b/apps/chrome-extension/camera-permission.html
new file mode 100644
index 00000000000..5451ff7bf26
--- /dev/null
+++ b/apps/chrome-extension/camera-permission.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Camera access - Cap
+
+
+
+
+
+
diff --git a/apps/chrome-extension/camera-preview.html b/apps/chrome-extension/camera-preview.html
new file mode 100644
index 00000000000..ca9d94312f6
--- /dev/null
+++ b/apps/chrome-extension/camera-preview.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Cap Camera Preview
+
+
+
+
+
+
diff --git a/apps/chrome-extension/e2e/overlay-ui.spec.ts b/apps/chrome-extension/e2e/overlay-ui.spec.ts
new file mode 100644
index 00000000000..dc4a287ee86
--- /dev/null
+++ b/apps/chrome-extension/e2e/overlay-ui.spec.ts
@@ -0,0 +1,479 @@
+import { mkdtemp, rm } from "node:fs/promises";
+import {
+ createServer,
+ type IncomingMessage,
+ type ServerResponse,
+} from "node:http";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import {
+ type BrowserContext,
+ chromium,
+ expect,
+ type Page,
+ test,
+} from "@playwright/test";
+
+type ChromeGlobal = typeof globalThis & {
+ chrome: {
+ runtime: {
+ lastError?: { message?: string };
+ sendMessage(
+ message: unknown,
+ callback: (response: unknown) => void,
+ ): void;
+ };
+ tabs: {
+ query(
+ queryInfo: Record,
+ callback: (tabs: Array<{ id?: number; url?: string }>) => void,
+ ): void;
+ sendMessage(
+ tabId: number,
+ message: unknown,
+ callback?: (response: unknown) => void,
+ ): void;
+ };
+ storage: {
+ local: {
+ clear(callback?: () => void): void;
+ set(items: Record, callback?: () => void): void;
+ };
+ };
+ };
+};
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const extensionPath = path.resolve(__dirname, "../dist");
+const SETTINGS_KEY = "cap-extension-settings";
+const AUTH_KEY = "cap-extension-auth";
+const BOOTSTRAP_CACHE_KEY = "cap-extension-bootstrap-cache";
+
+const readRequestBody = async (request: IncomingMessage) => {
+ const chunks: Buffer[] = [];
+ for await (const chunk of request) {
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
+ }
+ return Buffer.concat(chunks);
+};
+
+const sendJson = (
+ response: ServerResponse,
+ status: number,
+ body: Record,
+) => {
+ response.writeHead(status, {
+ "Access-Control-Allow-Headers":
+ "Authorization, Content-Type, Content-Range",
+ "Access-Control-Allow-Methods": "DELETE, GET, OPTIONS, POST, PUT",
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Expose-Headers": "ETag",
+ "Content-Type": "application/json",
+ });
+ response.end(JSON.stringify(body));
+};
+
+const createMockCapServer = async (
+ options: { failInstantRecordings?: boolean } = {},
+) => {
+ const server = createServer(async (request, response) => {
+ const url = new URL(request.url ?? "/", "http://127.0.0.1");
+ if (request.method === "OPTIONS") {
+ sendJson(response, 204, {});
+ return;
+ }
+ if (request.method === "GET" && url.pathname === "/capture.html") {
+ response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
+ response.end(
+ `Cap Close UI Target Close UI page `,
+ );
+ return;
+ }
+ if (
+ request.method === "GET" &&
+ url.pathname === "/api/extension/bootstrap"
+ ) {
+ sendJson(response, 200, {
+ user: { id: "user-e2e", email: "e2e@cap.test" },
+ organization: { id: "org-e2e", name: "E2E" },
+ plan: { isPro: true, maxRecordingSeconds: 600 },
+ });
+ return;
+ }
+ if (
+ request.method === "POST" &&
+ url.pathname === "/api/extension/instant-recordings"
+ ) {
+ await readRequestBody(request);
+ if (options.failInstantRecordings) {
+ sendJson(response, 500, { error: "mock instant recording failure" });
+ return;
+ }
+ sendJson(response, 200, {
+ id: "video-close-ui",
+ shareUrl: `${origin}/share/video-close-ui`,
+ upload: { type: "multipart" },
+ });
+ return;
+ }
+ if (
+ request.method === "POST" &&
+ url.pathname === "/api/upload/multipart/initiate"
+ ) {
+ await readRequestBody(request);
+ sendJson(response, 200, { uploadId: "upload-close", provider: "s3" });
+ return;
+ }
+ if (
+ request.method === "POST" &&
+ url.pathname === "/api/upload/multipart/presign-part"
+ ) {
+ await readRequestBody(request);
+ sendJson(response, 200, {
+ presignedUrl: `${origin}/mock-s3/part`,
+ provider: "s3",
+ });
+ return;
+ }
+ if (request.method === "PUT" && url.pathname.startsWith("/mock-s3/")) {
+ await readRequestBody(request);
+ response.writeHead(200, {
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Expose-Headers": "ETag",
+ ETag: '"etag-close"',
+ });
+ response.end();
+ return;
+ }
+ await readRequestBody(request).catch(() => undefined);
+ sendJson(response, 200, { success: true });
+ });
+
+ let origin = "";
+ await new Promise((resolve, reject) => {
+ server.listen(0, "127.0.0.1", () => {
+ const address = server.address();
+ if (!address || typeof address === "string") {
+ reject(new Error("no address"));
+ return;
+ }
+ origin = `http://127.0.0.1:${address.port}`;
+ resolve();
+ });
+ });
+
+ return {
+ origin,
+ close: () =>
+ new Promise((resolve, reject) => {
+ server.close((error) => (error ? reject(error) : resolve()));
+ }),
+ };
+};
+
+const launchExtensionContext = async () => {
+ const userDataDir = await mkdtemp(path.join(tmpdir(), "cap-close-e2e-"));
+ const context = await chromium.launchPersistentContext(userDataDir, {
+ channel: "chromium",
+ headless: true,
+ args: [
+ `--disable-extensions-except=${extensionPath}`,
+ `--load-extension=${extensionPath}`,
+ "--allow-http-screen-capture",
+ "--auto-select-tab-capture-source-by-title=Cap Close UI Target",
+ "--auto-select-desktop-capture-source=Cap Close UI Target",
+ "--enable-usermedia-screen-capturing",
+ "--autoplay-policy=no-user-gesture-required",
+ "--use-fake-device-for-media-stream",
+ "--use-fake-ui-for-media-stream",
+ ],
+ });
+ const cleanup = async () => {
+ await context.close();
+ await rm(userDataDir, { recursive: true, force: true });
+ };
+ return { context, cleanup };
+};
+
+const getServiceWorker = async (context: BrowserContext) => {
+ const existing = context
+ .serviceWorkers()
+ .find((worker) => worker.url().includes("assets/service-worker.js"));
+ if (existing) return existing;
+ return context.waitForEvent("serviceworker", (worker) =>
+ worker.url().includes("assets/service-worker.js"),
+ );
+};
+
+const configureExtension = async (
+ worker: Awaited>,
+ apiBaseUrl: string,
+) => {
+ await worker.evaluate(
+ async ({ authKey, bootstrapKey, settingsKey, apiBaseUrl }) => {
+ const chromeApi = (globalThis as ChromeGlobal).chrome;
+ await new Promise((resolve) =>
+ chromeApi.storage.local.clear(() => resolve()),
+ );
+ await new Promise((resolve) =>
+ chromeApi.storage.local.set(
+ {
+ [authKey]: { authApiKey: "auth-e2e", userId: "user-e2e" },
+ [bootstrapKey]: {
+ bootstrap: {
+ user: { id: "user-e2e", email: "e2e@cap.test" },
+ organization: { id: "org-e2e", name: "E2E" },
+ plan: { isPro: true, maxRecordingSeconds: 600 },
+ },
+ cachedAt: Date.now(),
+ },
+ [settingsKey]: {
+ apiBaseUrl,
+ capture: {
+ recordingMode: "fullscreen",
+ camera: null,
+ microphone: null,
+ },
+ webcam: {
+ enabled: true,
+ deviceId: "__cap_default_camera__",
+ position: "bottom-left",
+ size: 230,
+ shape: "round",
+ mirror: false,
+ },
+ microphone: { enabled: false, deviceId: null },
+ systemAudio: { enabled: false },
+ sounds: { enabled: false },
+ },
+ },
+ () => resolve(),
+ ),
+ );
+ },
+ {
+ apiBaseUrl,
+ authKey: AUTH_KEY,
+ bootstrapKey: BOOTSTRAP_CACHE_KEY,
+ settingsKey: SETTINGS_KEY,
+ },
+ );
+};
+
+const sendServiceWorkerMessage = async (
+ page: Page,
+ message: Record,
+) =>
+ page.evaluate(async (message) => {
+ const chromeApi = (globalThis as ChromeGlobal).chrome;
+ return new Promise((resolve, reject) => {
+ chromeApi.runtime.sendMessage(message, (response) => {
+ const error = chromeApi.runtime.lastError;
+ if (error) {
+ reject(new Error(error.message ?? "Chrome runtime message failed"));
+ return;
+ }
+ resolve(response);
+ });
+ });
+ }, message);
+
+const togglePanelInTab = async (
+ worker: Awaited>,
+ urlFragment: string,
+) =>
+ worker.evaluate(async (urlFragment) => {
+ const chromeApi = (globalThis as ChromeGlobal).chrome;
+ const tabs = await new Promise>(
+ (resolve) => chromeApi.tabs.query({}, resolve),
+ );
+ const tab = tabs.find((tab) => tab.url?.includes(urlFragment));
+ if (tab?.id === undefined) throw new Error("capture tab not found");
+ await new Promise((resolve) => {
+ chromeApi.tabs.sendMessage(tab.id as number, {
+ type: "overlay-panel-toggle",
+ });
+ resolve();
+ });
+ }, urlFragment);
+
+const frameWithUrl = (page: Page, fragment: string) =>
+ page.frames().find((frame) => frame.url().includes(fragment)) ?? null;
+
+test("clicking X in the panel closes the panel and the camera preview", async () => {
+ test.setTimeout(120_000);
+ const mockServer = await createMockCapServer();
+ const extension = await launchExtensionContext();
+
+ try {
+ const worker = await getServiceWorker(extension.context);
+ await configureExtension(worker, mockServer.origin);
+
+ const messengerPage = await extension.context.newPage();
+ await messengerPage.goto(
+ `chrome-extension://${new URL(worker.url()).host}/popup.html`,
+ );
+
+ const targetPage = await extension.context.newPage();
+ await targetPage.goto(`${mockServer.origin}/capture.html`);
+ await targetPage.bringToFront();
+
+ // Open the recorder panel + camera preview like the action click does.
+ await sendServiceWorkerMessage(messengerPage, {
+ target: "service-worker",
+ type: "bootstrap",
+ });
+ for (let attempt = 0; attempt < 10; attempt += 1) {
+ if (frameWithUrl(targetPage, "popup.html")) break;
+ await togglePanelInTab(worker, "/capture.html");
+ await targetPage.waitForTimeout(1_000);
+ }
+
+ // Both extension iframes should be mounted in the page.
+ await expect
+ .poll(() => frameWithUrl(targetPage, "popup.html") !== null, {
+ timeout: 15_000,
+ })
+ .toBe(true);
+ await expect
+ .poll(() => frameWithUrl(targetPage, "camera-preview.html") !== null, {
+ timeout: 15_000,
+ })
+ .toBe(true);
+
+ await targetPage.screenshot({ path: "test-results/close-ui-before.png" });
+
+ const panelFrame = frameWithUrl(targetPage, "popup.html");
+ if (!panelFrame) throw new Error("panel frame missing");
+ await panelFrame
+ .locator('button[aria-label="Close Cap and hide all recorder UI"]')
+ .click();
+
+ // The panel and the camera preview should both tear down.
+ await expect
+ .poll(() => frameWithUrl(targetPage, "popup.html") === null, {
+ timeout: 10_000,
+ })
+ .toBe(true);
+ await expect
+ .poll(() => frameWithUrl(targetPage, "camera-preview.html") === null, {
+ timeout: 10_000,
+ })
+ .toBe(true);
+
+ // And nothing should resurrect the preview shortly after.
+ await targetPage.waitForTimeout(2_500);
+ expect(frameWithUrl(targetPage, "camera-preview.html")).toBeNull();
+ await targetPage.screenshot({ path: "test-results/close-ui-after.png" });
+ } finally {
+ await extension.cleanup();
+ await mockServer.close();
+ }
+});
+
+test("a failed recording start reopens the panel with the error", async () => {
+ test.setTimeout(120_000);
+ const mockServer = await createMockCapServer({ failInstantRecordings: true });
+ const extension = await launchExtensionContext();
+
+ try {
+ const worker = await getServiceWorker(extension.context);
+ await configureExtension(worker, mockServer.origin);
+
+ const messengerPage = await extension.context.newPage();
+ await messengerPage.goto(
+ `chrome-extension://${new URL(worker.url()).host}/popup.html`,
+ );
+
+ const targetPage = await extension.context.newPage();
+ await targetPage.goto(`${mockServer.origin}/capture.html`);
+ await targetPage.bringToFront();
+
+ await sendServiceWorkerMessage(messengerPage, {
+ target: "service-worker",
+ type: "bootstrap",
+ });
+ await targetPage.waitForTimeout(3_000);
+
+ const startResponse = (await sendServiceWorkerMessage(messengerPage, {
+ target: "service-worker",
+ type: "start-recording",
+ mode: "fullscreen",
+ })) as { ok: boolean; error?: string; canceled?: boolean };
+ expect(startResponse.ok).toBe(false);
+ expect(startResponse.canceled).toBeFalsy();
+
+ // The status should be a visible error, not a silent reset to idle.
+ const statusResponse = (await sendServiceWorkerMessage(messengerPage, {
+ target: "service-worker",
+ type: "get-recording-status",
+ })) as { status?: { phase?: string } };
+ expect(statusResponse.status?.phase).toBe("error");
+
+ // The panel should reopen in the page to show the failure.
+ await expect
+ .poll(() => frameWithUrl(targetPage, "popup.html") !== null, {
+ timeout: 10_000,
+ })
+ .toBe(true);
+ const panelFrame = frameWithUrl(targetPage, "popup.html");
+ if (!panelFrame) throw new Error("panel frame missing");
+ await expect(panelFrame.getByText("Recording failed.")).toBeVisible({
+ timeout: 10_000,
+ });
+ await targetPage.screenshot({
+ path: "test-results/start-error-panel.png",
+ });
+ } finally {
+ await extension.cleanup();
+ await mockServer.close();
+ }
+});
+
+test("floating bar appears during recording", async () => {
+ test.setTimeout(120_000);
+ const mockServer = await createMockCapServer();
+ const extension = await launchExtensionContext();
+
+ try {
+ const worker = await getServiceWorker(extension.context);
+ await configureExtension(worker, mockServer.origin);
+
+ const messengerPage = await extension.context.newPage();
+ await messengerPage.goto(
+ `chrome-extension://${new URL(worker.url()).host}/popup.html`,
+ );
+
+ const targetPage = await extension.context.newPage();
+ await targetPage.goto(`${mockServer.origin}/capture.html`);
+ await targetPage.bringToFront();
+
+ await sendServiceWorkerMessage(messengerPage, {
+ target: "service-worker",
+ type: "bootstrap",
+ });
+ await targetPage.waitForTimeout(4_000);
+
+ const startResponse = (await sendServiceWorkerMessage(messengerPage, {
+ target: "service-worker",
+ type: "start-recording",
+ mode: "fullscreen",
+ })) as { ok: boolean };
+ expect(startResponse.ok).toBe(true);
+
+ await targetPage.waitForTimeout(2_000);
+ await targetPage.screenshot({
+ path: "test-results/recording-bar-visible.png",
+ });
+
+ const stopResponse = (await sendServiceWorkerMessage(messengerPage, {
+ target: "service-worker",
+ type: "stop-recording",
+ })) as { ok: boolean };
+ expect(stopResponse.ok).toBe(true);
+ } finally {
+ await extension.cleanup();
+ await mockServer.close();
+ }
+});
diff --git a/apps/chrome-extension/e2e/recording-upload.spec.ts b/apps/chrome-extension/e2e/recording-upload.spec.ts
new file mode 100644
index 00000000000..bec7fef5bec
--- /dev/null
+++ b/apps/chrome-extension/e2e/recording-upload.spec.ts
@@ -0,0 +1,655 @@
+import { mkdtemp, rm } from "node:fs/promises";
+import {
+ createServer,
+ type IncomingMessage,
+ type ServerResponse,
+} from "node:http";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import {
+ type BrowserContext,
+ chromium,
+ expect,
+ type Page,
+ test,
+} from "@playwright/test";
+import type { RecordingStatus } from "../src/shared/types";
+
+type ChromeRuntimeResponse =
+ | {
+ ok: true;
+ status?: RecordingStatus;
+ }
+ | {
+ ok: false;
+ error: string;
+ };
+
+type MockState = {
+ completeBodies: unknown[];
+ progressBodies: unknown[];
+ initiateBodies: unknown[];
+ presignBodies: unknown[];
+ uploadBytes: number[];
+ uploadHeaders: Record[];
+ videoId: string;
+};
+
+type ChromeGlobal = typeof globalThis & {
+ chrome: {
+ runtime: {
+ lastError?: { message?: string };
+ sendMessage(
+ message: unknown,
+ callback: (response: unknown) => void,
+ ): void;
+ };
+ storage: {
+ local: {
+ clear(callback?: () => void): void;
+ set(items: Record, callback?: () => void): void;
+ };
+ };
+ };
+};
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const extensionPath = path.resolve(__dirname, "../dist");
+const SETTINGS_KEY = "cap-extension-settings";
+const AUTH_KEY = "cap-extension-auth";
+const BOOTSTRAP_CACHE_KEY = "cap-extension-bootstrap-cache";
+const RECORDING_MS = 3_500;
+const RECORDING_MODE = "fullscreen";
+
+const readRequestBody = async (request: IncomingMessage) => {
+ const chunks: Buffer[] = [];
+ for await (const chunk of request) {
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
+ }
+ return Buffer.concat(chunks);
+};
+
+const parseJsonBody = async (request: IncomingMessage) => {
+ const body = await readRequestBody(request);
+ return body.length > 0 ? JSON.parse(body.toString("utf8")) : null;
+};
+
+const sendJson = (
+ response: ServerResponse,
+ status: number,
+ body: Record,
+ headers: Record = {},
+) => {
+ response.writeHead(status, {
+ "Access-Control-Allow-Headers":
+ "Authorization, Content-Type, Content-Range",
+ "Access-Control-Allow-Methods": "DELETE, GET, OPTIONS, POST, PUT",
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Expose-Headers": "ETag",
+ "Content-Type": "application/json",
+ ...headers,
+ });
+ response.end(JSON.stringify(body));
+};
+
+const sendHtml = (response: ServerResponse, html: string) => {
+ response.writeHead(200, {
+ "Access-Control-Allow-Origin": "*",
+ "Content-Type": "text/html; charset=utf-8",
+ });
+ response.end(html);
+};
+
+const animatedCapturePage = () => `
+
+
+ Cap E2E Capture Target
+
+
+
+
+
+
+`;
+
+const createMockCapServer = async () => {
+ const state: MockState = {
+ completeBodies: [],
+ progressBodies: [],
+ initiateBodies: [],
+ presignBodies: [],
+ uploadBytes: [],
+ uploadHeaders: [],
+ videoId: `e2e-${Date.now()}`,
+ };
+
+ const server = createServer(async (request, response) => {
+ const url = new URL(request.url ?? "/", "http://127.0.0.1");
+
+ if (request.method === "OPTIONS") {
+ sendJson(response, 204, {});
+ return;
+ }
+
+ try {
+ if (request.method === "GET" && url.pathname === "/capture.html") {
+ sendHtml(response, animatedCapturePage());
+ return;
+ }
+
+ if (
+ request.method === "GET" &&
+ url.pathname === "/api/extension/bootstrap"
+ ) {
+ sendJson(response, 200, {
+ user: {
+ id: "user-e2e",
+ email: "extension-e2e@cap.test",
+ },
+ organization: {
+ id: "org-e2e",
+ name: "Extension E2E",
+ },
+ plan: {
+ isPro: true,
+ maxRecordingSeconds: 600,
+ },
+ });
+ return;
+ }
+
+ if (
+ request.method === "POST" &&
+ url.pathname === "/api/extension/instant-recordings"
+ ) {
+ await parseJsonBody(request);
+ sendJson(response, 200, {
+ id: state.videoId,
+ shareUrl: `${baseUrl()}/share/${state.videoId}`,
+ upload: {
+ type: "multipart",
+ },
+ });
+ return;
+ }
+
+ if (
+ request.method === "POST" &&
+ url.pathname === "/api/upload/multipart/initiate"
+ ) {
+ state.initiateBodies.push(await parseJsonBody(request));
+ sendJson(response, 200, {
+ uploadId: "upload-e2e",
+ provider: "s3",
+ });
+ return;
+ }
+
+ if (
+ request.method === "POST" &&
+ url.pathname === "/api/upload/multipart/presign-part"
+ ) {
+ const body = await parseJsonBody(request);
+ state.presignBodies.push(body);
+ const partNumber =
+ body &&
+ typeof body === "object" &&
+ "partNumber" in body &&
+ typeof body.partNumber === "number"
+ ? body.partNumber
+ : state.presignBodies.length;
+ sendJson(response, 200, {
+ presignedUrl: `${baseUrl()}/mock-s3/part-${partNumber}`,
+ provider: "s3",
+ });
+ return;
+ }
+
+ if (request.method === "PUT" && url.pathname.startsWith("/mock-s3/")) {
+ const body = await readRequestBody(request);
+ state.uploadBytes.push(body.byteLength);
+ state.uploadHeaders.push(request.headers);
+ response.writeHead(200, {
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Expose-Headers": "ETag",
+ ETag: `"etag-${state.uploadBytes.length}"`,
+ });
+ response.end();
+ return;
+ }
+
+ if (
+ request.method === "POST" &&
+ url.pathname === "/api/upload/multipart/complete"
+ ) {
+ state.completeBodies.push(await parseJsonBody(request));
+ sendJson(response, 200, {
+ success: true,
+ processingStarted: true,
+ });
+ return;
+ }
+
+ if (
+ request.method === "POST" &&
+ url.pathname === "/api/extension/instant-recordings/progress"
+ ) {
+ state.progressBodies.push(await parseJsonBody(request));
+ sendJson(response, 200, {
+ success: true,
+ });
+ return;
+ }
+
+ if (
+ request.method === "DELETE" &&
+ url.pathname.startsWith("/api/extension/instant-recordings/")
+ ) {
+ sendJson(response, 200, {
+ success: true,
+ });
+ return;
+ }
+
+ sendJson(response, 404, {
+ error: `Unhandled ${request.method} ${url.pathname}`,
+ });
+ } catch (error) {
+ console.error("Mock server request failed", error);
+ sendJson(response, 500, {
+ error: "Mock server request failed",
+ });
+ }
+ });
+
+ let origin = "";
+ const baseUrl = () => origin;
+
+ await new Promise((resolve, reject) => {
+ server.listen(0, "127.0.0.1", () => {
+ const address = server.address();
+ if (!address || typeof address === "string") {
+ reject(new Error("Mock server did not expose a TCP address"));
+ return;
+ }
+ origin = `http://127.0.0.1:${address.port}`;
+ resolve();
+ });
+ });
+
+ return {
+ origin,
+ state,
+ close: () =>
+ new Promise((resolve, reject) => {
+ server.close((error) => {
+ if (error) {
+ reject(error);
+ return;
+ }
+ resolve();
+ });
+ }),
+ };
+};
+
+const launchExtensionContext = async () => {
+ const userDataDir = await mkdtemp(path.join(tmpdir(), "cap-extension-e2e-"));
+ const context = await chromium.launchPersistentContext(userDataDir, {
+ channel: "chromium",
+ headless: true,
+ args: [
+ `--disable-extensions-except=${extensionPath}`,
+ `--load-extension=${extensionPath}`,
+ "--allow-http-screen-capture",
+ "--auto-select-desktop-capture-source=Cap E2E Capture Target",
+ "--auto-select-tab-capture-source-by-title=Cap E2E Capture Target",
+ "--enable-usermedia-screen-capturing",
+ "--autoplay-policy=no-user-gesture-required",
+ "--use-fake-device-for-media-stream",
+ "--use-fake-ui-for-media-stream",
+ ],
+ });
+ const cleanup = async () => {
+ await context.close();
+ await rm(userDataDir, { recursive: true, force: true });
+ };
+
+ return { context, cleanup };
+};
+
+const getServiceWorker = async (context: BrowserContext) => {
+ const existing = context
+ .serviceWorkers()
+ .find((worker) => worker.url().includes("assets/service-worker.js"));
+ if (existing) return existing;
+
+ return context.waitForEvent("serviceworker", (worker) =>
+ worker.url().includes("assets/service-worker.js"),
+ );
+};
+
+const getExtensionId = (worker: Awaited>) =>
+ new URL(worker.url()).host;
+
+const openExtensionMessengerPage = async (
+ context: BrowserContext,
+ worker: Awaited>,
+) => {
+ const page = await context.newPage();
+ await page.goto(`chrome-extension://${getExtensionId(worker)}/popup.html`);
+ return page;
+};
+
+const configureExtension = async (
+ worker: Awaited>,
+ apiBaseUrl: string,
+) => {
+ await worker.evaluate(
+ async ({
+ authKey,
+ bootstrapKey,
+ recordingMode,
+ settingsKey,
+ apiBaseUrl,
+ }) => {
+ const chromeApi = (globalThis as ChromeGlobal).chrome;
+ await new Promise((resolve, reject) => {
+ chromeApi.storage.local.clear(() => {
+ const error = chromeApi.runtime.lastError;
+ if (error) {
+ reject(new Error(error.message ?? "Failed to clear storage"));
+ return;
+ }
+ resolve();
+ });
+ });
+ await new Promise((resolve, reject) => {
+ chromeApi.storage.local.set(
+ {
+ [authKey]: {
+ authApiKey: "auth-e2e",
+ userId: "user-e2e",
+ },
+ [bootstrapKey]: {
+ bootstrap: {
+ user: {
+ id: "user-e2e",
+ email: "extension-e2e@cap.test",
+ },
+ organization: {
+ id: "org-e2e",
+ name: "Extension E2E",
+ },
+ plan: {
+ isPro: true,
+ maxRecordingSeconds: 600,
+ },
+ },
+ cachedAt: Date.now(),
+ },
+ [settingsKey]: {
+ apiBaseUrl,
+ capture: {
+ recordingMode,
+ camera: null,
+ microphone: null,
+ },
+ webcam: {
+ enabled: false,
+ deviceId: null,
+ position: "bottom-left",
+ size: 230,
+ shape: "round",
+ mirror: false,
+ },
+ microphone: {
+ enabled: false,
+ deviceId: null,
+ },
+ systemAudio: {
+ enabled: false,
+ },
+ sounds: {
+ enabled: false,
+ },
+ },
+ },
+ () => {
+ const error = chromeApi.runtime.lastError;
+ if (error) {
+ reject(new Error(error.message ?? "Failed to write storage"));
+ return;
+ }
+ resolve();
+ },
+ );
+ });
+ },
+ {
+ apiBaseUrl,
+ authKey: AUTH_KEY,
+ bootstrapKey: BOOTSTRAP_CACHE_KEY,
+ recordingMode: RECORDING_MODE,
+ settingsKey: SETTINGS_KEY,
+ },
+ );
+};
+
+const sendServiceWorkerMessage = async (
+ page: Page,
+ message: Record,
+) =>
+ page.evaluate(async (message) => {
+ const chromeApi = (globalThis as ChromeGlobal).chrome;
+ return new Promise((resolve, reject) => {
+ chromeApi.runtime.sendMessage(message, (response) => {
+ const error = chromeApi.runtime.lastError;
+ if (error) {
+ reject(new Error(error.message ?? "Chrome runtime message failed"));
+ return;
+ }
+ resolve(response as ChromeRuntimeResponse);
+ });
+ });
+ }, message);
+
+const expectSuccessfulUpload = async (page: Page, state: MockState) => {
+ await expect
+ .poll(async () => {
+ const response = await sendServiceWorkerMessage(page, {
+ target: "service-worker",
+ type: "get-recording-status",
+ });
+ if (!response.ok) return response.error;
+ return response.status?.phase;
+ })
+ .toBe("completed");
+
+ expect(state.initiateBodies).toHaveLength(1);
+ expect(state.presignBodies.length).toBeGreaterThanOrEqual(1);
+ expect(state.uploadBytes.length).toBeGreaterThanOrEqual(1);
+ expect(
+ state.uploadBytes.reduce((total, bytes) => total + bytes, 0),
+ ).toBeGreaterThan(0);
+ expect(state.completeBodies).toHaveLength(1);
+ expect(state.progressBodies.length).toBeGreaterThanOrEqual(1);
+
+ const completeBody = state.completeBodies[0];
+ expect(completeBody).toMatchObject({
+ videoId: state.videoId,
+ uploadId: "upload-e2e",
+ subpath: "raw-upload.webm",
+ });
+ expect(
+ completeBody &&
+ typeof completeBody === "object" &&
+ "parts" in completeBody &&
+ Array.isArray(completeBody.parts)
+ ? completeBody.parts.length
+ : 0,
+ ).toBeGreaterThanOrEqual(1);
+};
+
+const startRecording = async (
+ context: BrowserContext,
+ worker: Awaited>,
+ apiBaseUrl: string,
+) => {
+ await configureExtension(worker, apiBaseUrl);
+ const messengerPage = await openExtensionMessengerPage(context, worker);
+ const capturePage = await context.newPage();
+ await capturePage.goto(`${apiBaseUrl}/capture.html`);
+ await capturePage.bringToFront();
+
+ const startResponse = await sendServiceWorkerMessage(messengerPage, {
+ target: "service-worker",
+ type: "start-recording",
+ mode: RECORDING_MODE,
+ });
+ if (!startResponse.ok) {
+ throw new Error(startResponse.error);
+ }
+
+ await expect
+ .poll(async () => {
+ const response = await sendServiceWorkerMessage(messengerPage, {
+ target: "service-worker",
+ type: "get-recording-status",
+ });
+ if (!response.ok) return response.error;
+ return response.status?.phase;
+ })
+ .toBe("recording");
+
+ await capturePage.waitForTimeout(RECORDING_MS);
+ return {
+ capturePage,
+ messengerPage,
+ };
+};
+
+test.describe("extension recording upload", () => {
+ let mockServer: Awaited> | null = null;
+ let extension: Awaited> | null =
+ null;
+
+ test.beforeEach(async () => {
+ mockServer = await createMockCapServer();
+ extension = await launchExtensionContext();
+ });
+
+ test.afterEach(async () => {
+ await extension?.cleanup();
+ await mockServer?.close();
+ });
+
+ test("records the selected display surface, uploads non-empty multipart data, and completes", async () => {
+ if (!extension || !mockServer)
+ throw new Error("Test harness did not start");
+ const worker = await getServiceWorker(extension.context);
+ const { messengerPage } = await startRecording(
+ extension.context,
+ worker,
+ mockServer.origin,
+ );
+
+ const stopResponse = await sendServiceWorkerMessage(messengerPage, {
+ target: "service-worker",
+ type: "stop-recording",
+ });
+ expect(stopResponse).toMatchObject({ ok: true });
+
+ await expectSuccessfulUpload(messengerPage, mockServer.state);
+ });
+
+ test("can complete two consecutive recording uploads without stale state", async () => {
+ if (!extension || !mockServer)
+ throw new Error("Test harness did not start");
+ const worker = await getServiceWorker(extension.context);
+ const firstRecording = await startRecording(
+ extension.context,
+ worker,
+ mockServer.origin,
+ );
+ const firstStopResponse = await sendServiceWorkerMessage(
+ firstRecording.messengerPage,
+ {
+ target: "service-worker",
+ type: "stop-recording",
+ },
+ );
+ expect(firstStopResponse).toMatchObject({ ok: true });
+ await expectSuccessfulUpload(
+ firstRecording.messengerPage,
+ mockServer.state,
+ );
+
+ await firstRecording.capturePage.close();
+ await firstRecording.messengerPage.close();
+
+ mockServer.state.completeBodies = [];
+ mockServer.state.progressBodies = [];
+ mockServer.state.initiateBodies = [];
+ mockServer.state.presignBodies = [];
+ mockServer.state.uploadBytes = [];
+ mockServer.state.uploadHeaders = [];
+ mockServer.state.videoId = `e2e-${Date.now()}-second`;
+
+ const secondRecording = await startRecording(
+ extension.context,
+ worker,
+ mockServer.origin,
+ );
+ const secondStopResponse = await sendServiceWorkerMessage(
+ secondRecording.messengerPage,
+ {
+ target: "service-worker",
+ type: "stop-recording",
+ },
+ );
+ expect(secondStopResponse).toMatchObject({ ok: true });
+ await expectSuccessfulUpload(
+ secondRecording.messengerPage,
+ mockServer.state,
+ );
+ });
+});
diff --git a/apps/chrome-extension/e2e/webcam-start.spec.ts b/apps/chrome-extension/e2e/webcam-start.spec.ts
new file mode 100644
index 00000000000..940cd39c0d9
--- /dev/null
+++ b/apps/chrome-extension/e2e/webcam-start.spec.ts
@@ -0,0 +1,359 @@
+import { mkdtemp, rm } from "node:fs/promises";
+import {
+ createServer,
+ type IncomingMessage,
+ type ServerResponse,
+} from "node:http";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import {
+ type BrowserContext,
+ chromium,
+ expect,
+ type Page,
+ test,
+} from "@playwright/test";
+
+type ChromeGlobal = typeof globalThis & {
+ chrome: {
+ runtime: {
+ lastError?: { message?: string };
+ sendMessage(
+ message: unknown,
+ callback: (response: unknown) => void,
+ ): void;
+ };
+ storage: {
+ local: {
+ clear(callback?: () => void): void;
+ set(items: Record, callback?: () => void): void;
+ };
+ };
+ };
+};
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const extensionPath = path.resolve(__dirname, "../dist");
+const SETTINGS_KEY = "cap-extension-settings";
+const AUTH_KEY = "cap-extension-auth";
+const BOOTSTRAP_CACHE_KEY = "cap-extension-bootstrap-cache";
+
+const readRequestBody = async (request: IncomingMessage) => {
+ const chunks: Buffer[] = [];
+ for await (const chunk of request) {
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
+ }
+ return Buffer.concat(chunks);
+};
+
+const sendJson = (
+ response: ServerResponse,
+ status: number,
+ body: Record,
+) => {
+ response.writeHead(status, {
+ "Access-Control-Allow-Headers":
+ "Authorization, Content-Type, Content-Range",
+ "Access-Control-Allow-Methods": "DELETE, GET, OPTIONS, POST, PUT",
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Expose-Headers": "ETag",
+ "Content-Type": "application/json",
+ });
+ response.end(JSON.stringify(body));
+};
+
+const capturePage = () => `
+
+ Cap Repro Capture Target
+ Repro page
+`;
+
+const createMockCapServer = async () => {
+ const requests: string[] = [];
+ const server = createServer(async (request, response) => {
+ const url = new URL(request.url ?? "/", "http://127.0.0.1");
+ requests.push(`${request.method} ${url.pathname}`);
+
+ if (request.method === "OPTIONS") {
+ sendJson(response, 204, {});
+ return;
+ }
+ if (request.method === "GET" && url.pathname === "/capture.html") {
+ response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
+ response.end(capturePage());
+ return;
+ }
+ if (
+ request.method === "GET" &&
+ url.pathname === "/api/extension/bootstrap"
+ ) {
+ sendJson(response, 200, {
+ user: { id: "user-e2e", email: "e2e@cap.test" },
+ organization: { id: "org-e2e", name: "E2E" },
+ plan: { isPro: true, maxRecordingSeconds: 600 },
+ });
+ return;
+ }
+ if (
+ request.method === "POST" &&
+ url.pathname === "/api/extension/instant-recordings"
+ ) {
+ await readRequestBody(request);
+ sendJson(response, 200, {
+ id: "video-repro",
+ shareUrl: `${origin}/share/video-repro`,
+ upload: { type: "multipart" },
+ });
+ return;
+ }
+ if (
+ request.method === "POST" &&
+ url.pathname === "/api/upload/multipart/initiate"
+ ) {
+ await readRequestBody(request);
+ sendJson(response, 200, { uploadId: "upload-repro", provider: "s3" });
+ return;
+ }
+ if (
+ request.method === "POST" &&
+ url.pathname === "/api/upload/multipart/presign-part"
+ ) {
+ await readRequestBody(request);
+ sendJson(response, 200, {
+ presignedUrl: `${origin}/mock-s3/part`,
+ provider: "s3",
+ });
+ return;
+ }
+ if (request.method === "PUT" && url.pathname.startsWith("/mock-s3/")) {
+ await readRequestBody(request);
+ response.writeHead(200, {
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Expose-Headers": "ETag",
+ ETag: '"etag-repro"',
+ });
+ response.end();
+ return;
+ }
+ await readRequestBody(request).catch(() => undefined);
+ sendJson(response, 200, { success: true, processingStarted: true });
+ });
+
+ let origin = "";
+ await new Promise((resolve, reject) => {
+ server.listen(0, "127.0.0.1", () => {
+ const address = server.address();
+ if (!address || typeof address === "string") {
+ reject(new Error("no address"));
+ return;
+ }
+ origin = `http://127.0.0.1:${address.port}`;
+ resolve();
+ });
+ });
+
+ return {
+ origin,
+ requests,
+ close: () =>
+ new Promise((resolve, reject) => {
+ server.close((error) => (error ? reject(error) : resolve()));
+ }),
+ };
+};
+
+const launchExtensionContext = async () => {
+ const userDataDir = await mkdtemp(path.join(tmpdir(), "cap-repro-e2e-"));
+ const context = await chromium.launchPersistentContext(userDataDir, {
+ channel: "chromium",
+ headless: true,
+ args: [
+ `--disable-extensions-except=${extensionPath}`,
+ `--load-extension=${extensionPath}`,
+ "--allow-http-screen-capture",
+ "--auto-select-tab-capture-source-by-title=Cap Repro Capture Target",
+ "--auto-select-desktop-capture-source=Cap Repro Capture Target",
+ "--enable-usermedia-screen-capturing",
+ "--autoplay-policy=no-user-gesture-required",
+ "--use-fake-device-for-media-stream",
+ "--use-fake-ui-for-media-stream",
+ ],
+ });
+ const cleanup = async () => {
+ await context.close();
+ await rm(userDataDir, { recursive: true, force: true });
+ };
+ return { context, cleanup };
+};
+
+const getServiceWorker = async (context: BrowserContext) => {
+ const existing = context
+ .serviceWorkers()
+ .find((worker) => worker.url().includes("assets/service-worker.js"));
+ if (existing) return existing;
+ return context.waitForEvent("serviceworker", (worker) =>
+ worker.url().includes("assets/service-worker.js"),
+ );
+};
+
+const configureExtension = async (
+ worker: Awaited>,
+ apiBaseUrl: string,
+) => {
+ await worker.evaluate(
+ async ({ authKey, bootstrapKey, settingsKey, apiBaseUrl }) => {
+ const chromeApi = (globalThis as ChromeGlobal).chrome;
+ await new Promise((resolve) =>
+ chromeApi.storage.local.clear(() => resolve()),
+ );
+ await new Promise((resolve) =>
+ chromeApi.storage.local.set(
+ {
+ [authKey]: { authApiKey: "auth-e2e", userId: "user-e2e" },
+ [bootstrapKey]: {
+ bootstrap: {
+ user: { id: "user-e2e", email: "e2e@cap.test" },
+ organization: { id: "org-e2e", name: "E2E" },
+ plan: { isPro: true, maxRecordingSeconds: 600 },
+ },
+ cachedAt: Date.now(),
+ },
+ [settingsKey]: {
+ apiBaseUrl,
+ capture: {
+ recordingMode: "fullscreen",
+ camera: null,
+ microphone: null,
+ },
+ webcam: {
+ enabled: true,
+ deviceId: "__cap_default_camera__",
+ position: "bottom-left",
+ size: 230,
+ shape: "round",
+ mirror: false,
+ },
+ microphone: { enabled: false, deviceId: null },
+ systemAudio: { enabled: false },
+ sounds: { enabled: false },
+ },
+ },
+ () => resolve(),
+ ),
+ );
+ },
+ {
+ apiBaseUrl,
+ authKey: AUTH_KEY,
+ bootstrapKey: BOOTSTRAP_CACHE_KEY,
+ settingsKey: SETTINGS_KEY,
+ },
+ );
+};
+
+const sendServiceWorkerMessage = async (
+ page: Page,
+ message: Record,
+) =>
+ page.evaluate(async (message) => {
+ const chromeApi = (globalThis as ChromeGlobal).chrome;
+ return new Promise((resolve, reject) => {
+ chromeApi.runtime.sendMessage(message, (response) => {
+ const error = chromeApi.runtime.lastError;
+ if (error) {
+ reject(new Error(error.message ?? "Chrome runtime message failed"));
+ return;
+ }
+ resolve(response);
+ });
+ });
+ }, message);
+
+test("repro: start recording with webcam preview enabled and live", async () => {
+ test.setTimeout(120_000);
+ const mockServer = await createMockCapServer();
+ const extension = await launchExtensionContext();
+ const logs: string[] = [];
+
+ try {
+ const worker = await getServiceWorker(extension.context);
+ await configureExtension(worker, mockServer.origin);
+
+ const messengerPage = await extension.context.newPage();
+ await messengerPage.goto(
+ `chrome-extension://${new URL(worker.url()).host}/popup.html`,
+ );
+
+ const targetPage = await extension.context.newPage();
+ targetPage.on("console", (message) => {
+ logs.push(`[capture-page ${message.type()}] ${message.text()}`);
+ });
+ await targetPage.goto(`${mockServer.origin}/capture.html`);
+ await targetPage.bringToFront();
+
+ // Simulate opening the recorder: this shows the camera preview overlay
+ // in the active tab (same as openRecorderPanel does).
+ const bootstrapResponse = await sendServiceWorkerMessage(messengerPage, {
+ target: "service-worker",
+ type: "bootstrap",
+ });
+ logs.push(`bootstrap: ${JSON.stringify(bootstrapResponse).slice(0, 200)}`);
+
+ // Give the camera preview time to go live (frames flowing over WebRTC).
+ await targetPage.waitForTimeout(6_000);
+ await targetPage.screenshot({
+ path: "test-results/repro-before-start.png",
+ });
+
+ const startedAt = Date.now();
+ const startResponse = await sendServiceWorkerMessage(messengerPage, {
+ target: "service-worker",
+ type: "start-recording",
+ mode: "fullscreen",
+ }).catch((error: unknown) => ({
+ ok: false,
+ error: `sendMessage rejected: ${error instanceof Error ? error.message : String(error)}`,
+ }));
+ logs.push(
+ `start-recording after ${Date.now() - startedAt}ms: ${JSON.stringify(startResponse)}`,
+ );
+
+ // Poll the status for a while to watch transitions.
+ for (let index = 0; index < 20; index += 1) {
+ const statusResponse = (await sendServiceWorkerMessage(messengerPage, {
+ target: "service-worker",
+ type: "get-recording-status",
+ }).catch(() => null)) as { status?: { phase?: string } } | null;
+ logs.push(
+ `status t+${index * 500}ms: ${JSON.stringify(statusResponse?.status)}`,
+ );
+ if (statusResponse?.status?.phase === "recording") break;
+ await messengerPage.waitForTimeout(500);
+ }
+
+ await targetPage.screenshot({ path: "test-results/repro-after-start.png" });
+ logs.push(`api requests: ${JSON.stringify(mockServer.requests)}`);
+
+ const finalStatus = (await sendServiceWorkerMessage(messengerPage, {
+ target: "service-worker",
+ type: "get-recording-status",
+ })) as { status?: { phase?: string } };
+ expect(finalStatus.status?.phase).toBe("recording");
+
+ // Content scripts read chrome.storage.session; without the service
+ // worker widening the access level every call fails with this error.
+ const storageErrors = logs.filter((line) =>
+ line.includes("Access to storage is not allowed"),
+ );
+ expect(storageErrors).toHaveLength(0);
+ } catch (error) {
+ console.log(
+ `\n===== REPRO LOG =====\n${logs.join("\n")}\n=====================`,
+ );
+ throw error;
+ } finally {
+ await extension.cleanup();
+ await mockServer.close();
+ }
+});
diff --git a/apps/chrome-extension/how-it-works.html b/apps/chrome-extension/how-it-works.html
new file mode 100644
index 00000000000..3734907d4d1
--- /dev/null
+++ b/apps/chrome-extension/how-it-works.html
@@ -0,0 +1,164 @@
+
+
+
+
+
+ How it works - Cap
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ How Cap works
+
+ Instant Mode uploads while you record, so your share link is ready the
+ moment you stop.
+
+
+
+
+
+
+
+ Step 1
+
+ Click the Cap icon, pick a tab, window, screen or camera, and press
+ start.
+
+
+
+
+
+
+
+
+ Step 2
+
+ Your video streams to the cloud while you record. No exports, no
+ waiting at the end.
+
+
+
+
+
+
+
+
+ Step 3
+
+ Stop the recording and the share link is live instantly. Paste it
+ anywhere.
+
+
+
+
+
Good to know
+
+
+
+ Drag the camera preview anywhere on the page, resize it, or pop it
+ out with Picture in Picture.
+
+
+
+ Pause and resume from the floating recording bar without losing
+ your take.
+
+
+
+ Click the Cap icon in your toolbar while recording to stop and jump
+ straight to your video.
+
+
+
+
+
+
+
+
diff --git a/apps/chrome-extension/offscreen.html b/apps/chrome-extension/offscreen.html
new file mode 100644
index 00000000000..07811d502c2
--- /dev/null
+++ b/apps/chrome-extension/offscreen.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+ Cap Recorder Offscreen
+
+
+
+
+
diff --git a/apps/chrome-extension/options.html b/apps/chrome-extension/options.html
new file mode 100644
index 00000000000..583f37990f5
--- /dev/null
+++ b/apps/chrome-extension/options.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Options - Cap
+
+
+
+
+
+
diff --git a/apps/chrome-extension/package.json b/apps/chrome-extension/package.json
new file mode 100644
index 00000000000..f3965dfca0d
--- /dev/null
+++ b/apps/chrome-extension/package.json
@@ -0,0 +1,41 @@
+{
+ "name": "@cap/chrome-extension",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "build": "rm -rf dist && vite build && vite build --config vite.content.config.ts && vite build --config vite.content-overlay.config.ts",
+ "dev": "rm -rf dist && (trap 'kill 0' INT TERM; vite build --watch --config vite.content.config.ts --mode development & vite build --watch --config vite.content-overlay.config.ts --mode development & vite build --watch --mode development & wait)",
+ "typecheck": "tsc --noEmit",
+ "test": "vitest run src",
+ "test:e2e:install": "playwright install chromium",
+ "test:e2e": "pnpm build && playwright test",
+ "test:e2e:headed": "pnpm build && playwright test --headed"
+ },
+ "dependencies": {
+ "@cap/recorder-core": "workspace:*",
+ "@cap/web-domain": "workspace:*",
+ "@radix-ui/colors": "^3.0.0",
+ "@radix-ui/react-select": "^2.2.5",
+ "@radix-ui/react-switch": "^1.1.0",
+ "class-variance-authority": "^0.7.0",
+ "clsx": "^2.1.1",
+ "lucide-react": "^0.525.0",
+ "react": "19.2.4",
+ "react-dom": "19.2.4",
+ "tailwind-merge": "^2.6.0"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.51.1",
+ "@types/chrome": "^0.0.323",
+ "@types/react": "19.2.14",
+ "@types/react-dom": "19.2.3",
+ "@vitejs/plugin-react": "4.4.1",
+ "autoprefixer": "^10.4.16",
+ "postcss": "^8.4.31",
+ "tailwindcss": "^3.4.0",
+ "typescript": "^5.8.3",
+ "vite": "6.3.5",
+ "vitest": "^3.2.0"
+ }
+}
diff --git a/apps/chrome-extension/playwright.config.ts b/apps/chrome-extension/playwright.config.ts
new file mode 100644
index 00000000000..d59f32a4295
--- /dev/null
+++ b/apps/chrome-extension/playwright.config.ts
@@ -0,0 +1,12 @@
+import { defineConfig } from "@playwright/test";
+
+export default defineConfig({
+ testDir: "./e2e",
+ fullyParallel: false,
+ workers: 1,
+ timeout: 60_000,
+ expect: {
+ timeout: 15_000,
+ },
+ reporter: [["list"]],
+});
diff --git a/apps/chrome-extension/popup.html b/apps/chrome-extension/popup.html
new file mode 100644
index 00000000000..9573af9a38b
--- /dev/null
+++ b/apps/chrome-extension/popup.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Cap Recorder
+
+
+
+
+
+
diff --git a/apps/chrome-extension/postcss.config.cjs b/apps/chrome-extension/postcss.config.cjs
new file mode 100644
index 00000000000..e873f1a4f23
--- /dev/null
+++ b/apps/chrome-extension/postcss.config.cjs
@@ -0,0 +1,6 @@
+module.exports = {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
diff --git a/apps/chrome-extension/public/fonts/NeueMontreal-Bold.otf b/apps/chrome-extension/public/fonts/NeueMontreal-Bold.otf
new file mode 100644
index 00000000000..a1c6974f0a1
Binary files /dev/null and b/apps/chrome-extension/public/fonts/NeueMontreal-Bold.otf differ
diff --git a/apps/chrome-extension/public/fonts/NeueMontreal-BoldItalic.otf b/apps/chrome-extension/public/fonts/NeueMontreal-BoldItalic.otf
new file mode 100644
index 00000000000..798048d1f71
Binary files /dev/null and b/apps/chrome-extension/public/fonts/NeueMontreal-BoldItalic.otf differ
diff --git a/apps/chrome-extension/public/fonts/NeueMontreal-Italic.otf b/apps/chrome-extension/public/fonts/NeueMontreal-Italic.otf
new file mode 100644
index 00000000000..a8c17e70784
Binary files /dev/null and b/apps/chrome-extension/public/fonts/NeueMontreal-Italic.otf differ
diff --git a/apps/chrome-extension/public/fonts/NeueMontreal-Medium.otf b/apps/chrome-extension/public/fonts/NeueMontreal-Medium.otf
new file mode 100644
index 00000000000..43030e87df2
Binary files /dev/null and b/apps/chrome-extension/public/fonts/NeueMontreal-Medium.otf differ
diff --git a/apps/chrome-extension/public/fonts/NeueMontreal-MediumItalic.otf b/apps/chrome-extension/public/fonts/NeueMontreal-MediumItalic.otf
new file mode 100644
index 00000000000..78b2fc54801
Binary files /dev/null and b/apps/chrome-extension/public/fonts/NeueMontreal-MediumItalic.otf differ
diff --git a/apps/chrome-extension/public/fonts/NeueMontreal-Regular.otf b/apps/chrome-extension/public/fonts/NeueMontreal-Regular.otf
new file mode 100644
index 00000000000..0265060cb6c
Binary files /dev/null and b/apps/chrome-extension/public/fonts/NeueMontreal-Regular.otf differ
diff --git a/apps/chrome-extension/public/icons/icon-128.png b/apps/chrome-extension/public/icons/icon-128.png
new file mode 100644
index 00000000000..90f9253216c
Binary files /dev/null and b/apps/chrome-extension/public/icons/icon-128.png differ
diff --git a/apps/chrome-extension/public/icons/icon-16.png b/apps/chrome-extension/public/icons/icon-16.png
new file mode 100644
index 00000000000..044fde5b71e
Binary files /dev/null and b/apps/chrome-extension/public/icons/icon-16.png differ
diff --git a/apps/chrome-extension/public/icons/icon-256.png b/apps/chrome-extension/public/icons/icon-256.png
new file mode 100644
index 00000000000..5beba069687
Binary files /dev/null and b/apps/chrome-extension/public/icons/icon-256.png differ
diff --git a/apps/chrome-extension/public/icons/icon-32.png b/apps/chrome-extension/public/icons/icon-32.png
new file mode 100644
index 00000000000..d3acd87a89f
Binary files /dev/null and b/apps/chrome-extension/public/icons/icon-32.png differ
diff --git a/apps/chrome-extension/public/icons/icon-48.png b/apps/chrome-extension/public/icons/icon-48.png
new file mode 100644
index 00000000000..ed909f6d22f
Binary files /dev/null and b/apps/chrome-extension/public/icons/icon-48.png differ
diff --git a/apps/chrome-extension/public/manifest.json b/apps/chrome-extension/public/manifest.json
new file mode 100644
index 00000000000..156cacdbb7c
--- /dev/null
+++ b/apps/chrome-extension/public/manifest.json
@@ -0,0 +1,56 @@
+{
+ "manifest_version": 3,
+ "name": "Cap - Screen Recorder & Screen Capture",
+ "short_name": "Cap",
+ "description": "Free, open source screen recorder. Capture your screen, tab, camera & mic in Chrome and share a video link the moment you stop.",
+ "version": "0.1.0",
+ "homepage_url": "https://cap.so",
+ "minimum_chrome_version": "116",
+ "icons": {
+ "16": "icons/icon-16.png",
+ "32": "icons/icon-32.png",
+ "48": "icons/icon-48.png",
+ "128": "icons/icon-128.png"
+ },
+ "action": {
+ "default_title": "Record your screen with Cap",
+ "default_icon": {
+ "16": "icons/icon-16.png",
+ "32": "icons/icon-32.png",
+ "48": "icons/icon-48.png",
+ "128": "icons/icon-128.png"
+ }
+ },
+ "background": {
+ "service_worker": "assets/service-worker.js",
+ "type": "module"
+ },
+ "options_page": "options.html",
+ "permissions": [
+ "activeTab",
+ "identity",
+ "offscreen",
+ "scripting",
+ "storage",
+ "tabCapture"
+ ],
+ "host_permissions": ["http://*/*", "https://*/*", "file:///*"],
+ "content_scripts": [
+ {
+ "matches": ["http://*/*", "https://*/*", "file:///*"],
+ "js": ["assets/content-bootstrap.js"],
+ "run_at": "document_idle"
+ }
+ ],
+ "web_accessible_resources": [
+ {
+ "resources": ["icons/*", "content/overlay.js"],
+ "matches": ["http://*/*", "https://*/*", "file:///*"]
+ },
+ {
+ "resources": ["camera-preview.html", "popup.html"],
+ "matches": ["http://*/*", "https://*/*", "file:///*"],
+ "use_dynamic_url": true
+ }
+ ]
+}
diff --git a/apps/chrome-extension/public/sounds/start-recording.ogg b/apps/chrome-extension/public/sounds/start-recording.ogg
new file mode 100644
index 00000000000..3c293756782
Binary files /dev/null and b/apps/chrome-extension/public/sounds/start-recording.ogg differ
diff --git a/apps/chrome-extension/public/sounds/stop-recording.ogg b/apps/chrome-extension/public/sounds/stop-recording.ogg
new file mode 100644
index 00000000000..fb172853027
Binary files /dev/null and b/apps/chrome-extension/public/sounds/stop-recording.ogg differ
diff --git a/apps/chrome-extension/src/background/service-worker.ts b/apps/chrome-extension/src/background/service-worker.ts
new file mode 100644
index 00000000000..aec96a529db
--- /dev/null
+++ b/apps/chrome-extension/src/background/service-worker.ts
@@ -0,0 +1,1903 @@
+import {
+ ApiRequestError,
+ createAuthStart,
+ fetchBootstrap,
+ parseAuthResponse,
+ revokeAuth,
+} from "../shared/api";
+import {
+ isRecordingStatusBroadcast,
+ isServiceWorkerRequest,
+} from "../shared/messages";
+import { rememberRecordingMode } from "../shared/preferences";
+import {
+ clearAuth,
+ clearAuthError,
+ clearCachedBootstrap,
+ clearPendingAuth,
+ isOverlayTokenRegistered,
+ loadAuth,
+ loadAuthError,
+ loadCachedBootstrap,
+ loadPendingAuth,
+ loadSettings,
+ loadSharedRecordingState,
+ loadSharedUiState,
+ loadUploadProgressTabId,
+ loadWebcamPreviewDismissed,
+ registerOverlayToken,
+ saveAuth,
+ saveAuthError,
+ saveCachedBootstrap,
+ savePendingAuth,
+ saveSettings,
+ saveSharedRecordingState,
+ saveUploadProgressTabId,
+ saveWebcamPreviewDismissed,
+ updateSharedUiState,
+} from "../shared/storage";
+import type {
+ BootstrapData,
+ CameraDevice,
+ CameraPreviewErrorReason,
+ CameraPreviewEventRelay,
+ ExtensionAuth,
+ ExtensionSettings,
+ MicrophoneDevice,
+ MicrophoneWarningVariant,
+ OffscreenRequest,
+ OffscreenResponse,
+ OverlayMessage,
+ RecordingCaptureSource,
+ RecordingMode,
+ RecordingStatus,
+ RecordingStatusBroadcast,
+ ServiceWorkerRequest,
+ ServiceWorkerResponse,
+} from "../shared/types";
+
+const POPUP_URL = "popup.html";
+const OFFSCREEN_URL = "offscreen.html";
+const AUTH_TIMEOUT_MS = 10 * 60 * 1000;
+const OFFSCREEN_MESSAGE_ATTEMPTS = 3;
+const OFFSCREEN_MESSAGE_RETRY_DELAY_MS = 75;
+const OVERLAY_MESSAGE_ATTEMPTS = 10;
+const OVERLAY_MESSAGE_RETRY_DELAY_MS = 100;
+const START_PREVIEW_READY_TIMEOUT_MS = 8000;
+
+let bootstrapCache: BootstrapData | null = null;
+let recordingStatus: RecordingStatus = { phase: "idle" };
+let cameraDevicesCache: CameraDevice[] = [];
+let microphoneDevicesCache: MicrophoneDevice[] = [];
+let uploadProgressTabId: number | null = null;
+let activePreviewTabId: number | null = null;
+let pendingPreviewTabId: number | null = null;
+let readyPreviewTabId: number | null = null;
+let offscreenDocumentCreation: Promise | null = null;
+let browserWindowFocused = true;
+let externalCaptureAutoPipPending = false;
+
+type TabWaiter = {
+ resolve: () => void;
+ reject: (error: Error) => void;
+ timeoutId: ReturnType;
+};
+
+const previewReadyWaiters = new Map>();
+
+// Content scripts read the webcam "dismissed" flag and the cached preview
+// frame from chrome.storage.session, which is only exposed to trusted
+// contexts unless the access level is widened. Without this every session
+// storage call from a content script fails.
+chrome.storage.session.setAccessLevel({
+ accessLevel: "TRUSTED_AND_UNTRUSTED_CONTEXTS",
+});
+
+const getActiveTab = () =>
+ new Promise((resolve) => {
+ chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
+ resolve(tabs[0] ?? null);
+ });
+ });
+
+const getLastFocusedTab = () =>
+ new Promise((resolve) => {
+ chrome.windows.getLastFocused((focusedWindow) => {
+ if (chrome.runtime.lastError || focusedWindow.id === undefined) {
+ resolve(null);
+ return;
+ }
+ chrome.tabs.query(
+ { active: true, windowId: focusedWindow.id },
+ (tabs) => {
+ resolve(tabs[0] ?? null);
+ },
+ );
+ });
+ });
+
+const getTabs = () =>
+ new Promise((resolve) => {
+ chrome.tabs.query({}, resolve);
+ });
+
+const getTab = (tabId: number) =>
+ new Promise((resolve) => {
+ chrome.tabs.get(tabId, (tab) => {
+ if (chrome.runtime.lastError) {
+ resolve(null);
+ return;
+ }
+ resolve(tab ?? null);
+ });
+ });
+
+const createTab = (url: string) =>
+ new Promise((resolve, reject) => {
+ chrome.tabs.create({ url, active: true }, (tab) => {
+ if (chrome.runtime.lastError) {
+ reject(
+ new Error(chrome.runtime.lastError.message ?? "Failed to open tab"),
+ );
+ return;
+ }
+ resolve(tab);
+ });
+ });
+
+const updateTab = (tabId: number, url: string) =>
+ new Promise((resolve, reject) => {
+ chrome.tabs.update(tabId, { url, active: true }, (tab) => {
+ if (chrome.runtime.lastError || !tab) {
+ reject(
+ new Error(
+ chrome.runtime.lastError?.message ?? "Failed to update tab",
+ ),
+ );
+ return;
+ }
+ resolve(tab);
+ });
+ });
+
+const activateTab = (tabId: number) =>
+ new Promise((resolve) => {
+ chrome.tabs.update(tabId, { active: true }, (tab) => {
+ if (chrome.runtime.lastError || !tab) {
+ resolve(null);
+ return;
+ }
+ resolve(tab);
+ });
+ });
+
+const focusWindow = (windowId: number) =>
+ new Promise((resolve) => {
+ chrome.windows.update(windowId, { focused: true }, () => {
+ void chrome.runtime.lastError;
+ resolve();
+ });
+ });
+
+const focusTab = async (tabId: number) => {
+ const tab = await getTab(tabId);
+ if (tab?.windowId !== undefined) {
+ await focusWindow(tab.windowId);
+ }
+ await activateTab(tabId);
+};
+
+const getOffscreenDocumentContexts = async () => {
+ const offscreenUrl = chrome.runtime.getURL(OFFSCREEN_URL);
+ return new Promise>((resolve) => {
+ chrome.runtime.getContexts(
+ {
+ contextTypes: [chrome.runtime.ContextType.OFFSCREEN_DOCUMENT],
+ documentUrls: [offscreenUrl],
+ },
+ (contexts) => resolve(contexts),
+ );
+ });
+};
+
+const hasOffscreenDocument = async () =>
+ (await getOffscreenDocumentContexts()).length > 0;
+
+const createOffscreenDocument = () =>
+ new Promise((resolve, reject) => {
+ chrome.offscreen.createDocument(
+ {
+ url: OFFSCREEN_URL,
+ reasons: ["USER_MEDIA", "DISPLAY_MEDIA", "BLOBS", "AUDIO_PLAYBACK"],
+ justification: "Record and upload Cap videos from an extension page.",
+ },
+ () => {
+ const error = chrome.runtime.lastError;
+ if (!error) {
+ resolve();
+ return;
+ }
+
+ const message = error.message ?? "Failed to create offscreen document";
+ if (message.toLowerCase().includes("single offscreen document")) {
+ resolve();
+ return;
+ }
+
+ reject(new Error(message));
+ },
+ );
+ });
+
+const ensureOffscreenDocument = async () => {
+ const contexts = await getOffscreenDocumentContexts();
+ if (contexts.length > 0) return;
+
+ offscreenDocumentCreation ??= createOffscreenDocument().finally(() => {
+ offscreenDocumentCreation = null;
+ });
+ await offscreenDocumentCreation;
+};
+
+const wait = (durationMs: number) =>
+ new Promise((resolve) => {
+ globalThis.setTimeout(resolve, durationMs);
+ });
+
+const isTransientOffscreenMessageError = (error: unknown) => {
+ if (!(error instanceof Error)) return false;
+ const message = error.message.toLowerCase();
+ return (
+ message.includes("receiving end does not exist") ||
+ message.includes("could not establish connection")
+ );
+};
+
+const sendOffscreenRuntimeMessage = (message: OffscreenRequest) =>
+ new Promise((resolve, reject) => {
+ chrome.runtime.sendMessage(message, (response) => {
+ if (chrome.runtime.lastError) {
+ reject(new Error(chrome.runtime.lastError.message ?? "Message failed"));
+ return;
+ }
+ resolve(response as OffscreenResponse);
+ });
+ });
+
+const sendOffscreen = async (
+ message: OffscreenRequest,
+ options: { createIfMissing?: boolean } = {},
+) => {
+ if (options.createIfMissing === false) {
+ const hasDocument = await hasOffscreenDocument();
+ if (!hasDocument) {
+ return { ok: true, status: recordingStatus } satisfies OffscreenResponse;
+ }
+ } else {
+ await ensureOffscreenDocument();
+ }
+
+ let lastError: unknown;
+ for (let attempt = 1; attempt <= OFFSCREEN_MESSAGE_ATTEMPTS; attempt += 1) {
+ try {
+ return await sendOffscreenRuntimeMessage(message);
+ } catch (error) {
+ lastError = error;
+ if (
+ options.createIfMissing === false ||
+ attempt === OFFSCREEN_MESSAGE_ATTEMPTS ||
+ !isTransientOffscreenMessageError(error)
+ ) {
+ break;
+ }
+ await wait(OFFSCREEN_MESSAGE_RETRY_DELAY_MS);
+ await ensureOffscreenDocument();
+ }
+ }
+
+ throw lastError instanceof Error ? lastError : new Error(String(lastError));
+};
+
+const getTabStreamId = (tabId: number) =>
+ new Promise((resolve, reject) => {
+ chrome.tabCapture.getMediaStreamId({ targetTabId: tabId }, (streamId) => {
+ if (chrome.runtime.lastError) {
+ reject(
+ new Error(
+ chrome.runtime.lastError.message ?? "Failed to capture tab",
+ ),
+ );
+ return;
+ }
+ resolve(streamId);
+ });
+ });
+
+const sendOverlayMessage = (tabId: number, message: OverlayMessage) =>
+ new Promise((resolve) => {
+ chrome.tabs.sendMessage(tabId, message, () => {
+ resolve(!chrome.runtime.lastError);
+ });
+ });
+
+const sendOverlayMessageWithRetries = async (
+ tabId: number,
+ message: OverlayMessage,
+) => {
+ for (let attempt = 1; attempt <= OVERLAY_MESSAGE_ATTEMPTS; attempt += 1) {
+ if (await sendOverlayMessage(tabId, message)) return true;
+ if (attempt < OVERLAY_MESSAGE_ATTEMPTS) {
+ await wait(OVERLAY_MESSAGE_RETRY_DELAY_MS);
+ }
+ }
+
+ return false;
+};
+
+const sendOverlay = async (
+ tabId: number,
+ message: OverlayMessage,
+ injectIfMissing = true,
+) => {
+ const delivered = await sendOverlayMessage(tabId, message);
+ if (delivered) return true;
+ if (!injectIfMissing) return false;
+
+ const injected = await new Promise((resolve) => {
+ chrome.scripting.executeScript(
+ { target: { tabId }, files: ["assets/content-bootstrap.js"] },
+ () => resolve(!chrome.runtime.lastError),
+ );
+ });
+
+ if (!injected) return false;
+
+ return sendOverlayMessageWithRetries(tabId, message);
+};
+
+const removeTabWaiter = (
+ waiters: Map>,
+ tabId: number,
+ waiter: TabWaiter,
+) => {
+ const tabWaiters = waiters.get(tabId);
+ if (!tabWaiters) return;
+ tabWaiters.delete(waiter);
+ if (tabWaiters.size === 0) {
+ waiters.delete(tabId);
+ }
+};
+
+const waitForTabSignal = (
+ waiters: Map>,
+ tabId: number,
+ timeoutMs: number,
+ timeoutMessage: string,
+) =>
+ new Promise((resolve, reject) => {
+ let waiter: TabWaiter;
+ const timeoutId = globalThis.setTimeout(() => {
+ removeTabWaiter(waiters, tabId, waiter);
+ reject(new Error(timeoutMessage));
+ }, timeoutMs);
+
+ waiter = {
+ resolve: () => {
+ globalThis.clearTimeout(timeoutId);
+ resolve();
+ },
+ reject: (error) => {
+ globalThis.clearTimeout(timeoutId);
+ reject(error);
+ },
+ timeoutId,
+ };
+
+ const tabWaiters = waiters.get(tabId) ?? new Set();
+ tabWaiters.add(waiter);
+ waiters.set(tabId, tabWaiters);
+ });
+
+const settleTabWaiters = (
+ waiters: Map>,
+ tabId: number,
+ error?: Error,
+) => {
+ const tabWaiters = waiters.get(tabId);
+ if (!tabWaiters) return;
+ waiters.delete(tabId);
+ for (const waiter of tabWaiters) {
+ if (error) {
+ waiter.reject(error);
+ } else {
+ waiter.resolve();
+ }
+ }
+};
+
+const canInjectIntoTab = (tab: chrome.tabs.Tab) => {
+ if (tab.id === undefined) return false;
+ if (!tab.url) return true;
+ try {
+ const protocol = new URL(tab.url).protocol;
+ return (
+ protocol === "http:" || protocol === "https:" || protocol === "file:"
+ );
+ } catch {
+ return false;
+ }
+};
+
+const isWebPageSender = (sender: chrome.runtime.MessageSender) => {
+ if (!sender.tab) return false;
+ const senderUrl = sender.url ?? "";
+ return !senderUrl.startsWith("chrome-extension:");
+};
+
+// camera-preview.html is web accessible, so any site can load it in an
+// iframe. Only honour camera requests carrying a token that one of our
+// content scripts registered: web pages cannot send runtime messages, so a
+// registered token proves the frame was embedded by the extension overlay.
+const isCameraPreviewRequestAllowed = async (
+ sender: chrome.runtime.MessageSender,
+ sessionId: string,
+) => {
+ const token = sessionId.split(":")[0] ?? "";
+ if (!(await isOverlayTokenRegistered(token))) return false;
+
+ const senderUrl = sender.url ?? "";
+ if (senderUrl.startsWith("chrome-extension:")) {
+ // The camera preview document is the only extension page that drives
+ // the camera.
+ try {
+ return new URL(senderUrl).pathname === "/camera-preview.html";
+ } catch {
+ return false;
+ }
+ }
+
+ // Otherwise the sender is a content script (the parent PiP fallback).
+ return Boolean(sender.tab);
+};
+
+// Preview events (webcam frames, drag, errors) may only originate from the
+// camera-preview document itself, and only one whose URL-hash token a content
+// script registered. They are then relayed to the embedding tab's content
+// script over chrome.tabs.sendMessage — never window.postMessage, which the
+// recorded page could listen to.
+const isCameraPreviewEventAllowed = async (
+ sender: chrome.runtime.MessageSender,
+ token: string,
+) => {
+ if (!token || !(await isOverlayTokenRegistered(token))) return false;
+ const senderUrl = sender.url ?? "";
+ if (!senderUrl.startsWith("chrome-extension:")) return false;
+ try {
+ return new URL(senderUrl).pathname === "/camera-preview.html";
+ } catch {
+ return false;
+ }
+};
+
+const isActiveRecordingStatus = (status: RecordingStatus) =>
+ status.phase === "recording" ||
+ status.phase === "paused" ||
+ status.phase === "uploading";
+
+const isCapturingRecordingStatus = (status: RecordingStatus) =>
+ status.phase === "recording" || status.phase === "paused";
+
+const isRecordingPreviewStatus = (status: RecordingStatus) =>
+ status.phase === "creating" ||
+ status.phase === "recording" ||
+ status.phase === "paused";
+
+const normalizeComparableText = (value: string) =>
+ value.toLowerCase().replace(/\s+/g, " ").trim();
+
+const getComparableCaptureLabel = (label: string) =>
+ normalizeComparableText(label)
+ .replace(
+ /^(chrome tab|browser tab|tab|window|application|screen|display)\s*[:|-]\s*/,
+ "",
+ )
+ .replace(
+ /\s+-\s+(google chrome|chrome|chromium|microsoft edge|edge|brave|arc|opera|vivaldi).*$/,
+ "",
+ )
+ .trim();
+
+const getCapturedTabScore = (tab: chrome.tabs.Tab, label: string) => {
+ if (!tab.title) return 0;
+ const rawLabel = normalizeComparableText(label);
+ const comparableLabel = getComparableCaptureLabel(label);
+ const title = normalizeComparableText(tab.title);
+ if (!title || !comparableLabel) return 0;
+ if (title === comparableLabel || title === rawLabel) return 1000;
+ if (rawLabel.includes(title)) return 900 + title.length;
+ if (comparableLabel.includes(title)) return 800 + title.length;
+ if (title.includes(comparableLabel) && comparableLabel.length >= 8) {
+ return 700 + comparableLabel.length;
+ }
+ return 0;
+};
+
+const findCapturedBrowserTabId = async (source: RecordingCaptureSource) => {
+ if (source.tabId !== undefined) return source.tabId;
+ if (!source.label) return null;
+ const tabs = await getTabs();
+ let bestTabId: number | null = null;
+ let bestScore = 0;
+ for (const tab of tabs) {
+ if (tab.id === undefined) continue;
+ const score = getCapturedTabScore(tab, source.label);
+ if (score > bestScore) {
+ bestScore = score;
+ bestTabId = tab.id;
+ }
+ }
+ return bestScore > 0 ? bestTabId : null;
+};
+
+const isBrowserCaptureSource = (source: RecordingCaptureSource) =>
+ source.detectedMode === "tab" ||
+ source.displaySurface === "browser" ||
+ source.displaySurface === "tab";
+
+const isWindowCaptureSource = (source: RecordingCaptureSource) =>
+ source.detectedMode === "window" ||
+ source.displaySurface === "window" ||
+ source.displaySurface === "application";
+
+const isLikelyBrowserWindow = (source: RecordingCaptureSource) => {
+ if (!source.label) return false;
+ return /\b(google chrome|chrome|chromium|microsoft edge|edge|brave|arc|opera|vivaldi)\b/i.test(
+ source.label,
+ );
+};
+
+const shouldAutoPipCaptureSource = (source: RecordingCaptureSource) =>
+ isWindowCaptureSource(source) && !isLikelyBrowserWindow(source);
+
+const isWebcamPreviewEnabled = (settings: ExtensionSettings) =>
+ settings.webcam.enabled && Boolean(settings.webcam.deviceId);
+
+const shouldShowWebcamPreview = async (settings: ExtensionSettings) =>
+ isWebcamPreviewEnabled(settings) && !(await loadWebcamPreviewDismissed());
+
+const setActionPopup = (popup: string) =>
+ new Promise((resolve) => {
+ chrome.action.setPopup({ popup }, () => resolve());
+ });
+
+const setActionBadgeText = (text: string) =>
+ new Promise((resolve) => {
+ chrome.action.setBadgeText({ text }, () => resolve());
+ });
+
+const setActionTitle = (title: string) =>
+ new Promise((resolve) => {
+ chrome.action.setTitle({ title }, () => resolve());
+ });
+
+const updateActionForStatus = (nextStatus: RecordingStatus) => {
+ const isCapturing = isCapturingRecordingStatus(nextStatus);
+ return Promise.all([
+ // The recorder renders inside the page, so the action never opens a popup.
+ setActionPopup(""),
+ setActionBadgeText(isCapturing ? "REC" : ""),
+ setActionTitle(
+ isCapturing ? "Stop Cap recording" : "Record your screen with Cap",
+ ),
+ ]).then(() => undefined);
+};
+
+let lastSharedRecordingStateJson: string | null = null;
+
+const setRecordingStatus = (nextStatus: RecordingStatus) => {
+ recordingStatus = nextStatus;
+ void updateActionForStatus(nextStatus);
+ // Session storage is the cross-tab source of truth: every tab's floating
+ // bar subscribes to this key, so switching tabs never shows stale state.
+ // Skip identical writes — each write fans a storage.onChanged event out to
+ // every open tab, and status polls would otherwise rewrite an unchanged
+ // status several times per second.
+ const sharedState = {
+ status: nextStatus,
+ plan: bootstrapCache?.plan ?? null,
+ };
+ const sharedStateJson = JSON.stringify(sharedState);
+ if (sharedStateJson === lastSharedRecordingStateJson) return;
+ lastSharedRecordingStateJson = sharedStateJson;
+ void saveSharedRecordingState({
+ ...sharedState,
+ updatedAt: Date.now(),
+ }).catch(() => undefined);
+};
+
+const syncPanelForStatus = (status: RecordingStatus) => {
+ if (status.phase === "error") {
+ // Reopen the panel everywhere so the failure is actually visible; only
+ // the tab on screen renders it.
+ void updateSharedUiState((current) => ({
+ ...current,
+ panelOpen: true,
+ readyBarDismissed: false,
+ updatedAt: Date.now(),
+ })).catch(() => undefined);
+ return;
+ }
+ if (status.phase !== "idle") {
+ void updateSharedUiState((current) =>
+ current.panelOpen
+ ? { ...current, panelOpen: false, updatedAt: Date.now() }
+ : current,
+ ).catch(() => undefined);
+ }
+};
+
+const hidePreviewTab = async (tabId: number) => {
+ await sendOverlay(tabId, { type: "overlay-hide" }, false).catch(
+ () => undefined,
+ );
+ if (activePreviewTabId === tabId) {
+ activePreviewTabId = null;
+ }
+ if (pendingPreviewTabId === tabId) {
+ pendingPreviewTabId = null;
+ }
+ if (readyPreviewTabId === tabId) {
+ readyPreviewTabId = null;
+ }
+};
+
+const hidePreviewTabsExcept = async (activeTabId: number) => {
+ const tabs = await getTabs();
+ await Promise.all(
+ tabs.map((tab) => {
+ if (
+ tab.id === undefined ||
+ tab.id === activeTabId ||
+ !canInjectIntoTab(tab)
+ ) {
+ return undefined;
+ }
+ return sendOverlay(tab.id, { type: "overlay-hide" }, false).catch(
+ () => undefined,
+ );
+ }),
+ );
+};
+
+const showOverlayInActiveTab = async (
+ settings: ExtensionSettings,
+ recording: boolean,
+) => {
+ if (!(await shouldShowWebcamPreview(settings))) {
+ if (activePreviewTabId !== null) {
+ await hidePreviewTab(activePreviewTabId);
+ }
+ if (pendingPreviewTabId !== null) {
+ await hidePreviewTab(pendingPreviewTabId);
+ }
+ return false;
+ }
+
+ const tab = await getActiveTab();
+ return showOverlayInTab(tab, settings, recording);
+};
+
+const showOverlayInTab = async (
+ tab: chrome.tabs.Tab | null,
+ settings: ExtensionSettings,
+ recording: boolean,
+) => {
+ if (!(await shouldShowWebcamPreview(settings))) {
+ if (activePreviewTabId !== null) {
+ await hidePreviewTab(activePreviewTabId);
+ }
+ if (pendingPreviewTabId !== null) {
+ await hidePreviewTab(pendingPreviewTabId);
+ }
+ return false;
+ }
+
+ if (!tab || !canInjectIntoTab(tab) || tab.id === undefined) return false;
+ const delivered = await sendOverlay(tab.id, {
+ type: "overlay-settings",
+ settings: settings.webcam,
+ recording,
+ });
+ if (!delivered) return false;
+
+ if (activePreviewTabId === null || activePreviewTabId === tab.id) {
+ activePreviewTabId = tab.id;
+ pendingPreviewTabId = null;
+ await hidePreviewTabsExcept(tab.id);
+ } else {
+ pendingPreviewTabId = tab.id;
+ }
+
+ return true;
+};
+
+const closeAllExtensionUi = async () => {
+ await saveWebcamPreviewDismissed(true);
+ // Closing the recorder acknowledges a surfaced failure; otherwise the
+ // error keeps reappearing every time the panel opens.
+ if (recordingStatus.phase === "error") {
+ setRecordingStatus({ phase: "idle" });
+ await sendOffscreen(
+ { target: "offscreen", type: "acknowledge-error" },
+ { createIfMissing: false },
+ ).catch(() => undefined);
+ }
+ await Promise.all([
+ broadcastOverlayHide(),
+ updateSharedUiState((current) => ({
+ ...current,
+ panelOpen: false,
+ updatedAt: Date.now(),
+ })).then(() => undefined),
+ ]);
+};
+
+const showPreviewForRecorderOpen = async (
+ tab: chrome.tabs.Tab,
+ status: RecordingStatus,
+) => {
+ const [settings, auth] = await Promise.all([loadSettings(), loadAuth()]);
+ if (!auth || !isWebcamPreviewEnabled(settings)) return;
+ await saveWebcamPreviewDismissed(false);
+ await showOverlayInTab(tab, settings, isRecordingPreviewStatus(status));
+};
+
+type InjectableTab = chrome.tabs.Tab & { id: number };
+
+const getRecorderPanelTabs = async (actionTab?: chrome.tabs.Tab) => {
+ const candidates = [
+ actionTab ?? null,
+ await getActiveTab(),
+ await getLastFocusedTab(),
+ ];
+ const seen = new Set();
+
+ return candidates.filter((tab): tab is InjectableTab => {
+ if (!tab || tab.id === undefined || !canInjectIntoTab(tab)) return false;
+ if (seen.has(tab.id)) return false;
+ seen.add(tab.id);
+ return true;
+ });
+};
+
+const openRecorderPanel = async (actionTab?: chrome.tabs.Tab) => {
+ // Clicking the action toggles the recorder UI. When it is already open,
+ // close it through the same path as the panel's own close button so the
+ // camera preview is torn down too — a blind panel-toggle would hide the
+ // panel and recording bar but leave the camera window stranded.
+ const sharedUi = await loadSharedUiState().catch(() => null);
+ if (sharedUi?.panelOpen) {
+ await closeAllExtensionUi();
+ return;
+ }
+
+ const currentStatus = await syncRecordingStatus().catch(
+ () => recordingStatus,
+ );
+ for (const tab of await getRecorderPanelTabs(actionTab)) {
+ const delivered = await sendOverlay(tab.id, {
+ type: "overlay-panel-toggle",
+ });
+ if (delivered) {
+ await focusTab(tab.id);
+ void showPreviewForRecorderOpen(tab, currentStatus).catch(
+ () => undefined,
+ );
+ return;
+ }
+ }
+
+ // Pages we cannot inject into (chrome://, the Web Store, etc.) still get a
+ // recorder via a standalone popup window.
+ chrome.windows.create({
+ url: chrome.runtime.getURL(POPUP_URL),
+ type: "popup",
+ width: 332,
+ height: 600,
+ });
+};
+
+const getPreviewTabIdForPip = async () => {
+ const settings = await loadSettings();
+ if (!(await shouldShowWebcamPreview(settings))) return null;
+ if (activePreviewTabId !== null) return activePreviewTabId;
+ const tab = await getLastFocusedTab();
+ if (!tab || !canInjectIntoTab(tab) || tab.id === undefined) return null;
+ activePreviewTabId = tab.id;
+ return tab.id;
+};
+
+const enterActivePreviewAutoPip = async () => {
+ const tabId = await getPreviewTabIdForPip();
+ if (tabId === null) return false;
+ return sendOverlay(tabId, { type: "overlay-enter-auto-pip" }, false).catch(
+ () => false,
+ );
+};
+
+const exitActivePreviewAutoPip = async () => {
+ const tabId = await getPreviewTabIdForPip();
+ if (tabId === null) return false;
+ return sendOverlay(tabId, { type: "overlay-exit-auto-pip" }, false).catch(
+ () => false,
+ );
+};
+
+const waitForWebcamPreviewReady = (tabId: number) => {
+ if (readyPreviewTabId === tabId) return Promise.resolve();
+ return waitForTabSignal(
+ previewReadyWaiters,
+ tabId,
+ START_PREVIEW_READY_TIMEOUT_MS,
+ "Camera preview did not become ready before recording started.",
+ );
+};
+
+const disconnectAllCameraPreviews = async () => {
+ const response = await sendOffscreen(
+ { target: "offscreen", type: "disconnect-camera-previews" },
+ { createIfMissing: false },
+ );
+ return response.ok;
+};
+
+const broadcastOverlayHide = async () => {
+ await disconnectAllCameraPreviews().catch(() => undefined);
+ const tabs = await getTabs();
+ await Promise.all(
+ tabs.map((tab) => {
+ if (!canInjectIntoTab(tab) || tab.id === undefined) {
+ return undefined;
+ }
+ return sendOverlay(tab.id, { type: "overlay-hide" }, false).catch(
+ () => undefined,
+ );
+ }),
+ );
+ activePreviewTabId = null;
+ pendingPreviewTabId = null;
+ readyPreviewTabId = null;
+};
+
+const broadcastRecordingStatusToTabs = async (status: RecordingStatus) => {
+ const message: RecordingStatusBroadcast = {
+ target: "recording-status",
+ type: "recording-status-changed",
+ status,
+ };
+ const tabs = await getTabs();
+ await Promise.all(
+ tabs.map((tab) => {
+ if (!canInjectIntoTab(tab) || tab.id === undefined) {
+ return undefined;
+ }
+ const tabId = tab.id;
+ return new Promise((resolve) => {
+ chrome.tabs.sendMessage(tabId, message, () => {
+ void chrome.runtime.lastError;
+ resolve();
+ });
+ });
+ }),
+ );
+};
+
+const setRecordingStatusAndBroadcast = (nextStatus: RecordingStatus) => {
+ // Progress ticks arrive from the offscreen recorder every 500ms; fanning
+ // each one out with a tabs.query plus a per-tab sendMessage multiplies a
+ // sustained 2/sec storm by the tab count. The session-storage mirror
+ // written by setRecordingStatus (deduped against identical states) is the
+ // per-tick fan-out; per-tab messages are reserved for phase transitions,
+ // which consumers use as low-frequency "something changed" wake-ups.
+ const phaseChanged = nextStatus.phase !== recordingStatus.phase;
+ setRecordingStatus(nextStatus);
+ if (phaseChanged) {
+ void broadcastRecordingStatusToTabs(nextStatus);
+ }
+ syncPanelForStatus(nextStatus);
+};
+
+const injectOverlayIntoOpenTabs = async () => {
+ const tabs = await getTabs();
+ await Promise.all(
+ tabs.map((tab) => {
+ if (!canInjectIntoTab(tab) || tab.id === undefined) {
+ return undefined;
+ }
+ const tabId = tab.id;
+ return new Promise((resolve) => {
+ chrome.scripting.executeScript(
+ { target: { tabId }, files: ["assets/content-bootstrap.js"] },
+ () => {
+ void chrome.runtime.lastError;
+ resolve();
+ },
+ );
+ });
+ }),
+ );
+};
+
+const broadcastCameraDevices = (devices: CameraDevice[]) => {
+ chrome.runtime.sendMessage(
+ {
+ type: "camera-devices-changed",
+ devices,
+ },
+ () => undefined,
+ );
+};
+
+// The recorder panel is a cross-origin iframe, so its own enumerateDevices()
+// returns no labelled devices even when the grant exists. Enumerate in the
+// offscreen document instead (a top-level extension page that keeps the grant)
+// and cache the result. Empty results never overwrite a populated cache: a
+// transient enumeration that loses labels must not wipe known devices.
+const refreshMediaDevicesFromOffscreen = async () => {
+ try {
+ const response = await sendOffscreen({
+ target: "offscreen",
+ type: "enumerate-devices",
+ });
+ if (response.ok && response.devices) {
+ if (response.devices.cameras.length > 0) {
+ cameraDevicesCache = response.devices.cameras;
+ broadcastCameraDevices(cameraDevicesCache);
+ }
+ if (response.devices.microphones.length > 0) {
+ microphoneDevicesCache = response.devices.microphones;
+ }
+ }
+ } catch {
+ // Fall back to whatever is already cached.
+ }
+};
+
+const getUploadProgressUrl = (videoId: string) => {
+ const url = new URL(chrome.runtime.getURL("uploading.html"));
+ url.searchParams.set("videoId", videoId);
+ return url.toString();
+};
+
+// Module state dies whenever the MV3 service worker is recycled, so the
+// uploading-tab id is mirrored into session storage; a restarted worker
+// rehydrates it before deciding to open another tab.
+const setUploadProgressTabId = (tabId: number | null) => {
+ uploadProgressTabId = tabId;
+ void saveUploadProgressTabId(tabId).catch(() => undefined);
+};
+
+const getUploadProgressTabId = async () => {
+ if (uploadProgressTabId !== null) return uploadProgressTabId;
+ const persisted = await loadUploadProgressTabId().catch(() => null);
+ if (persisted === null) return null;
+ const tab = await getTab(persisted);
+ if (!tab) {
+ void saveUploadProgressTabId(null).catch(() => undefined);
+ return null;
+ }
+ uploadProgressTabId = persisted;
+ return persisted;
+};
+
+const openRecordingDestinationInner = async (status: RecordingStatus) => {
+ if (status.phase === "completed") {
+ await createTab(status.shareUrl);
+ return;
+ }
+
+ if (status.phase !== "uploading" || !status.videoId) return;
+
+ const url = getUploadProgressUrl(status.videoId);
+ const existingTabId = await getUploadProgressTabId();
+ if (existingTabId !== null) {
+ const updated = await updateTab(existingTabId, url).then(
+ () => true,
+ () => false,
+ );
+ if (updated) return;
+ setUploadProgressTabId(null);
+ }
+
+ const tab = await createTab(url);
+ setUploadProgressTabId(tab.id ?? null);
+};
+
+// Serialised: concurrent callers (the free-plan auto-stop tick re-sends
+// stop-recording until finalize starts) could otherwise both read a null
+// upload-tab id before either persists one and open duplicate tabs.
+let openRecordingDestinationQueue: Promise = Promise.resolve();
+
+const openRecordingDestination = (status: RecordingStatus) => {
+ const run = openRecordingDestinationQueue.then(() =>
+ openRecordingDestinationInner(status),
+ );
+ openRecordingDestinationQueue = run.then(
+ () => undefined,
+ () => undefined,
+ );
+ return run;
+};
+
+let bootstrapRefreshInFlight = false;
+
+const refreshBootstrapInBackground = (
+ settings: ExtensionSettings,
+ auth: ExtensionAuth,
+) => {
+ if (bootstrapRefreshInFlight) return;
+ bootstrapRefreshInFlight = true;
+ fetchBootstrap(settings, auth)
+ .then((bootstrap) => {
+ bootstrapCache = bootstrap;
+ return saveCachedBootstrap(bootstrap);
+ })
+ .catch(() => undefined)
+ .finally(() => {
+ bootstrapRefreshInFlight = false;
+ });
+};
+
+const loadSignedInState = async () => {
+ const [settings, auth, pendingAuth, authError] = await Promise.all([
+ loadSettings(),
+ loadAuth(),
+ loadPendingAuth(),
+ loadAuthError(),
+ ]);
+ const authPending =
+ !!pendingAuth && Date.now() - pendingAuth.startedAt < AUTH_TIMEOUT_MS;
+ if (pendingAuth && !authPending) {
+ await clearPendingAuth();
+ }
+ if (!auth) {
+ return { settings, auth: null, bootstrap: null, authPending, authError };
+ }
+
+ if (!bootstrapCache) {
+ bootstrapCache = await loadCachedBootstrap();
+ }
+
+ // Serve the cached bootstrap immediately so the popup opens without waiting
+ // on the network, and refresh it in the background.
+ if (bootstrapCache) {
+ refreshBootstrapInBackground(settings, auth);
+ return {
+ settings,
+ auth,
+ bootstrap: bootstrapCache,
+ authPending: false,
+ authError: null,
+ };
+ }
+
+ bootstrapCache = await fetchBootstrap(settings, auth);
+ await saveCachedBootstrap(bootstrapCache);
+ return {
+ settings,
+ auth,
+ bootstrap: bootstrapCache,
+ authPending: false,
+ authError: null,
+ };
+};
+
+const requireSignedInState = async () => {
+ const state = await loadSignedInState();
+ if (!state.auth || !state.bootstrap) {
+ throw new Error("Sign in to Cap first");
+ }
+ return state as {
+ settings: ExtensionSettings;
+ auth: ExtensionAuth;
+ bootstrap: BootstrapData;
+ };
+};
+
+// Pending floating-confirm prompts keyed by requestId; the recorded tab's
+// overlay resolves them via a "confirm-result" request. Falls back to cancel
+// after the timeout so a start never hangs forever on a prompt the user walked
+// away from.
+const recordingConfirmWaiters = new Map void>();
+const CONFIRM_DECISION_TIMEOUT_MS = 2 * 60 * 1000;
+
+const resolveRecordingConfirmation = (
+ requestId: string,
+ confirmed: boolean,
+) => {
+ const waiter = recordingConfirmWaiters.get(requestId);
+ if (!waiter) return;
+ recordingConfirmWaiters.delete(requestId);
+ waiter(confirmed);
+};
+
+// Shows the floating confirm prompt on the recorded tab and waits for the
+// user's decision. If the prompt cannot be shown (no tab, restricted page),
+// the recording proceeds rather than being blocked by UI that never appeared.
+const requestRecordingConfirmation = async (
+ tabId: number | undefined,
+ variant: MicrophoneWarningVariant,
+): Promise => {
+ if (tabId === undefined) return true;
+
+ const requestId = crypto.randomUUID();
+ let settle: (confirmed: boolean) => void = () => undefined;
+ const decision = new Promise((resolve) => {
+ settle = resolve;
+ });
+ const timer = globalThis.setTimeout(() => {
+ recordingConfirmWaiters.delete(requestId);
+ settle(false);
+ }, CONFIRM_DECISION_TIMEOUT_MS);
+ // Register before sending so a fast click cannot resolve before the waiter
+ // exists.
+ recordingConfirmWaiters.set(requestId, (confirmed) => {
+ globalThis.clearTimeout(timer);
+ settle(confirmed);
+ });
+
+ const delivered = await sendOverlay(tabId, {
+ type: "overlay-confirm",
+ requestId,
+ variant,
+ });
+ if (!delivered) {
+ globalThis.clearTimeout(timer);
+ recordingConfirmWaiters.delete(requestId);
+ return true;
+ }
+ return decision;
+};
+
+// Decides whether a recording start needs a mic warning. The silence check
+// runs in the offscreen document, which has reliable mic access.
+const resolveMicWarning = async (
+ settings: ExtensionSettings,
+): Promise => {
+ if (!settings.microphoneWarning.enabled) return null;
+ if (!settings.microphone.enabled) return "no-mic";
+ const response = await sendOffscreen({
+ target: "offscreen",
+ type: "probe-microphone",
+ microphone: settings.microphone,
+ }).catch(() => null);
+ if (
+ response?.ok &&
+ response.micProbe?.available &&
+ !response.micProbe.hasSound
+ ) {
+ return "no-sound";
+ }
+ return null;
+};
+
+const startRecording = async (mode: RecordingMode) => {
+ const { settings, auth, bootstrap } = await requireSignedInState();
+ externalCaptureAutoPipPending = false;
+ const recordingSettings =
+ settings.capture.recordingMode === mode
+ ? settings
+ : rememberRecordingMode(settings, mode);
+ if (recordingSettings !== settings) {
+ await saveSettings(recordingSettings);
+ }
+ if (mode === "camera" && !recordingSettings.webcam.deviceId) {
+ throw new Error("Select a camera before recording.");
+ }
+ if (isWebcamPreviewEnabled(recordingSettings)) {
+ await saveWebcamPreviewDismissed(false);
+ }
+ const tab = await getActiveTab();
+ const tabId = tab?.id;
+
+ // Shared mic gate: every start path (panel button and the floating bar)
+ // funnels through here, so the warning is consistent. Run it before any
+ // capture setup so declining leaves nothing to tear down.
+ const micWarning = await resolveMicWarning(recordingSettings);
+ if (micWarning) {
+ const confirmed = await requestRecordingConfirmation(tabId, micWarning);
+ if (!confirmed) {
+ externalCaptureAutoPipPending = false;
+ return {
+ ok: false,
+ canceled: true,
+ error: "Recording canceled",
+ } satisfies OffscreenResponse;
+ }
+ }
+
+ const tabStreamId =
+ mode === "tab" && tabId !== undefined
+ ? await getTabStreamId(tabId)
+ : undefined;
+ const overlayShown = await showOverlayInTab(
+ tab ?? null,
+ recordingSettings,
+ true,
+ );
+ const readyWaits: Array> = [];
+ if (overlayShown && tabId !== undefined) {
+ if (isWebcamPreviewEnabled(recordingSettings)) {
+ // The preview is a nice-to-have: never abort the recording because it
+ // did not come up in time.
+ readyWaits.push(waitForWebcamPreviewReady(tabId).catch(() => undefined));
+ }
+ }
+
+ const creatingStatus = { phase: "creating" } satisfies RecordingStatus;
+ setRecordingStatusAndBroadcast(creatingStatus);
+ // The manifest injects the bootstrap content script into every page at
+ // document_idle and onInstalled covers tabs that predate the extension, so
+ // no blanket re-injection is needed here; sendOverlay still injects
+ // per-tab on demand and the bootstrap lazy-loads the overlay UI.
+ if (overlayShown) {
+ await showOverlayInTab(tab ?? null, recordingSettings, true);
+ }
+
+ try {
+ await Promise.all(readyWaits);
+ return await sendOffscreen({
+ target: "offscreen",
+ type: "start-recording",
+ mode,
+ settings: recordingSettings,
+ auth,
+ bootstrap,
+ tabId,
+ tabStreamId,
+ });
+ } catch (error) {
+ // The recorder panel closes as soon as the status leaves "idle", so a
+ // silent reset would leave the user with no feedback at all. Broadcast
+ // the failure so the panel reopens and shows it.
+ setRecordingStatusAndBroadcast({
+ phase: "error",
+ message: error instanceof Error ? error.message : String(error),
+ });
+ externalCaptureAutoPipPending = false;
+ throw error;
+ }
+};
+
+const forwardToOffscreen = (type: OffscreenRequest["type"]) =>
+ sendOffscreen({ target: "offscreen", type } as OffscreenRequest);
+
+const syncRecordingStatus = async () => {
+ const hasDocument = await hasOffscreenDocument();
+ if (!hasDocument) {
+ if (isActiveRecordingStatus(recordingStatus)) {
+ setRecordingStatusAndBroadcast({ phase: "idle" });
+ } else {
+ // A restarted service worker boots as "idle" while session storage may
+ // still hold the previous life's status; rewrite it so no tab keeps
+ // rendering a recording bar for a recorder that no longer exists.
+ void loadSharedRecordingState()
+ .then((state) => {
+ if (state && state.status.phase !== recordingStatus.phase) {
+ setRecordingStatus(recordingStatus);
+ }
+ })
+ .catch(() => undefined);
+ }
+ return recordingStatus;
+ }
+ const response = await sendOffscreen(
+ { target: "offscreen", type: "get-recording-status" },
+ { createIfMissing: false },
+ );
+ if (response.ok && response.status) {
+ if (
+ recordingStatus.phase === "creating" &&
+ response.status.phase === "idle"
+ ) {
+ return recordingStatus;
+ }
+ // A restarted service worker boots as "idle" even while the offscreen
+ // document is still recording. Broadcast phase changes so open tabs
+ // (the floating bar in particular) catch back up.
+ if (response.status.phase !== recordingStatus.phase) {
+ setRecordingStatusAndBroadcast(response.status);
+ } else {
+ setRecordingStatus(response.status);
+ }
+ }
+ return recordingStatus;
+};
+
+const stopRecordingAndOpenDestination = async () => {
+ const response = await forwardToOffscreen("stop-recording");
+ if (response.ok && response.status) {
+ setRecordingStatus(response.status);
+ if (!isCapturingRecordingStatus(response.status)) {
+ externalCaptureAutoPipPending = false;
+ await saveWebcamPreviewDismissed(true);
+ void broadcastOverlayHide();
+ }
+ void openRecordingDestination(response.status).catch(() => undefined);
+ }
+ return response;
+};
+
+const syncActivePreview = async (tabId?: number) => {
+ const currentStatus = await syncRecordingStatus().catch(
+ () => recordingStatus,
+ );
+ const settings = await loadSettings();
+ const recording = isRecordingPreviewStatus(currentStatus);
+ if (tabId !== undefined) {
+ const shown = await showOverlayInTab(
+ await getTab(tabId),
+ settings,
+ recording,
+ );
+ if (!shown && (await shouldShowWebcamPreview(settings))) {
+ await enterActivePreviewAutoPip();
+ }
+ return;
+ }
+ const shown = await showOverlayInActiveTab(settings, recording);
+ if (!shown && (await shouldShowWebcamPreview(settings))) {
+ await enterActivePreviewAutoPip();
+ }
+};
+
+const launchWebAuthFlow = (url: string) =>
+ new Promise((resolve, reject) => {
+ chrome.identity.launchWebAuthFlow(
+ { url, interactive: true },
+ (responseUrl) => {
+ if (chrome.runtime.lastError || !responseUrl) {
+ reject(
+ new Error(
+ chrome.runtime.lastError?.message ??
+ "The sign-in window was closed",
+ ),
+ );
+ return;
+ }
+ resolve(responseUrl);
+ },
+ );
+ });
+
+// Chrome rejects a second interactive auth flow while one is open, so a
+// repeat click must not relaunch; the open window stays authoritative.
+let authFlowInFlight = false;
+
+const beginAuthFlow = async (settings: ExtensionSettings) => {
+ if (authFlowInFlight) return;
+ // Claimed synchronously: the guard sits before several awaits (storage,
+ // the createAuthStart round trip), so a second click in that window would
+ // otherwise launch a duplicate interactive flow that Chrome rejects.
+ authFlowInFlight = true;
+
+ let authStart: Awaited>;
+ try {
+ await clearAuthError();
+ await clearPendingAuth();
+ authStart = await createAuthStart(settings);
+ await savePendingAuth({
+ state: authStart.state,
+ redirectUri: authStart.redirectUri,
+ startedAt: Date.now(),
+ });
+ } catch (error) {
+ authFlowInFlight = false;
+ throw error;
+ }
+ // launchWebAuthFlow keeps the whole exchange inside an isolated auth
+ // window: the minted key only travels in the intercepted redirect, never
+ // through a regular tab's URL bar, browser history, or the tabs API that
+ // co-installed extensions can observe.
+ void launchWebAuthFlow(authStart.url)
+ .then(async (responseUrl) => {
+ const auth = parseAuthResponse(responseUrl, authStart.state);
+ // Server-minted keys never expire, so a re-auth would otherwise leave
+ // the previous key live in auth_api_keys forever; revoke it
+ // best-effort before it is forgotten locally.
+ const previousAuth = await loadAuth().catch(() => null);
+ if (previousAuth && previousAuth.authApiKey !== auth.authApiKey) {
+ await revokeAuth(settings, previousAuth).catch(() => undefined);
+ }
+ await saveAuth(auth);
+ bootstrapCache = null;
+ refreshBootstrapInBackground(settings, auth);
+ })
+ .catch(async (error: unknown) => {
+ // This rejection has no open message channel to land in; persist it
+ // so the popup's pending-auth poll can show the failure instead of
+ // silently snapping back to the sign-in button.
+ await saveAuthError(
+ error instanceof Error ? error.message : String(error),
+ ).catch(() => undefined);
+ })
+ .finally(() => {
+ authFlowInFlight = false;
+ void clearPendingAuth();
+ });
+};
+
+const handlePreviewReady = async (tabId?: number) => {
+ if (tabId === undefined) return;
+ if (pendingPreviewTabId !== null && pendingPreviewTabId !== tabId) return;
+ activePreviewTabId = tabId;
+ pendingPreviewTabId = null;
+ readyPreviewTabId = tabId;
+ settleTabWaiters(previewReadyWaiters, tabId);
+ await hidePreviewTabsExcept(tabId);
+ if (!browserWindowFocused || externalCaptureAutoPipPending) {
+ await enterActivePreviewAutoPip();
+ }
+};
+
+const handlePreviewError = async (
+ tabId: number | undefined,
+ reason: CameraPreviewErrorReason,
+) => {
+ if (tabId === undefined) return;
+ if (pendingPreviewTabId === tabId) {
+ pendingPreviewTabId = null;
+ }
+ if (readyPreviewTabId === tabId) {
+ readyPreviewTabId = null;
+ }
+ settleTabWaiters(
+ previewReadyWaiters,
+ tabId,
+ new Error("Camera preview did not become ready."),
+ );
+
+ const fallbackTabId =
+ activePreviewTabId !== null && activePreviewTabId !== tabId
+ ? activePreviewTabId
+ : null;
+
+ if (reason === "permissions-policy" && fallbackTabId !== null) {
+ await hidePreviewTab(tabId);
+ await enterActivePreviewAutoPip();
+ }
+};
+
+const handleCaptureSource = async (source: RecordingCaptureSource) => {
+ if (isBrowserCaptureSource(source)) {
+ externalCaptureAutoPipPending = false;
+ const tabId = await findCapturedBrowserTabId(source);
+ if (tabId !== null) {
+ await focusTab(tabId);
+ await syncActivePreview(tabId);
+ }
+ await exitActivePreviewAutoPip();
+ return;
+ }
+
+ if (!shouldAutoPipCaptureSource(source)) {
+ externalCaptureAutoPipPending = false;
+ return;
+ }
+
+ externalCaptureAutoPipPending = true;
+ await enterActivePreviewAutoPip();
+};
+
+const handleRequest = async (
+ message: ServiceWorkerRequest,
+ sender: chrome.runtime.MessageSender,
+): Promise => {
+ if (message.type === "auth-start") {
+ const settings = await loadSettings();
+ await beginAuthFlow(settings);
+ return { ok: true, auth: null, authPending: true, settings };
+ }
+
+ if (message.type === "auth-revoke") {
+ const settings = await loadSettings();
+ const auth = await loadAuth();
+ if (auth) {
+ try {
+ await revokeAuth(settings, auth);
+ } catch (error) {
+ // A definitive 4xx means the server saw the request and the key
+ // is unusable or already gone, so clearing local state is right.
+ // Anything else (network failure, timeout, 5xx) leaves a live
+ // key on the server; keep the session and surface the failure
+ // instead of pretending the sign-out worked.
+ const status = error instanceof ApiRequestError ? error.status : null;
+ if (status === null || status >= 500) {
+ throw new Error(
+ "Could not reach Cap to revoke this sign-in. Check your connection and try again.",
+ );
+ }
+ }
+ }
+ await clearAuth();
+ await clearPendingAuth();
+ await clearCachedBootstrap();
+ bootstrapCache = null;
+ return { ok: true, auth: null, settings };
+ }
+
+ if (message.type === "bootstrap") {
+ const state = await loadSignedInState();
+ await saveWebcamPreviewDismissed(false);
+ await syncRecordingStatus().catch(() => recordingStatus);
+ if (state.auth) {
+ void showOverlayInActiveTab(
+ state.settings,
+ isRecordingPreviewStatus(recordingStatus),
+ ).catch(() => undefined);
+ }
+ return {
+ ok: true,
+ auth: state.auth,
+ authPending: state.authPending,
+ authError: state.authError,
+ bootstrap: state.bootstrap ?? undefined,
+ cameraDevices: cameraDevicesCache,
+ microphoneDevices: microphoneDevicesCache,
+ settings: state.settings,
+ status: recordingStatus,
+ };
+ }
+
+ if (message.type === "get-overlay-settings") {
+ const [settings, currentStatus] = await Promise.all([
+ loadSettings(),
+ syncRecordingStatus().catch(() => recordingStatus),
+ ]);
+ return { ok: true, settings, status: currentStatus };
+ }
+
+ if (message.type === "get-camera-devices") {
+ return { ok: true, cameraDevices: cameraDevicesCache };
+ }
+
+ if (message.type === "get-media-devices") {
+ await refreshMediaDevicesFromOffscreen();
+ return {
+ ok: true,
+ cameraDevices: cameraDevicesCache,
+ microphoneDevices: microphoneDevicesCache,
+ };
+ }
+
+ if (message.type === "camera-devices-updated") {
+ // Pages that enumerate from a context without device labels (the camera
+ // preview iframe) publish an empty list; let it through only when nothing
+ // is cached yet so it cannot wipe a populated list.
+ if (message.devices.length > 0 || cameraDevicesCache.length === 0) {
+ cameraDevicesCache = message.devices;
+ broadcastCameraDevices(cameraDevicesCache);
+ }
+ return { ok: true, cameraDevices: cameraDevicesCache };
+ }
+
+ if (message.type === "start-recording") {
+ const response = await startRecording(message.mode);
+ if (response.ok && response.status) {
+ setRecordingStatusAndBroadcast(response.status);
+ if (isRecordingPreviewStatus(response.status)) {
+ const settings = await loadSettings();
+ if (isWebcamPreviewEnabled(settings)) {
+ await saveWebcamPreviewDismissed(false);
+ await showOverlayInActiveTab(settings, true);
+ }
+ }
+ } else if (!response.ok && !response.canceled) {
+ // Surface real failures: the panel already closed on "creating", so
+ // this broadcast is what reopens it with the error message.
+ setRecordingStatusAndBroadcast({
+ phase: "error",
+ message: response.error,
+ });
+ } else {
+ // The user dismissed the capture picker; quietly return to idle.
+ setRecordingStatusAndBroadcast({ phase: "idle" });
+ }
+ return response.ok
+ ? { ok: true, status: response.status }
+ : { ok: false, error: response.error, canceled: response.canceled };
+ }
+
+ if (message.type === "stop-recording") {
+ const response = await stopRecordingAndOpenDestination();
+ return response.ok
+ ? { ok: true, status: response.status }
+ : { ok: false, error: response.error };
+ }
+
+ if (message.type === "retry-upload") {
+ const response = await sendOffscreen({
+ target: "offscreen",
+ type: "retry-upload",
+ videoId: message.videoId,
+ });
+ if (response.ok && response.status) {
+ setRecordingStatusAndBroadcast(response.status);
+ }
+ return response.ok
+ ? { ok: true, status: response.status }
+ : { ok: false, error: response.error };
+ }
+
+ if (message.type === "get-recording-status") {
+ const currentStatus = await syncRecordingStatus().catch(
+ () => recordingStatus,
+ );
+ if (!bootstrapCache) {
+ bootstrapCache = await loadCachedBootstrap();
+ }
+ return {
+ ok: true,
+ status: currentStatus,
+ plan: bootstrapCache?.plan,
+ };
+ }
+
+ if (
+ message.type === "pause-recording" ||
+ message.type === "resume-recording"
+ ) {
+ const response = await forwardToOffscreen(message.type);
+ if (response.ok && response.status) {
+ setRecordingStatusAndBroadcast(response.status);
+ }
+ return response.ok
+ ? { ok: true, status: response.status }
+ : { ok: false, error: response.error };
+ }
+
+ if (message.type === "open-options") {
+ chrome.tabs.create({ url: chrome.runtime.getURL("options.html") });
+ return { ok: true };
+ }
+
+ if (message.type === "open-how-it-works") {
+ chrome.tabs.create({ url: chrome.runtime.getURL("how-it-works.html") });
+ return { ok: true };
+ }
+
+ if (message.type === "close-extension-ui") {
+ await closeAllExtensionUi();
+ return { ok: true };
+ }
+
+ if (message.type === "settings-updated") {
+ await saveSettings(message.settings);
+ if (isWebcamPreviewEnabled(message.settings)) {
+ await saveWebcamPreviewDismissed(false);
+ await showOverlayInActiveTab(
+ message.settings,
+ isRecordingPreviewStatus(recordingStatus),
+ );
+ } else {
+ await broadcastOverlayHide();
+ }
+ return { ok: true, settings: message.settings };
+ }
+
+ if (message.type === "close-webcam-preview") {
+ const settings = await loadSettings();
+ await saveWebcamPreviewDismissed(true);
+ await broadcastOverlayHide();
+ return { ok: true, settings };
+ }
+
+ if (message.type === "register-overlay-token") {
+ // Tokens may only come from content scripts. Extension pages are
+ // excluded so a web-accessible page embedded by a hostile site cannot
+ // authorise itself.
+ if (!isWebPageSender(sender)) {
+ return { ok: false, error: "Unauthorized" };
+ }
+ await registerOverlayToken(message.token);
+ return { ok: true };
+ }
+
+ if (message.type === "validate-overlay-token") {
+ return { ok: true, valid: await isOverlayTokenRegistered(message.token) };
+ }
+
+ if (message.type === "connect-camera-preview") {
+ const allowed = await isCameraPreviewRequestAllowed(
+ sender,
+ message.sessionId,
+ );
+ if (!allowed) {
+ return { ok: false, error: "Camera preview is not authorized." };
+ }
+ const response = await sendOffscreen({
+ target: "offscreen",
+ type: "connect-camera-preview",
+ sessionId: message.sessionId,
+ settings: message.settings,
+ offer: message.offer,
+ });
+ return response.ok
+ ? { ok: true, answer: response.answer }
+ : { ok: false, error: response.error };
+ }
+
+ if (message.type === "disconnect-camera-preview") {
+ const response = await sendOffscreen(
+ {
+ target: "offscreen",
+ type: "disconnect-camera-preview",
+ sessionId: message.sessionId,
+ },
+ { createIfMissing: false },
+ );
+ return response.ok ? { ok: true } : { ok: false, error: response.error };
+ }
+
+ if (message.type === "camera-preview-event") {
+ const tabId = sender.tab?.id;
+ if (
+ tabId === undefined ||
+ !(await isCameraPreviewEventAllowed(sender, message.token))
+ ) {
+ return { ok: false, error: "Unauthorized" };
+ }
+ chrome.tabs.sendMessage(
+ tabId,
+ {
+ source: "cap-extension-camera-preview",
+ token: message.token,
+ event: message.event,
+ } satisfies CameraPreviewEventRelay,
+ () => {
+ void chrome.runtime.lastError;
+ },
+ );
+ return { ok: true };
+ }
+
+ if (message.type === "webcam-preview-ready") {
+ await handlePreviewReady(sender.tab?.id);
+ return { ok: true };
+ }
+
+ if (message.type === "confirm-result") {
+ resolveRecordingConfirmation(message.requestId, message.confirmed);
+ return { ok: true };
+ }
+
+ if (message.type === "show-countdown") {
+ // Relay the offscreen recorder's countdown to the recorded tab. Inject
+ // the overlay if it is not there yet; a tab that cannot host it (e.g. a
+ // chrome:// page) just shows nothing while the recorder waits out the
+ // same countdown, so the count is still kept out of the capture.
+ if (message.tabId !== undefined) {
+ void sendOverlay(message.tabId, {
+ type: "overlay-countdown",
+ seconds: message.seconds,
+ durationMs: message.durationMs,
+ }).catch(() => undefined);
+ }
+ return { ok: true };
+ }
+
+ if (message.type === "recording-capture-source") {
+ await handleCaptureSource(message.source);
+ return { ok: true };
+ }
+
+ if (message.type === "webcam-preview-error") {
+ await handlePreviewError(sender.tab?.id, message.reason);
+ return { ok: true };
+ }
+
+ return { ok: false, error: "Unknown request" };
+};
+
+chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
+ if (isRecordingStatusBroadcast(message)) {
+ setRecordingStatusAndBroadcast(message.status);
+ if (!isCapturingRecordingStatus(message.status)) {
+ externalCaptureAutoPipPending = false;
+ void saveWebcamPreviewDismissed(true)
+ .then(() => broadcastOverlayHide())
+ .catch(() => undefined);
+ }
+ if (message.status.phase === "completed") {
+ const shareUrl = message.status.shareUrl;
+ void getUploadProgressTabId()
+ .then((tabId) => {
+ if (tabId === null) return undefined;
+ setUploadProgressTabId(null);
+ return updateTab(tabId, shareUrl);
+ })
+ .catch(() => undefined);
+ }
+ return false;
+ }
+
+ if (!isServiceWorkerRequest(message)) return false;
+
+ handleRequest(message, _sender)
+ .then(sendResponse)
+ .catch((error: unknown) => {
+ sendResponse({
+ ok: false,
+ error: error instanceof Error ? error.message : String(error),
+ } satisfies ServiceWorkerResponse);
+ });
+
+ return true;
+});
+
+chrome.runtime.onInstalled.addListener((details) => {
+ void updateActionForStatus(recordingStatus);
+ void injectOverlayIntoOpenTabs();
+ if (details.reason === "install") {
+ void createTab(chrome.runtime.getURL("welcome.html")).catch(
+ () => undefined,
+ );
+ }
+});
+
+chrome.action.onClicked.addListener((tab) => {
+ void syncRecordingStatus()
+ .catch(() => recordingStatus)
+ .then((currentStatus) => {
+ if (isCapturingRecordingStatus(currentStatus)) {
+ return stopRecordingAndOpenDestination().then(() => undefined);
+ }
+ return openRecorderPanel(tab);
+ })
+ .catch(() => undefined);
+});
+
+chrome.tabs.onActivated.addListener(({ tabId }) => {
+ void syncActivePreview(tabId).catch(() => undefined);
+});
+
+chrome.windows.onFocusChanged.addListener((windowId) => {
+ if (windowId === chrome.windows.WINDOW_ID_NONE) {
+ browserWindowFocused = false;
+ void enterActivePreviewAutoPip();
+ return;
+ }
+ browserWindowFocused = true;
+ externalCaptureAutoPipPending = false;
+ void exitActivePreviewAutoPip()
+ .then(() => syncActivePreview())
+ .catch(() => undefined);
+});
+
+chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
+ if (changeInfo.status === "complete" && tab.active) {
+ void syncActivePreview(tabId).catch(() => undefined);
+ }
+});
+
+chrome.tabs.onRemoved.addListener((tabId) => {
+ if (uploadProgressTabId === tabId) {
+ setUploadProgressTabId(null);
+ } else if (uploadProgressTabId === null) {
+ // A restarted worker may only hold the id in session storage.
+ void loadUploadProgressTabId()
+ .then((persisted) =>
+ persisted === tabId ? saveUploadProgressTabId(null) : undefined,
+ )
+ .catch(() => undefined);
+ }
+ if (activePreviewTabId === tabId) {
+ activePreviewTabId = null;
+ }
+ if (pendingPreviewTabId === tabId) {
+ pendingPreviewTabId = null;
+ }
+ if (readyPreviewTabId === tabId) {
+ readyPreviewTabId = null;
+ }
+});
diff --git a/apps/chrome-extension/src/chrome.d.ts b/apps/chrome-extension/src/chrome.d.ts
new file mode 100644
index 00000000000..6a8a9b0a6be
--- /dev/null
+++ b/apps/chrome-extension/src/chrome.d.ts
@@ -0,0 +1,12 @@
+// Chrome API typings come from @types/chrome; this file only declares the
+// Vite-specific module shapes the extension relies on.
+interface ImportMeta {
+ readonly env: {
+ readonly MODE: string;
+ };
+}
+
+declare module "*.css?inline" {
+ const content: string;
+ export default content;
+}
diff --git a/apps/chrome-extension/src/content/bootstrap.ts b/apps/chrome-extension/src/content/bootstrap.ts
new file mode 100644
index 00000000000..7369689b301
--- /dev/null
+++ b/apps/chrome-extension/src/content/bootstrap.ts
@@ -0,0 +1,161 @@
+import {
+ isOverlayMessage,
+ isRecordingStatusBroadcast,
+} from "../shared/messages";
+import {
+ RECORDING_STATE_KEY,
+ SHARED_UI_STATE_KEY,
+} from "../shared/storage-keys";
+
+// The manifest injects only this bootstrap into every page: a few KB of
+// vanilla code that decides whether the page actually needs the recorder UI.
+// The full overlay/recording-bar bundle (React, icons, ~250KB) is an ES
+// module listed in web_accessible_resources and is dynamically imported only
+// once recording state or a service-worker message says this tab should show
+// something. Plain page loads must stay cheap: reading chrome.storage does
+// not wake the MV3 service worker, so the bootstrap never sends runtime
+// messages of its own.
+
+type OverlayModule = {
+ init: (startupMessages: readonly unknown[]) => void;
+};
+
+// The phases for which the floating UI (recording bar, camera preview)
+// renders. Every other phase needs no UI until a message or a storage change
+// says otherwise: "error" reopens the recorder panel through the shared UI
+// state's panelOpen flag, which is watched below.
+const UI_PHASES = new Set(["creating", "recording", "paused"]);
+
+const BOOTSTRAP_FLAG = "__capExtensionContentBootstrap";
+
+const readPhase = (value: unknown): string | null => {
+ if (!value || typeof value !== "object") return null;
+ const status = (value as { status?: unknown }).status;
+ if (!status || typeof status !== "object") return null;
+ const phase = (status as { phase?: unknown }).phase;
+ return typeof phase === "string" ? phase : null;
+};
+
+const isUiPhase = (value: unknown) => {
+ const phase = readPhase(value);
+ return phase !== null && UI_PHASES.has(phase);
+};
+
+const readPanelOpen = (value: unknown): boolean =>
+ !!value &&
+ typeof value === "object" &&
+ (value as { panelOpen?: unknown }).panelOpen === true;
+
+const bootstrap = () => {
+ const overlayModuleUrl = chrome.runtime.getURL("content/overlay.js");
+ // Messages acknowledged while the overlay module is still being fetched.
+ // init() hands them to the module, whose components replay them on mount,
+ // so the panel toggle or webcam settings push that triggered the lazy
+ // load is not dropped.
+ const pendingMessages: unknown[] = [];
+ let modulePromise: Promise | null = null;
+ let moduleStarted = false;
+
+ const startOverlayModule = () => {
+ modulePromise ??= import(/* @vite-ignore */ overlayModuleUrl)
+ .then((module: OverlayModule) => {
+ moduleStarted = true;
+ // The module registers its own runtime and storage listeners;
+ // from here the bootstrap goes dormant. Messages arriving in the
+ // brief window before the module's listeners mount are covered by
+ // the service worker's send retries and the storage mirror.
+ chrome.runtime.onMessage.removeListener(handleRuntimeMessage);
+ chrome.storage.onChanged.removeListener(handleStorageChange);
+ module.init(pendingMessages);
+ })
+ .catch(() => {
+ // Leave the trigger listeners armed so a later signal retries.
+ modulePromise = null;
+ });
+ return modulePromise;
+ };
+
+ const handleStorageChange = (
+ changes: Record,
+ areaName: string,
+ ) => {
+ if (areaName !== "session") return;
+ if (
+ isUiPhase(changes[RECORDING_STATE_KEY]?.newValue) ||
+ readPanelOpen(changes[SHARED_UI_STATE_KEY]?.newValue)
+ ) {
+ void startOverlayModule();
+ }
+ };
+
+ const handleRuntimeMessage = (
+ message: unknown,
+ _sender: chrome.runtime.MessageSender,
+ sendResponse: (response?: unknown) => void,
+ ) => {
+ if (moduleStarted) return false;
+
+ if (isOverlayMessage(message)) {
+ // Acknowledge like the full overlay does so the service worker's
+ // delivery check (and its inject-and-retry fallback) sees this tab
+ // as alive.
+ sendResponse({ ok: true });
+ if (
+ message.type === "overlay-hide" ||
+ message.type === "overlay-enter-auto-pip" ||
+ message.type === "overlay-exit-auto-pip"
+ ) {
+ // Nothing is mounted, so there is nothing to hide or to move
+ // into Picture in Picture; loading the UI just to no-op is waste.
+ return false;
+ }
+ pendingMessages.push(message);
+ void startOverlayModule();
+ return false;
+ }
+
+ if (
+ isRecordingStatusBroadcast(message) &&
+ UI_PHASES.has(message.status.phase)
+ ) {
+ pendingMessages.push(message);
+ void startOverlayModule();
+ }
+
+ return false;
+ };
+
+ chrome.runtime.onMessage.addListener(handleRuntimeMessage);
+ chrome.storage.onChanged.addListener(handleStorageChange);
+
+ // One cheap session-storage read decides whether this page needs UI right
+ // away: a recording in progress or the recorder panel open (the panel
+ // follows the user across tabs).
+ try {
+ chrome.storage.session.get(
+ [RECORDING_STATE_KEY, SHARED_UI_STATE_KEY],
+ (items) => {
+ if (chrome.runtime.lastError || !items) return;
+ if (
+ isUiPhase(items[RECORDING_STATE_KEY]) ||
+ readPanelOpen(items[SHARED_UI_STATE_KEY])
+ ) {
+ void startOverlayModule();
+ }
+ },
+ );
+ } catch {
+ // Session storage access is widened by the service worker on startup;
+ // until that has happened there is no recording state to show either.
+ }
+};
+
+// chrome.scripting.executeScript re-runs this file in the same isolated
+// world (the service worker injects it before messaging tabs that predate
+// the extension), so a second execution must not stack duplicate listeners
+// or reload the overlay module.
+const globalScope = globalThis as Record;
+if (globalScope[BOOTSTRAP_FLAG] !== true) {
+ globalScope[BOOTSTRAP_FLAG] = true;
+ bootstrap();
+}
diff --git a/apps/chrome-extension/src/content/confirm-overlay.tsx b/apps/chrome-extension/src/content/confirm-overlay.tsx
new file mode 100644
index 00000000000..08eb8da28dd
--- /dev/null
+++ b/apps/chrome-extension/src/content/confirm-overlay.tsx
@@ -0,0 +1,149 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import { isOverlayMessage } from "../shared/messages";
+import { sendServiceWorkerMessage } from "../shared/runtime";
+import type { MicrophoneWarningVariant } from "../shared/types";
+import { replayStartupMessages } from "./startup-messages";
+
+type ConfirmRequest = {
+ requestId: string;
+ variant: MicrophoneWarningVariant;
+};
+
+const COPY: Record<
+ MicrophoneWarningVariant,
+ { title: string; message: string }
+> = {
+ "no-mic": {
+ title: "Record without a microphone?",
+ message:
+ "No microphone is selected, so this recording won't capture your voice.",
+ },
+ "no-sound": {
+ title: "No sound from your microphone",
+ message:
+ "We're not detecting any audio from the selected microphone. It may be muted or unplugged.",
+ },
+};
+
+// Shared confirm prompt shown as a floating overlay in the middle of the
+// recorded/active tab before a recording starts. Every start path (the panel
+// and the floating bar) routes through the service worker, which blocks on the
+// decision reported back here.
+export function ConfirmOverlay() {
+ const [request, setRequest] = useState(null);
+ const requestRef = useRef(null);
+ const confirmButtonRef = useRef(null);
+
+ useEffect(() => {
+ requestRef.current = request;
+ }, [request]);
+
+ const respond = useCallback((current: ConfirmRequest, confirmed: boolean) => {
+ setRequest(null);
+ void sendServiceWorkerMessage({
+ target: "service-worker",
+ type: "confirm-result",
+ requestId: current.requestId,
+ confirmed,
+ }).catch(() => undefined);
+ }, []);
+
+ useEffect(() => {
+ const handleMessage = (
+ message: unknown,
+ _sender: chrome.runtime.MessageSender,
+ sendResponse: (response?: unknown) => void,
+ ) => {
+ if (!isOverlayMessage(message)) return false;
+ if (message.type === "overlay-confirm") {
+ sendResponse({ ok: true });
+ setRequest({ requestId: message.requestId, variant: message.variant });
+ return false;
+ }
+ if (message.type === "overlay-hide") {
+ // A teardown while the prompt is open cancels the pending start so
+ // the worker is not left waiting on a decision that can't be made.
+ const current = requestRef.current;
+ if (current) respond(current, false);
+ return false;
+ }
+ return false;
+ };
+
+ chrome.runtime.onMessage.addListener(handleMessage);
+ replayStartupMessages(handleMessage);
+ return () => chrome.runtime.onMessage.removeListener(handleMessage);
+ }, [respond]);
+
+ useEffect(() => {
+ if (!request) return;
+ confirmButtonRef.current?.focus();
+ const handleKey = (event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ event.preventDefault();
+ event.stopPropagation();
+ respond(request, false);
+ }
+ };
+ window.addEventListener("keydown", handleKey, true);
+ return () => window.removeEventListener("keydown", handleKey, true);
+ }, [request, respond]);
+
+ if (!request) return null;
+
+ const { title, message } = COPY[request.variant];
+
+ return (
+
+
respond(request, false)}
+ />
+
+
+
+
+
+
+
+
+
+
{title}
+
{message}
+
+ respond(request, false)}
+ >
+ Cancel
+
+ respond(request, true)}
+ >
+ Start anyway
+
+
+
+
+ );
+}
diff --git a/apps/chrome-extension/src/content/countdown-overlay.tsx b/apps/chrome-extension/src/content/countdown-overlay.tsx
new file mode 100644
index 00000000000..11cd3bb5180
--- /dev/null
+++ b/apps/chrome-extension/src/content/countdown-overlay.tsx
@@ -0,0 +1,160 @@
+import { type CSSProperties, useCallback, useEffect, useState } from "react";
+import { isOverlayMessage } from "../shared/messages";
+import { sendServiceWorkerMessage } from "../shared/runtime";
+
+type ActiveCountdown = {
+ seconds: number;
+ durationMs: number;
+};
+
+// The offscreen recorder waits the full countdown duration before capturing,
+// but the relay to this tab adds latency, so the overlay must clear with margin
+// to spare. Tab/screen capture would otherwise catch the tail of the count in
+// the first recorded frames.
+const FADE_MS = 220;
+// Unmount this far before the recorder's own timer elapses.
+const REMOVE_LEAD_MS = 140;
+
+const classNames = (...values: Array) =>
+ values.filter(Boolean).join(" ");
+
+// Full-screen pre-roll countdown (3, 2, 1) shown over the page right before a
+// recording starts. The offscreen recorder owns the timing and waits the same
+// duration, so this component is purely visual; pressing Escape cancels the
+// pending start.
+export function CountdownOverlay() {
+ const [countdown, setCountdown] = useState(null);
+ const [value, setValue] = useState(0);
+ const [leaving, setLeaving] = useState(false);
+
+ const dismiss = useCallback(() => {
+ setCountdown(null);
+ setLeaving(false);
+ }, []);
+
+ const cancel = useCallback(() => {
+ dismiss();
+ // Aborts the start while it is still in the countdown window; the
+ // offscreen recorder tears down the half-built session without uploading.
+ void sendServiceWorkerMessage({
+ target: "service-worker",
+ type: "stop-recording",
+ }).catch(() => undefined);
+ }, [dismiss]);
+
+ useEffect(() => {
+ const handleMessage = (
+ message: unknown,
+ _sender: chrome.runtime.MessageSender,
+ sendResponse: (response?: unknown) => void,
+ ) => {
+ if (!isOverlayMessage(message)) return false;
+ if (message.type === "overlay-countdown") {
+ sendResponse({ ok: true });
+ setLeaving(false);
+ setValue(message.seconds);
+ setCountdown({
+ seconds: message.seconds,
+ durationMs: message.durationMs,
+ });
+ return false;
+ }
+ // A stop or teardown elsewhere (panel stop button, capture ended)
+ // clears the countdown without routing another stop request.
+ if (message.type === "overlay-hide") {
+ dismiss();
+ return false;
+ }
+ return false;
+ };
+
+ chrome.runtime.onMessage.addListener(handleMessage);
+ return () => chrome.runtime.onMessage.removeListener(handleMessage);
+ }, [dismiss]);
+
+ useEffect(() => {
+ if (!countdown) return;
+ const perNumberMs = countdown.durationMs / countdown.seconds;
+ let current = countdown.seconds;
+ setValue(current);
+
+ const interval = window.setInterval(() => {
+ current -= 1;
+ if (current <= 0) {
+ window.clearInterval(interval);
+ return;
+ }
+ setValue(current);
+ }, perNumberMs);
+ const removeAt = Math.max(0, countdown.durationMs - REMOVE_LEAD_MS);
+ const hideTimer = window.setTimeout(
+ () => setLeaving(true),
+ Math.max(0, removeAt - FADE_MS),
+ );
+ const removeTimer = window.setTimeout(() => {
+ setCountdown(null);
+ setLeaving(false);
+ }, removeAt);
+
+ return () => {
+ window.clearInterval(interval);
+ window.clearTimeout(hideTimer);
+ window.clearTimeout(removeTimer);
+ };
+ }, [countdown]);
+
+ useEffect(() => {
+ if (!countdown) return;
+ const handleKey = (event: KeyboardEvent) => {
+ if (event.key !== "Escape") return;
+ event.preventDefault();
+ event.stopPropagation();
+ cancel();
+ };
+ window.addEventListener("keydown", handleKey, true);
+ return () => window.removeEventListener("keydown", handleKey, true);
+ }, [countdown, cancel]);
+
+ if (!countdown) return null;
+
+ const perNumberMs = countdown.durationMs / countdown.seconds;
+
+ return (
+ event.stopPropagation()}
+ >
+
+ {`Recording starts in ${value}`}
+
+
+
+
+
+
+
+ {value}
+
+
+
Press Esc to cancel
+
+ );
+}
diff --git a/apps/chrome-extension/src/content/drawing-overlay.tsx b/apps/chrome-extension/src/content/drawing-overlay.tsx
new file mode 100644
index 00000000000..aac1bae30ae
--- /dev/null
+++ b/apps/chrome-extension/src/content/drawing-overlay.tsx
@@ -0,0 +1,344 @@
+import { Timer, Trash2, X } from "lucide-react";
+import {
+ type PointerEvent as ReactPointerEvent,
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+} from "react";
+
+// A bright, presentation-friendly palette: the warm/cool brights read well on
+// light pages and white covers dark slides. Red is the default ink.
+const DRAW_COLORS = [
+ { name: "Red", value: "#ef4444" },
+ { name: "Yellow", value: "#facc15" },
+ { name: "Green", value: "#22c55e" },
+ { name: "Blue", value: "#3b82f6" },
+ { name: "White", value: "#ffffff" },
+] as const;
+
+// Stroke width in CSS pixels paired with the diameter of the dot shown in the
+// toolbar, so the picker previews roughly what the brush draws.
+const BRUSH_SIZES = [
+ { name: "Small", value: 4, dot: 6 },
+ { name: "Medium", value: 9, dot: 10 },
+ { name: "Large", value: 16, dot: 15 },
+] as const;
+
+const DEFAULT_COLOR = DRAW_COLORS[0].value;
+const DEFAULT_SIZE = BRUSH_SIZES[1].value;
+
+// Auto-fade keeps a completed stroke fully opaque for the delay, then fades it
+// out over the duration before it is dropped. Timed off the rAF clock
+// (performance.now), which is what completedAt is stamped with.
+const FADE_DELAY_MS = 2500;
+const FADE_DURATION_MS = 900;
+
+type Point = { x: number; y: number };
+
+type Stroke = {
+ points: Point[];
+ color: string;
+ size: number;
+ // performance.now() when the pointer was released; null while the stroke is
+ // still being drawn, in which case it never fades.
+ completedAt: number | null;
+};
+
+const classNames = (...values: Array) =>
+ values.filter(Boolean).join(" ");
+
+const strokeOpacity = (stroke: Stroke, now: number, autoFade: boolean) => {
+ if (!autoFade || stroke.completedAt === null) return 1;
+ const age = now - stroke.completedAt;
+ if (age <= FADE_DELAY_MS) return 1;
+ return Math.max(0, 1 - (age - FADE_DELAY_MS) / FADE_DURATION_MS);
+};
+
+const paintStroke = (
+ ctx: CanvasRenderingContext2D,
+ stroke: Stroke,
+ opacity: number,
+) => {
+ const points = stroke.points;
+ if (points.length === 0) return;
+ ctx.globalAlpha = opacity;
+ ctx.fillStyle = stroke.color;
+ ctx.strokeStyle = stroke.color;
+ ctx.lineWidth = stroke.size;
+ ctx.lineCap = "round";
+ ctx.lineJoin = "round";
+
+ // A tap with no movement is just a filled dot.
+ if (points.length === 1) {
+ ctx.beginPath();
+ ctx.arc(points[0].x, points[0].y, stroke.size / 2, 0, Math.PI * 2);
+ ctx.fill();
+ return;
+ }
+
+ // Smooth the polyline by curving through the midpoints between samples;
+ // the raw points become the control handles, so the ink reads as a single
+ // fluid line rather than a chain of segments.
+ ctx.beginPath();
+ ctx.moveTo(points[0].x, points[0].y);
+ for (let i = 1; i < points.length - 1; i += 1) {
+ const midX = (points[i].x + points[i + 1].x) / 2;
+ const midY = (points[i].y + points[i + 1].y) / 2;
+ ctx.quadraticCurveTo(points[i].x, points[i].y, midX, midY);
+ }
+ const last = points[points.length - 1];
+ ctx.lineTo(last.x, last.y);
+ ctx.stroke();
+};
+
+type DrawingOverlayProps = {
+ active: boolean;
+ onClose: () => void;
+};
+
+export function DrawingOverlay({ active, onClose }: DrawingOverlayProps) {
+ const [color, setColor] = useState(DEFAULT_COLOR);
+ const [size, setSize] = useState(DEFAULT_SIZE);
+ const [autoFade, setAutoFade] = useState(true);
+
+ const canvasRef = useRef(null);
+ const strokesRef = useRef([]);
+ const activeStrokeRef = useRef(null);
+ const colorRef = useRef(color);
+ const sizeRef = useRef(size);
+ const autoFadeRef = useRef(autoFade);
+ const dprRef = useRef(1);
+
+ useEffect(() => {
+ colorRef.current = color;
+ }, [color]);
+ useEffect(() => {
+ sizeRef.current = size;
+ }, [size]);
+ useEffect(() => {
+ autoFadeRef.current = autoFade;
+ }, [autoFade]);
+
+ // Leaving draw mode wipes the ink so re-entering starts on a clean canvas
+ // and no stale strokes linger over the page.
+ useEffect(() => {
+ if (active) return;
+ strokesRef.current = [];
+ activeStrokeRef.current = null;
+ }, [active]);
+
+ // Match the canvas backing store to the viewport at device resolution so
+ // the ink stays crisp; strokes are stored in CSS pixels and redraw on
+ // resize without conversion.
+ useEffect(() => {
+ if (!active) return;
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+ const resize = () => {
+ const dpr = window.devicePixelRatio || 1;
+ dprRef.current = dpr;
+ canvas.width = Math.round(window.innerWidth * dpr);
+ canvas.height = Math.round(window.innerHeight * dpr);
+ canvas.style.width = `${window.innerWidth}px`;
+ canvas.style.height = `${window.innerHeight}px`;
+ };
+ resize();
+ window.addEventListener("resize", resize);
+ return () => window.removeEventListener("resize", resize);
+ }, [active]);
+
+ // One rAF loop owns all painting: it reads the mutable stroke buffer (kept
+ // out of React state so a fast scribble never triggers re-renders), applies
+ // the current fade, and drops strokes once they have fully faded.
+ useEffect(() => {
+ if (!active) return;
+ let frame = 0;
+ const loop = (now: number) => {
+ const canvas = canvasRef.current;
+ const ctx = canvas?.getContext("2d");
+ if (canvas && ctx) {
+ const dpr = dprRef.current;
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ const fade = autoFadeRef.current;
+ const strokes = strokesRef.current;
+ for (const stroke of strokes) {
+ const opacity = strokeOpacity(stroke, now, fade);
+ if (opacity > 0) paintStroke(ctx, stroke, opacity);
+ }
+ if (fade) {
+ strokesRef.current = strokes.filter(
+ (stroke) =>
+ stroke.completedAt === null ||
+ strokeOpacity(stroke, now, true) > 0,
+ );
+ }
+ }
+ frame = window.requestAnimationFrame(loop);
+ };
+ frame = window.requestAnimationFrame(loop);
+ return () => window.cancelAnimationFrame(frame);
+ }, [active]);
+
+ const handlePointerDown = useCallback(
+ (event: ReactPointerEvent) => {
+ event.preventDefault();
+ event.currentTarget.setPointerCapture(event.pointerId);
+ const stroke: Stroke = {
+ points: [{ x: event.clientX, y: event.clientY }],
+ color: colorRef.current,
+ size: sizeRef.current,
+ completedAt: null,
+ };
+ activeStrokeRef.current = stroke;
+ strokesRef.current = [...strokesRef.current, stroke];
+ },
+ [],
+ );
+
+ const handlePointerMove = useCallback(
+ (event: ReactPointerEvent) => {
+ const stroke = activeStrokeRef.current;
+ if (!stroke) return;
+ // Coalesced events recover the sub-frame pointer samples the browser
+ // batched, so quick strokes stay smooth instead of polygonal.
+ const native = event.nativeEvent;
+ const coalesced = native.getCoalescedEvents?.() ?? [];
+ if (coalesced.length > 0) {
+ for (const sample of coalesced) {
+ stroke.points.push({ x: sample.clientX, y: sample.clientY });
+ }
+ } else {
+ stroke.points.push({ x: event.clientX, y: event.clientY });
+ }
+ },
+ [],
+ );
+
+ const handlePointerUp = useCallback(() => {
+ const stroke = activeStrokeRef.current;
+ if (!stroke) return;
+ stroke.completedAt = window.performance.now();
+ activeStrokeRef.current = null;
+ }, []);
+
+ const clear = useCallback(() => {
+ strokesRef.current = [];
+ activeStrokeRef.current = null;
+ }, []);
+
+ const toggleAutoFade = useCallback(() => {
+ setAutoFade((previous) => {
+ const next = !previous;
+ // Re-arm the fade clock on every completed stroke when turning it back
+ // on, otherwise older ink would jump straight to faded.
+ if (next) {
+ const now = window.performance.now();
+ for (const stroke of strokesRef.current) {
+ if (stroke.completedAt !== null) stroke.completedAt = now;
+ }
+ }
+ return next;
+ });
+ }, []);
+
+ if (!active) return null;
+
+ return (
+ <>
+
+
+
+ {DRAW_COLORS.map((swatch) => (
+ setColor(swatch.value)}
+ />
+ ))}
+
+
+
+ {BRUSH_SIZES.map((brush) => (
+ setSize(brush.value)}
+ >
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/apps/chrome-extension/src/content/overlay.css b/apps/chrome-extension/src/content/overlay.css
new file mode 100644
index 00000000000..f4cc0abb7ca
--- /dev/null
+++ b/apps/chrome-extension/src/content/overlay.css
@@ -0,0 +1,1096 @@
+:host {
+ all: initial;
+ position: fixed;
+ inset: 0;
+ z-index: 2147483647;
+ display: block;
+ overflow: visible;
+ pointer-events: none;
+ color-scheme: light;
+ isolation: isolate;
+ contain: layout style paint;
+}
+
+:host,
+:host * {
+ box-sizing: border-box;
+ font-synthesis: none;
+ text-shadow: none;
+ text-transform: none;
+}
+
+.cap-extension-camera-window {
+ position: fixed;
+ left: 0;
+ top: 0;
+ z-index: 2147483647;
+ cursor: move;
+ pointer-events: auto;
+ touch-action: none;
+ user-select: none;
+ will-change: transform;
+ color: #202020;
+ font-family:
+ Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
+ "Segoe UI", sans-serif;
+ font-size: 14px;
+ font-weight: 400;
+ letter-spacing: 0;
+ line-height: 1.2;
+}
+
+.cap-extension-camera-window.is-dragging {
+ cursor: grabbing;
+}
+
+.cap-extension-drag-surface {
+ all: unset;
+ position: fixed;
+ inset: 0;
+ display: block;
+ z-index: 2147483647;
+ cursor: grabbing;
+ pointer-events: auto;
+ touch-action: none;
+ user-select: none;
+}
+
+.cap-extension-camera-shell {
+ position: relative;
+ display: flex;
+ width: 100%;
+ height: 100%;
+ flex-direction: column;
+ cursor: move;
+}
+
+.cap-extension-camera-bar {
+ display: flex;
+ height: 52px;
+ align-items: flex-start;
+ justify-content: center;
+ pointer-events: none;
+}
+
+.cap-extension-camera-controls {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 4px;
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ border-radius: 12px;
+ background: #fcfcfc;
+ box-shadow: 0 16px 48px rgba(0, 0, 0, 0.16);
+ color: #838383;
+ opacity: 0;
+ pointer-events: auto;
+ transform: translateY(8px);
+ transition:
+ opacity 160ms ease,
+ transform 160ms ease;
+}
+
+.cap-extension-camera-window:hover .cap-extension-camera-controls,
+.cap-extension-camera-controls:focus-within {
+ opacity: 1;
+ transform: translateY(0);
+}
+
+.cap-extension-camera-control {
+ all: unset;
+ display: flex;
+ width: 38px;
+ height: 38px;
+ align-items: center;
+ justify-content: center;
+ border-radius: 8px;
+ color: #838383;
+ cursor: pointer;
+ transition:
+ background 160ms ease,
+ color 160ms ease,
+ opacity 160ms ease;
+}
+
+.cap-extension-camera-control:hover,
+.cap-extension-camera-control:focus-visible,
+.cap-extension-camera-control.is-active {
+ background: #f0f0f0;
+ color: #202020;
+}
+
+.cap-extension-camera-control:focus-visible {
+ outline: 1px solid #5eb1ef;
+ outline-offset: 1px;
+}
+
+.cap-extension-camera-control svg {
+ display: block;
+ width: 22px;
+ height: 22px;
+ flex: 0 0 auto;
+}
+
+.cap-extension-camera-control:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.cap-extension-parent-pip-video {
+ position: fixed;
+ right: 0;
+ bottom: 0;
+ width: 1px;
+ height: 1px;
+ opacity: 0;
+ pointer-events: none;
+}
+
+.cap-extension-camera-frame {
+ position: relative;
+ overflow: hidden;
+ border: 0;
+ background: #000;
+ box-shadow: 0 10px 28px rgba(0, 0, 0, 0.28);
+ color: #fff;
+}
+
+.cap-extension-camera-iframe {
+ position: absolute;
+ inset: 0;
+ display: block;
+ width: 100%;
+ height: 100%;
+ border: 0;
+ background: #000;
+ pointer-events: auto;
+}
+
+.cap-extension-camera-last-frame {
+ position: absolute;
+ inset: 0;
+ z-index: 1;
+ display: block;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ opacity: 1;
+ pointer-events: none;
+ transition: opacity 140ms ease;
+}
+
+.cap-extension-camera-last-frame.is-hidden {
+ opacity: 0;
+}
+
+.cap-extension-camera-last-frame.is-mirrored {
+ transform: scaleX(-1);
+}
+
+.cap-extension-camera-frame.is-pip .cap-extension-camera-iframe,
+.cap-extension-camera-frame.is-pip .cap-extension-camera-last-frame {
+ visibility: hidden;
+}
+
+.cap-extension-camera-loading,
+.cap-extension-camera-error,
+.cap-extension-camera-pip-active {
+ position: absolute;
+ inset: 0;
+ z-index: 2;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.cap-extension-camera-loading {
+ background: rgba(0, 0, 0, 0.26);
+}
+
+.cap-extension-camera-loading span {
+ display: block;
+ width: 28px;
+ height: 28px;
+ border: 2px solid rgba(255, 255, 255, 0.3);
+ border-top-color: rgba(255, 255, 255, 0.9);
+ border-radius: 999px;
+ animation: cap-extension-spin 760ms linear infinite;
+}
+
+.cap-extension-camera-error {
+ padding: 18px;
+ background: rgba(0, 0, 0, 0.82);
+ color: rgba(255, 255, 255, 0.9);
+ font-size: 12px;
+ font-weight: 500;
+ line-height: 1.35;
+ text-align: center;
+}
+
+@keyframes cap-extension-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.cap-extension-camera-pip-active {
+ background: linear-gradient(180deg, #1b1c21 0%, #101114 100%);
+}
+
+.cap-extension-camera-pip-active > div {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ max-width: calc(100% - 24px);
+ padding: 8px 12px;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 999px;
+ background: rgba(0, 0, 0, 0.6);
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.24);
+ backdrop-filter: blur(12px);
+ color: rgba(255, 255, 255, 0.9);
+ font-size: 12px;
+ font-weight: 500;
+ white-space: nowrap;
+}
+
+.cap-extension-camera-pip-active button {
+ all: unset;
+ display: flex;
+ width: 16px;
+ height: 16px;
+ align-items: center;
+ justify-content: center;
+ border-radius: 999px;
+ cursor: pointer;
+ transition: background 160ms ease;
+}
+
+.cap-extension-camera-pip-active button:hover,
+.cap-extension-camera-pip-active button:focus-visible {
+ background: rgba(255, 255, 255, 0.2);
+}
+
+.cap-extension-camera-pip-active button:focus-visible {
+ outline: 1px solid rgba(255, 255, 255, 0.65);
+ outline-offset: 1px;
+}
+
+.cap-extension-panel-backdrop {
+ all: unset;
+ position: fixed;
+ inset: 0;
+ /* Sits below the drawing canvas so that, while drawing, strokes land on the
+ canvas instead of this backdrop closing the panel out from under them. It
+ still covers the page (which lives far below) to catch outside clicks when
+ no canvas is mounted. */
+ z-index: 2147483640;
+ display: block;
+ background: transparent;
+ pointer-events: auto;
+ cursor: default;
+}
+
+.cap-extension-panel-backdrop:focus-visible {
+ outline: none;
+}
+
+.cap-extension-panel {
+ position: fixed;
+ top: 16px;
+ right: 16px;
+ z-index: 2147483647;
+ overflow: hidden;
+ border: 1px solid rgba(18, 18, 23, 0.1);
+ border-radius: 20px;
+ background: #f9f9f9;
+ box-shadow:
+ 0 24px 64px rgba(0, 0, 0, 0.28),
+ 0 4px 16px rgba(0, 0, 0, 0.12);
+ pointer-events: auto;
+ transition: height 180ms ease;
+ animation: cap-extension-panel-in 240ms cubic-bezier(0.16, 1, 0.3, 1);
+ will-change: transform, height;
+}
+
+.cap-extension-panel-iframe {
+ display: block;
+ width: 100%;
+ height: 100%;
+ border: 0;
+ background: transparent;
+}
+
+@keyframes cap-extension-panel-in {
+ from {
+ opacity: 0;
+ transform: translateY(-10px) scale(0.98);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+}
+
+.cap-extension-control-bar {
+ position: fixed;
+ z-index: 2147483647;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 12px;
+ border: 1px solid rgba(18, 18, 23, 0.08);
+ border-radius: 18px;
+ background: rgba(252, 252, 252, 0.97);
+ box-shadow:
+ 0 24px 64px rgba(0, 0, 0, 0.28),
+ 0 4px 16px rgba(0, 0, 0, 0.12);
+ color: #202020;
+ cursor: grab;
+ pointer-events: auto;
+ user-select: none;
+ backdrop-filter: blur(20px);
+ font-family:
+ Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
+ "Segoe UI", sans-serif;
+ font-size: 13px;
+ letter-spacing: 0;
+ line-height: 1;
+ animation: cap-extension-bar-in 260ms cubic-bezier(0.16, 1, 0.3, 1);
+}
+
+.cap-extension-control-bar.is-dragging {
+ cursor: grabbing;
+}
+
+.cap-extension-control-bar-info {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding-left: 2px;
+}
+
+.cap-extension-control-bar-logo {
+ display: block;
+ width: 30px;
+ height: 30px;
+ flex: 0 0 auto;
+ border-radius: 8px;
+ pointer-events: none;
+}
+
+.cap-extension-control-bar-text {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.cap-extension-control-bar-title {
+ font-size: 14px;
+ font-weight: 600;
+ line-height: 1;
+ color: #18181b;
+ white-space: nowrap;
+}
+
+.cap-extension-control-bar-title.is-timer {
+ min-width: 44px;
+ font-size: 15px;
+ font-variant-numeric: tabular-nums;
+}
+
+.cap-extension-control-bar-title.is-warning {
+ color: #e5484d;
+}
+
+.cap-extension-control-bar-subtitle {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ font-size: 11.5px;
+ font-weight: 500;
+ line-height: 1;
+ color: #83838b;
+ white-space: nowrap;
+}
+
+.cap-extension-control-bar-dot {
+ width: 8px;
+ height: 8px;
+ flex: 0 0 auto;
+ border-radius: 999px;
+}
+
+.cap-extension-control-bar-dot.is-recording {
+ background: #e5484d;
+ box-shadow: 0 0 0 3px rgba(229, 72, 77, 0.18);
+ animation: cap-extension-pulse 1.6s ease-in-out infinite;
+}
+
+.cap-extension-control-bar-dot.is-creating {
+ background: #f59f00;
+ box-shadow: 0 0 0 3px rgba(245, 159, 0, 0.18);
+ animation: cap-extension-pulse 1.6s ease-in-out infinite;
+}
+
+.cap-extension-control-bar-dot.is-paused {
+ background: #b4b4b4;
+}
+
+.cap-extension-control-bar-dot.is-ready {
+ background: #3b82f6;
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.16);
+}
+
+.cap-extension-control-bar-divider {
+ width: 1px;
+ align-self: stretch;
+ margin: 2px 0;
+ background: rgba(18, 18, 23, 0.08);
+}
+
+.cap-extension-control-bar-actions {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.cap-extension-control-bar-icon-button {
+ all: unset;
+ box-sizing: border-box;
+ display: flex;
+ width: 40px;
+ height: 40px;
+ align-items: center;
+ justify-content: center;
+ border-radius: 12px;
+ color: #5f5f66;
+ cursor: pointer;
+ transition:
+ background 160ms ease,
+ color 160ms ease;
+}
+
+.cap-extension-control-bar-icon-button svg {
+ display: block;
+ flex: 0 0 auto;
+}
+
+.cap-extension-control-bar-icon-button:hover,
+.cap-extension-control-bar-icon-button:focus-visible {
+ background: rgba(18, 18, 23, 0.06);
+ color: #18181b;
+}
+
+.cap-extension-control-bar-icon-button:focus-visible {
+ outline: 2px solid #5eb1ef;
+ outline-offset: 1px;
+}
+
+.cap-extension-control-bar-icon-button:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+}
+
+.cap-extension-control-bar-icon-button.is-quiet {
+ color: #9b9ba3;
+}
+
+.cap-extension-control-bar-icon-button.is-active {
+ background: rgba(37, 99, 235, 0.12);
+ color: #2563eb;
+}
+
+.cap-extension-control-bar-icon-button.is-active:hover,
+.cap-extension-control-bar-icon-button.is-active:focus-visible {
+ background: rgba(37, 99, 235, 0.18);
+ color: #1d4ed8;
+}
+
+.cap-extension-control-bar-pill {
+ all: unset;
+ box-sizing: border-box;
+ display: flex;
+ height: 40px;
+ align-items: center;
+ gap: 8px;
+ padding: 0 18px;
+ border-radius: 12px;
+ border: 1px solid transparent;
+ box-shadow: 0 1.5px 0 0 rgba(255, 255, 255, 0.2) inset;
+ color: #fff;
+ font-size: 13.5px;
+ font-weight: 600;
+ line-height: 1;
+ white-space: nowrap;
+ cursor: pointer;
+ transition:
+ background 160ms ease,
+ border-color 160ms ease;
+}
+
+.cap-extension-control-bar-pill svg {
+ display: block;
+ flex: 0 0 auto;
+}
+
+.cap-extension-control-bar-pill:focus-visible {
+ outline: 2px solid #5eb1ef;
+ outline-offset: 1px;
+}
+
+.cap-extension-control-bar-pill:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.cap-extension-control-bar-pill.is-stop {
+ gap: 7px;
+ padding: 0 16px;
+ background: #e5484d;
+ border-color: #d93036;
+}
+
+.cap-extension-control-bar-pill.is-stop:hover:not(:disabled),
+.cap-extension-control-bar-pill.is-stop:focus-visible {
+ background: #d93036;
+}
+
+.cap-extension-control-bar-pill.is-start {
+ background: #2563eb;
+ border-color: #1d4ed8;
+}
+
+.cap-extension-control-bar-pill.is-start:hover:not(:disabled),
+.cap-extension-control-bar-pill.is-start:focus-visible {
+ background: #1d4ed8;
+}
+
+.cap-extension-recording-rail {
+ position: fixed;
+ left: 16px;
+ top: 50%;
+ z-index: 2147483647;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 8px 8px;
+ border: 1px solid rgba(18, 18, 23, 0.08);
+ border-radius: 18px;
+ background: rgba(252, 252, 252, 0.97);
+ box-shadow:
+ 0 24px 64px rgba(0, 0, 0, 0.28),
+ 0 4px 16px rgba(0, 0, 0, 0.12);
+ color: #202020;
+ pointer-events: auto;
+ user-select: none;
+ backdrop-filter: blur(20px);
+ font-family:
+ Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
+ "Segoe UI", sans-serif;
+ font-size: 13px;
+ letter-spacing: 0;
+ line-height: 1;
+ transform: translateY(-50%);
+ animation: cap-extension-rail-in 260ms cubic-bezier(0.16, 1, 0.3, 1);
+}
+
+.cap-extension-recording-rail-timer {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 7px;
+ padding: 4px 6px 2px;
+}
+
+.cap-extension-recording-rail-time {
+ min-width: 40px;
+ font-size: 15px;
+ font-weight: 600;
+ line-height: 1;
+ color: #18181b;
+ text-align: center;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+}
+
+.cap-extension-recording-rail-timer.is-warning
+ .cap-extension-recording-rail-time {
+ color: #e5484d;
+}
+
+@keyframes cap-extension-rail-in {
+ from {
+ opacity: 0;
+ transform: translateY(-50%) translateX(-14px) scale(0.97);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(-50%) translateX(0) scale(1);
+ }
+}
+
+@keyframes cap-extension-pulse {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.35;
+ }
+}
+
+@keyframes cap-extension-bar-in {
+ from {
+ opacity: 0;
+ transform: translateY(14px) scale(0.97);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+}
+
+/* The drawing surface sits above the recorder panel's backdrop (so drawing
+ works even while the panel that hosts the "Ready to record" bar is open) but
+ below the floating bars and the draw toolbar (which stay at the maximum
+ z-index) so those controls remain clickable while the canvas captures
+ pointer events everywhere else on the page. */
+.cap-extension-draw-canvas {
+ position: fixed;
+ inset: 0;
+ z-index: 2147483645;
+ display: block;
+ cursor: crosshair;
+ pointer-events: auto;
+ touch-action: none;
+}
+
+.cap-extension-draw-toolbar {
+ position: fixed;
+ top: 16px;
+ left: 50%;
+ z-index: 2147483647;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 10px;
+ border: 1px solid rgba(18, 18, 23, 0.08);
+ border-radius: 16px;
+ background: rgba(252, 252, 252, 0.97);
+ box-shadow:
+ 0 24px 64px rgba(0, 0, 0, 0.28),
+ 0 4px 16px rgba(0, 0, 0, 0.12);
+ color: #202020;
+ pointer-events: auto;
+ user-select: none;
+ backdrop-filter: blur(20px);
+ font-family:
+ Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
+ "Segoe UI", sans-serif;
+ font-size: 13px;
+ letter-spacing: 0;
+ line-height: 1;
+ transform: translateX(-50%);
+ animation: cap-extension-draw-toolbar-in 240ms cubic-bezier(0.16, 1, 0.3, 1);
+}
+
+.cap-extension-draw-group {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.cap-extension-draw-divider {
+ width: 1px;
+ align-self: stretch;
+ margin: 3px 0;
+ background: rgba(18, 18, 23, 0.08);
+}
+
+.cap-extension-draw-swatch {
+ all: unset;
+ box-sizing: border-box;
+ width: 22px;
+ height: 22px;
+ flex: 0 0 auto;
+ border-radius: 999px;
+ box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.12);
+ cursor: pointer;
+ transition:
+ transform 140ms ease,
+ box-shadow 140ms ease;
+}
+
+.cap-extension-draw-swatch:hover {
+ transform: scale(1.12);
+}
+
+.cap-extension-draw-swatch.is-active {
+ box-shadow:
+ inset 0 0 0 1px rgba(0, 0, 0, 0.12),
+ 0 0 0 2px #fcfcfc,
+ 0 0 0 4px currentColor;
+}
+
+.cap-extension-draw-swatch:focus-visible {
+ outline: 2px solid #5eb1ef;
+ outline-offset: 2px;
+}
+
+.cap-extension-draw-size,
+.cap-extension-draw-icon-button {
+ all: unset;
+ box-sizing: border-box;
+ display: flex;
+ width: 32px;
+ height: 32px;
+ align-items: center;
+ justify-content: center;
+ border-radius: 9px;
+ color: #5f5f66;
+ cursor: pointer;
+ transition:
+ background 140ms ease,
+ color 140ms ease;
+}
+
+.cap-extension-draw-size:hover,
+.cap-extension-draw-icon-button:hover,
+.cap-extension-draw-size:focus-visible,
+.cap-extension-draw-icon-button:focus-visible {
+ background: rgba(18, 18, 23, 0.06);
+ color: #18181b;
+}
+
+.cap-extension-draw-size:focus-visible,
+.cap-extension-draw-icon-button:focus-visible {
+ outline: 2px solid #5eb1ef;
+ outline-offset: 1px;
+}
+
+.cap-extension-draw-size.is-active,
+.cap-extension-draw-icon-button.is-active {
+ background: rgba(18, 18, 23, 0.09);
+ color: #18181b;
+}
+
+.cap-extension-draw-icon-button svg {
+ display: block;
+ flex: 0 0 auto;
+}
+
+.cap-extension-draw-size-dot {
+ display: block;
+ flex: 0 0 auto;
+ border-radius: 999px;
+ background: currentColor;
+}
+
+@keyframes cap-extension-draw-toolbar-in {
+ from {
+ opacity: 0;
+ transform: translateX(-50%) translateY(-10px) scale(0.98);
+ }
+ to {
+ opacity: 1;
+ transform: translateX(-50%) translateY(0) scale(1);
+ }
+}
+
+/* Pre-roll countdown: a full-screen takeover that counts 3, 2, 1 before the
+ recording begins. */
+.cap-extension-countdown {
+ position: fixed;
+ inset: 0;
+ z-index: 2147483647;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 28px;
+ pointer-events: auto;
+ background: radial-gradient(
+ 120% 120% at 50% 45%,
+ rgba(12, 14, 20, 0.62) 0%,
+ rgba(8, 9, 13, 0.82) 100%
+ );
+ backdrop-filter: blur(10px) saturate(120%);
+ -webkit-backdrop-filter: blur(10px) saturate(120%);
+ color: #fff;
+ font-family:
+ Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
+ "Segoe UI", sans-serif;
+ animation: cap-countdown-backdrop-in 260ms cubic-bezier(0.16, 1, 0.3, 1) both;
+ will-change: opacity;
+}
+
+.cap-extension-countdown.is-leaving {
+ animation: cap-countdown-backdrop-out 220ms ease-in both;
+ pointer-events: none;
+}
+
+.cap-extension-countdown-sr {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ margin: -1px;
+ padding: 0;
+ border: 0;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ clip-path: inset(50%);
+ white-space: nowrap;
+}
+
+.cap-extension-countdown-stage {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: min(52vmin, 460px);
+ height: min(52vmin, 460px);
+}
+
+.cap-extension-countdown-ring {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ transform: rotate(-90deg);
+ overflow: visible;
+}
+
+.cap-extension-countdown-ring-track {
+ fill: none;
+ stroke: rgba(255, 255, 255, 0.14);
+ stroke-width: 4;
+}
+
+.cap-extension-countdown-ring-progress {
+ fill: none;
+ stroke: #fff;
+ stroke-width: 4;
+ stroke-linecap: round;
+ /* 2 * pi * r (r = 54) */
+ stroke-dasharray: 339.292;
+ filter: drop-shadow(0 0 10px rgba(255, 255, 255, 0.45));
+ animation: cap-countdown-ring-drain var(--cap-countdown-step, 1000ms) linear
+ both;
+}
+
+.cap-extension-countdown-number {
+ position: relative;
+ font-size: clamp(140px, 30vmin, 360px);
+ font-weight: 700;
+ line-height: 1;
+ letter-spacing: -0.02em;
+ font-variant-numeric: tabular-nums;
+ text-shadow: 0 8px 40px rgba(0, 0, 0, 0.45);
+ animation: cap-countdown-pop var(--cap-countdown-step, 1000ms)
+ cubic-bezier(0.22, 1, 0.36, 1) both;
+ will-change: transform, opacity;
+}
+
+.cap-extension-countdown-hint {
+ font-size: 14px;
+ font-weight: 500;
+ letter-spacing: 0.01em;
+ color: rgba(255, 255, 255, 0.62);
+ animation: cap-countdown-backdrop-in 360ms ease-out both;
+}
+
+@keyframes cap-countdown-backdrop-in {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes cap-countdown-backdrop-out {
+ from {
+ opacity: 1;
+ }
+ to {
+ opacity: 0;
+ }
+}
+
+@keyframes cap-countdown-pop {
+ 0% {
+ transform: scale(0.55);
+ opacity: 0;
+ }
+ 18% {
+ transform: scale(1.04);
+ opacity: 1;
+ }
+ 30% {
+ transform: scale(1);
+ opacity: 1;
+ }
+ 72% {
+ transform: scale(1);
+ opacity: 1;
+ }
+ 100% {
+ transform: scale(1.32);
+ opacity: 0;
+ }
+}
+
+@keyframes cap-countdown-ring-drain {
+ from {
+ stroke-dashoffset: 0;
+ }
+ to {
+ stroke-dashoffset: 339.292;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .cap-extension-countdown-number {
+ animation: cap-countdown-backdrop-in 200ms ease-out both;
+ }
+ .cap-extension-countdown-ring-progress {
+ animation-timing-function: linear;
+ }
+}
+
+/* Shared confirm prompt (no mic / no sound) — a floating card centered over
+ the page that the user must answer before recording starts. */
+.cap-extension-confirm {
+ position: fixed;
+ inset: 0;
+ z-index: 2147483647;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 24px;
+ pointer-events: auto;
+ font-family:
+ Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
+ "Segoe UI", sans-serif;
+ animation: cap-countdown-backdrop-in 160ms ease-out both;
+}
+
+.cap-extension-confirm-backdrop {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ margin: 0;
+ padding: 0;
+ border: 0;
+ cursor: default;
+ background: rgba(8, 9, 13, 0.5);
+ backdrop-filter: blur(4px);
+ -webkit-backdrop-filter: blur(4px);
+}
+
+.cap-extension-confirm-card {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 12px;
+ width: min(340px, 100%);
+ padding: 24px;
+ border-radius: 18px;
+ background: #fff;
+ color: #18181b;
+ text-align: center;
+ box-shadow:
+ 0 1px 2px rgba(0, 0, 0, 0.08),
+ 0 12px 40px rgba(0, 0, 0, 0.28);
+ animation: cap-confirm-card-in 200ms cubic-bezier(0.22, 1, 0.36, 1) both;
+}
+
+.cap-extension-confirm-icon {
+ width: 26px;
+ height: 26px;
+ padding: 9px;
+ box-sizing: content-box;
+ border-radius: 9999px;
+ background: #fde8e8;
+ color: #dc2626;
+}
+
+.cap-extension-confirm-title {
+ margin: 0;
+ font-size: 16px;
+ font-weight: 600;
+ line-height: 1.25;
+ color: #18181b;
+}
+
+.cap-extension-confirm-message {
+ margin: 0;
+ font-size: 13.5px;
+ font-weight: 400;
+ line-height: 1.45;
+ color: #52525b;
+}
+
+.cap-extension-confirm-actions {
+ display: flex;
+ gap: 10px;
+ width: 100%;
+ margin-top: 6px;
+}
+
+.cap-extension-confirm-button {
+ flex: 1 1 0;
+ min-width: 0;
+ height: 40px;
+ padding: 0 16px;
+ border-radius: 9999px;
+ border: 1px solid transparent;
+ font-family: inherit;
+ font-size: 14px;
+ font-weight: 500;
+ line-height: 1;
+ white-space: nowrap;
+ cursor: pointer;
+ transition:
+ background-color 150ms ease,
+ border-color 150ms ease;
+}
+
+.cap-extension-confirm-button:focus-visible {
+ outline: 2px solid #2563eb;
+ outline-offset: 2px;
+}
+
+.cap-extension-confirm-button.is-secondary {
+ background: #f4f4f5;
+ border-color: #e4e4e7;
+ color: #27272a;
+}
+
+.cap-extension-confirm-button.is-secondary:hover {
+ background: #e4e4e7;
+}
+
+.cap-extension-confirm-button.is-primary {
+ background: #18181b;
+ color: #fafafa;
+}
+
+.cap-extension-confirm-button.is-primary:hover {
+ background: #27272a;
+}
+
+@keyframes cap-confirm-card-in {
+ from {
+ opacity: 0;
+ transform: translateY(8px) scale(0.96);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+}
diff --git a/apps/chrome-extension/src/content/overlay.tsx b/apps/chrome-extension/src/content/overlay.tsx
new file mode 100644
index 00000000000..c83f93aee13
--- /dev/null
+++ b/apps/chrome-extension/src/content/overlay.tsx
@@ -0,0 +1,1759 @@
+import {
+ Circle,
+ FlipHorizontal,
+ Maximize2,
+ PictureInPicture,
+ RectangleHorizontal,
+ Square,
+ X,
+} from "lucide-react";
+import {
+ type PointerEvent as ReactPointerEvent,
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+} from "react";
+import { createRoot } from "react-dom/client";
+import { isOverlayMessage } from "../shared/messages";
+import { sendServiceWorkerMessage } from "../shared/runtime";
+import {
+ loadLastWebcamPreviewFrame,
+ loadOverlayUiState,
+ loadSettings,
+ loadSharedRecordingState,
+ loadSharedUiState,
+ loadWebcamPreviewDismissed,
+ OVERLAY_UI_STATE_KEY,
+ SETTINGS_KEY,
+ SHARED_UI_STATE_KEY,
+ saveLastWebcamPreviewFrame,
+ saveSettings,
+ updateOverlayUiState,
+ updateSharedUiState,
+ WEBCAM_PREVIEW_DISMISSED_KEY,
+} from "../shared/storage";
+import type {
+ CameraPreviewEventRelay,
+ ExtensionSettings,
+ OverlayPosition,
+ RecordingStatus,
+ WebcamPosition,
+ WebcamPreviewFrame,
+ WebcamSettings,
+ WebcamShape,
+} from "../shared/types";
+import {
+ toSessionDescriptionInit,
+ waitForIceGatheringComplete,
+} from "../shared/webrtc";
+import { ConfirmOverlay } from "./confirm-overlay";
+import { CountdownOverlay } from "./countdown-overlay";
+import overlayCss from "./overlay.css?inline";
+import { RecordingBarOverlay } from "./recording-bar";
+import { replayStartupMessages, setStartupMessages } from "./startup-messages";
+
+const ROOT_ID = "cap-extension-recorder-overlay";
+const WINDOW_PADDING = 20;
+const BAR_HEIGHT = 52;
+// The iframe tokens are readable from the host page DOM (they sit in the
+// iframe src), so token checks alone cannot authenticate window messages.
+// Frames we embed always run on the extension origin; require it too.
+const EXTENSION_ORIGIN = new URL(chrome.runtime.getURL("")).origin;
+const createSecureToken = () => {
+ if (typeof crypto.randomUUID === "function") return crypto.randomUUID();
+ const bytes = new Uint8Array(16);
+ crypto.getRandomValues(bytes);
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(
+ "",
+ );
+};
+
+const PREVIEW_TOKEN = createSecureToken();
+const PREVIEW_URL = chrome.runtime.getURL("camera-preview.html");
+const PREVIEW_SRC = `${PREVIEW_URL}#${encodeURIComponent(PREVIEW_TOKEN)}`;
+const PREVIEW_ERROR_DELAY_MS = 1200;
+const PANEL_TOKEN = createSecureToken();
+const PANEL_URL = chrome.runtime.getURL("popup.html");
+const PANEL_SRC = `${PANEL_URL}#${encodeURIComponent(PANEL_TOKEN)}`;
+const PANEL_WIDTH = 300;
+const PANEL_DEFAULT_HEIGHT = 460;
+const PANEL_MARGIN = 16;
+// Persisting every 700ms preview frame to session storage broadcasts a
+// 10-30KB onChanged event to every open tab; the cached frame is only a
+// placeholder, so a coarse cadence is plenty.
+const FRAME_PERSIST_INTERVAL_MS = 5000;
+
+// The extension iframes (camera preview, recorder panel) are web accessible,
+// so the service worker only honours requests from frames whose URL-hash
+// token was registered by this content script. Registration must complete
+// before an iframe is rendered, otherwise its first camera connect races the
+// registry write.
+let overlayTokensRegistration: Promise | null = null;
+
+const ensureOverlayTokensRegistered = () => {
+ overlayTokensRegistration ??= Promise.all(
+ [PREVIEW_TOKEN, PANEL_TOKEN].map((token) =>
+ sendServiceWorkerMessage({
+ target: "service-worker",
+ type: "register-overlay-token",
+ token,
+ }),
+ ),
+ )
+ .then((responses) => {
+ const registered = responses.every((response) => response.ok);
+ if (!registered) {
+ overlayTokensRegistration = null;
+ }
+ return registered;
+ })
+ .catch(() => {
+ overlayTokensRegistration = null;
+ return false;
+ });
+ return overlayTokensRegistration;
+};
+type VideoDimensions = {
+ width: number;
+ height: number;
+};
+
+// Preview events (frames, drag, errors) arrive from the camera-preview
+// iframe via the service worker relay (chrome.tabs.sendMessage), never via
+// window.postMessage — the host page shares this window and could read
+// webcam frames out of a postMessage stream.
+type PreviewEventRelay = CameraPreviewEventRelay;
+
+type PreviewParentMessage =
+ | {
+ source: "cap-extension-overlay";
+ token: string;
+ type: "settings";
+ settings: WebcamSettings;
+ }
+ | {
+ source: "cap-extension-overlay";
+ token: string;
+ type: "toggle-pip";
+ }
+ | {
+ source: "cap-extension-overlay";
+ token: string;
+ type: "enter-pip";
+ }
+ | {
+ source: "cap-extension-overlay";
+ token: string;
+ type: "exit-auto-pip";
+ }
+ | {
+ source: "cap-extension-overlay";
+ token: string;
+ type: "stop";
+ };
+
+const classNames = (...values: Array) =>
+ values.filter(Boolean).join(" ");
+
+const isPreviewEventRelay = (value: unknown): value is PreviewEventRelay => {
+ if (!value || typeof value !== "object") return false;
+ const candidate = value as Partial;
+ return (
+ candidate.source === "cap-extension-camera-preview" &&
+ candidate.token === PREVIEW_TOKEN &&
+ !!candidate.event &&
+ typeof candidate.event === "object"
+ );
+};
+
+const getPreviewMetrics = (
+ base: number,
+ shape: WebcamShape,
+ dimensions: VideoDimensions | null,
+) => {
+ if (!dimensions || dimensions.height === 0) {
+ return {
+ width: base,
+ height: base,
+ aspectRatio: 1,
+ };
+ }
+
+ const aspectRatio = dimensions.width / dimensions.height;
+
+ if (shape !== "full") {
+ return {
+ width: base,
+ height: base,
+ aspectRatio,
+ };
+ }
+
+ if (aspectRatio >= 1) {
+ return {
+ width: base * aspectRatio,
+ height: base,
+ aspectRatio,
+ };
+ }
+
+ return {
+ width: base,
+ height: base / aspectRatio,
+ aspectRatio,
+ };
+};
+
+const getBorderRadius = (size: number, shape: WebcamShape) => {
+ if (shape === "round") return "9999px";
+ return size <= 230 ? "3rem" : "4rem";
+};
+
+const toOverlayPosition = (position: {
+ x: number;
+ y: number;
+}): OverlayPosition => ({
+ ...position,
+ viewportWidth: window.innerWidth,
+ viewportHeight: window.innerHeight,
+ updatedAt: Date.now(),
+});
+
+const isRecordingPreviewStatus = (status: RecordingStatus | undefined) =>
+ status?.phase === "creating" ||
+ status?.phase === "recording" ||
+ status?.phase === "paused";
+
+const isSameWebcamSettings = (
+ current: WebcamSettings | null,
+ next: WebcamSettings,
+) =>
+ current?.enabled === next.enabled &&
+ current.deviceId === next.deviceId &&
+ current.position === next.position &&
+ current.size === next.size &&
+ current.shape === next.shape &&
+ current.mirror === next.mirror;
+
+const waitForRemoteStream = (peer: RTCPeerConnection) =>
+ new Promise((resolve, reject) => {
+ let settled = false;
+ const timeout = window.setTimeout(() => {
+ if (settled) return;
+ settled = true;
+ reject(new Error("Camera preview timed out."));
+ }, 10000);
+
+ const finish = (stream: MediaStream) => {
+ if (settled) return;
+ settled = true;
+ window.clearTimeout(timeout);
+ resolve(stream);
+ };
+
+ peer.addEventListener("track", (event) => {
+ finish(event.streams[0] ?? new MediaStream([event.track]));
+ });
+
+ peer.addEventListener("connectionstatechange", () => {
+ if (
+ settled ||
+ (peer.connectionState !== "failed" && peer.connectionState !== "closed")
+ ) {
+ return;
+ }
+ settled = true;
+ window.clearTimeout(timeout);
+ reject(new Error("Camera preview connection failed."));
+ });
+ });
+
+const stopStream = (stream: MediaStream | null) => {
+ if (!stream) return;
+ for (const track of stream.getTracks()) {
+ track.stop();
+ }
+};
+
+const connectCameraPreview = async (
+ settings: WebcamSettings,
+ sessionId: string,
+) => {
+ const peer = new RTCPeerConnection();
+ peer.addTransceiver("video", { direction: "recvonly" });
+ const remoteStreamPromise = waitForRemoteStream(peer);
+ await peer.setLocalDescription(await peer.createOffer());
+ await waitForIceGatheringComplete(peer);
+
+ const response = await sendServiceWorkerMessage({
+ target: "service-worker",
+ type: "connect-camera-preview",
+ sessionId,
+ settings,
+ offer: toSessionDescriptionInit(peer.localDescription),
+ });
+
+ if (!response.ok) {
+ peer.close();
+ throw new Error(response.error);
+ }
+ if (!response.answer) {
+ peer.close();
+ throw new Error("Camera preview did not return an answer.");
+ }
+
+ await peer.setRemoteDescription(response.answer);
+ return {
+ peer,
+ stream: await remoteStreamPromise,
+ };
+};
+
+type PanelFrameMessage =
+ | {
+ source: "cap-extension-panel";
+ token: string;
+ type: "size";
+ height: number;
+ }
+ | {
+ source: "cap-extension-panel";
+ token: string;
+ type: "dismiss";
+ };
+
+const isPanelFrameMessage = (value: unknown): value is PanelFrameMessage => {
+ if (!value || typeof value !== "object") return false;
+ const candidate = value as Partial;
+ if (
+ candidate.source !== "cap-extension-panel" ||
+ candidate.token !== PANEL_TOKEN
+ ) {
+ return false;
+ }
+ if (candidate.type === "dismiss") return true;
+ return (
+ candidate.type === "size" &&
+ typeof candidate.height === "number" &&
+ Number.isFinite(candidate.height)
+ );
+};
+
+function RecorderPanelOverlay({
+ onOpenChange,
+}: {
+ onOpenChange: (open: boolean) => void;
+}) {
+ const [open, setOpen] = useState(false);
+ const [pageVisible, setPageVisible] = useState(
+ () => document.visibilityState === "visible",
+ );
+ const [contentHeight, setContentHeight] = useState(PANEL_DEFAULT_HEIGHT);
+ const [viewportHeight, setViewportHeight] = useState(
+ () => window.innerHeight,
+ );
+ const [tokenReady, setTokenReady] = useState(false);
+ const startupReplayedRef = useRef(false);
+
+ useEffect(() => {
+ onOpenChange(open);
+ }, [onOpenChange, open]);
+
+ useEffect(() => {
+ if (!open || tokenReady) return;
+ let disposed = false;
+ void ensureOverlayTokensRegistered().then((registered) => {
+ if (!disposed && registered) setTokenReady(true);
+ });
+ return () => {
+ disposed = true;
+ };
+ }, [open, tokenReady]);
+
+ // The open flag lives in chrome.storage.session so the panel follows the
+ // user across tabs: opening or closing it anywhere applies everywhere.
+ useEffect(() => {
+ let disposed = false;
+
+ const syncPanelState = () => {
+ loadSharedUiState()
+ .then((state) => {
+ if (!disposed) setOpen(state.panelOpen);
+ })
+ .catch(() => undefined);
+ };
+
+ const handleStorageChange = (
+ changes: Record,
+ areaName: string,
+ ) => {
+ if (areaName === "session" && changes[SHARED_UI_STATE_KEY]) {
+ syncPanelState();
+ }
+ };
+
+ syncPanelState();
+ chrome.storage.onChanged.addListener(handleStorageChange);
+ return () => {
+ disposed = true;
+ chrome.storage.onChanged.removeListener(handleStorageChange);
+ };
+ }, []);
+
+ useEffect(() => {
+ const handleVisibility = () =>
+ setPageVisible(document.visibilityState === "visible");
+ document.addEventListener("visibilitychange", handleVisibility);
+ return () =>
+ document.removeEventListener("visibilitychange", handleVisibility);
+ }, []);
+
+ const closePanel = useCallback(() => {
+ setOpen(false);
+ void updateSharedUiState((current) => ({
+ ...current,
+ panelOpen: false,
+ updatedAt: Date.now(),
+ })).catch(() => undefined);
+ }, []);
+
+ useEffect(() => {
+ const handleMessage = (
+ message: unknown,
+ _sender: chrome.runtime.MessageSender,
+ sendResponse: (response?: unknown) => void,
+ ) => {
+ if (!isOverlayMessage(message)) return false;
+ if (message.type === "overlay-panel-toggle") {
+ sendResponse({ ok: true });
+ // Flip the shared flag; every tab (this one included) follows the
+ // storage change. Reopening also resurfaces a dismissed ready bar.
+ void updateSharedUiState((current) => ({
+ ...current,
+ panelOpen: !current.panelOpen,
+ readyBarDismissed: current.panelOpen
+ ? current.readyBarDismissed
+ : false,
+ updatedAt: Date.now(),
+ }))
+ .then((state) => setOpen(state.panelOpen))
+ .catch(() => undefined);
+ return false;
+ }
+ if (message.type === "overlay-panel-hide") {
+ sendResponse({ ok: true });
+ closePanel();
+ return false;
+ }
+ return false;
+ };
+
+ chrome.runtime.onMessage.addListener(handleMessage);
+ if (!startupReplayedRef.current) {
+ startupReplayedRef.current = true;
+ replayStartupMessages(handleMessage);
+ }
+ return () => chrome.runtime.onMessage.removeListener(handleMessage);
+ }, [closePanel]);
+
+ useEffect(() => {
+ const handleFrameMessage = (event: MessageEvent) => {
+ if (event.origin !== EXTENSION_ORIGIN) return;
+ if (!isPanelFrameMessage(event.data)) return;
+ if (event.data.type === "size") {
+ setContentHeight(Math.max(320, Math.ceil(event.data.height)));
+ return;
+ }
+ closePanel();
+ };
+
+ window.addEventListener("message", handleFrameMessage);
+ return () => window.removeEventListener("message", handleFrameMessage);
+ }, [closePanel]);
+
+ useEffect(() => {
+ if (!open) return;
+ const handleResize = () => setViewportHeight(window.innerHeight);
+ handleResize();
+ window.addEventListener("resize", handleResize);
+ return () => window.removeEventListener("resize", handleResize);
+ }, [open]);
+
+ // Only the tab on screen renders the iframe; hidden tabs keep just the
+ // shared flag so the panel reappears instantly when they come forward.
+ // The iframe also waits for its token registration so the panel page can
+ // verify it was embedded by this extension.
+ if (!open || !pageVisible || !tokenReady) return null;
+
+ const height = Math.min(contentHeight, viewportHeight - PANEL_MARGIN * 2);
+
+ return (
+ <>
+
+
+
+
+ >
+ );
+}
+
+function OverlayApp() {
+ const [extensionSettings, setExtensionSettings] =
+ useState(null);
+ const [position, setPosition] = useState<{ x: number; y: number } | null>(
+ null,
+ );
+ const [persistedWebcamPosition, setPersistedWebcamPosition] =
+ useState(null);
+ const [isDragging, setIsDragging] = useState(false);
+ const [videoDimensions, setVideoDimensions] =
+ useState(null);
+ const [lastPreviewFrame, setLastPreviewFrame] =
+ useState(null);
+ const [livePreviewReady, setLivePreviewReady] = useState(false);
+ const [previewError, setPreviewError] = useState(null);
+ const [showPreviewError, setShowPreviewError] = useState(false);
+ const [iframeReady, setIframeReady] = useState(false);
+ const [pipSupported, setPipSupported] = useState(false);
+ const [parentPipActive, setParentPipActive] = useState(false);
+ const [framePipActive, setFramePipActive] = useState(false);
+ const [previewOpen, setPreviewOpen] = useState(false);
+ const [recordingPreviewActive, setRecordingPreviewActive] = useState(false);
+ const [recorderPanelOpen, setRecorderPanelOpen] = useState(false);
+ const [previewTokenReady, setPreviewTokenReady] = useState(false);
+ const iframeRef = useRef(null);
+ const windowRef = useRef(null);
+ const pipVideoRef = useRef(null);
+ const pipPeerRef = useRef(null);
+ const pipStreamRef = useRef(null);
+ const pipSettingsRef = useRef(null);
+ const previewSessionIdRef = useRef(null);
+ const webcamRef = useRef(null);
+ const settingsRef = useRef(null);
+ const positionRef = useRef<{ x: number; y: number } | null>(null);
+ const videoDimensionsRef = useRef(null);
+ const dragStartRef = useRef({ x: 0, y: 0 });
+ const dragFrameRef = useRef(null);
+ const isDraggingRef = useRef(false);
+ const framePipActiveRef = useRef(false);
+ const recordingPreviewActiveRef = useRef(false);
+ const previewOpenRef = useRef(false);
+ const livePreviewReadyRef = useRef(false);
+ const previewDismissedRef = useRef(false);
+ const lastFrameRef = useRef(null);
+ const lastFrameSavedAtRef = useRef(0);
+ const startupReplayedRef = useRef(false);
+ const webcam = extensionSettings?.webcam ?? null;
+ const previewEnabled = Boolean(
+ webcam?.enabled && webcam.deviceId && previewOpen,
+ );
+ const isInPictureInPicture = parentPipActive || framePipActive;
+ const parentPipSupported =
+ typeof document !== "undefined" && document.pictureInPictureEnabled;
+
+ // Control messages travel over chrome.runtime instead of window.postMessage:
+ // the host page can read the iframe token from the DOM and post forged
+ // window messages, but it cannot speak chrome.runtime at all. The preview
+ // filters on the token, so only this tab's iframe reacts.
+ const postPreviewMessage = useCallback((message: PreviewParentMessage) => {
+ chrome.runtime.sendMessage(message, () => {
+ void chrome.runtime.lastError;
+ });
+ }, []);
+
+ const persistPreviewFrame = useCallback(
+ (frame: WebcamPreviewFrame | null, force: boolean) => {
+ if (!frame) return;
+ const now = Date.now();
+ if (
+ !force &&
+ now - lastFrameSavedAtRef.current < FRAME_PERSIST_INTERVAL_MS
+ ) {
+ return;
+ }
+ lastFrameSavedAtRef.current = now;
+ void saveLastWebcamPreviewFrame(frame).catch(() => undefined);
+ },
+ [],
+ );
+
+ const disconnectParentPipPreview = useCallback(() => {
+ pipPeerRef.current?.close();
+ stopStream(pipStreamRef.current);
+ pipPeerRef.current = null;
+ pipStreamRef.current = null;
+ pipSettingsRef.current = null;
+ const video = pipVideoRef.current;
+ if (video) {
+ video.srcObject = null;
+ video.removeAttribute("src");
+ }
+ void sendServiceWorkerMessage({
+ target: "service-worker",
+ type: "disconnect-camera-preview",
+ sessionId: `${PREVIEW_TOKEN}:pip`,
+ }).catch(() => undefined);
+ }, []);
+
+ const disconnectPreviewSession = useCallback(() => {
+ const sessionId = previewSessionIdRef.current;
+ previewSessionIdRef.current = null;
+ if (!sessionId) return;
+ void sendServiceWorkerMessage({
+ target: "service-worker",
+ type: "disconnect-camera-preview",
+ sessionId,
+ }).catch(() => undefined);
+ }, []);
+
+ const stopLocalPreview = useCallback(() => {
+ setPreviewOpen(false);
+ setLivePreviewReady(false);
+ // Surface the freshest captured frame as the placeholder for the next
+ // time the preview opens (state updates are skipped while live).
+ if (lastFrameRef.current) {
+ setLastPreviewFrame(lastFrameRef.current);
+ persistPreviewFrame(lastFrameRef.current, true);
+ }
+ disconnectParentPipPreview();
+ disconnectPreviewSession();
+ postPreviewMessage({
+ source: "cap-extension-overlay",
+ token: PREVIEW_TOKEN,
+ type: "stop",
+ });
+ }, [
+ disconnectParentPipPreview,
+ disconnectPreviewSession,
+ persistPreviewFrame,
+ postPreviewMessage,
+ ]);
+
+ const applyWebcamSettings = useCallback(
+ (getNext: (current: WebcamSettings) => WebcamSettings) => {
+ setExtensionSettings((current) => {
+ if (!current) return current;
+ const next = {
+ ...current,
+ webcam: getNext(current.webcam),
+ };
+ void saveSettings(next)
+ .then(() =>
+ sendServiceWorkerMessage({
+ target: "service-worker",
+ type: "settings-updated",
+ settings: next,
+ }),
+ )
+ .catch(() => undefined);
+ return next;
+ });
+ },
+ [],
+ );
+
+ useEffect(() => {
+ webcamRef.current = webcam;
+ }, [webcam]);
+
+ useEffect(() => {
+ settingsRef.current = extensionSettings;
+ }, [extensionSettings]);
+
+ useEffect(() => {
+ positionRef.current = position;
+ }, [position]);
+
+ useEffect(() => {
+ videoDimensionsRef.current = videoDimensions;
+ }, [videoDimensions]);
+
+ useEffect(() => {
+ framePipActiveRef.current = framePipActive;
+ }, [framePipActive]);
+
+ useEffect(() => {
+ recordingPreviewActiveRef.current = recordingPreviewActive;
+ }, [recordingPreviewActive]);
+
+ useEffect(() => {
+ previewOpenRef.current = previewOpen;
+ }, [previewOpen]);
+
+ useEffect(() => {
+ livePreviewReadyRef.current = livePreviewReady;
+ }, [livePreviewReady]);
+
+ useEffect(() => {
+ let disposed = false;
+ loadLastWebcamPreviewFrame()
+ .then((frame) => {
+ if (!disposed) setLastPreviewFrame(frame);
+ })
+ .catch(() => undefined);
+
+ return () => {
+ disposed = true;
+ };
+ }, []);
+
+ // The preview iframe and the PiP fallback may only start once the service
+ // worker knows this page's tokens; an unregistered frame is refused camera
+ // access.
+ useEffect(() => {
+ if (!previewEnabled || previewTokenReady) return;
+ let disposed = false;
+ void ensureOverlayTokensRegistered().then((registered) => {
+ if (!disposed && registered) setPreviewTokenReady(true);
+ });
+ return () => {
+ disposed = true;
+ };
+ }, [previewEnabled, previewTokenReady]);
+
+ useEffect(() => {
+ let disposed = false;
+
+ const syncOverlayUiState = () => {
+ loadOverlayUiState()
+ .then((state) => {
+ if (!disposed) setPersistedWebcamPosition(state.webcamPosition);
+ })
+ .catch(() => undefined);
+ };
+
+ const syncSettingsState = () => {
+ Promise.all([loadSettings(), loadWebcamPreviewDismissed()])
+ .then(([nextSettings, dismissed]) => {
+ if (disposed) return;
+ previewDismissedRef.current = dismissed;
+ setExtensionSettings(nextSettings);
+ if (
+ dismissed ||
+ !nextSettings.webcam.enabled ||
+ !nextSettings.webcam.deviceId
+ ) {
+ setRecordingPreviewActive(false);
+ setLivePreviewReady(false);
+ stopLocalPreview();
+ }
+ })
+ .catch(() => undefined);
+ };
+
+ const syncPreviewDismissedState = () => {
+ loadWebcamPreviewDismissed()
+ .then((dismissed) => {
+ if (disposed) return;
+ previewDismissedRef.current = dismissed;
+ if (dismissed) {
+ setRecordingPreviewActive(false);
+ setLivePreviewReady(false);
+ stopLocalPreview();
+ }
+ })
+ .catch(() => undefined);
+ };
+
+ const handleStorageChange = (
+ changes: Record,
+ areaName: string,
+ ) => {
+ if (areaName === "local" && changes[OVERLAY_UI_STATE_KEY]) {
+ syncOverlayUiState();
+ }
+ if (areaName === "local" && changes[SETTINGS_KEY]) {
+ syncSettingsState();
+ }
+ if (areaName === "session" && changes[WEBCAM_PREVIEW_DISMISSED_KEY]) {
+ syncPreviewDismissedState();
+ }
+ };
+
+ syncOverlayUiState();
+ syncPreviewDismissedState();
+ chrome.storage.onChanged.addListener(handleStorageChange);
+ return () => {
+ disposed = true;
+ chrome.storage.onChanged.removeListener(handleStorageChange);
+ };
+ }, [stopLocalPreview]);
+
+ useEffect(() => {
+ let disposed = false;
+ Promise.all([
+ sendServiceWorkerMessage({
+ target: "service-worker",
+ type: "get-overlay-settings",
+ }),
+ loadWebcamPreviewDismissed(),
+ ])
+ .then(([response, dismissed]) => {
+ if (!disposed && response.ok && response.settings) {
+ previewDismissedRef.current = dismissed;
+ setExtensionSettings(response.settings);
+ const previewActive = isRecordingPreviewStatus(response.status);
+ setRecordingPreviewActive(previewActive);
+ // A replayed startup message (the lazy-load trigger) may have
+ // already opened the preview before this sync resolves, so only
+ // ever turn it on here.
+ if (!dismissed && previewActive) setPreviewOpen(true);
+ }
+ })
+ .catch(() => undefined);
+ return () => {
+ disposed = true;
+ };
+ }, []);
+
+ useEffect(() => {
+ let disposed = false;
+
+ const syncPreviewForVisibility = () => {
+ if (document.visibilityState !== "visible") {
+ return;
+ }
+
+ // Tab switches are frequent, so read settings and the session-storage
+ // status mirror instead of a get-overlay-settings round trip that
+ // would wake the MV3 service worker on every visibility flip. The
+ // recording bar's slow poll keeps the mirror reconciled while active.
+ Promise.all([
+ loadSettings(),
+ loadSharedRecordingState(),
+ loadWebcamPreviewDismissed(),
+ ])
+ .then(([nextSettings, sharedState, dismissed]) => {
+ if (disposed) return;
+ previewDismissedRef.current = dismissed;
+ const previewActive = isRecordingPreviewStatus(sharedState?.status);
+ setExtensionSettings(nextSettings);
+ if (dismissed) {
+ setRecordingPreviewActive(false);
+ stopLocalPreview();
+ return;
+ }
+ if (previewActive) {
+ setRecordingPreviewActive(true);
+ setPreviewOpen(true);
+ return;
+ }
+ if (recordingPreviewActiveRef.current) {
+ setRecordingPreviewActive(false);
+ stopLocalPreview();
+ }
+ })
+ .catch(() => undefined);
+ };
+
+ document.addEventListener("visibilitychange", syncPreviewForVisibility);
+ window.addEventListener("focus", syncPreviewForVisibility);
+ return () => {
+ disposed = true;
+ document.removeEventListener(
+ "visibilitychange",
+ syncPreviewForVisibility,
+ );
+ window.removeEventListener("focus", syncPreviewForVisibility);
+ };
+ }, [stopLocalPreview]);
+
+ useEffect(() => {
+ const handleMessage = (
+ message: unknown,
+ _sender: chrome.runtime.MessageSender,
+ sendResponse: (response?: unknown) => void,
+ ) => {
+ if (!isOverlayMessage(message)) return false;
+
+ sendResponse({ ok: true });
+
+ if (message.type === "overlay-hide") {
+ setRecordingPreviewActive(false);
+ stopLocalPreview();
+ return false;
+ }
+
+ if (message.type === "overlay-enter-auto-pip") {
+ // Only one Picture in Picture surface may drive at a time; racing
+ // the parent fallback video against the preview iframe flips PiP
+ // on and off and leaves the badge state inconsistent.
+ const current = webcamRef.current;
+ const frameLive =
+ previewOpenRef.current &&
+ livePreviewReadyRef.current &&
+ Boolean(current?.enabled && current.deviceId);
+ if (frameLive) {
+ postPreviewMessage({
+ source: "cap-extension-overlay",
+ token: PREVIEW_TOKEN,
+ type: "enter-pip",
+ });
+ return false;
+ }
+ const video = pipVideoRef.current;
+ if (video && parentPipSupported) {
+ void video
+ .play()
+ .then(() => video.requestPictureInPicture())
+ .catch(() => undefined);
+ }
+ return false;
+ }
+
+ if (message.type === "overlay-exit-auto-pip") {
+ const video = pipVideoRef.current;
+ if (video && document.pictureInPictureElement === video) {
+ void document.exitPictureInPicture().catch(() => undefined);
+ }
+ postPreviewMessage({
+ source: "cap-extension-overlay",
+ token: PREVIEW_TOKEN,
+ type: "exit-auto-pip",
+ });
+ return false;
+ }
+
+ if (message.type !== "overlay-settings") return false;
+
+ if (previewDismissedRef.current) return false;
+
+ const webcamSettings = message.settings;
+ const sameLivePreview =
+ previewOpenRef.current &&
+ livePreviewReadyRef.current &&
+ isSameWebcamSettings(webcamRef.current, webcamSettings);
+ if (!sameLivePreview) {
+ setLivePreviewReady(false);
+ } else {
+ // "webcam-preview-ready" is normally only sent when the preview
+ // first goes live. A restarted service worker loses that flag, so
+ // re-announce readiness whenever it pushes settings while the
+ // preview is already streaming; recording start waits on it.
+ void sendServiceWorkerMessage({
+ target: "service-worker",
+ type: "webcam-preview-ready",
+ }).catch(() => undefined);
+ }
+ setPreviewError(null);
+ setShowPreviewError(false);
+ setPreviewOpen(true);
+ setRecordingPreviewActive(message.recording);
+ if (settingsRef.current) {
+ setExtensionSettings({
+ ...settingsRef.current,
+ webcam: webcamSettings,
+ });
+ } else {
+ sendServiceWorkerMessage({
+ target: "service-worker",
+ type: "get-overlay-settings",
+ })
+ .then((response) => {
+ if (response.ok && response.settings) {
+ setExtensionSettings({
+ ...response.settings,
+ webcam: webcamSettings,
+ });
+ }
+ })
+ .catch(() => undefined);
+ }
+ return false;
+ };
+
+ chrome.runtime.onMessage.addListener(handleMessage);
+ if (!startupReplayedRef.current) {
+ startupReplayedRef.current = true;
+ replayStartupMessages(handleMessage);
+ }
+ return () => chrome.runtime.onMessage.removeListener(handleMessage);
+ }, [parentPipSupported, postPreviewMessage, stopLocalPreview]);
+
+ const beginDrag = useCallback((clientX: number, clientY: number) => {
+ isDraggingRef.current = true;
+ setIsDragging(true);
+ const currentPosition = positionRef.current;
+ dragStartRef.current = {
+ x: clientX - (currentPosition?.x ?? 0),
+ y: clientY - (currentPosition?.y ?? 0),
+ };
+ }, []);
+
+ const applyDragTransform = useCallback(() => {
+ dragFrameRef.current = null;
+ const element = windowRef.current;
+ const next = positionRef.current;
+ if (!element || !next) return;
+ element.style.transform = `translate3d(${next.x}px, ${next.y}px, 0)`;
+ }, []);
+
+ // Dragging writes the transform straight to the DOM inside one rAF per
+ // frame; going through React state for every pointermove made the preview
+ // visibly lag behind the cursor.
+ const moveDrag = useCallback(
+ (clientX: number, clientY: number) => {
+ const webcamSettings = webcamRef.current;
+ if (!isDraggingRef.current || !webcamSettings) return;
+
+ const metrics = getPreviewMetrics(
+ webcamSettings.size,
+ webcamSettings.shape,
+ videoDimensionsRef.current,
+ );
+ const totalHeight = metrics.height + BAR_HEIGHT;
+ const maxX = Math.max(0, window.innerWidth - metrics.width);
+ const maxY = Math.max(0, window.innerHeight - totalHeight);
+ const nextX = clientX - dragStartRef.current.x;
+ const nextY = clientY - dragStartRef.current.y;
+
+ positionRef.current = {
+ x: Math.max(0, Math.min(nextX, maxX)),
+ y: Math.max(0, Math.min(nextY, maxY)),
+ };
+ dragFrameRef.current ??= window.requestAnimationFrame(applyDragTransform);
+ },
+ [applyDragTransform],
+ );
+
+ const endDrag = useCallback(() => {
+ if (!isDraggingRef.current) return;
+ isDraggingRef.current = false;
+ setIsDragging(false);
+ if (dragFrameRef.current !== null) {
+ window.cancelAnimationFrame(dragFrameRef.current);
+ dragFrameRef.current = null;
+ applyDragTransform();
+ }
+ const nextPosition = positionRef.current;
+ if (!nextPosition) return;
+ setPosition(nextPosition);
+ void updateOverlayUiState((current) => ({
+ ...current,
+ webcamPosition: toOverlayPosition(nextPosition),
+ }))
+ .then((state) => setPersistedWebcamPosition(state.webcamPosition))
+ .catch(() => undefined);
+ }, [applyDragTransform]);
+
+ const toPagePoint = useCallback((clientX: number, clientY: number) => {
+ const rect = iframeRef.current?.getBoundingClientRect();
+ if (rect) {
+ return {
+ x: rect.left + clientX,
+ y: rect.top + clientY,
+ };
+ }
+ const currentPosition = positionRef.current ?? { x: 0, y: 0 };
+ return {
+ x: clientX + currentPosition.x,
+ y: clientY + currentPosition.y + BAR_HEIGHT,
+ };
+ }, []);
+
+ useEffect(() => {
+ const handlePreviewEvent = (message: unknown) => {
+ if (!isPreviewEventRelay(message)) return false;
+ const event = message.event;
+
+ if (event.type === "ready") {
+ setIframeReady(true);
+ const current = webcamRef.current;
+ if (current?.enabled && current.deviceId) {
+ postPreviewMessage({
+ source: "cap-extension-overlay",
+ token: PREVIEW_TOKEN,
+ type: "settings",
+ settings: current,
+ });
+ }
+ return false;
+ }
+
+ if (event.type === "metadata") {
+ setVideoDimensions(event.dimensions);
+ setPreviewError(null);
+ setShowPreviewError(false);
+ return false;
+ }
+
+ if (event.type === "session") {
+ previewSessionIdRef.current = event.sessionId;
+ return false;
+ }
+
+ if (event.type === "frame") {
+ const wasLive = livePreviewReadyRef.current;
+ lastFrameRef.current = event.frame;
+ // The cached frame only paints while the live iframe is hidden,
+ // so skip the full overlay re-render on every 700ms frame once
+ // the preview is live; the freshest frame is flushed from the
+ // ref when the preview stops.
+ if (!wasLive) {
+ setLastPreviewFrame(event.frame);
+ setLivePreviewReady(true);
+ livePreviewReadyRef.current = true;
+ setPreviewError(null);
+ setShowPreviewError(false);
+ void sendServiceWorkerMessage({
+ target: "service-worker",
+ type: "webcam-preview-ready",
+ }).catch(() => undefined);
+ }
+ persistPreviewFrame(event.frame, !wasLive);
+ return false;
+ }
+
+ if (event.type === "drag-start") {
+ const point = toPagePoint(event.clientX, event.clientY);
+ beginDrag(point.x, point.y);
+ return false;
+ }
+
+ if (event.type === "drag-move") {
+ const point = toPagePoint(event.clientX, event.clientY);
+ moveDrag(point.x, point.y);
+ return false;
+ }
+
+ if (event.type === "drag-end") {
+ endDrag();
+ return false;
+ }
+
+ if (event.type === "error") {
+ setVideoDimensions(null);
+ setLivePreviewReady(false);
+ setPreviewError(event.message);
+ setShowPreviewError(false);
+ void sendServiceWorkerMessage({
+ target: "service-worker",
+ type: "webcam-preview-error",
+ reason: event.reason,
+ message: event.message,
+ }).catch(() => undefined);
+ return false;
+ }
+
+ setPipSupported(event.supported);
+ setFramePipActive(event.active);
+ return false;
+ };
+
+ chrome.runtime.onMessage.addListener(handlePreviewEvent);
+ return () => chrome.runtime.onMessage.removeListener(handlePreviewEvent);
+ }, [
+ beginDrag,
+ endDrag,
+ moveDrag,
+ persistPreviewFrame,
+ postPreviewMessage,
+ toPagePoint,
+ ]);
+
+ useEffect(() => {
+ if (!previewEnabled) {
+ setIframeReady(false);
+ setVideoDimensions(null);
+ setLivePreviewReady(false);
+ setPreviewError(null);
+ setShowPreviewError(false);
+ setPipSupported(false);
+ setFramePipActive(false);
+ postPreviewMessage({
+ source: "cap-extension-overlay",
+ token: PREVIEW_TOKEN,
+ type: "stop",
+ });
+ }
+ }, [postPreviewMessage, previewEnabled]);
+
+ useEffect(() => {
+ if (!previewError || !previewEnabled || livePreviewReady) return;
+ const timeout = window.setTimeout(
+ () => setShowPreviewError(true),
+ PREVIEW_ERROR_DELAY_MS,
+ );
+ return () => window.clearTimeout(timeout);
+ }, [livePreviewReady, previewEnabled, previewError]);
+
+ useEffect(() => {
+ if (!iframeReady || !previewEnabled || !webcam) return;
+ postPreviewMessage({
+ source: "cap-extension-overlay",
+ token: PREVIEW_TOKEN,
+ type: "settings",
+ settings: webcam,
+ });
+ }, [iframeReady, postPreviewMessage, previewEnabled, webcam]);
+
+ useEffect(() => {
+ if (!previewEnabled || !previewTokenReady || !webcam) {
+ disconnectParentPipPreview();
+ return;
+ }
+
+ let disposed = false;
+ const sessionId = `${PREVIEW_TOKEN}:pip`;
+
+ const connect = async () => {
+ const peerActive =
+ pipPeerRef.current &&
+ pipPeerRef.current.connectionState !== "closed" &&
+ pipPeerRef.current.connectionState !== "failed" &&
+ pipPeerRef.current.connectionState !== "disconnected";
+ if (
+ pipStreamRef.current &&
+ peerActive &&
+ pipSettingsRef.current &&
+ isSameWebcamSettings(pipSettingsRef.current, webcam)
+ ) {
+ if (
+ pipVideoRef.current &&
+ pipVideoRef.current.srcObject !== pipStreamRef.current
+ ) {
+ pipVideoRef.current.srcObject = pipStreamRef.current;
+ }
+ await pipVideoRef.current?.play().catch(() => undefined);
+ return;
+ }
+
+ disconnectParentPipPreview();
+
+ try {
+ const { peer, stream } = await connectCameraPreview(webcam, sessionId);
+ if (disposed) {
+ peer.close();
+ stopStream(stream);
+ void sendServiceWorkerMessage({
+ target: "service-worker",
+ type: "disconnect-camera-preview",
+ sessionId,
+ }).catch(() => undefined);
+ return;
+ }
+
+ pipPeerRef.current = peer;
+ pipStreamRef.current = stream;
+ pipSettingsRef.current = webcam;
+ if (pipVideoRef.current) {
+ pipVideoRef.current.srcObject = stream;
+ await pipVideoRef.current.play().catch(() => undefined);
+ }
+ } catch {
+ if (!disposed) {
+ disconnectParentPipPreview();
+ }
+ }
+ };
+
+ void connect();
+
+ return () => {
+ disposed = true;
+ };
+ }, [disconnectParentPipPreview, previewEnabled, previewTokenReady, webcam]);
+
+ useEffect(() => {
+ const video = pipVideoRef.current;
+ if (!video || !parentPipSupported) return;
+
+ const handleEnter = () => {
+ setParentPipActive(true);
+ };
+ const handleLeave = () => {
+ setParentPipActive(false);
+ };
+
+ video.addEventListener("enterpictureinpicture", handleEnter);
+ video.addEventListener("leavepictureinpicture", handleLeave);
+ return () => {
+ video.removeEventListener("enterpictureinpicture", handleEnter);
+ video.removeEventListener("leavepictureinpicture", handleLeave);
+ };
+ }, [parentPipSupported]);
+
+ const clampPosition = useCallback(
+ (previous: { x: number; y: number } | null) => {
+ if (!previewEnabled || !webcam) return previous;
+ const metrics = getPreviewMetrics(
+ webcam.size,
+ webcam.shape,
+ videoDimensions,
+ );
+ const totalHeight = metrics.height + BAR_HEIGHT;
+ const maxX = Math.max(0, window.innerWidth - metrics.width);
+ const maxY = Math.max(0, window.innerHeight - totalHeight);
+ const defaultX = webcam.position.includes("left")
+ ? WINDOW_PADDING
+ : window.innerWidth - metrics.width - WINDOW_PADDING;
+ const defaultY = webcam.position.includes("top")
+ ? WINDOW_PADDING
+ : window.innerHeight - totalHeight - WINDOW_PADDING;
+ const nextX = previous?.x ?? defaultX;
+ const nextY = previous?.y ?? defaultY;
+
+ return {
+ x: Math.max(0, Math.min(nextX, maxX)),
+ y: Math.max(0, Math.min(nextY, maxY)),
+ };
+ },
+ [previewEnabled, webcam, videoDimensions],
+ );
+
+ const positionPrefRef = useRef(null);
+
+ useEffect(() => {
+ const positionPref = webcam?.position ?? null;
+ const prefChanged =
+ positionPrefRef.current !== null &&
+ positionPrefRef.current !== positionPref;
+ positionPrefRef.current = positionPref;
+ if (prefChanged) {
+ void updateOverlayUiState((current) => ({
+ ...current,
+ webcamPosition: null,
+ }))
+ .then((state) => setPersistedWebcamPosition(state.webcamPosition))
+ .catch(() => undefined);
+ }
+ setPosition((previous) => clampPosition(prefChanged ? null : previous));
+ }, [clampPosition, webcam?.position]);
+
+ useEffect(() => {
+ if (!persistedWebcamPosition || isDragging) return;
+ setPosition(
+ clampPosition({
+ x: persistedWebcamPosition.x,
+ y: persistedWebcamPosition.y,
+ }),
+ );
+ }, [clampPosition, isDragging, persistedWebcamPosition]);
+
+ useEffect(() => {
+ const handleResize = () => {
+ setPosition(clampPosition);
+ };
+ window.addEventListener("resize", handleResize);
+ return () => window.removeEventListener("resize", handleResize);
+ }, [clampPosition]);
+
+ const handlePointerDown = useCallback(
+ (event: ReactPointerEvent) => {
+ if ((event.target as HTMLElement).closest("[data-controls]")) {
+ return;
+ }
+ event.stopPropagation();
+ event.preventDefault();
+ // Capture the pointer so the drag keeps receiving events even while
+ // the cursor passes over the preview iframe (a separate document).
+ event.currentTarget.setPointerCapture(event.pointerId);
+ beginDrag(event.clientX, event.clientY);
+ },
+ [beginDrag],
+ );
+
+ const handlePointerMove = useCallback(
+ (event: PointerEvent) => {
+ moveDrag(event.clientX, event.clientY);
+ },
+ [moveDrag],
+ );
+
+ useEffect(() => {
+ if (!isDragging) return;
+
+ window.addEventListener("pointermove", handlePointerMove);
+ window.addEventListener("pointerup", endDrag);
+ window.addEventListener("pointercancel", endDrag);
+ return () => {
+ window.removeEventListener("pointermove", handlePointerMove);
+ window.removeEventListener("pointerup", endDrag);
+ window.removeEventListener("pointercancel", endDrag);
+ };
+ }, [endDrag, handlePointerMove, isDragging]);
+
+ const handleClose = useCallback(() => {
+ previewDismissedRef.current = true;
+ stopLocalPreview();
+ setRecordingPreviewActive(false);
+ void sendServiceWorkerMessage({
+ target: "service-worker",
+ type: "close-webcam-preview",
+ }).catch(() => undefined);
+ }, [stopLocalPreview]);
+
+ const updateShape = useCallback(() => {
+ applyWebcamSettings((current) => ({
+ ...current,
+ shape:
+ current.shape === "round"
+ ? "square"
+ : current.shape === "square"
+ ? "full"
+ : "round",
+ }));
+ }, [applyWebcamSettings]);
+
+ const updateSize = useCallback(() => {
+ applyWebcamSettings((current) => ({
+ ...current,
+ size: current.size <= 230 ? 400 : 230,
+ }));
+ }, [applyWebcamSettings]);
+
+ const updateMirror = useCallback(() => {
+ applyWebcamSettings((current) => ({
+ ...current,
+ mirror: !current.mirror,
+ }));
+ }, [applyWebcamSettings]);
+
+ const handleTogglePictureInPicture = useCallback(() => {
+ const video = pipVideoRef.current;
+ if (video && document.pictureInPictureElement === video) {
+ void document.exitPictureInPicture().catch(() => undefined);
+ return;
+ }
+
+ const current = webcamRef.current;
+ const frameLive =
+ previewOpenRef.current &&
+ livePreviewReadyRef.current &&
+ Boolean(current?.enabled && current.deviceId);
+ if (framePipActiveRef.current || frameLive) {
+ postPreviewMessage({
+ source: "cap-extension-overlay",
+ token: PREVIEW_TOKEN,
+ type: "toggle-pip",
+ });
+ return;
+ }
+
+ if (!video || !parentPipSupported) return;
+ void (async () => {
+ try {
+ await video.play().catch(() => undefined);
+ await video.requestPictureInPicture();
+ } catch {}
+ })();
+ }, [parentPipSupported, postPreviewMessage]);
+
+ const handlePreviewLoad = useCallback(() => {
+ const current = webcamRef.current;
+ if (!current?.enabled || !current.deviceId) return;
+
+ postPreviewMessage({
+ source: "cap-extension-overlay",
+ token: PREVIEW_TOKEN,
+ type: "settings",
+ settings: current,
+ });
+ }, [postPreviewMessage]);
+
+ const metricsDimensions =
+ videoDimensions ?? lastPreviewFrame?.dimensions ?? null;
+ const metrics = webcam
+ ? getPreviewMetrics(webcam.size, webcam.shape, metricsDimensions)
+ : null;
+ const totalHeight = metrics ? metrics.height + BAR_HEIGHT : 0;
+ const borderRadius = webcam
+ ? getBorderRadius(webcam.size, webcam.shape)
+ : "0";
+ const renderPosition =
+ (isDragging ? positionRef.current : position) ?? position;
+
+ const cameraWindow = previewEnabled &&
+ previewTokenReady &&
+ webcam &&
+ renderPosition &&
+ metrics && (
+
+
+
+
event.stopPropagation()}
+ onClick={(event) => event.stopPropagation()}
+ onKeyDown={(event) => {
+ if (event.key === "Escape") {
+ event.stopPropagation();
+ handleClose();
+ }
+ }}
+ >
+
+
+
+
230 && "is-active",
+ )}
+ aria-label="Resize camera preview"
+ title="Resize"
+ onClick={updateSize}
+ >
+
+
+
+ {webcam.shape === "round" ? (
+
+ ) : null}
+ {webcam.shape === "square" ? (
+
+ ) : null}
+ {webcam.shape === "full" ? (
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+
+ {lastPreviewFrame ? (
+
+ ) : null}
+
+ {!livePreviewReady && !showPreviewError ? (
+
+
+
+ ) : null}
+ {previewError && showPreviewError ? (
+
{previewError}
+ ) : null}
+ {isInPictureInPicture ? (
+
+
+ Picture in Picture active
+
+
+
+
+
+ ) : null}
+
+
+
+ );
+
+ return (
+ <>
+ {cameraWindow}
+
+
+
+
+
+ {isDragging ? (
+ moveDrag(event.clientX, event.clientY)}
+ onPointerUp={endDrag}
+ onPointerCancel={endDrag}
+ />
+ ) : null}
+ >
+ );
+}
+
+const isExtensionContextValid = () => {
+ try {
+ return Boolean(chrome.runtime?.id);
+ } catch {
+ return false;
+ }
+};
+
+const TEARDOWN_EVENT = "cap-extension-overlay-teardown";
+
+const watchExtensionContext = (root: HTMLElement, teardown: () => void) => {
+ const interval = window.setInterval(() => {
+ if (!isExtensionContextValid()) {
+ window.clearInterval(interval);
+ teardown();
+ root.remove();
+ }
+ }, 5000);
+ return () => window.clearInterval(interval);
+};
+
+const mountOverlay = () => {
+ // Re-initializing this module (extension reload loads a fresh copy in a
+ // new isolated world) must fully unmount the previous React tree:
+ // removing only its DOM node would leak the tree's chrome listeners,
+ // polls, and clock timers for the rest of the page's life. The teardown
+ // event reaches the prior execution's listener through the shared DOM.
+ const existingRoot = document.getElementById(ROOT_ID);
+ if (existingRoot) {
+ existingRoot.dispatchEvent(new Event(TEARDOWN_EVENT));
+ existingRoot.remove();
+ }
+
+ const root = document.createElement("div");
+ root.id = ROOT_ID;
+ root.dataset.capMounted = "true";
+ const shadow = root.attachShadow({ mode: "closed" });
+ const style = document.createElement("style");
+ style.textContent = overlayCss;
+ const app = document.createElement("div");
+ shadow.append(style, app);
+ document.documentElement.append(root);
+ const reactRoot = createRoot(app);
+ reactRoot.render( );
+ const unmount = () => {
+ try {
+ reactRoot.unmount();
+ } catch {}
+ };
+ const stopWatching = watchExtensionContext(root, unmount);
+ root.addEventListener(
+ TEARDOWN_EVENT,
+ () => {
+ stopWatching();
+ unmount();
+ },
+ { once: true },
+ );
+};
+
+let initialized = false;
+
+// This module is lazily imported by the bootstrap content script, which is
+// the only manifest-declared script; nothing mounts as an import side
+// effect. The bootstrap passes along any service-worker messages it
+// acknowledged while the module was downloading so they replay into the
+// freshly mounted tree.
+export const init = (pendingMessages: readonly unknown[] = []) => {
+ if (initialized) return;
+ initialized = true;
+ setStartupMessages(pendingMessages);
+ if (document.documentElement) {
+ mountOverlay();
+ } else {
+ document.addEventListener("DOMContentLoaded", mountOverlay, { once: true });
+ }
+};
diff --git a/apps/chrome-extension/src/content/recording-bar.tsx b/apps/chrome-extension/src/content/recording-bar.tsx
new file mode 100644
index 00000000000..e68091c6d6b
--- /dev/null
+++ b/apps/chrome-extension/src/content/recording-bar.tsx
@@ -0,0 +1,610 @@
+import { Pause, Pencil, Play, Square, X } from "lucide-react";
+import {
+ type PointerEvent as ReactPointerEvent,
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+} from "react";
+import { formatDuration } from "../shared/format-duration";
+import {
+ isOverlayMessage,
+ isRecordingStatusBroadcast,
+} from "../shared/messages";
+import { sendServiceWorkerMessage } from "../shared/runtime";
+import {
+ AUTH_KEY,
+ loadAuth,
+ loadOverlayUiState,
+ loadSettings,
+ loadSharedRecordingState,
+ loadSharedUiState,
+ OVERLAY_UI_STATE_KEY,
+ RECORDING_STATE_KEY,
+ SHARED_UI_STATE_KEY,
+ updateOverlayUiState,
+ updateSharedUiState,
+} from "../shared/storage";
+import type {
+ OverlayPosition,
+ RecordingPlan,
+ RecordingStatus,
+ SharedRecordingState,
+} from "../shared/types";
+import { DrawingOverlay } from "./drawing-overlay";
+
+const EDGE_PADDING = 16;
+const BOTTOM_OFFSET = 28;
+// Live updates arrive via status broadcasts and the session-storage mirror;
+// this poll only reconciles drift, so it can be slow instead of hammering the
+// service worker (and through it the offscreen document) from every tab.
+const POLL_INTERVAL_MS = 5000;
+const WARNING_THRESHOLD_MS = 60_000;
+const LOGO_URL = chrome.runtime.getURL("icons/icon-48.png");
+
+type BarStatus = {
+ phase: "creating" | "recording" | "paused";
+ startedAt: number;
+ durationMs: number;
+ updatedAt: number;
+};
+
+type BarControl = "stop-recording" | "pause-recording" | "resume-recording";
+
+type RecordingBarOverlayProps = {
+ recorderPanelOpen: boolean;
+};
+
+const toBarStatus = (
+ status: RecordingStatus | undefined,
+ current: BarStatus | null,
+): BarStatus | null => {
+ if (!status) return null;
+ if (status.phase === "creating") {
+ if (current?.phase === "creating") return current;
+ const now = Date.now();
+ return {
+ phase: "creating",
+ startedAt: now,
+ durationMs: 0,
+ updatedAt: now,
+ };
+ }
+ if (status.phase !== "recording" && status.phase !== "paused") return null;
+ return {
+ phase: status.phase,
+ startedAt: status.startedAt,
+ durationMs: status.durationMs,
+ updatedAt: status.updatedAt ?? status.startedAt,
+ };
+};
+
+const classNames = (...values: Array) =>
+ values.filter(Boolean).join(" ");
+
+const toOverlayPosition = (position: {
+ x: number;
+ y: number;
+}): OverlayPosition => ({
+ ...position,
+ viewportWidth: window.innerWidth,
+ viewportHeight: window.innerHeight,
+ updatedAt: Date.now(),
+});
+
+// The timer derives from wall-clock deltas against the shared status, so as
+// long as every tab holds the same status object their clocks read the same.
+const currentDurationMs = (status: BarStatus, now: number) =>
+ status.phase === "creating"
+ ? status.durationMs
+ : status.phase === "recording"
+ ? status.durationMs + Math.max(0, now - status.updatedAt)
+ : status.durationMs;
+
+export function RecordingBarOverlay({
+ recorderPanelOpen,
+}: RecordingBarOverlayProps) {
+ const [status, setStatus] = useState(null);
+ const [plan, setPlan] = useState(null);
+ const [signedIn, setSignedIn] = useState(false);
+ const [readyDismissed, setReadyDismissed] = useState(false);
+ const [position, setPosition] = useState<{ x: number; y: number } | null>(
+ null,
+ );
+ const [persistedBarPosition, setPersistedBarPosition] =
+ useState(null);
+ const [isDragging, setIsDragging] = useState(false);
+ const [busy, setBusy] = useState(false);
+ const [drawing, setDrawing] = useState(false);
+ const [now, setNow] = useState(() => Date.now());
+ const dragOffsetRef = useRef({ x: 0, y: 0 });
+ const barRef = useRef(null);
+ const planRef = useRef(null);
+ const positionRef = useRef<{ x: number; y: number } | null>(null);
+ const recorderPanelOpenRef = useRef(false);
+
+ useEffect(() => {
+ planRef.current = plan;
+ }, [plan]);
+
+ useEffect(() => {
+ positionRef.current = position;
+ }, [position]);
+
+ const applyResponse = useCallback(
+ (nextStatus: RecordingStatus | undefined, nextPlan?: RecordingPlan) => {
+ if (nextPlan) setPlan(nextPlan);
+ setStatus((current) => toBarStatus(nextStatus, current));
+ setNow(Date.now());
+ },
+ [],
+ );
+
+ const applySharedState = useCallback(
+ (state: SharedRecordingState | null) => {
+ if (!state) return;
+ applyResponse(state.status, state.plan ?? undefined);
+ },
+ [applyResponse],
+ );
+
+ const refresh = useCallback(() => {
+ sendServiceWorkerMessage({
+ target: "service-worker",
+ type: "get-recording-status",
+ })
+ .then((response) => {
+ if (response.ok) applyResponse(response.status, response.plan);
+ })
+ .catch(() => undefined);
+ }, [applyResponse]);
+
+ useEffect(() => {
+ refresh();
+ }, [refresh]);
+
+ useEffect(() => {
+ if (recorderPanelOpen && !recorderPanelOpenRef.current) {
+ refresh();
+ }
+ recorderPanelOpenRef.current = recorderPanelOpen;
+ }, [recorderPanelOpen, refresh]);
+
+ // chrome.storage.session is the cross-tab source of truth for both the
+ // recording state and the shared UI flags; the storage change events reach
+ // every tab (including backgrounded ones), which keeps each bar in lockstep
+ // without per-tab polling round-trips.
+ useEffect(() => {
+ let disposed = false;
+
+ const syncOverlayUiState = () => {
+ loadOverlayUiState()
+ .then((state) => {
+ if (!disposed) {
+ setPersistedBarPosition(state.recordingBarPosition);
+ }
+ })
+ .catch(() => undefined);
+ };
+
+ const syncSharedRecordingState = () => {
+ loadSharedRecordingState()
+ .then((state) => {
+ if (!disposed) applySharedState(state);
+ })
+ .catch(() => undefined);
+ };
+
+ const syncSharedUiState = () => {
+ loadSharedUiState()
+ .then((state) => {
+ if (!disposed) setReadyDismissed(state.readyBarDismissed);
+ })
+ .catch(() => undefined);
+ };
+
+ const syncAuthState = () => {
+ loadAuth()
+ .then((auth) => {
+ if (!disposed) setSignedIn(auth !== null);
+ })
+ .catch(() => undefined);
+ };
+
+ const handleStorageChange = (
+ changes: Record,
+ areaName: string,
+ ) => {
+ if (areaName === "local" && changes[OVERLAY_UI_STATE_KEY]) {
+ syncOverlayUiState();
+ }
+ if (areaName === "local" && changes[AUTH_KEY]) {
+ syncAuthState();
+ }
+ if (areaName === "session" && changes[RECORDING_STATE_KEY]) {
+ syncSharedRecordingState();
+ }
+ if (areaName === "session" && changes[SHARED_UI_STATE_KEY]) {
+ syncSharedUiState();
+ }
+ };
+
+ syncOverlayUiState();
+ syncSharedRecordingState();
+ syncSharedUiState();
+ syncAuthState();
+ chrome.storage.onChanged.addListener(handleStorageChange);
+ return () => {
+ disposed = true;
+ chrome.storage.onChanged.removeListener(handleStorageChange);
+ };
+ }, [applySharedState]);
+
+ useEffect(() => {
+ const handleVisibility = () => {
+ if (document.visibilityState !== "visible") return;
+ // Recompute the clock immediately so the first painted frame after a
+ // tab switch already shows the right time, then reconcile from the
+ // session-storage mirror: reading storage does not wake the service
+ // worker, unlike a get-recording-status round trip, and the slow
+ // poll below still corrects any drift while the bar is active.
+ setNow(Date.now());
+ loadSharedRecordingState()
+ .then((state) => applySharedState(state))
+ .catch(() => undefined);
+ };
+ document.addEventListener("visibilitychange", handleVisibility);
+ return () =>
+ document.removeEventListener("visibilitychange", handleVisibility);
+ }, [applySharedState]);
+
+ useEffect(() => {
+ const handleMessage = (message: unknown) => {
+ if (isRecordingStatusBroadcast(message)) {
+ applyResponse(message.status);
+ if (!planRef.current) refresh();
+ return false;
+ }
+ if (!isOverlayMessage(message)) return false;
+ refresh();
+ return false;
+ };
+ chrome.runtime.onMessage.addListener(handleMessage);
+ return () => chrome.runtime.onMessage.removeListener(handleMessage);
+ }, [applyResponse, refresh]);
+
+ const active = status !== null;
+ // The "Ready to record" bar only makes sense once the user can actually
+ // start a recording, which requires being signed in.
+ const ready = signedIn && !active && recorderPanelOpen && !readyDismissed;
+ const visible = active || ready;
+
+ // Drawing is reachable only from a visible bar, so once the bar hides
+ // (recording stopped and the ready bar dismissed) tear the canvas down too
+ // rather than leaving an invisible surface armed to swallow page clicks.
+ useEffect(() => {
+ if (!visible) setDrawing(false);
+ }, [visible]);
+
+ useEffect(() => {
+ if (!active) return;
+ setNow(Date.now());
+ const interval = window.setInterval(() => setNow(Date.now()), 500);
+ return () => window.clearInterval(interval);
+ }, [active]);
+
+ useEffect(() => {
+ if (!active) return;
+ const interval = window.setInterval(() => {
+ if (document.visibilityState === "visible") refresh();
+ }, POLL_INTERVAL_MS);
+ return () => window.clearInterval(interval);
+ }, [active, refresh]);
+
+ const clampToViewport = useCallback((value: { x: number; y: number }) => {
+ const rect = barRef.current?.getBoundingClientRect();
+ const width = rect?.width ?? 360;
+ const height = rect?.height ?? 64;
+ const maxX = Math.max(
+ EDGE_PADDING,
+ window.innerWidth - width - EDGE_PADDING,
+ );
+ const maxY = Math.max(
+ EDGE_PADDING,
+ window.innerHeight - height - EDGE_PADDING,
+ );
+ return {
+ x: Math.min(Math.max(value.x, EDGE_PADDING), maxX),
+ y: Math.min(Math.max(value.y, EDGE_PADDING), maxY),
+ };
+ }, []);
+
+ useEffect(() => {
+ if (!ready || isDragging) return;
+ const bar = barRef.current;
+ if (!bar) return;
+ const reposition = () => {
+ const rect = bar.getBoundingClientRect();
+ if (rect.width === 0) return;
+ const restored = persistedBarPosition
+ ? {
+ x: persistedBarPosition.x,
+ y: persistedBarPosition.y,
+ }
+ : {
+ x: (window.innerWidth - rect.width) / 2,
+ y: window.innerHeight - rect.height - BOTTOM_OFFSET,
+ };
+ setPosition(clampToViewport(restored));
+ };
+ reposition();
+ const observer = new ResizeObserver(reposition);
+ observer.observe(bar);
+ return () => observer.disconnect();
+ }, [clampToViewport, persistedBarPosition, isDragging, ready]);
+
+ useEffect(() => {
+ if (!ready) return;
+ const handleResize = () => {
+ setPosition((previous) =>
+ previous ? clampToViewport(previous) : previous,
+ );
+ };
+ window.addEventListener("resize", handleResize);
+ return () => window.removeEventListener("resize", handleResize);
+ }, [clampToViewport, ready]);
+
+ const handlePointerDown = useCallback(
+ (event: ReactPointerEvent) => {
+ if ((event.target as HTMLElement).closest("[data-controls]")) return;
+ event.preventDefault();
+ event.stopPropagation();
+ // Capture the pointer so the drag survives the cursor crossing
+ // iframes (for example the camera preview) on the page.
+ event.currentTarget.setPointerCapture(event.pointerId);
+ setIsDragging(true);
+ dragOffsetRef.current = {
+ x: event.clientX - (position?.x ?? EDGE_PADDING),
+ y: event.clientY - (position?.y ?? EDGE_PADDING),
+ };
+ },
+ [position],
+ );
+
+ useEffect(() => {
+ if (!isDragging) return;
+ const handlePointerMove = (event: PointerEvent) => {
+ setPosition(
+ clampToViewport({
+ x: event.clientX - dragOffsetRef.current.x,
+ y: event.clientY - dragOffsetRef.current.y,
+ }),
+ );
+ };
+ const handlePointerUp = () => {
+ setIsDragging(false);
+ const nextPosition = positionRef.current;
+ if (!nextPosition) return;
+ void updateOverlayUiState((current) => ({
+ ...current,
+ recordingBarPosition: toOverlayPosition(nextPosition),
+ }))
+ .then((state) => setPersistedBarPosition(state.recordingBarPosition))
+ .catch(() => undefined);
+ };
+ window.addEventListener("pointermove", handlePointerMove);
+ window.addEventListener("pointerup", handlePointerUp);
+ window.addEventListener("pointercancel", handlePointerUp);
+ return () => {
+ window.removeEventListener("pointermove", handlePointerMove);
+ window.removeEventListener("pointerup", handlePointerUp);
+ window.removeEventListener("pointercancel", handlePointerUp);
+ };
+ }, [clampToViewport, isDragging]);
+
+ const sendControl = useCallback(
+ (type: BarControl) => {
+ setBusy(true);
+ sendServiceWorkerMessage({ target: "service-worker", type })
+ .then((response) => {
+ if (response.ok) applyResponse(response.status, response.plan);
+ })
+ .catch(() => undefined)
+ .finally(() => setBusy(false));
+ },
+ [applyResponse],
+ );
+
+ const startRecording = useCallback(() => {
+ setBusy(true);
+ loadSettings()
+ .then((settings) =>
+ sendServiceWorkerMessage({
+ target: "service-worker",
+ type: "start-recording",
+ mode: settings.capture.recordingMode,
+ }),
+ )
+ .then((response) => {
+ if (response.ok) applyResponse(response.status, response.plan);
+ })
+ .catch(() => undefined)
+ .finally(() => setBusy(false));
+ }, [applyResponse]);
+
+ const dismissReadyBar = useCallback(() => {
+ setReadyDismissed(true);
+ void updateSharedUiState((current) => ({
+ ...current,
+ readyBarDismissed: true,
+ updatedAt: Date.now(),
+ })).catch(() => undefined);
+ }, []);
+
+ const toggleDrawing = useCallback(() => setDrawing((value) => !value), []);
+ const stopDrawing = useCallback(() => setDrawing(false), []);
+
+ if (!visible) return null;
+
+ if (status === null) {
+ return (
+ <>
+
+
+
+
+
+ Ready to record
+
+
+
+ Cap
+
+
+
+
+
+
+
+ Start recording
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+ }
+
+ const isCreating = status.phase === "creating";
+ const isPaused = status.phase === "paused";
+ const maxMs =
+ plan && !plan.isPro && plan.maxRecordingSeconds !== null
+ ? plan.maxRecordingSeconds * 1000
+ : null;
+ const durationMs = currentDurationMs(status, now);
+ const displayMs =
+ maxMs !== null ? Math.max(0, maxMs - durationMs) : durationMs;
+ const isWarning = maxMs !== null && displayMs <= WARNING_THRESHOLD_MS;
+
+ // While recording the bar is a fixed rail pinned to the middle left of the
+ // page: just the clock and Stop, identical in every tab.
+ return (
+ <>
+
+
+
+
+ {formatDuration(displayMs)}
+
+
+
+ sendControl(isPaused ? "resume-recording" : "pause-recording")
+ }
+ >
+ {isPaused ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
sendControl("stop-recording")}
+ >
+
+ Stop
+
+
+
+ >
+ );
+}
diff --git a/apps/chrome-extension/src/content/startup-messages.ts b/apps/chrome-extension/src/content/startup-messages.ts
new file mode 100644
index 00000000000..202d7eddc84
--- /dev/null
+++ b/apps/chrome-extension/src/content/startup-messages.ts
@@ -0,0 +1,21 @@
+// Messages the bootstrap content script acknowledged while the overlay module
+// was still being fetched. Each message-handling component replays them once on
+// mount so the signal that triggered the lazy load (a panel toggle, a webcam
+// settings push, a confirm prompt) is not dropped.
+let startupMessages: readonly unknown[] = [];
+
+export const setStartupMessages = (messages: readonly unknown[]) => {
+ startupMessages = messages;
+};
+
+export const replayStartupMessages = (
+ handleMessage: (
+ message: unknown,
+ sender: chrome.runtime.MessageSender,
+ sendResponse: (response?: unknown) => void,
+ ) => boolean | undefined,
+) => {
+ for (const message of startupMessages) {
+ handleMessage(message, {} as chrome.runtime.MessageSender, () => undefined);
+ }
+};
diff --git a/apps/chrome-extension/src/how-it-works/main.ts b/apps/chrome-extension/src/how-it-works/main.ts
new file mode 100644
index 00000000000..55f31874bb9
--- /dev/null
+++ b/apps/chrome-extension/src/how-it-works/main.ts
@@ -0,0 +1,4 @@
+import { mountPageNav } from "../shared/page-nav";
+import "./styles.css";
+
+mountPageNav("how-it-works");
diff --git a/apps/chrome-extension/src/how-it-works/styles.css b/apps/chrome-extension/src/how-it-works/styles.css
new file mode 100644
index 00000000000..6c2209e186e
--- /dev/null
+++ b/apps/chrome-extension/src/how-it-works/styles.css
@@ -0,0 +1,185 @@
+@import "../shared/paper.css";
+
+.bolt-body {
+ stroke-dasharray: 1;
+ stroke-dashoffset: 1;
+ animation: draw 0.9s ease 0.15s forwards;
+}
+
+.spark-1 {
+ animation-delay: 1.1s;
+}
+
+.spark-2 {
+ animation-delay: 1.25s;
+}
+
+.spark-3 {
+ animation-delay: 1.4s;
+}
+
+.steps {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 14px;
+ width: min(680px, calc(100vw - 48px));
+ margin: 36px 0 0;
+ padding: 0;
+ list-style: none;
+}
+
+.step {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 6px;
+ padding: 20px 16px 18px;
+ border: 1.5px solid var(--track);
+ border-radius: 16px;
+ background: rgba(255, 255, 255, 0.65);
+ animation: fade-up 0.5s ease both;
+}
+
+.step:nth-child(1) {
+ animation-delay: 0.2s;
+}
+
+.step:nth-child(2) {
+ animation-delay: 0.3s;
+}
+
+.step:nth-child(3) {
+ animation-delay: 0.4s;
+}
+
+.step-doodle {
+ width: 44px;
+ height: 44px;
+ overflow: visible;
+}
+
+.doodle-stroke-sm {
+ fill: none;
+ stroke: var(--ink);
+ stroke-width: 3;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+
+.record-dot {
+ fill: var(--accent);
+ animation: record-pulse 1.8s ease-in-out 1s infinite;
+}
+
+.upload-arrow {
+ fill: none;
+ stroke: var(--accent);
+ stroke-width: 3;
+ stroke-linecap: round;
+ stroke-dasharray: 2 4.2;
+ animation: march 1.1s linear infinite;
+}
+
+.arrow-head {
+ stroke: var(--accent);
+}
+
+.link-piece {
+ stroke-dasharray: 1;
+ stroke-dashoffset: 1;
+ animation: draw 0.5s ease 0.6s forwards;
+}
+
+.link-piece-2 {
+ animation-delay: 0.8s;
+}
+
+.link-bar {
+ stroke: var(--accent);
+ stroke-dasharray: 1;
+ stroke-dashoffset: 1;
+ animation: draw 0.35s ease 1.05s forwards;
+}
+
+.step-count {
+ font-size: 12px;
+ font-weight: 500;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--ink-soft);
+}
+
+.step p {
+ font-size: 13.5px;
+ line-height: 1.45;
+ color: var(--ink);
+}
+
+.tips-card {
+ width: min(680px, calc(100vw - 48px));
+ margin-top: 18px;
+ animation-delay: 0.5s;
+}
+
+.tips {
+ display: grid;
+ gap: 10px;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.tips li {
+ display: flex;
+ align-items: baseline;
+ gap: 10px;
+ font-size: 14px;
+ line-height: 1.5;
+ color: var(--ink);
+}
+
+.tip-dot {
+ width: 8px;
+ height: 8px;
+ flex: 0 0 auto;
+ border-radius: 999px;
+ background: var(--accent);
+ transform: translateY(-1px);
+}
+
+.footnote {
+ animation-delay: 0.6s;
+}
+
+@keyframes record-pulse {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.35;
+ }
+}
+
+@media (max-width: 720px) {
+ .steps {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .step,
+ .tips-card,
+ .bolt-body,
+ .link-piece,
+ .link-piece-2,
+ .link-bar {
+ animation-duration: 0.01ms;
+ animation-delay: 0s;
+ }
+
+ .record-dot,
+ .upload-arrow {
+ animation: none;
+ }
+}
diff --git a/apps/chrome-extension/src/offscreen/recorder.ts b/apps/chrome-extension/src/offscreen/recorder.ts
new file mode 100644
index 00000000000..024f0e2eca1
--- /dev/null
+++ b/apps/chrome-extension/src/offscreen/recorder.ts
@@ -0,0 +1,1736 @@
+import {
+ appendLocalRecordingChunk,
+ type ChunkUploadState,
+ DEFAULT_API_REQUEST_TIMEOUT_MS,
+ DISPLAY_MEDIA_IDEAL,
+ DISPLAY_MEDIA_VIDEO_CONSTRAINTS,
+ DISPLAY_MODE_PREFERENCES,
+ deleteRecoveredRecordingSpool,
+ describeRecordingCodecs,
+ detectRecordingModeFromTrack,
+ type ExtendedDisplayMediaStreamOptions,
+ InstantRecordingUploader,
+ initialLocalRecordingState,
+ initiateMultipartUpload,
+ isUserCancellationError,
+ type LocalRecordingState,
+ listRecordingSpoolSessions,
+ MultipartCompletionUncertainError,
+ RECORDING_SPOOL_LIVE_MIN_IDLE_MS,
+ RecordingSpool,
+ recoverRecordingSpoolSession,
+ selectRecordingPipeline,
+ shouldRetryDisplayMediaWithoutPreferences,
+ type VideoId,
+} from "@cap/recorder-core";
+
+import {
+ createInstantRecording,
+ deleteInstantRecording,
+ updateUploadProgress,
+} from "../shared/api";
+import { toCameraDevices, toMicrophoneDevices } from "../shared/devices";
+import { isOffscreenRequest } from "../shared/messages";
+import {
+ loadAuth,
+ loadFailedRecordings,
+ loadLiveRecordingManifests,
+ loadSettings,
+ pruneLiveRecordingManifests,
+ removeFailedRecording,
+ removeLiveRecordingManifest,
+ saveFailedRecordings,
+ saveLiveRecordingManifest,
+ upsertFailedRecording,
+} from "../shared/storage";
+import type {
+ ConnectCameraPreviewRequest,
+ ExtensionSettings,
+ MicrophoneProbeResult,
+ MicrophoneSettings,
+ OffscreenRequest,
+ OffscreenResponse,
+ RecordingCaptureSource,
+ RecordingMode,
+ RecordingStatus,
+ RecordingStatusBroadcast,
+ ServiceWorkerRequest,
+ StartRecordingRequest,
+ UploadSummary,
+ WebcamSettings,
+} from "../shared/types";
+import {
+ DEFAULT_CAMERA_DEVICE_ID,
+ DEFAULT_MICROPHONE_DEVICE_ID,
+} from "../shared/types";
+import {
+ toSessionDescriptionInit,
+ waitForIceGatheringComplete,
+} from "../shared/webrtc";
+
+const RECORDING_TIMESLICE_MS = 1000;
+const RECORDING_TIMESLICE_GUARD_MS = RECORDING_TIMESLICE_MS * 3;
+const DEFAULT_WIDTH = DISPLAY_MEDIA_IDEAL.width;
+const DEFAULT_HEIGHT = DISPLAY_MEDIA_IDEAL.height;
+const DEFAULT_FPS = DISPLAY_MEDIA_IDEAL.frameRate;
+// Upload progress reaches the upload page (and the session-storage mirror)
+// through broadcasts; throttle them so chunk-level callbacks do not flood
+// every open tab with messages.
+const PROGRESS_BROADCAST_INTERVAL_MS = 500;
+// Recordings stranded by a crash or abandoned after a failed upload are kept
+// recoverable for this long before the spool sweep reclaims the space.
+const ORPHAN_SPOOL_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000;
+// Cap on the in-memory continuation kept after a spool write failure. An
+// offscreen document cannot page memory out, so an unbounded backup would pin
+// the rest of an hour-long session in RAM (risking an OOM that also kills the
+// healthy streaming upload). Overflow drops the local copy entirely — a
+// truncated backup cannot be retried — while the upload continues untouched.
+const MEMORY_BACKUP_MAX_BYTES = 256 * 1024 * 1024;
+const UNCERTAIN_COMPLETION_MESSAGE =
+ "Upload confirmation was interrupted. Open the video to verify it processed before retrying.";
+
+type ChunkingMode = "manual" | "timeslice";
+type RecordingSound = "start-recording" | "stop-recording";
+
+type ActiveRecording = {
+ recorder: MediaRecorder;
+ stopPromise: Promise;
+ streams: MediaStream[];
+ recordingStream: MediaStream;
+ statusTimer: number | null;
+ spool: RecordingSpool;
+ uploader: InstantRecordingUploader;
+ startedAt: number;
+ durationMs: number;
+ lastResumedAt: number | null;
+ videoId: VideoId;
+ shareUrl: string;
+ width: number;
+ height: number;
+ fps: number;
+ subpath: string;
+ mimeType: string;
+ maxDurationMs: number | null;
+ audioContext?: AudioContext;
+ chunkChain: Promise;
+ dataRequestInterval: number | null;
+ chunkStartGuard: number | null;
+ chunkingMode: ChunkingMode | null;
+ lastChunkAt: number | null;
+ recordedBytes: number;
+ finalizePromise: Promise | null;
+ cleanedUp: boolean;
+ // A spool write failure (IndexedDB quota, backpressure) must not end an
+ // otherwise healthy session: the local copy degrades to capped memory
+ // (mirrors the dashboard recorder's in-memory fallback) while the
+ // streaming upload continues untouched.
+ spoolFailed: boolean;
+ memoryBackup: LocalRecordingState;
+};
+
+let activeRecording: ActiveRecording | null = null;
+let status: RecordingStatus = { phase: "idle" };
+// Set synchronously when a start request is accepted so two near-simultaneous
+// starts cannot both pass the activeRecording check (which is only assigned
+// after several awaits).
+let startInProgress = false;
+// Lets a stop request abort a start that is still in the "creating" phase
+// (capture picker open, server round-trips pending).
+let startCancelRequested = false;
+// True only while the pre-roll countdown is running (capture is fully set up
+// but no frame has been recorded yet). A stop request during this window is a
+// cancellation, not a real recording stop.
+let countdownInProgress = false;
+// Resolves the in-progress countdown wait early so a cancel takes effect
+// immediately instead of after the full countdown elapses.
+let countdownResolve: (() => void) | null = null;
+// Mirrors startInProgress for retries: a retry upload must not run while a
+// recording starts (or vice versa), or its terminal status write would
+// clobber the live session's state machine.
+let retryInProgress = false;
+let lastProgressBroadcastAt = 0;
+let cameraPreviewStream: MediaStream | null = null;
+let cameraPreviewDeviceId: string | null = null;
+const cameraPreviewSessions = new Map();
+const activeRecordingSounds = new Set();
+
+const playRecordingSound = (
+ sound: RecordingSound,
+ settings: ExtensionSettings,
+) => {
+ if (!settings.sounds.enabled) return;
+ const audio = new Audio(chrome.runtime.getURL(`sounds/${sound}.ogg`));
+ const releaseAudio = () => {
+ activeRecordingSounds.delete(audio);
+ };
+ activeRecordingSounds.add(audio);
+ audio.addEventListener("ended", releaseAudio, { once: true });
+ audio.addEventListener("error", releaseAudio, { once: true });
+ void audio.play().catch(releaseAudio);
+};
+
+const broadcastStatus = () => {
+ chrome.runtime.sendMessage(
+ {
+ target: "recording-status",
+ type: "recording-status-changed",
+ status,
+ } satisfies RecordingStatusBroadcast,
+ () => {
+ void chrome.runtime.lastError;
+ },
+ );
+};
+
+const broadcastProgressThrottled = () => {
+ const now = Date.now();
+ if (now - lastProgressBroadcastAt < PROGRESS_BROADCAST_INTERVAL_MS) return;
+ lastProgressBroadcastAt = now;
+ broadcastStatus();
+};
+
+const summarizeChunks = (chunks: ChunkUploadState[]): UploadSummary => {
+ let totalBytes = 0;
+ let uploadedBytes = 0;
+ let completedChunks = 0;
+ let failedChunks = 0;
+ for (const chunk of chunks) {
+ totalBytes += chunk.sizeBytes;
+ uploadedBytes += chunk.uploadedBytes;
+ if (chunk.status === "complete") completedChunks += 1;
+ if (chunk.status === "error") failedChunks += 1;
+ }
+ return {
+ totalBytes,
+ uploadedBytes,
+ totalChunks: chunks.length,
+ completedChunks,
+ failedChunks,
+ };
+};
+
+const throwIfStartCanceled = () => {
+ if (startCancelRequested) {
+ throw new DOMException("Recording start was canceled", "AbortError");
+ }
+};
+
+// Plays the pre-roll countdown on the recorded/active tab and waits for it to
+// run its course before the caller starts the MediaRecorder. The animation
+// lives in the page overlay; this side only owns the timing, so the recording
+// begins the instant the countdown ends and the count never lands in the
+// captured frames. A stop request resolves the wait early via
+// `countdownResolve` so cancelling does not block for the full duration.
+const runStartCountdown = async (request: StartRecordingRequest) => {
+ const { enabled, seconds } = request.settings.countdown;
+ if (!enabled || seconds <= 0) return;
+ const durationMs = seconds * 1000;
+
+ // The offscreen document cannot call chrome.tabs; the service worker relays
+ // the countdown to the recorded tab's content overlay. Fire-and-forget: a
+ // tab that cannot show the overlay (e.g. a chrome:// page) just leaves the
+ // screen blank for the wait, which still keeps the count out of the capture.
+ chrome.runtime.sendMessage(
+ {
+ target: "service-worker",
+ type: "show-countdown",
+ tabId: request.tabId,
+ seconds,
+ durationMs,
+ } satisfies ServiceWorkerRequest,
+ () => {
+ void chrome.runtime.lastError;
+ },
+ );
+
+ countdownInProgress = true;
+ try {
+ await new Promise((resolve) => {
+ const finish = () => {
+ window.clearTimeout(timer);
+ countdownResolve = null;
+ resolve();
+ };
+ const timer = window.setTimeout(finish, durationMs);
+ countdownResolve = finish;
+ });
+ } finally {
+ countdownInProgress = false;
+ countdownResolve = null;
+ }
+};
+
+const stopTracks = (stream: MediaStream) => {
+ for (const track of stream.getTracks()) {
+ track.stop();
+ }
+};
+
+const stopCameraPreviewStream = () => {
+ if (!cameraPreviewStream) return;
+ stopTracks(cameraPreviewStream);
+ cameraPreviewStream = null;
+ cameraPreviewDeviceId = null;
+};
+
+const disconnectCameraPreview = (sessionId: string) => {
+ const peer = cameraPreviewSessions.get(sessionId);
+ if (!peer) return;
+ cameraPreviewSessions.delete(sessionId);
+ peer.close();
+ if (cameraPreviewSessions.size === 0) {
+ stopCameraPreviewStream();
+ }
+};
+
+const disconnectCameraPreviews = () => {
+ for (const sessionId of Array.from(cameraPreviewSessions.keys())) {
+ disconnectCameraPreview(sessionId);
+ }
+ stopCameraPreviewStream();
+};
+
+const getCameraPreviewStream = async (settings: WebcamSettings) => {
+ if (
+ cameraPreviewStream?.active &&
+ cameraPreviewDeviceId === settings.deviceId
+ ) {
+ return cameraPreviewStream;
+ }
+
+ disconnectCameraPreviews();
+ cameraPreviewStream = await getCameraMediaStream(settings, false);
+ cameraPreviewDeviceId = settings.deviceId;
+ return cameraPreviewStream;
+};
+
+const getStreamSize = (stream: MediaStream) => {
+ const settings = stream.getVideoTracks()[0]?.getSettings();
+ return {
+ width: Math.max(1, settings?.width ?? DEFAULT_WIDTH),
+ height: Math.max(1, settings?.height ?? DEFAULT_HEIGHT),
+ fps: Math.max(1, settings?.frameRate ?? DEFAULT_FPS),
+ };
+};
+
+const getDisplaySurface = (settings: MediaTrackSettings) => {
+ const value = (settings as Partial<{ displaySurface?: unknown }>)
+ .displaySurface;
+ return typeof value === "string" ? value : null;
+};
+
+const getCaptureSource = (
+ request: StartRecordingRequest,
+ stream: MediaStream,
+): RecordingCaptureSource | null => {
+ if (request.mode === "camera") return null;
+ const track = stream.getVideoTracks()[0] ?? null;
+ if (!track) return null;
+ const settings = track.getSettings();
+ return {
+ requestedMode: request.mode,
+ detectedMode:
+ request.mode === "tab"
+ ? "tab"
+ : detectRecordingModeFromTrack(track, settings),
+ displaySurface: getDisplaySurface(settings),
+ label: track.label || null,
+ tabId: request.tabId,
+ };
+};
+
+const broadcastCaptureSource = (source: RecordingCaptureSource) => {
+ chrome.runtime.sendMessage(
+ {
+ target: "service-worker",
+ type: "recording-capture-source",
+ source,
+ },
+ () => {
+ void chrome.runtime.lastError;
+ },
+ );
+};
+
+const getVideoConstraint = (webcam: WebcamSettings) =>
+ webcam.deviceId && webcam.deviceId !== DEFAULT_CAMERA_DEVICE_ID
+ ? {
+ deviceId: { exact: webcam.deviceId },
+ }
+ : true;
+
+const shouldRetryDefaultCamera = (webcam: WebcamSettings, error: unknown) =>
+ webcam.deviceId !== null &&
+ webcam.deviceId !== DEFAULT_CAMERA_DEVICE_ID &&
+ error instanceof DOMException &&
+ (error.name === "NotFoundError" || error.name === "OverconstrainedError");
+
+const getCameraMediaStream = async (
+ webcam: WebcamSettings,
+ audio: boolean | MediaTrackConstraints,
+) => {
+ try {
+ return await navigator.mediaDevices.getUserMedia({
+ video: getVideoConstraint(webcam),
+ audio,
+ });
+ } catch (error) {
+ if (!shouldRetryDefaultCamera(webcam, error)) {
+ throw error;
+ }
+
+ return navigator.mediaDevices.getUserMedia({
+ video: true,
+ audio,
+ });
+ }
+};
+
+const tabCaptureConstraints = (streamId: string, includeAudio: boolean) =>
+ ({
+ ...(includeAudio
+ ? {
+ audio: {
+ mandatory: {
+ chromeMediaSource: "tab",
+ chromeMediaSourceId: streamId,
+ },
+ },
+ }
+ : {}),
+ video: {
+ mandatory: {
+ chromeMediaSource: "tab",
+ chromeMediaSourceId: streamId,
+ },
+ },
+ }) as unknown as MediaStreamConstraints;
+
+const requestDisplayMedia = (
+ options: Partial,
+) =>
+ navigator.mediaDevices.getDisplayMedia(options as DisplayMediaStreamOptions);
+
+const getDisplayStream = async (
+ mode: Exclude,
+ includeAudio: boolean,
+) => {
+ const preferences = DISPLAY_MODE_PREFERENCES[mode];
+ const video = DISPLAY_MEDIA_VIDEO_CONSTRAINTS;
+
+ try {
+ return await requestDisplayMedia({
+ ...preferences,
+ video,
+ audio: includeAudio,
+ });
+ } catch (error) {
+ if (isUserCancellationError(error)) throw error;
+
+ // Some browsers/OSes reject the advanced surface preferences
+ // (monitorTypeSurfaces, surfaceSwitching, preferCurrentTab, …) or a
+ // system-audio request the picker cannot satisfy. Fall back the way the
+ // dashboard recorder does instead of failing the whole capture.
+ if (shouldRetryDisplayMediaWithoutPreferences(error)) {
+ try {
+ return await requestDisplayMedia({ video, audio: includeAudio });
+ } catch (retryError) {
+ if (
+ includeAudio &&
+ shouldRetryDisplayMediaWithoutPreferences(retryError)
+ ) {
+ return requestDisplayMedia({ video, audio: false });
+ }
+ throw retryError;
+ }
+ }
+
+ if (includeAudio) {
+ try {
+ return await requestDisplayMedia({
+ ...preferences,
+ video,
+ audio: false,
+ });
+ } catch {
+ throw error;
+ }
+ }
+
+ throw error;
+ }
+};
+
+const getMainStream = async (request: StartRecordingRequest) => {
+ if (request.mode === "tab") {
+ if (!request.tabStreamId) throw new Error("Tab stream id is missing");
+ return navigator.mediaDevices.getUserMedia(
+ tabCaptureConstraints(
+ request.tabStreamId,
+ request.settings.systemAudio.enabled,
+ ),
+ );
+ }
+
+ if (request.mode === "camera") {
+ return getCameraMediaStream(
+ request.settings.webcam,
+ getAudioConstraint(request.settings.microphone),
+ );
+ }
+
+ return getDisplayStream(request.mode, request.settings.systemAudio.enabled);
+};
+
+const getAudioConstraint = (
+ microphone: MicrophoneSettings,
+): boolean | MediaTrackConstraints => {
+ if (!microphone.enabled) return false;
+ if (
+ microphone.deviceId &&
+ microphone.deviceId !== DEFAULT_MICROPHONE_DEVICE_ID
+ ) {
+ return {
+ deviceId: { exact: microphone.deviceId },
+ echoCancellation: true,
+ autoGainControl: true,
+ noiseSuppression: true,
+ };
+ }
+ return true;
+};
+
+const getMicrophoneStream = async (
+ microphone: MicrophoneSettings,
+ mode: RecordingMode,
+) => {
+ if (mode === "camera") return null;
+ if (!microphone.enabled) return null;
+
+ const audio = getAudioConstraint(microphone);
+ const constraints: MediaStreamConstraints = {
+ audio,
+ video: false,
+ };
+
+ try {
+ return await navigator.mediaDevices.getUserMedia(constraints);
+ } catch {
+ return null;
+ }
+};
+
+// How long a probe listens before declaring the mic silent, and the peak
+// amplitude (time-domain, 0..1) that counts as sound. A muted/dead device
+// flat-lines near 0; a working mic's noise floor clears this threshold, so it
+// only flags an effectively-silent input.
+const MIC_PROBE_WINDOW_MS = 1200;
+const MIC_PROBE_SAMPLE_INTERVAL_MS = 50;
+const MIC_SOUND_MIN_PEAK = 0.0015;
+
+// Opens the selected mic and listens for any signal so the recorder can warn
+// before starting. Resolves as soon as sound is heard; only a genuinely silent
+// mic waits out the full window. Unlike a popup, the offscreen AudioContext is
+// not blocked by the page autoplay policy, so the analyser actually runs.
+const probeMicrophone = async (
+ microphone: MicrophoneSettings,
+): Promise => {
+ if (!microphone.enabled) return { available: false, hasSound: false };
+
+ let stream: MediaStream;
+ try {
+ stream = await navigator.mediaDevices.getUserMedia({
+ audio: getAudioConstraint(microphone),
+ video: false,
+ });
+ } catch {
+ return { available: false, hasSound: false };
+ }
+
+ const context = new AudioContext();
+ try {
+ if (context.state === "suspended") {
+ await context.resume().catch(() => undefined);
+ }
+ const source = context.createMediaStreamSource(stream);
+ const analyser = context.createAnalyser();
+ analyser.fftSize = 2048;
+ source.connect(analyser);
+ const buffer = new Float32Array(analyser.fftSize);
+
+ const deadline = performance.now() + MIC_PROBE_WINDOW_MS;
+ let peak = 0;
+ while (performance.now() < deadline) {
+ analyser.getFloatTimeDomainData(buffer);
+ for (let i = 0; i < buffer.length; i += 1) {
+ const amplitude = Math.abs(buffer[i]);
+ if (amplitude > peak) peak = amplitude;
+ }
+ if (peak >= MIC_SOUND_MIN_PEAK) break;
+ await new Promise((resolve) => {
+ window.setTimeout(resolve, MIC_PROBE_SAMPLE_INTERVAL_MS);
+ });
+ }
+ return { available: true, hasSound: peak >= MIC_SOUND_MIN_PEAK };
+ } catch {
+ // If the measurement itself fails, do not block the recording with a
+ // false silence warning.
+ return { available: true, hasSound: true };
+ } finally {
+ stopTracks(stream);
+ await context.close().catch(() => undefined);
+ }
+};
+
+const addAudioTracks = ({
+ output,
+ streams,
+ routeFirstStreamToSpeakers,
+}: {
+ output: MediaStream;
+ streams: MediaStream[];
+ routeFirstStreamToSpeakers: boolean;
+}) => {
+ const streamsWithAudio = streams.filter(
+ (stream) => stream.getAudioTracks().length > 0,
+ );
+ if (streamsWithAudio.length === 0) return undefined;
+
+ const audioContext = new AudioContext();
+ const destination = audioContext.createMediaStreamDestination();
+
+ streamsWithAudio.forEach((stream, index) => {
+ const source = audioContext.createMediaStreamSource(stream);
+ source.connect(destination);
+ if (routeFirstStreamToSpeakers && index === 0) {
+ source.connect(audioContext.destination);
+ }
+ });
+
+ for (const track of destination.stream.getAudioTracks()) {
+ output.addTrack(track);
+ }
+
+ return audioContext;
+};
+
+const updateStatusDuration = () => {
+ if (!activeRecording) return;
+ const now = Date.now();
+ const durationMs = getRecordingDuration(activeRecording, now);
+ // Enforce the free-plan cap (the floating bar only displays the
+ // countdown); routing through the service worker mirrors a user-initiated
+ // stop so the upload page opens as usual.
+ if (
+ activeRecording.maxDurationMs !== null &&
+ durationMs >= activeRecording.maxDurationMs &&
+ !activeRecording.finalizePromise
+ ) {
+ stopRecordingFromTrackEnd();
+ }
+ if (status.phase === "recording") {
+ status = {
+ ...status,
+ durationMs,
+ updatedAt: now,
+ };
+ }
+};
+
+const getRecordingDuration = (recording: ActiveRecording, now = Date.now()) =>
+ recording.lastResumedAt === null
+ ? recording.durationMs
+ : recording.durationMs + Math.max(0, now - recording.lastResumedAt);
+
+const cleanupActiveRecording = async (recording: ActiveRecording) => {
+ if (recording.cleanedUp) return;
+ recording.cleanedUp = true;
+ if (recording.statusTimer !== null) {
+ window.clearInterval(recording.statusTimer);
+ }
+ if (recording.dataRequestInterval !== null) {
+ window.clearInterval(recording.dataRequestInterval);
+ }
+ if (recording.chunkStartGuard !== null) {
+ window.clearTimeout(recording.chunkStartGuard);
+ }
+ for (const stream of recording.streams) {
+ stopTracks(stream);
+ }
+ stopTracks(recording.recordingStream);
+ await recording.audioContext?.close().catch(() => undefined);
+};
+
+// Reconcile the IndexedDB recording spool with the failed-recording metadata
+// whenever this document starts: drop metadata whose bytes are gone, record
+// spools stranded by a crash so they stay recoverable from the upload page,
+// and reclaim space from abandoned recordings. Works from session metadata
+// only — materialising every stranded recording's chunks just to take
+// inventory would re-read gigabytes of IndexedDB on each recorder spin-up.
+const sweepOrphanedRecordingSpools = async () => {
+ try {
+ const [sessions, failed, manifests] = await Promise.all([
+ listRecordingSpoolSessions(),
+ loadFailedRecordings(),
+ loadLiveRecordingManifests(),
+ ]);
+ const now = Date.now();
+ const knownSessions = new Set(failed.map((entry) => entry.sessionId));
+ const manifestsBySession = new Map(
+ manifests.map((manifest) => [manifest.sessionId, manifest]),
+ );
+ const remainingSessions = new Set();
+ let entries = [...failed];
+
+ for (const orphan of sessions) {
+ if (activeRecording?.spool.sessionId === orphan.sessionId) {
+ remainingSessions.add(orphan.sessionId);
+ continue;
+ }
+ if (now - orphan.updatedAt < RECORDING_SPOOL_LIVE_MIN_IDLE_MS) {
+ remainingSessions.add(orphan.sessionId);
+ continue;
+ }
+
+ // Idle sessions that never received a chunk hold no recoverable data.
+ if (
+ orphan.chunkCount === 0 ||
+ now - orphan.updatedAt > ORPHAN_SPOOL_MAX_AGE_MS
+ ) {
+ await deleteRecoveredRecordingSpool(orphan.sessionId).catch(
+ () => undefined,
+ );
+ entries = entries.filter(
+ (entry) => entry.sessionId !== orphan.sessionId,
+ );
+ continue;
+ }
+
+ remainingSessions.add(orphan.sessionId);
+ if (!knownSessions.has(orphan.sessionId) && orphan.totalBytes > 0) {
+ // A crash-stranded session whose live manifest survived keeps its
+ // videoId/subpath so the entry stays retryable, not download-only.
+ // The duration is a wall-clock estimate (it includes pauses); it
+ // only feeds the completion metadata on retry.
+ const manifest = manifestsBySession.get(orphan.sessionId);
+ entries.push({
+ sessionId: orphan.sessionId,
+ videoId: manifest?.videoId ?? null,
+ shareUrl: manifest?.shareUrl ?? null,
+ mimeType: orphan.mimeType,
+ subpath: manifest?.subpath ?? null,
+ durationMs: manifest
+ ? Math.max(0, orphan.updatedAt - manifest.startedAt)
+ : 0,
+ width: manifest?.width ?? null,
+ height: manifest?.height ?? null,
+ fps: manifest?.fps ?? null,
+ totalBytes: orphan.totalBytes,
+ createdAt: orphan.updatedAt,
+ message: "The recording was interrupted before its upload finished.",
+ });
+ }
+ }
+
+ const { dropped } = await saveFailedRecordings(
+ entries.filter((entry) => remainingSessions.has(entry.sessionId)),
+ );
+ // Entries pushed out by the metadata cap can never be retried; reclaim
+ // their spooled bytes instead of leaving them for the 14-day sweep.
+ const survivingSessions = new Set(remainingSessions);
+ for (const entry of dropped) {
+ survivingSessions.delete(entry.sessionId);
+ await deleteRecoveredRecordingSpool(entry.sessionId).catch(
+ () => undefined,
+ );
+ }
+ await pruneLiveRecordingManifests(survivingSessions).catch(() => undefined);
+ } catch {
+ // Recovery bookkeeping must never block the recorder from starting.
+ }
+};
+
+function stopRecorderAfterError(recorder: MediaRecorder) {
+ if (recorder.state !== "inactive") {
+ recorder.stop();
+ }
+ window.setTimeout(() => {
+ if (activeRecording?.recorder === recorder) {
+ void stopRecording();
+ }
+ }, 0);
+}
+
+const requestRecorderData = (recording: ActiveRecording) => {
+ if (recording.chunkingMode !== "manual") return;
+ if (recording.recorder.state !== "recording") return;
+ try {
+ recording.recorder.requestData();
+ } catch {}
+};
+
+const stopManualChunking = (recording: ActiveRecording) => {
+ if (recording.dataRequestInterval !== null) {
+ window.clearInterval(recording.dataRequestInterval);
+ recording.dataRequestInterval = null;
+ }
+ if (recording.chunkStartGuard !== null) {
+ window.clearTimeout(recording.chunkStartGuard);
+ recording.chunkStartGuard = null;
+ }
+};
+
+const beginManualChunking = (recording: ActiveRecording) => {
+ recording.chunkingMode = "manual";
+ recording.lastChunkAt = null;
+ stopManualChunking(recording);
+ requestRecorderData(recording);
+ recording.dataRequestInterval = window.setInterval(() => {
+ requestRecorderData(recording);
+ }, RECORDING_TIMESLICE_MS);
+};
+
+const scheduleTimesliceGuard = (recording: ActiveRecording) => {
+ if (recording.chunkStartGuard !== null) {
+ window.clearTimeout(recording.chunkStartGuard);
+ }
+ recording.chunkStartGuard = window.setTimeout(() => {
+ recording.chunkStartGuard = null;
+ if (recording.chunkingMode !== "timeslice") return;
+ if (recording.lastChunkAt !== null) return;
+ beginManualChunking(recording);
+ }, RECORDING_TIMESLICE_GUARD_MS);
+};
+
+const stopRecordingFromTrackEnd = () => {
+ const recording = activeRecording;
+ if (!recording || recording.finalizePromise) return;
+ chrome.runtime.sendMessage(
+ { target: "service-worker", type: "stop-recording" },
+ () => {
+ if (!chrome.runtime.lastError) return;
+ void stopRecording();
+ },
+ );
+};
+
+const startRecording = async (request: StartRecordingRequest) => {
+ if (activeRecording || startInProgress || retryInProgress) {
+ // Thrown before this attempt owns anything, so the cleanup below must
+ // never run for it: a duplicate start would otherwise tear down — and
+ // delete server-side — the live recording it was rejected to protect.
+ throw new Error("Recording is already active");
+ }
+ startInProgress = true;
+ startCancelRequested = false;
+
+ // Everything acquired by THIS attempt. The failure path releases exactly
+ // these; module state like activeRecording is only touched when this
+ // attempt is the one that set it.
+ const ownedStreams: MediaStream[] = [];
+ let ownedVideoId: string | null = null;
+ let ownedSpool: RecordingSpool | null = null;
+ let ownedRecording: ActiveRecording | null = null;
+
+ try {
+ status = { phase: "creating" };
+
+ const mainStream = await getMainStream(request);
+ ownedStreams.push(mainStream);
+ throwIfStartCanceled();
+ const captureSource = getCaptureSource(request, mainStream);
+ if (captureSource) {
+ broadcastCaptureSource(captureSource);
+ }
+ const microphoneStream = await getMicrophoneStream(
+ request.settings.microphone,
+ request.mode,
+ );
+ if (microphoneStream) {
+ ownedStreams.push(microphoneStream);
+ }
+ throwIfStartCanceled();
+ const { width, height, fps } = getStreamSize(mainStream);
+ const videoTracks = mainStream.getVideoTracks();
+ if (videoTracks.length === 0) {
+ throw new Error("No video track was captured");
+ }
+ const recordingStream = new MediaStream(videoTracks);
+ const streams = microphoneStream
+ ? [mainStream, microphoneStream]
+ : [mainStream];
+ const audioContext = addAudioTracks({
+ output: recordingStream,
+ streams,
+ routeFirstStreamToSpeakers: request.mode === "tab",
+ });
+ const hasAudio = recordingStream.getAudioTracks().length > 0;
+ const pipeline = selectRecordingPipeline(hasAudio);
+ if (!pipeline) throw new Error("No supported recorder format is available");
+
+ const { videoCodec, audioCodec } = describeRecordingCodecs(
+ pipeline.mimeType,
+ hasAudio,
+ );
+ const creation = await createInstantRecording({
+ settings: request.settings,
+ auth: request.auth,
+ input: {
+ orgId: request.bootstrap.organization.id,
+ folderId: undefined,
+ resolution: `${width}x${height}`,
+ width,
+ height,
+ videoCodec,
+ audioCodec,
+ supportsUploadProgress: true,
+ },
+ });
+ ownedVideoId = creation.id;
+ throwIfStartCanceled();
+ const subpath = `raw-upload.${pipeline.fileExtension}`;
+ const api = {
+ baseUrl: request.settings.apiBaseUrl,
+ authToken: request.auth.authApiKey,
+ requestTimeoutMs: DEFAULT_API_REQUEST_TIMEOUT_MS,
+ };
+ const uploadSession = await initiateMultipartUpload({
+ videoId: creation.id,
+ contentType: pipeline.mimeType,
+ subpath,
+ api,
+ });
+ throwIfStartCanceled();
+ const spool = await RecordingSpool.create({ mimeType: pipeline.mimeType });
+ ownedSpool = spool;
+ throwIfStartCanceled();
+ const uploader = new InstantRecordingUploader({
+ videoId: creation.id,
+ uploadId: uploadSession.uploadId,
+ provider: uploadSession.provider,
+ mimeType: pipeline.mimeType,
+ subpath,
+ api,
+ setUploadStatus: (uploadStatus) => {
+ if (
+ status.phase === "recording" ||
+ status.phase === "paused" ||
+ status.phase === "uploading"
+ ) {
+ status = { ...status, uploadStatus };
+ broadcastProgressThrottled();
+ }
+ },
+ sendProgressUpdate: (uploaded, total) =>
+ updateUploadProgress({
+ settings: request.settings,
+ auth: request.auth,
+ videoId: creation.id,
+ uploaded,
+ total,
+ }).then(() => undefined),
+ onChunkStateChange: (nextChunks) => {
+ if (
+ status.phase === "recording" ||
+ status.phase === "paused" ||
+ status.phase === "uploading"
+ ) {
+ status = { ...status, upload: summarizeChunks(nextChunks) };
+ broadcastProgressThrottled();
+ }
+ },
+ // Deliberately no onOverflow handler: when uploads fall 128MB behind
+ // (MAX_PENDING_UPLOAD_BYTES) the recording is stopped via
+ // onFatalError instead of degrading like the dashboard recorder.
+ // Every byte is already double-written to the IndexedDB spool, so
+ // the user keeps retry/download, and capping the buffer matters
+ // more in an offscreen document the browser can't page out.
+ onFatalError: (error) => {
+ status = {
+ phase: "error",
+ message: error.message,
+ videoId: creation.id,
+ };
+ broadcastStatus();
+ if (activeRecording?.recorder) {
+ stopRecorderAfterError(activeRecording.recorder);
+ }
+ },
+ });
+
+ const recorder = new MediaRecorder(recordingStream, {
+ mimeType: pipeline.mimeType,
+ });
+
+ // Pre-roll countdown is the last step before capture: the picker and every
+ // server round-trip are already done, so recording begins the moment the
+ // count ends. `startedAt` is read afterwards so the countdown is excluded
+ // from the recording duration.
+ await runStartCountdown(request);
+ throwIfStartCanceled();
+
+ const startedAt = Date.now();
+ const plan = request.bootstrap.plan;
+ const maxDurationMs =
+ !plan.isPro && plan.maxRecordingSeconds !== null
+ ? plan.maxRecordingSeconds * 1000
+ : null;
+
+ const recording: ActiveRecording = {
+ recorder,
+ stopPromise: Promise.resolve(),
+ streams,
+ recordingStream,
+ statusTimer: null,
+ spool,
+ uploader,
+ startedAt,
+ durationMs: 0,
+ lastResumedAt: startedAt,
+ videoId: creation.id,
+ shareUrl: creation.shareUrl,
+ width,
+ height,
+ fps,
+ subpath,
+ mimeType: pipeline.mimeType,
+ maxDurationMs,
+ audioContext,
+ chunkChain: Promise.resolve(),
+ dataRequestInterval: null,
+ chunkStartGuard: null,
+ chunkingMode: null,
+ lastChunkAt: null,
+ recordedBytes: 0,
+ finalizePromise: null,
+ cleanedUp: false,
+ spoolFailed: false,
+ memoryBackup: initialLocalRecordingState(),
+ };
+
+ ownedRecording = recording;
+ activeRecording = recording;
+ for (const track of mainStream.getVideoTracks()) {
+ track.addEventListener("ended", stopRecordingFromTrackEnd, {
+ once: true,
+ });
+ }
+ // A crash from here on strands the spool; persisting the recording's
+ // identity alongside it lets the startup sweep surface a retryable
+ // failed-recording entry (videoId, subpath) instead of download-only.
+ await saveLiveRecordingManifest({
+ sessionId: spool.sessionId,
+ videoId: creation.id,
+ shareUrl: creation.shareUrl,
+ mimeType: pipeline.mimeType,
+ subpath,
+ width,
+ height,
+ fps,
+ startedAt,
+ }).catch(() => undefined);
+ recording.statusTimer = window.setInterval(updateStatusDuration, 1000);
+ recording.stopPromise = new Promise((resolve, reject) => {
+ recorder.onstop = () => resolve();
+ recorder.onerror = () => reject(new Error("MediaRecorder failed"));
+ });
+ // A mid-recording recorder failure must stop the session right away;
+ // without this the rejection sits unhandled while the timer keeps
+ // ticking over a recorder that no longer produces chunks, and nothing
+ // surfaces until the user stops manually.
+ recording.stopPromise.catch(() => {
+ if (activeRecording !== recording || recording.finalizePromise) return;
+ status = {
+ phase: "error",
+ message: "Recording failed: the recorder stopped unexpectedly",
+ videoId: creation.id,
+ };
+ broadcastStatus();
+ stopRecorderAfterError(recorder);
+ });
+ recorder.ondataavailable = (event) => {
+ if (event.data.size === 0) return;
+ recording.lastChunkAt =
+ typeof performance !== "undefined" ? performance.now() : Date.now();
+ if (
+ recording.chunkingMode === "timeslice" &&
+ recording.chunkStartGuard !== null
+ ) {
+ window.clearTimeout(recording.chunkStartGuard);
+ recording.chunkStartGuard = null;
+ }
+ recording.recordedBytes += event.data.size;
+ const recordedBytes = recording.recordedBytes;
+ recording.chunkChain = recording.chunkChain.then(async () => {
+ if (recording.spoolFailed) {
+ appendMemoryBackupChunk(recording, event.data);
+ return;
+ }
+ try {
+ await spool.appendChunk(event.data);
+ } catch (error) {
+ // The local crash-recovery copy degrades to memory; the
+ // streaming upload still has every byte, so erroring the whole
+ // session here would throw away a healthy recording. The failed
+ // chunk is deliberately NOT added to the memory backup: the
+ // spool keeps it in its pending buffer and recoverBlob() returns
+ // it, so appending it here too would duplicate its bytes
+ // mid-file in every recovered blob.
+ recording.spoolFailed = true;
+ console.warn(
+ "Recording spool failed; keeping the local backup in memory",
+ error,
+ );
+ }
+ });
+ try {
+ uploader.handleChunk(event.data, recordedBytes);
+ } catch (error) {
+ status = {
+ phase: "error",
+ message: error instanceof Error ? error.message : String(error),
+ videoId: creation.id,
+ };
+ stopRecorderAfterError(recorder);
+ }
+ };
+
+ status = {
+ phase: "recording",
+ videoId: creation.id,
+ startedAt,
+ durationMs: 0,
+ updatedAt: startedAt,
+ };
+
+ try {
+ recorder.start(RECORDING_TIMESLICE_MS);
+ recording.chunkingMode = "timeslice";
+ scheduleTimesliceGuard(recording);
+ } catch {
+ recorder.start();
+ beginManualChunking(recording);
+ }
+ playRecordingSound("start-recording", request.settings);
+ // The service worker that sent start-recording may have been killed
+ // while the capture picker was open, which destroys the response
+ // channel. The broadcast wakes it (or its replacement) so the badge and
+ // the floating recording bar still update.
+ broadcastStatus();
+ return status;
+ } catch (error) {
+ // Release only what this attempt acquired. No chunk can have been
+ // captured yet — chunks only flow once recorder.start() succeeds, after
+ // which nothing here throws — so the spool holds no recoverable data.
+ for (const stream of ownedStreams) {
+ stopTracks(stream);
+ }
+ if (ownedVideoId) {
+ await deleteInstantRecording(
+ request.settings,
+ request.auth,
+ ownedVideoId,
+ ).catch(() => undefined);
+ }
+ if (ownedRecording) {
+ if (activeRecording === ownedRecording) {
+ activeRecording = null;
+ }
+ await cleanupActiveRecording(ownedRecording);
+ }
+ if (ownedSpool) {
+ await removeLiveRecordingManifest(ownedSpool.sessionId).catch(
+ () => undefined,
+ );
+ await ownedSpool.dispose().catch(() => undefined);
+ }
+ // Reset the "creating" status so later status syncs do not report a
+ // phantom in-progress recording.
+ if (status.phase === "creating") {
+ status = isUserCancellationError(error)
+ ? { phase: "idle" }
+ : {
+ phase: "error",
+ message: error instanceof Error ? error.message : String(error),
+ };
+ }
+ throw error;
+ } finally {
+ startInProgress = false;
+ }
+};
+
+const appendMemoryBackupChunk = (recording: ActiveRecording, chunk: Blob) => {
+ const previous = recording.memoryBackup;
+ recording.memoryBackup = appendLocalRecordingChunk(previous, chunk, {
+ mode: "capped",
+ maxBytes: MEMORY_BACKUP_MAX_BYTES,
+ });
+ if (recording.memoryBackup.overflowed && !previous.overflowed) {
+ console.warn(
+ "In-memory recording backup exceeded its cap; dropping the local copy (the streaming upload still has every byte)",
+ );
+ }
+};
+
+// The complete recording: the spool when it stayed healthy, otherwise the
+// spooled prefix plus the in-memory continuation. Null once the capped
+// memory backup has overflowed — a truncated local copy must not masquerade
+// as the complete recording.
+const recoverRecordingBlob = async (recording: ActiveRecording) => {
+ if (!recording.spoolFailed) {
+ return recording.spool.recoverBlob();
+ }
+ if (recording.memoryBackup.overflowed) return null;
+ const spooledBlob = await recording.spool.recoverBlob().catch(() => null);
+ const parts = spooledBlob
+ ? [spooledBlob, ...recording.memoryBackup.chunks]
+ : recording.memoryBackup.chunks;
+ if (parts.length === 0) return null;
+ return new Blob(parts, { type: recording.mimeType });
+};
+
+// Returns whether the recording bytes are persisted and retryable from the
+// upload page.
+const rememberFailedRecording = async (
+ recording: ActiveRecording,
+ error: unknown,
+): Promise => {
+ if (recording.recordedBytes === 0) return false;
+
+ let sessionId = recording.spool.sessionId;
+ if (recording.spoolFailed) {
+ // The original spool is missing the tail that went to memory; a retry
+ // reading it would upload a truncated file. Persist the full recording
+ // into a fresh spool once, and if that also fails (quota), drop the
+ // partial spool so the entry is never offered as retryable.
+ const fullBlob = await recoverRecordingBlob(recording).catch(() => null);
+ const replacement =
+ fullBlob && fullBlob.size >= recording.recordedBytes
+ ? await RecordingSpool.create({
+ mimeType: recording.mimeType,
+ // One awaited write of the whole blob; the default budget
+ // guards live capture and would reject any blob over it.
+ maxPendingChunkBytes: fullBlob.size,
+ })
+ .then(async (spool) => {
+ await spool.appendChunk(fullBlob);
+ await spool.flush();
+ return spool;
+ })
+ .catch(() => null)
+ : null;
+ await recording.spool.dispose().catch(() => undefined);
+ if (!replacement) return false;
+ sessionId = replacement.sessionId;
+ }
+
+ const saved = await upsertFailedRecording({
+ sessionId,
+ videoId: recording.videoId,
+ shareUrl: recording.shareUrl,
+ mimeType: recording.mimeType,
+ subpath: recording.subpath,
+ durationMs: recording.durationMs,
+ width: recording.width,
+ height: recording.height,
+ fps: recording.fps,
+ totalBytes: recording.recordedBytes,
+ createdAt: Date.now(),
+ message: error instanceof Error ? error.message : String(error),
+ }).catch(() => null);
+ if (!saved) return false;
+
+ // Entries pushed out by the metadata cap can no longer be retried;
+ // reclaim their spooled bytes right away.
+ for (const dropped of saved.dropped) {
+ await deleteRecoveredRecordingSpool(dropped.sessionId).catch(
+ () => undefined,
+ );
+ }
+
+ return saved.kept.some((entry) => entry.sessionId === sessionId);
+};
+
+const finalizeRecording = async (recording: ActiveRecording) => {
+ try {
+ await recording.stopPromise;
+ await cleanupActiveRecording(recording);
+ await recording.chunkChain;
+ // finalBlob is null when the capped memory backup overflowed. The
+ // streamed parts still carry every byte; the uploader just falls back
+ // to its recorded-bytes counter instead of the local blob's size.
+ const finalBlob = await recoverRecordingBlob(recording);
+ if ((!finalBlob || finalBlob.size === 0) && recording.recordedBytes === 0) {
+ throw new Error("No recording data was captured");
+ }
+ await recording.uploader.finalize({
+ finalBlob: finalBlob && finalBlob.size > 0 ? finalBlob : null,
+ durationSeconds: Math.max(1, Math.round(recording.durationMs / 1000)),
+ width: recording.width,
+ height: recording.height,
+ fps: recording.fps,
+ subpath: recording.subpath,
+ });
+ await recording.spool.dispose();
+ await removeFailedRecording(recording.spool.sessionId).catch(
+ () => undefined,
+ );
+ await removeLiveRecordingManifest(recording.spool.sessionId).catch(
+ () => undefined,
+ );
+ status = {
+ phase: "completed",
+ videoId: recording.videoId,
+ shareUrl: recording.shareUrl,
+ };
+ broadcastStatus();
+ return status;
+ } catch (error) {
+ if (error instanceof MultipartCompletionUncertainError) {
+ // The parts are uploaded but the completion call never got a
+ // definitive answer (it is retried internally first). If it never
+ // reached the server the S3 object was never assembled, so the
+ // spooled bytes are the only remaining copy — keep them and the
+ // failed-recording entry so the user can verify via the share link
+ // and then retry or download (mirrors the dashboard recorder).
+ const recoverable = await rememberFailedRecording(
+ recording,
+ new Error(UNCERTAIN_COMPLETION_MESSAGE),
+ );
+ status = {
+ phase: "error",
+ message: UNCERTAIN_COMPLETION_MESSAGE,
+ videoId: recording.videoId,
+ shareUrl: recording.shareUrl,
+ recoverable,
+ };
+ broadcastStatus();
+ return status;
+ }
+ // Keep the spooled bytes and remember the recording so the upload page
+ // can retry the upload or download the file; disposing here would lose
+ // the captured data forever.
+ const recoverable = await rememberFailedRecording(recording, error);
+ status = {
+ phase: "error",
+ message: error instanceof Error ? error.message : String(error),
+ videoId: recording.videoId,
+ recoverable,
+ };
+ broadcastStatus();
+ return status;
+ } finally {
+ if (activeRecording === recording) {
+ activeRecording = null;
+ }
+ await cleanupActiveRecording(recording);
+ }
+};
+
+const getCurrentUploadSnapshot = () =>
+ status.phase === "recording" ||
+ status.phase === "paused" ||
+ status.phase === "uploading"
+ ? {
+ upload: status.upload,
+ uploadStatus: status.uploadStatus,
+ }
+ : {
+ upload: undefined,
+ uploadStatus: undefined,
+ };
+
+async function stopRecording() {
+ // A stop during the pre-roll countdown cancels the start before any frame is
+ // captured. Resolving the countdown wait lets startRecording's
+ // throwIfStartCanceled tear down the half-built session (streams, server
+ // recording, spool) instead of finalizing an empty recording.
+ if (countdownInProgress) {
+ startCancelRequested = true;
+ countdownResolve?.();
+ return status;
+ }
+
+ const recording = activeRecording;
+ if (!recording) {
+ // A stop while the start sequence is still running (capture picker
+ // open, server round-trips pending) aborts that start; the start call
+ // itself resolves as a cancellation.
+ if (startInProgress) {
+ startCancelRequested = true;
+ }
+ return status;
+ }
+
+ stopManualChunking(recording);
+ const now = Date.now();
+ recording.durationMs = getRecordingDuration(recording, now);
+ recording.lastResumedAt = null;
+
+ if (recording.recorder.state !== "inactive") {
+ recording.recorder.stop();
+ }
+
+ if (status.phase !== "error") {
+ const snapshot = getCurrentUploadSnapshot();
+ status = {
+ phase: "uploading",
+ videoId: recording.videoId,
+ startedAt: recording.startedAt,
+ durationMs: recording.durationMs,
+ updatedAt: now,
+ upload: snapshot.upload,
+ uploadStatus: snapshot.uploadStatus,
+ };
+ broadcastStatus();
+ void loadSettings()
+ .then((settings) => playRecordingSound("stop-recording", settings))
+ .catch(() => undefined);
+ }
+
+ if (!recording.finalizePromise) {
+ recording.finalizePromise = finalizeRecording(recording);
+ void recording.finalizePromise.catch(() => undefined);
+ }
+
+ return status;
+}
+
+const pauseRecording = () => {
+ const recording = activeRecording;
+ if (!recording || recording.recorder.state !== "recording") {
+ return status;
+ }
+ const now = Date.now();
+ recording.durationMs = getRecordingDuration(recording, now);
+ recording.lastResumedAt = null;
+ recording.recorder.pause();
+ if (status.phase === "recording") {
+ status = {
+ ...status,
+ phase: "paused",
+ durationMs: recording.durationMs,
+ updatedAt: now,
+ };
+ }
+ return status;
+};
+
+const resumeRecording = () => {
+ const recording = activeRecording;
+ if (!recording || recording.recorder.state !== "paused") {
+ return status;
+ }
+ const now = Date.now();
+ recording.recorder.resume();
+ recording.lastResumedAt = now;
+ if (status.phase === "paused") {
+ status = {
+ ...status,
+ phase: "recording",
+ durationMs: recording.durationMs,
+ updatedAt: now,
+ };
+ }
+ return status;
+};
+
+// Re-upload a recording whose bytes are still spooled in IndexedDB after a
+// failed upload. A fresh multipart session is started for the same video and
+// the whole blob is re-sent.
+const retryFailedUpload = async (videoId: string): Promise => {
+ if (activeRecording || startInProgress || retryInProgress) {
+ throw new Error("A recording is already in progress");
+ }
+ retryInProgress = true;
+ try {
+ return await runFailedUploadRetry(videoId);
+ } finally {
+ retryInProgress = false;
+ }
+};
+
+const runFailedUploadRetry = async (
+ videoId: string,
+): Promise => {
+ const failed = (await loadFailedRecordings()).find(
+ (entry) => entry.videoId === videoId,
+ );
+ if (!failed?.videoId) {
+ throw new Error("This recording is no longer available to retry.");
+ }
+
+ const orphan = await recoverRecordingSpoolSession(failed.sessionId);
+ if (!orphan || orphan.blob.size === 0) {
+ await removeFailedRecording(failed.sessionId).catch(() => undefined);
+ throw new Error("The recorded data is no longer available.");
+ }
+
+ const [settings, auth] = await Promise.all([loadSettings(), loadAuth()]);
+ if (!auth) {
+ throw new Error("Sign in to Cap to retry this upload.");
+ }
+
+ const typedVideoId = failed.videoId as VideoId;
+ const shareUrl =
+ failed.shareUrl ??
+ new URL(`/s/${failed.videoId}`, settings.apiBaseUrl).toString();
+ const subpath =
+ failed.subpath ??
+ `raw-upload.${failed.mimeType.includes("webm") ? "webm" : "mp4"}`;
+ const api = {
+ baseUrl: settings.apiBaseUrl,
+ authToken: auth.authApiKey,
+ requestTimeoutMs: DEFAULT_API_REQUEST_TIMEOUT_MS,
+ };
+
+ status = {
+ phase: "uploading",
+ videoId: typedVideoId,
+ startedAt: failed.createdAt,
+ durationMs: failed.durationMs,
+ updatedAt: Date.now(),
+ };
+ broadcastStatus();
+
+ // Defense in depth on top of the retryInProgress lock: status writes only
+ // land while this retry's "uploading" status is still the live one, so a
+ // path that slips past the lock can never repaint another session.
+ const retryOwnsStatus = () =>
+ status.phase === "uploading" && status.videoId === typedVideoId;
+
+ const setRetryStatus = (nextStatus: RecordingStatus) => {
+ if (!retryOwnsStatus()) return;
+ status = nextStatus;
+ broadcastStatus();
+ };
+
+ try {
+ const uploadSession = await initiateMultipartUpload({
+ videoId: typedVideoId,
+ contentType: failed.mimeType,
+ subpath,
+ api,
+ });
+ const uploader = new InstantRecordingUploader({
+ videoId: typedVideoId,
+ uploadId: uploadSession.uploadId,
+ provider: uploadSession.provider,
+ mimeType: failed.mimeType,
+ subpath,
+ api,
+ setUploadStatus: (uploadStatus) => {
+ if (retryOwnsStatus() && status.phase === "uploading") {
+ status = { ...status, uploadStatus };
+ broadcastProgressThrottled();
+ }
+ },
+ sendProgressUpdate: (uploaded, total) =>
+ updateUploadProgress({
+ settings,
+ auth,
+ videoId: typedVideoId,
+ uploaded,
+ total,
+ }).then(() => undefined),
+ onChunkStateChange: (nextChunks) => {
+ if (retryOwnsStatus() && status.phase === "uploading") {
+ status = { ...status, upload: summarizeChunks(nextChunks) };
+ broadcastProgressThrottled();
+ }
+ },
+ });
+
+ await uploader.finalize({
+ finalBlob: orphan.blob,
+ durationSeconds: Math.max(1, Math.round(failed.durationMs / 1000)),
+ width: failed.width ?? undefined,
+ height: failed.height ?? undefined,
+ fps: failed.fps ?? undefined,
+ subpath,
+ });
+ await deleteRecoveredRecordingSpool(failed.sessionId).catch(
+ () => undefined,
+ );
+ await removeFailedRecording(failed.sessionId).catch(() => undefined);
+ setRetryStatus({
+ phase: "completed",
+ videoId: typedVideoId,
+ shareUrl,
+ });
+ return status;
+ } catch (error) {
+ if (error instanceof MultipartCompletionUncertainError) {
+ // Keep the spooled bytes and the entry: if the retried completion
+ // never reached the server, this is still the only copy. The user
+ // can verify via the share link, then retry again or download.
+ await upsertFailedRecording({
+ ...failed,
+ message: UNCERTAIN_COMPLETION_MESSAGE,
+ }).catch(() => undefined);
+ setRetryStatus({
+ phase: "error",
+ message: UNCERTAIN_COMPLETION_MESSAGE,
+ videoId: typedVideoId,
+ shareUrl,
+ recoverable: true,
+ });
+ return status;
+ }
+ setRetryStatus({
+ phase: "error",
+ message: error instanceof Error ? error.message : String(error),
+ videoId: typedVideoId,
+ recoverable: true,
+ });
+ return status;
+ }
+};
+
+// This document is a top-level extension page, so once the extension origin
+// holds the camera/mic grant (the same grant that lets recording getUserMedia
+// run here without a prompt) enumerateDevices() returns full labels — unlike
+// the recorder panel, which Chrome treats as a cross-origin iframe and strips
+// device labels from.
+const enumerateMediaDevices = async () => {
+ const devices = await navigator.mediaDevices.enumerateDevices();
+ return {
+ cameras: toCameraDevices(devices),
+ microphones: toMicrophoneDevices(devices),
+ };
+};
+
+const connectCameraPreview = async (request: ConnectCameraPreviewRequest) => {
+ disconnectCameraPreview(request.sessionId);
+ const stream = await getCameraPreviewStream(request.settings);
+ const peer = new RTCPeerConnection();
+ cameraPreviewSessions.set(request.sessionId, peer);
+
+ try {
+ peer.addEventListener("connectionstatechange", () => {
+ if (
+ peer.connectionState === "closed" ||
+ peer.connectionState === "disconnected" ||
+ peer.connectionState === "failed"
+ ) {
+ disconnectCameraPreview(request.sessionId);
+ }
+ });
+
+ await peer.setRemoteDescription(request.offer);
+ for (const track of stream.getVideoTracks()) {
+ peer.addTrack(track, stream);
+ }
+ await peer.setLocalDescription(await peer.createAnswer());
+ await waitForIceGatheringComplete(peer);
+
+ return toSessionDescriptionInit(peer.localDescription);
+ } catch (error) {
+ disconnectCameraPreview(request.sessionId);
+ throw error;
+ }
+};
+
+const handleRequest = async (
+ message: OffscreenRequest,
+): Promise => {
+ if (message.type === "start-recording") {
+ const nextStatus = await startRecording(message);
+ return { ok: true, status: nextStatus };
+ }
+
+ if (message.type === "stop-recording") {
+ const nextStatus = await stopRecording();
+ return { ok: true, status: nextStatus };
+ }
+
+ if (message.type === "pause-recording") {
+ return { ok: true, status: pauseRecording() };
+ }
+
+ if (message.type === "resume-recording") {
+ return { ok: true, status: resumeRecording() };
+ }
+
+ if (message.type === "connect-camera-preview") {
+ return { ok: true, answer: await connectCameraPreview(message) };
+ }
+
+ if (message.type === "disconnect-camera-preview") {
+ disconnectCameraPreview(message.sessionId);
+ return { ok: true, status };
+ }
+
+ if (message.type === "disconnect-camera-previews") {
+ disconnectCameraPreviews();
+ return { ok: true, status };
+ }
+
+ if (message.type === "acknowledge-error") {
+ if (status.phase === "error") {
+ status = { phase: "idle" };
+ }
+ return { ok: true, status };
+ }
+
+ if (message.type === "retry-upload") {
+ return { ok: true, status: await retryFailedUpload(message.videoId) };
+ }
+
+ if (message.type === "enumerate-devices") {
+ return { ok: true, devices: await enumerateMediaDevices() };
+ }
+
+ if (message.type === "probe-microphone") {
+ return { ok: true, micProbe: await probeMicrophone(message.microphone) };
+ }
+
+ return { ok: true, status };
+};
+
+chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
+ if (!isOffscreenRequest(message)) return false;
+
+ handleRequest(message)
+ .then(sendResponse)
+ .catch((error: unknown) => {
+ // Failure cleanup is owned by each handler (startRecording releases
+ // exactly the resources its own attempt acquired); this layer only
+ // formats the rejection. Tearing anything down here would let an
+ // unrelated failed message — a duplicate start rejected by the
+ // already-active guard, a camera-preview error — destroy a live
+ // recording's streams or delete its video server-side.
+ sendResponse({
+ ok: false,
+ error: error instanceof Error ? error.message : String(error),
+ canceled: isUserCancellationError(error),
+ } satisfies OffscreenResponse);
+ });
+
+ return true;
+});
+
+void sweepOrphanedRecordingSpools();
+
+// After the first recording this document would otherwise live for the rest
+// of the browser session, fielding a status round trip on every tab switch.
+// Close it once nothing here is live — the service worker recreates it on
+// demand and serves its own status mirror while it is gone. Error statuses
+// keep the document open so the upload page's retry context stays live.
+const IDLE_CLOSE_CHECK_INTERVAL_MS = 60 * 1000;
+
+const canCloseIdleDocument = () =>
+ !activeRecording &&
+ !startInProgress &&
+ !retryInProgress &&
+ cameraPreviewSessions.size === 0 &&
+ cameraPreviewStream === null &&
+ activeRecordingSounds.size === 0 &&
+ (status.phase === "idle" || status.phase === "completed");
+
+window.setInterval(() => {
+ if (canCloseIdleDocument()) {
+ window.close();
+ }
+}, IDLE_CLOSE_CHECK_INTERVAL_MS);
diff --git a/apps/chrome-extension/src/options/main.tsx b/apps/chrome-extension/src/options/main.tsx
new file mode 100644
index 00000000000..05a5d1ad081
--- /dev/null
+++ b/apps/chrome-extension/src/options/main.tsx
@@ -0,0 +1,652 @@
+import {
+ deleteRecoveredRecordingSpool,
+ recoverOrphanedRecordingSpools,
+} from "@cap/recorder-core";
+import { useCallback, useEffect, useState } from "react";
+import { createRoot } from "react-dom/client";
+import { CapBrand, DoodleBoilFilter } from "../shared/cap-brand";
+import { formatRecordedDuration } from "../shared/format-duration";
+import { mountPageNav } from "../shared/page-nav";
+import { sendServiceWorkerMessage } from "../shared/runtime";
+import {
+ clearAuth,
+ defaultSettings,
+ FAILED_RECORDINGS_KEY,
+ type FailedRecording,
+ loadAuth,
+ loadFailedRecordings,
+ loadSettings,
+ loadSharedRecordingState,
+ removeFailedRecording,
+ saveSettings,
+} from "../shared/storage";
+import type { ExtensionAuth, ExtensionSettings } from "../shared/types";
+import "./styles.css";
+
+mountPageNav("options");
+
+// Spool sessions younger than this may still belong to a recording that is
+// live in the offscreen document (mirrors the offscreen sweep's guard).
+const SPOOL_MIN_IDLE_MS = 60 * 1000;
+
+const formatBytes = (bytes: number) => {
+ if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
+ if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
+ if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`;
+ return `${Math.max(0, Math.round(bytes))} B`;
+};
+
+const fileExtensionForMimeType = (mimeType: string) =>
+ mimeType.includes("webm") ? "webm" : "mp4";
+
+const isActiveRecordingPhase = (phase: string | undefined) =>
+ phase === "creating" ||
+ phase === "recording" ||
+ phase === "paused" ||
+ phase === "uploading";
+
+// Every extension page shares the extension-origin IndexedDB with the
+// offscreen recorder, so this page reads the recording spool directly. The
+// listing pairs the failed-recording metadata with the spools that still
+// hold bytes, and also surfaces spools stranded by a crash before the
+// offscreen sweep could record them (those have no metadata yet).
+const loadRecoveredRecordings = async (): Promise => {
+ const [failed, spools, recordingState] = await Promise.all([
+ loadFailedRecordings(),
+ recoverOrphanedRecordingSpools(),
+ loadSharedRecordingState().catch(() => null),
+ ]);
+ const spoolSessions = new Set(spools.map((spool) => spool.sessionId));
+ const knownSessions = new Set(failed.map((entry) => entry.sessionId));
+ const entries = failed.filter((entry) => spoolSessions.has(entry.sessionId));
+
+ // Skip unknown spools while a recording is live anywhere: an in-flight
+ // recording's spool is indistinguishable from a stranded one from here.
+ if (!isActiveRecordingPhase(recordingState?.status.phase)) {
+ const now = Date.now();
+ for (const spool of spools) {
+ if (knownSessions.has(spool.sessionId)) continue;
+ if (spool.totalBytes <= 0) continue;
+ if (now - spool.updatedAt < SPOOL_MIN_IDLE_MS) continue;
+ entries.push({
+ sessionId: spool.sessionId,
+ videoId: null,
+ shareUrl: null,
+ mimeType: spool.mimeType,
+ subpath: null,
+ durationMs: 0,
+ width: null,
+ height: null,
+ fps: null,
+ totalBytes: spool.totalBytes,
+ createdAt: spool.updatedAt,
+ message: "The recording was interrupted before its upload finished.",
+ });
+ }
+ }
+
+ return entries.sort((left, right) => right.createdAt - left.createdAt);
+};
+
+type RecoveryNotice = {
+ kind: "success" | "error";
+ message: string;
+ shareUrl?: string;
+};
+
+function RecoveredRecordingsSection() {
+ const [entries, setEntries] = useState([]);
+ const [busySession, setBusySession] = useState(null);
+ const [retryingSession, setRetryingSession] = useState(null);
+ const [notice, setNotice] = useState(null);
+
+ const refresh = useCallback(() => {
+ loadRecoveredRecordings()
+ .then(setEntries)
+ .catch(() => setEntries([]));
+ }, []);
+
+ useEffect(() => {
+ refresh();
+ const handleStorageChange = (
+ changes: Record,
+ areaName: string,
+ ) => {
+ if (areaName !== "local" || !changes[FAILED_RECORDINGS_KEY]) return;
+ refresh();
+ };
+ chrome.storage.onChanged.addListener(handleStorageChange);
+ return () => chrome.storage.onChanged.removeListener(handleStorageChange);
+ }, [refresh]);
+
+ const runAction = async (
+ sessionId: string,
+ task: () => Promise,
+ ) => {
+ if (busySession !== null) return;
+ setBusySession(sessionId);
+ setNotice(null);
+ try {
+ setNotice(await task());
+ } catch (err) {
+ setNotice({
+ kind: "error",
+ message: err instanceof Error ? err.message : String(err),
+ });
+ } finally {
+ setBusySession(null);
+ setRetryingSession(null);
+ }
+ };
+
+ const download = (entry: FailedRecording) =>
+ runAction(entry.sessionId, async () => {
+ const spools = await recoverOrphanedRecordingSpools();
+ const spool = spools.find(
+ (candidate) => candidate.sessionId === entry.sessionId,
+ );
+ if (!spool || spool.blob.size === 0) {
+ refresh();
+ throw new Error("The recorded data is no longer available.");
+ }
+ const url = URL.createObjectURL(spool.blob);
+ const anchor = document.createElement("a");
+ anchor.href = url;
+ anchor.download = `cap-recording-${
+ entry.videoId ??
+ new Date(entry.createdAt).toISOString().replace(/[:.]/g, "-")
+ }.${fileExtensionForMimeType(entry.mimeType)}`;
+ anchor.click();
+ window.setTimeout(() => URL.revokeObjectURL(url), 10_000);
+ return null;
+ });
+
+ const remove = (entry: FailedRecording) =>
+ runAction(entry.sessionId, async () => {
+ await deleteRecoveredRecordingSpool(entry.sessionId);
+ await removeFailedRecording(entry.sessionId).catch(() => undefined);
+ refresh();
+ return null;
+ });
+
+ const retry = (entry: FailedRecording) => {
+ const videoId = entry.videoId;
+ if (!videoId) return;
+ setRetryingSession(entry.sessionId);
+ void runAction(entry.sessionId, async () => {
+ // The offscreen recorder re-uploads the whole spooled blob before this
+ // resolves, so the button stays in its uploading state until then.
+ const response = await sendServiceWorkerMessage({
+ target: "service-worker",
+ type: "retry-upload",
+ videoId,
+ });
+ refresh();
+ if (!response.ok) throw new Error(response.error);
+ if (response.status?.phase === "error") {
+ throw new Error(response.status.message);
+ }
+ return {
+ kind: "success",
+ message: "Upload finished.",
+ shareUrl:
+ response.status?.phase === "completed"
+ ? response.status.shareUrl
+ : (entry.shareUrl ?? undefined),
+ };
+ });
+ };
+
+ if (entries.length === 0 && !notice) return null;
+
+ return (
+
+ Recovered recordings
+
+ These recordings never finished uploading. Their captured data is still
+ on this device, so you can download it or retry the upload.
+
+
+ {entries.map((entry) => (
+
+
+
+ {new Date(entry.createdAt).toLocaleString()}
+
+
+ {formatBytes(entry.totalBytes)}
+ {entry.durationMs > 0
+ ? ` · ${formatRecordedDuration(entry.durationMs)}`
+ : ""}
+ {entry.videoId ? "" : " · interrupted before upload"}
+
+
+
+ {entry.videoId && (
+ retry(entry)}
+ >
+ {retryingSession === entry.sessionId
+ ? "Uploading…"
+ : "Retry upload"}
+
+ )}
+ void download(entry)}
+ >
+ Download
+
+ void remove(entry)}
+ >
+ Delete
+
+
+
+ ))}
+
+ {notice && (
+
+ {notice.message}
+ {notice.shareUrl && (
+
+ Open video
+
+ )}
+
+ )}
+
+ );
+}
+
+const DoodleCheckbox = ({
+ label,
+ checked,
+ onChange,
+}: {
+ label: string;
+ checked: boolean;
+ onChange: (checked: boolean) => void;
+}) => (
+
+ onChange(event.currentTarget.checked)}
+ />
+
+
+
+
+
+ {label}
+
+);
+
+function App() {
+ const [settings, setSettings] = useState(defaultSettings);
+ const [auth, setAuth] = useState(null);
+ const [saved, setSaved] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let disposed = false;
+ Promise.all([loadSettings(), loadAuth()])
+ .then(([nextSettings, nextAuth]) => {
+ if (disposed) return;
+ setSettings(nextSettings);
+ setAuth(nextAuth);
+ })
+ .catch((err: unknown) => {
+ if (!disposed) {
+ setError(err instanceof Error ? err.message : String(err));
+ }
+ });
+ return () => {
+ disposed = true;
+ };
+ }, []);
+
+ const save = async () => {
+ setError(null);
+ setSaved(false);
+ try {
+ new URL(settings.apiBaseUrl);
+ await saveSettings(settings);
+ await sendServiceWorkerMessage({
+ target: "service-worker",
+ type: "settings-updated",
+ settings,
+ }).catch(() => undefined);
+ setSaved(true);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err));
+ }
+ };
+
+ const signOut = async () => {
+ setError(null);
+ const response = await sendServiceWorkerMessage({
+ target: "service-worker",
+ type: "auth-revoke",
+ }).catch((err: unknown) => ({
+ ok: false as const,
+ error: err instanceof Error ? err.message : String(err),
+ }));
+ if (!response.ok) {
+ setError(response.error);
+ return;
+ }
+ await clearAuth();
+ setAuth(null);
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Recorder options
+
+ Tune where Cap uploads and how your camera shows up by default.
+
+
+
+
+
+
+ Recording defaults
+
+
+ Camera size
+
+ setSettings({
+ ...settings,
+ webcam: {
+ ...settings.webcam,
+ size: Number(event.currentTarget.value),
+ },
+ })
+ }
+ />
+
+
+ Position
+
+ setSettings({
+ ...settings,
+ webcam: {
+ ...settings.webcam,
+ position: event.currentTarget
+ .value as ExtensionSettings["webcam"]["position"],
+ },
+ })
+ }
+ >
+ Bottom right
+ Bottom left
+ Top right
+ Top left
+
+
+
+ Shape
+
+ setSettings({
+ ...settings,
+ webcam: {
+ ...settings.webcam,
+ shape: event.currentTarget
+ .value as ExtensionSettings["webcam"]["shape"],
+ },
+ })
+ }
+ >
+ Round
+ Square
+ Full
+
+
+
+ Countdown
+
+ setSettings({
+ ...settings,
+ countdown: {
+ ...settings.countdown,
+ seconds: Number(event.currentTarget.value),
+ },
+ })
+ }
+ >
+ 3 seconds
+ 5 seconds
+ 10 seconds
+
+
+
+
+
+ setSettings({
+ ...settings,
+ webcam: {
+ ...settings.webcam,
+ enabled: checked && Boolean(settings.webcam.deviceId),
+ },
+ })
+ }
+ />
+
+ setSettings({
+ ...settings,
+ microphone: {
+ ...settings.microphone,
+ enabled: checked,
+ },
+ })
+ }
+ />
+
+ setSettings({
+ ...settings,
+ systemAudio: {
+ ...settings.systemAudio,
+ enabled: checked,
+ },
+ })
+ }
+ />
+
+ setSettings({
+ ...settings,
+ sounds: {
+ ...settings.sounds,
+ enabled: checked,
+ },
+ })
+ }
+ />
+
+ setSettings({
+ ...settings,
+ countdown: {
+ ...settings.countdown,
+ enabled: checked,
+ },
+ })
+ }
+ />
+
+ setSettings({
+ ...settings,
+ microphoneWarning: {
+ ...settings.microphoneWarning,
+ enabled: checked,
+ },
+ })
+ }
+ />
+
+ setSettings({
+ ...settings,
+ webcam: {
+ ...settings.webcam,
+ mirror: checked,
+ },
+ })
+ }
+ />
+
+
+
+
+
+
+
void save()}>
+ Save changes
+
+
void signOut()}
+ >
+ Sign out
+
+ {saved && (
+
+
+
+
+ Saved
+
+ )}
+
+ {error &&
{error}
}
+
+
+
+ Changes apply the next time you open the recorder.
+
+ >
+ );
+}
+
+createRoot(document.getElementById("root") as HTMLElement).render( );
diff --git a/apps/chrome-extension/src/options/styles.css b/apps/chrome-extension/src/options/styles.css
new file mode 100644
index 00000000000..136c8ab5d41
--- /dev/null
+++ b/apps/chrome-extension/src/options/styles.css
@@ -0,0 +1,247 @@
+@import "../shared/paper.css";
+
+#root {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+}
+
+.slider-line {
+ stroke-dasharray: 1;
+ stroke-dashoffset: 1;
+}
+
+.line-1 {
+ animation: draw 0.45s ease 0.15s forwards;
+}
+
+.line-2 {
+ animation: draw 0.45s ease 0.3s forwards;
+}
+
+.line-3 {
+ animation: draw 0.45s ease 0.45s forwards;
+}
+
+.knob-circle {
+ fill: var(--paper);
+ stroke: var(--ink);
+ stroke-width: 4.5;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+
+.knob-accent {
+ stroke: var(--accent);
+}
+
+.slider-knob {
+ opacity: 0;
+ transform-box: fill-box;
+ transform-origin: center;
+ animation: spark-pop 0.4s ease forwards;
+}
+
+.knob-1 {
+ animation-delay: 0.7s;
+}
+
+.knob-2 {
+ animation:
+ spark-pop 0.4s ease 0.85s forwards,
+ knob-tune 5s ease-in-out 1.8s infinite;
+}
+
+.knob-3 {
+ animation-delay: 1s;
+}
+
+.sheet {
+ width: min(560px, calc(100vw - 48px));
+ margin-top: 32px;
+ display: grid;
+ gap: 14px;
+ justify-items: stretch;
+ text-align: left;
+}
+
+.card-1 {
+ animation-delay: 0.2s;
+}
+
+.card-2 {
+ animation-delay: 0.3s;
+}
+
+.card-3 {
+ animation-delay: 0.4s;
+}
+
+.recovery-lede {
+ font-size: 13px;
+ line-height: 1.5;
+ color: var(--ink-soft);
+}
+
+.recovery-list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ display: grid;
+ gap: 10px;
+}
+
+.recovery-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ flex-wrap: wrap;
+ padding: 12px 14px;
+ border: 1.5px solid var(--track);
+ border-radius: 12px;
+ background: #ffffff;
+}
+
+.recovery-meta {
+ display: grid;
+ gap: 2px;
+ font-size: 13px;
+}
+
+.recovery-title {
+ font-weight: 500;
+ color: var(--ink);
+}
+
+.recovery-detail {
+ color: var(--ink-soft);
+}
+
+.recovery-actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.cta.small {
+ min-height: 32px;
+ padding: 0 14px;
+ font-size: 13px;
+}
+
+.recovery-link {
+ color: inherit;
+ font-weight: 500;
+}
+
+.field-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 12px;
+}
+
+.checks {
+ display: grid;
+ gap: 12px;
+ padding-top: 4px;
+}
+
+.doodle-check {
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ font-size: 14px;
+ color: var(--ink);
+ cursor: pointer;
+}
+
+.doodle-check input {
+ position: absolute;
+ opacity: 0;
+ pointer-events: none;
+}
+
+.doodle-check-box {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 22px;
+ height: 22px;
+ flex: 0 0 auto;
+ border: 1.5px solid var(--ink);
+ border-radius: 7px;
+ background: #ffffff;
+}
+
+.doodle-check-box svg {
+ width: 14px;
+ height: 14px;
+ overflow: visible;
+}
+
+.doodle-check-mark {
+ fill: none;
+ stroke: var(--accent);
+ stroke-width: 3.5;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ stroke-dasharray: 1;
+ stroke-dashoffset: 1;
+ transition: stroke-dashoffset 0.25s ease;
+}
+
+.doodle-check input:checked + .doodle-check-box .doodle-check-mark {
+ stroke-dashoffset: 0;
+}
+
+.doodle-check input:focus-visible + .doodle-check-box {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+
+.actions {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ animation: fade-up 0.5s ease 0.4s both;
+}
+
+.actions .paper-pill {
+ min-height: 36px;
+ padding: 4px 16px;
+}
+
+@keyframes knob-tune {
+ 0%,
+ 100% {
+ transform: translateX(0);
+ }
+ 35% {
+ transform: translateX(-16px);
+ }
+ 70% {
+ transform: translateX(8px);
+ }
+}
+
+@media (max-width: 560px) {
+ .field-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .actions,
+ .slider-line {
+ animation-duration: 0.01ms;
+ animation-delay: 0s;
+ }
+
+ .slider-knob,
+ .knob-2 {
+ animation: fade-in 0.01ms forwards;
+ }
+}
diff --git a/apps/chrome-extension/src/permission/camera-permission.tsx b/apps/chrome-extension/src/permission/camera-permission.tsx
new file mode 100644
index 00000000000..cf6f909d12f
--- /dev/null
+++ b/apps/chrome-extension/src/permission/camera-permission.tsx
@@ -0,0 +1,311 @@
+import { useEffect, useState } from "react";
+import { createRoot } from "react-dom/client";
+import { CapBrand, DoodleBoilFilter } from "../shared/cap-brand";
+import { toCameraDevices } from "../shared/devices";
+import { mountPageNav } from "../shared/page-nav";
+import { rememberCameraSelection } from "../shared/preferences";
+import { sendServiceWorkerMessage } from "../shared/runtime";
+import {
+ loadSettings,
+ saveSettings,
+ updateMediaAccessState,
+} from "../shared/storage";
+import type { CameraDevice, ExtensionSettings } from "../shared/types";
+import { DEFAULT_CAMERA_DEVICE_ID } from "../shared/types";
+import "./styles.css";
+
+mountPageNav("camera");
+
+type Status = "idle" | "requesting" | "ready" | "error";
+
+const headlines: Record = {
+ idle: {
+ title: "Camera & microphone access",
+ lede: "Allow access once so Cap can show your camera preview and record your voice.",
+ },
+ requesting: {
+ title: "Waiting for Chrome",
+ lede: "Click Allow in the browser prompt up by the address bar.",
+ },
+ ready: {
+ title: "You're all set",
+ lede: "Pick the camera Cap should use. It's remembered for every recording.",
+ },
+ error: {
+ title: "Cap needs access",
+ lede: "Allow access once so Cap can show your camera preview and record your voice.",
+ },
+};
+
+const stopStream = (stream: MediaStream) => {
+ for (const track of stream.getTracks()) {
+ track.stop();
+ }
+};
+
+const getCameraErrorMessage = (error: unknown) => {
+ if (!(error instanceof Error)) return "Camera unavailable";
+ if (
+ error.name === "NotAllowedError" ||
+ error.message.toLowerCase().includes("permission")
+ ) {
+ return "Chrome did not grant camera access. Click Allow in the browser prompt.";
+ }
+ if (error.name === "NotFoundError") return "No camera was found.";
+ if (error.name === "NotReadableError") return "Camera is already in use.";
+ return error.message || "Camera unavailable";
+};
+
+const getPreferredDeviceId = (
+ settings: ExtensionSettings,
+ devices: CameraDevice[],
+) => {
+ const selectedDeviceStillExists = devices.some(
+ (device) => device.deviceId === settings.webcam.deviceId,
+ );
+ if (
+ settings.webcam.deviceId &&
+ settings.webcam.deviceId !== DEFAULT_CAMERA_DEVICE_ID &&
+ selectedDeviceStillExists
+ ) {
+ return settings.webcam.deviceId;
+ }
+ return devices[0]?.deviceId ?? DEFAULT_CAMERA_DEVICE_ID;
+};
+
+function App() {
+ const [settings, setSettings] = useState(null);
+ const [devices, setDevices] = useState([]);
+ const [selectedDeviceId, setSelectedDeviceId] = useState(
+ DEFAULT_CAMERA_DEVICE_ID,
+ );
+ const [status, setStatus] = useState("idle");
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let disposed = false;
+
+ Promise.all([
+ loadSettings(),
+ sendServiceWorkerMessage({
+ target: "service-worker",
+ type: "get-camera-devices",
+ }).catch(() => null),
+ ]).then(([nextSettings, response]) => {
+ if (disposed) return;
+ setSettings(nextSettings);
+ const cachedDevices = response?.ok ? (response.cameraDevices ?? []) : [];
+ setDevices(cachedDevices);
+ setSelectedDeviceId(
+ nextSettings.webcam.deviceId ??
+ cachedDevices[0]?.deviceId ??
+ DEFAULT_CAMERA_DEVICE_ID,
+ );
+ if (cachedDevices.length > 0) {
+ void updateMediaAccessState({ camera: true }).catch(() => undefined);
+ setStatus("ready");
+ }
+ });
+
+ return () => {
+ disposed = true;
+ };
+ }, []);
+
+ const saveCameraSelection = async (
+ nextSettings: ExtensionSettings,
+ nextDevices: CameraDevice[],
+ deviceId: string,
+ ) => {
+ const settingsToSave = rememberCameraSelection(
+ nextSettings,
+ deviceId,
+ nextDevices,
+ );
+ setSettings(settingsToSave);
+ setSelectedDeviceId(deviceId);
+ await saveSettings(settingsToSave);
+ await sendServiceWorkerMessage({
+ target: "service-worker",
+ type: "camera-devices-updated",
+ devices: nextDevices,
+ }).catch(() => undefined);
+ await sendServiceWorkerMessage({
+ target: "service-worker",
+ type: "settings-updated",
+ settings: settingsToSave,
+ }).catch(() => undefined);
+ };
+
+ const requestAccess = async () => {
+ if (!settings) return;
+ if (!navigator.mediaDevices?.getUserMedia) {
+ setStatus("error");
+ setError("Camera access is not available in this browser.");
+ return;
+ }
+
+ setStatus("requesting");
+ setError(null);
+
+ try {
+ // Request camera and microphone together so the extension origin gets
+ // both grants from one prompt; retry camera-only when no mic exists.
+ const stream = await navigator.mediaDevices
+ .getUserMedia({ video: true, audio: true })
+ .catch((err: unknown) => {
+ if (err instanceof DOMException && err.name === "NotFoundError") {
+ return navigator.mediaDevices.getUserMedia({
+ video: true,
+ audio: false,
+ });
+ }
+ throw err;
+ });
+ const cameraGranted = stream.getVideoTracks().length > 0;
+ const microphoneGranted = stream.getAudioTracks().length > 0;
+ stopStream(stream);
+ await updateMediaAccessState({
+ ...(cameraGranted ? { camera: true } : {}),
+ ...(microphoneGranted ? { microphone: true } : {}),
+ });
+
+ const nextDevices = toCameraDevices(
+ await navigator.mediaDevices.enumerateDevices(),
+ );
+ const deviceId = getPreferredDeviceId(settings, nextDevices);
+ setDevices(nextDevices);
+ await saveCameraSelection(settings, nextDevices, deviceId);
+ setStatus("ready");
+ } catch (err) {
+ setStatus("error");
+ setError(getCameraErrorMessage(err));
+ }
+ };
+
+ const handleDeviceChange = async (deviceId: string) => {
+ if (!settings) return;
+ await saveCameraSelection(settings, devices, deviceId);
+ };
+
+ const { title, lede } = headlines[status];
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {title}
+ {lede}
+
+ {status === "ready" && devices.length > 0 && (
+
+
+ Camera Cap should use
+
+ void handleDeviceChange(event.currentTarget.value)
+ }
+ >
+ {devices.map((device) => (
+
+ {device.label}
+
+ ))}
+
+
+
+
+
+
+ Camera preview is enabled.
+
+
+ )}
+
+ {status === "error" && error && (
+ {error}
+ )}
+
+
+ void requestAccess()}
+ >
+ {status === "requesting"
+ ? "Waiting for Chrome…"
+ : status === "ready"
+ ? "Re-check access"
+ : "Allow camera & microphone"}
+
+ {status === "ready" && (
+ void requestAccess()}
+ >
+ Refresh list
+
+ )}
+
+
+
+
+
+
+ Chrome remembers this. Cap only uses your camera while you record.
+
+ >
+ );
+}
+
+createRoot(document.getElementById("root") as HTMLElement).render( );
diff --git a/apps/chrome-extension/src/permission/styles.css b/apps/chrome-extension/src/permission/styles.css
new file mode 100644
index 00000000000..4e57ec1493c
--- /dev/null
+++ b/apps/chrome-extension/src/permission/styles.css
@@ -0,0 +1,133 @@
+@import "../shared/paper.css";
+
+#root {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+}
+
+.camera-body {
+ stroke-dasharray: 1;
+ stroke-dashoffset: 1;
+ animation: draw 0.9s ease 0.15s forwards;
+}
+
+.camera-lens {
+ stroke-dasharray: 1;
+ stroke-dashoffset: 1;
+ animation: draw 0.5s ease 0.9s forwards;
+}
+
+.lens-dot {
+ fill: var(--accent);
+ opacity: 0;
+ animation: fade-in 0.3s ease 1.2s forwards;
+}
+
+.flash-dot {
+ fill: var(--accent);
+ opacity: 0;
+ animation: fade-in 0.3s ease 1.35s forwards;
+}
+
+.stage[data-mode="requesting"] .lens-dot {
+ opacity: 1;
+ animation: lens-pulse 1.2s ease-in-out infinite;
+}
+
+.stage[data-mode="error"] .lens-dot {
+ fill: var(--error);
+}
+
+.spark {
+ display: none;
+}
+
+.stage[data-mode="ready"] .spark {
+ display: inline;
+}
+
+.spark-1 {
+ animation-delay: 0.05s;
+}
+
+.spark-2 {
+ animation-delay: 0.2s;
+}
+
+.spark-3 {
+ animation-delay: 0.35s;
+}
+
+.device-card {
+ width: min(420px, calc(100vw - 48px));
+ margin-top: 28px;
+ justify-items: start;
+}
+
+.device-card .field {
+ width: 100%;
+}
+
+.stage > .paper-pill.error {
+ margin-top: 24px;
+ max-width: 480px;
+}
+
+.cta-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-top: 28px;
+ animation: fade-up 0.5s ease 0.3s both;
+}
+
+.wait-squiggle {
+ display: none;
+ width: 200px;
+ height: 26px;
+ margin-top: 16px;
+ overflow: visible;
+}
+
+.stage[data-mode="requesting"] .wait-squiggle {
+ display: block;
+}
+
+.wait-squiggle-path {
+ fill: none;
+ stroke: var(--ink-soft);
+ stroke-width: 2.5;
+ stroke-linecap: round;
+ stroke-dasharray: 2 4.2;
+ animation: march 1.2s linear infinite;
+}
+
+@keyframes lens-pulse {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.3;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .camera-body,
+ .camera-lens,
+ .cta-row {
+ animation-duration: 0.01ms;
+ animation-delay: 0s;
+ }
+
+ .lens-dot,
+ .flash-dot,
+ .stage[data-mode="requesting"] .lens-dot {
+ animation: fade-in 0.01ms forwards;
+ }
+
+ .wait-squiggle-path {
+ animation: none;
+ }
+}
diff --git a/apps/chrome-extension/src/popup/components/camera-selector.tsx b/apps/chrome-extension/src/popup/components/camera-selector.tsx
new file mode 100644
index 00000000000..ad0c9f6521d
--- /dev/null
+++ b/apps/chrome-extension/src/popup/components/camera-selector.tsx
@@ -0,0 +1,192 @@
+import {
+ NO_CAMERA,
+ NO_CAMERA_VALUE,
+} from "@cap/recorder-core/recorder-constants";
+import clsx from "clsx";
+import { CameraIcon, CameraOffIcon } from "lucide-react";
+import type { KeyboardEvent, MouseEvent } from "react";
+import { useRef } from "react";
+import type { CameraDevice } from "../../shared/types";
+import { DEFAULT_CAMERA_DEVICE_ID } from "../../shared/types";
+import {
+ type DeviceSelectOption,
+ DeviceSelectOverlay,
+} from "./device-select-overlay";
+import { useMediaPermission } from "./use-media-permission";
+
+interface CameraSelectorProps {
+ selectedCameraId: string | null;
+ availableCameras: CameraDevice[];
+ permissionGranted: boolean;
+ disabled?: boolean;
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+ onCameraChange: (cameraId: string | null) => void;
+ onRefreshDevices: () => Promise | void;
+ onPermissionBlocked: () => void;
+}
+
+export const CameraSelector = ({
+ selectedCameraId,
+ availableCameras,
+ permissionGranted,
+ disabled = false,
+ open = false,
+ onOpenChange,
+ onCameraChange,
+ onRefreshDevices,
+ onPermissionBlocked,
+}: CameraSelectorProps) => {
+ const cameraEnabled = selectedCameraId !== null;
+ const triggerRef = useRef(null);
+ const { state: permissionState, requestPermission } =
+ useMediaPermission("camera");
+
+ const permissionSupported = permissionState !== "unsupported";
+ const hasDeviceAccess = availableCameras.length > 0;
+ const hasAccess = permissionGranted || hasDeviceAccess || cameraEnabled;
+ const shouldRequestPermission =
+ permissionSupported && permissionState !== "granted" && !hasAccess;
+
+ const statusPillDisabled = !shouldRequestPermission && !cameraEnabled;
+
+ const currentValue = selectedCameraId ?? NO_CAMERA_VALUE;
+
+ const options: DeviceSelectOption[] = [
+ { value: NO_CAMERA_VALUE, label: NO_CAMERA, icon: CameraOffIcon },
+ ];
+ if (
+ selectedCameraId === DEFAULT_CAMERA_DEVICE_ID &&
+ !availableCameras.some(
+ (camera) => camera.deviceId === DEFAULT_CAMERA_DEVICE_ID,
+ )
+ ) {
+ options.push({
+ value: DEFAULT_CAMERA_DEVICE_ID,
+ label: "System default camera",
+ icon: CameraIcon,
+ });
+ }
+ availableCameras.forEach((camera, index) => {
+ options.push({
+ value: camera.deviceId,
+ label: camera.label?.trim() || `Camera ${index + 1}`,
+ icon: CameraIcon,
+ });
+ });
+
+ const selectedOption =
+ options.find((option) => option.value === currentValue) ?? options[0];
+ const TriggerIcon = selectedOption.icon;
+
+ const statusPillClassName = clsx(
+ "px-[0.375rem] h-[1.25rem] min-w-[2.5rem] rounded-full text-[0.75rem] leading-[1.25rem] flex items-center justify-center font-normal transition-colors duration-200 disabled:opacity-100 disabled:pointer-events-none focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:ring-[var(--blue-8)]",
+ statusPillDisabled ? "cursor-default" : "cursor-pointer",
+ shouldRequestPermission
+ ? "bg-[var(--red-3)] text-[var(--red-11)]"
+ : cameraEnabled
+ ? "bg-[var(--blue-3)] text-[var(--blue-11)] hover:bg-[var(--blue-4)]"
+ : "bg-[var(--red-3)] text-[var(--red-11)]",
+ );
+
+ const openPicker = () => {
+ if (disabled || shouldRequestPermission) return;
+ onOpenChange?.(true);
+ };
+
+ const closePicker = () => {
+ onOpenChange?.(false);
+ triggerRef.current?.focus();
+ };
+
+ const handleStatusPillClick = async (
+ event: MouseEvent | KeyboardEvent,
+ ) => {
+ if (shouldRequestPermission) {
+ event.preventDefault();
+ event.stopPropagation();
+
+ try {
+ const granted = await requestPermission();
+ if (granted) {
+ await Promise.resolve(onRefreshDevices());
+ }
+ } catch (error) {
+ console.error("Camera permission request failed", error);
+ onPermissionBlocked();
+ }
+
+ return;
+ }
+
+ if (!cameraEnabled) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ onCameraChange(null);
+ };
+
+ const handleStatusPillKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Enter" || event.key === " ") {
+ void handleStatusPillClick(event);
+ }
+ };
+
+ return (
+
+
+
+
+
+ {selectedOption.label}
+
+
+ void handleStatusPillClick(event)}
+ onKeyDown={handleStatusPillKeyDown}
+ >
+ {shouldRequestPermission
+ ? "Request permission"
+ : cameraEnabled
+ ? "On"
+ : "Off"}
+
+
+ {open && (
+
{
+ onCameraChange(value === NO_CAMERA_VALUE ? null : value);
+ closePicker();
+ }}
+ onClose={closePicker}
+ />
+ )}
+
+ );
+};
diff --git a/apps/chrome-extension/src/popup/components/cog-icon.tsx b/apps/chrome-extension/src/popup/components/cog-icon.tsx
new file mode 100644
index 00000000000..4ca0786895f
--- /dev/null
+++ b/apps/chrome-extension/src/popup/components/cog-icon.tsx
@@ -0,0 +1,43 @@
+import clsx from "clsx";
+import type { SVGProps } from "react";
+
+interface CogIconProps extends SVGProps {
+ size?: number;
+}
+
+const CogIcon = ({ className, size = 28, ...props }: CogIconProps) => (
+
+ Settings
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
+
+export default CogIcon;
diff --git a/apps/chrome-extension/src/popup/components/dashboard-button.tsx b/apps/chrome-extension/src/popup/components/dashboard-button.tsx
new file mode 100644
index 00000000000..93a91b1066e
--- /dev/null
+++ b/apps/chrome-extension/src/popup/components/dashboard-button.tsx
@@ -0,0 +1,19 @@
+import { LayoutDashboardIcon } from "lucide-react";
+import { Button } from "../ui/button";
+
+interface DashboardButtonProps {
+ onClick: () => void;
+}
+
+export const DashboardButton = ({ onClick }: DashboardButtonProps) => (
+
+
+
+);
diff --git a/apps/chrome-extension/src/popup/components/device-select-overlay.tsx b/apps/chrome-extension/src/popup/components/device-select-overlay.tsx
new file mode 100644
index 00000000000..bc2e0d6e7a4
--- /dev/null
+++ b/apps/chrome-extension/src/popup/components/device-select-overlay.tsx
@@ -0,0 +1,114 @@
+import clsx from "clsx";
+import { ArrowLeftIcon, CheckIcon, type LucideIcon } from "lucide-react";
+import { useEffect, useRef } from "react";
+import { createPortal } from "react-dom";
+
+export interface DeviceSelectOption {
+ value: string;
+ label: string;
+ icon: LucideIcon;
+}
+
+interface DeviceSelectOverlayProps {
+ title: string;
+ options: DeviceSelectOption[];
+ selectedValue: string;
+ onSelect: (value: string) => void;
+ onClose: () => void;
+}
+
+/**
+ * A full-panel device picker. The recorder panel renders inside a narrow,
+ * fixed-size iframe, so a popper dropdown overflows it and spills off screen.
+ * Instead this slides a sheet over the whole panel with a back button and a
+ * scrollable list that always fits the panel bounds.
+ */
+export const DeviceSelectOverlay = ({
+ title,
+ options,
+ selectedValue,
+ onSelect,
+ onClose,
+}: DeviceSelectOverlayProps) => {
+ const backRef = useRef(null);
+
+ useEffect(() => {
+ backRef.current?.focus();
+
+ // Capture phase + stopImmediatePropagation so the panel's own
+ // Escape-to-dismiss handler doesn't also fire and tear down the recorder
+ // when the user only meant to back out of the picker.
+ const handleKey = (event: KeyboardEvent) => {
+ if (event.key !== "Escape") return;
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ onClose();
+ };
+ window.addEventListener("keydown", handleKey, true);
+ return () => window.removeEventListener("keydown", handleKey, true);
+ }, [onClose]);
+
+ return createPortal(
+
+
+
+
+ {options.map((option) => {
+ const Icon = option.icon;
+ const selected = option.value === selectedValue;
+ return (
+
+ onSelect(option.value)}
+ aria-pressed={selected}
+ title={option.label}
+ className={clsx(
+ "flex w-full items-center gap-[0.5rem] rounded-lg px-[0.625rem] py-[0.5rem] text-left text-[0.875rem] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--blue-8)]",
+ selected
+ ? "bg-[var(--blue-3)] text-[var(--blue-11)]"
+ : "text-[--text-primary] hover:bg-gray-3",
+ )}
+ >
+
+ {option.label}
+ {selected && (
+
+ )}
+
+
+ );
+ })}
+
+
+
,
+ document.body,
+ );
+};
diff --git a/apps/chrome-extension/src/popup/components/how-it-works-button.tsx b/apps/chrome-extension/src/popup/components/how-it-works-button.tsx
new file mode 100644
index 00000000000..be4355f12b4
--- /dev/null
+++ b/apps/chrome-extension/src/popup/components/how-it-works-button.tsx
@@ -0,0 +1,18 @@
+import { CircleHelpIcon } from "lucide-react";
+
+interface HowItWorksButtonProps {
+ onClick: () => void;
+}
+
+export const HowItWorksButton = ({ onClick }: HowItWorksButtonProps) => {
+ return (
+
+
+ How it works (tips)
+
+ );
+};
diff --git a/apps/chrome-extension/src/popup/components/microphone-selector.tsx b/apps/chrome-extension/src/popup/components/microphone-selector.tsx
new file mode 100644
index 00000000000..94d8979a275
--- /dev/null
+++ b/apps/chrome-extension/src/popup/components/microphone-selector.tsx
@@ -0,0 +1,205 @@
+import {
+ NO_MICROPHONE,
+ NO_MICROPHONE_VALUE,
+} from "@cap/recorder-core/recorder-constants";
+import clsx from "clsx";
+import { MicIcon, MicOffIcon } from "lucide-react";
+import type { KeyboardEvent, MouseEvent } from "react";
+import { useRef } from "react";
+import type { MicrophoneDevice } from "../../shared/types";
+import { DEFAULT_MICROPHONE_DEVICE_ID } from "../../shared/types";
+import {
+ type DeviceSelectOption,
+ DeviceSelectOverlay,
+} from "./device-select-overlay";
+import { useMediaPermission } from "./use-media-permission";
+
+interface MicrophoneSelectorProps {
+ selectedMicId: string | null;
+ availableMics: MicrophoneDevice[];
+ permissionGranted: boolean;
+ disabled?: boolean;
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+ onMicChange: (micId: string | null) => void;
+ onRefreshDevices: () => Promise | void;
+ onPermissionBlocked: () => void;
+}
+
+export const MicrophoneSelector = ({
+ selectedMicId,
+ availableMics,
+ permissionGranted,
+ disabled = false,
+ open = false,
+ onOpenChange,
+ onMicChange,
+ onRefreshDevices,
+ onPermissionBlocked,
+}: MicrophoneSelectorProps) => {
+ const micEnabled = selectedMicId !== null;
+ const triggerRef = useRef(null);
+ const { state: permissionState, requestPermission } =
+ useMediaPermission("microphone");
+
+ const permissionSupported = permissionState !== "unsupported";
+ const hasDeviceAccess = availableMics.length > 0;
+ const hasAccess = permissionGranted || hasDeviceAccess || micEnabled;
+ const shouldRequestPermission =
+ permissionSupported && permissionState !== "granted" && !hasAccess;
+
+ const statusPillDisabled =
+ disabled || (!shouldRequestPermission && !micEnabled);
+
+ const currentValue = selectedMicId ?? NO_MICROPHONE_VALUE;
+
+ const options: DeviceSelectOption[] = [
+ { value: NO_MICROPHONE_VALUE, label: NO_MICROPHONE, icon: MicOffIcon },
+ ];
+ if (
+ availableMics.length === 0 ||
+ selectedMicId === DEFAULT_MICROPHONE_DEVICE_ID
+ ) {
+ options.push({
+ value: DEFAULT_MICROPHONE_DEVICE_ID,
+ label: "Default microphone",
+ icon: MicIcon,
+ });
+ }
+ availableMics.forEach((mic, index) => {
+ options.push({
+ value: mic.deviceId,
+ label: mic.label?.trim() || `Microphone ${index + 1}`,
+ icon: MicIcon,
+ });
+ });
+
+ const selectedOption =
+ options.find((option) => option.value === currentValue) ?? options[0];
+ const TriggerIcon = selectedOption.icon;
+
+ const statusPillClassName = clsx(
+ "px-[0.375rem] h-[1.25rem] min-w-[2.5rem] rounded-full text-[0.75rem] leading-[1.25rem] flex items-center justify-center font-normal transition-colors duration-200 disabled:opacity-100 disabled:pointer-events-none focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:ring-[var(--blue-8)]",
+ statusPillDisabled ? "cursor-default" : "cursor-pointer",
+ shouldRequestPermission
+ ? "bg-[var(--red-3)] text-[var(--red-11)]"
+ : micEnabled
+ ? "bg-[var(--blue-3)] text-[var(--blue-11)] hover:bg-[var(--blue-4)]"
+ : "bg-[var(--red-3)] text-[var(--red-11)]",
+ );
+
+ const openPicker = () => {
+ if (disabled || shouldRequestPermission) return;
+ onOpenChange?.(true);
+ };
+
+ const closePicker = () => {
+ onOpenChange?.(false);
+ triggerRef.current?.focus();
+ };
+
+ const handleStatusPillClick = async (
+ event: MouseEvent | KeyboardEvent,
+ ) => {
+ if (disabled) {
+ event.preventDefault();
+ event.stopPropagation();
+ return;
+ }
+
+ if (shouldRequestPermission) {
+ event.preventDefault();
+ event.stopPropagation();
+
+ try {
+ const granted = await requestPermission();
+ if (granted) {
+ await Promise.resolve(onRefreshDevices());
+ }
+ } catch (error) {
+ console.error("Microphone permission request failed", error);
+ onPermissionBlocked();
+ }
+
+ return;
+ }
+
+ if (!micEnabled) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ onMicChange(null);
+ };
+
+ const handleStatusPillKeyDown = (event: KeyboardEvent) => {
+ if (disabled) {
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ return;
+ }
+
+ if (event.key === "Enter" || event.key === " ") {
+ void handleStatusPillClick(event);
+ }
+ };
+
+ return (
+
+
+
+
+
+ {selectedOption.label}
+
+
+ void handleStatusPillClick(event)}
+ onKeyDown={handleStatusPillKeyDown}
+ >
+ {shouldRequestPermission
+ ? "Request permission"
+ : micEnabled
+ ? "On"
+ : "Off"}
+
+
+ {open && (
+
{
+ onMicChange(value === NO_MICROPHONE_VALUE ? null : value);
+ closePicker();
+ }}
+ onClose={closePicker}
+ />
+ )}
+
+ );
+};
diff --git a/apps/chrome-extension/src/popup/components/recorder-header.tsx b/apps/chrome-extension/src/popup/components/recorder-header.tsx
new file mode 100644
index 00000000000..3b6d92e130c
--- /dev/null
+++ b/apps/chrome-extension/src/popup/components/recorder-header.tsx
@@ -0,0 +1,118 @@
+import clsx from "clsx";
+import { X } from "lucide-react";
+
+interface RecorderHeaderProps {
+ isBusy: boolean;
+ isPro: boolean;
+ showPlan: boolean;
+ /** Hide the logo row; the sign-in screen renders its own centered brand. */
+ minimal?: boolean;
+ onClose: () => void;
+ onUpgradeClick: () => void;
+}
+
+export const RecorderHeader = ({
+ isBusy,
+ isPro,
+ showPlan,
+ minimal = false,
+ onClose,
+ onUpgradeClick,
+}: RecorderHeaderProps) => {
+ const planLabel = isPro ? "Pro" : "Free";
+ const planClassName = clsx(
+ "ml-2 inline-flex items-center rounded-full px-2 text-[0.7rem] font-medium transition-colors",
+ isPro
+ ? "bg-blue-9 text-gray-1"
+ : "cursor-pointer bg-gray-3 text-gray-12 hover:bg-gray-4",
+ );
+
+ return (
+ <>
+
+ {minimal ? null : (
+
+
+
+ Cap Logo
+
+
+
+
+
+
+
+ {showPlan &&
+ (isPro ? (
+
{planLabel}
+ ) : (
+
+ {planLabel}
+
+ ))}
+
+
+ )}
+ >
+ );
+};
diff --git a/apps/chrome-extension/src/popup/components/recording-bar.tsx b/apps/chrome-extension/src/popup/components/recording-bar.tsx
new file mode 100644
index 00000000000..9cb9208a587
--- /dev/null
+++ b/apps/chrome-extension/src/popup/components/recording-bar.tsx
@@ -0,0 +1,176 @@
+import clsx from "clsx";
+import {
+ Mic,
+ MicOff,
+ MoreVertical,
+ PauseCircle,
+ PlayCircle,
+ RotateCcw,
+ StopCircle,
+} from "lucide-react";
+import type { ComponentProps } from "react";
+import { formatDuration } from "../../shared/format-duration";
+import type { RecordingStatus, UploadSummary } from "../../shared/types";
+
+const ActionButton = ({ className, ...props }: ComponentProps<"button">) => (
+
+);
+
+const InlineChunkProgress = ({ upload }: { upload?: UploadSummary }) => {
+ if (!upload || upload.totalChunks === 0) {
+ return null;
+ }
+
+ const completedCount = upload.completedChunks;
+ const failed = upload.failedChunks > 0;
+ const progressRatio = Math.max(
+ 0,
+ Math.min(
+ 1,
+ upload.totalBytes > 0
+ ? upload.uploadedBytes / upload.totalBytes
+ : completedCount / upload.totalChunks,
+ ),
+ );
+ const radius = 15.9155;
+ const circumference = 2 * Math.PI * radius;
+ const strokeDashoffset = circumference * (1 - progressRatio);
+ const colorClass = failed
+ ? "text-[var(--red-11)]"
+ : completedCount === upload.totalChunks
+ ? "text-[var(--green-11)]"
+ : "text-blue-9";
+
+ return (
+
+
+
+ Upload progress
+
+
+
+
+
+ {completedCount}/{upload.totalChunks}
+
+
+ );
+};
+
+type ActiveRecordingStatus = Extract<
+ RecordingStatus,
+ { phase: "recording" | "paused" | "uploading" }
+>;
+
+interface RecordingBarProps {
+ status: ActiveRecordingStatus;
+ hasAudioTrack: boolean;
+ disabled: boolean;
+ onStop: () => void;
+ onPauseResume: () => void;
+}
+
+export const RecordingBar = ({
+ status,
+ hasAudioTrack,
+ disabled,
+ onStop,
+ onPauseResume,
+}: RecordingBarProps) => {
+ const isPaused = status.phase === "paused";
+ const canStop = !disabled;
+ const showTimer = status.phase === "recording" || isPaused;
+ const statusText = showTimer
+ ? formatDuration(status.durationMs)
+ : "Uploading";
+ const canTogglePause = !disabled && status.phase !== "uploading";
+
+ return (
+
+
+
+
+
+ {statusText}
+
+
+
+
+
+
+ {hasAudioTrack ? (
+ <>
+
+
+ >
+ ) : (
+
+ )}
+
+
+
+ {isPaused ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/chrome-extension/src/popup/components/recording-button.tsx b/apps/chrome-extension/src/popup/components/recording-button.tsx
new file mode 100644
index 00000000000..8190b0b32ae
--- /dev/null
+++ b/apps/chrome-extension/src/popup/components/recording-button.tsx
@@ -0,0 +1,58 @@
+import type { SVGProps } from "react";
+import { Button } from "../ui/button";
+
+interface RecordingButtonProps {
+ isRecording: boolean;
+ disabled?: boolean;
+ onStart: () => void;
+ onStop: () => void;
+}
+
+export const InstantIcon = ({
+ className,
+ ...props
+}: SVGProps) => (
+
+ Instant recording
+
+
+);
+
+export const RecordingButton = ({
+ isRecording,
+ disabled = false,
+ onStart,
+ onStop,
+}: RecordingButtonProps) => {
+ return (
+
+
+ {isRecording ? (
+ "Stop Recording"
+ ) : (
+ <>
+
+ Start recording
+ >
+ )}
+
+
+ );
+};
diff --git a/apps/chrome-extension/src/popup/components/recording-mode-selector.tsx b/apps/chrome-extension/src/popup/components/recording-mode-selector.tsx
new file mode 100644
index 00000000000..7ef1d188a55
--- /dev/null
+++ b/apps/chrome-extension/src/popup/components/recording-mode-selector.tsx
@@ -0,0 +1,102 @@
+import {
+ CameraIcon,
+ Globe,
+ type LucideIcon,
+ MonitorIcon,
+ RectangleHorizontal,
+} from "lucide-react";
+import type { RecordingMode } from "../../shared/types";
+import {
+ SelectContent,
+ SelectItem,
+ SelectRoot,
+ SelectTrigger,
+ SelectValue,
+} from "../ui/select";
+
+interface RecordingModeSelectorProps {
+ mode: RecordingMode;
+ disabled?: boolean;
+ onModeChange: (mode: RecordingMode) => void;
+}
+
+export const RecordingModeSelector = ({
+ mode,
+ disabled = false,
+ onModeChange,
+}: RecordingModeSelectorProps) => {
+ const recordingModeOptions: Record<
+ RecordingMode,
+ {
+ label: string;
+ displayLabel: string;
+ icon: LucideIcon;
+ }
+ > = {
+ fullscreen: {
+ label: "Full Screen (Recommended)",
+ displayLabel: "Full Screen",
+ icon: MonitorIcon,
+ },
+ window: {
+ label: "Window",
+ displayLabel: "Window",
+ icon: RectangleHorizontal,
+ },
+ tab: {
+ label: "Current tab",
+ displayLabel: "Current tab",
+ icon: Globe,
+ },
+ camera: {
+ label: "Camera only",
+ displayLabel: "Camera only",
+ icon: CameraIcon,
+ },
+ };
+
+ const selectedOption = mode ? recordingModeOptions[mode] : null;
+ const SelectedIcon = selectedOption?.icon;
+
+ return (
+
+ {
+ onModeChange(value as RecordingMode);
+ }}
+ disabled={disabled}
+ >
+
+
+ {selectedOption && SelectedIcon && (
+
+
+ {selectedOption.displayLabel}
+
+ )}
+
+
+
+ {Object.entries(recordingModeOptions).map(([value, option]) => {
+ const OptionIcon = option.icon;
+
+ return (
+
+
+
+
+ {option.label}
+
+
+
+ );
+ })}
+
+
+
+ );
+};
diff --git a/apps/chrome-extension/src/popup/components/settings-button.tsx b/apps/chrome-extension/src/popup/components/settings-button.tsx
new file mode 100644
index 00000000000..0d76f22b5a5
--- /dev/null
+++ b/apps/chrome-extension/src/popup/components/settings-button.tsx
@@ -0,0 +1,19 @@
+import { Button } from "../ui/button";
+import CogIcon from "./cog-icon";
+
+interface SettingsButtonProps {
+ onClick: () => void;
+}
+
+export const SettingsButton = ({ onClick }: SettingsButtonProps) => (
+
+
+
+);
diff --git a/apps/chrome-extension/src/popup/components/sign-in-view.tsx b/apps/chrome-extension/src/popup/components/sign-in-view.tsx
new file mode 100644
index 00000000000..7b53dd664bd
--- /dev/null
+++ b/apps/chrome-extension/src/popup/components/sign-in-view.tsx
@@ -0,0 +1,94 @@
+import { type CSSProperties, useId } from "react";
+import { CapBrand, DoodleBoilFilter } from "../../shared/cap-brand";
+
+interface SignInViewProps {
+ authPending: boolean;
+ busy: boolean;
+ onSignIn: () => void;
+}
+
+export const SignInView = ({
+ authPending,
+ busy,
+ onSignIn,
+}: SignInViewProps) => {
+ const boilId = useId();
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {authPending ? "Finish signing in" : "Sign in to record"}
+
+
+ {authPending
+ ? "Complete sign-in in the Cap window. This panel updates automatically."
+ : "Record your tab, screen or camera. Your video uploads while you record."}
+
+ {authPending ? (
+
+
+
+
+ Waiting for the Cap sign-in window…
+
+ ) : null}
+
+ {authPending ? "Open the sign-in window again" : "Sign in to Cap"}
+
+
+ {authPending
+ ? "The window closes by itself once it connects."
+ : "Your share link is ready the moment you stop."}
+
+
+ );
+};
diff --git a/apps/chrome-extension/src/popup/components/system-audio-toggle.tsx b/apps/chrome-extension/src/popup/components/system-audio-toggle.tsx
new file mode 100644
index 00000000000..dd9ccf794a9
--- /dev/null
+++ b/apps/chrome-extension/src/popup/components/system-audio-toggle.tsx
@@ -0,0 +1,57 @@
+import clsx from "clsx";
+import { Volume2Icon, VolumeOffIcon } from "lucide-react";
+import type { RecordingMode } from "../../shared/types";
+
+interface SystemAudioToggleProps {
+ enabled: boolean;
+ disabled?: boolean;
+ recordingMode: RecordingMode;
+ onToggle: (enabled: boolean) => void;
+}
+
+const SYSTEM_AUDIO_HINTS: Partial> = {
+ fullscreen: 'Make sure to check "Share system audio" in the browser picker.',
+ window: "System audio may not be available when sharing a window.",
+};
+
+export const SystemAudioToggle = ({
+ enabled,
+ disabled = false,
+ recordingMode,
+ onToggle,
+}: SystemAudioToggleProps) => {
+ const Icon = enabled ? Volume2Icon : VolumeOffIcon;
+ const hint = enabled ? SYSTEM_AUDIO_HINTS[recordingMode] : undefined;
+
+ return (
+
+
onToggle(!enabled)}
+ className={clsx(
+ "relative flex flex-row items-center h-[2rem] px-[0.375rem] gap-[0.375rem] border border-gray-3 rounded-lg w-full transition-colors overflow-hidden font-normal text-[0.875rem] text-[--text-primary] disabled:text-gray-11",
+ disabled ? "cursor-default" : "cursor-pointer hover:bg-gray-3/50",
+ )}
+ >
+
+ System Audio
+
+ {enabled ? "On" : "Off"}
+
+
+ {hint && (
+
+ {hint}
+
+ )}
+
+ );
+};
diff --git a/apps/chrome-extension/src/popup/components/use-media-permission.ts b/apps/chrome-extension/src/popup/components/use-media-permission.ts
new file mode 100644
index 00000000000..b4f7d322a1e
--- /dev/null
+++ b/apps/chrome-extension/src/popup/components/use-media-permission.ts
@@ -0,0 +1,103 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+
+type MediaPermissionKind = "camera" | "microphone";
+
+type MediaPermissionState = PermissionState | "unsupported" | "unknown";
+
+const permissionNameMap: Record = {
+ camera: "camera",
+ microphone: "microphone",
+};
+
+const mediaConstraintsMap: Record =
+ {
+ camera: {
+ video: { width: { ideal: 1280 }, height: { ideal: 720 } },
+ audio: false,
+ },
+ microphone: { audio: true, video: false },
+ };
+
+export const useMediaPermission = (kind: MediaPermissionKind) => {
+ const [state, setState] = useState("unknown");
+ const permissionStatusRef = useRef(null);
+
+ const updateState = useCallback((next: MediaPermissionState) => {
+ setState((prev) => {
+ if (prev === next) return prev;
+ return next;
+ });
+ }, []);
+
+ const refreshPermission = useCallback(async () => {
+ if (!navigator.permissions?.query) {
+ updateState("unsupported");
+ return;
+ }
+
+ try {
+ const descriptor = {
+ name: permissionNameMap[kind],
+ } as PermissionDescriptor;
+
+ const permissionStatus = await navigator.permissions.query(descriptor);
+ if (permissionStatusRef.current) {
+ permissionStatusRef.current.onchange = null;
+ }
+ permissionStatusRef.current = permissionStatus;
+
+ updateState(permissionStatus.state);
+
+ permissionStatus.onchange = () => {
+ updateState(permissionStatus.state);
+ };
+ } catch (_error) {
+ updateState("unsupported");
+ }
+ }, [kind, updateState]);
+
+ useEffect(() => {
+ void refreshPermission();
+
+ return () => {
+ if (permissionStatusRef.current) {
+ permissionStatusRef.current.onchange = null;
+ }
+ permissionStatusRef.current = null;
+ };
+ }, [refreshPermission]);
+
+ const requestPermission = useCallback(async () => {
+ if (!navigator.mediaDevices?.getUserMedia) {
+ updateState("unsupported");
+ return false;
+ }
+
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia(
+ mediaConstraintsMap[kind],
+ );
+ for (const track of stream.getTracks()) {
+ track.stop();
+ }
+ updateState("granted");
+ await refreshPermission();
+ return true;
+ } catch (error) {
+ if (error instanceof DOMException) {
+ if (
+ error.name === "NotAllowedError" ||
+ error.name === "SecurityError"
+ ) {
+ updateState("denied");
+ }
+ }
+ throw error;
+ }
+ }, [kind, refreshPermission, updateState]);
+
+ return {
+ state,
+ requestPermission,
+ };
+};
diff --git a/apps/chrome-extension/src/popup/main.tsx b/apps/chrome-extension/src/popup/main.tsx
new file mode 100644
index 00000000000..5730e5ef5f3
--- /dev/null
+++ b/apps/chrome-extension/src/popup/main.tsx
@@ -0,0 +1,720 @@
+import clsx from "clsx";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { createRoot } from "react-dom/client";
+import { toCameraDevices, toMicrophoneDevices } from "../shared/devices";
+import {
+ reconcileRememberedDevices,
+ rememberCameraSelection,
+ rememberMicrophoneSelection,
+ rememberRecordingMode,
+} from "../shared/preferences";
+import { sendServiceWorkerMessage } from "../shared/runtime";
+import {
+ defaultSettings,
+ FAILED_RECORDINGS_KEY,
+ loadAuth,
+ loadCachedBootstrap,
+ loadFailedRecordings,
+ loadMediaAccessState,
+ loadSettings,
+ MEDIA_ACCESS_KEY,
+ type MediaAccessState,
+ saveSettings,
+ updateMediaAccessState,
+} from "../shared/storage";
+import type {
+ BootstrapData,
+ CameraDevice,
+ ExtensionAuth,
+ ExtensionSettings,
+ MicrophoneDevice,
+ RecordingMode,
+ RecordingStatus,
+} from "../shared/types";
+import { DEFAULT_MICROPHONE_DEVICE_ID } from "../shared/types";
+import { CameraSelector } from "./components/camera-selector";
+import { DashboardButton } from "./components/dashboard-button";
+import { HowItWorksButton } from "./components/how-it-works-button";
+import { MicrophoneSelector } from "./components/microphone-selector";
+import { RecorderHeader } from "./components/recorder-header";
+import { RecordingBar } from "./components/recording-bar";
+import { RecordingButton } from "./components/recording-button";
+import { RecordingModeSelector } from "./components/recording-mode-selector";
+import { SettingsButton } from "./components/settings-button";
+import { SignInView } from "./components/sign-in-view";
+import { SystemAudioToggle } from "./components/system-audio-toggle";
+import "./styles.css";
+
+type ActiveRecordingStatus = Extract<
+ RecordingStatus,
+ { phase: "recording" | "paused" | "uploading" }
+>;
+
+const PANEL_TOKEN = decodeURIComponent(window.location.hash.slice(1));
+const IS_EMBEDDED = PANEL_TOKEN.length > 0 && window.parent !== window;
+const DEFAULT_MEDIA_ACCESS: MediaAccessState = {
+ camera: false,
+ microphone: false,
+ updatedAt: 0,
+};
+
+const postPanelMessage = (
+ message: { type: "size"; height: number } | { type: "dismiss" },
+) => {
+ if (!IS_EMBEDDED) return;
+ window.parent.postMessage(
+ {
+ source: "cap-extension-panel",
+ token: PANEL_TOKEN,
+ ...message,
+ },
+ "*",
+ );
+};
+
+const isRecordingStatus = (
+ status: RecordingStatus,
+): status is ActiveRecordingStatus =>
+ status.phase === "recording" ||
+ status.phase === "paused" ||
+ status.phase === "uploading";
+
+function App() {
+ // This page is web accessible, so any site can put it in an iframe and
+ // overlay it for clickjacking. When embedded, render nothing until the
+ // service worker confirms the URL-hash token was registered by one of our
+ // content scripts — only the extension overlay can do that.
+ const [embedAuthorized, setEmbedAuthorized] = useState(!IS_EMBEDDED);
+ const [auth, setAuth] = useState(null);
+ const [bootstrap, setBootstrap] = useState(null);
+ const [settings, setSettings] = useState(defaultSettings);
+ const [status, setStatus] = useState({ phase: "idle" });
+ const [mode, setMode] = useState("fullscreen");
+ const [authPending, setAuthPending] = useState(false);
+ const [bootstrapped, setBootstrapped] = useState(false);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+ const [cameraDevices, setCameraDevices] = useState([]);
+ const [micDevices, setMicDevices] = useState([]);
+ const [mediaAccess, setMediaAccess] =
+ useState(DEFAULT_MEDIA_ACCESS);
+ const [cameraSelectOpen, setCameraSelectOpen] = useState(false);
+ const [micSelectOpen, setMicSelectOpen] = useState(false);
+ const [failedRecordingsCount, setFailedRecordingsCount] = useState(0);
+ const settingsRef = useRef(defaultSettings);
+
+ const recordingActive = isRecordingStatus(status);
+ const isPro = Boolean(bootstrap?.plan.isPro);
+ const cameraRequired = mode === "camera" && !settings.webcam.deviceId;
+
+ const selectedCameraId =
+ settings.webcam.enabled && settings.webcam.deviceId
+ ? settings.webcam.deviceId
+ : null;
+ const selectedMicId = settings.microphone.enabled
+ ? (settings.microphone.deviceId ?? DEFAULT_MICROPHONE_DEVICE_ID)
+ : null;
+
+ const updateSettings = useCallback(async (next: ExtensionSettings) => {
+ settingsRef.current = next;
+ setSettings(next);
+ setMode(next.capture.recordingMode);
+ await saveSettings(next);
+ await sendServiceWorkerMessage({
+ target: "service-worker",
+ type: "settings-updated",
+ settings: next,
+ }).catch(() => undefined);
+ }, []);
+
+ const applySettings = useCallback((next: ExtensionSettings) => {
+ settingsRef.current = next;
+ setSettings(next);
+ setMode(next.capture.recordingMode);
+ }, []);
+
+ const loadDevices = useCallback(async () => {
+ let cameras: CameraDevice[] = [];
+ let microphones: MicrophoneDevice[] = [];
+
+ if (navigator.mediaDevices?.enumerateDevices) {
+ try {
+ const devices = await navigator.mediaDevices.enumerateDevices();
+ cameras = toCameraDevices(devices);
+ microphones = toMicrophoneDevices(devices);
+ } catch {
+ // The offscreen fallback below covers the failure.
+ }
+ }
+
+ // This panel usually runs as a cross-origin iframe inside the host page,
+ // where Chrome withholds device labels from enumerateDevices() even though
+ // the extension origin holds the camera/mic grant. Ask the offscreen
+ // document (a top-level extension page that keeps the grant) for whichever
+ // list came up empty.
+ if (cameras.length === 0 || microphones.length === 0) {
+ const response = await sendServiceWorkerMessage({
+ target: "service-worker",
+ type: "get-media-devices",
+ }).catch(() => null);
+ if (response?.ok) {
+ if (cameras.length === 0 && response.cameraDevices) {
+ cameras = response.cameraDevices;
+ }
+ if (microphones.length === 0 && response.microphoneDevices) {
+ microphones = response.microphoneDevices;
+ }
+ }
+ }
+
+ setCameraDevices(cameras);
+ setMicDevices(microphones);
+ if (cameras.length > 0 || microphones.length > 0) {
+ const nextAccess = await updateMediaAccessState({
+ ...(cameras.length > 0 ? { camera: true } : {}),
+ ...(microphones.length > 0 ? { microphone: true } : {}),
+ });
+ setMediaAccess(nextAccess);
+ }
+ const reconciled = reconcileRememberedDevices(
+ settingsRef.current,
+ cameras,
+ microphones,
+ );
+ if (reconciled !== settingsRef.current) {
+ await updateSettings(reconciled);
+ }
+ }, [updateSettings]);
+
+ const openPermissionPage = () => {
+ chrome.tabs.create({
+ url: chrome.runtime.getURL("camera-permission.html"),
+ active: true,
+ });
+ };
+
+ useEffect(() => {
+ if (!IS_EMBEDDED) return;
+ let disposed = false;
+ sendServiceWorkerMessage({
+ target: "service-worker",
+ type: "validate-overlay-token",
+ token: PANEL_TOKEN,
+ })
+ .then((response) => {
+ if (!disposed) {
+ setEmbedAuthorized(response.ok && response.valid === true);
+ }
+ })
+ .catch(() => {
+ if (!disposed) setEmbedAuthorized(false);
+ });
+ return () => {
+ disposed = true;
+ };
+ }, []);
+
+ useEffect(() => {
+ if (!IS_EMBEDDED) return;
+ const postSize = () => {
+ postPanelMessage({
+ type: "size",
+ height: Math.ceil(document.body.getBoundingClientRect().height),
+ });
+ };
+ const observer = new ResizeObserver(postSize);
+ observer.observe(document.body);
+ postSize();
+ return () => observer.disconnect();
+ }, []);
+
+ useEffect(() => {
+ if (!IS_EMBEDDED) return;
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") postPanelMessage({ type: "dismiss" });
+ };
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, []);
+
+ useEffect(() => {
+ let disposed = false;
+
+ Promise.all([
+ loadSettings(),
+ loadAuth(),
+ loadCachedBootstrap(),
+ loadMediaAccessState(),
+ ])
+ .then(
+ ([cachedSettings, cachedAuth, cachedBootstrap, cachedMediaAccess]) => {
+ if (disposed || !cachedAuth) return;
+ applySettings(cachedSettings);
+ setAuth(cachedAuth);
+ setBootstrap(cachedBootstrap);
+ setMediaAccess(cachedMediaAccess);
+ setBootstrapped(true);
+ },
+ )
+ .catch(() => undefined);
+
+ sendServiceWorkerMessage({ target: "service-worker", type: "bootstrap" })
+ .then((response) => {
+ if (disposed) return;
+ setBootstrapped(true);
+ if (!response.ok) {
+ setError(response.error);
+ return;
+ }
+ setAuth(response.auth ?? null);
+ setAuthPending(Boolean(response.authPending && !response.auth));
+ setBootstrap(response.bootstrap ?? null);
+ if (response.authError) setError(response.authError);
+ if (response.cameraDevices) setCameraDevices(response.cameraDevices);
+ if (response.microphoneDevices)
+ setMicDevices(response.microphoneDevices);
+ if (response.settings) applySettings(response.settings);
+ if (response.status) setStatus(response.status);
+ })
+ .catch((err: unknown) => {
+ if (!disposed) {
+ setBootstrapped(true);
+ setError(err instanceof Error ? err.message : String(err));
+ }
+ });
+ return () => {
+ disposed = true;
+ };
+ }, [applySettings]);
+
+ useEffect(() => {
+ const handleMessage = (message: unknown) => {
+ if (!message || typeof message !== "object") return false;
+ const candidate = message as {
+ type?: unknown;
+ devices?: unknown;
+ };
+ if (
+ candidate.type === "camera-devices-changed" &&
+ Array.isArray(candidate.devices)
+ ) {
+ setCameraDevices(candidate.devices as CameraDevice[]);
+ }
+ return false;
+ };
+
+ chrome.runtime.onMessage.addListener(handleMessage);
+ return () => chrome.runtime.onMessage.removeListener(handleMessage);
+ }, []);
+
+ useEffect(() => {
+ const handleStorageChange = (
+ changes: Record,
+ areaName: string,
+ ) => {
+ if (areaName !== "local" || !changes[MEDIA_ACCESS_KEY]) return;
+ void loadMediaAccessState()
+ .then(setMediaAccess)
+ .catch(() => undefined);
+ };
+
+ chrome.storage.onChanged.addListener(handleStorageChange);
+ return () => chrome.storage.onChanged.removeListener(handleStorageChange);
+ }, []);
+
+ // Recordings whose upload failed (or that a crash stranded) wait in local
+ // storage; surface a small recovery link so they are discoverable from
+ // here instead of only from the options page.
+ useEffect(() => {
+ let disposed = false;
+ const refreshFailedRecordings = () => {
+ loadFailedRecordings()
+ .then((entries) => {
+ if (!disposed) setFailedRecordingsCount(entries.length);
+ })
+ .catch(() => undefined);
+ };
+
+ const handleStorageChange = (
+ changes: Record,
+ areaName: string,
+ ) => {
+ if (areaName !== "local" || !changes[FAILED_RECORDINGS_KEY]) return;
+ refreshFailedRecordings();
+ };
+
+ refreshFailedRecordings();
+ chrome.storage.onChanged.addListener(handleStorageChange);
+ return () => {
+ disposed = true;
+ chrome.storage.onChanged.removeListener(handleStorageChange);
+ };
+ }, []);
+
+ useEffect(() => {
+ if (!auth) return;
+
+ void loadDevices();
+
+ const handleDeviceChange = () => {
+ void loadDevices();
+ };
+
+ navigator.mediaDevices?.addEventListener(
+ "devicechange",
+ handleDeviceChange,
+ );
+ return () => {
+ navigator.mediaDevices?.removeEventListener(
+ "devicechange",
+ handleDeviceChange,
+ );
+ };
+ }, [auth, loadDevices]);
+
+ useEffect(() => {
+ if (!authPending || auth) return;
+ const interval = window.setInterval(() => {
+ sendServiceWorkerMessage({ target: "service-worker", type: "bootstrap" })
+ .then((response) => {
+ if (!response.ok) return;
+ setAuth(response.auth ?? null);
+ setAuthPending(Boolean(response.authPending && !response.auth));
+ setBootstrap(response.bootstrap ?? null);
+ // A failed sign-in clears authPending (stopping this poll), so
+ // the stored failure is surfaced here or never.
+ if (response.authError) setError(response.authError);
+ if (response.cameraDevices) setCameraDevices(response.cameraDevices);
+ if (response.microphoneDevices)
+ setMicDevices(response.microphoneDevices);
+ if (response.settings) applySettings(response.settings);
+ if (response.status) setStatus(response.status);
+ })
+ .catch(() => undefined);
+ }, 1000);
+ return () => window.clearInterval(interval);
+ }, [authPending, auth, applySettings]);
+
+ useEffect(() => {
+ if (!recordingActive) return;
+ const interval = window.setInterval(() => {
+ sendServiceWorkerMessage({
+ target: "service-worker",
+ type: "get-recording-status",
+ })
+ .then((response) => {
+ if (response.ok && response.status) setStatus(response.status);
+ })
+ .catch(() => undefined);
+ }, 1000);
+ return () => window.clearInterval(interval);
+ }, [recordingActive]);
+
+ const run = async (task: () => Promise) => {
+ setBusy(true);
+ setError(null);
+ try {
+ await task();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const signIn = () =>
+ run(async () => {
+ const response = await sendServiceWorkerMessage({
+ target: "service-worker",
+ type: "auth-start",
+ });
+ if (!response.ok) throw new Error(response.error);
+ setAuth(response.auth ?? null);
+ setAuthPending(Boolean(response.authPending && !response.auth));
+ setBootstrap(response.bootstrap ?? null);
+ if (response.settings) applySettings(response.settings);
+ });
+
+ // The microphone warning (no mic / no sound) is handled centrally in the
+ // service worker so the panel and the floating recording bar share one
+ // flow; a declined warning comes back as a cancellation.
+ const start = () =>
+ run(async () => {
+ if (cameraRequired) {
+ throw new Error("Select a camera before recording.");
+ }
+ const response = await sendServiceWorkerMessage({
+ target: "service-worker",
+ type: "start-recording",
+ mode,
+ });
+ if (!response.ok) {
+ // Dismissing the capture picker or the mic warning is a deliberate
+ // action, not a failure worth surfacing.
+ if (response.canceled) return;
+ throw new Error(response.error);
+ }
+ if (response.status) setStatus(response.status);
+ postPanelMessage({ type: "dismiss" });
+ });
+
+ const stop = () =>
+ run(async () => {
+ const response = await sendServiceWorkerMessage({
+ target: "service-worker",
+ type: "stop-recording",
+ });
+ if (!response.ok) throw new Error(response.error);
+ if (response.status) setStatus(response.status);
+ });
+
+ const pauseOrResume = () =>
+ run(async () => {
+ const response = await sendServiceWorkerMessage({
+ target: "service-worker",
+ type:
+ status.phase === "paused" ? "resume-recording" : "pause-recording",
+ });
+ if (!response.ok) throw new Error(response.error);
+ if (response.status) setStatus(response.status);
+ });
+
+ const openOptions = () =>
+ sendServiceWorkerMessage({
+ target: "service-worker",
+ type: "open-options",
+ }).catch(() => undefined);
+
+ const openHowItWorks = () =>
+ sendServiceWorkerMessage({
+ target: "service-worker",
+ type: "open-how-it-works",
+ }).catch(() => undefined);
+
+ const handleModeChange = (recordingMode: RecordingMode) => {
+ setMode(recordingMode);
+ void updateSettings(
+ rememberRecordingMode(settingsRef.current, recordingMode),
+ );
+ };
+
+ const handleCameraChange = (cameraId: string | null) => {
+ void updateSettings(
+ rememberCameraSelection(settingsRef.current, cameraId, cameraDevices),
+ );
+ };
+
+ const handleMicChange = (micId: string | null) => {
+ void updateSettings(
+ rememberMicrophoneSelection(settingsRef.current, micId, micDevices),
+ );
+ };
+
+ const handleUpgradeClick = () => {
+ chrome.tabs.create({
+ url: `${settings.apiBaseUrl}/pricing`,
+ active: true,
+ });
+ };
+
+ const openDashboard = () => {
+ chrome.tabs.create({
+ url: `${settings.apiBaseUrl}/dashboard`,
+ active: true,
+ });
+ };
+
+ const closePanel = () => {
+ if (busy) return;
+ // Closing the recorder tears down every piece of Cap UI: the panel,
+ // the camera preview and the recording bar in every tab.
+ postPanelMessage({ type: "dismiss" });
+ void sendServiceWorkerMessage({
+ target: "service-worker",
+ type: "close-extension-ui",
+ }).catch(() => undefined);
+ if (!IS_EMBEDDED) window.close();
+ };
+
+ const maxRecordingMs =
+ bootstrap && bootstrap.plan.maxRecordingSeconds !== null
+ ? bootstrap.plan.maxRecordingSeconds * 1000
+ : null;
+ const recordingTimerDisplayMs =
+ recordingActive && status.phase !== "uploading"
+ ? isPro || maxRecordingMs === null
+ ? status.durationMs
+ : Math.max(0, maxRecordingMs - status.durationMs)
+ : 0;
+
+ const recordingBarStatus = recordingActive
+ ? status.phase === "uploading"
+ ? status
+ : { ...status, durationMs: recordingTimerDisplayMs }
+ : null;
+
+ const signedOut = bootstrapped && !auth;
+
+ if (!embedAuthorized) return null;
+
+ return (
+
+
+ {auth && (
+
+
+ void openOptions()} />
+
+ )}
+
+
+ {!bootstrapped ? (
+
+
+
+ ) : auth ? (
+ <>
+
+
+
+
+ {
+ setCameraSelectOpen(isOpen);
+ if (isOpen) {
+ setMicSelectOpen(false);
+ }
+ }}
+ onCameraChange={handleCameraChange}
+ onRefreshDevices={loadDevices}
+ onPermissionBlocked={openPermissionPage}
+ />
+
+
+ {
+ setMicSelectOpen(isOpen);
+ if (isOpen) {
+ setCameraSelectOpen(false);
+ }
+ }}
+ onMicChange={handleMicChange}
+ onRefreshDevices={loadDevices}
+ onPermissionBlocked={openPermissionPage}
+ />
+
+ {mode !== "camera" && (
+
+
+ void updateSettings({
+ ...settings,
+ systemAudio: { ...settings.systemAudio, enabled },
+ })
+ }
+ />
+
+ )}
+
+ void start()}
+ onStop={() => void stop()}
+ />
+
+ {recordingBarStatus && (
+
+ void stop()}
+ onPauseResume={() => void pauseOrResume()}
+ />
+
+ )}
+ {status.phase === "error" && (
+
+ Recording failed. {" "}
+ {status.message}
+
+ )}
+
+ void openHowItWorks()} />
+
+ {failedRecordingsCount > 0 && (
+
+ void openOptions()}
+ className="flex w-full items-center justify-center gap-1 text-xs font-medium text-[var(--red-11)] transition-colors hover:text-[var(--red-12)]"
+ >
+ Recover {failedRecordingsCount}{" "}
+ {failedRecordingsCount === 1 ? "recording" : "recordings"}
+
+
+ )}
+ >
+ ) : (
+
void signIn()}
+ />
+ )}
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+ );
+}
+
+createRoot(document.getElementById("root") as HTMLElement).render( );
diff --git a/apps/chrome-extension/src/popup/styles.css b/apps/chrome-extension/src/popup/styles.css
new file mode 100644
index 00000000000..21cc017989c
--- /dev/null
+++ b/apps/chrome-extension/src/popup/styles.css
@@ -0,0 +1,526 @@
+@import "@radix-ui/colors/red.css";
+@import "@radix-ui/colors/red-dark.css";
+@import "@radix-ui/colors/gray.css";
+@import "@radix-ui/colors/gray-alpha.css";
+@import "@radix-ui/colors/gray-dark.css";
+@import "@radix-ui/colors/blue.css";
+@import "@radix-ui/colors/green.css";
+@import "@radix-ui/colors/purple.css";
+
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@font-face {
+ font-family: "Neue Montreal";
+ src: url("/fonts/NeueMontreal-Regular.otf") format("opentype");
+ font-weight: 400;
+ font-style: normal;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: "Neue Montreal";
+ src: url("/fonts/NeueMontreal-Italic.otf") format("opentype");
+ font-weight: 400;
+ font-style: italic;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: "Neue Montreal";
+ src: url("/fonts/NeueMontreal-Medium.otf") format("opentype");
+ font-weight: 500;
+ font-style: normal;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: "Neue Montreal";
+ src: url("/fonts/NeueMontreal-MediumItalic.otf") format("opentype");
+ font-weight: 500;
+ font-style: italic;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: "Neue Montreal";
+ src: url("/fonts/NeueMontreal-Bold.otf") format("opentype");
+ font-weight: 700;
+ font-style: normal;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: "Neue Montreal";
+ src: url("/fonts/NeueMontreal-BoldItalic.otf") format("opentype");
+ font-weight: 700;
+ font-style: italic;
+ font-display: swap;
+}
+
+:root {
+ --primary: #005cb1;
+ --primary-2: #004c93;
+ --primary-3: #003b73;
+ --secondary: #2eb4ff;
+ --secondary-2: #1696e0;
+ --secondary-3: #117ebd;
+ --tertiary: #c5eaff;
+ --tertiary-2: #d3e5ff;
+ --tertiary-3: #e0edff;
+ --filler: #efefef;
+ --filler-2: #e4e4e4;
+ --filler-3: #e2e2e2;
+ --filler-txt: #b3b3b3;
+ --text-primary: #0d1b2a;
+ --text-secondary: #ffffff;
+ color-scheme: light;
+ font-family:
+ "Neue Montreal", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
+ "Segoe UI", sans-serif;
+}
+
+@layer base {
+ :root {
+ --background: 0 0% 100%;
+ --foreground: 224 71.4% 4.1%;
+ --card: 0 0% 100%;
+ --card-foreground: 224 71.4% 4.1%;
+ --popover: 0 0% 100%;
+ --popover-foreground: 224 71.4% 4.1%;
+ --primary-foreground: 210 20% 98%;
+ --secondary-foreground: 220.9 39.3% 11%;
+ --muted: 220 14.3% 95.9%;
+ --muted-foreground: 220 8.9% 46.1%;
+ --accent: 220 14.3% 95.9%;
+ --accent-foreground: 220.9 39.3% 11%;
+ --destructive: 0 84.2% 60.2%;
+ --destructive-foreground: 210 20% 98%;
+ --border: 220 13% 91%;
+ --input: 220 13% 91%;
+ --ring: 224 71.4% 4.1%;
+ --radius: 0.5rem;
+ }
+
+ * {
+ @apply border-border;
+ }
+}
+
+*,
+*:before,
+*:after {
+ box-sizing: border-box;
+}
+
+* {
+ min-width: 0;
+ min-height: 0;
+}
+
+::selection {
+ background: #000000;
+ color: #ffffff;
+}
+
+::-webkit-scrollbar {
+ width: 4px;
+ height: 4px;
+}
+
+::-webkit-scrollbar-track {
+ border-radius: 10px;
+}
+
+::-webkit-scrollbar-thumb {
+ background: var(--gray-7);
+ border-radius: 10px;
+}
+
+h1,
+h2,
+h3,
+h4,
+h5 {
+ @apply m-0 font-normal tracking-normal;
+}
+
+a,
+p,
+span,
+input,
+label,
+button {
+ @apply tracking-normal leading-[1.5rem];
+}
+
+p {
+ @apply m-0;
+}
+
+a {
+ @apply transition-all;
+}
+
+label {
+ @apply block text-sm font-semibold text-left;
+}
+
+.dark-button-shadow {
+ box-shadow: 0 1.5px 0 0 rgba(255, 255, 255, 0.2) inset;
+}
+
+.gray-button-border {
+ @apply border-gray-8;
+}
+
+.dark-button-border {
+ @apply border-gray-12;
+}
+
+.gray-button-shadow {
+ box-shadow: 0 1.5px 0 0 rgba(255, 255, 255, 0.4) inset;
+}
+
+body {
+ margin: 0;
+ width: 100%;
+ color: var(--text-primary);
+ background: var(--gray-2);
+}
+
+.settings-slider {
+ -webkit-appearance: none;
+ appearance: none;
+ width: 100%;
+ height: 4px;
+ border-radius: 999px;
+ background: linear-gradient(
+ to right,
+ var(--blue-9) 0%,
+ var(--blue-9) var(--slider-progress, 50%),
+ var(--gray-4) var(--slider-progress, 50%),
+ var(--gray-4) 100%
+ );
+ outline: none;
+ cursor: pointer;
+}
+
+.settings-slider::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ appearance: none;
+ width: 16px;
+ height: 16px;
+ border-radius: 50%;
+ background: #ffffff;
+ border: 1px solid var(--gray-6);
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
+}
+
+/* Paper aesthetic shared with the welcome / how-it-works pages. */
+:root {
+ --paper: #fbfaf7;
+ --ink: #20242c;
+ --ink-soft: #8b8f98;
+ --paper-accent: #4785ff;
+ --track: #dcdcd4;
+}
+
+.cap-fade-up {
+ animation: cap-fade-up 0.45s ease both;
+}
+
+/* Drill-in sheet (device pickers) sliding over the recorder panel. */
+.cap-sheet-in {
+ animation: cap-sheet-in 0.2s ease both;
+}
+
+.cap-fade-up-1 {
+ animation-delay: 0.06s;
+}
+
+.cap-fade-up-2 {
+ animation-delay: 0.12s;
+}
+
+.cap-fade-up-3 {
+ animation-delay: 0.18s;
+}
+
+.cap-fade-up-4 {
+ animation-delay: 0.24s;
+}
+
+.cap-fade-up-5 {
+ animation-delay: 0.3s;
+}
+
+.cap-fade-up-6 {
+ animation-delay: 0.36s;
+}
+
+.cap-signin {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 4px 4px 10px;
+ text-align: center;
+}
+
+.cap-signin-brand {
+ height: 24px;
+ width: auto;
+ color: var(--ink);
+}
+
+.cap-signin-doodle {
+ width: 102px;
+ height: auto;
+ margin-top: 26px;
+ overflow: visible;
+}
+
+.cap-signin-boil {
+ filter: var(--cap-signin-boil, none);
+}
+
+.cap-signin-stroke {
+ fill: none;
+ stroke: var(--ink);
+ stroke-width: 4.5;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+
+.cap-signin-ring {
+ stroke-dasharray: 1;
+ stroke-dashoffset: 1;
+ animation: cap-draw 0.8s ease 0.35s forwards;
+}
+
+.cap-signin-dot {
+ fill: var(--paper-accent);
+ opacity: 0;
+ transform-box: fill-box;
+ transform-origin: center;
+ animation:
+ cap-pop 0.45s ease 1.05s both,
+ cap-soft-pulse 1.9s ease-in-out 2.2s infinite;
+}
+
+.cap-signin-spark {
+ fill: none;
+ stroke: var(--paper-accent);
+ stroke-width: 3.5;
+ stroke-linecap: round;
+ opacity: 0;
+ transform-box: fill-box;
+ transform-origin: center;
+ animation: cap-pop 0.5s ease forwards;
+}
+
+.cap-signin-spark.is-1 {
+ animation-delay: 1.2s;
+}
+
+.cap-signin-spark.is-2 {
+ animation-delay: 1.35s;
+}
+
+.cap-signin-spark.is-3 {
+ animation-delay: 1.5s;
+}
+
+.cap-signin h1 {
+ margin-top: 22px;
+ font-size: 19px;
+ font-weight: 500;
+ letter-spacing: -0.01em;
+ line-height: 1.25;
+ color: var(--ink);
+}
+
+.cap-signin-lede {
+ margin-top: 8px;
+ max-width: 236px;
+ font-size: 13px;
+ line-height: 1.5;
+ color: var(--ink-soft);
+}
+
+.cap-signin-wait {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ margin-top: 16px;
+ font-size: 12.5px;
+ font-weight: 500;
+ line-height: 1.4;
+ color: var(--ink-soft);
+}
+
+.cap-signin-wait svg {
+ width: 15px;
+ height: 15px;
+ flex: 0 0 auto;
+ overflow: visible;
+}
+
+.cap-signin-wait circle {
+ fill: none;
+ stroke: var(--paper-accent);
+ stroke-width: 3;
+ stroke-linecap: round;
+ stroke-dasharray: 0.125 0.125;
+ animation: cap-march 1.1s linear infinite;
+}
+
+.cap-paper-cta {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ width: 100%;
+ min-height: 42px;
+ margin-top: 22px;
+ padding: 0 24px;
+ border: 1.5px solid var(--ink);
+ border-radius: 999px;
+ background: transparent;
+ color: var(--ink);
+ font-family: inherit;
+ font-size: 14px;
+ font-weight: 500;
+ line-height: 1.4;
+ cursor: pointer;
+ transition:
+ background 0.2s ease,
+ color 0.2s ease,
+ border-color 0.2s ease,
+ opacity 0.2s ease;
+}
+
+.cap-paper-cta:hover:not(:disabled) {
+ background: var(--ink);
+ color: var(--paper);
+}
+
+.cap-paper-cta:focus-visible {
+ outline: 2px solid var(--paper-accent);
+ outline-offset: 2px;
+}
+
+.cap-paper-cta:disabled {
+ cursor: not-allowed;
+ opacity: 0.45;
+}
+
+.cap-paper-cta.is-ghost {
+ border-color: var(--track);
+ color: var(--ink-soft);
+}
+
+.cap-paper-cta.is-ghost:hover:not(:disabled) {
+ background: transparent;
+ border-color: var(--ink-soft);
+ color: var(--ink);
+}
+
+.cap-signin-footnote {
+ margin-top: 14px;
+ font-size: 12px;
+ line-height: 1.5;
+ color: var(--ink-soft);
+}
+
+.cap-paper-error {
+ padding: 10px 14px;
+ border: 1.5px solid #e5484d;
+ border-radius: 14px;
+ color: #e5484d;
+ font-size: 12px;
+ line-height: 1.5;
+ text-align: left;
+}
+
+@keyframes cap-fade-up {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+ to {
+ opacity: 1;
+ transform: none;
+ }
+}
+
+@keyframes cap-sheet-in {
+ from {
+ opacity: 0;
+ transform: translateX(14px);
+ }
+ to {
+ opacity: 1;
+ transform: none;
+ }
+}
+
+@keyframes cap-draw {
+ to {
+ stroke-dashoffset: 0;
+ }
+}
+
+@keyframes cap-pop {
+ from {
+ opacity: 0;
+ transform: scale(0.2);
+ }
+ 60% {
+ opacity: 1;
+ transform: scale(1.15);
+ }
+ to {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
+
+@keyframes cap-soft-pulse {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.4;
+ }
+}
+
+@keyframes cap-march {
+ to {
+ stroke-dashoffset: -0.25;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .cap-signin-boil {
+ filter: none;
+ }
+
+ .cap-fade-up,
+ .cap-sheet-in,
+ .cap-signin-ring,
+ .cap-signin-dot,
+ .cap-signin-spark {
+ animation-duration: 0.01ms;
+ animation-delay: 0s;
+ animation-iteration-count: 1;
+ }
+
+ .cap-signin-wait circle {
+ animation: none;
+ }
+}
diff --git a/apps/chrome-extension/src/popup/ui/button.tsx b/apps/chrome-extension/src/popup/ui/button.tsx
new file mode 100644
index 00000000000..69c4a8c6894
--- /dev/null
+++ b/apps/chrome-extension/src/popup/ui/button.tsx
@@ -0,0 +1,68 @@
+import { cva, type VariantProps } from "class-variance-authority";
+import * as React from "react";
+import { classNames } from "./class-names";
+
+const buttonVariants = cva(
+ "flex items-center justify-center transition-colors duration-200 rounded-full disabled:cursor-not-allowed cursor-pointer font-medium px-5 ring-offset-transparent relative gap-1",
+ {
+ defaultVariants: {
+ variant: "primary",
+ size: "md",
+ },
+ variants: {
+ variant: {
+ primary:
+ "bg-gray-12 dark-button-shadow text-gray-1 disabled:bg-gray-6 disabled:text-gray-9",
+ blue: "bg-blue-600 text-white disabled:border-gray-8 border border-blue-800 shadow-[0_1.50px_0_0_rgba(255,255,255,0.20)_inset] hover:bg-blue-700 disabled:bg-gray-7 disabled:text-gray-10",
+ destructive:
+ "bg-red-500 text-white border-transparent hover:bg-red-600 disabled:bg-gray-7 disabled:border-gray-8 border disabled:text-gray-10",
+ outline:
+ "border border-gray-4 hover:border-gray-5 hover:bg-gray-3 text-gray-12 disabled:bg-gray-8",
+ white:
+ "bg-gray-3 border border-gray-5 hover:border-gray-6 text-gray-12 hover:bg-gray-6 disabled:bg-gray-8",
+ gray: "bg-gray-5 hover:bg-gray-7 border gray-button-border gray-button-shadow text-gray-12 disabled:border-gray-7 disabled:bg-gray-8 disabled:text-gray-11",
+ dark: "bg-gray-12 dark-button-shadow hover:bg-gray-11 border dark-button-border text-gray-1 disabled:cursor-not-allowed disabled:text-gray-10 disabled:bg-gray-7 disabled:border-gray-8",
+ },
+ size: {
+ xs: "text-xs h-[32px]",
+ sm: "text-sm h-[40px]",
+ md: "text-sm h-[44px]",
+ lg: "text-md h-[48px]",
+ icon: "h-9 w-9",
+ },
+ },
+ },
+);
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes,
+ VariantProps {
+ href?: string;
+ target?: string;
+}
+
+const Button = React.forwardRef(
+ ({ className, variant, size, href, target, ...props }, ref) => {
+ if (href) {
+ return (
+
+ {props.children}
+
+ );
+ }
+ return (
+
+ );
+ },
+);
+Button.displayName = "Button";
+
+export { Button, buttonVariants };
diff --git a/apps/chrome-extension/src/popup/ui/class-names.ts b/apps/chrome-extension/src/popup/ui/class-names.ts
new file mode 100644
index 00000000000..293ec62f1ff
--- /dev/null
+++ b/apps/chrome-extension/src/popup/ui/class-names.ts
@@ -0,0 +1,6 @@
+import { type ClassValue, clsx } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function classNames(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/apps/chrome-extension/src/popup/ui/select.tsx b/apps/chrome-extension/src/popup/ui/select.tsx
new file mode 100644
index 00000000000..4e1178740a1
--- /dev/null
+++ b/apps/chrome-extension/src/popup/ui/select.tsx
@@ -0,0 +1,266 @@
+import * as SelectPrimitive from "@radix-ui/react-select";
+import { cva, cx } from "class-variance-authority";
+import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
+import * as React from "react";
+
+type SelectVariant = "default" | "light" | "dark" | "gray" | "transparent";
+type Size = "default" | "fit" | "sm" | "md" | "lg";
+
+const SelectVariantContext = React.createContext("default");
+
+const selectTriggerVariants = cva(
+ cx(
+ "font-medium flex transition-all duration-200 text-[13px] outline-0",
+ "border items-center justify-between gap-2 whitespace-nowrap",
+ "disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-gray-3 disabled:text-gray-9",
+ "ring-0 ring-gray-6 ring-offset-gray-6 data-[state=open]:border-gray-7 data-[state=open]:ring-gray-7 data-[state=open]:ring-1 data-[state=open]:ring-offset-0",
+ "data-placeholder:text-gray-12",
+ "data-[slot=select-value]:*:line-clamp-1 data-[slot=select-value]:*:flex data-[slot=select-value]:*:items-center data-[slot=select-value]:*:gap-2",
+ "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg]:transition-transform [&_svg]:duration-200",
+ "[&[data-state=open]_svg.caret-icon]:rotate-180",
+ ),
+ {
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ variants: {
+ variant: {
+ default:
+ "bg-gray-2 border-gray-5 text-gray-12 hover:bg-gray-3 hover:border-gray-6",
+ dark: "bg-gray-12 transition-all duration-200 data-[state=open]:ring-offset-2 data-[state=open]:ring-gray-10 ring-transparent ring-offset-gray-3 text-gray-1 border-gray-5 hover:bg-gray-11 hover:border-gray-6",
+ light:
+ "bg-gray-1 transition-all duration-200 data-[state=open]:ring-offset-2 data-[state=open]:ring-gray-10 ring-transparent ring-offset-gray-3 text-gray-12 border-gray-5 hover:bg-gray-3 hover:border-gray-6",
+ gray: "bg-gray-5 text-gray-12 border-gray-5 hover:bg-gray-7 hover:border-gray-6",
+ transparent:
+ "bg-transparent text-gray-12 border-transparent hover:bg-gray-3 hover:border-gray-6",
+ },
+ size: {
+ default: "w-full h-[44px] px-4 rounded-xl",
+ fit: "w-fit h-[32px] px-3 rounded-[10px]",
+ sm: "w-fit h-[32px] px-3 rounded-[10px]",
+ md: "w-fit h-[40px] px-3 rounded-xl",
+ lg: "w-fit h-[48px] px-4 rounded-xl",
+ },
+ },
+ },
+);
+
+const selectContentVariants = cva(
+ cx(
+ "z-[1000] rounded-xl border overflow-hidden",
+ "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ "data-[state=open]:animate-in data-[state=closed]:animate-out",
+ "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
+ "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
+ "data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2",
+ "data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
+ ),
+ {
+ defaultVariants: {
+ variant: "default",
+ },
+ variants: {
+ variant: {
+ default: "bg-gray-2 border-gray-5 text-gray-12",
+ dark: "hover:bg-gray-11-50 bg-gray-12 dark-button-border dark-button-shadow text-gray-1 border-gray-5",
+ light:
+ "bg-gray-1 transition-all duration-200 data-[state=open]:ring-offset-2 data-[state=open]:ring-gray-10 ring-transparent ring-offset-gray-3 text-gray-12 border-gray-5",
+ gray: "bg-gray-5 text-gray-12 border-gray-5",
+ transparent:
+ "bg-transparent hover:bg-gray-3 text-gray-12 border-transparent",
+ },
+ },
+ },
+);
+
+const selectItemVariants = cva(
+ cx(
+ "relative flex w-full cursor-default items-center gap-2 py-2 pr-8 pl-3 text-[13px]",
+ "rounded-lg outline-hidden select-none transition-colors duration-200",
+ "data-disabled:pointer-events-none data-disabled:opacity-50 data-disabled:text-gray-9",
+ "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ ),
+ {
+ defaultVariants: {
+ variant: "default",
+ },
+ variants: {
+ variant: {
+ default: "text-gray-12 hover:bg-gray-3 focus:bg-gray-3",
+ dark: "text-gray-1 hover:text-gray-12 focus:text-gray-12 hover:bg-gray-1 focus:bg-gray-1",
+ light:
+ "text-gray-12 hover:text-gray-12 hover:bg-gray-3 focus:bg-gray-3",
+ gray: "text-gray-12 hover:text-gray-12 hover:bg-gray-6 focus:bg-gray-6",
+ transparent:
+ "text-gray-12 hover:text-gray-12 hover:bg-gray-3 focus:bg-gray-3",
+ },
+ },
+ },
+);
+
+function SelectRoot({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function SelectValue({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function SelectTrigger({
+ className,
+ icon,
+ size = "default",
+ variant = "default",
+ children,
+ ...props
+}: React.ComponentProps & {
+ size?: Size;
+ variant?: SelectVariant;
+ icon?: React.ReactNode;
+}) {
+ const iconSizeVariant = {
+ default: "size-2.5",
+ fit: "size-2",
+ sm: "size-2",
+ md: "size-3",
+ lg: "size-3",
+ };
+ return (
+
+ {icon &&
+ React.cloneElement(icon as React.ReactElement<{ className: string }>, {
+ className: cx(iconSizeVariant[size], "text-gray-9"),
+ })}
+ {children}
+
+
+
+
+ );
+}
+
+function SelectContent({
+ className,
+ children,
+ position = "popper",
+ variant = "default",
+ sideOffset = 4,
+ ...props
+}: React.ComponentProps & {
+ variant?: SelectVariant;
+}) {
+ return (
+
+
+
+
+
+ {children}
+
+
+
+
+
+ );
+}
+
+function SelectItem({
+ className,
+ children,
+ icon,
+ ...props
+}: React.ComponentProps & {
+ icon?: React.ReactNode;
+}) {
+ const variant = React.useContext(SelectVariantContext);
+
+ return (
+
+
+
+
+
+
+
+ {children}
+ {icon &&
+ React.cloneElement(
+ icon as React.ReactElement<{ className: string }>,
+ { className: cx("size-3", "text-gray-9") },
+ )}
+
+
+ );
+}
+
+function SelectScrollUpButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+function SelectScrollDownButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+export {
+ SelectRoot,
+ SelectContent,
+ SelectItem,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectTrigger,
+ SelectValue,
+};
diff --git a/apps/chrome-extension/src/popup/ui/switch.tsx b/apps/chrome-extension/src/popup/ui/switch.tsx
new file mode 100644
index 00000000000..ab69d514cc1
--- /dev/null
+++ b/apps/chrome-extension/src/popup/ui/switch.tsx
@@ -0,0 +1,32 @@
+import * as SwitchPrimitives from "@radix-ui/react-switch";
+import * as React from "react";
+import { classNames } from "./class-names";
+
+const Switch = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+));
+Switch.displayName = SwitchPrimitives.Root.displayName;
+
+export { Switch };
diff --git a/apps/chrome-extension/src/preview/camera-preview.tsx b/apps/chrome-extension/src/preview/camera-preview.tsx
new file mode 100644
index 00000000000..bd4cb18fdcc
--- /dev/null
+++ b/apps/chrome-extension/src/preview/camera-preview.tsx
@@ -0,0 +1,636 @@
+import { PictureInPicture } from "lucide-react";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { createRoot } from "react-dom/client";
+import { toCameraDevices } from "../shared/devices";
+import { sendServiceWorkerMessage } from "../shared/runtime";
+import type {
+ CameraPreviewErrorReason,
+ CameraPreviewEvent,
+ WebcamPreviewFrame,
+ WebcamSettings,
+} from "../shared/types";
+import {
+ toSessionDescriptionInit,
+ waitForIceGatheringComplete,
+} from "../shared/webrtc";
+import "./styles.css";
+
+const FRAME_CAPTURE_INTERVAL_MS = 700;
+const FRAME_CAPTURE_MAX_WIDTH = 320;
+
+type ParentMessage =
+ | {
+ source: "cap-extension-overlay";
+ token: string;
+ type: "settings";
+ settings: WebcamSettings;
+ }
+ | {
+ source: "cap-extension-overlay";
+ token: string;
+ type: "toggle-pip";
+ }
+ | {
+ source: "cap-extension-overlay";
+ token: string;
+ type: "enter-pip";
+ }
+ | {
+ source: "cap-extension-overlay";
+ token: string;
+ type: "exit-auto-pip";
+ }
+ | {
+ source: "cap-extension-overlay";
+ token: string;
+ type: "stop";
+ };
+
+const token = decodeURIComponent(window.location.hash.slice(1));
+
+const isParentMessage = (value: unknown): value is ParentMessage => {
+ if (!value || typeof value !== "object") return false;
+ const candidate = value as Partial;
+ return (
+ candidate.source === "cap-extension-overlay" && candidate.token === token
+ );
+};
+
+// Events for the embedding overlay travel over chrome.runtime via the
+// service worker, never window.parent.postMessage: the parent window is the
+// recorded web page, and a postMessage stream — webcam frames above all —
+// would be readable by any listener that page installs. The service worker
+// validates this frame's URL and registered token before relaying to the
+// embedding tab's content script.
+const postParent = (event: CameraPreviewEvent) => {
+ chrome.runtime.sendMessage(
+ {
+ target: "service-worker",
+ type: "camera-preview-event",
+ token,
+ event,
+ },
+ () => {
+ void chrome.runtime.lastError;
+ },
+ );
+};
+
+const waitForRemoteStream = (peer: RTCPeerConnection) =>
+ new Promise((resolve, reject) => {
+ let settled = false;
+ const timeout = window.setTimeout(() => {
+ if (settled) return;
+ settled = true;
+ reject(new Error("Camera preview timed out."));
+ }, 10000);
+
+ const finish = (stream: MediaStream) => {
+ if (settled) return;
+ settled = true;
+ window.clearTimeout(timeout);
+ resolve(stream);
+ };
+
+ peer.addEventListener("track", (event) => {
+ finish(event.streams[0] ?? new MediaStream([event.track]));
+ });
+
+ peer.addEventListener("connectionstatechange", () => {
+ if (
+ settled ||
+ (peer.connectionState !== "failed" && peer.connectionState !== "closed")
+ ) {
+ return;
+ }
+ settled = true;
+ window.clearTimeout(timeout);
+ reject(new Error("Camera preview connection failed."));
+ });
+ });
+
+const connectCameraPreview = async (
+ settings: WebcamSettings,
+ sessionId: string,
+) => {
+ const peer = new RTCPeerConnection();
+ peer.addTransceiver("video", { direction: "recvonly" });
+ const remoteStreamPromise = waitForRemoteStream(peer);
+ await peer.setLocalDescription(await peer.createOffer());
+ await waitForIceGatheringComplete(peer);
+
+ const response = await sendServiceWorkerMessage({
+ target: "service-worker",
+ type: "connect-camera-preview",
+ sessionId,
+ settings,
+ offer: toSessionDescriptionInit(peer.localDescription),
+ });
+
+ if (!response.ok) {
+ peer.close();
+ throw new Error(response.error);
+ }
+ if (!response.answer) {
+ peer.close();
+ throw new Error("Camera preview did not return an answer.");
+ }
+
+ await peer.setRemoteDescription(response.answer);
+ return {
+ peer,
+ stream: await remoteStreamPromise,
+ };
+};
+
+let frameCanvas: HTMLCanvasElement | null = null;
+
+const captureVideoFrame = (
+ video: HTMLVideoElement,
+): WebcamPreviewFrame | null => {
+ if (video.videoWidth <= 0 || video.videoHeight <= 0) return null;
+ frameCanvas ??= document.createElement("canvas");
+ const scale = Math.min(1, FRAME_CAPTURE_MAX_WIDTH / video.videoWidth);
+ const width = Math.max(1, Math.round(video.videoWidth * scale));
+ const height = Math.max(1, Math.round(video.videoHeight * scale));
+ frameCanvas.width = width;
+ frameCanvas.height = height;
+ const context = frameCanvas.getContext("2d");
+ if (!context) return null;
+ context.drawImage(video, 0, 0, width, height);
+ return {
+ dataUrl: frameCanvas.toDataURL("image/jpeg", 0.72),
+ dimensions: {
+ width: video.videoWidth,
+ height: video.videoHeight,
+ },
+ capturedAt: Date.now(),
+ };
+};
+
+const stopStream = (stream: MediaStream | null) => {
+ if (!stream) return;
+ for (const track of stream.getTracks()) {
+ track.stop();
+ }
+};
+
+const disconnectCameraPreview = (sessionId: string | null) => {
+ if (!sessionId) return;
+ void sendServiceWorkerMessage({
+ target: "service-worker",
+ type: "disconnect-camera-preview",
+ sessionId,
+ }).catch(() => undefined);
+};
+
+const isSameWebcamSettings = (
+ current: WebcamSettings | null,
+ next: WebcamSettings,
+) =>
+ current?.enabled === next.enabled &&
+ current.deviceId === next.deviceId &&
+ current.position === next.position &&
+ current.size === next.size &&
+ current.shape === next.shape &&
+ current.mirror === next.mirror;
+
+const getCameraErrorDetails = (
+ error: unknown,
+): { reason: CameraPreviewErrorReason; message: string } => {
+ if (!(error instanceof Error)) {
+ return { reason: "unknown", message: "Camera unavailable" };
+ }
+ const lowerMessage = error.message.toLowerCase();
+ if (error.name === "NotAllowedError" || lowerMessage.includes("permission")) {
+ return {
+ reason: "permission",
+ message:
+ "Camera permission was dismissed. Select a camera again and choose Allow.",
+ };
+ }
+ if (error.name === "NotFoundError") {
+ return { reason: "not-found", message: "Selected camera was not found." };
+ }
+ if (error.name === "NotReadableError") {
+ return { reason: "in-use", message: "Selected camera is already in use." };
+ }
+ return { reason: "unknown", message: error.message || "Camera unavailable" };
+};
+
+type AutoPictureInPictureVideo = HTMLVideoElement & {
+ autoPictureInPicture?: boolean;
+};
+
+const publishCameraDevices = async () => {
+ const devices = toCameraDevices(
+ await navigator.mediaDevices.enumerateDevices(),
+ );
+ chrome.runtime.sendMessage(
+ {
+ target: "service-worker",
+ type: "camera-devices-updated",
+ devices,
+ },
+ () => undefined,
+ );
+};
+
+function App() {
+ const [settings, setSettings] = useState(null);
+ const [isInPictureInPicture, setIsInPictureInPicture] = useState(false);
+ const videoRef = useRef(null);
+ const streamRef = useRef(null);
+ const peerRef = useRef(null);
+ const activeDeviceRef = useRef(null);
+ const sessionIdRef = useRef(null);
+ const sessionCounterRef = useRef(0);
+ const autoPictureInPictureRef = useRef(false);
+ const autoPictureInPictureEnabledRef = useRef(false);
+ const previewEnabled = Boolean(settings?.enabled && settings.deviceId);
+ const isPictureInPictureSupported =
+ typeof document !== "undefined" && document.pictureInPictureEnabled;
+
+ const publishPreviewFrame = useCallback(() => {
+ const currentVideo = videoRef.current;
+ if (!currentVideo) return;
+ const frame = captureVideoFrame(currentVideo);
+ if (!frame) return;
+ postParent({
+ type: "frame",
+ frame,
+ });
+ }, []);
+
+ const setAutomaticPictureInPicture = useCallback((enabled: boolean) => {
+ autoPictureInPictureEnabledRef.current = enabled;
+ const currentVideo = videoRef.current as AutoPictureInPictureVideo | null;
+ if (currentVideo && "autoPictureInPicture" in currentVideo) {
+ currentVideo.autoPictureInPicture = enabled;
+ }
+
+ try {
+ navigator.mediaSession?.setActionHandler(
+ "enterpictureinpicture" as MediaSessionAction,
+ enabled
+ ? async () => {
+ const video = videoRef.current;
+ if (!video || document.pictureInPictureElement) return;
+ try {
+ autoPictureInPictureRef.current = true;
+ await video.requestPictureInPicture();
+ } catch {
+ autoPictureInPictureRef.current = false;
+ }
+ }
+ : null,
+ );
+ } catch {}
+ }, []);
+
+ const stopPreview = useCallback(() => {
+ setAutomaticPictureInPicture(false);
+ if (
+ videoRef.current &&
+ document.pictureInPictureElement === videoRef.current
+ ) {
+ document.exitPictureInPicture().catch(() => undefined);
+ }
+ stopStream(streamRef.current);
+ peerRef.current?.close();
+ disconnectCameraPreview(sessionIdRef.current);
+ peerRef.current = null;
+ streamRef.current = null;
+ activeDeviceRef.current = null;
+ sessionIdRef.current = null;
+ videoRef.current?.removeAttribute("src");
+ if (videoRef.current) {
+ videoRef.current.srcObject = null;
+ }
+ setIsInPictureInPicture(false);
+ postParent({
+ type: "pip-state",
+ active: false,
+ supported: isPictureInPictureSupported,
+ });
+ }, [isPictureInPictureSupported, setAutomaticPictureInPicture]);
+
+ const enterPictureInPicture = useCallback(
+ async (auto: boolean) => {
+ const currentVideo = videoRef.current;
+ if (auto) {
+ setAutomaticPictureInPicture(true);
+ }
+ if (!currentVideo || !isPictureInPictureSupported) return;
+
+ try {
+ if (document.pictureInPictureElement === currentVideo) {
+ if (!auto) autoPictureInPictureRef.current = false;
+ return;
+ }
+ if (document.pictureInPictureElement) return;
+ await currentVideo.requestPictureInPicture();
+ autoPictureInPictureRef.current = auto;
+ } catch {
+ autoPictureInPictureRef.current = false;
+ }
+ },
+ [isPictureInPictureSupported, setAutomaticPictureInPicture],
+ );
+
+ const exitAutoPictureInPicture = useCallback(async () => {
+ setAutomaticPictureInPicture(false);
+ const currentVideo = videoRef.current;
+ if (
+ !currentVideo ||
+ !autoPictureInPictureRef.current ||
+ document.pictureInPictureElement !== currentVideo
+ ) {
+ return;
+ }
+
+ try {
+ await document.exitPictureInPicture();
+ } catch {
+ autoPictureInPictureRef.current = false;
+ }
+ }, [setAutomaticPictureInPicture]);
+
+ const togglePictureInPicture = useCallback(async () => {
+ const currentVideo = videoRef.current;
+ if (!currentVideo || !isPictureInPictureSupported) return;
+
+ try {
+ setAutomaticPictureInPicture(false);
+ autoPictureInPictureRef.current = false;
+ if (document.pictureInPictureElement === currentVideo) {
+ await document.exitPictureInPicture();
+ } else {
+ await currentVideo.requestPictureInPicture();
+ }
+ } catch {
+ autoPictureInPictureRef.current = false;
+ }
+ }, [isPictureInPictureSupported, setAutomaticPictureInPicture]);
+
+ useEffect(() => {
+ postParent({
+ type: "ready",
+ });
+ postParent({
+ type: "pip-state",
+ active: false,
+ supported: isPictureInPictureSupported,
+ });
+ }, [isPictureInPictureSupported]);
+
+ useEffect(() => {
+ // Control messages arrive over chrome.runtime, which the host page (and
+ // other extensions) cannot speak — window messages were forgeable since
+ // the token is readable from the iframe src in the page DOM. The token
+ // check scopes the runtime broadcast to this tab's preview.
+ const handleMessage = (message: unknown) => {
+ if (!isParentMessage(message)) return false;
+
+ if (message.type === "settings") {
+ const nextSettings = message.settings;
+ setSettings((current) =>
+ isSameWebcamSettings(current, nextSettings) ? current : nextSettings,
+ );
+ window.setTimeout(publishPreviewFrame, 0);
+ return false;
+ }
+
+ if (message.type === "toggle-pip") {
+ void togglePictureInPicture();
+ return false;
+ }
+
+ if (message.type === "enter-pip") {
+ void enterPictureInPicture(true);
+ return false;
+ }
+
+ if (message.type === "exit-auto-pip") {
+ void exitAutoPictureInPicture();
+ return false;
+ }
+
+ setSettings(null);
+ stopPreview();
+ return false;
+ };
+
+ chrome.runtime.onMessage.addListener(handleMessage);
+ return () => chrome.runtime.onMessage.removeListener(handleMessage);
+ }, [
+ enterPictureInPicture,
+ exitAutoPictureInPicture,
+ publishPreviewFrame,
+ stopPreview,
+ togglePictureInPicture,
+ ]);
+
+ useEffect(() => {
+ if (!previewEnabled || !settings) {
+ stopPreview();
+ return;
+ }
+
+ let disposed = false;
+
+ const startPreview = async () => {
+ const peerActive =
+ peerRef.current &&
+ peerRef.current.connectionState !== "closed" &&
+ peerRef.current.connectionState !== "failed" &&
+ peerRef.current.connectionState !== "disconnected";
+ if (
+ streamRef.current &&
+ peerActive &&
+ activeDeviceRef.current === settings.deviceId
+ ) {
+ if (
+ videoRef.current &&
+ videoRef.current.srcObject !== streamRef.current
+ ) {
+ videoRef.current.srcObject = streamRef.current;
+ }
+ await videoRef.current?.play().catch(() => undefined);
+ return;
+ }
+
+ stopPreview();
+ const sessionId = `${token}:${Date.now()}:${sessionCounterRef.current + 1}`;
+ sessionCounterRef.current += 1;
+ sessionIdRef.current = sessionId;
+ postParent({
+ type: "session",
+ sessionId,
+ });
+
+ try {
+ const { peer, stream } = await connectCameraPreview(
+ settings,
+ sessionId,
+ );
+
+ if (disposed || sessionIdRef.current !== sessionId) {
+ peer.close();
+ stopStream(stream);
+ disconnectCameraPreview(sessionId);
+ return;
+ }
+
+ peerRef.current = peer;
+ streamRef.current = stream;
+ activeDeviceRef.current = settings.deviceId;
+
+ if (videoRef.current) {
+ videoRef.current.srcObject = stream;
+ if (autoPictureInPictureEnabledRef.current) {
+ setAutomaticPictureInPicture(true);
+ }
+ await videoRef.current.play().catch(() => undefined);
+ }
+ publishPreviewFrame();
+ await publishCameraDevices().catch(() => undefined);
+ } catch (error) {
+ if (!disposed) {
+ const details = getCameraErrorDetails(error);
+ stopPreview();
+ postParent({
+ type: "error",
+ reason: details.reason,
+ message: details.message,
+ });
+ }
+ }
+ };
+
+ void startPreview();
+
+ return () => {
+ disposed = true;
+ };
+ }, [
+ previewEnabled,
+ publishPreviewFrame,
+ settings,
+ setAutomaticPictureInPicture,
+ stopPreview,
+ ]);
+
+ useEffect(() => {
+ if (!previewEnabled) return;
+ const interval = window.setInterval(
+ publishPreviewFrame,
+ FRAME_CAPTURE_INTERVAL_MS,
+ );
+ return () => window.clearInterval(interval);
+ }, [previewEnabled, publishPreviewFrame]);
+
+ useEffect(() => {
+ if (!videoRef.current || !isPictureInPictureSupported) {
+ return;
+ }
+
+ const currentVideo = videoRef.current;
+ const handlePipEnter = () => {
+ setIsInPictureInPicture(true);
+ postParent({
+ type: "pip-state",
+ active: true,
+ supported: true,
+ });
+ };
+ const handlePipLeave = () => {
+ autoPictureInPictureRef.current = false;
+ setAutomaticPictureInPicture(false);
+ setIsInPictureInPicture(false);
+ postParent({
+ type: "pip-state",
+ active: false,
+ supported: true,
+ });
+ };
+
+ currentVideo.addEventListener("enterpictureinpicture", handlePipEnter);
+ currentVideo.addEventListener("leavepictureinpicture", handlePipLeave);
+
+ return () => {
+ currentVideo.removeEventListener("enterpictureinpicture", handlePipEnter);
+ currentVideo.removeEventListener("leavepictureinpicture", handlePipLeave);
+ };
+ }, [isPictureInPictureSupported, setAutomaticPictureInPicture]);
+
+ useEffect(() => {
+ return () => {
+ stopPreview();
+ };
+ }, [stopPreview]);
+
+ return (
+ {
+ if ((event.target as HTMLElement).closest("[data-pip-control]")) {
+ return;
+ }
+ event.preventDefault();
+ postParent({
+ type: "drag-start",
+ clientX: event.clientX,
+ clientY: event.clientY,
+ });
+ }}
+ onPointerUp={() => {
+ postParent({ type: "drag-end" });
+ }}
+ onPointerCancel={() => {
+ postParent({ type: "drag-end" });
+ }}
+ >
+
{
+ const currentVideo = videoRef.current;
+ if (!currentVideo) return;
+ if (currentVideo.videoWidth > 0 && currentVideo.videoHeight > 0) {
+ postParent({
+ type: "metadata",
+ dimensions: {
+ width: currentVideo.videoWidth,
+ height: currentVideo.videoHeight,
+ },
+ });
+ publishPreviewFrame();
+ }
+ }}
+ onLoadedData={publishPreviewFrame}
+ onPlaying={publishPreviewFrame}
+ />
+ {isPictureInPictureSupported && !isInPictureInPicture ? (
+
+
+
+ ) : null}
+
+ );
+}
+
+createRoot(document.getElementById("root") as HTMLElement).render( );
diff --git a/apps/chrome-extension/src/preview/styles.css b/apps/chrome-extension/src/preview/styles.css
new file mode 100644
index 00000000000..6cef8268e2a
--- /dev/null
+++ b/apps/chrome-extension/src/preview/styles.css
@@ -0,0 +1,82 @@
+:root {
+ color-scheme: dark;
+ background: #000;
+}
+
+html,
+body,
+#root {
+ width: 100%;
+ height: 100%;
+ margin: 0;
+ overflow: hidden;
+ background: #000;
+}
+
+.camera-preview-root {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ cursor: move;
+ touch-action: none;
+ user-select: none;
+}
+
+video {
+ display: block;
+ width: 100%;
+ height: 100%;
+ background: #000;
+ object-fit: cover;
+ pointer-events: none;
+ transition: opacity 180ms ease;
+}
+
+video[data-mirror="true"] {
+ transform: scaleX(-1);
+}
+
+video[data-pip="true"] {
+ opacity: 0;
+}
+
+.camera-preview-pip-button {
+ all: unset;
+ position: absolute;
+ top: 10px;
+ right: 10px;
+ display: flex;
+ width: 36px;
+ height: 36px;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid rgba(255, 255, 255, 0.18);
+ border-radius: 10px;
+ background: rgba(0, 0, 0, 0.58);
+ box-shadow: 0 10px 28px rgba(0, 0, 0, 0.28);
+ color: rgba(255, 255, 255, 0.94);
+ cursor: pointer;
+ opacity: 1;
+ backdrop-filter: blur(14px);
+ transition:
+ opacity 160ms ease,
+ background 160ms ease,
+ transform 160ms ease;
+}
+
+.camera-preview-pip-button:hover,
+.camera-preview-pip-button:focus-visible {
+ background: rgba(0, 0, 0, 0.72);
+ transform: translateY(-1px);
+}
+
+.camera-preview-pip-button:focus-visible {
+ outline: 1px solid rgba(255, 255, 255, 0.72);
+ outline-offset: 2px;
+}
+
+.camera-preview-pip-button svg {
+ display: block;
+ width: 20px;
+ height: 20px;
+}
diff --git a/apps/chrome-extension/src/shared/api.ts b/apps/chrome-extension/src/shared/api.ts
new file mode 100644
index 00000000000..25aded7b90c
--- /dev/null
+++ b/apps/chrome-extension/src/shared/api.ts
@@ -0,0 +1,223 @@
+import type { Extension, Video } from "@cap/web-domain";
+
+import type {
+ BootstrapData,
+ ExtensionAuth,
+ ExtensionSettings,
+ InstantRecordingCreation,
+} from "./types";
+
+// The JSON request body matches the encoded side of the server schema, so
+// contract drift fails to compile instead of surfacing at runtime.
+type CreateInstantRecordingInput =
+ typeof Video.InstantRecordingCreateInput.Encoded;
+
+// A hung request would otherwise leave the recorder UI stuck with no way out
+// (the floating bar shows "creating" until the start call settles).
+const API_REQUEST_TIMEOUT_MS = 30_000;
+const AUTH_CHECK_TIMEOUT_MS = 10_000;
+
+// Literal copies of the contract paths. A value import of @cap/web-domain
+// would drag the effect runtime into every extension bundle, so the import
+// above is type-only and these `satisfies` checks make any drift from the
+// contract fail to compile instead of surfacing at runtime.
+const EXTENSION_HTTP_PREFIX =
+ "/extension" satisfies typeof Extension.EXTENSION_HTTP_PREFIX;
+
+const ExtensionApiPaths = {
+ startAuth: "/auth/start",
+ approveAuth: "/auth/approve",
+ revokeAuth: "/auth/revoke",
+ bootstrap: "/bootstrap",
+ createInstantRecording: "/instant-recordings",
+ updateInstantRecordingProgress: "/instant-recordings/progress",
+ deleteInstantRecording: (videoId: string) =>
+ `/instant-recordings/${encodeURIComponent(videoId)}` as const,
+} as const satisfies typeof Extension.ExtensionApiPaths;
+
+// The web app mounts the HTTP API under /api (web-domain Http/Api.ts).
+const EXTENSION_API_PREFIX = `/api${EXTENSION_HTTP_PREFIX}`;
+
+const extensionApiPath = (path: string) => `${EXTENSION_API_PREFIX}${path}`;
+
+const apiUrl = (settings: ExtensionSettings, path: string) =>
+ new URL(path, settings.apiBaseUrl).toString();
+
+const checkAuthStartRoute = async (
+ settings: ExtensionSettings,
+ url: URL,
+ redirectUri: string,
+) => {
+ try {
+ const response = await fetch(url, {
+ redirect: "manual",
+ signal: AbortSignal.timeout(AUTH_CHECK_TIMEOUT_MS),
+ });
+ if (response.type === "opaqueredirect" || response.status === 302) return;
+ if (response.ok) return;
+ throw new Error(`${response.status} ${await response.text()}`);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ throw new Error(
+ `Could not reach Cap extension auth at ${url.origin}. Start the local web server with pnpm dev:extension and make sure the extension Options Cap URL is ${settings.apiBaseUrl}. Redirect URI: ${redirectUri}. ${message}`,
+ );
+ }
+};
+
+// Carries the HTTP status so callers can tell a definitive server answer
+// (the request was processed and rejected) from a failure where the request
+// may never have arrived.
+export class ApiRequestError extends Error {
+ readonly status: number;
+
+ constructor(status: number, message: string) {
+ super(message || `Request failed with status ${status}`);
+ this.name = "ApiRequestError";
+ this.status = status;
+ }
+}
+
+const requestJson = async ({
+ settings,
+ auth,
+ path,
+ method,
+ body,
+}: {
+ settings: ExtensionSettings;
+ auth: ExtensionAuth;
+ path: string;
+ method: "GET" | "POST" | "DELETE";
+ body?: unknown;
+}) => {
+ const response = await fetch(apiUrl(settings, path), {
+ method,
+ headers: {
+ Authorization: `Bearer ${auth.authApiKey}`,
+ "Content-Type": "application/json",
+ },
+ body: body === undefined ? undefined : JSON.stringify(body),
+ signal: AbortSignal.timeout(API_REQUEST_TIMEOUT_MS),
+ });
+
+ if (!response.ok) {
+ throw new ApiRequestError(response.status, await response.text());
+ }
+
+ return (await response.json()) as TResponse;
+};
+
+export const createAuthStart = async (settings: ExtensionSettings) => {
+ const redirectUri = chrome.identity.getRedirectURL();
+ const state = crypto.randomUUID();
+ const url = new URL(
+ apiUrl(settings, extensionApiPath(ExtensionApiPaths.startAuth)),
+ );
+ url.searchParams.set("redirectUri", redirectUri);
+ url.searchParams.set("state", state);
+
+ await checkAuthStartRoute(settings, url, redirectUri);
+
+ return { redirectUri, state, url: url.toString() };
+};
+
+export const parseAuthResponse = (responseUrl: string, state: string) => {
+ const hash = new URL(responseUrl).hash.slice(1);
+ const params = new URLSearchParams(hash);
+ if (params.get("state") !== state) {
+ throw new Error("Auth state did not match");
+ }
+
+ // The consent page's Cancel link redirects back with an error in the
+ // fragment instead of a key.
+ const error = params.get("error");
+ if (error) {
+ throw new Error(
+ error === "access_denied"
+ ? "Sign-in was canceled."
+ : `Sign-in failed: ${error}`,
+ );
+ }
+
+ const authApiKey = params.get("authApiKey");
+ const userId = params.get("userId");
+ if (!authApiKey || !userId) {
+ throw new Error("Auth response did not include a token");
+ }
+
+ return { authApiKey, userId };
+};
+
+export const revokeAuth = (settings: ExtensionSettings, auth: ExtensionAuth) =>
+ requestJson<{ success: boolean }>({
+ settings,
+ auth,
+ path: extensionApiPath(ExtensionApiPaths.revokeAuth),
+ method: "POST",
+ });
+
+export const fetchBootstrap = (
+ settings: ExtensionSettings,
+ auth: ExtensionAuth,
+) =>
+ requestJson({
+ settings,
+ auth,
+ path: extensionApiPath(ExtensionApiPaths.bootstrap),
+ method: "GET",
+ });
+
+export const createInstantRecording = ({
+ settings,
+ auth,
+ input,
+}: {
+ settings: ExtensionSettings;
+ auth: ExtensionAuth;
+ input: CreateInstantRecordingInput;
+}) =>
+ requestJson({
+ settings,
+ auth,
+ path: extensionApiPath(ExtensionApiPaths.createInstantRecording),
+ method: "POST",
+ body: input,
+ });
+
+export const updateUploadProgress = ({
+ settings,
+ auth,
+ videoId,
+ uploaded,
+ total,
+}: {
+ settings: ExtensionSettings;
+ auth: ExtensionAuth;
+ videoId: string;
+ uploaded: number;
+ total: number;
+}) =>
+ requestJson<{ success: boolean }>({
+ settings,
+ auth,
+ path: extensionApiPath(ExtensionApiPaths.updateInstantRecordingProgress),
+ method: "POST",
+ body: {
+ videoId,
+ uploaded,
+ total,
+ updatedAt: new Date().toISOString(),
+ },
+ });
+
+export const deleteInstantRecording = (
+ settings: ExtensionSettings,
+ auth: ExtensionAuth,
+ videoId: string,
+) =>
+ requestJson<{ success: boolean }>({
+ settings,
+ auth,
+ path: extensionApiPath(ExtensionApiPaths.deleteInstantRecording(videoId)),
+ method: "DELETE",
+ });
diff --git a/apps/chrome-extension/src/shared/cap-brand.tsx b/apps/chrome-extension/src/shared/cap-brand.tsx
new file mode 100644
index 00000000000..d5e21ecdb62
--- /dev/null
+++ b/apps/chrome-extension/src/shared/cap-brand.tsx
@@ -0,0 +1,57 @@
+import type { SVGProps } from "react";
+
+export const CapBrand = (props: SVGProps) => (
+
+ Cap
+
+
+
+
+
+);
+
+export const DoodleBoilFilter = ({ id = "boil" }: { id?: string }) => (
+
+
+
+
+
+
+);
diff --git a/apps/chrome-extension/src/shared/devices.ts b/apps/chrome-extension/src/shared/devices.ts
new file mode 100644
index 00000000000..19bf66e5d54
--- /dev/null
+++ b/apps/chrome-extension/src/shared/devices.ts
@@ -0,0 +1,31 @@
+import type { CameraDevice, MicrophoneDevice } from "./types";
+
+// enumerateDevices() returns placeholder entries with an empty deviceId for
+// device kinds the current document is not allowed to read (no permission, or a
+// cross-origin iframe that Chrome withholds labels from). Dropping those leaves
+// only the real, addressable devices.
+export const toCameraDevices = (devices: MediaDeviceInfo[]): CameraDevice[] =>
+ devices
+ .filter(
+ (device) =>
+ device.kind === "videoinput" && device.deviceId.trim().length > 0,
+ )
+ .map((device, index) => ({
+ deviceId: device.deviceId,
+ groupId: device.groupId,
+ label: device.label?.trim() || `Camera ${index + 1}`,
+ }));
+
+export const toMicrophoneDevices = (
+ devices: MediaDeviceInfo[],
+): MicrophoneDevice[] =>
+ devices
+ .filter(
+ (device) =>
+ device.kind === "audioinput" && device.deviceId.trim().length > 0,
+ )
+ .map((device, index) => ({
+ deviceId: device.deviceId,
+ groupId: device.groupId,
+ label: device.label?.trim() || `Microphone ${index + 1}`,
+ }));
diff --git a/apps/chrome-extension/src/shared/format-duration.ts b/apps/chrome-extension/src/shared/format-duration.ts
new file mode 100644
index 00000000000..8ec62b3802b
--- /dev/null
+++ b/apps/chrome-extension/src/shared/format-duration.ts
@@ -0,0 +1,17 @@
+// Live timers (recording bars) count up from 0:00 and truncate so the
+// display never runs ahead of the actual elapsed time.
+export const formatDuration = (durationMs: number) => {
+ const totalSeconds = Math.max(0, Math.floor(durationMs / 1000));
+ const minutes = Math.floor(totalSeconds / 60);
+ const seconds = totalSeconds % 60;
+ return `${minutes}:${seconds.toString().padStart(2, "0")}`;
+};
+
+// Finished recordings round to the nearest second and never show 0:00 — a
+// sub-second capture still represents real data.
+export const formatRecordedDuration = (durationMs: number) => {
+ const totalSeconds = Math.max(1, Math.round(durationMs / 1000));
+ const minutes = Math.floor(totalSeconds / 60);
+ const seconds = totalSeconds % 60;
+ return `${minutes}:${seconds.toString().padStart(2, "0")}`;
+};
diff --git a/apps/chrome-extension/src/shared/messages.test.ts b/apps/chrome-extension/src/shared/messages.test.ts
new file mode 100644
index 00000000000..51eb884fb34
--- /dev/null
+++ b/apps/chrome-extension/src/shared/messages.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, it } from "vitest";
+import {
+ isOffscreenRequest,
+ isOverlayMessage,
+ isRecordingStatusBroadcast,
+ isServiceWorkerRequest,
+} from "./messages";
+
+describe("extension message contracts", () => {
+ it("routes popup requests only to the service worker", () => {
+ const message = { target: "service-worker", type: "bootstrap" };
+ expect(isServiceWorkerRequest(message)).toBe(true);
+ expect(isOffscreenRequest(message)).toBe(false);
+ });
+
+ it("routes recording requests only to the offscreen document", () => {
+ const message = { target: "offscreen", type: "get-recording-status" };
+ expect(isOffscreenRequest(message)).toBe(true);
+ expect(isServiceWorkerRequest(message)).toBe(false);
+ });
+
+ it("accepts overlay messages without runtime targets", () => {
+ expect(isOverlayMessage({ type: "overlay-hide" })).toBe(true);
+ expect(isOverlayMessage({ type: "overlay-enter-auto-pip" })).toBe(true);
+ expect(isOverlayMessage({ type: "overlay-exit-auto-pip" })).toBe(true);
+ expect(
+ isOverlayMessage({ target: "offscreen", type: "overlay-hide" }),
+ ).toBe(true);
+ expect(isOverlayMessage({ type: "bootstrap" })).toBe(false);
+ });
+
+ it("routes recording status broadcasts separately from requests", () => {
+ const message = {
+ target: "recording-status",
+ type: "recording-status-changed",
+ status: { phase: "completed", videoId: "v", shareUrl: "https://cap.so" },
+ };
+ expect(isRecordingStatusBroadcast(message)).toBe(true);
+ expect(isServiceWorkerRequest(message)).toBe(false);
+ expect(isOffscreenRequest(message)).toBe(false);
+ expect(
+ isRecordingStatusBroadcast({
+ target: "recording-status",
+ type: "bootstrap",
+ }),
+ ).toBe(false);
+ });
+});
diff --git a/apps/chrome-extension/src/shared/messages.ts b/apps/chrome-extension/src/shared/messages.ts
new file mode 100644
index 00000000000..9bedcaca83b
--- /dev/null
+++ b/apps/chrome-extension/src/shared/messages.ts
@@ -0,0 +1,43 @@
+import type {
+ OffscreenRequest,
+ OverlayMessage,
+ RecordingStatusBroadcast,
+ ServiceWorkerRequest,
+} from "./types";
+
+const hasType = (message: unknown): message is { type: string } =>
+ !!message &&
+ typeof message === "object" &&
+ "type" in message &&
+ typeof message.type === "string";
+
+const hasTarget = (
+ message: unknown,
+ target: TTarget,
+): message is { target: TTarget; type: string } =>
+ hasType(message) && "target" in message && message.target === target;
+
+export const isServiceWorkerRequest = (
+ message: unknown,
+): message is ServiceWorkerRequest => hasTarget(message, "service-worker");
+
+export const isOffscreenRequest = (
+ message: unknown,
+): message is OffscreenRequest => hasTarget(message, "offscreen");
+
+export const isOverlayMessage = (message: unknown): message is OverlayMessage =>
+ hasType(message) &&
+ (message.type === "overlay-settings" ||
+ message.type === "overlay-countdown" ||
+ message.type === "overlay-confirm" ||
+ message.type === "overlay-enter-auto-pip" ||
+ message.type === "overlay-exit-auto-pip" ||
+ message.type === "overlay-hide" ||
+ message.type === "overlay-panel-toggle" ||
+ message.type === "overlay-panel-hide");
+
+export const isRecordingStatusBroadcast = (
+ message: unknown,
+): message is RecordingStatusBroadcast =>
+ hasTarget(message, "recording-status") &&
+ message.type === "recording-status-changed";
diff --git a/apps/chrome-extension/src/shared/page-nav.ts b/apps/chrome-extension/src/shared/page-nav.ts
new file mode 100644
index 00000000000..261bf5840a0
--- /dev/null
+++ b/apps/chrome-extension/src/shared/page-nav.ts
@@ -0,0 +1,67 @@
+import { loadSettings } from "./storage";
+
+export const PAGE_NAV_LINKS = [
+ { id: "welcome", label: "Welcome", href: "welcome.html" },
+ { id: "how-it-works", label: "How it works", href: "how-it-works.html" },
+ { id: "camera", label: "Camera access", href: "camera-permission.html" },
+ { id: "options", label: "Options", href: "options.html" },
+] as const;
+
+export type PageNavId = (typeof PAGE_NAV_LINKS)[number]["id"];
+
+const DEFAULT_DASHBOARD_URL =
+ import.meta.env.MODE === "development"
+ ? "http://localhost:3000/dashboard"
+ : "https://cap.so/dashboard";
+
+const CAP_LOGO_SVG = ` `;
+
+export const mountPageNav = (active: PageNavId) => {
+ if (document.querySelector(".page-nav")) return;
+
+ const nav = document.createElement("nav");
+ nav.className = "page-nav";
+ nav.setAttribute("aria-label", "Cap extension pages");
+
+ const inner = document.createElement("div");
+ inner.className = "page-nav-inner";
+
+ const brand = document.createElement("a");
+ brand.className = "page-nav-brand";
+ brand.href = "welcome.html";
+ brand.setAttribute("aria-label", "Cap");
+ brand.innerHTML = CAP_LOGO_SVG;
+
+ const links = document.createElement("div");
+ links.className = "page-nav-links";
+ for (const link of PAGE_NAV_LINKS) {
+ const anchor = document.createElement("a");
+ anchor.className =
+ link.id === active ? "page-nav-link is-active" : "page-nav-link";
+ anchor.href = link.href;
+ anchor.textContent = link.label;
+ if (link.id === active) {
+ anchor.setAttribute("aria-current", "page");
+ }
+ links.append(anchor);
+ }
+
+ const dashboard = document.createElement("a");
+ dashboard.className = "page-nav-link";
+ dashboard.href = DEFAULT_DASHBOARD_URL;
+ dashboard.target = "_blank";
+ dashboard.rel = "noopener";
+ dashboard.textContent = "Dashboard";
+ links.append(dashboard);
+ // The user may point the extension at a self-hosted instance, so resolve
+ // the real base URL once settings load and leave the default until then.
+ void loadSettings()
+ .then((settings) => {
+ dashboard.href = new URL("/dashboard", settings.apiBaseUrl).toString();
+ })
+ .catch(() => undefined);
+
+ inner.append(brand, links);
+ nav.append(inner);
+ document.body.prepend(nav);
+};
diff --git a/apps/chrome-extension/src/shared/paper.css b/apps/chrome-extension/src/shared/paper.css
new file mode 100644
index 00000000000..157d73b68d0
--- /dev/null
+++ b/apps/chrome-extension/src/shared/paper.css
@@ -0,0 +1,447 @@
+@font-face {
+ font-family: "Neue Montreal";
+ src: url("/fonts/NeueMontreal-Regular.otf") format("opentype");
+ font-weight: 400;
+ font-style: normal;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: "Neue Montreal";
+ src: url("/fonts/NeueMontreal-Italic.otf") format("opentype");
+ font-weight: 400;
+ font-style: italic;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: "Neue Montreal";
+ src: url("/fonts/NeueMontreal-Medium.otf") format("opentype");
+ font-weight: 500;
+ font-style: normal;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: "Neue Montreal";
+ src: url("/fonts/NeueMontreal-Bold.otf") format("opentype");
+ font-weight: 700;
+ font-style: normal;
+ font-display: swap;
+}
+
+*,
+*:before,
+*:after {
+ box-sizing: border-box;
+}
+
+:root {
+ --paper: #fbfaf7;
+ --ink: #20242c;
+ --ink-soft: #8b8f98;
+ --accent: #4785ff;
+ --track: #dcdcd4;
+ --error: #e5484d;
+ --success: #2f9e69;
+}
+
+html,
+body {
+ height: 100%;
+}
+
+body {
+ margin: 0;
+ min-height: 100dvh;
+ display: flex;
+ flex-direction: column;
+ background: var(--paper);
+ color: var(--ink);
+ font-family:
+ "Neue Montreal", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
+ "Segoe UI", sans-serif;
+ -webkit-font-smoothing: antialiased;
+}
+
+h1,
+h2,
+p {
+ margin: 0;
+}
+
+.page-nav {
+ position: sticky;
+ top: 0;
+ z-index: 20;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 64px;
+ flex: 0 0 64px;
+ border-bottom: 1.5px solid var(--track);
+ background: rgba(251, 250, 247, 0.92);
+ backdrop-filter: blur(10px);
+}
+
+.page-nav-inner {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ width: min(760px, calc(100vw - 48px));
+}
+
+.page-nav-brand {
+ display: flex;
+ align-items: center;
+ border-radius: 9px;
+ line-height: 0;
+}
+
+.page-nav-brand .page-nav-logo {
+ display: block;
+ height: 24px;
+ width: auto;
+ color: var(--ink);
+}
+
+.page-nav-brand:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 3px;
+}
+
+.page-nav-links {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 4px;
+ border: 1.5px solid var(--track);
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.65);
+}
+
+.page-nav-link {
+ display: inline-flex;
+ align-items: center;
+ padding: 5px 13px;
+ border-radius: 999px;
+ color: var(--ink-soft);
+ font-size: 13px;
+ font-weight: 500;
+ white-space: nowrap;
+ text-decoration: none;
+ transition:
+ background 0.18s ease,
+ color 0.18s ease;
+}
+
+.page-nav-link:hover {
+ color: var(--ink);
+}
+
+.page-nav-link:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 1px;
+}
+
+.page-nav-link.is-active {
+ background: var(--ink);
+ color: var(--paper);
+}
+
+.stage {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ padding: 48px 24px 24px;
+}
+
+.brand {
+ margin-bottom: 40px;
+ animation: fade-up 0.5s ease both;
+}
+
+.brand-logo {
+ height: 26px;
+ width: auto;
+ color: var(--ink);
+}
+
+.doodle {
+ width: 128px;
+ height: auto;
+ animation: fade-up 0.5s ease 0.05s both;
+}
+
+.doodle-boil {
+ filter: url(#boil);
+}
+
+.doodle-stroke {
+ fill: none;
+ stroke: var(--ink);
+ stroke-width: 4.5;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+
+.spark {
+ fill: none;
+ stroke: var(--accent);
+ stroke-width: 3.5;
+ stroke-linecap: round;
+ opacity: 0;
+ transform-box: fill-box;
+ transform-origin: center;
+ animation: spark-pop 0.5s ease forwards;
+}
+
+.stage h1 {
+ margin-top: 26px;
+ font-size: 26px;
+ font-weight: 500;
+ letter-spacing: -0.01em;
+ animation: fade-up 0.5s ease 0.1s both;
+}
+
+.lede {
+ margin-top: 8px;
+ max-width: 440px;
+ font-size: 15px;
+ line-height: 1.5;
+ color: var(--ink-soft);
+ animation: fade-up 0.5s ease 0.15s both;
+}
+
+.card {
+ display: grid;
+ gap: 14px;
+ padding: 20px;
+ border: 1.5px solid var(--track);
+ border-radius: 16px;
+ background: rgba(255, 255, 255, 0.65);
+ text-align: left;
+ animation: fade-up 0.5s ease both;
+}
+
+.card h2 {
+ font-size: 12px;
+ font-weight: 500;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--ink-soft);
+}
+
+.cta {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 42px;
+ padding: 0 24px;
+ border: 1.5px solid var(--ink);
+ border-radius: 999px;
+ background: transparent;
+ color: var(--ink);
+ font-family: inherit;
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ transition:
+ background 0.2s ease,
+ color 0.2s ease,
+ opacity 0.2s ease;
+}
+
+.cta:hover:not(:disabled) {
+ background: var(--ink);
+ color: var(--paper);
+}
+
+.cta:disabled {
+ cursor: not-allowed;
+ opacity: 0.45;
+}
+
+.cta.ghost {
+ border-color: var(--track);
+ color: var(--ink-soft);
+}
+
+.cta.ghost:hover:not(:disabled) {
+ background: transparent;
+ border-color: var(--ink-soft);
+ color: var(--ink);
+}
+
+.cta[hidden] {
+ display: none;
+}
+
+.paper-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ min-height: 42px;
+ padding: 8px 20px;
+ border: 1.5px solid var(--ink);
+ border-radius: 999px;
+ color: var(--ink);
+ font-size: 14px;
+ font-weight: 500;
+ line-height: 1.4;
+ animation: fade-up 0.4s ease both;
+}
+
+.paper-pill.success {
+ border-color: var(--success);
+ color: var(--success);
+}
+
+.paper-pill.error {
+ border-color: var(--error);
+ border-radius: 16px;
+ color: var(--error);
+ text-align: left;
+}
+
+.paper-pill[hidden] {
+ display: none;
+}
+
+.check-mini {
+ width: 16px;
+ height: 16px;
+ flex: 0 0 auto;
+ overflow: visible;
+}
+
+.check-mini-path {
+ fill: none;
+ stroke: currentColor;
+ stroke-width: 3;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ stroke-dasharray: 1;
+ stroke-dashoffset: 1;
+ animation: draw 0.4s ease 0.15s forwards;
+}
+
+.field {
+ display: grid;
+ gap: 6px;
+ text-align: left;
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--ink);
+}
+
+.field > span {
+ color: var(--ink-soft);
+}
+
+.field input,
+.field select {
+ min-height: 40px;
+ padding: 0 12px;
+ border: 1.5px solid var(--track);
+ border-radius: 10px;
+ background: #ffffff;
+ color: var(--ink);
+ font: inherit;
+ font-size: 14px;
+ transition: border-color 0.15s ease;
+}
+
+.field input:focus,
+.field select:focus {
+ outline: none;
+ border-color: var(--ink);
+}
+
+.footnote {
+ padding: 0 24px 28px;
+ text-align: center;
+ font-size: 12.5px;
+ color: var(--ink-soft);
+ animation: fade-up 0.5s ease 0.25s both;
+}
+
+@keyframes draw {
+ to {
+ stroke-dashoffset: 0;
+ }
+}
+
+@keyframes fade-in {
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes fade-up {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@keyframes bob {
+ 0%,
+ 100% {
+ transform: translateY(0);
+ }
+ 50% {
+ transform: translateY(-4px);
+ }
+}
+
+@keyframes spark-pop {
+ from {
+ opacity: 0;
+ transform: scale(0.2);
+ }
+ 60% {
+ opacity: 1;
+ transform: scale(1.2);
+ }
+ to {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
+
+@keyframes march {
+ from {
+ stroke-dashoffset: 0;
+ }
+ to {
+ stroke-dashoffset: -6.2;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .doodle-boil {
+ filter: none;
+ }
+
+ .brand,
+ .doodle,
+ .stage h1,
+ .lede,
+ .card,
+ .paper-pill,
+ .check-mini-path,
+ .spark,
+ .footnote {
+ animation-duration: 0.01ms;
+ animation-delay: 0s;
+ }
+}
diff --git a/apps/chrome-extension/src/shared/preferences.test.ts b/apps/chrome-extension/src/shared/preferences.test.ts
new file mode 100644
index 00000000000..60526aa8f30
--- /dev/null
+++ b/apps/chrome-extension/src/shared/preferences.test.ts
@@ -0,0 +1,96 @@
+import { describe, expect, it } from "vitest";
+import {
+ reconcileRememberedDevices,
+ rememberCameraSelection,
+} from "./preferences";
+import type { CameraDevice, ExtensionSettings } from "./types";
+
+const camera: CameraDevice = {
+ deviceId: "camera-1",
+ groupId: "group-1",
+ label: "Studio Camera",
+};
+
+const settings: ExtensionSettings = {
+ apiBaseUrl: "https://cap.so",
+ capture: {
+ recordingMode: "fullscreen",
+ camera: null,
+ microphone: null,
+ },
+ webcam: {
+ enabled: false,
+ deviceId: null,
+ position: "bottom-left",
+ size: 230,
+ shape: "round",
+ mirror: false,
+ },
+ microphone: {
+ enabled: false,
+ deviceId: null,
+ },
+ systemAudio: {
+ enabled: true,
+ },
+ sounds: {
+ enabled: true,
+ },
+ countdown: {
+ enabled: true,
+ seconds: 3,
+ },
+ microphoneWarning: {
+ enabled: true,
+ },
+};
+
+describe("camera preferences", () => {
+ it("clears the remembered camera when camera is explicitly disabled", () => {
+ const selected = rememberCameraSelection(settings, camera.deviceId, [
+ camera,
+ ]);
+
+ expect(
+ rememberCameraSelection(selected, null, [camera]).capture.camera,
+ ).toBeNull();
+ });
+
+ it("does not restore the remembered camera when webcam preview was disabled", () => {
+ const selected = rememberCameraSelection(settings, camera.deviceId, [
+ camera,
+ ]);
+ const inactive = {
+ ...selected,
+ webcam: {
+ ...selected.webcam,
+ enabled: false,
+ deviceId: null,
+ },
+ };
+
+ const reconciled = reconcileRememberedDevices(inactive, [camera], []);
+
+ expect(reconciled.webcam.enabled).toBe(false);
+ expect(reconciled.webcam.deviceId).toBeNull();
+ });
+
+ it("restores the remembered camera when webcam preview is enabled without an active device", () => {
+ const selected = rememberCameraSelection(settings, camera.deviceId, [
+ camera,
+ ]);
+ const missingDevice = {
+ ...selected,
+ webcam: {
+ ...selected.webcam,
+ enabled: true,
+ deviceId: null,
+ },
+ };
+
+ const reconciled = reconcileRememberedDevices(missingDevice, [camera], []);
+
+ expect(reconciled.webcam.enabled).toBe(true);
+ expect(reconciled.webcam.deviceId).toBe(camera.deviceId);
+ });
+});
diff --git a/apps/chrome-extension/src/shared/preferences.ts b/apps/chrome-extension/src/shared/preferences.ts
new file mode 100644
index 00000000000..cab50d1ee2f
--- /dev/null
+++ b/apps/chrome-extension/src/shared/preferences.ts
@@ -0,0 +1,180 @@
+import {
+ type CameraDevice,
+ DEFAULT_CAMERA_DEVICE_ID,
+ DEFAULT_MICROPHONE_DEVICE_ID,
+ type DevicePreference,
+ type ExtensionSettings,
+ type MicrophoneDevice,
+ type RecordingMode,
+} from "./types";
+
+type DeviceWithIdentity = CameraDevice | MicrophoneDevice;
+
+const cleanText = (value: string | null | undefined) => {
+ const trimmed = value?.trim();
+ return trimmed && trimmed.length > 0 ? trimmed : null;
+};
+
+const rememberDevice = (
+ deviceId: string,
+ devices: DeviceWithIdentity[],
+): DevicePreference => {
+ const device = devices.find((item) => item.deviceId === deviceId);
+ return {
+ deviceId,
+ label: cleanText(device?.label),
+ groupId: cleanText(device?.groupId),
+ updatedAt: Date.now(),
+ };
+};
+
+const findRememberedDevice = (
+ preference: DevicePreference | null,
+ devices: DeviceWithIdentity[],
+) => {
+ if (!preference) return null;
+
+ const exactMatch = devices.find(
+ (device) => device.deviceId === preference.deviceId,
+ );
+ if (exactMatch) return exactMatch;
+
+ const groupMatch = preference.groupId
+ ? devices.find((device) => cleanText(device.groupId) === preference.groupId)
+ : null;
+ if (groupMatch) return groupMatch;
+
+ return preference.label
+ ? (devices.find((device) => cleanText(device.label) === preference.label) ??
+ null)
+ : null;
+};
+
+const resolveDeviceId = ({
+ currentDeviceId,
+ preference,
+ devices,
+ defaultDeviceId,
+}: {
+ currentDeviceId: string | null;
+ preference: DevicePreference | null;
+ devices: DeviceWithIdentity[];
+ defaultDeviceId: string;
+}) => {
+ if (!currentDeviceId || currentDeviceId === defaultDeviceId) {
+ return currentDeviceId;
+ }
+
+ if (devices.some((device) => device.deviceId === currentDeviceId)) {
+ return currentDeviceId;
+ }
+
+ return (
+ findRememberedDevice(preference, devices)?.deviceId ??
+ devices[0]?.deviceId ??
+ currentDeviceId
+ );
+};
+
+export const rememberRecordingMode = (
+ settings: ExtensionSettings,
+ recordingMode: RecordingMode,
+): ExtensionSettings => ({
+ ...settings,
+ capture: {
+ ...settings.capture,
+ recordingMode,
+ },
+});
+
+export const rememberCameraSelection = (
+ settings: ExtensionSettings,
+ cameraId: string | null,
+ devices: CameraDevice[],
+): ExtensionSettings => ({
+ ...settings,
+ capture: {
+ ...settings.capture,
+ camera: cameraId ? rememberDevice(cameraId, devices) : null,
+ },
+ webcam: {
+ ...settings.webcam,
+ enabled: Boolean(cameraId),
+ deviceId: cameraId,
+ },
+});
+
+export const rememberMicrophoneSelection = (
+ settings: ExtensionSettings,
+ microphoneId: string | null,
+ devices: MicrophoneDevice[],
+): ExtensionSettings => ({
+ ...settings,
+ capture: {
+ ...settings.capture,
+ microphone: microphoneId
+ ? rememberDevice(microphoneId, devices)
+ : settings.capture.microphone,
+ },
+ microphone: {
+ enabled: microphoneId !== null,
+ deviceId:
+ microphoneId === null || microphoneId === DEFAULT_MICROPHONE_DEVICE_ID
+ ? null
+ : microphoneId,
+ },
+});
+
+export const reconcileRememberedDevices = (
+ settings: ExtensionSettings,
+ cameras: CameraDevice[],
+ microphones: MicrophoneDevice[],
+) => {
+ const restoredCameraId =
+ settings.webcam.enabled && !settings.webcam.deviceId
+ ? (findRememberedDevice(settings.capture.camera, cameras)?.deviceId ??
+ null)
+ : null;
+ const cameraId =
+ restoredCameraId ??
+ resolveDeviceId({
+ currentDeviceId: settings.webcam.deviceId,
+ preference: settings.capture.camera,
+ devices: cameras,
+ defaultDeviceId: DEFAULT_CAMERA_DEVICE_ID,
+ });
+ const activeMicrophoneId =
+ settings.microphone.enabled &&
+ settings.microphone.deviceId !== null &&
+ settings.microphone.deviceId !== DEFAULT_MICROPHONE_DEVICE_ID
+ ? settings.microphone.deviceId
+ : null;
+ const microphoneId = resolveDeviceId({
+ currentDeviceId: activeMicrophoneId,
+ preference: settings.capture.microphone,
+ devices: microphones,
+ defaultDeviceId: DEFAULT_MICROPHONE_DEVICE_ID,
+ });
+
+ if (
+ cameraId === settings.webcam.deviceId &&
+ microphoneId === activeMicrophoneId
+ ) {
+ return settings;
+ }
+
+ return {
+ ...settings,
+ webcam: {
+ ...settings.webcam,
+ deviceId: cameraId,
+ enabled: restoredCameraId
+ ? true
+ : settings.webcam.enabled && Boolean(cameraId),
+ },
+ microphone: {
+ ...settings.microphone,
+ deviceId: microphoneId,
+ },
+ };
+};
diff --git a/apps/chrome-extension/src/shared/runtime.ts b/apps/chrome-extension/src/shared/runtime.ts
new file mode 100644
index 00000000000..d36940fe286
--- /dev/null
+++ b/apps/chrome-extension/src/shared/runtime.ts
@@ -0,0 +1,12 @@
+import type { ServiceWorkerRequest, ServiceWorkerResponse } from "./types";
+
+export const sendServiceWorkerMessage = (message: ServiceWorkerRequest) =>
+ new Promise((resolve, reject) => {
+ chrome.runtime.sendMessage(message, (response) => {
+ if (chrome.runtime.lastError) {
+ reject(new Error(chrome.runtime.lastError.message ?? "Message failed"));
+ return;
+ }
+ resolve(response as ServiceWorkerResponse);
+ });
+ });
diff --git a/apps/chrome-extension/src/shared/storage-keys.ts b/apps/chrome-extension/src/shared/storage-keys.ts
new file mode 100644
index 00000000000..15dfd812db5
--- /dev/null
+++ b/apps/chrome-extension/src/shared/storage-keys.ts
@@ -0,0 +1,7 @@
+// Storage keys shared with the content bootstrap script. The bootstrap is
+// injected into every page and must stay a few KB of dependency-free code,
+// so it imports the keys it needs from here instead of pulling in the full
+// storage module. Everything else keeps importing them via ./storage, which
+// re-exports these constants, so both sides always agree on the key names.
+export const RECORDING_STATE_KEY = "cap-extension-recording-state";
+export const SHARED_UI_STATE_KEY = "cap-extension-shared-ui-state";
diff --git a/apps/chrome-extension/src/shared/storage.test.ts b/apps/chrome-extension/src/shared/storage.test.ts
new file mode 100644
index 00000000000..f60cf32d78f
--- /dev/null
+++ b/apps/chrome-extension/src/shared/storage.test.ts
@@ -0,0 +1,116 @@
+import { beforeEach, describe, expect, it } from "vitest";
+import {
+ type FailedRecording,
+ loadFailedRecordings,
+ loadOverlayTokens,
+ loadSharedUiState,
+ registerOverlayToken,
+ updateSharedUiState,
+ upsertFailedRecording,
+} from "./storage";
+
+// chrome.storage resolves callbacks asynchronously, which is what lets
+// concurrent read-modify-write calls interleave (both read before either
+// writes). The fake reproduces that timing so the tests fail without the
+// per-key write queue in storage.ts.
+const createAsyncStorageArea = () => {
+ const data = new Map();
+ return {
+ get(keys: string[], callback: (items: Record) => void) {
+ const snapshot: Record = {};
+ for (const key of keys) {
+ if (data.has(key)) snapshot[key] = data.get(key);
+ }
+ setTimeout(() => callback(snapshot), 0);
+ },
+ set(items: Record, callback: () => void) {
+ setTimeout(() => {
+ for (const [key, value] of Object.entries(items)) {
+ data.set(key, value);
+ }
+ callback();
+ }, 0);
+ },
+ remove(keys: string[] | string, callback: () => void) {
+ setTimeout(() => {
+ for (const key of Array.isArray(keys) ? keys : [keys]) {
+ data.delete(key);
+ }
+ callback();
+ }, 0);
+ },
+ };
+};
+
+const failedRecording = (sessionId: string): FailedRecording => ({
+ sessionId,
+ videoId: null,
+ shareUrl: null,
+ mimeType: "video/webm",
+ subpath: null,
+ durationMs: 1000,
+ width: null,
+ height: null,
+ fps: null,
+ totalBytes: 1024,
+ createdAt: Date.now(),
+ message: null,
+});
+
+beforeEach(() => {
+ (globalThis as { chrome?: unknown }).chrome = {
+ storage: {
+ local: createAsyncStorageArea(),
+ session: createAsyncStorageArea(),
+ },
+ };
+});
+
+describe("storage read-modify-write serialization", () => {
+ it("keeps every overlay token registered by concurrent calls", async () => {
+ await Promise.all([
+ registerOverlayToken("token-a"),
+ registerOverlayToken("token-b"),
+ registerOverlayToken("token-c"),
+ ]);
+
+ const tokens = await loadOverlayTokens();
+ expect(Object.keys(tokens).sort()).toEqual([
+ "token-a",
+ "token-b",
+ "token-c",
+ ]);
+ });
+
+ it("applies concurrent shared UI state updates without dropping either", async () => {
+ await Promise.all([
+ updateSharedUiState((current) => ({
+ ...current,
+ panelOpen: true,
+ updatedAt: Date.now(),
+ })),
+ updateSharedUiState((current) => ({
+ ...current,
+ readyBarDismissed: true,
+ updatedAt: Date.now(),
+ })),
+ ]);
+
+ const state = await loadSharedUiState();
+ expect(state.panelOpen).toBe(true);
+ expect(state.readyBarDismissed).toBe(true);
+ });
+
+ it("keeps every failed recording upserted by concurrent calls", async () => {
+ await Promise.all([
+ upsertFailedRecording(failedRecording("session-a")),
+ upsertFailedRecording(failedRecording("session-b")),
+ ]);
+
+ const recordings = await loadFailedRecordings();
+ expect(recordings.map((entry) => entry.sessionId).sort()).toEqual([
+ "session-a",
+ "session-b",
+ ]);
+ });
+});
diff --git a/apps/chrome-extension/src/shared/storage.ts b/apps/chrome-extension/src/shared/storage.ts
new file mode 100644
index 00000000000..f0d369ee3ce
--- /dev/null
+++ b/apps/chrome-extension/src/shared/storage.ts
@@ -0,0 +1,863 @@
+import { RECORDING_STATE_KEY, SHARED_UI_STATE_KEY } from "./storage-keys";
+import type {
+ BootstrapData,
+ CapturePreferences,
+ DevicePreference,
+ ExtensionAuth,
+ ExtensionSettings,
+ OverlayPosition,
+ OverlayUiState,
+ PendingAuth,
+ RecordingMode,
+ SharedRecordingState,
+ SharedUiState,
+ WebcamPreviewFrame,
+} from "./types";
+
+export { RECORDING_STATE_KEY, SHARED_UI_STATE_KEY };
+
+export const SETTINGS_KEY = "cap-extension-settings";
+export const AUTH_KEY = "cap-extension-auth";
+const PENDING_AUTH_KEY = "cap-extension-pending-auth";
+const BOOTSTRAP_CACHE_KEY = "cap-extension-bootstrap-cache";
+export const OVERLAY_UI_STATE_KEY = "cap-extension-overlay-ui-state";
+export const WEBCAM_PREVIEW_DISMISSED_KEY =
+ "cap-extension-webcam-preview-dismissed";
+export const MEDIA_ACCESS_KEY = "cap-extension-media-access";
+export const FAILED_RECORDINGS_KEY = "cap-extension-failed-recordings";
+const OVERLAY_TOKENS_KEY = "cap-extension-overlay-tokens";
+const LAST_WEBCAM_PREVIEW_FRAME_KEY = "cap-extension-last-webcam-preview-frame";
+const PRODUCTION_API_BASE_URL = "https://cap.so";
+const DEFAULT_API_BASE_URL =
+ import.meta.env.MODE === "development"
+ ? "http://localhost:3000"
+ : PRODUCTION_API_BASE_URL;
+
+export type MediaAccessState = {
+ camera: boolean;
+ microphone: boolean;
+ updatedAt: number;
+};
+
+// Metadata for a recording whose upload did not complete. The captured bytes
+// stay in the IndexedDB recording spool under sessionId until the upload is
+// retried successfully or the entry is pruned.
+export type FailedRecording = {
+ sessionId: string;
+ videoId: string | null;
+ shareUrl: string | null;
+ mimeType: string;
+ subpath: string | null;
+ durationMs: number;
+ width: number | null;
+ height: number | null;
+ fps: number | null;
+ totalBytes: number;
+ createdAt: number;
+ message: string | null;
+};
+
+export const defaultSettings: ExtensionSettings = {
+ apiBaseUrl: DEFAULT_API_BASE_URL,
+ capture: {
+ recordingMode: "fullscreen",
+ camera: null,
+ microphone: null,
+ },
+ webcam: {
+ enabled: false,
+ deviceId: null,
+ position: "bottom-left",
+ size: 230,
+ shape: "round",
+ mirror: false,
+ },
+ microphone: {
+ enabled: true,
+ deviceId: null,
+ },
+ systemAudio: {
+ enabled: true,
+ },
+ sounds: {
+ enabled: true,
+ },
+ countdown: {
+ enabled: true,
+ seconds: 3,
+ },
+ microphoneWarning: {
+ enabled: true,
+ },
+};
+
+const defaultMediaAccessState: MediaAccessState = {
+ camera: false,
+ microphone: false,
+ updatedAt: 0,
+};
+
+// chrome.storage has no transactions, so concurrent read-modify-write calls
+// (parallel service-worker handlers, several extension pages) can interleave
+// and silently drop writes. Every RMW helper below funnels through this
+// per-key promise chain so updates to the same key apply one at a time
+// within a JS context. The key set is small and fixed, so the map never
+// needs pruning.
+const keyWriteQueues = new Map>();
+
+const withKeyLock = (key: string, task: () => Promise): Promise => {
+ const previous = keyWriteQueues.get(key) ?? Promise.resolve();
+ const run = previous.then(task, task);
+ keyWriteQueues.set(
+ key,
+ run.then(
+ () => undefined,
+ () => undefined,
+ ),
+ );
+ return run;
+};
+
+const getLocal = (keys: string[]) =>
+ new Promise>((resolve) => {
+ chrome.storage.local.get(keys, (items) => resolve(items));
+ });
+
+const setLocal = (items: Record) =>
+ new Promise((resolve) => {
+ chrome.storage.local.set(items, resolve);
+ });
+
+const removeLocal = (keys: string[] | string) =>
+ new Promise((resolve) => {
+ chrome.storage.local.remove(keys, resolve);
+ });
+
+const getSession = (keys: string[]) =>
+ new Promise>((resolve) => {
+ chrome.storage.session.get(keys, (items) => resolve(items));
+ });
+
+const setSession = (items: Record) =>
+ new Promise((resolve) => {
+ chrome.storage.session.set(items, resolve);
+ });
+
+const removeSession = (keys: string[] | string) =>
+ new Promise((resolve) => {
+ chrome.storage.session.remove(keys, resolve);
+ });
+
+export const loadSettings = async () => {
+ const result = await getLocal([SETTINGS_KEY]);
+ const saved = result[SETTINGS_KEY];
+ if (!isSettings(saved)) return defaultSettings;
+ const apiBaseUrl =
+ import.meta.env.MODE === "development" &&
+ saved.apiBaseUrl === PRODUCTION_API_BASE_URL
+ ? DEFAULT_API_BASE_URL
+ : saved.apiBaseUrl;
+ return {
+ ...defaultSettings,
+ ...saved,
+ apiBaseUrl,
+ capture: normalizeCapturePreferences(saved.capture),
+ webcam: normalizeWebcamSettings(saved.webcam),
+ microphone: normalizeMicrophoneSettings(saved.microphone),
+ systemAudio: {
+ ...defaultSettings.systemAudio,
+ ...saved.systemAudio,
+ },
+ sounds: normalizeSoundSettings(saved.sounds),
+ countdown: normalizeCountdownSettings(saved.countdown),
+ microphoneWarning: normalizeMicrophoneWarningSettings(
+ saved.microphoneWarning,
+ ),
+ };
+};
+
+export const saveSettings = (settings: ExtensionSettings) =>
+ setLocal({ [SETTINGS_KEY]: settings });
+
+export const loadAuth = async () => {
+ const result = await getLocal([AUTH_KEY]);
+ const saved = result[AUTH_KEY];
+ return isAuth(saved) ? saved : null;
+};
+
+export const saveAuth = (auth: ExtensionAuth) => setLocal({ [AUTH_KEY]: auth });
+
+export const clearAuth = () => removeLocal(AUTH_KEY);
+
+export const loadPendingAuth = async () => {
+ const result = await getLocal([PENDING_AUTH_KEY]);
+ const saved = result[PENDING_AUTH_KEY];
+ return isPendingAuth(saved) ? saved : null;
+};
+
+export const loadCachedBootstrap = async () => {
+ const result = await getLocal([BOOTSTRAP_CACHE_KEY]);
+ const saved = result[BOOTSTRAP_CACHE_KEY];
+ if (isBootstrap(saved)) return saved;
+ if (isCachedBootstrap(saved)) return saved.bootstrap;
+ return null;
+};
+
+export const saveCachedBootstrap = (bootstrap: BootstrapData) =>
+ setLocal({
+ [BOOTSTRAP_CACHE_KEY]: {
+ bootstrap,
+ cachedAt: Date.now(),
+ },
+ });
+
+export const clearCachedBootstrap = () => removeLocal(BOOTSTRAP_CACHE_KEY);
+
+export const savePendingAuth = (pendingAuth: PendingAuth) =>
+ setLocal({ [PENDING_AUTH_KEY]: pendingAuth });
+
+export const clearPendingAuth = () => removeLocal(PENDING_AUTH_KEY);
+
+export const loadMediaAccessState = async () => {
+ const result = await getLocal([MEDIA_ACCESS_KEY]);
+ return normalizeMediaAccessState(result[MEDIA_ACCESS_KEY]);
+};
+
+export const updateMediaAccessState = (
+ access: Partial>,
+) =>
+ withKeyLock(MEDIA_ACCESS_KEY, async () => {
+ const current = await loadMediaAccessState();
+ const next = normalizeMediaAccessState({
+ ...current,
+ ...access,
+ updatedAt: Date.now(),
+ });
+ await setLocal({ [MEDIA_ACCESS_KEY]: next });
+ return next;
+ });
+
+export const loadOverlayUiState = async () => {
+ const result = await getLocal([OVERLAY_UI_STATE_KEY]);
+ return normalizeOverlayUiState(result[OVERLAY_UI_STATE_KEY]);
+};
+
+export const saveOverlayUiState = (state: OverlayUiState) =>
+ setLocal({ [OVERLAY_UI_STATE_KEY]: state });
+
+export const updateOverlayUiState = (
+ update: (current: OverlayUiState) => OverlayUiState,
+) =>
+ withKeyLock(OVERLAY_UI_STATE_KEY, async () => {
+ const current = await loadOverlayUiState();
+ const next = normalizeOverlayUiState(update(current));
+ await saveOverlayUiState(next);
+ return next;
+ });
+
+export const loadLastWebcamPreviewFrame = async () => {
+ const result = await getSession([LAST_WEBCAM_PREVIEW_FRAME_KEY]);
+ const saved = result[LAST_WEBCAM_PREVIEW_FRAME_KEY];
+ return isWebcamPreviewFrame(saved) ? saved : null;
+};
+
+export const saveLastWebcamPreviewFrame = (frame: WebcamPreviewFrame) =>
+ setSession({ [LAST_WEBCAM_PREVIEW_FRAME_KEY]: frame });
+
+export const loadWebcamPreviewDismissed = async () => {
+ const result = await getSession([WEBCAM_PREVIEW_DISMISSED_KEY]);
+ return result[WEBCAM_PREVIEW_DISMISSED_KEY] === true;
+};
+
+export const saveWebcamPreviewDismissed = (dismissed: boolean) =>
+ setSession({ [WEBCAM_PREVIEW_DISMISSED_KEY]: dismissed });
+
+const MAX_FAILED_RECORDINGS = 5;
+
+const isFailedRecording = (value: unknown): value is FailedRecording => {
+ if (!value || typeof value !== "object") return false;
+ const candidate = value as Partial;
+ return (
+ typeof candidate.sessionId === "string" &&
+ typeof candidate.mimeType === "string" &&
+ typeof candidate.totalBytes === "number" &&
+ typeof candidate.createdAt === "number"
+ );
+};
+
+export const loadFailedRecordings = async (): Promise => {
+ const result = await getLocal([FAILED_RECORDINGS_KEY]);
+ const saved = result[FAILED_RECORDINGS_KEY];
+ if (!Array.isArray(saved)) return [];
+ return saved.filter(isFailedRecording);
+};
+
+// The metadata list is capped; entries pushed out by newer failures are
+// returned so the caller can also reclaim their spooled bytes in IndexedDB.
+// Silently dropping them would leave multi-GB spools stranded (and later
+// re-surfaced by the orphan sweep with videoId null, i.e. unretryable).
+export const saveFailedRecordings = async (
+ recordings: FailedRecording[],
+): Promise<{ kept: FailedRecording[]; dropped: FailedRecording[] }> => {
+ const sorted = [...recordings].sort(
+ (left, right) => right.createdAt - left.createdAt,
+ );
+ const kept = sorted.slice(0, MAX_FAILED_RECORDINGS);
+ const dropped = sorted.slice(MAX_FAILED_RECORDINGS);
+ await setLocal({ [FAILED_RECORDINGS_KEY]: kept });
+ return { kept, dropped };
+};
+
+export const upsertFailedRecording = (recording: FailedRecording) =>
+ withKeyLock(FAILED_RECORDINGS_KEY, async () => {
+ const current = await loadFailedRecordings();
+ const next = [
+ recording,
+ ...current.filter((entry) => entry.sessionId !== recording.sessionId),
+ ];
+ return saveFailedRecordings(next);
+ });
+
+export const removeFailedRecording = (sessionId: string) =>
+ withKeyLock(FAILED_RECORDINGS_KEY, async () => {
+ const current = await loadFailedRecordings();
+ const next = current.filter((entry) => entry.sessionId !== sessionId);
+ return saveFailedRecordings(next);
+ });
+
+// Identity of a recording that is currently live in the offscreen document,
+// keyed by its spool session. Persisted when the recording starts so that a
+// browser/offscreen-document crash still leaves enough metadata for the
+// orphan sweep to surface a retryable failed-recording entry (videoId,
+// subpath, dimensions) instead of a download-only one.
+export type LiveRecordingManifest = {
+ sessionId: string;
+ videoId: string;
+ shareUrl: string;
+ mimeType: string;
+ subpath: string;
+ width: number;
+ height: number;
+ fps: number;
+ startedAt: number;
+};
+
+const LIVE_RECORDING_MANIFESTS_KEY = "cap-extension-live-recordings";
+
+const isLiveRecordingManifest = (
+ value: unknown,
+): value is LiveRecordingManifest => {
+ if (!value || typeof value !== "object") return false;
+ const candidate = value as Partial;
+ return (
+ typeof candidate.sessionId === "string" &&
+ typeof candidate.videoId === "string" &&
+ typeof candidate.shareUrl === "string" &&
+ typeof candidate.mimeType === "string" &&
+ typeof candidate.subpath === "string" &&
+ typeof candidate.width === "number" &&
+ typeof candidate.height === "number" &&
+ typeof candidate.fps === "number" &&
+ typeof candidate.startedAt === "number"
+ );
+};
+
+export const loadLiveRecordingManifests = async (): Promise<
+ LiveRecordingManifest[]
+> => {
+ const result = await getLocal([LIVE_RECORDING_MANIFESTS_KEY]);
+ const saved = result[LIVE_RECORDING_MANIFESTS_KEY];
+ if (!Array.isArray(saved)) return [];
+ return saved.filter(isLiveRecordingManifest);
+};
+
+export const saveLiveRecordingManifest = (manifest: LiveRecordingManifest) =>
+ withKeyLock(LIVE_RECORDING_MANIFESTS_KEY, async () => {
+ const current = await loadLiveRecordingManifests();
+ await setLocal({
+ [LIVE_RECORDING_MANIFESTS_KEY]: [
+ manifest,
+ ...current.filter((entry) => entry.sessionId !== manifest.sessionId),
+ ],
+ });
+ });
+
+export const removeLiveRecordingManifest = (sessionId: string) =>
+ withKeyLock(LIVE_RECORDING_MANIFESTS_KEY, async () => {
+ const current = await loadLiveRecordingManifests();
+ await setLocal({
+ [LIVE_RECORDING_MANIFESTS_KEY]: current.filter(
+ (entry) => entry.sessionId !== sessionId,
+ ),
+ });
+ });
+
+// Manifests are bookkeeping for spool sessions; once a session is gone its
+// manifest is dead weight (a failed-recording entry carries its own copy of
+// the metadata).
+export const pruneLiveRecordingManifests = (
+ survivingSessionIds: ReadonlySet,
+) =>
+ withKeyLock(LIVE_RECORDING_MANIFESTS_KEY, async () => {
+ const current = await loadLiveRecordingManifests();
+ const next = current.filter((entry) =>
+ survivingSessionIds.has(entry.sessionId),
+ );
+ if (next.length !== current.length) {
+ await setLocal({ [LIVE_RECORDING_MANIFESTS_KEY]: next });
+ }
+ });
+
+// Sign-in failures land in a detached launchWebAuthFlow callback with no open
+// UI to reject into; the popup reads this on its next status poll. Session
+// storage survives service-worker restarts but clears with the browser.
+const AUTH_ERROR_KEY = "cap-extension-auth-error";
+
+export const loadAuthError = async (): Promise => {
+ const result = await getSession([AUTH_ERROR_KEY]);
+ const saved = result[AUTH_ERROR_KEY];
+ return typeof saved === "string" && saved.length > 0 ? saved : null;
+};
+
+export const saveAuthError = (message: string) =>
+ setSession({ [AUTH_ERROR_KEY]: message });
+
+export const clearAuthError = () => removeSession(AUTH_ERROR_KEY);
+
+// Tokens registered by content scripts for the extension iframes they embed.
+// chrome.storage.session survives service worker restarts but clears with the
+// browser session, matching the lifetime of the iframes themselves.
+const MAX_OVERLAY_TOKENS = 64;
+
+export const loadOverlayTokens = async (): Promise> => {
+ const result = await getSession([OVERLAY_TOKENS_KEY]);
+ const saved = result[OVERLAY_TOKENS_KEY];
+ if (!saved || typeof saved !== "object" || Array.isArray(saved)) return {};
+ const entries = Object.entries(saved as Record).filter(
+ (entry): entry is [string, number] => typeof entry[1] === "number",
+ );
+ return Object.fromEntries(entries);
+};
+
+export const registerOverlayToken = async (token: string) => {
+ if (!token) return;
+ await withKeyLock(OVERLAY_TOKENS_KEY, async () => {
+ const tokens = await loadOverlayTokens();
+ tokens[token] = Date.now();
+ const pruned = Object.entries(tokens)
+ .sort((left, right) => right[1] - left[1])
+ .slice(0, MAX_OVERLAY_TOKENS);
+ await setSession({ [OVERLAY_TOKENS_KEY]: Object.fromEntries(pruned) });
+ });
+};
+
+export const isOverlayTokenRegistered = async (token: string) => {
+ if (!token) return false;
+ const tokens = await loadOverlayTokens();
+ return Object.hasOwn(tokens, token);
+};
+
+// The uploading-tab id is service-worker state, but MV3 workers restart at
+// any time; mirroring the id into session storage lets a restarted worker
+// reuse the existing tab instead of opening a duplicate.
+const UPLOAD_PROGRESS_TAB_KEY = "cap-extension-upload-progress-tab";
+
+export const loadUploadProgressTabId = async (): Promise => {
+ const result = await getSession([UPLOAD_PROGRESS_TAB_KEY]);
+ const saved = result[UPLOAD_PROGRESS_TAB_KEY];
+ return typeof saved === "number" && Number.isFinite(saved) ? saved : null;
+};
+
+export const saveUploadProgressTabId = (tabId: number | null) =>
+ tabId === null
+ ? removeSession(UPLOAD_PROGRESS_TAB_KEY)
+ : setSession({ [UPLOAD_PROGRESS_TAB_KEY]: tabId });
+
+export const loadSharedRecordingState =
+ async (): Promise => {
+ const result = await getSession([RECORDING_STATE_KEY]);
+ const saved = result[RECORDING_STATE_KEY];
+ return isSharedRecordingState(saved) ? saved : null;
+ };
+
+export const saveSharedRecordingState = (state: SharedRecordingState) =>
+ setSession({ [RECORDING_STATE_KEY]: state });
+
+export const loadSharedUiState = async (): Promise => {
+ const result = await getSession([SHARED_UI_STATE_KEY]);
+ return normalizeSharedUiState(result[SHARED_UI_STATE_KEY]);
+};
+
+export const saveSharedUiState = (state: SharedUiState) =>
+ setSession({ [SHARED_UI_STATE_KEY]: state });
+
+export const updateSharedUiState = (
+ update: (current: SharedUiState) => SharedUiState,
+) =>
+ withKeyLock(SHARED_UI_STATE_KEY, async () => {
+ const current = await loadSharedUiState();
+ const updated = update(current);
+ // Callers signal "no change" by returning the object they were given;
+ // skipping the write keeps high-frequency callers (the per-status panel
+ // sync) from fanning storage.onChanged events out to every open tab.
+ if (updated === current) return current;
+ const next = normalizeSharedUiState(updated);
+ await saveSharedUiState(next);
+ return next;
+ });
+
+const isSettings = (value: unknown): value is ExtensionSettings => {
+ if (!value || typeof value !== "object") return false;
+ const candidate = value as Partial;
+ return (
+ typeof candidate.apiBaseUrl === "string" &&
+ typeof candidate.webcam === "object" &&
+ candidate.webcam !== null
+ );
+};
+
+const isWebcamPreviewFrame = (value: unknown): value is WebcamPreviewFrame => {
+ if (!value || typeof value !== "object") return false;
+ const candidate = value as Partial;
+ const dimensions = candidate.dimensions;
+ return (
+ typeof candidate.dataUrl === "string" &&
+ candidate.dataUrl.startsWith("data:image/") &&
+ typeof candidate.capturedAt === "number" &&
+ Number.isFinite(candidate.capturedAt) &&
+ !!dimensions &&
+ typeof dimensions === "object" &&
+ typeof dimensions.width === "number" &&
+ typeof dimensions.height === "number" &&
+ Number.isFinite(dimensions.width) &&
+ Number.isFinite(dimensions.height) &&
+ dimensions.width > 0 &&
+ dimensions.height > 0
+ );
+};
+
+const normalizeRecordingMode = (value: unknown): RecordingMode =>
+ value === "tab" ||
+ value === "fullscreen" ||
+ value === "window" ||
+ value === "camera"
+ ? value
+ : defaultSettings.capture.recordingMode;
+
+const normalizeDevicePreference = (value: unknown): DevicePreference | null => {
+ if (!value || typeof value !== "object") return null;
+ const candidate = value as Partial;
+ if (
+ typeof candidate.deviceId !== "string" ||
+ candidate.deviceId.trim().length === 0
+ ) {
+ return null;
+ }
+
+ return {
+ deviceId: candidate.deviceId,
+ label:
+ typeof candidate.label === "string" && candidate.label.trim().length > 0
+ ? candidate.label
+ : null,
+ groupId:
+ typeof candidate.groupId === "string" &&
+ candidate.groupId.trim().length > 0
+ ? candidate.groupId
+ : null,
+ updatedAt:
+ typeof candidate.updatedAt === "number" &&
+ Number.isFinite(candidate.updatedAt)
+ ? candidate.updatedAt
+ : 0,
+ };
+};
+
+const normalizeCapturePreferences = (value: unknown): CapturePreferences => {
+ const capture =
+ value && typeof value === "object"
+ ? (value as Partial)
+ : {};
+
+ return {
+ recordingMode: normalizeRecordingMode(capture.recordingMode),
+ camera: normalizeDevicePreference(capture.camera),
+ microphone: normalizeDevicePreference(capture.microphone),
+ };
+};
+
+const normalizeWebcamShape = (
+ value: unknown,
+): ExtensionSettings["webcam"]["shape"] => {
+ if (value === "round" || value === "full" || value === "square") {
+ return value;
+ }
+ if (value === "circle") {
+ return "round";
+ }
+ if (value === "rounded") {
+ return "square";
+ }
+ return defaultSettings.webcam.shape;
+};
+
+const normalizeWebcamSettings = (
+ value: unknown,
+): ExtensionSettings["webcam"] => {
+ const webcam =
+ value && typeof value === "object"
+ ? (value as Partial)
+ : {};
+ const size =
+ typeof webcam.size === "number" && Number.isFinite(webcam.size)
+ ? webcam.size
+ : defaultSettings.webcam.size;
+ const position =
+ webcam.position === "top-left" ||
+ webcam.position === "top-right" ||
+ webcam.position === "bottom-left" ||
+ webcam.position === "bottom-right"
+ ? webcam.position
+ : defaultSettings.webcam.position;
+ const deviceId =
+ typeof webcam.deviceId === "string" && webcam.deviceId.trim().length > 0
+ ? webcam.deviceId
+ : null;
+
+ return {
+ enabled:
+ typeof webcam.enabled === "boolean"
+ ? webcam.enabled
+ : defaultSettings.webcam.enabled,
+ deviceId,
+ position,
+ size: Math.max(120, Math.min(420, size)),
+ shape: normalizeWebcamShape(webcam.shape),
+ mirror: typeof webcam.mirror === "boolean" ? webcam.mirror : false,
+ };
+};
+
+const normalizeMicrophoneSettings = (
+ value: unknown,
+): ExtensionSettings["microphone"] => {
+ const microphone =
+ value && typeof value === "object"
+ ? (value as Partial)
+ : {};
+ return {
+ enabled:
+ typeof microphone.enabled === "boolean"
+ ? microphone.enabled
+ : defaultSettings.microphone.enabled,
+ deviceId:
+ typeof microphone.deviceId === "string" &&
+ microphone.deviceId.trim().length > 0
+ ? microphone.deviceId
+ : null,
+ };
+};
+
+const normalizeSoundSettings = (
+ value: unknown,
+): ExtensionSettings["sounds"] => {
+ const sounds =
+ value && typeof value === "object"
+ ? (value as Partial)
+ : {};
+
+ return {
+ enabled:
+ typeof sounds.enabled === "boolean"
+ ? sounds.enabled
+ : defaultSettings.sounds.enabled,
+ };
+};
+
+// Only 3/5/10 are offered in the UI, but any positive integer is accepted so a
+// hand-edited or future value is not silently reset to the default.
+const ALLOWED_COUNTDOWN_SECONDS = [3, 5, 10];
+
+const normalizeCountdownSettings = (
+ value: unknown,
+): ExtensionSettings["countdown"] => {
+ const countdown =
+ value && typeof value === "object"
+ ? (value as Partial)
+ : {};
+ const seconds =
+ typeof countdown.seconds === "number" &&
+ Number.isFinite(countdown.seconds) &&
+ countdown.seconds > 0
+ ? Math.round(countdown.seconds)
+ : defaultSettings.countdown.seconds;
+
+ return {
+ enabled:
+ typeof countdown.enabled === "boolean"
+ ? countdown.enabled
+ : defaultSettings.countdown.enabled,
+ seconds: ALLOWED_COUNTDOWN_SECONDS.includes(seconds)
+ ? seconds
+ : defaultSettings.countdown.seconds,
+ };
+};
+
+const normalizeMicrophoneWarningSettings = (
+ value: unknown,
+): ExtensionSettings["microphoneWarning"] => {
+ const warning =
+ value && typeof value === "object"
+ ? (value as Partial)
+ : {};
+
+ return {
+ enabled:
+ typeof warning.enabled === "boolean"
+ ? warning.enabled
+ : defaultSettings.microphoneWarning.enabled,
+ };
+};
+
+const normalizeMediaAccessState = (value: unknown): MediaAccessState => {
+ const access =
+ value && typeof value === "object"
+ ? (value as Partial