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:
1
extension/.gitignore
vendored
1
extension/.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
32
extension/manifests/chrome.json
Normal file
32
extension/manifests/chrome.json
Normal 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"
|
||||
}
|
||||
}
|
||||
32
extension/manifests/edge.json
Normal file
32
extension/manifests/edge.json
Normal 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"
|
||||
}
|
||||
}
|
||||
41
extension/manifests/firefox.json
Normal file
41
extension/manifests/firefox.json
Normal 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"
|
||||
}
|
||||
}
|
||||
36
extension/manifests/safari.json
Normal file
36
extension/manifests/safari.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,11 @@
|
||||
"description": "CookieBridge Chrome Extension — cross-device cookie sync with E2E encryption",
|
||||
"scripts": {
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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
278
extension/src/lib/compat.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
@@ -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 *.). */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
async function sendMessage(type: string, payload?: unknown): Promise<any> {
|
||||
const response = await runtime.sendMessage({ type, payload });
|
||||
if (response?.error) {
|
||||
reject(new Error(response.error));
|
||||
return;
|
||||
throw new Error(response.error);
|
||||
}
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
async function sendMessage(type: string, payload?: unknown): Promise<any> {
|
||||
const response = await runtime.sendMessage({ type, payload });
|
||||
if (response?.error) {
|
||||
reject(new Error(response.error));
|
||||
return;
|
||||
throw new Error(response.error);
|
||||
}
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
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 ---
|
||||
|
||||
Reference in New Issue
Block a user