feat: implement M3 multi-browser support (Firefox, Edge, Safari)

Add browser abstraction layer (compat.ts) that normalizes Chrome/Firefox/
Edge/Safari extension APIs behind a unified promise-based interface.
Replace all direct chrome.* calls with compat layer across service worker,
sync engine, badge, storage, popup, and options modules.

- Browser-specific manifests in manifests/ (Firefox MV3 with gecko settings,
  Edge/Safari variants)
- Multi-target build system: `npm run build` produces all four browser
  builds in build/<browser>/
- Per-browser build scripts: build:chrome, build:firefox, build:edge, build:safari
- Auto-detects browser at runtime for platform-specific device registration
- All 38 existing tests pass, typecheck clean

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
徐枫
2026-03-17 16:58:44 +08:00
parent dc3be4d73f
commit f39ff8c215
14 changed files with 580 additions and 88 deletions

View File

@@ -1,17 +1,33 @@
import * as esbuild from "esbuild";
import { cpSync, mkdirSync, existsSync } from "fs";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const isWatch = process.argv.includes("--watch");
const targetBrowser = process.argv.find((a) => a.startsWith("--browser="))?.split("=")[1] || "all";
const buildOptions = {
const browsers = targetBrowser === "all"
? ["chrome", "firefox", "edge", "safari"]
: [targetBrowser];
// Browser-specific esbuild targets
const browserTargets = {
chrome: "chrome120",
edge: "chrome120", // Edge is Chromium-based
firefox: "firefox109",
safari: "safari16",
};
const sharedBuildOptions = {
entryPoints: [
"src/background/service-worker.ts",
"src/popup/popup.ts",
"src/options/options.ts",
],
bundle: true,
outdir: "dist",
format: "esm",
target: "chrome120",
sourcemap: true,
minify: !isWatch,
// Force CJS resolution for libsodium (ESM entry has broken sibling import)
@@ -21,11 +37,65 @@ const buildOptions = {
},
};
if (isWatch) {
const ctx = await esbuild.context(buildOptions);
await ctx.watch();
console.log("Watching for changes...");
} else {
await esbuild.build(buildOptions);
console.log("Build complete.");
/**
* Copy static assets (HTML, icons, manifest) to a browser-specific build directory.
*/
function copyStaticAssets(browser) {
const outDir = join(__dirname, "build", browser);
mkdirSync(outDir, { recursive: true });
// Copy manifest
const manifestSrc = join(__dirname, "manifests", `${browser}.json`);
if (existsSync(manifestSrc)) {
cpSync(manifestSrc, join(outDir, "manifest.json"));
}
// Copy src (HTML, icons)
const srcDir = join(__dirname, "src");
cpSync(srcDir, join(outDir, "src"), { recursive: true, filter: (src) => !src.endsWith(".ts") });
}
async function buildBrowser(browser) {
const outDir = join("build", browser, "dist");
const options = {
...sharedBuildOptions,
outdir: outDir,
target: browserTargets[browser] || "es2020",
};
if (isWatch && browsers.length === 1) {
const ctx = await esbuild.context(options);
await ctx.watch();
console.log(`Watching for changes (${browser})...`);
} else {
await esbuild.build(options);
}
copyStaticAssets(browser);
console.log(`Build complete: ${browser} → build/${browser}/`);
}
// Also build the default dist/ for backwards compatibility (Chrome)
async function buildDefault() {
const options = {
...sharedBuildOptions,
outdir: "dist",
target: "chrome120",
};
if (isWatch) {
const ctx = await esbuild.context(options);
await ctx.watch();
console.log("Watching for changes (default)...");
} else {
await esbuild.build(options);
console.log("Build complete (default dist/).");
}
}
// Build all targets
await buildDefault();
for (const browser of browsers) {
await buildBrowser(browser);
}