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>
68 changes: 68 additions & 0 deletions apps/chrome-extension/src/background/recorder-host.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// 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.
//
// On Firefox there is no offscreen document API; hasRecorderHost and
// ensureRecorderHost return early so the service worker can import this module
// on both targets without reaching Chrome-only APIs at runtime.
import { capabilities } from "../platform/capabilities";

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: ["OFFSCREEN_DOCUMENT" as chrome.runtime.ContextType],
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 () => {
if (!capabilities.supportsOffscreen) return false;
return (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 () => {
if (!capabilities.supportsOffscreen) return;
const contexts = await getRecorderContexts();
if (contexts.length > 0) return;

recorderDocumentCreation ??= createOffscreenDocument().finally(() => {
recorderDocumentCreation = null;
});
await recorderDocumentCreation;
};
76 changes: 15 additions & 61 deletions apps/chrome-extension/src/background/service-worker.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { capabilities } from "../platform/capabilities";
import { EXTENSION_PROTOCOL } from "../platform/extension-protocol";
import {
ApiRequestError,
createAuthStart,
Expand Down Expand Up @@ -56,14 +58,14 @@
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 @@ -81,7 +83,7 @@
let uploadProgressTabId: number | null = null;
let activePreviewTabId: number | null = null;
let pendingPreviewTabId: number | null = null;
let offscreenDocumentCreation: Promise<void> | null = null;
let readyPreviewTabId: number | null = null;

Check warning on line 86 in apps/chrome-extension/src/background/service-worker.ts

View workflow job for this annotation

GitHub Actions / Lint (Biome)

lint/style/useConst

This let declares a variable that is only assigned once.

Check warning on line 86 in apps/chrome-extension/src/background/service-worker.ts

View workflow job for this annotation

GitHub Actions / Lint (Biome)

lint/correctness/noUnusedVariables

This variable readyPreviewTabId is unused.
let browserWindowFocused = true;
let externalCaptureAutoPipPending = false;
let recordingStartInFlight: Promise<OffscreenResponse> | null = null;
Expand Down Expand Up @@ -188,58 +190,6 @@
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 @@ -270,12 +220,12 @@
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 @@ -292,7 +242,7 @@
break;
}
await wait(OFFSCREEN_MESSAGE_RETRY_DELAY_MS);
await ensureOffscreenDocument();
await ensureRecorderHost();
}
}

Expand All @@ -301,6 +251,10 @@

const getTabStreamId = (tabId: number) =>
new Promise<string>((resolve, reject) => {
if (!capabilities.supportsTabCapture) {
reject(new Error("Tab capture is not supported on this browser"));
return;
}
chrome.tabCapture.getMediaStreamId({ targetTabId: tabId }, (streamId) => {
if (chrome.runtime.lastError) {
reject(
Expand Down Expand Up @@ -372,7 +326,7 @@
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 @@ -387,7 +341,7 @@
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 @@ -412,7 +366,7 @@
) => {
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 @@ -1244,7 +1198,7 @@
sendOffscreen({ target: "offscreen", type } as OffscreenRequest);

const syncRecordingStatus = async () => {
const hasDocument = await hasOffscreenDocument();
const hasDocument = await hasRecorderHost();
if (!hasDocument) {
if (
isActiveRecordingStatus(recordingStatus) ||
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",

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 Tab Capture Unguarded

supportsTabCapture is defined as false for Firefox, but the service worker tab-recording path still calls chrome.tabCapture.getMediaStreamId without checking it. When a Firefox build starts a tab-mode recording, that path can still dereference an API Firefox does not provide, so the recording flow fails before capture can start. This needs a separate guard or tab-mode disablement at the call path that uses the capability.

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

Comment:
**Tab Capture Unguarded**

`supportsTabCapture` is defined as false for Firefox, but the service worker tab-recording path still calls `chrome.tabCapture.getMediaStreamId` without checking it. When a Firefox build starts a tab-mode recording, that path can still dereference an API Firefox does not provide, so the recording flow fails before capture can start. This needs a separate guard or tab-mode disablement at the call path that uses the capability.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

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;
10 changes: 10 additions & 0 deletions apps/chrome-extension/src/platform/target.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Injected by vite `define` per build target (e.g. `__TARGET__: "firefox"`).
// Under vitest there is no define; the fallback to "chrome" is intentional —
// tests run against Chrome APIs. Every production Vite build MUST inject this
// define so that Firefox builds never accidentally enable Chrome-only paths.
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 @@ -16,7 +16,7 @@
RECORDING_SPOOL_LIVE_MIN_IDLE_MS,
RecordingSpool,
recoverRecordingSpoolSession,
selectRecordingPipeline,
selectRecordingPipelineFromSupport,
type VideoId,
} from "@cap/recorder-core";

Expand Down Expand Up @@ -548,6 +548,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 @@ -818,7 +821,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 @@ -1359,7 +1370,7 @@

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

Check warning on line 1373 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 @@ -1379,7 +1390,7 @@

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

Check warning on line 1393 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