diff --git a/apps/chrome-extension/offscreen.html b/apps/chrome-extension/recorder.html similarity index 62% rename from apps/chrome-extension/offscreen.html rename to apps/chrome-extension/recorder.html index 07811d502c2..115b7fa7abd 100644 --- a/apps/chrome-extension/offscreen.html +++ b/apps/chrome-extension/recorder.html @@ -3,9 +3,9 @@ - Cap Recorder Offscreen + Cap Recorder - + 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..b23ff029d28 --- /dev/null +++ b/apps/chrome-extension/src/background/recorder-host.ts @@ -0,0 +1,59 @@ +// 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; this module owns its lifecycle so the rest of the +// service worker never touches chrome.offscreen directly. +export const RECORDER_URL = "recorder.html"; + +let recorderDocumentCreation: Promise | null = null; + +const getRecorderContexts = async () => { + const recorderUrl = chrome.runtime.getURL(RECORDER_URL); + return new Promise>((resolve) => { + chrome.runtime.getContexts( + { + contextTypes: [chrome.runtime.ContextType.OFFSCREEN_DOCUMENT], + documentUrls: [recorderUrl], + }, + (contexts) => resolve(contexts), + ); + }); +}; + +export const hasRecorderHost = async () => + (await getRecorderContexts()).length > 0; + +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)); + }, + ); + }); + +export const ensureRecorderHost = async () => { + const contexts = await getRecorderContexts(); + if (contexts.length > 0) return; + + recorderDocumentCreation ??= createOffscreenDocument().finally(() => { + recorderDocumentCreation = null; + }); + await recorderDocumentCreation; +}; diff --git a/apps/chrome-extension/src/background/service-worker.ts b/apps/chrome-extension/src/background/service-worker.ts index 43eccb06c54..b601f050e06 100644 --- a/apps/chrome-extension/src/background/service-worker.ts +++ b/apps/chrome-extension/src/background/service-worker.ts @@ -1,3 +1,4 @@ +import { EXTENSION_PROTOCOL } from "../platform/extension-protocol"; import { ApiRequestError, createAuthStart, @@ -56,6 +57,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 +65,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 +84,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; @@ -197,58 +197,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); @@ -279,12 +227,12 @@ const sendOffscreen = async ( options: { createIfMissing?: boolean } = {}, ) => { 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(); } let lastError: unknown; @@ -301,7 +249,7 @@ const sendOffscreen = async ( break; } await wait(OFFSCREEN_MESSAGE_RETRY_DELAY_MS); - await ensureOffscreenDocument(); + await ensureRecorderHost(); } } @@ -441,7 +389,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 +404,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 +429,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 { @@ -1311,7 +1259,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" }); 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/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..7179f9d2016 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"; @@ -599,6 +599,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) => { @@ -867,7 +870,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/vite.config.ts b/apps/chrome-extension/vite.config.ts index bbf6f130b36..bffb41fc238 100644 --- a/apps/chrome-extension/vite.config.ts +++ b/apps/chrome-extension/vite.config.ts @@ -15,7 +15,7 @@ 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(