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,6 +1,7 @@
/**
* CookieBridge background service worker.
* Manages device identity, connection lifecycle, and cookie sync.
* Uses the browser-agnostic compat layer for cross-browser support.
*/
import {
generateKeyPair,
@@ -15,6 +16,7 @@ import { ApiClient } from "../lib/api-client";
import { SyncEngine } from "../lib/sync";
import { setIconState, clearSyncBadge } from "../lib/badge";
import { MESSAGE_TYPES, POLL_INTERVAL_MS, type Envelope } from "../lib/protocol";
import { alarms, runtime, cookies, getPlatformName } from "../lib/compat";
let connection: ConnectionManager | null = null;
let syncEngine: SyncEngine | null = null;
@@ -77,18 +79,18 @@ async function handleIncomingMessage(envelope: Envelope, peers: PeerDevice[]) {
}
function startPolling(intervalSec: number) {
chrome.alarms.create(pollAlarmName, {
alarms.create(pollAlarmName, {
periodInMinutes: Math.max(intervalSec / 60, 1 / 60),
});
}
function stopPolling() {
chrome.alarms.clear(pollAlarmName);
alarms.clear(pollAlarmName);
}
// --- Alarm handler for HTTP polling ---
chrome.alarms.onAlarm.addListener(async (alarm) => {
alarms.onAlarm.addListener(async (alarm) => {
if (alarm.name !== pollAlarmName) return;
if (!api) return;
@@ -114,8 +116,8 @@ export interface ExtensionMessage {
payload?: unknown;
}
chrome.runtime.onMessage.addListener(
(message: ExtensionMessage, _sender, sendResponse) => {
runtime.onMessage.addListener(
(message: ExtensionMessage, _sender: any, sendResponse: (response: any) => void) => {
handleMessage(message).then(sendResponse).catch((err) => {
sendResponse({ error: err.message });
});
@@ -226,8 +228,8 @@ async function getStatus(): Promise<{
let cookieCount = 0;
if (state.whitelist.length > 0) {
for (const domain of state.whitelist) {
const cookies = await chrome.cookies.getAll({ domain });
cookieCount += cookies.length;
const domainCookies = await cookies.getAll({ domain });
cookieCount += domainCookies.length;
}
}
@@ -253,7 +255,7 @@ async function registerDevice(params: { name: string }) {
const device = await client.registerDevice({
deviceId,
name: params.name,
platform: "chrome-extension",
platform: getPlatformName(),
encPub: serialized.encPub,
});
@@ -303,11 +305,11 @@ async function acceptPairing(params: { code: string }) {
// --- Start on install/startup ---
chrome.runtime.onInstalled.addListener(() => {
runtime.onInstalled.addListener(() => {
initialize();
});
chrome.runtime.onStartup.addListener(() => {
runtime.onStartup.addListener(() => {
initialize();
});

View File

@@ -1,6 +1,7 @@
/**
* Badge/icon management — updates extension icon color and badge text
* based on connection status and sync activity.
* Uses the browser-agnostic compat layer.
*
* States:
* - gray: Not logged in / no device identity
@@ -8,6 +9,7 @@
* - green: Syncing (with count badge)
* - red: Error / disconnected
*/
import { action, storage } from "./compat";
type IconColor = "gray" | "blue" | "green" | "red";
@@ -29,29 +31,29 @@ export async function setIconState(
) {
switch (state) {
case "not_logged_in":
await chrome.action.setIcon({ path: iconSet("gray") });
await chrome.action.setBadgeText({ text: "" });
await action.setIcon({ path: iconSet("gray") });
await action.setBadgeText({ text: "" });
break;
case "connected":
await chrome.action.setIcon({ path: iconSet("blue") });
await chrome.action.setBadgeText({ text: "" });
await action.setIcon({ path: iconSet("blue") });
await action.setBadgeText({ text: "" });
break;
case "syncing":
await chrome.action.setIcon({ path: iconSet("green") });
await action.setIcon({ path: iconSet("green") });
if (syncCount && syncCount > 0) {
await chrome.action.setBadgeText({
await action.setBadgeText({
text: syncCount > 99 ? "99+" : String(syncCount),
});
await chrome.action.setBadgeBackgroundColor({ color: "#22C55E" });
await action.setBadgeBackgroundColor({ color: "#22C55E" });
}
break;
case "error":
await chrome.action.setIcon({ path: iconSet("red") });
await chrome.action.setBadgeText({ text: "!" });
await chrome.action.setBadgeBackgroundColor({ color: "#EF4444" });
await action.setIcon({ path: iconSet("red") });
await action.setBadgeText({ text: "!" });
await action.setBadgeBackgroundColor({ color: "#EF4444" });
break;
}
}
@@ -59,7 +61,7 @@ export async function setIconState(
/** Clear the sync badge after a delay. */
export function clearSyncBadge(delayMs = 3000) {
setTimeout(async () => {
const state = await chrome.storage.local.get(["apiToken"]);
const state = await storage.get(["apiToken"]);
if (state.apiToken) {
await setIconState("connected");
} else {

278
extension/src/lib/compat.ts Normal file
View File

@@ -0,0 +1,278 @@
/**
* Browser compatibility layer — provides a unified API across Chrome, Firefox, Edge, and Safari.
*
* Firefox uses the `browser.*` namespace with promise-based APIs.
* Chrome/Edge use `chrome.*` with callback-based APIs.
* Safari uses `browser.*` (WebExtensions) with some limitations.
*
* This module detects the environment and exports a normalized `browserAPI`
* that always returns promises and works across all supported browsers.
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
type BrowserType = "chrome" | "firefox" | "safari" | "edge" | "unknown";
function detectBrowser(): BrowserType {
if (typeof globalThis !== "undefined") {
const ua =
typeof navigator !== "undefined" ? navigator.userAgent || "" : "";
// Safari: check before Chrome since Safari UA may contain "Chrome" in some contexts
if (
ua.includes("Safari") &&
!ua.includes("Chrome") &&
!ua.includes("Chromium")
) {
return "safari";
}
// Firefox
if (
typeof (globalThis as any).browser !== "undefined" &&
typeof (globalThis as any).browser.runtime !== "undefined" &&
ua.includes("Firefox")
) {
return "firefox";
}
// Edge (Chromium-based)
if (ua.includes("Edg/")) {
return "edge";
}
// Chrome
if (
typeof (globalThis as any).chrome !== "undefined" &&
typeof (globalThis as any).chrome.runtime !== "undefined"
) {
return "chrome";
}
}
return "unknown";
}
export const BROWSER: BrowserType = detectBrowser();
/**
* Get the raw extension API object (`browser` on Firefox/Safari, `chrome` on Chrome/Edge).
*/
function getRawAPI(): any {
if (
typeof (globalThis as any).browser !== "undefined" &&
(globalThis as any).browser.runtime
) {
return (globalThis as any).browser;
}
if (
typeof (globalThis as any).chrome !== "undefined" &&
(globalThis as any).chrome.runtime
) {
return (globalThis as any).chrome;
}
throw new Error("No extension API found");
}
const raw = getRawAPI();
/**
* Wraps a callback-style Chrome API call into a promise.
* Firefox's `browser.*` already returns promises so we detect and pass through.
*/
function promisify<T>(fn: (...args: any[]) => any, ...args: any[]): Promise<T> {
const result = fn(...args);
if (result && typeof result.then === "function") {
return result;
}
return new Promise<T>((resolve, reject) => {
fn(...args, (val: T) => {
if (raw.runtime.lastError) {
reject(new Error(raw.runtime.lastError.message));
} else {
resolve(val);
}
});
});
}
// ─── Storage ───
export const storage = {
async get(keys: string | string[] | null): Promise<Record<string, any>> {
return promisify((k: any, cb?: any) => raw.storage.local.get(k, cb), keys);
},
async set(items: Record<string, any>): Promise<void> {
return promisify((i: any, cb?: any) => raw.storage.local.set(i, cb), items);
},
async clear(): Promise<void> {
return promisify((cb?: any) => raw.storage.local.clear(cb));
},
};
// ─── Cookies ───
export interface CompatCookie {
domain: string;
name: string;
value: string;
path: string;
secure: boolean;
httpOnly: boolean;
sameSite: string;
expirationDate?: number;
}
export interface CompatCookieChangeInfo {
cookie: CompatCookie;
removed: boolean;
cause: string;
}
export const cookies = {
async getAll(details: { domain?: string; url?: string }): Promise<CompatCookie[]> {
return promisify(
(d: any, cb?: any) => raw.cookies.getAll(d, cb),
details,
);
},
async set(details: Record<string, any>): Promise<CompatCookie | null> {
return promisify(
(d: any, cb?: any) => raw.cookies.set(d, cb),
details,
);
},
async remove(details: { url: string; name: string }): Promise<any> {
return promisify(
(d: any, cb?: any) => raw.cookies.remove(d, cb),
details,
);
},
onChanged: {
addListener(callback: (changeInfo: CompatCookieChangeInfo) => void) {
raw.cookies.onChanged.addListener(callback);
},
removeListener(callback: (changeInfo: CompatCookieChangeInfo) => void) {
raw.cookies.onChanged.removeListener(callback);
},
},
};
// ─── Tabs ───
export const tabs = {
async query(queryInfo: {
active?: boolean;
currentWindow?: boolean;
}): Promise<Array<{ url?: string; id?: number }>> {
return promisify(
(q: any, cb?: any) => raw.tabs.query(q, cb),
queryInfo,
);
},
};
// ─── Alarms ───
export const alarms = {
create(name: string, alarmInfo: { periodInMinutes: number }): void {
raw.alarms.create(name, alarmInfo);
},
clear(name: string): void {
raw.alarms.clear(name);
},
onAlarm: {
addListener(callback: (alarm: { name: string }) => void): void {
raw.alarms.onAlarm.addListener(callback);
},
},
};
// ─── Runtime ───
export const runtime = {
onMessage: {
addListener(
callback: (
message: any,
sender: any,
sendResponse: (response: any) => void,
) => boolean | void,
): void {
raw.runtime.onMessage.addListener(callback);
},
},
sendMessage(message: any): Promise<any> {
// Firefox returns a promise, Chrome uses callbacks
const result = raw.runtime.sendMessage(message);
if (result && typeof result.then === "function") {
return result;
}
return new Promise((resolve, reject) => {
raw.runtime.sendMessage(message, (response: any) => {
if (raw.runtime.lastError) {
reject(new Error(raw.runtime.lastError.message));
} else {
resolve(response);
}
});
});
},
onInstalled: {
addListener(callback: () => void): void {
raw.runtime.onInstalled.addListener(callback);
},
},
onStartup: {
addListener(callback: () => void): void {
raw.runtime.onStartup.addListener(callback);
},
},
openOptionsPage(): void {
raw.runtime.openOptionsPage();
},
get lastError(): { message?: string } | null | undefined {
return raw.runtime.lastError;
},
};
// ─── Action (browserAction on older Firefox) ───
function getActionAPI(): any {
return raw.action || raw.browserAction;
}
export const action = {
async setIcon(details: { path: Record<string, string> }): Promise<void> {
const api = getActionAPI();
if (!api) return;
return promisify((d: any, cb?: any) => api.setIcon(d, cb), details);
},
async setBadgeText(details: { text: string }): Promise<void> {
const api = getActionAPI();
if (!api) return;
return promisify((d: any, cb?: any) => api.setBadgeText(d, cb), details);
},
async setBadgeBackgroundColor(details: {
color: string | [number, number, number, number];
}): Promise<void> {
const api = getActionAPI();
if (!api) return;
return promisify(
(d: any, cb?: any) => api.setBadgeBackgroundColor(d, cb),
details,
);
},
};
// ─── Platform detection for device registration ───
export function getPlatformName(): string {
switch (BROWSER) {
case "firefox":
return "firefox-extension";
case "safari":
return "safari-extension";
case "edge":
return "edge-extension";
case "chrome":
default:
return "chrome-extension";
}
}

View File

@@ -1,7 +1,9 @@
/**
* Chrome storage wrapper — provides typed access to extension state.
* Storage wrapper — provides typed access to extension state.
* Uses the browser-agnostic compat layer.
*/
import type { SerializedKeyPair } from "./crypto";
import { storage } from "./compat";
export interface PeerDevice {
deviceId: string;
@@ -64,7 +66,7 @@ const DEFAULT_STATE: ExtensionState = {
/** Get full extension state, merging defaults. */
export async function getState(): Promise<ExtensionState> {
const data = await chrome.storage.local.get(null);
const data = await storage.get(null);
return { ...DEFAULT_STATE, ...data } as ExtensionState;
}
@@ -72,12 +74,12 @@ export async function getState(): Promise<ExtensionState> {
export async function setState(
partial: Partial<ExtensionState>,
): Promise<void> {
await chrome.storage.local.set(partial);
await storage.set(partial);
}
/** Reset all state to defaults. */
export async function clearState(): Promise<void> {
await chrome.storage.local.clear();
await storage.clear();
}
/** Check if a domain matches a pattern (supports leading wildcard *.). */

View File

@@ -1,5 +1,6 @@
/**
* Cookie sync engine — monitors chrome.cookies, syncs with relay server.
* Cookie sync engine — monitors browser cookies, syncs with relay server.
* Uses the browser-agnostic compat layer.
*/
import type { CookieEntry, CookieSyncPayload, Envelope } from "./protocol";
import { MESSAGE_TYPES } from "./protocol";
@@ -13,6 +14,7 @@ import {
import type { ConnectionManager } from "./connection";
import type { ApiClient } from "./api-client";
import { getState, setState, isDomainAllowed, type PeerDevice } from "./storage";
import { cookies, tabs, type CompatCookie, type CompatCookieChangeInfo } from "./compat";
type CookieKey = string;
@@ -26,7 +28,7 @@ function cookieKey(domain: string, name: string, path: string): CookieKey {
return `${domain}|${name}|${path}`;
}
function chromeCookieToEntry(cookie: chrome.cookies.Cookie): CookieEntry {
function browserCookieToEntry(cookie: CompatCookie): CookieEntry {
return {
domain: cookie.domain,
name: cookie.name,
@@ -46,7 +48,7 @@ function chromeCookieToEntry(cookie: chrome.cookies.Cookie): CookieEntry {
}
export class SyncEngine {
private cookies = new Map<CookieKey, TrackedCookie>();
private cookieMap = new Map<CookieKey, TrackedCookie>();
private lamportClock = 0;
private deviceId: string;
private keys: DeviceKeyPair;
@@ -67,11 +69,11 @@ export class SyncEngine {
/** Start monitoring cookie changes. */
start() {
chrome.cookies.onChanged.addListener(this.handleCookieChange);
cookies.onChanged.addListener(this.handleCookieChange);
}
stop() {
chrome.cookies.onChanged.removeListener(this.handleCookieChange);
cookies.onChanged.removeListener(this.handleCookieChange);
}
/** Handle incoming envelope from WebSocket. */
@@ -102,16 +104,16 @@ export class SyncEngine {
/** Sync a specific domain's cookies to all peers. */
async syncDomain(domain: string) {
const cookies = await chrome.cookies.getAll({ domain });
if (cookies.length === 0) return;
const allCookies = await cookies.getAll({ domain });
if (allCookies.length === 0) return;
const entries = cookies.map(chromeCookieToEntry);
const entries = allCookies.map(browserCookieToEntry);
await this.syncEntriesToPeers(entries, "set");
}
/** Sync the current tab's cookies. */
async syncCurrentTab() {
const [tab] = await chrome.tabs.query({
const [tab] = await tabs.query({
active: true,
currentWindow: true,
});
@@ -130,7 +132,7 @@ export class SyncEngine {
}
private handleCookieChange = async (
changeInfo: chrome.cookies.CookieChangeInfo,
changeInfo: CompatCookieChangeInfo,
) => {
const { cookie, removed, cause } = changeInfo;
@@ -149,18 +151,18 @@ export class SyncEngine {
if (!isDomainAllowed(cookie.domain, state.whitelist, state.blacklist))
return;
const entry = chromeCookieToEntry(cookie);
const entry = browserCookieToEntry(cookie);
const action = removed ? "delete" : "set";
if (!removed) {
this.lamportClock++;
this.cookies.set(key, {
this.cookieMap.set(key, {
entry,
lamportTs: this.lamportClock,
sourceDeviceId: this.deviceId,
});
} else {
this.cookies.delete(key);
this.cookieMap.delete(key);
}
await this.syncEntriesToPeers([entry], action);
@@ -199,18 +201,18 @@ export class SyncEngine {
for (const entry of payload.cookies) {
const key = cookieKey(entry.domain, entry.name, entry.path);
const existing = this.cookies.get(key);
const existing = this.cookieMap.get(key);
if (payload.action === "delete") {
if (this.shouldApply(existing, payload.lamportTs, sourceDeviceId)) {
this.cookies.delete(key);
this.cookieMap.delete(key);
applied.push(entry);
}
continue;
}
if (this.shouldApply(existing, payload.lamportTs, sourceDeviceId)) {
this.cookies.set(key, {
this.cookieMap.set(key, {
entry,
lamportTs: payload.lamportTs,
sourceDeviceId,
@@ -242,11 +244,11 @@ export class SyncEngine {
const url = `http${entry.secure ? "s" : ""}://${entry.domain.replace(/^\./, "")}${entry.path}`;
if (action === "delete") {
await chrome.cookies.remove({ url, name: entry.name });
await cookies.remove({ url, name: entry.name });
return;
}
const details: chrome.cookies.SetDetails = {
const details: Record<string, any> = {
url,
name: entry.name,
value: entry.value,
@@ -264,6 +266,6 @@ export class SyncEngine {
details.expirationDate = new Date(entry.expiresAt).getTime() / 1000;
}
await chrome.cookies.set(details);
await cookies.set(details);
}
}

View File

@@ -1,24 +1,19 @@
/**
* Options page script — manages extension settings.
* Uses the browser-agnostic compat layer.
*/
import { runtime, storage } from "../lib/compat";
export {};
// --- Messaging helper ---
function sendMessage(type: string, payload?: unknown): Promise<any> {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ type, payload }, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
}
if (response?.error) {
reject(new Error(response.error));
return;
}
resolve(response);
});
});
async function sendMessage(type: string, payload?: unknown): Promise<any> {
const response = await runtime.sendMessage({ type, payload });
if (response?.error) {
throw new Error(response.error);
}
return response;
}
// --- Elements ---
@@ -59,7 +54,7 @@ let blacklist: string[] = [];
// --- Load Settings ---
async function loadSettings() {
const state = await chrome.storage.local.get(null);
const state = await storage.get(null);
optDeviceName.textContent = state.deviceName || "—";
optDeviceId.textContent = state.deviceId || "—";
@@ -168,7 +163,7 @@ btnAddBlacklist.addEventListener("click", () => {
// Save
btnSave.addEventListener("click", async () => {
await chrome.storage.local.set({
await storage.set({
serverUrl: optServerUrl.value.trim(),
connectionMode: optConnectionMode.value,
pollIntervalSec: parseInt(optPollInterval.value),
@@ -247,7 +242,7 @@ btnClearData.addEventListener("click", async () => {
return;
}
await sendMessage("LOGOUT");
await chrome.storage.local.clear();
await storage.clear();
alert("All data cleared.");
window.close();
});

View File

@@ -1,6 +1,9 @@
/**
* Popup script — controls the popup UI interactions.
* Uses the browser-agnostic compat layer.
*/
import { runtime, tabs } from "../lib/compat";
export {};
// --- Elements ---
@@ -40,20 +43,12 @@ const btnPairingCancel = document.getElementById("btn-pairing-cancel") as HTMLBu
// --- Messaging helper ---
function sendMessage(type: string, payload?: unknown): Promise<any> {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ type, payload }, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
}
if (response?.error) {
reject(new Error(response.error));
return;
}
resolve(response);
});
});
async function sendMessage(type: string, payload?: unknown): Promise<any> {
const response = await runtime.sendMessage({ type, payload });
if (response?.error) {
throw new Error(response.error);
}
return response;
}
// --- UI Updates ---
@@ -142,7 +137,7 @@ btnSyncTab.addEventListener("click", async () => {
});
btnAddWhitelist.addEventListener("click", async () => {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
const [tab] = await tabs.query({ active: true, currentWindow: true });
if (!tab?.url) return;
try {
@@ -198,7 +193,7 @@ btnPairingCancel.addEventListener("click", () => {
btnSettings.addEventListener("click", (e) => {
e.preventDefault();
chrome.runtime.openOptionsPage();
runtime.openOptionsPage();
});
// --- Init ---