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. +

+
    +
  1. + + Step 1 +

    + Click the Cap icon, pick a tab, window, screen or camera, and press + start. +

    +
  2. +
  3. + + Step 2 +

    + Your video streams to the cloud while you record. No exports, no + waiting at the end. +

    +
  4. +
  5. + + Step 3 +

    + Stop the recording and the share link is live instantly. Paste it + anywhere. +

    +
  6. +
+
+

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. +
  • +
+
+
+

+ Click the Cap icon in your toolbar whenever you're ready to record. +

+ + + 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 ( +
+ + +
+ + + ); +} 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) => ( +
+
+
+ {BRUSH_SIZES.map((brush) => ( + + ))} +
+
+ + +
+ +
+ + ); +} 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 ( + <> +