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/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
build/
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
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",
|
"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": {
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
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 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 *.). */
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 ---
|
||||||
|
|||||||
Reference in New Issue
Block a user