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
2 changes: 1 addition & 1 deletion apps/chrome-extension/e2e/overlay-ui.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
2 changes: 1 addition & 1 deletion apps/chrome-extension/e2e/recording-upload.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
2 changes: 1 addition & 1 deletion apps/chrome-extension/e2e/webcam-start.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
59 changes: 59 additions & 0 deletions apps/chrome-extension/manifests/manifest.firefox.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
{
"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"],
"type": "module"
},
"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:///*"]
}
]
}
16 changes: 11 additions & 5 deletions apps/chrome-extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",

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 Chrome Package Contains Both Targets

When the Chrome publish workflow runs the default build, this script now creates both dist/chrome and dist/firefox. The existing package step zips dist as the extension root, so the upload contains target subdirectories and Firefox artifacts instead of a valid Chrome extension layout.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/chrome-extension/package.json
Line: 7

Comment:
**Chrome Package Contains Both Targets**

When the Chrome publish workflow runs the default `build`, this script now creates both `dist/chrome` and `dist/firefox`. The existing package step zips `dist` as the extension root, so the upload contains target subdirectories and Firefox artifacts instead of a valid Chrome extension layout.

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

"build:chrome": "rm -rf dist/chrome && TARGET=chrome vite build && TARGET=chrome vite build --config vite.content.config.ts && TARGET=chrome vite build --config vite.content-overlay.config.ts",
"build:firefox": "rm -rf dist/firefox && TARGET=firefox vite build && TARGET=firefox vite build --config vite.content.config.ts && TARGET=firefox vite build --config vite.content-overlay.config.ts",
"dev": "rm -rf dist/chrome && (trap 'kill 0' INT TERM; 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.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:*",
Expand All @@ -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"
}
}
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(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This file assumes chrome.runtime.getContexts + chrome.offscreen.createDocument exist. Since this PR adds a Firefox build (and the manifest drops the offscreen permission), it might be worth adding a Firefox-specific implementation here (or at least a feature-detect + clearer error) so sendOffscreen(...) doesn’t end up throwing a TypeError at runtime.

{
contextTypes: [chrome.runtime.ContextType.OFFSCREEN_DOCUMENT],
documentUrls: [recorderUrl],
},
Comment on lines +9 to +16

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 Reaches Offscreen APIs

The shared background script imports this helper for both targets, but hasRecorderHost() and ensureRecorderHost() call Chrome-only offscreen APIs without checking the target. In the Firefox build, normal paths like starting a recording, probing the mic, enumerating devices, or connecting camera preview can reach chrome.runtime.getContexts or chrome.offscreen.createDocument, producing a runtime error instead of opening a Firefox recorder page.

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

Comment:
**Firefox Reaches Offscreen APIs**

The shared background script imports this helper for both targets, but `hasRecorderHost()` and `ensureRecorderHost()` call Chrome-only offscreen APIs without checking the target. In the Firefox build, normal paths like starting a recording, probing the mic, enumerating devices, or connecting camera preview can reach `chrome.runtime.getContexts` or `chrome.offscreen.createDocument`, producing a runtime error instead of opening a Firefox recorder page.

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

(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__;
Loading
Loading