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,2 +1,3 @@
node_modules/ node_modules/
dist/ dist/
build/

View File

@@ -1,17 +1,33 @@
import * as esbuild from "esbuild"; 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 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: [ entryPoints: [
"src/background/service-worker.ts", "src/background/service-worker.ts",
"src/popup/popup.ts", "src/popup/popup.ts",
"src/options/options.ts", "src/options/options.ts",
], ],
bundle: true, bundle: true,
outdir: "dist",
format: "esm", format: "esm",
target: "chrome120",
sourcemap: true, sourcemap: true,
minify: !isWatch, minify: !isWatch,
// Force CJS resolution for libsodium (ESM entry has broken sibling import) // 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); * Copy static assets (HTML, icons, manifest) to a browser-specific build directory.
await ctx.watch(); */
console.log("Watching for changes..."); function copyStaticAssets(browser) {
} else { const outDir = join(__dirname, "build", browser);
await esbuild.build(buildOptions); mkdirSync(outDir, { recursive: true });
console.log("Build complete.");
// 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);
} }

View File

@@ -0,0 +1,32 @@
{
"manifest_version": 3,
"name": "CookieBridge",
"version": "0.1.0",
"description": "Cross-device cookie synchronization with end-to-end encryption",
"permissions": [
"cookies",
"storage",
"alarms",
"tabs",
"activeTab"
],
"host_permissions": ["<all_urls>"],
"background": {
"service_worker": "dist/background/service-worker.js",
"type": "module"
},
"action": {
"default_popup": "src/popup/popup.html",
"default_icon": {
"16": "src/icons/icon-gray-16.png",
"48": "src/icons/icon-gray-48.png",
"128": "src/icons/icon-gray-128.png"
}
},
"options_page": "src/options/options.html",
"icons": {
"16": "src/icons/icon-blue-16.png",
"48": "src/icons/icon-blue-48.png",
"128": "src/icons/icon-blue-128.png"
}
}

View File

@@ -0,0 +1,32 @@
{
"manifest_version": 3,
"name": "CookieBridge",
"version": "0.1.0",
"description": "Cross-device cookie synchronization with end-to-end encryption",
"permissions": [
"cookies",
"storage",
"alarms",
"tabs",
"activeTab"
],
"host_permissions": ["<all_urls>"],
"background": {
"service_worker": "dist/background/service-worker.js",
"type": "module"
},
"action": {
"default_popup": "src/popup/popup.html",
"default_icon": {
"16": "src/icons/icon-gray-16.png",
"48": "src/icons/icon-gray-48.png",
"128": "src/icons/icon-gray-128.png"
}
},
"options_page": "src/options/options.html",
"icons": {
"16": "src/icons/icon-blue-16.png",
"48": "src/icons/icon-blue-48.png",
"128": "src/icons/icon-blue-128.png"
}
}

View File

@@ -0,0 +1,41 @@
{
"manifest_version": 3,
"name": "CookieBridge",
"version": "0.1.0",
"description": "Cross-device cookie synchronization with end-to-end encryption",
"browser_specific_settings": {
"gecko": {
"id": "cookiebridge@rc707agency.com",
"strict_min_version": "109.0"
}
},
"permissions": [
"cookies",
"storage",
"alarms",
"tabs",
"activeTab"
],
"host_permissions": ["<all_urls>"],
"background": {
"scripts": ["dist/background/service-worker.js"],
"type": "module"
},
"action": {
"default_popup": "src/popup/popup.html",
"default_icon": {
"16": "src/icons/icon-gray-16.png",
"48": "src/icons/icon-gray-48.png",
"128": "src/icons/icon-gray-128.png"
}
},
"options_ui": {
"page": "src/options/options.html",
"open_in_tab": true
},
"icons": {
"16": "src/icons/icon-blue-16.png",
"48": "src/icons/icon-blue-48.png",
"128": "src/icons/icon-blue-128.png"
}
}

View File

@@ -0,0 +1,36 @@
{
"manifest_version": 3,
"name": "CookieBridge",
"version": "0.1.0",
"description": "Cross-device cookie synchronization with end-to-end encryption",
"permissions": [
"cookies",
"storage",
"alarms",
"tabs",
"activeTab"
],
"host_permissions": ["<all_urls>"],
"background": {
"scripts": ["dist/background/service-worker.js"],
"type": "module",
"persistent": false
},
"action": {
"default_popup": "src/popup/popup.html",
"default_icon": {
"16": "src/icons/icon-gray-16.png",
"48": "src/icons/icon-gray-48.png",
"128": "src/icons/icon-gray-128.png"
}
},
"options_ui": {
"page": "src/options/options.html",
"open_in_tab": true
},
"icons": {
"16": "src/icons/icon-blue-16.png",
"48": "src/icons/icon-blue-48.png",
"128": "src/icons/icon-blue-128.png"
}
}

View File

@@ -5,7 +5,11 @@
"description": "CookieBridge Chrome Extension — cross-device cookie sync with E2E encryption", "description": "CookieBridge Chrome Extension — cross-device cookie sync with E2E encryption",
"scripts": { "scripts": {
"build": "node esbuild.config.mjs", "build": "node esbuild.config.mjs",
"watch": "node esbuild.config.mjs --watch", "build:chrome": "node esbuild.config.mjs --browser=chrome",
"build:firefox": "node esbuild.config.mjs --browser=firefox",
"build:edge": "node esbuild.config.mjs --browser=edge",
"build:safari": "node esbuild.config.mjs --browser=safari",
"watch": "node esbuild.config.mjs --watch --browser=chrome",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {

View File

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

View File

@@ -1,6 +1,7 @@
/** /**
* Badge/icon management — updates extension icon color and badge text * Badge/icon management — updates extension icon color and badge text
* based on connection status and sync activity. * based on connection status and sync activity.
* Uses the browser-agnostic compat layer.
* *
* States: * States:
* - gray: Not logged in / no device identity * - gray: Not logged in / no device identity
@@ -8,6 +9,7 @@
* - green: Syncing (with count badge) * - green: Syncing (with count badge)
* - red: Error / disconnected * - red: Error / disconnected
*/ */
import { action, storage } from "./compat";
type IconColor = "gray" | "blue" | "green" | "red"; type IconColor = "gray" | "blue" | "green" | "red";
@@ -29,29 +31,29 @@ export async function setIconState(
) { ) {
switch (state) { switch (state) {
case "not_logged_in": case "not_logged_in":
await chrome.action.setIcon({ path: iconSet("gray") }); await action.setIcon({ path: iconSet("gray") });
await chrome.action.setBadgeText({ text: "" }); await action.setBadgeText({ text: "" });
break; break;
case "connected": case "connected":
await chrome.action.setIcon({ path: iconSet("blue") }); await action.setIcon({ path: iconSet("blue") });
await chrome.action.setBadgeText({ text: "" }); await action.setBadgeText({ text: "" });
break; break;
case "syncing": case "syncing":
await chrome.action.setIcon({ path: iconSet("green") }); await action.setIcon({ path: iconSet("green") });
if (syncCount && syncCount > 0) { if (syncCount && syncCount > 0) {
await chrome.action.setBadgeText({ await action.setBadgeText({
text: syncCount > 99 ? "99+" : String(syncCount), text: syncCount > 99 ? "99+" : String(syncCount),
}); });
await chrome.action.setBadgeBackgroundColor({ color: "#22C55E" }); await action.setBadgeBackgroundColor({ color: "#22C55E" });
} }
break; break;
case "error": case "error":
await chrome.action.setIcon({ path: iconSet("red") }); await action.setIcon({ path: iconSet("red") });
await chrome.action.setBadgeText({ text: "!" }); await action.setBadgeText({ text: "!" });
await chrome.action.setBadgeBackgroundColor({ color: "#EF4444" }); await action.setBadgeBackgroundColor({ color: "#EF4444" });
break; break;
} }
} }
@@ -59,7 +61,7 @@ export async function setIconState(
/** Clear the sync badge after a delay. */ /** Clear the sync badge after a delay. */
export function clearSyncBadge(delayMs = 3000) { export function clearSyncBadge(delayMs = 3000) {
setTimeout(async () => { setTimeout(async () => {
const state = await chrome.storage.local.get(["apiToken"]); const state = await storage.get(["apiToken"]);
if (state.apiToken) { if (state.apiToken) {
await setIconState("connected"); await setIconState("connected");
} else { } 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 type { SerializedKeyPair } from "./crypto";
import { storage } from "./compat";
export interface PeerDevice { export interface PeerDevice {
deviceId: string; deviceId: string;
@@ -64,7 +66,7 @@ const DEFAULT_STATE: ExtensionState = {
/** Get full extension state, merging defaults. */ /** Get full extension state, merging defaults. */
export async function getState(): Promise<ExtensionState> { 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; return { ...DEFAULT_STATE, ...data } as ExtensionState;
} }
@@ -72,12 +74,12 @@ export async function getState(): Promise<ExtensionState> {
export async function setState( export async function setState(
partial: Partial<ExtensionState>, partial: Partial<ExtensionState>,
): Promise<void> { ): Promise<void> {
await chrome.storage.local.set(partial); await storage.set(partial);
} }
/** Reset all state to defaults. */ /** Reset all state to defaults. */
export async function clearState(): Promise<void> { export async function clearState(): Promise<void> {
await chrome.storage.local.clear(); await storage.clear();
} }
/** Check if a domain matches a pattern (supports leading wildcard *.). */ /** 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 type { CookieEntry, CookieSyncPayload, Envelope } from "./protocol";
import { MESSAGE_TYPES } from "./protocol"; import { MESSAGE_TYPES } from "./protocol";
@@ -13,6 +14,7 @@ import {
import type { ConnectionManager } from "./connection"; import type { ConnectionManager } from "./connection";
import type { ApiClient } from "./api-client"; import type { ApiClient } from "./api-client";
import { getState, setState, isDomainAllowed, type PeerDevice } from "./storage"; import { getState, setState, isDomainAllowed, type PeerDevice } from "./storage";
import { cookies, tabs, type CompatCookie, type CompatCookieChangeInfo } from "./compat";
type CookieKey = string; type CookieKey = string;
@@ -26,7 +28,7 @@ function cookieKey(domain: string, name: string, path: string): CookieKey {
return `${domain}|${name}|${path}`; return `${domain}|${name}|${path}`;
} }
function chromeCookieToEntry(cookie: chrome.cookies.Cookie): CookieEntry { function browserCookieToEntry(cookie: CompatCookie): CookieEntry {
return { return {
domain: cookie.domain, domain: cookie.domain,
name: cookie.name, name: cookie.name,
@@ -46,7 +48,7 @@ function chromeCookieToEntry(cookie: chrome.cookies.Cookie): CookieEntry {
} }
export class SyncEngine { export class SyncEngine {
private cookies = new Map<CookieKey, TrackedCookie>(); private cookieMap = new Map<CookieKey, TrackedCookie>();
private lamportClock = 0; private lamportClock = 0;
private deviceId: string; private deviceId: string;
private keys: DeviceKeyPair; private keys: DeviceKeyPair;
@@ -67,11 +69,11 @@ export class SyncEngine {
/** Start monitoring cookie changes. */ /** Start monitoring cookie changes. */
start() { start() {
chrome.cookies.onChanged.addListener(this.handleCookieChange); cookies.onChanged.addListener(this.handleCookieChange);
} }
stop() { stop() {
chrome.cookies.onChanged.removeListener(this.handleCookieChange); cookies.onChanged.removeListener(this.handleCookieChange);
} }
/** Handle incoming envelope from WebSocket. */ /** Handle incoming envelope from WebSocket. */
@@ -102,16 +104,16 @@ export class SyncEngine {
/** Sync a specific domain's cookies to all peers. */ /** Sync a specific domain's cookies to all peers. */
async syncDomain(domain: string) { async syncDomain(domain: string) {
const cookies = await chrome.cookies.getAll({ domain }); const allCookies = await cookies.getAll({ domain });
if (cookies.length === 0) return; if (allCookies.length === 0) return;
const entries = cookies.map(chromeCookieToEntry); const entries = allCookies.map(browserCookieToEntry);
await this.syncEntriesToPeers(entries, "set"); await this.syncEntriesToPeers(entries, "set");
} }
/** Sync the current tab's cookies. */ /** Sync the current tab's cookies. */
async syncCurrentTab() { async syncCurrentTab() {
const [tab] = await chrome.tabs.query({ const [tab] = await tabs.query({
active: true, active: true,
currentWindow: true, currentWindow: true,
}); });
@@ -130,7 +132,7 @@ export class SyncEngine {
} }
private handleCookieChange = async ( private handleCookieChange = async (
changeInfo: chrome.cookies.CookieChangeInfo, changeInfo: CompatCookieChangeInfo,
) => { ) => {
const { cookie, removed, cause } = changeInfo; const { cookie, removed, cause } = changeInfo;
@@ -149,18 +151,18 @@ export class SyncEngine {
if (!isDomainAllowed(cookie.domain, state.whitelist, state.blacklist)) if (!isDomainAllowed(cookie.domain, state.whitelist, state.blacklist))
return; return;
const entry = chromeCookieToEntry(cookie); const entry = browserCookieToEntry(cookie);
const action = removed ? "delete" : "set"; const action = removed ? "delete" : "set";
if (!removed) { if (!removed) {
this.lamportClock++; this.lamportClock++;
this.cookies.set(key, { this.cookieMap.set(key, {
entry, entry,
lamportTs: this.lamportClock, lamportTs: this.lamportClock,
sourceDeviceId: this.deviceId, sourceDeviceId: this.deviceId,
}); });
} else { } else {
this.cookies.delete(key); this.cookieMap.delete(key);
} }
await this.syncEntriesToPeers([entry], action); await this.syncEntriesToPeers([entry], action);
@@ -199,18 +201,18 @@ export class SyncEngine {
for (const entry of payload.cookies) { for (const entry of payload.cookies) {
const key = cookieKey(entry.domain, entry.name, entry.path); 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 (payload.action === "delete") {
if (this.shouldApply(existing, payload.lamportTs, sourceDeviceId)) { if (this.shouldApply(existing, payload.lamportTs, sourceDeviceId)) {
this.cookies.delete(key); this.cookieMap.delete(key);
applied.push(entry); applied.push(entry);
} }
continue; continue;
} }
if (this.shouldApply(existing, payload.lamportTs, sourceDeviceId)) { if (this.shouldApply(existing, payload.lamportTs, sourceDeviceId)) {
this.cookies.set(key, { this.cookieMap.set(key, {
entry, entry,
lamportTs: payload.lamportTs, lamportTs: payload.lamportTs,
sourceDeviceId, sourceDeviceId,
@@ -242,11 +244,11 @@ export class SyncEngine {
const url = `http${entry.secure ? "s" : ""}://${entry.domain.replace(/^\./, "")}${entry.path}`; const url = `http${entry.secure ? "s" : ""}://${entry.domain.replace(/^\./, "")}${entry.path}`;
if (action === "delete") { if (action === "delete") {
await chrome.cookies.remove({ url, name: entry.name }); await cookies.remove({ url, name: entry.name });
return; return;
} }
const details: chrome.cookies.SetDetails = { const details: Record<string, any> = {
url, url,
name: entry.name, name: entry.name,
value: entry.value, value: entry.value,
@@ -264,6 +266,6 @@ export class SyncEngine {
details.expirationDate = new Date(entry.expiresAt).getTime() / 1000; 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. * Options page script — manages extension settings.
* Uses the browser-agnostic compat layer.
*/ */
import { runtime, storage } from "../lib/compat";
export {}; export {};
// --- Messaging helper --- // --- Messaging helper ---
function sendMessage(type: string, payload?: unknown): Promise<any> { async function sendMessage(type: string, payload?: unknown): Promise<any> {
return new Promise((resolve, reject) => { const response = await runtime.sendMessage({ type, payload });
chrome.runtime.sendMessage({ type, payload }, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
}
if (response?.error) { if (response?.error) {
reject(new Error(response.error)); throw new Error(response.error);
return;
} }
resolve(response); return response;
});
});
} }
// --- Elements --- // --- Elements ---
@@ -59,7 +54,7 @@ let blacklist: string[] = [];
// --- Load Settings --- // --- Load Settings ---
async function loadSettings() { async function loadSettings() {
const state = await chrome.storage.local.get(null); const state = await storage.get(null);
optDeviceName.textContent = state.deviceName || "—"; optDeviceName.textContent = state.deviceName || "—";
optDeviceId.textContent = state.deviceId || "—"; optDeviceId.textContent = state.deviceId || "—";
@@ -168,7 +163,7 @@ btnAddBlacklist.addEventListener("click", () => {
// Save // Save
btnSave.addEventListener("click", async () => { btnSave.addEventListener("click", async () => {
await chrome.storage.local.set({ await storage.set({
serverUrl: optServerUrl.value.trim(), serverUrl: optServerUrl.value.trim(),
connectionMode: optConnectionMode.value, connectionMode: optConnectionMode.value,
pollIntervalSec: parseInt(optPollInterval.value), pollIntervalSec: parseInt(optPollInterval.value),
@@ -247,7 +242,7 @@ btnClearData.addEventListener("click", async () => {
return; return;
} }
await sendMessage("LOGOUT"); await sendMessage("LOGOUT");
await chrome.storage.local.clear(); await storage.clear();
alert("All data cleared."); alert("All data cleared.");
window.close(); window.close();
}); });

View File

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