diff --git a/extension/.gitignore b/extension/.gitignore index b947077..d93463d 100644 --- a/extension/.gitignore +++ b/extension/.gitignore @@ -1,2 +1,3 @@ node_modules/ dist/ +build/ diff --git a/extension/esbuild.config.mjs b/extension/esbuild.config.mjs index 695c1f8..e3c4b8f 100644 --- a/extension/esbuild.config.mjs +++ b/extension/esbuild.config.mjs @@ -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); } diff --git a/extension/manifests/chrome.json b/extension/manifests/chrome.json new file mode 100644 index 0000000..4a8efe1 --- /dev/null +++ b/extension/manifests/chrome.json @@ -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": [""], + "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" + } +} diff --git a/extension/manifests/edge.json b/extension/manifests/edge.json new file mode 100644 index 0000000..4a8efe1 --- /dev/null +++ b/extension/manifests/edge.json @@ -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": [""], + "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" + } +} diff --git a/extension/manifests/firefox.json b/extension/manifests/firefox.json new file mode 100644 index 0000000..cf0af71 --- /dev/null +++ b/extension/manifests/firefox.json @@ -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": [""], + "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" + } +} diff --git a/extension/manifests/safari.json b/extension/manifests/safari.json new file mode 100644 index 0000000..6a54f71 --- /dev/null +++ b/extension/manifests/safari.json @@ -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": [""], + "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" + } +} diff --git a/extension/package.json b/extension/package.json index 4242c91..49bcfc8 100644 --- a/extension/package.json +++ b/extension/package.json @@ -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": { diff --git a/extension/src/background/service-worker.ts b/extension/src/background/service-worker.ts index 20e37ef..e8688c6 100644 --- a/extension/src/background/service-worker.ts +++ b/extension/src/background/service-worker.ts @@ -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(); }); diff --git a/extension/src/lib/badge.ts b/extension/src/lib/badge.ts index 205db28..b42ac38 100644 --- a/extension/src/lib/badge.ts +++ b/extension/src/lib/badge.ts @@ -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 { diff --git a/extension/src/lib/compat.ts b/extension/src/lib/compat.ts new file mode 100644 index 0000000..bf9a1e0 --- /dev/null +++ b/extension/src/lib/compat.ts @@ -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(fn: (...args: any[]) => any, ...args: any[]): Promise { + const result = fn(...args); + if (result && typeof result.then === "function") { + return result; + } + return new Promise((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> { + return promisify((k: any, cb?: any) => raw.storage.local.get(k, cb), keys); + }, + async set(items: Record): Promise { + return promisify((i: any, cb?: any) => raw.storage.local.set(i, cb), items); + }, + async clear(): Promise { + 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 { + return promisify( + (d: any, cb?: any) => raw.cookies.getAll(d, cb), + details, + ); + }, + async set(details: Record): Promise { + return promisify( + (d: any, cb?: any) => raw.cookies.set(d, cb), + details, + ); + }, + async remove(details: { url: string; name: string }): Promise { + 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> { + 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 { + // 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 }): Promise { + const api = getActionAPI(); + if (!api) return; + return promisify((d: any, cb?: any) => api.setIcon(d, cb), details); + }, + async setBadgeText(details: { text: string }): Promise { + 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 { + 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"; + } +} diff --git a/extension/src/lib/storage.ts b/extension/src/lib/storage.ts index d0a2095..0bc2bfc 100644 --- a/extension/src/lib/storage.ts +++ b/extension/src/lib/storage.ts @@ -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 { - 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 { export async function setState( partial: Partial, ): Promise { - await chrome.storage.local.set(partial); + await storage.set(partial); } /** Reset all state to defaults. */ export async function clearState(): Promise { - await chrome.storage.local.clear(); + await storage.clear(); } /** Check if a domain matches a pattern (supports leading wildcard *.). */ diff --git a/extension/src/lib/sync.ts b/extension/src/lib/sync.ts index 868ab24..ed6abe0 100644 --- a/extension/src/lib/sync.ts +++ b/extension/src/lib/sync.ts @@ -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(); + private cookieMap = new Map(); 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 = { 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); } } diff --git a/extension/src/options/options.ts b/extension/src/options/options.ts index 2b0563d..ce40ce4 100644 --- a/extension/src/options/options.ts +++ b/extension/src/options/options.ts @@ -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 { - 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 { + 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(); }); diff --git a/extension/src/popup/popup.ts b/extension/src/popup/popup.ts index 8b01929..0748de6 100644 --- a/extension/src/popup/popup.ts +++ b/extension/src/popup/popup.ts @@ -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 { - 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 { + 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 ---