Skip to content

Commit ec176e4

Browse files
refactor(extension): extract target-neutral recorder host and platform layer
1 parent 48a5d01 commit ec176e4

10 files changed

Lines changed: 111 additions & 66 deletions

File tree

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
<head>
44
<meta charset="UTF-8" />
55
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6-
<title>Cap Recorder Offscreen</title>
6+
<title>Cap Recorder</title>
77
</head>
88
<body>
9-
<script type="module" src="/src/offscreen/recorder.ts"></script>
9+
<script type="module" src="/src/recorder/recorder.ts"></script>
1010
</body>
1111
</html>
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// The recorder document (recorder.html) hosts capture, upload, device
2+
// enumeration, the mic probe and the camera-preview relay. On Chrome it runs
3+
// as an offscreen document; this module owns its lifecycle so the rest of the
4+
// service worker never touches chrome.offscreen directly.
5+
export const RECORDER_URL = "recorder.html";
6+
7+
let recorderDocumentCreation: Promise<void> | null = null;
8+
9+
const getRecorderContexts = async () => {
10+
const recorderUrl = chrome.runtime.getURL(RECORDER_URL);
11+
return new Promise<Array<{ documentUrl?: string }>>((resolve) => {
12+
chrome.runtime.getContexts(
13+
{
14+
contextTypes: [chrome.runtime.ContextType.OFFSCREEN_DOCUMENT],
15+
documentUrls: [recorderUrl],
16+
},
17+
(contexts) => resolve(contexts),
18+
);
19+
});
20+
};
21+
22+
export const hasRecorderHost = async () =>
23+
(await getRecorderContexts()).length > 0;
24+
25+
const createOffscreenDocument = () =>
26+
new Promise<void>((resolve, reject) => {
27+
chrome.offscreen.createDocument(
28+
{
29+
url: RECORDER_URL,
30+
reasons: ["USER_MEDIA", "DISPLAY_MEDIA", "BLOBS", "AUDIO_PLAYBACK"],
31+
justification: "Record and upload Cap videos from an extension page.",
32+
},
33+
() => {
34+
const error = chrome.runtime.lastError;
35+
if (!error) {
36+
resolve();
37+
return;
38+
}
39+
40+
const message = error.message ?? "Failed to create offscreen document";
41+
if (message.toLowerCase().includes("single offscreen document")) {
42+
resolve();
43+
return;
44+
}
45+
46+
reject(new Error(message));
47+
},
48+
);
49+
});
50+
51+
export const ensureRecorderHost = async () => {
52+
const contexts = await getRecorderContexts();
53+
if (contexts.length > 0) return;
54+
55+
recorderDocumentCreation ??= createOffscreenDocument().finally(() => {
56+
recorderDocumentCreation = null;
57+
});
58+
await recorderDocumentCreation;
59+
};

apps/chrome-extension/src/background/service-worker.ts

Lines changed: 10 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { EXTENSION_PROTOCOL } from "../platform/extension-protocol";
12
import {
23
ApiRequestError,
34
createAuthStart,
@@ -56,14 +57,14 @@ import type {
5657
ServiceWorkerRequest,
5758
ServiceWorkerResponse,
5859
} from "../shared/types";
60+
import { ensureRecorderHost, hasRecorderHost } from "./recorder-host";
5961

6062
// popup.html is web-accessible with use_dynamic_url so sites cannot fingerprint
6163
// the extension via the overlay iframe's static URL; that same flag makes its
6264
// static chrome-extension:// URL fail with ERR_BLOCKED_BY_CLIENT when opened as
6365
// a window. The standalone fallback therefore loads a privileged twin that is
6466
// not in web_accessible_resources.
6567
const POPUP_URL = "popup-window.html";
66-
const OFFSCREEN_URL = "offscreen.html";
6768
const AUTH_TIMEOUT_MS = 10 * 60 * 1000;
6869
const OFFSCREEN_MESSAGE_ATTEMPTS = 3;
6970
const OFFSCREEN_MESSAGE_RETRY_DELAY_MS = 75;
@@ -81,7 +82,7 @@ let mediaPermissionsCache: MediaPermissionSnapshot = {
8182
let uploadProgressTabId: number | null = null;
8283
let activePreviewTabId: number | null = null;
8384
let pendingPreviewTabId: number | null = null;
84-
let offscreenDocumentCreation: Promise<void> | null = null;
85+
let readyPreviewTabId: number | null = null;
8586
let browserWindowFocused = true;
8687
let externalCaptureAutoPipPending = false;
8788
let recordingStartInFlight: Promise<OffscreenResponse> | null = null;
@@ -188,58 +189,6 @@ const focusTab = async (tabId: number) => {
188189
await activateTab(tabId);
189190
};
190191

191-
const getOffscreenDocumentContexts = async () => {
192-
const offscreenUrl = chrome.runtime.getURL(OFFSCREEN_URL);
193-
return new Promise<Array<{ documentUrl?: string }>>((resolve) => {
194-
chrome.runtime.getContexts(
195-
{
196-
contextTypes: [chrome.runtime.ContextType.OFFSCREEN_DOCUMENT],
197-
documentUrls: [offscreenUrl],
198-
},
199-
(contexts) => resolve(contexts),
200-
);
201-
});
202-
};
203-
204-
const hasOffscreenDocument = async () =>
205-
(await getOffscreenDocumentContexts()).length > 0;
206-
207-
const createOffscreenDocument = () =>
208-
new Promise<void>((resolve, reject) => {
209-
chrome.offscreen.createDocument(
210-
{
211-
url: OFFSCREEN_URL,
212-
reasons: ["USER_MEDIA", "DISPLAY_MEDIA", "BLOBS", "AUDIO_PLAYBACK"],
213-
justification: "Record and upload Cap videos from an extension page.",
214-
},
215-
() => {
216-
const error = chrome.runtime.lastError;
217-
if (!error) {
218-
resolve();
219-
return;
220-
}
221-
222-
const message = error.message ?? "Failed to create offscreen document";
223-
if (message.toLowerCase().includes("single offscreen document")) {
224-
resolve();
225-
return;
226-
}
227-
228-
reject(new Error(message));
229-
},
230-
);
231-
});
232-
233-
const ensureOffscreenDocument = async () => {
234-
const contexts = await getOffscreenDocumentContexts();
235-
if (contexts.length > 0) return;
236-
237-
offscreenDocumentCreation ??= createOffscreenDocument().finally(() => {
238-
offscreenDocumentCreation = null;
239-
});
240-
await offscreenDocumentCreation;
241-
};
242-
243192
const wait = (durationMs: number) =>
244193
new Promise<void>((resolve) => {
245194
globalThis.setTimeout(resolve, durationMs);
@@ -270,12 +219,12 @@ const sendOffscreen = async (
270219
options: { createIfMissing?: boolean } = {},
271220
) => {
272221
if (options.createIfMissing === false) {
273-
const hasDocument = await hasOffscreenDocument();
222+
const hasDocument = await hasRecorderHost();
274223
if (!hasDocument) {
275224
return { ok: true, status: recordingStatus } satisfies OffscreenResponse;
276225
}
277226
} else {
278-
await ensureOffscreenDocument();
227+
await ensureRecorderHost();
279228
}
280229

281230
let lastError: unknown;
@@ -292,7 +241,7 @@ const sendOffscreen = async (
292241
break;
293242
}
294243
await wait(OFFSCREEN_MESSAGE_RETRY_DELAY_MS);
295-
await ensureOffscreenDocument();
244+
await ensureRecorderHost();
296245
}
297246
}
298247

@@ -372,7 +321,7 @@ const canInjectIntoTab = (tab: chrome.tabs.Tab) => {
372321
const isWebPageSender = (sender: chrome.runtime.MessageSender) => {
373322
if (!sender.tab) return false;
374323
const senderUrl = sender.url ?? "";
375-
return !senderUrl.startsWith("chrome-extension:");
324+
return !senderUrl.startsWith(EXTENSION_PROTOCOL);
376325
};
377326

378327
// camera-preview.html is web accessible, so any site can load it in an
@@ -387,7 +336,7 @@ const isCameraPreviewRequestAllowed = async (
387336
if (!(await isOverlayTokenRegistered(token))) return false;
388337

389338
const senderUrl = sender.url ?? "";
390-
if (senderUrl.startsWith("chrome-extension:")) {
339+
if (senderUrl.startsWith(EXTENSION_PROTOCOL)) {
391340
// The camera preview document is the only extension page that drives
392341
// the camera.
393342
try {
@@ -412,7 +361,7 @@ const isCameraPreviewEventAllowed = async (
412361
) => {
413362
if (!token || !(await isOverlayTokenRegistered(token))) return false;
414363
const senderUrl = sender.url ?? "";
415-
if (!senderUrl.startsWith("chrome-extension:")) return false;
364+
if (!senderUrl.startsWith(EXTENSION_PROTOCOL)) return false;
416365
try {
417366
return new URL(senderUrl).pathname === "/camera-preview.html";
418367
} catch {
@@ -1244,7 +1193,7 @@ const forwardToOffscreen = (type: OffscreenRequest["type"]) =>
12441193
sendOffscreen({ target: "offscreen", type } as OffscreenRequest);
12451194

12461195
const syncRecordingStatus = async () => {
1247-
const hasDocument = await hasOffscreenDocument();
1196+
const hasDocument = await hasRecorderHost();
12481197
if (!hasDocument) {
12491198
if (
12501199
isActiveRecordingStatus(recordingStatus) ||
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { TARGET } from "./target";
2+
3+
// Per-target feature availability. Firefox has no chrome.offscreen or
4+
// chrome.tabCapture, its getDisplayMedia exposes no system audio or per-tab
5+
// surface, MV3 host permissions are user-grantable rather than granted at
6+
// install, and getDisplayMedia requires transient user activation so capture
7+
// cannot start without a click inside the recorder document.
8+
export const capabilities = {
9+
supportsTabCapture: TARGET === "chrome",
10+
supportsOffscreen: TARGET === "chrome",
11+
supportsSystemAudioCapture: TARGET === "chrome",
12+
hostPermissionsGrantedAtInstall: TARGET === "chrome",
13+
recorderNeedsUserGesture: TARGET === "firefox",
14+
} as const;
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// "chrome-extension:" on Chromium, "moz-extension:" on Firefox. Sender checks
2+
// must use this instead of a hardcoded literal or Firefox extension pages get
3+
// misclassified as web pages.
4+
export const EXTENSION_PROTOCOL = new URL(chrome.runtime.getURL("")).protocol;
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// Injected by vite `define` per build target; undefined under vitest, which
2+
// runs without a define and must behave like the Chrome build.
3+
declare const __TARGET__: "chrome" | "firefox" | undefined;
4+
5+
export type ExtensionTarget = "chrome" | "firefox";
6+
7+
export const TARGET: ExtensionTarget =
8+
typeof __TARGET__ === "undefined" ? "chrome" : __TARGET__;

apps/chrome-extension/src/offscreen/display-capture.test.ts renamed to apps/chrome-extension/src/recorder/display-capture.test.ts

File renamed without changes.

apps/chrome-extension/src/offscreen/display-capture.ts renamed to apps/chrome-extension/src/recorder/display-capture.ts

File renamed without changes.

apps/chrome-extension/src/offscreen/recorder.ts renamed to apps/chrome-extension/src/recorder/recorder.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
RECORDING_SPOOL_LIVE_MIN_IDLE_MS,
1717
RecordingSpool,
1818
recoverRecordingSpoolSession,
19-
selectRecordingPipeline,
19+
selectRecordingPipelineFromSupport,
2020
type VideoId,
2121
} from "@cap/recorder-core";
2222

@@ -548,6 +548,9 @@ const addAudioTracks = ({
548548
if (streamsWithAudio.length === 0) return undefined;
549549

550550
const audioContext = new AudioContext();
551+
// Autoplay policy can hand back a suspended context in a document that has
552+
// never seen user activation, which would silently mute the mixed tracks.
553+
void audioContext.resume().catch(() => undefined);
551554
const destination = audioContext.createMediaStreamDestination();
552555

553556
streamsWithAudio.forEach((stream, index) => {
@@ -818,7 +821,15 @@ const startRecording = async (request: StartRecordingRequest) => {
818821
routeFirstStreamToSpeakers: request.mode === "tab",
819822
});
820823
const hasAudio = recordingStream.getAudioTracks().length > 0;
821-
const pipeline = selectRecordingPipeline(hasAudio);
824+
// The extension always streams the recording through
825+
// InstantRecordingUploader, so the container must stay streamable
826+
// regardless of what selectRecordingPipeline's user-agent heuristic
827+
// (written for the web recorder, and false on Firefox) would decide.
828+
const pipeline = selectRecordingPipelineFromSupport(
829+
hasAudio,
830+
(candidate) => MediaRecorder.isTypeSupported(candidate),
831+
{ preferStreamingUpload: true },
832+
);
822833
if (!pipeline) throw new Error("No supported recorder format is available");
823834

824835
const { videoCodec, audioCodec } = describeRecordingCodecs(

apps/chrome-extension/vite.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export default defineConfig({
1515
welcome: resolve(__dirname, "welcome.html"),
1616
"how-it-works": resolve(__dirname, "how-it-works.html"),
1717
uploading: resolve(__dirname, "uploading.html"),
18-
offscreen: resolve(__dirname, "offscreen.html"),
18+
recorder: resolve(__dirname, "recorder.html"),
1919
"camera-preview": resolve(__dirname, "camera-preview.html"),
2020
"camera-permission": resolve(__dirname, "camera-permission.html"),
2121
"service-worker": resolve(

0 commit comments

Comments
 (0)