diff --git a/apps/chrome-extension/e2e/overlay-ui.spec.ts b/apps/chrome-extension/e2e/overlay-ui.spec.ts index dc4a287ee86..c45910d9200 100644 --- a/apps/chrome-extension/e2e/overlay-ui.spec.ts +++ b/apps/chrome-extension/e2e/overlay-ui.spec.ts @@ -45,7 +45,7 @@ type ChromeGlobal = typeof globalThis & { }; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const extensionPath = path.resolve(__dirname, "../dist"); +const extensionPath = path.resolve(__dirname, "../dist/chrome"); const SETTINGS_KEY = "cap-extension-settings"; const AUTH_KEY = "cap-extension-auth"; const BOOTSTRAP_CACHE_KEY = "cap-extension-bootstrap-cache"; diff --git a/apps/chrome-extension/e2e/recording-upload.spec.ts b/apps/chrome-extension/e2e/recording-upload.spec.ts index bec7fef5bec..7688b3f215e 100644 --- a/apps/chrome-extension/e2e/recording-upload.spec.ts +++ b/apps/chrome-extension/e2e/recording-upload.spec.ts @@ -55,7 +55,7 @@ type ChromeGlobal = typeof globalThis & { }; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const extensionPath = path.resolve(__dirname, "../dist"); +const extensionPath = path.resolve(__dirname, "../dist/chrome"); const SETTINGS_KEY = "cap-extension-settings"; const AUTH_KEY = "cap-extension-auth"; const BOOTSTRAP_CACHE_KEY = "cap-extension-bootstrap-cache"; diff --git a/apps/chrome-extension/e2e/webcam-start.spec.ts b/apps/chrome-extension/e2e/webcam-start.spec.ts index 940cd39c0d9..b5cb1a5a6f8 100644 --- a/apps/chrome-extension/e2e/webcam-start.spec.ts +++ b/apps/chrome-extension/e2e/webcam-start.spec.ts @@ -34,7 +34,7 @@ type ChromeGlobal = typeof globalThis & { }; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const extensionPath = path.resolve(__dirname, "../dist"); +const extensionPath = path.resolve(__dirname, "../dist/chrome"); const SETTINGS_KEY = "cap-extension-settings"; const AUTH_KEY = "cap-extension-auth"; const BOOTSTRAP_CACHE_KEY = "cap-extension-bootstrap-cache"; diff --git a/apps/chrome-extension/public/manifest.json b/apps/chrome-extension/manifests/manifest.chrome.json similarity index 95% rename from apps/chrome-extension/public/manifest.json rename to apps/chrome-extension/manifests/manifest.chrome.json index eeb058f457c..6e838b21aea 100644 --- a/apps/chrome-extension/public/manifest.json +++ b/apps/chrome-extension/manifests/manifest.chrome.json @@ -22,8 +22,7 @@ } }, "background": { - "service_worker": "assets/service-worker.js", - "type": "module" + "service_worker": "assets/service-worker.js" }, "options_page": "options.html", "permissions": [ diff --git a/apps/chrome-extension/manifests/manifest.firefox.json b/apps/chrome-extension/manifests/manifest.firefox.json new file mode 100644 index 00000000000..6e499af4bce --- /dev/null +++ b/apps/chrome-extension/manifests/manifest.firefox.json @@ -0,0 +1,58 @@ +{ + "manifest_version": 3, + "name": "Cap - Screen Recorder & Screen Capture", + "short_name": "Cap", + "description": "Free, open source screen recorder. Capture your screen, camera & mic in Firefox and share a video link the moment you stop.", + "version": "1.0.2", + "homepage_url": "https://cap.so", + "browser_specific_settings": { + "gecko": { + "id": "extension@cap.so", + "strict_min_version": "128.0", + "data_collection_permissions": { + "required": ["authenticationInfo", "websiteContent"] + } + } + }, + "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": { + "scripts": ["assets/service-worker.js"] + }, + "options_ui": { + "page": "options.html", + "open_in_tab": true + }, + "permissions": ["activeTab", "identity", "scripting", "storage"], + "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", "welcome.html"], + "matches": ["http://*/*", "https://*/*", "file:///*"] + }, + { + "resources": ["camera-preview.html", "popup.html"], + "matches": ["http://*/*", "https://*/*", "file:///*"] + } + ] +} diff --git a/apps/chrome-extension/offscreen.html b/apps/chrome-extension/offscreen.html deleted file mode 100644 index 07811d502c2..00000000000 --- a/apps/chrome-extension/offscreen.html +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - Cap Recorder Offscreen - - - - - diff --git a/apps/chrome-extension/package.json b/apps/chrome-extension/package.json index b77b2a3362b..0ed03bbf7ac 100644 --- a/apps/chrome-extension/package.json +++ b/apps/chrome-extension/package.json @@ -4,13 +4,18 @@ "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)", + "build": "pnpm build:chrome && pnpm build:firefox", + "build:chrome": "rm -rf dist/chrome && TARGET=chrome vite build --config vite.background.config.ts && TARGET=chrome vite build --config vite.content.config.ts && TARGET=chrome vite build --config vite.content-overlay.config.ts && TARGET=chrome vite build", + "build:firefox": "rm -rf dist/firefox && TARGET=firefox vite build --config vite.background.config.ts && TARGET=firefox vite build --config vite.content.config.ts && TARGET=firefox vite build --config vite.content-overlay.config.ts && TARGET=firefox vite build", + "dev": "rm -rf dist/chrome && (trap 'kill 0' INT TERM; TARGET=chrome vite build --watch --config vite.background.config.ts --mode development & TARGET=chrome vite build --watch --config vite.content.config.ts --mode development & TARGET=chrome vite build --watch --config vite.content-overlay.config.ts --mode development & TARGET=chrome vite build --watch --mode development & wait)", + "dev:firefox": "rm -rf dist/firefox && (trap 'kill 0' INT TERM; TARGET=firefox vite build --watch --config vite.background.config.ts --mode development & TARGET=firefox vite build --watch --config vite.content.config.ts --mode development & TARGET=firefox vite build --watch --config vite.content-overlay.config.ts --mode development & TARGET=firefox vite build --watch --mode development & wait)", + "run:firefox": "web-ext run --source-dir dist/firefox", + "lint:firefox": "web-ext lint --source-dir dist/firefox", "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" + "test:e2e": "pnpm build:chrome && playwright test", + "test:e2e:headed": "pnpm build:chrome && playwright test --headed" }, "dependencies": { "@cap/recorder-core": "workspace:*", @@ -36,6 +41,7 @@ "tailwindcss": "^3.4.0", "typescript": "^5.8.3", "vite": "6.3.5", - "vitest": "^3.2.0" + "vitest": "^3.2.0", + "web-ext": "^8.9.0" } } diff --git a/apps/chrome-extension/recorder.html b/apps/chrome-extension/recorder.html new file mode 100644 index 00000000000..fea79da731e --- /dev/null +++ b/apps/chrome-extension/recorder.html @@ -0,0 +1,96 @@ + + + + + + Cap Recorder + + + + +
+ Cap +

Cap recording engine

+

+ This window powers your recording and uploads. It stays minimized + while you record and closes by itself when it is no longer needed. +

+
+ +
+ Cap +

Ready to record?

+

+ Firefox asks for one click here, then you'll choose what to share. +

+ +

+ Recording starts after the countdown. This window gets out of the + way by itself. +

+
+ + + diff --git a/apps/chrome-extension/src/background/recorder-host.ts b/apps/chrome-extension/src/background/recorder-host.ts new file mode 100644 index 00000000000..6a51d7cd2d0 --- /dev/null +++ b/apps/chrome-extension/src/background/recorder-host.ts @@ -0,0 +1,207 @@ +import { capabilities } from "../platform/capabilities"; +import { wait } from "../shared/runtime"; + +// The recorder document (recorder.html) hosts capture, upload, device +// enumeration, the mic probe and the camera-preview relay. On Chrome it runs +// as an offscreen document; Firefox has no offscreen API, so it runs in a +// small popup window instead (minimized unless the user must interact with +// it — getDisplayMedia there requires transient user activation, which the +// page collects with an arm button). Either way the document closes itself +// once idle, so this module only ever has to create and find it. +export const RECORDER_URL = "recorder.html"; + +const RECORDER_WINDOW_WIDTH = 440; +const RECORDER_WINDOW_HEIGHT = 400; +const RECORDER_READY_TIMEOUT_MS = 10_000; +const RECORDER_READY_POLL_INTERVAL_MS = 100; + +let recorderHostCreation: Promise | null = null; + +type RecorderContext = { + documentUrl?: string; + windowId?: number; +}; + +// Match by document URL only: Chrome reports the recorder as an +// OFFSCREEN_DOCUMENT context while Firefox's popup window may surface as TAB +// (or another type — the spec leaves extension pages in popup windows +// underspecified). Only the recorder document ever has this URL, so the URL +// filter alone is unambiguous on both browsers. +const getRecorderContexts = async (): Promise => { + const recorderUrl = chrome.runtime.getURL(RECORDER_URL); + return new Promise((resolve) => { + chrome.runtime.getContexts({ documentUrls: [recorderUrl] }, (contexts) => + resolve(contexts ?? []), + ); + }); +}; + +export const hasRecorderHost = async () => + (await getRecorderContexts()).length > 0; + +const recorderWindowIdFrom = (contexts: RecorderContext[]) => { + for (const context of contexts) { + if (typeof context.windowId === "number" && context.windowId >= 0) { + return context.windowId; + } + } + return null; +}; + +const createOffscreenDocument = () => + new Promise((resolve, reject) => { + chrome.offscreen.createDocument( + { + url: RECORDER_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)); + }, + ); + }); + +// Center the interactive recorder window over the browser window the user is +// looking at, so the arm dialog reads as part of the flow rather than a stray +// popup. +const getCenteredBounds = () => + new Promise<{ left?: number; top?: number }>((resolve) => { + chrome.windows.getLastFocused((focusedWindow) => { + if ( + chrome.runtime.lastError || + focusedWindow?.left === undefined || + focusedWindow.top === undefined || + !focusedWindow.width || + !focusedWindow.height + ) { + resolve({}); + return; + } + resolve({ + left: Math.max( + 0, + Math.round( + focusedWindow.left + + (focusedWindow.width - RECORDER_WINDOW_WIDTH) / 2, + ), + ), + top: Math.max( + 0, + Math.round( + focusedWindow.top + + (focusedWindow.height - RECORDER_WINDOW_HEIGHT) / 2, + ), + ), + }); + }); + }); + +const createRecorderWindow = async (interactive: boolean) => { + // state cannot be combined with bounds or focus in windows.create. + const createData: chrome.windows.CreateData = interactive + ? { + url: RECORDER_URL, + type: "popup", + focused: true, + width: RECORDER_WINDOW_WIDTH, + height: RECORDER_WINDOW_HEIGHT, + ...(await getCenteredBounds()), + } + : { url: RECORDER_URL, type: "popup", state: "minimized" }; + const windowId = await new Promise((resolve, reject) => { + chrome.windows.create(createData, (created) => { + const error = chrome.runtime.lastError; + if (error) { + reject(new Error(error.message ?? "Failed to open the Cap recorder")); + return; + } + resolve(created?.id); + }); + }); + + try { + await waitForRecorderReady(); + } catch (error) { + // Close the unresponsive window, or every later ensureRecorderHost call + // would find its context, assume the host is healthy, and fail forever. + if (windowId !== undefined) { + await new Promise((resolve) => { + chrome.windows.remove(windowId, () => { + void chrome.runtime.lastError; + resolve(); + }); + }); + } + throw error; + } +}; + +const pingRecorder = () => + new Promise((resolve) => { + chrome.runtime.sendMessage( + { target: "offscreen", type: "get-recording-status" }, + (response) => { + resolve(!chrome.runtime.lastError && Boolean(response)); + }, + ); + }); + +// chrome.offscreen.createDocument resolves only once the document has loaded, +// but windows.create resolves as soon as the window exists, well before the +// recorder script runs — so the Firefox path waits until the document +// actually answers before callers start sending it real messages. +const waitForRecorderReady = async () => { + const deadline = Date.now() + RECORDER_READY_TIMEOUT_MS; + while (Date.now() < deadline) { + if (await pingRecorder()) return; + await wait(RECORDER_READY_POLL_INTERVAL_MS); + } + throw new Error("The Cap recorder window did not become ready in time"); +}; + +const focusRecorderWindow = (windowId: number) => + new Promise((resolve) => { + chrome.windows.update(windowId, { focused: true, state: "normal" }, () => { + void chrome.runtime.lastError; + resolve(); + }); + }); + +// Ensures the recorder document exists; with `interactive` it also brings the +// Firefox recorder window forward so the user can click its arm button (and +// see permission prompts anchored to it). Chrome's offscreen document has no +// window, so `interactive` is a no-op there. +export const ensureRecorderHost = async ( + options: { interactive?: boolean } = {}, +) => { + const contexts = await getRecorderContexts(); + if (contexts.length > 0) { + if (options.interactive === true && !capabilities.supportsOffscreen) { + const windowId = recorderWindowIdFrom(contexts); + if (windowId !== null) await focusRecorderWindow(windowId); + } + return; + } + + recorderHostCreation ??= ( + capabilities.supportsOffscreen + ? createOffscreenDocument() + : createRecorderWindow(options.interactive === true) + ).finally(() => { + recorderHostCreation = null; + }); + await recorderHostCreation; +}; diff --git a/apps/chrome-extension/src/background/service-worker.ts b/apps/chrome-extension/src/background/service-worker.ts index 43eccb06c54..d4f4b97a2bd 100644 --- a/apps/chrome-extension/src/background/service-worker.ts +++ b/apps/chrome-extension/src/background/service-worker.ts @@ -1,3 +1,5 @@ +import { capabilities } from "../platform/capabilities"; +import { EXTENSION_PROTOCOL } from "../platform/extension-protocol"; import { ApiRequestError, createAuthStart, @@ -10,11 +12,13 @@ import { isServiceWorkerRequest, } from "../shared/messages"; import { rememberRecordingMode } from "../shared/preferences"; +import { wait } from "../shared/runtime"; import { clearAuth, clearAuthError, clearCachedBootstrap, clearPendingAuth, + clearSharedSessionState, isOverlayTokenRegistered, loadAuth, loadAuthError, @@ -56,6 +60,7 @@ import type { ServiceWorkerRequest, ServiceWorkerResponse, } from "../shared/types"; +import { ensureRecorderHost, hasRecorderHost } from "./recorder-host"; // popup.html is web-accessible with use_dynamic_url so sites cannot fingerprint // the extension via the overlay iframe's static URL; that same flag makes its @@ -63,7 +68,6 @@ import type { // a window. The standalone fallback therefore loads a privileged twin that is // not in web_accessible_resources. const POPUP_URL = "popup-window.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; @@ -83,7 +87,6 @@ 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; @@ -98,11 +101,20 @@ 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({ +// storage call from a content script fails. Firefox does not implement +// setAccessLevel at all — calling it unconditionally throws and kills the +// whole background script — so content scripts there rely on the runtime +// message fallbacks instead of the session-storage mirror. +chrome.storage.session.setAccessLevel?.({ accessLevel: "TRUSTED_AND_UNTRUSTED_CONTEXTS", }); +// On Firefox the shared "session" keys live in storage.local; drop them at +// browser startup so stale recording/UI state does not outlive the session. +chrome.runtime.onStartup.addListener(() => { + void clearSharedSessionState().catch(() => undefined); +}); + const getActiveTab = () => new Promise((resolve) => { chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { @@ -197,63 +209,6 @@ const focusTab = async (tabId: number) => { 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(); @@ -276,15 +231,19 @@ const sendOffscreenRuntimeMessage = (message: OffscreenRequest) => const sendOffscreen = async ( message: OffscreenRequest, - options: { createIfMissing?: boolean } = {}, + options: { createIfMissing?: boolean; interactive?: boolean } = {}, ) => { + // `interactive` must survive into the retry path: recreating the Firefox + // recorder window minimized for a start-recording message would leave its + // arm button waiting for a click no one can make. + const hostOptions = { interactive: options.interactive === true }; if (options.createIfMissing === false) { - const hasDocument = await hasOffscreenDocument(); + const hasDocument = await hasRecorderHost(); if (!hasDocument) { return { ok: true, status: recordingStatus } satisfies OffscreenResponse; } } else { - await ensureOffscreenDocument(); + await ensureRecorderHost(hostOptions); } let lastError: unknown; @@ -301,7 +260,7 @@ const sendOffscreen = async ( break; } await wait(OFFSCREEN_MESSAGE_RETRY_DELAY_MS); - await ensureOffscreenDocument(); + await ensureRecorderHost(hostOptions); } } @@ -441,7 +400,7 @@ const canInjectIntoTab = (tab: chrome.tabs.Tab) => { const isWebPageSender = (sender: chrome.runtime.MessageSender) => { if (!sender.tab) return false; const senderUrl = sender.url ?? ""; - return !senderUrl.startsWith("chrome-extension:"); + return !senderUrl.startsWith(EXTENSION_PROTOCOL); }; // camera-preview.html is web accessible, so any site can load it in an @@ -456,7 +415,7 @@ const isCameraPreviewRequestAllowed = async ( if (!(await isOverlayTokenRegistered(token))) return false; const senderUrl = sender.url ?? ""; - if (senderUrl.startsWith("chrome-extension:")) { + if (senderUrl.startsWith(EXTENSION_PROTOCOL)) { // The camera preview document is the only extension page that drives // the camera. try { @@ -481,7 +440,7 @@ const isCameraPreviewEventAllowed = async ( ) => { if (!token || !(await isOverlayTokenRegistered(token))) return false; const senderUrl = sender.url ?? ""; - if (!senderUrl.startsWith("chrome-extension:")) return false; + if (!senderUrl.startsWith(EXTENSION_PROTOCOL)) return false; try { return new URL(senderUrl).pathname === "/camera-preview.html"; } catch { @@ -1220,6 +1179,11 @@ const resolveMicWarning = async ( }; const startRecording = async (mode: RecordingMode) => { + // Defense against stale overlay/content-script messages: the mode selector + // already hides tab capture where it is unsupported. + if (mode === "tab" && !capabilities.supportsTabCapture) { + throw new Error("Tab recording is not available in this browser."); + } const { settings, auth, bootstrap } = await requireSignedInState(); externalCaptureAutoPipPending = false; const recordingSettings = @@ -1284,16 +1248,23 @@ const startRecording = async (mode: RecordingMode) => { try { await Promise.all(readyWaits); - return await sendOffscreen({ - target: "offscreen", - type: "start-recording", - mode, - settings: recordingSettings, - auth, - bootstrap, - tabId, - tabStreamId, - }); + // On Firefox the recorder document must collect a click (transient + // activation for getDisplayMedia), so its window is created — or + // surfaced — in front of the user. A no-op beyond document creation on + // Chrome. + return await sendOffscreen( + { + target: "offscreen", + type: "start-recording", + mode, + settings: recordingSettings, + auth, + bootstrap, + tabId, + tabStreamId, + }, + { interactive: true }, + ); } 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 @@ -1311,7 +1282,7 @@ const forwardToOffscreen = (type: OffscreenRequest["type"]) => sendOffscreen({ target: "offscreen", type } as OffscreenRequest); const syncRecordingStatus = async () => { - const hasDocument = await hasOffscreenDocument(); + const hasDocument = await hasRecorderHost(); if (!hasDocument) { if (isActiveRecordingStatus(recordingStatus)) { setRecordingStatusAndBroadcast({ phase: "idle" }); @@ -1687,6 +1658,29 @@ const handleRequest = async ( return { ok: true }; } + // Firefox content scripts cannot dynamic-import extension modules + // (bugzilla 1536094), so the bootstrap asks for the overlay bundle to be + // injected into its isolated world instead. + if (message.type === "inject-overlay-module") { + const tabId = sender.tab?.id; + if (tabId === undefined) { + return { ok: false, error: "Overlay injection needs a tab sender" }; + } + return new Promise((resolve) => { + chrome.scripting.executeScript( + { target: { tabId }, files: ["content/overlay.js"] }, + () => { + const error = chrome.runtime.lastError; + resolve( + error + ? { ok: false, error: error.message ?? "Injection failed" } + : { ok: true }, + ); + }, + ); + }); + } + if ( message.type === "pause-recording" || message.type === "resume-recording" @@ -1924,6 +1918,23 @@ chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { } }); +// The Firefox recorder host is a window the user can close mid-recording; +// syncRecordingStatus already degrades to idle when the host is gone, it +// just needs to be triggered promptly. Chrome's offscreen document is not +// user-closable, so this never fires anything meaningful there. +chrome.windows.onRemoved.addListener(() => { + if (capabilities.supportsOffscreen) return; + void syncRecordingStatus().catch(() => undefined); +}); + +// Firefox grants host permissions after install (welcome page); inject the +// bootstrap into tabs that were already open once that happens. +chrome.permissions.onAdded.addListener((permissions) => { + if (permissions.origins?.length) { + void injectOverlayIntoOpenTabs(); + } +}); + chrome.tabs.onRemoved.addListener((tabId) => { if (uploadProgressTabId === tabId) { setUploadProgressTabId(null); diff --git a/apps/chrome-extension/src/content/bootstrap.ts b/apps/chrome-extension/src/content/bootstrap.ts index 60cf019e170..15a7257beed 100644 --- a/apps/chrome-extension/src/content/bootstrap.ts +++ b/apps/chrome-extension/src/content/bootstrap.ts @@ -1,9 +1,12 @@ +import { TARGET } from "../platform/target"; import { isOverlayMessage, isRecordingStatusBroadcast, } from "../shared/messages"; +import { sendServiceWorkerMessage } from "../shared/runtime"; import { RECORDING_STATE_KEY, + SHARED_STATE_AREA, SHARED_UI_STATE_KEY, } from "../shared/storage-keys"; @@ -72,8 +75,30 @@ const bootstrap = () => { let modulePromise: Promise | null = null; let moduleStarted = false; + const loadOverlayModule = (): Promise => { + if (TARGET !== "firefox") { + return import(/* @vite-ignore */ overlayModuleUrl); + } + // Firefox content scripts cannot dynamic-import extension modules + // (bugzilla 1536094). The Firefox build ships the overlay as a classic + // IIFE exposing CapOverlay; ask the service worker to executeScript it + // into this same isolated world. This is the one message the bootstrap + // sends, and only once a tab actually needs UI. + return sendServiceWorkerMessage({ + target: "service-worker", + type: "inject-overlay-module", + }).then((response) => { + const module = (globalThis as { CapOverlay?: OverlayModule }).CapOverlay; + if (response.ok && module) return module; + throw new Error( + ("error" in response ? response.error : undefined) ?? + "Overlay module injection failed", + ); + }); + }; + const startOverlayModule = () => { - modulePromise ??= import(/* @vite-ignore */ overlayModuleUrl) + modulePromise ??= loadOverlayModule() .then((module: OverlayModule) => { moduleStarted = true; // The module registers its own runtime and storage listeners; @@ -84,8 +109,11 @@ const bootstrap = () => { chrome.storage.onChanged.removeListener(handleStorageChange); module.init(pendingMessages); }) - .catch(() => { - // Leave the trigger listeners armed so a later signal retries. + .catch((error: unknown) => { + // Leave the trigger listeners armed so a later signal retries. The + // failure is otherwise invisible ("clicking the icon does nothing"), + // so leave a trace for debugging. + console.error("[cap] overlay module load failed", error); modulePromise = null; }); return modulePromise; @@ -95,7 +123,7 @@ const bootstrap = () => { changes: Record, areaName: string, ) => { - if (areaName !== "session") return; + if (areaName !== SHARED_STATE_AREA) return; if ( isUiPhase(changes[RECORDING_STATE_KEY]?.newValue) || readPanelOpen(changes[SHARED_UI_STATE_KEY]?.newValue) @@ -157,11 +185,16 @@ const bootstrap = () => { }); } - // 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). + // One cheap 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). The shared state lives in storage.session on Chrome + // and storage.local on Firefox (see SHARED_STATE_AREA). try { - chrome.storage.session.get( + const sharedStateStorage = + SHARED_STATE_AREA === "session" + ? chrome.storage.session + : chrome.storage.local; + sharedStateStorage.get( [RECORDING_STATE_KEY, SHARED_UI_STATE_KEY], (items) => { if (chrome.runtime.lastError || !items) return; diff --git a/apps/chrome-extension/src/content/overlay.tsx b/apps/chrome-extension/src/content/overlay.tsx index c83f93aee13..5e27e967097 100644 --- a/apps/chrome-extension/src/content/overlay.tsx +++ b/apps/chrome-extension/src/content/overlay.tsx @@ -26,6 +26,7 @@ import { loadWebcamPreviewDismissed, OVERLAY_UI_STATE_KEY, SETTINGS_KEY, + SHARED_STATE_AREA, SHARED_UI_STATE_KEY, saveLastWebcamPreviewFrame, saveSettings, @@ -388,7 +389,7 @@ function RecorderPanelOverlay({ changes: Record, areaName: string, ) => { - if (areaName === "session" && changes[SHARED_UI_STATE_KEY]) { + if (areaName === SHARED_STATE_AREA && changes[SHARED_UI_STATE_KEY]) { syncPanelState(); } }; @@ -781,7 +782,10 @@ function OverlayApp() { if (areaName === "local" && changes[SETTINGS_KEY]) { syncSettingsState(); } - if (areaName === "session" && changes[WEBCAM_PREVIEW_DISMISSED_KEY]) { + if ( + areaName === SHARED_STATE_AREA && + changes[WEBCAM_PREVIEW_DISMISSED_KEY] + ) { syncPreviewDismissedState(); } }; diff --git a/apps/chrome-extension/src/content/recording-bar.tsx b/apps/chrome-extension/src/content/recording-bar.tsx index e68091c6d6b..0e0c11ea8fc 100644 --- a/apps/chrome-extension/src/content/recording-bar.tsx +++ b/apps/chrome-extension/src/content/recording-bar.tsx @@ -21,6 +21,7 @@ import { loadSharedUiState, OVERLAY_UI_STATE_KEY, RECORDING_STATE_KEY, + SHARED_STATE_AREA, SHARED_UI_STATE_KEY, updateOverlayUiState, updateSharedUiState, @@ -221,10 +222,10 @@ export function RecordingBarOverlay({ if (areaName === "local" && changes[AUTH_KEY]) { syncAuthState(); } - if (areaName === "session" && changes[RECORDING_STATE_KEY]) { + if (areaName === SHARED_STATE_AREA && changes[RECORDING_STATE_KEY]) { syncSharedRecordingState(); } - if (areaName === "session" && changes[SHARED_UI_STATE_KEY]) { + if (areaName === SHARED_STATE_AREA && changes[SHARED_UI_STATE_KEY]) { syncSharedUiState(); } }; diff --git a/apps/chrome-extension/src/permission/camera-permission.tsx b/apps/chrome-extension/src/permission/camera-permission.tsx index cf6f909d12f..b36bebb481f 100644 --- a/apps/chrome-extension/src/permission/camera-permission.tsx +++ b/apps/chrome-extension/src/permission/camera-permission.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; import { createRoot } from "react-dom/client"; +import { TARGET } from "../platform/target"; import { CapBrand, DoodleBoilFilter } from "../shared/cap-brand"; import { toCameraDevices } from "../shared/devices"; import { mountPageNav } from "../shared/page-nav"; @@ -18,14 +19,24 @@ mountPageNav("camera"); type Status = "idle" | "requesting" | "ready" | "error"; +const BROWSER_NAME = TARGET === "firefox" ? "Firefox" : "Chrome"; + +// Firefox forgets getUserMedia grants when the prompt is dismissed without +// the checkbox, which would resurface the prompt inside a minimized recorder +// window on every recording. +const REMEMBER_HINT = + TARGET === "firefox" + ? ' Tick "Remember this decision" so Firefox keeps the grant for future recordings.' + : ""; + const headlines: Record = { idle: { title: "Camera & microphone access", lede: "Allow access once so Cap can show your camera preview and record your voice.", }, requesting: { - title: "Waiting for Chrome", - lede: "Click Allow in the browser prompt up by the address bar.", + title: `Waiting for ${BROWSER_NAME}`, + lede: `Click Allow in the browser prompt up by the address bar.${REMEMBER_HINT}`, }, ready: { title: "You're all set", @@ -49,7 +60,7 @@ const getCameraErrorMessage = (error: unknown) => { error.name === "NotAllowedError" || error.message.toLowerCase().includes("permission") ) { - return "Chrome did not grant camera access. Click Allow in the browser prompt."; + return `${BROWSER_NAME} did not grant camera access. Click Allow in the browser prompt.${REMEMBER_HINT}`; } if (error.name === "NotFoundError") return "No camera was found."; if (error.name === "NotReadableError") return "Camera is already in use."; diff --git a/apps/chrome-extension/src/platform/capabilities.ts b/apps/chrome-extension/src/platform/capabilities.ts new file mode 100644 index 00000000000..5949cb5f226 --- /dev/null +++ b/apps/chrome-extension/src/platform/capabilities.ts @@ -0,0 +1,14 @@ +import { TARGET } from "./target"; + +// Per-target feature availability. Firefox has no chrome.offscreen or +// chrome.tabCapture, its getDisplayMedia exposes no system audio or per-tab +// surface, MV3 host permissions are user-grantable rather than granted at +// install, and getDisplayMedia requires transient user activation so capture +// cannot start without a click inside the recorder document. +export const capabilities = { + supportsTabCapture: TARGET === "chrome", + supportsOffscreen: TARGET === "chrome", + supportsSystemAudioCapture: TARGET === "chrome", + hostPermissionsGrantedAtInstall: TARGET === "chrome", + recorderNeedsUserGesture: TARGET === "firefox", +} as const; diff --git a/apps/chrome-extension/src/platform/extension-protocol.ts b/apps/chrome-extension/src/platform/extension-protocol.ts new file mode 100644 index 00000000000..51343207880 --- /dev/null +++ b/apps/chrome-extension/src/platform/extension-protocol.ts @@ -0,0 +1,4 @@ +// "chrome-extension:" on Chromium, "moz-extension:" on Firefox. Sender checks +// must use this instead of a hardcoded literal or Firefox extension pages get +// misclassified as web pages. +export const EXTENSION_PROTOCOL = new URL(chrome.runtime.getURL("")).protocol; diff --git a/apps/chrome-extension/src/platform/target.ts b/apps/chrome-extension/src/platform/target.ts new file mode 100644 index 00000000000..f4bad385e87 --- /dev/null +++ b/apps/chrome-extension/src/platform/target.ts @@ -0,0 +1,8 @@ +// Injected by vite `define` per build target; undefined under vitest, which +// runs without a define and must behave like the Chrome build. +declare const __TARGET__: "chrome" | "firefox" | undefined; + +export type ExtensionTarget = "chrome" | "firefox"; + +export const TARGET: ExtensionTarget = + typeof __TARGET__ === "undefined" ? "chrome" : __TARGET__; diff --git a/apps/chrome-extension/src/popup/components/recording-mode-selector.tsx b/apps/chrome-extension/src/popup/components/recording-mode-selector.tsx index 7ef1d188a55..bb413ea45a5 100644 --- a/apps/chrome-extension/src/popup/components/recording-mode-selector.tsx +++ b/apps/chrome-extension/src/popup/components/recording-mode-selector.tsx @@ -5,6 +5,7 @@ import { MonitorIcon, RectangleHorizontal, } from "lucide-react"; +import { capabilities } from "../../platform/capabilities"; import type { RecordingMode } from "../../shared/types"; import { SelectContent, @@ -55,6 +56,10 @@ export const RecordingModeSelector = ({ }, }; + const availableModeEntries = Object.entries(recordingModeOptions).filter( + ([value]) => value !== "tab" || capabilities.supportsTabCapture, + ); + const selectedOption = mode ? recordingModeOptions[mode] : null; const SelectedIcon = selectedOption?.icon; @@ -81,7 +86,7 @@ export const RecordingModeSelector = ({ - {Object.entries(recordingModeOptions).map(([value, option]) => { + {availableModeEntries.map(([value, option]) => { const OptionIcon = option.icon; return ( diff --git a/apps/chrome-extension/src/popup/main.tsx b/apps/chrome-extension/src/popup/main.tsx index be8fc9c0469..081c6c55b12 100644 --- a/apps/chrome-extension/src/popup/main.tsx +++ b/apps/chrome-extension/src/popup/main.tsx @@ -1,6 +1,7 @@ import clsx from "clsx"; import { useCallback, useEffect, useRef, useState } from "react"; import { createRoot } from "react-dom/client"; +import { capabilities } from "../platform/capabilities"; import { toCameraDevices, toMicrophoneDevices } from "../shared/devices"; import { reconcileRememberedDevices, @@ -92,6 +93,13 @@ const isRecordingStatus = ( status.phase === "paused" || status.phase === "uploading"; +// A remembered "tab" mode can arrive from settings synced on a browser that +// supports tab capture; fall back rather than render an unselectable mode. +const resolveAvailableMode = (mode: RecordingMode): RecordingMode => + mode === "tab" && !capabilities.supportsTabCapture ? "fullscreen" : mode; + +const HOST_PERMISSION_ORIGINS = ["http://*/*", "https://*/*"]; + function App() { // This page is web accessible, so any site can put it in an iframe and // overlay it for clickjacking. When embedded, render nothing until the @@ -114,6 +122,9 @@ function App() { const [cameraSelectOpen, setCameraSelectOpen] = useState(false); const [micSelectOpen, setMicSelectOpen] = useState(false); const [failedRecordingsCount, setFailedRecordingsCount] = useState(0); + const [hostAccessGranted, setHostAccessGranted] = useState( + capabilities.hostPermissionsGrantedAtInstall, + ); const settingsRef = useRef(defaultSettings); const recordingActive = isRecordingStatus(status); @@ -131,7 +142,7 @@ function App() { const updateSettings = useCallback(async (next: ExtensionSettings) => { settingsRef.current = next; setSettings(next); - setMode(next.capture.recordingMode); + setMode(resolveAvailableMode(next.capture.recordingMode)); await saveSettings(next); await sendServiceWorkerMessage({ target: "service-worker", @@ -143,7 +154,7 @@ function App() { const applySettings = useCallback((next: ExtensionSettings) => { settingsRef.current = next; setSettings(next); - setMode(next.capture.recordingMode); + setMode(resolveAvailableMode(next.capture.recordingMode)); }, []); const loadDevices = useCallback(async () => { @@ -225,6 +236,21 @@ function App() { }); }; + const openWelcomePage = () => { + chrome.tabs.create({ + url: chrome.runtime.getURL("welcome.html"), + active: true, + }); + }; + + useEffect(() => { + if (capabilities.hostPermissionsGrantedAtInstall) return; + chrome.permissions.contains( + { origins: HOST_PERMISSION_ORIGINS }, + (granted) => setHostAccessGranted(Boolean(granted)), + ); + }, []); + useEffect(() => { if (!IS_EMBEDDED) return; let disposed = false; @@ -664,7 +690,7 @@ function App() { onPermissionBlocked={openPermissionPage} /> - {mode !== "camera" && ( + {mode !== "camera" && capabilities.supportsSystemAudioCapture && (
void stop()} />
+ {!hostAccessGranted && ( + + )} {recordingBarStatus && (
void) => { + if (TARGET !== "firefox") return; + const container = document.getElementById("arm-capture"); + const button = document.getElementById("arm-capture-button"); + const idleState = document.getElementById("idle-state"); + if ( + !(container instanceof HTMLElement) || + !(button instanceof HTMLButtonElement) + ) { + return; + } + + if (idleState) idleState.hidden = true; + container.hidden = false; + // Belt and braces: if any code path brought this document up minimized + // (e.g. a host created for device enumeration), surface it — a hidden arm + // button would otherwise wait forever for a click no one can make. + chrome.windows.getCurrent((currentWindow) => { + if (chrome.runtime.lastError || currentWindow?.id === undefined) return; + chrome.windows.update( + currentWindow.id, + { state: "normal", focused: true }, + () => { + void chrome.runtime.lastError; + }, + ); + }); + try { + await new Promise((resolve, reject) => { + const onClick = () => { + window.clearInterval(intervalId); + resolve(); + }; + const intervalId = window.setInterval(() => { + try { + throwIfCanceled(); + } catch (error) { + window.clearInterval(intervalId); + button.removeEventListener("click", onClick); + reject(error instanceof Error ? error : new Error(String(error))); + } + }, ARM_CANCEL_POLL_INTERVAL_MS); + button.addEventListener("click", onClick, { once: true }); + }); + } finally { + container.hidden = true; + if (idleState) idleState.hidden = false; + } +}; + +// Once every capture prompt has been answered this window has no further +// business on screen; get it out of the shot (and out of the picker's way) +// before the countdown runs. Cosmetic, so callers fire-and-forget it. +export const minimizeRecorderWindow = async () => { + if (TARGET !== "firefox") return; + await new Promise((resolve) => { + chrome.windows.getCurrent((currentWindow) => { + if (chrome.runtime.lastError || currentWindow?.id === undefined) { + resolve(); + return; + } + chrome.windows.update(currentWindow.id, { state: "minimized" }, () => { + void chrome.runtime.lastError; + resolve(); + }); + }); + }); +}; diff --git a/apps/chrome-extension/src/offscreen/recorder.ts b/apps/chrome-extension/src/recorder/recorder.ts similarity index 98% rename from apps/chrome-extension/src/offscreen/recorder.ts rename to apps/chrome-extension/src/recorder/recorder.ts index d9ea4377a1c..9aabb3a9bac 100644 --- a/apps/chrome-extension/src/offscreen/recorder.ts +++ b/apps/chrome-extension/src/recorder/recorder.ts @@ -19,7 +19,7 @@ import { RECORDING_SPOOL_LIVE_MIN_IDLE_MS, RecordingSpool, recoverRecordingSpoolSession, - selectRecordingPipeline, + selectRecordingPipelineFromSupport, shouldRetryDisplayMediaWithoutPreferences, type VideoId, } from "@cap/recorder-core"; @@ -69,6 +69,7 @@ import { toSessionDescriptionInit, waitForIceGatheringComplete, } from "../shared/webrtc"; +import { awaitCaptureGesture, minimizeRecorderWindow } from "./arm-gesture"; const RECORDING_TIMESLICE_MS = 1000; const RECORDING_TIMESLICE_GUARD_MS = RECORDING_TIMESLICE_MS * 3; @@ -599,6 +600,9 @@ const addAudioTracks = ({ if (streamsWithAudio.length === 0) return undefined; const audioContext = new AudioContext(); + // Autoplay policy can hand back a suspended context in a document that has + // never seen user activation, which would silently mute the mixed tracks. + void audioContext.resume().catch(() => undefined); const destination = audioContext.createMediaStreamDestination(); streamsWithAudio.forEach((stream, index) => { @@ -837,6 +841,7 @@ const startRecording = async (request: StartRecordingRequest) => { try { status = { phase: "creating" }; + await awaitCaptureGesture(throwIfStartCanceled); const mainStream = await getMainStream(request); ownedStreams.push(mainStream); throwIfStartCanceled(); @@ -852,6 +857,7 @@ const startRecording = async (request: StartRecordingRequest) => { ownedStreams.push(microphoneStream); } throwIfStartCanceled(); + void minimizeRecorderWindow(); const { width, height, fps } = getStreamSize(mainStream); const videoTracks = mainStream.getVideoTracks(); if (videoTracks.length === 0) { @@ -867,7 +873,15 @@ const startRecording = async (request: StartRecordingRequest) => { routeFirstStreamToSpeakers: request.mode === "tab", }); const hasAudio = recordingStream.getAudioTracks().length > 0; - const pipeline = selectRecordingPipeline(hasAudio); + // The extension always streams the recording through + // InstantRecordingUploader, so the container must stay streamable + // regardless of what selectRecordingPipeline's user-agent heuristic + // (written for the web recorder, and false on Firefox) would decide. + const pipeline = selectRecordingPipelineFromSupport( + hasAudio, + (candidate) => MediaRecorder.isTypeSupported(candidate), + { preferStreamingUpload: true }, + ); if (!pipeline) throw new Error("No supported recorder format is available"); const { videoCodec, audioCodec } = describeRecordingCodecs( diff --git a/apps/chrome-extension/src/shared/manifest.test.ts b/apps/chrome-extension/src/shared/manifest.test.ts new file mode 100644 index 00000000000..923ad4088a3 --- /dev/null +++ b/apps/chrome-extension/src/shared/manifest.test.ts @@ -0,0 +1,109 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +// The two manifests are maintained by hand; these assertions keep the parts +// that must not drift (version, entry points, content scripts, resources) in +// lockstep and pin the deliberate per-browser differences. + +type Manifest = { + manifest_version: number; + name: string; + short_name: string; + version: string; + homepage_url: string; + icons: Record; + action: unknown; + background: { + service_worker?: string; + scripts?: string[]; + type?: string; + }; + permissions: string[]; + host_permissions: string[]; + content_scripts: unknown[]; + web_accessible_resources: Array<{ + resources: string[]; + matches: string[]; + use_dynamic_url?: boolean; + }>; + options_page?: string; + options_ui?: { page: string }; + browser_specific_settings?: { + gecko?: { + id?: string; + strict_min_version?: string; + }; + }; +}; + +const loadManifest = (target: "chrome" | "firefox"): Manifest => + JSON.parse( + readFileSync( + resolve(__dirname, `../../manifests/manifest.${target}.json`), + "utf8", + ), + ); + +const chrome = loadManifest("chrome"); +const firefox = loadManifest("firefox"); + +describe("manifest parity", () => { + it("keeps shared identity fields in sync", () => { + expect(firefox.manifest_version).toBe(chrome.manifest_version); + expect(firefox.name).toBe(chrome.name); + expect(firefox.short_name).toBe(chrome.short_name); + expect(firefox.version).toBe(chrome.version); + expect(firefox.homepage_url).toBe(chrome.homepage_url); + expect(firefox.icons).toEqual(chrome.icons); + expect(firefox.action).toEqual(chrome.action); + }); + + it("keeps content scripts and host permissions in sync", () => { + expect(firefox.content_scripts).toEqual(chrome.content_scripts); + expect(firefox.host_permissions).toEqual(chrome.host_permissions); + }); + + it("exposes the same web accessible resources (modulo use_dynamic_url)", () => { + const strip = (entries: Manifest["web_accessible_resources"]) => + entries.map(({ resources, matches }) => ({ resources, matches })); + expect(strip(firefox.web_accessible_resources)).toEqual( + strip(chrome.web_accessible_resources), + ); + // use_dynamic_url is Chromium-only. + expect( + firefox.web_accessible_resources.every( + (entry) => entry.use_dynamic_url === undefined, + ), + ).toBe(true); + }); + + it("points both backgrounds at the same script", () => { + expect(chrome.background.service_worker).toBe("assets/service-worker.js"); + expect(firefox.background.scripts).toEqual(["assets/service-worker.js"]); + expect(firefox.background.service_worker).toBeUndefined(); + }); + + it("only differs in the Chrome-only permissions", () => { + expect(chrome.permissions).toEqual( + expect.arrayContaining(["offscreen", "tabCapture"]), + ); + const chromeOnly = ["offscreen", "tabCapture"]; + expect(firefox.permissions.filter((p) => chromeOnly.includes(p))).toEqual( + [], + ); + expect([...firefox.permissions].sort()).toEqual( + chrome.permissions.filter((p) => !chromeOnly.includes(p)).sort(), + ); + }); + + it("declares Firefox-specific settings and options_ui", () => { + expect(firefox.browser_specific_settings?.gecko?.id).toBeTruthy(); + expect( + firefox.browser_specific_settings?.gecko?.strict_min_version, + ).toBeTruthy(); + expect(firefox.options_ui?.page).toBe(chrome.options_page); + expect(firefox.options_page).toBeUndefined(); + expect(chrome.browser_specific_settings).toBeUndefined(); + }); +}); diff --git a/apps/chrome-extension/src/shared/runtime.ts b/apps/chrome-extension/src/shared/runtime.ts index d36940fe286..6131ee4cb4e 100644 --- a/apps/chrome-extension/src/shared/runtime.ts +++ b/apps/chrome-extension/src/shared/runtime.ts @@ -1,5 +1,10 @@ import type { ServiceWorkerRequest, ServiceWorkerResponse } from "./types"; +export const wait = (durationMs: number) => + new Promise((resolve) => { + globalThis.setTimeout(resolve, durationMs); + }); + export const sendServiceWorkerMessage = (message: ServiceWorkerRequest) => new Promise((resolve, reject) => { chrome.runtime.sendMessage(message, (response) => { diff --git a/apps/chrome-extension/src/shared/storage-keys.ts b/apps/chrome-extension/src/shared/storage-keys.ts index 15dfd812db5..5edff54fcca 100644 --- a/apps/chrome-extension/src/shared/storage-keys.ts +++ b/apps/chrome-extension/src/shared/storage-keys.ts @@ -1,3 +1,5 @@ +import { TARGET } from "../platform/target"; + // Storage keys shared with the content bootstrap script. The bootstrap is // injected into every page and must stay a few KB of dependency-free code, // so it imports the keys it needs from here instead of pulling in the full @@ -5,3 +7,11 @@ // re-exports these constants, so both sides always agree on the key names. export const RECORDING_STATE_KEY = "cap-extension-recording-state"; export const SHARED_UI_STATE_KEY = "cap-extension-shared-ui-state"; + +// Which storage area carries the cross-context shared state. Chrome uses +// storage.session (widened to content scripts via setAccessLevel). Firefox +// exposes neither storage.session nor setAccessLevel to content scripts, so +// the same keys live in storage.local there and the background clears them +// on browser startup to restore session semantics. +export const SHARED_STATE_AREA: "session" | "local" = + TARGET === "firefox" ? "local" : "session"; diff --git a/apps/chrome-extension/src/shared/storage.ts b/apps/chrome-extension/src/shared/storage.ts index f0d369ee3ce..059780858c2 100644 --- a/apps/chrome-extension/src/shared/storage.ts +++ b/apps/chrome-extension/src/shared/storage.ts @@ -1,4 +1,8 @@ -import { RECORDING_STATE_KEY, SHARED_UI_STATE_KEY } from "./storage-keys"; +import { + RECORDING_STATE_KEY, + SHARED_STATE_AREA, + SHARED_UI_STATE_KEY, +} from "./storage-keys"; import type { BootstrapData, CapturePreferences, @@ -14,7 +18,7 @@ import type { WebcamPreviewFrame, } from "./types"; -export { RECORDING_STATE_KEY, SHARED_UI_STATE_KEY }; +export { RECORDING_STATE_KEY, SHARED_STATE_AREA, SHARED_UI_STATE_KEY }; export const SETTINGS_KEY = "cap-extension-settings"; export const AUTH_KEY = "cap-extension-auth"; @@ -133,21 +137,47 @@ const removeLocal = (keys: string[] | string) => chrome.storage.local.remove(keys, resolve); }); +// Any key stored through getSession/setSession/removeSession must also be +// listed in clearSharedSessionState below: on Firefox these keys live in +// storage.local, and that list is what restores their session-only lifetime +// across browser restarts. +const sharedStateStorage = () => + SHARED_STATE_AREA === "session" + ? chrome.storage.session + : chrome.storage.local; + const getSession = (keys: string[]) => new Promise>((resolve) => { - chrome.storage.session.get(keys, (items) => resolve(items)); + sharedStateStorage().get(keys, (items) => resolve(items)); }); const setSession = (items: Record) => new Promise((resolve) => { - chrome.storage.session.set(items, resolve); + sharedStateStorage().set(items, resolve); }); const removeSession = (keys: string[] | string) => new Promise((resolve) => { - chrome.storage.session.remove(keys, resolve); + sharedStateStorage().remove(keys, resolve); }); +// On Firefox the shared "session" keys live in storage.local (see +// SHARED_STATE_AREA); the background calls this at browser startup so stale +// recording/UI state does not survive a restart the way it never would have +// in real session storage. +export const clearSharedSessionState = () => + SHARED_STATE_AREA === "session" + ? Promise.resolve() + : removeLocal([ + RECORDING_STATE_KEY, + SHARED_UI_STATE_KEY, + AUTH_ERROR_KEY, + OVERLAY_TOKENS_KEY, + UPLOAD_PROGRESS_TAB_KEY, + WEBCAM_PREVIEW_DISMISSED_KEY, + LAST_WEBCAM_PREVIEW_FRAME_KEY, + ]); + export const loadSettings = async () => { const result = await getLocal([SETTINGS_KEY]); const saved = result[SETTINGS_KEY]; diff --git a/apps/chrome-extension/src/shared/types.ts b/apps/chrome-extension/src/shared/types.ts index 68331e9bec2..9be2a2b33a9 100644 --- a/apps/chrome-extension/src/shared/types.ts +++ b/apps/chrome-extension/src/shared/types.ts @@ -373,6 +373,10 @@ export type ServiceWorkerRequest = target: "service-worker"; type: "open-recorder-panel"; } + | { + target: "service-worker"; + type: "inject-overlay-module"; + } | { target: "service-worker"; type: "open-options"; diff --git a/apps/chrome-extension/src/uploading/main.ts b/apps/chrome-extension/src/uploading/main.ts index 2e2fe9704ec..e56aa723e6b 100644 --- a/apps/chrome-extension/src/uploading/main.ts +++ b/apps/chrome-extension/src/uploading/main.ts @@ -6,6 +6,7 @@ import { loadFailedRecordings, loadSharedRecordingState, RECORDING_STATE_KEY, + SHARED_STATE_AREA, } from "../shared/storage"; import type { RecordingStatus } from "../shared/types"; import "./styles.css"; @@ -452,7 +453,7 @@ chrome.runtime.onMessage.addListener((message) => { // The service worker mirrors every status change into session storage, so // the change events keep this page live without polling round trips. chrome.storage.onChanged.addListener((changes, areaName) => { - if (areaName !== "session" || !changes[RECORDING_STATE_KEY]) return; + if (areaName !== SHARED_STATE_AREA || !changes[RECORDING_STATE_KEY]) return; if (redirecting) return; void loadSharedRecordingState() .then((state) => { diff --git a/apps/chrome-extension/src/welcome/main.ts b/apps/chrome-extension/src/welcome/main.ts index 45f8d8fd02b..c66391ca891 100644 --- a/apps/chrome-extension/src/welcome/main.ts +++ b/apps/chrome-extension/src/welcome/main.ts @@ -1,3 +1,4 @@ +import { capabilities } from "../platform/capabilities"; import { mountPageNav } from "../shared/page-nav"; import { sendServiceWorkerMessage } from "../shared/runtime"; import { loadAuth } from "../shared/storage"; @@ -44,3 +45,34 @@ signInButton.addEventListener("click", () => { authPollId = window.setInterval(() => void checkAuth(), 1000); void checkAuth(); + +// Firefox treats MV3 host permissions as opt-in, so the declared content +// script (overlay, countdown, recording bar) stays inert until the user +// grants access here. The click on the button supplies the required user +// gesture for permissions.request. +const HOST_PERMISSION_ORIGINS = ["http://*/*", "https://*/*"]; + +if (!capabilities.hostPermissionsGrantedAtInstall) { + const hostPermissionRow = byId("host-permission-row"); + const hostPermissionNote = byId("host-permission-note"); + const grantHostsButton = byId("grant-hosts"); + const hostsGrantedPill = byId("hosts-granted"); + + const reflectHostPermission = (granted: boolean) => { + grantHostsButton.hidden = granted; + hostsGrantedPill.hidden = !granted; + hostPermissionNote.hidden = granted; + }; + + hostPermissionRow.hidden = false; + hostPermissionNote.hidden = false; + chrome.permissions.contains({ origins: HOST_PERMISSION_ORIGINS }, (granted) => + reflectHostPermission(Boolean(granted)), + ); + grantHostsButton.addEventListener("click", () => { + chrome.permissions.request( + { origins: HOST_PERMISSION_ORIGINS }, + (granted) => reflectHostPermission(Boolean(granted)), + ); + }); +} diff --git a/apps/chrome-extension/vite.background.config.ts b/apps/chrome-extension/vite.background.config.ts new file mode 100644 index 00000000000..6edcc973069 --- /dev/null +++ b/apps/chrome-extension/vite.background.config.ts @@ -0,0 +1,24 @@ +import { resolve } from "node:path"; +import { defineConfig } from "vite"; +import { outDirFor, resolveTarget, targetDefine } from "./vite.shared"; + +const target = resolveTarget(); + +// The background ships as a single self-contained classic script. Firefox +// event pages terminate and restart constantly, and module backgrounds fail +// to come back up (the page starts once at install, then never wakes); a +// classic IIFE with no imports restarts reliably on both browsers. +export default defineConfig({ + define: targetDefine(target), + build: { + emptyOutDir: false, + outDir: outDirFor(target), + rollupOptions: { + input: resolve(__dirname, "src/background/service-worker.ts"), + output: { + format: "iife", + entryFileNames: "assets/service-worker.js", + }, + }, + }, +}); diff --git a/apps/chrome-extension/vite.config.ts b/apps/chrome-extension/vite.config.ts index bbf6f130b36..c4b2a5c6a15 100644 --- a/apps/chrome-extension/vite.config.ts +++ b/apps/chrome-extension/vite.config.ts @@ -1,12 +1,48 @@ +import { copyFileSync, existsSync } from "node:fs"; import { resolve } from "node:path"; import react from "@vitejs/plugin-react"; -import { defineConfig } from "vite"; +import { defineConfig, type Plugin } from "vite"; +import { outDirFor, resolveTarget, targetDefine } from "./vite.shared"; -export default defineConfig({ - plugins: [react()], +const target = resolveTarget(); + +// The manifests live outside public/ because public/ is copied verbatim into +// every outDir, which would ship the Chrome manifest into the Firefox build. +// This config is only one piece of the bundle (the background script and +// content scripts come from the vite.*.config.ts builds the package.json +// scripts chain BEFORE this one), so before stamping the manifest — the last +// step of a build — verify the background artifact it references exists. +const copyManifest = (watchMode: boolean): Plugin => ({ + name: "cap-copy-manifest", + closeBundle() { + const backgroundScript = resolve( + __dirname, + outDirFor(target), + "assets/service-worker.js", + ); + if (!watchMode && !existsSync(backgroundScript)) { + throw new Error( + "assets/service-worker.js is missing — the extension must be built " + + "with the package.json build scripts (build:chrome / build:firefox), " + + "not a bare `vite build`.", + ); + } + copyFileSync( + resolve(__dirname, `manifests/manifest.${target}.json`), + resolve(__dirname, outDirFor(target), "manifest.json"), + ); + }, +}); + +export default defineConfig(({ command, mode }) => ({ + plugins: [ + react(), + copyManifest(command === "serve" || mode === "development"), + ], + define: targetDefine(target), build: { emptyOutDir: false, - outDir: "dist", + outDir: outDirFor(target), rollupOptions: { input: { popup: resolve(__dirname, "popup.html"), @@ -15,13 +51,9 @@ export default defineConfig({ welcome: resolve(__dirname, "welcome.html"), "how-it-works": resolve(__dirname, "how-it-works.html"), uploading: resolve(__dirname, "uploading.html"), - offscreen: resolve(__dirname, "offscreen.html"), + recorder: resolve(__dirname, "recorder.html"), "camera-preview": resolve(__dirname, "camera-preview.html"), "camera-permission": resolve(__dirname, "camera-permission.html"), - "service-worker": resolve( - __dirname, - "src/background/service-worker.ts", - ), }, output: { entryFileNames: "assets/[name].js", @@ -30,4 +62,4 @@ export default defineConfig({ }, }, }, -}); +})); diff --git a/apps/chrome-extension/vite.content-overlay.config.ts b/apps/chrome-extension/vite.content-overlay.config.ts index 91b72a1ad93..1d09fa02632 100644 --- a/apps/chrome-extension/vite.content-overlay.config.ts +++ b/apps/chrome-extension/vite.content-overlay.config.ts @@ -1,25 +1,41 @@ import { resolve } from "node:path"; import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; +import { outDirFor, resolveTarget, targetDefine } from "./vite.shared"; -// The full overlay/recording-bar UI, lazily import()ed by the bootstrap -// content script. It must be an ES module (the bootstrap dynamic-imports -// it), self-contained, and emitted at a stable hash-free path so the -// manifest's web_accessible_resources entry can list it. +const target = resolveTarget(); + +// The full overlay/recording-bar UI, lazily loaded by the bootstrap content +// script, self-contained and emitted at a stable hash-free path. +// +// Chrome: an ES module the bootstrap dynamic-imports from +// web_accessible_resources. Firefox: content scripts cannot dynamic-import +// extension modules (bugzilla 1536094), so the service worker executeScripts +// it into the bootstrap's isolated world instead — a classic IIFE that +// exposes its exports as the CapOverlay global. export default defineConfig({ plugins: [react()], + define: targetDefine(target), build: { emptyOutDir: false, - outDir: "dist", + outDir: outDirFor(target), rollupOptions: { input: resolve(__dirname, "src/content/overlay.tsx"), - // Keep the init() export the bootstrap calls after import(). + // Keep the init() export the bootstrap calls after loading. preserveEntrySignatures: "exports-only", - output: { - format: "es", - entryFileNames: "content/overlay.js", - inlineDynamicImports: true, - }, + output: + target === "firefox" + ? { + format: "iife", + name: "CapOverlay", + entryFileNames: "content/overlay.js", + inlineDynamicImports: true, + } + : { + format: "es", + entryFileNames: "content/overlay.js", + inlineDynamicImports: true, + }, }, }, }); diff --git a/apps/chrome-extension/vite.content.config.ts b/apps/chrome-extension/vite.content.config.ts index 012b5b95bcb..d82b00d3482 100644 --- a/apps/chrome-extension/vite.content.config.ts +++ b/apps/chrome-extension/vite.content.config.ts @@ -1,5 +1,8 @@ import { resolve } from "node:path"; import { defineConfig } from "vite"; +import { outDirFor, resolveTarget, targetDefine } from "./vite.shared"; + +const target = resolveTarget(); // Chrome runs manifest content scripts as classic scripts, so this entry // must be a single self-contained IIFE with no ES module imports. Only the @@ -7,9 +10,10 @@ import { defineConfig } from "vite"; // real overlay UI (built by vite.content-overlay.config.ts) from // web_accessible_resources when a tab actually needs it. export default defineConfig({ + define: targetDefine(target), build: { emptyOutDir: false, - outDir: "dist", + outDir: outDirFor(target), rollupOptions: { input: resolve(__dirname, "src/content/bootstrap.ts"), output: { diff --git a/apps/chrome-extension/vite.shared.ts b/apps/chrome-extension/vite.shared.ts new file mode 100644 index 00000000000..9f3aa88928b --- /dev/null +++ b/apps/chrome-extension/vite.shared.ts @@ -0,0 +1,20 @@ +// Shared between vite.config.ts, vite.content.config.ts and +// vite.content-overlay.config.ts so all three chunks of one target build +// agree on the output directory and the injected __TARGET__ constant. +export type BuildTarget = "chrome" | "firefox"; + +export const resolveTarget = (): BuildTarget => { + const target = process.env.TARGET ?? "chrome"; + if (target !== "chrome" && target !== "firefox") { + throw new Error( + `Unknown TARGET "${target}" (expected "chrome" or "firefox")`, + ); + } + return target; +}; + +export const outDirFor = (target: BuildTarget) => `dist/${target}`; + +export const targetDefine = (target: BuildTarget) => ({ + __TARGET__: JSON.stringify(target), +}); diff --git a/apps/chrome-extension/welcome.html b/apps/chrome-extension/welcome.html index edae273207a..e728f448935 100644 --- a/apps/chrome-extension/welcome.html +++ b/apps/chrome-extension/welcome.html @@ -168,6 +168,25 @@

Pin Cap to your toolbar

Click the Cap icon whenever you're ready to record.

+ +