Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Cap Recorder Offscreen</title>
<title>Cap Recorder</title>
</head>
<body>
<script type="module" src="/src/offscreen/recorder.ts"></script>
<script type="module" src="/src/recorder/recorder.ts"></script>
</body>
</html>
59 changes: 59 additions & 0 deletions apps/chrome-extension/src/background/recorder-host.ts
Original file line number Diff line number Diff line change
@@ -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<void> | null = null;

const getRecorderContexts = async () => {
const recorderUrl = chrome.runtime.getURL(RECORDER_URL);
return new Promise<Array<{ documentUrl?: string }>>((resolve) => {
chrome.runtime.getContexts(
{
contextTypes: [chrome.runtime.ContextType.OFFSCREEN_DOCUMENT],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Firefox Worker Imports Chrome APIs

recorder-host.ts is imported by the service worker at module load time, but this line reads chrome.runtime.ContextType.OFFSCREEN_DOCUMENT before any capability guard can run. In a Firefox build where that Chrome-only API is missing, the background worker can throw during startup instead of reaching the supportsOffscreen checks.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/chrome-extension/src/background/recorder-host.ts
Line: 14

Comment:
**Firefox Worker Imports Chrome APIs**

`recorder-host.ts` is imported by the service worker at module load time, but this line reads `chrome.runtime.ContextType.OFFSCREEN_DOCUMENT` before any capability guard can run. In a Firefox build where that Chrome-only API is missing, the background worker can throw during startup instead of reaching the `supportsOffscreen` checks.

How can I resolve this? If you propose a fix, please make it concise.

documentUrls: [recorderUrl],
},
(contexts) => resolve(contexts),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

chrome.runtime.getContexts can invoke the callback with contexts undefined (or fail in older runtimes), which would make hasRecorderHost() throw on .length. Small hardening tweak:

Suggested change
(contexts) => resolve(contexts),
(contexts) => resolve(contexts ?? []),

);
});
};

export const hasRecorderHost = async () =>
(await getRecorderContexts()).length > 0;

const createOffscreenDocument = () =>
new Promise<void>((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;
};
70 changes: 9 additions & 61 deletions apps/chrome-extension/src/background/service-worker.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { EXTENSION_PROTOCOL } from "../platform/extension-protocol";
import {
ApiRequestError,
createAuthStart,
Expand Down Expand Up @@ -56,14 +57,14 @@ 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
// static chrome-extension:// URL fail with ERR_BLOCKED_BY_CLIENT when opened as
// 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;
Expand All @@ -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<void> | null = null;
let browserWindowFocused = true;
let externalCaptureAutoPipPending = false;

Expand Down Expand Up @@ -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<Array<{ documentUrl?: string }>>((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<void>((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<void>((resolve) => {
globalThis.setTimeout(resolve, durationMs);
Expand Down Expand Up @@ -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;
Expand All @@ -301,7 +249,7 @@ const sendOffscreen = async (
break;
}
await wait(OFFSCREEN_MESSAGE_RETRY_DELAY_MS);
await ensureOffscreenDocument();
await ensureRecorderHost();
}
}

Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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" });
Expand Down
14 changes: 14 additions & 0 deletions apps/chrome-extension/src/platform/capabilities.ts
Original file line number Diff line number Diff line change
@@ -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;
4 changes: 4 additions & 0 deletions apps/chrome-extension/src/platform/extension-protocol.ts
Original file line number Diff line number Diff line change
@@ -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;
8 changes: 8 additions & 0 deletions apps/chrome-extension/src/platform/target.ts
Original file line number Diff line number Diff line change
@@ -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__;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Missing Target Becomes Chrome

TARGET defaults to "chrome" whenever __TARGET__ is absent, but the changed Vite config does not define __TARGET__. A Firefox build that uses this config without an injected define will enable Chrome-only capabilities like offscreen and tab capture, sending the Firefox worker into unsupported APIs.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/chrome-extension/src/platform/target.ts
Line: 8

Comment:
**Missing Target Becomes Chrome**

`TARGET` defaults to `"chrome"` whenever `__TARGET__` is absent, but the changed Vite config does not define `__TARGET__`. A Firefox build that uses this config without an injected define will enable Chrome-only capabilities like offscreen and tab capture, sending the Firefox worker into unsupported APIs.

How can I resolve this? If you propose a fix, please make it concise.

Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
RECORDING_SPOOL_LIVE_MIN_IDLE_MS,
RecordingSpool,
recoverRecordingSpoolSession,
selectRecordingPipeline,
selectRecordingPipelineFromSupport,
shouldRetryDisplayMediaWithoutPreferences,
type VideoId,
} from "@cap/recorder-core";
Expand Down Expand Up @@ -599,6 +599,9 @@
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) => {
Expand Down Expand Up @@ -867,7 +870,15 @@
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(
Expand Down Expand Up @@ -1401,7 +1412,7 @@

const pauseRecording = () => {
const recording = activeRecording;
if (!recording || recording.recorder.state !== "recording") {

Check warning on line 1415 in apps/chrome-extension/src/recorder/recorder.ts

View workflow job for this annotation

GitHub Actions / Lint (Biome)

lint/complexity/useOptionalChain

Change to an optional chain.
return status;
}
const now = Date.now();
Expand All @@ -1421,7 +1432,7 @@

const resumeRecording = () => {
const recording = activeRecording;
if (!recording || recording.recorder.state !== "paused") {

Check warning on line 1435 in apps/chrome-extension/src/recorder/recorder.ts

View workflow job for this annotation

GitHub Actions / Lint (Biome)

lint/complexity/useOptionalChain

Change to an optional chain.
return status;
}
const now = Date.now();
Expand Down
2 changes: 1 addition & 1 deletion apps/chrome-extension/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading