Skip to content

Commit 718b032

Browse files
build(extension): add TARGET-driven dual builds with Firefox manifest
1 parent ec176e4 commit 718b032

13 files changed

Lines changed: 1245 additions & 50 deletions

apps/chrome-extension/e2e/overlay-ui.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ type ChromeGlobal = typeof globalThis & {
5757
};
5858

5959
const __dirname = path.dirname(fileURLToPath(import.meta.url));
60-
const extensionPath = path.resolve(__dirname, "../dist");
60+
const extensionPath = path.resolve(__dirname, "../dist/chrome");
6161
const SETTINGS_KEY = "cap-extension-settings";
6262
const AUTH_KEY = "cap-extension-auth";
6363
const BOOTSTRAP_CACHE_KEY = "cap-extension-bootstrap-cache";

apps/chrome-extension/e2e/recording-upload.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ type ChromeGlobal = typeof globalThis & {
5555
};
5656

5757
const __dirname = path.dirname(fileURLToPath(import.meta.url));
58-
const extensionPath = path.resolve(__dirname, "../dist");
58+
const extensionPath = path.resolve(__dirname, "../dist/chrome");
5959
const SETTINGS_KEY = "cap-extension-settings";
6060
const AUTH_KEY = "cap-extension-auth";
6161
const BOOTSTRAP_CACHE_KEY = "cap-extension-bootstrap-cache";

apps/chrome-extension/e2e/webcam-start.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ type ChromeGlobal = typeof globalThis & {
3434
};
3535

3636
const __dirname = path.dirname(fileURLToPath(import.meta.url));
37-
const extensionPath = path.resolve(__dirname, "../dist");
37+
const extensionPath = path.resolve(__dirname, "../dist/chrome");
3838
const SETTINGS_KEY = "cap-extension-settings";
3939
const AUTH_KEY = "cap-extension-auth";
4040
const BOOTSTRAP_CACHE_KEY = "cap-extension-bootstrap-cache";
File renamed without changes.
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
{
2+
"manifest_version": 3,
3+
"name": "Cap - Screen Recorder & Screen Capture",
4+
"short_name": "Cap",
5+
"description": "Free, open source screen recorder. Capture your screen, camera & mic in Firefox and share a video link the moment you stop.",
6+
"version": "1.0.3",
7+
"homepage_url": "https://cap.so",
8+
"browser_specific_settings": {
9+
"gecko": {
10+
"id": "extension@cap.so",
11+
"strict_min_version": "128.0",
12+
"data_collection_permissions": {
13+
"required": ["authenticationInfo", "websiteContent"]
14+
}
15+
}
16+
},
17+
"icons": {
18+
"16": "icons/icon-16.png",
19+
"32": "icons/icon-32.png",
20+
"48": "icons/icon-48.png",
21+
"128": "icons/icon-128.png"
22+
},
23+
"action": {
24+
"default_title": "Record your screen with Cap",
25+
"default_icon": {
26+
"16": "icons/icon-16.png",
27+
"32": "icons/icon-32.png",
28+
"48": "icons/icon-48.png",
29+
"128": "icons/icon-128.png"
30+
}
31+
},
32+
"background": {
33+
"scripts": ["assets/service-worker.js"],
34+
"type": "module"
35+
},
36+
"options_ui": {
37+
"page": "options.html",
38+
"open_in_tab": true
39+
},
40+
"permissions": ["activeTab", "identity", "scripting", "storage"],
41+
"host_permissions": ["http://*/*", "https://*/*", "file:///*"],
42+
"content_scripts": [
43+
{
44+
"matches": ["http://*/*", "https://*/*", "file:///*"],
45+
"js": ["assets/content-bootstrap.js"],
46+
"run_at": "document_idle"
47+
}
48+
],
49+
"web_accessible_resources": [
50+
{
51+
"resources": ["icons/*", "content/overlay.js", "welcome.html"],
52+
"matches": ["http://*/*", "https://*/*", "file:///*"]
53+
},
54+
{
55+
"resources": ["camera-preview.html", "popup.html"],
56+
"matches": ["http://*/*", "https://*/*", "file:///*"]
57+
}
58+
]
59+
}

apps/chrome-extension/package.json

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,18 @@
44
"private": true,
55
"type": "module",
66
"scripts": {
7-
"build": "rm -rf dist && vite build && vite build --config vite.content.config.ts && vite build --config vite.content-overlay.config.ts",
8-
"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)",
7+
"build": "pnpm build:chrome && pnpm build:firefox",
8+
"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",
9+
"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",
10+
"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)",
11+
"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)",
12+
"run:firefox": "web-ext run --source-dir dist/firefox",
13+
"lint:firefox": "web-ext lint --source-dir dist/firefox",
914
"typecheck": "tsc --noEmit",
1015
"test": "vitest run src",
1116
"test:e2e:install": "playwright install chromium",
12-
"test:e2e": "pnpm build && playwright test",
13-
"test:e2e:headed": "pnpm build && playwright test --headed"
17+
"test:e2e": "pnpm build:chrome && playwright test",
18+
"test:e2e:headed": "pnpm build:chrome && playwright test --headed"
1419
},
1520
"dependencies": {
1621
"@cap/recorder-core": "workspace:*",
@@ -36,6 +41,7 @@
3641
"tailwindcss": "^3.4.0",
3742
"typescript": "^5.8.3",
3843
"vite": "6.3.5",
39-
"vitest": "^3.2.0"
44+
"vitest": "^3.2.0",
45+
"web-ext": "^8.9.0"
4046
}
4147
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { readFileSync } from "node:fs";
2+
import { resolve } from "node:path";
3+
import { describe, expect, it } from "vitest";
4+
5+
// The two manifests are maintained by hand; these assertions keep the parts
6+
// that must not drift (version, entry points, content scripts, resources) in
7+
// lockstep and pin the deliberate per-browser differences.
8+
9+
type Manifest = {
10+
manifest_version: number;
11+
name: string;
12+
short_name: string;
13+
version: string;
14+
homepage_url: string;
15+
icons: Record<string, string>;
16+
action: unknown;
17+
background: {
18+
service_worker?: string;
19+
scripts?: string[];
20+
type?: string;
21+
};
22+
permissions: string[];
23+
host_permissions: string[];
24+
content_scripts: unknown[];
25+
web_accessible_resources: Array<{
26+
resources: string[];
27+
matches: string[];
28+
use_dynamic_url?: boolean;
29+
}>;
30+
options_page?: string;
31+
options_ui?: { page: string };
32+
browser_specific_settings?: {
33+
gecko?: {
34+
id?: string;
35+
strict_min_version?: string;
36+
};
37+
};
38+
};
39+
40+
const loadManifest = (target: "chrome" | "firefox"): Manifest =>
41+
JSON.parse(
42+
readFileSync(
43+
resolve(__dirname, `../../manifests/manifest.${target}.json`),
44+
"utf8",
45+
),
46+
);
47+
48+
const chrome = loadManifest("chrome");
49+
const firefox = loadManifest("firefox");
50+
51+
describe("manifest parity", () => {
52+
it("keeps shared identity fields in sync", () => {
53+
expect(firefox.manifest_version).toBe(chrome.manifest_version);
54+
expect(firefox.name).toBe(chrome.name);
55+
expect(firefox.short_name).toBe(chrome.short_name);
56+
expect(firefox.version).toBe(chrome.version);
57+
expect(firefox.homepage_url).toBe(chrome.homepage_url);
58+
expect(firefox.icons).toEqual(chrome.icons);
59+
expect(firefox.action).toEqual(chrome.action);
60+
});
61+
62+
it("keeps content scripts and host permissions in sync", () => {
63+
expect(firefox.content_scripts).toEqual(chrome.content_scripts);
64+
expect(firefox.host_permissions).toEqual(chrome.host_permissions);
65+
});
66+
67+
it("exposes the same web accessible resources (modulo use_dynamic_url)", () => {
68+
const strip = (entries: Manifest["web_accessible_resources"]) =>
69+
entries.map(({ resources, matches }) => ({ resources, matches }));
70+
expect(strip(firefox.web_accessible_resources)).toEqual(
71+
strip(chrome.web_accessible_resources),
72+
);
73+
// use_dynamic_url is Chromium-only.
74+
expect(
75+
firefox.web_accessible_resources.every(
76+
(entry) => entry.use_dynamic_url === undefined,
77+
),
78+
).toBe(true);
79+
});
80+
81+
it("points both backgrounds at the same script", () => {
82+
expect(chrome.background.service_worker).toBe("assets/service-worker.js");
83+
expect(firefox.background.scripts).toEqual(["assets/service-worker.js"]);
84+
expect(firefox.background.service_worker).toBeUndefined();
85+
});
86+
87+
it("only differs in the Chrome-only permissions", () => {
88+
expect(chrome.permissions).toEqual(
89+
expect.arrayContaining(["offscreen", "tabCapture"]),
90+
);
91+
const chromeOnly = ["offscreen", "tabCapture"];
92+
expect(firefox.permissions.filter((p) => chromeOnly.includes(p))).toEqual(
93+
[],
94+
);
95+
expect([...firefox.permissions].sort()).toEqual(
96+
chrome.permissions.filter((p) => !chromeOnly.includes(p)).sort(),
97+
);
98+
});
99+
100+
it("declares Firefox-specific settings and options_ui", () => {
101+
expect(firefox.browser_specific_settings?.gecko?.id).toBeTruthy();
102+
expect(
103+
firefox.browser_specific_settings?.gecko?.strict_min_version,
104+
).toBeTruthy();
105+
expect(firefox.options_ui?.page).toBe(chrome.options_page);
106+
expect(firefox.options_page).toBeUndefined();
107+
expect(chrome.browser_specific_settings).toBeUndefined();
108+
});
109+
});

apps/chrome-extension/vite.config.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,29 @@
1+
import { copyFileSync } from "node:fs";
12
import { resolve } from "node:path";
23
import react from "@vitejs/plugin-react";
3-
import { defineConfig } from "vite";
4+
import { defineConfig, type Plugin } from "vite";
5+
import { outDirFor, resolveTarget, targetDefine } from "./vite.shared";
6+
7+
const target = resolveTarget();
8+
9+
// The manifests live outside public/ because public/ is copied verbatim into
10+
// every outDir, which would ship the Chrome manifest into the Firefox build.
11+
const copyManifest = (): Plugin => ({
12+
name: "cap-copy-manifest",
13+
closeBundle() {
14+
copyFileSync(
15+
resolve(__dirname, `manifests/manifest.${target}.json`),
16+
resolve(__dirname, outDirFor(target), "manifest.json"),
17+
);
18+
},
19+
});
420

521
export default defineConfig({
6-
plugins: [react()],
22+
plugins: [react(), copyManifest()],
23+
define: targetDefine(target),
724
build: {
825
emptyOutDir: false,
9-
outDir: "dist",
26+
outDir: outDirFor(target),
1027
rollupOptions: {
1128
input: {
1229
popup: resolve(__dirname, "popup.html"),

apps/chrome-extension/vite.content-overlay.config.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
11
import { resolve } from "node:path";
22
import react from "@vitejs/plugin-react";
33
import { defineConfig } from "vite";
4+
import { outDirFor, resolveTarget, targetDefine } from "./vite.shared";
5+
6+
const target = resolveTarget();
47

58
// The full overlay/recording-bar UI, lazily import()ed by the bootstrap
69
// content script. It must be an ES module (the bootstrap dynamic-imports
710
// it), self-contained, and emitted at a stable hash-free path so the
811
// manifest's web_accessible_resources entry can list it.
912
export default defineConfig({
1013
plugins: [react()],
14+
define: targetDefine(target),
1115
build: {
1216
emptyOutDir: false,
13-
outDir: "dist",
17+
outDir: outDirFor(target),
1418
rollupOptions: {
1519
input: resolve(__dirname, "src/content/overlay.tsx"),
1620
// Keep the init() export the bootstrap calls after import().

apps/chrome-extension/vite.content.config.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
import { resolve } from "node:path";
22
import { defineConfig } from "vite";
3+
import { outDirFor, resolveTarget, targetDefine } from "./vite.shared";
4+
5+
const target = resolveTarget();
36

47
// Chrome runs manifest content scripts as classic scripts, so this entry
58
// must be a single self-contained IIFE with no ES module imports. Only the
69
// tiny dependency-free bootstrap ships this way; it lazily import()s the
710
// real overlay UI (built by vite.content-overlay.config.ts) from
811
// web_accessible_resources when a tab actually needs it.
912
export default defineConfig({
13+
define: targetDefine(target),
1014
build: {
1115
emptyOutDir: false,
12-
outDir: "dist",
16+
outDir: outDirFor(target),
1317
rollupOptions: {
1418
input: resolve(__dirname, "src/content/bootstrap.ts"),
1519
output: {

0 commit comments

Comments
 (0)