feat: implement M2 Chrome browser extension
Build the CookieBridge Chrome extension (Manifest V3) with: - Background service worker: cookie monitoring via chrome.cookies.onChanged, WebSocket connection to relay server with auto-reconnect, HTTP polling fallback, device registration and pairing flow - Browser-compatible crypto: libsodium-wrappers-sumo for XChaCha20-Poly1305 encryption, Ed25519 signing, X25519 key exchange (mirrors server's sodium-native API) - Popup UI: device registration, connection status indicator (gray/blue/ green/red), cookie/device/sync stats, one-click current site sync, whitelist quick-add, device pairing with 6-digit code - Options page: server URL config, connection mode (auto/WS/polling), poll interval slider, auto-sync toggle, domain whitelist/blacklist management, paired device list, key export/import, data clearing - Sync engine: LWW conflict resolution with Lamport clocks (same as server), bidirectional cookie sync with all paired peers, echo suppression to prevent sync loops - Badge management: icon color reflects state (gray=not logged in, blue=connected, green=syncing with count, red=error) - Build system: esbuild bundling for Chrome 120+, TypeScript with strict mode, clean type checking Co-Authored-By: Paperclip <noreply@paperclip.ing>
2
extension/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
31
extension/esbuild.config.mjs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import * as esbuild from "esbuild";
|
||||||
|
|
||||||
|
const isWatch = process.argv.includes("--watch");
|
||||||
|
|
||||||
|
const buildOptions = {
|
||||||
|
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)
|
||||||
|
alias: {
|
||||||
|
"libsodium-wrappers-sumo":
|
||||||
|
"./node_modules/libsodium-wrappers-sumo/dist/modules-sumo/libsodium-wrappers.js",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
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.");
|
||||||
|
}
|
||||||
139
extension/generate-icons.mjs
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
/**
|
||||||
|
* Generate simple PNG icons for the extension.
|
||||||
|
* Creates colored circle icons with a "C" letter.
|
||||||
|
* Run: node generate-icons.mjs
|
||||||
|
*/
|
||||||
|
import { writeFileSync } from "fs";
|
||||||
|
|
||||||
|
// Minimal PNG encoder for simple icons
|
||||||
|
function createPNG(size, r, g, b) {
|
||||||
|
// Create raw RGBA pixel data
|
||||||
|
const pixels = new Uint8Array(size * size * 4);
|
||||||
|
const center = size / 2;
|
||||||
|
const radius = size / 2 - 1;
|
||||||
|
|
||||||
|
for (let y = 0; y < size; y++) {
|
||||||
|
for (let x = 0; x < size; x++) {
|
||||||
|
const idx = (y * size + x) * 4;
|
||||||
|
const dist = Math.sqrt((x - center) ** 2 + (y - center) ** 2);
|
||||||
|
if (dist <= radius) {
|
||||||
|
pixels[idx] = r;
|
||||||
|
pixels[idx + 1] = g;
|
||||||
|
pixels[idx + 2] = b;
|
||||||
|
pixels[idx + 3] = 255;
|
||||||
|
} else if (dist <= radius + 1) {
|
||||||
|
// Anti-aliased edge
|
||||||
|
const alpha = Math.max(0, Math.round((radius + 1 - dist) * 255));
|
||||||
|
pixels[idx] = r;
|
||||||
|
pixels[idx + 1] = g;
|
||||||
|
pixels[idx + 2] = b;
|
||||||
|
pixels[idx + 3] = alpha;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode as PNG
|
||||||
|
return encodePNG(size, size, pixels);
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodePNG(width, height, pixels) {
|
||||||
|
const SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||||
|
|
||||||
|
function crc32(buf) {
|
||||||
|
let c = 0xffffffff;
|
||||||
|
for (let i = 0; i < buf.length; i++) {
|
||||||
|
c ^= buf[i];
|
||||||
|
for (let j = 0; j < 8; j++) {
|
||||||
|
c = (c >>> 1) ^ (c & 1 ? 0xedb88320 : 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (c ^ 0xffffffff) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chunk(type, data) {
|
||||||
|
const typeBytes = Buffer.from(type);
|
||||||
|
const len = Buffer.alloc(4);
|
||||||
|
len.writeUInt32BE(data.length);
|
||||||
|
const combined = Buffer.concat([typeBytes, data]);
|
||||||
|
const crc = Buffer.alloc(4);
|
||||||
|
crc.writeUInt32BE(crc32(combined));
|
||||||
|
return Buffer.concat([len, combined, crc]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function adler32(buf) {
|
||||||
|
let a = 1, b = 0;
|
||||||
|
for (let i = 0; i < buf.length; i++) {
|
||||||
|
a = (a + buf[i]) % 65521;
|
||||||
|
b = (b + a) % 65521;
|
||||||
|
}
|
||||||
|
return ((b << 16) | a) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// IHDR
|
||||||
|
const ihdr = Buffer.alloc(13);
|
||||||
|
ihdr.writeUInt32BE(width, 0);
|
||||||
|
ihdr.writeUInt32BE(height, 4);
|
||||||
|
ihdr[8] = 8; // bit depth
|
||||||
|
ihdr[9] = 6; // RGBA
|
||||||
|
ihdr[10] = 0; // compression
|
||||||
|
ihdr[11] = 0; // filter
|
||||||
|
ihdr[12] = 0; // interlace
|
||||||
|
|
||||||
|
// IDAT - raw pixel data with filter bytes
|
||||||
|
const rawData = Buffer.alloc(height * (1 + width * 4));
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
rawData[y * (1 + width * 4)] = 0; // no filter
|
||||||
|
for (let x = 0; x < width * 4; x++) {
|
||||||
|
rawData[y * (1 + width * 4) + 1 + x] = pixels[y * width * 4 + x];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deflate with store (no compression) - simple but works
|
||||||
|
const blocks = [];
|
||||||
|
let offset = 0;
|
||||||
|
while (offset < rawData.length) {
|
||||||
|
const remaining = rawData.length - offset;
|
||||||
|
const blockSize = Math.min(remaining, 65535);
|
||||||
|
const isLast = offset + blockSize >= rawData.length;
|
||||||
|
const header = Buffer.alloc(5);
|
||||||
|
header[0] = isLast ? 1 : 0;
|
||||||
|
header.writeUInt16LE(blockSize, 1);
|
||||||
|
header.writeUInt16LE(blockSize ^ 0xffff, 3);
|
||||||
|
blocks.push(header);
|
||||||
|
blocks.push(rawData.subarray(offset, offset + blockSize));
|
||||||
|
offset += blockSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
const zlibHeader = Buffer.from([0x78, 0x01]); // deflate, no compression
|
||||||
|
const adler = Buffer.alloc(4);
|
||||||
|
adler.writeUInt32BE(adler32(rawData));
|
||||||
|
const compressed = Buffer.concat([zlibHeader, ...blocks, adler]);
|
||||||
|
|
||||||
|
// IEND
|
||||||
|
const iend = Buffer.alloc(0);
|
||||||
|
|
||||||
|
return Buffer.concat([
|
||||||
|
SIGNATURE,
|
||||||
|
chunk("IHDR", ihdr),
|
||||||
|
chunk("IDAT", compressed),
|
||||||
|
chunk("IEND", iend),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const colors = {
|
||||||
|
gray: [156, 163, 175],
|
||||||
|
blue: [59, 130, 246],
|
||||||
|
green: [34, 197, 94],
|
||||||
|
red: [239, 68, 68],
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizes = [16, 48, 128];
|
||||||
|
|
||||||
|
for (const [name, [r, g, b]] of Object.entries(colors)) {
|
||||||
|
for (const size of sizes) {
|
||||||
|
const png = createPNG(size, r, g, b);
|
||||||
|
const path = `src/icons/icon-${name}-${size}.png`;
|
||||||
|
writeFileSync(path, png);
|
||||||
|
console.log(`Generated ${path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
32
extension/manifest.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"
|
||||||
|
}
|
||||||
|
}
|
||||||
550
extension/package-lock.json
generated
Normal file
@@ -0,0 +1,550 @@
|
|||||||
|
{
|
||||||
|
"name": "cookiebridge-extension",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "cookiebridge-extension",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dependencies": {
|
||||||
|
"libsodium-wrappers-sumo": "^0.7.15"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/chrome": "^0.0.287",
|
||||||
|
"esbuild": "^0.24.2",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/chrome": {
|
||||||
|
"version": "0.0.287",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.287.tgz",
|
||||||
|
"integrity": "sha512-wWhBNPNXZHwycHKNYnexUcpSbrihVZu++0rdp6GEk5ZgAglenLx+RwdEouh6FrHS0XQiOxSd62yaujM1OoQlZQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/filesystem": "*",
|
||||||
|
"@types/har-format": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/filesystem": {
|
||||||
|
"version": "0.0.36",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz",
|
||||||
|
"integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/filewriter": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/filewriter": {
|
||||||
|
"version": "0.0.33",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.33.tgz",
|
||||||
|
"integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/har-format": {
|
||||||
|
"version": "1.2.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz",
|
||||||
|
"integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/esbuild": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.24.2",
|
||||||
|
"@esbuild/android-arm": "0.24.2",
|
||||||
|
"@esbuild/android-arm64": "0.24.2",
|
||||||
|
"@esbuild/android-x64": "0.24.2",
|
||||||
|
"@esbuild/darwin-arm64": "0.24.2",
|
||||||
|
"@esbuild/darwin-x64": "0.24.2",
|
||||||
|
"@esbuild/freebsd-arm64": "0.24.2",
|
||||||
|
"@esbuild/freebsd-x64": "0.24.2",
|
||||||
|
"@esbuild/linux-arm": "0.24.2",
|
||||||
|
"@esbuild/linux-arm64": "0.24.2",
|
||||||
|
"@esbuild/linux-ia32": "0.24.2",
|
||||||
|
"@esbuild/linux-loong64": "0.24.2",
|
||||||
|
"@esbuild/linux-mips64el": "0.24.2",
|
||||||
|
"@esbuild/linux-ppc64": "0.24.2",
|
||||||
|
"@esbuild/linux-riscv64": "0.24.2",
|
||||||
|
"@esbuild/linux-s390x": "0.24.2",
|
||||||
|
"@esbuild/linux-x64": "0.24.2",
|
||||||
|
"@esbuild/netbsd-arm64": "0.24.2",
|
||||||
|
"@esbuild/netbsd-x64": "0.24.2",
|
||||||
|
"@esbuild/openbsd-arm64": "0.24.2",
|
||||||
|
"@esbuild/openbsd-x64": "0.24.2",
|
||||||
|
"@esbuild/sunos-x64": "0.24.2",
|
||||||
|
"@esbuild/win32-arm64": "0.24.2",
|
||||||
|
"@esbuild/win32-ia32": "0.24.2",
|
||||||
|
"@esbuild/win32-x64": "0.24.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/libsodium-sumo": {
|
||||||
|
"version": "0.7.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/libsodium-sumo/-/libsodium-sumo-0.7.16.tgz",
|
||||||
|
"integrity": "sha512-x6atrz2AdXCJg6G709x9W9TTJRI6/0NcL5dD0l5GGVqNE48UJmDsjO4RUWYTeyXXUpg+NXZ2SHECaZnFRYzwGA==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/libsodium-wrappers-sumo": {
|
||||||
|
"version": "0.7.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/libsodium-wrappers-sumo/-/libsodium-wrappers-sumo-0.7.16.tgz",
|
||||||
|
"integrity": "sha512-gR0JEFPeN3831lB9+ogooQk0KH4K5LSMIO5Prd5Q5XYR2wHFtZfPg0eP7t1oJIWq+UIzlU4WVeBxZ97mt28tXw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"libsodium-sumo": "^0.7.16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
extension/package.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "cookiebridge-extension",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "CookieBridge Chrome Extension — cross-device cookie sync with E2E encryption",
|
||||||
|
"scripts": {
|
||||||
|
"build": "node esbuild.config.mjs",
|
||||||
|
"watch": "node esbuild.config.mjs --watch",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"libsodium-wrappers-sumo": "^0.7.15"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/chrome": "^0.0.287",
|
||||||
|
"esbuild": "^0.24.2",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
315
extension/src/background/service-worker.ts
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
/**
|
||||||
|
* CookieBridge background service worker.
|
||||||
|
* Manages device identity, connection lifecycle, and cookie sync.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
generateKeyPair,
|
||||||
|
deviceIdFromKeys,
|
||||||
|
serializeKeyPair,
|
||||||
|
deserializeKeyPair,
|
||||||
|
type DeviceKeyPair,
|
||||||
|
} from "../lib/crypto";
|
||||||
|
import { getState, setState, type PeerDevice } from "../lib/storage";
|
||||||
|
import { ConnectionManager, type ConnectionStatus } from "../lib/connection";
|
||||||
|
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";
|
||||||
|
|
||||||
|
let connection: ConnectionManager | null = null;
|
||||||
|
let syncEngine: SyncEngine | null = null;
|
||||||
|
let api: ApiClient | null = null;
|
||||||
|
let pollAlarmName = "cookiebridge-poll";
|
||||||
|
|
||||||
|
// --- Initialization ---
|
||||||
|
|
||||||
|
async function initialize() {
|
||||||
|
const state = await getState();
|
||||||
|
|
||||||
|
if (!state.keys || !state.apiToken) {
|
||||||
|
await setIconState("not_logged_in");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = deserializeKeyPair(state.keys);
|
||||||
|
api = new ApiClient(state.serverUrl, state.apiToken);
|
||||||
|
|
||||||
|
connection = new ConnectionManager(state.serverUrl, state.apiToken, keys, {
|
||||||
|
onMessage: (envelope) => handleIncomingMessage(envelope, state.peers),
|
||||||
|
onStatusChange: handleStatusChange,
|
||||||
|
});
|
||||||
|
|
||||||
|
syncEngine = new SyncEngine(keys, connection, api);
|
||||||
|
syncEngine.start();
|
||||||
|
|
||||||
|
if (state.connectionMode === "polling") {
|
||||||
|
startPolling(state.pollIntervalSec);
|
||||||
|
} else {
|
||||||
|
connection.connect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStatusChange(status: ConnectionStatus) {
|
||||||
|
switch (status) {
|
||||||
|
case "connected":
|
||||||
|
setIconState("connected");
|
||||||
|
break;
|
||||||
|
case "disconnected":
|
||||||
|
case "connecting":
|
||||||
|
setIconState("not_logged_in");
|
||||||
|
break;
|
||||||
|
case "error":
|
||||||
|
setIconState("error");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleIncomingMessage(envelope: Envelope, peers: PeerDevice[]) {
|
||||||
|
if (
|
||||||
|
envelope.type === MESSAGE_TYPES.COOKIE_SYNC ||
|
||||||
|
envelope.type === MESSAGE_TYPES.COOKIE_DELETE
|
||||||
|
) {
|
||||||
|
if (!syncEngine) return;
|
||||||
|
await syncEngine.handleIncomingEnvelope(envelope, peers);
|
||||||
|
await setIconState("syncing", 1);
|
||||||
|
clearSyncBadge();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPolling(intervalSec: number) {
|
||||||
|
chrome.alarms.create(pollAlarmName, {
|
||||||
|
periodInMinutes: Math.max(intervalSec / 60, 1 / 60),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPolling() {
|
||||||
|
chrome.alarms.clear(pollAlarmName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Alarm handler for HTTP polling ---
|
||||||
|
|
||||||
|
chrome.alarms.onAlarm.addListener(async (alarm) => {
|
||||||
|
if (alarm.name !== pollAlarmName) return;
|
||||||
|
if (!api) return;
|
||||||
|
|
||||||
|
const state = await getState();
|
||||||
|
if (!state.lastSyncAt) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updates = await api.pullUpdates(state.lastSyncAt);
|
||||||
|
if (updates.length > 0) {
|
||||||
|
await setState({ lastSyncAt: new Date().toISOString() });
|
||||||
|
await setIconState("syncing", updates.length);
|
||||||
|
clearSyncBadge();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Polling failure — will retry on next alarm
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Message handling from popup/options ---
|
||||||
|
|
||||||
|
export interface ExtensionMessage {
|
||||||
|
type: string;
|
||||||
|
payload?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
chrome.runtime.onMessage.addListener(
|
||||||
|
(message: ExtensionMessage, _sender, sendResponse) => {
|
||||||
|
handleMessage(message).then(sendResponse).catch((err) => {
|
||||||
|
sendResponse({ error: err.message });
|
||||||
|
});
|
||||||
|
return true; // async response
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleMessage(msg: ExtensionMessage): Promise<unknown> {
|
||||||
|
switch (msg.type) {
|
||||||
|
case "GET_STATUS":
|
||||||
|
return getStatus();
|
||||||
|
|
||||||
|
case "REGISTER_DEVICE":
|
||||||
|
return registerDevice(msg.payload as { name: string });
|
||||||
|
|
||||||
|
case "INITIATE_PAIRING":
|
||||||
|
return initiatePairing();
|
||||||
|
|
||||||
|
case "ACCEPT_PAIRING":
|
||||||
|
return acceptPairing(msg.payload as { code: string });
|
||||||
|
|
||||||
|
case "SYNC_CURRENT_TAB":
|
||||||
|
if (syncEngine) await syncEngine.syncCurrentTab();
|
||||||
|
return { ok: true };
|
||||||
|
|
||||||
|
case "SYNC_DOMAIN":
|
||||||
|
if (syncEngine) {
|
||||||
|
await syncEngine.syncDomain(
|
||||||
|
(msg.payload as { domain: string }).domain,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
|
||||||
|
case "ADD_WHITELIST": {
|
||||||
|
const state = await getState();
|
||||||
|
const domain = (msg.payload as { domain: string }).domain;
|
||||||
|
if (!state.whitelist.includes(domain)) {
|
||||||
|
await setState({ whitelist: [...state.whitelist, domain] });
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
case "DISCONNECT":
|
||||||
|
connection?.disconnect();
|
||||||
|
stopPolling();
|
||||||
|
return { ok: true };
|
||||||
|
|
||||||
|
case "RECONNECT":
|
||||||
|
await initialize();
|
||||||
|
return { ok: true };
|
||||||
|
|
||||||
|
case "EXPORT_KEYS": {
|
||||||
|
const state = await getState();
|
||||||
|
return { keys: state.keys };
|
||||||
|
}
|
||||||
|
|
||||||
|
case "IMPORT_KEYS": {
|
||||||
|
const keys = msg.payload as {
|
||||||
|
signPub: string;
|
||||||
|
signSec: string;
|
||||||
|
encPub: string;
|
||||||
|
encSec: string;
|
||||||
|
};
|
||||||
|
const kp = deserializeKeyPair(keys);
|
||||||
|
await setState({
|
||||||
|
keys,
|
||||||
|
deviceId: deviceIdFromKeys(kp),
|
||||||
|
});
|
||||||
|
await initialize();
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
case "LOGOUT":
|
||||||
|
connection?.disconnect();
|
||||||
|
syncEngine?.stop();
|
||||||
|
stopPolling();
|
||||||
|
await setState({
|
||||||
|
keys: null,
|
||||||
|
deviceId: null,
|
||||||
|
deviceName: null,
|
||||||
|
apiToken: null,
|
||||||
|
peers: [],
|
||||||
|
syncCount: 0,
|
||||||
|
lastSyncAt: null,
|
||||||
|
lamportClock: 0,
|
||||||
|
});
|
||||||
|
await setIconState("not_logged_in");
|
||||||
|
return { ok: true };
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown message type: ${msg.type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStatus(): Promise<{
|
||||||
|
loggedIn: boolean;
|
||||||
|
deviceId: string | null;
|
||||||
|
deviceName: string | null;
|
||||||
|
connectionStatus: ConnectionStatus;
|
||||||
|
peerCount: number;
|
||||||
|
syncCount: number;
|
||||||
|
lastSyncAt: string | null;
|
||||||
|
cookieCount: number;
|
||||||
|
}> {
|
||||||
|
const state = await getState();
|
||||||
|
|
||||||
|
// Count tracked cookies
|
||||||
|
let cookieCount = 0;
|
||||||
|
if (state.whitelist.length > 0) {
|
||||||
|
for (const domain of state.whitelist) {
|
||||||
|
const cookies = await chrome.cookies.getAll({ domain });
|
||||||
|
cookieCount += cookies.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
loggedIn: !!state.apiToken,
|
||||||
|
deviceId: state.deviceId,
|
||||||
|
deviceName: state.deviceName,
|
||||||
|
connectionStatus: connection?.status ?? "disconnected",
|
||||||
|
peerCount: state.peers.length,
|
||||||
|
syncCount: state.syncCount,
|
||||||
|
lastSyncAt: state.lastSyncAt,
|
||||||
|
cookieCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registerDevice(params: { name: string }) {
|
||||||
|
const keys = await generateKeyPair();
|
||||||
|
const serialized = serializeKeyPair(keys);
|
||||||
|
const deviceId = deviceIdFromKeys(keys);
|
||||||
|
|
||||||
|
const state = await getState();
|
||||||
|
const client = new ApiClient(state.serverUrl);
|
||||||
|
const device = await client.registerDevice({
|
||||||
|
deviceId,
|
||||||
|
name: params.name,
|
||||||
|
platform: "chrome-extension",
|
||||||
|
encPub: serialized.encPub,
|
||||||
|
});
|
||||||
|
|
||||||
|
await setState({
|
||||||
|
keys: serialized,
|
||||||
|
deviceId,
|
||||||
|
deviceName: params.name,
|
||||||
|
apiToken: device.token,
|
||||||
|
});
|
||||||
|
|
||||||
|
await initialize();
|
||||||
|
return { deviceId, name: params.name };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initiatePairing() {
|
||||||
|
const state = await getState();
|
||||||
|
if (!state.keys || !api) throw new Error("Not registered");
|
||||||
|
|
||||||
|
const result = await api.initiatePairing(
|
||||||
|
state.deviceId!,
|
||||||
|
state.keys.encPub,
|
||||||
|
);
|
||||||
|
return { pairingCode: result.pairingCode };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function acceptPairing(params: { code: string }) {
|
||||||
|
const state = await getState();
|
||||||
|
if (!state.keys || !api) throw new Error("Not registered");
|
||||||
|
|
||||||
|
const result = await api.acceptPairing(
|
||||||
|
state.deviceId!,
|
||||||
|
state.keys.encPub,
|
||||||
|
params.code,
|
||||||
|
);
|
||||||
|
|
||||||
|
const peer: PeerDevice = {
|
||||||
|
deviceId: result.peerDeviceId,
|
||||||
|
name: "Paired Device",
|
||||||
|
platform: "unknown",
|
||||||
|
encPub: result.peerX25519PubKey,
|
||||||
|
pairedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await setState({ peers: [...state.peers, peer] });
|
||||||
|
return { peerId: result.peerDeviceId };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Start on install/startup ---
|
||||||
|
|
||||||
|
chrome.runtime.onInstalled.addListener(() => {
|
||||||
|
initialize();
|
||||||
|
});
|
||||||
|
|
||||||
|
chrome.runtime.onStartup.addListener(() => {
|
||||||
|
initialize();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize immediately for service worker restart
|
||||||
|
initialize();
|
||||||
45
extension/src/icons/generate-icons.html
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><title>Generate CookieBridge Icons</title></head>
|
||||||
|
<body>
|
||||||
|
<script>
|
||||||
|
// Run this in a browser to generate icon PNGs, or use the canvas-based approach in service worker
|
||||||
|
const sizes = [16, 48, 128];
|
||||||
|
const colors = {
|
||||||
|
gray: '#9CA3AF',
|
||||||
|
blue: '#3B82F6',
|
||||||
|
green: '#22C55E',
|
||||||
|
red: '#EF4444',
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [colorName, color] of Object.entries(colors)) {
|
||||||
|
for (const size of sizes) {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = size;
|
||||||
|
canvas.height = size;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
// Background circle
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(size / 2, size / 2, size / 2 - 1, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Cookie icon (simplified)
|
||||||
|
ctx.fillStyle = '#FFFFFF';
|
||||||
|
ctx.font = `bold ${size * 0.5}px sans-serif`;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText('C', size / 2, size / 2);
|
||||||
|
|
||||||
|
// Download
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.download = `icon-${colorName}-${size}.png`;
|
||||||
|
link.href = canvas.toDataURL('image/png');
|
||||||
|
link.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.body.textContent = 'Icons generated! Check downloads.';
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
extension/src/icons/icon-blue-128.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
extension/src/icons/icon-blue-16.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
extension/src/icons/icon-blue-48.png
Normal file
|
After Width: | Height: | Size: 9.1 KiB |
BIN
extension/src/icons/icon-gray-128.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
extension/src/icons/icon-gray-16.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
extension/src/icons/icon-gray-48.png
Normal file
|
After Width: | Height: | Size: 9.1 KiB |
BIN
extension/src/icons/icon-green-128.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
extension/src/icons/icon-green-16.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
extension/src/icons/icon-green-48.png
Normal file
|
After Width: | Height: | Size: 9.1 KiB |
BIN
extension/src/icons/icon-red-128.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
extension/src/icons/icon-red-16.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
extension/src/icons/icon-red-48.png
Normal file
|
After Width: | Height: | Size: 9.1 KiB |
119
extension/src/lib/api-client.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
/**
|
||||||
|
* HTTP client for the CookieBridge relay server REST API.
|
||||||
|
*/
|
||||||
|
import type {
|
||||||
|
DeviceRegisterRequest,
|
||||||
|
DeviceInfo,
|
||||||
|
PairingResult,
|
||||||
|
EncryptedCookieBlob,
|
||||||
|
} from "./protocol";
|
||||||
|
|
||||||
|
export class ApiClient {
|
||||||
|
constructor(
|
||||||
|
private baseUrl: string,
|
||||||
|
private token: string | null = null,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
setToken(token: string) {
|
||||||
|
this.token = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBaseUrl(url: string) {
|
||||||
|
this.baseUrl = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request<T>(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
): Promise<T> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
if (this.token) {
|
||||||
|
headers["Authorization"] = `Bearer ${this.token}`;
|
||||||
|
}
|
||||||
|
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
throw new Error(`API ${method} ${path}: ${res.status} ${text}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Device Registration ---
|
||||||
|
|
||||||
|
async registerDevice(req: DeviceRegisterRequest): Promise<DeviceInfo> {
|
||||||
|
return this.request("POST", "/api/devices/register", req);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Pairing ---
|
||||||
|
|
||||||
|
async initiatePairing(
|
||||||
|
deviceId: string,
|
||||||
|
x25519PubKey: string,
|
||||||
|
): Promise<{ pairingCode: string }> {
|
||||||
|
return this.request("POST", "/api/pair", { deviceId, x25519PubKey });
|
||||||
|
}
|
||||||
|
|
||||||
|
async acceptPairing(
|
||||||
|
deviceId: string,
|
||||||
|
x25519PubKey: string,
|
||||||
|
pairingCode: string,
|
||||||
|
): Promise<PairingResult> {
|
||||||
|
return this.request("POST", "/api/pair/accept", {
|
||||||
|
deviceId,
|
||||||
|
x25519PubKey,
|
||||||
|
pairingCode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Cookie Storage (HTTP polling) ---
|
||||||
|
|
||||||
|
async pushCookies(
|
||||||
|
blobs: Array<{
|
||||||
|
domain: string;
|
||||||
|
cookieName: string;
|
||||||
|
path: string;
|
||||||
|
nonce: string;
|
||||||
|
ciphertext: string;
|
||||||
|
lamportTs: number;
|
||||||
|
}>,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.request("POST", "/api/cookies", { cookies: blobs });
|
||||||
|
}
|
||||||
|
|
||||||
|
async pullCookies(domain?: string): Promise<EncryptedCookieBlob[]> {
|
||||||
|
const params = domain ? `?domain=${encodeURIComponent(domain)}` : "";
|
||||||
|
return this.request("GET", `/api/cookies${params}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async pullUpdates(since: string): Promise<EncryptedCookieBlob[]> {
|
||||||
|
return this.request(
|
||||||
|
"GET",
|
||||||
|
`/api/cookies/updates?since=${encodeURIComponent(since)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteCookie(
|
||||||
|
domain: string,
|
||||||
|
cookieName: string,
|
||||||
|
path: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.request("DELETE", "/api/cookies", {
|
||||||
|
domain,
|
||||||
|
cookieName,
|
||||||
|
path,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Health ---
|
||||||
|
|
||||||
|
async health(): Promise<{ status: string; connections: number }> {
|
||||||
|
return this.request("GET", "/health");
|
||||||
|
}
|
||||||
|
}
|
||||||
69
extension/src/lib/badge.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Badge/icon management — updates extension icon color and badge text
|
||||||
|
* based on connection status and sync activity.
|
||||||
|
*
|
||||||
|
* States:
|
||||||
|
* - gray: Not logged in / no device identity
|
||||||
|
* - blue: Connected, idle
|
||||||
|
* - green: Syncing (with count badge)
|
||||||
|
* - red: Error / disconnected
|
||||||
|
*/
|
||||||
|
|
||||||
|
type IconColor = "gray" | "blue" | "green" | "red";
|
||||||
|
|
||||||
|
function iconPath(color: IconColor, size: number): string {
|
||||||
|
return `src/icons/icon-${color}-${size}.png`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function iconSet(color: IconColor): Record<string, string> {
|
||||||
|
return {
|
||||||
|
"16": iconPath(color, 16),
|
||||||
|
"48": iconPath(color, 48),
|
||||||
|
"128": iconPath(color, 128),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setIconState(
|
||||||
|
state: "not_logged_in" | "connected" | "syncing" | "error",
|
||||||
|
syncCount?: number,
|
||||||
|
) {
|
||||||
|
switch (state) {
|
||||||
|
case "not_logged_in":
|
||||||
|
await chrome.action.setIcon({ path: iconSet("gray") });
|
||||||
|
await chrome.action.setBadgeText({ text: "" });
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "connected":
|
||||||
|
await chrome.action.setIcon({ path: iconSet("blue") });
|
||||||
|
await chrome.action.setBadgeText({ text: "" });
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "syncing":
|
||||||
|
await chrome.action.setIcon({ path: iconSet("green") });
|
||||||
|
if (syncCount && syncCount > 0) {
|
||||||
|
await chrome.action.setBadgeText({
|
||||||
|
text: syncCount > 99 ? "99+" : String(syncCount),
|
||||||
|
});
|
||||||
|
await chrome.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" });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear the sync badge after a delay. */
|
||||||
|
export function clearSyncBadge(delayMs = 3000) {
|
||||||
|
setTimeout(async () => {
|
||||||
|
const state = await chrome.storage.local.get(["apiToken"]);
|
||||||
|
if (state.apiToken) {
|
||||||
|
await setIconState("connected");
|
||||||
|
} else {
|
||||||
|
await setIconState("not_logged_in");
|
||||||
|
}
|
||||||
|
}, delayMs);
|
||||||
|
}
|
||||||
168
extension/src/lib/connection.ts
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
/**
|
||||||
|
* Connection manager — handles WebSocket and HTTP polling connections to the relay server.
|
||||||
|
*/
|
||||||
|
import { toHex } from "./hex";
|
||||||
|
import type { DeviceKeyPair } from "./crypto";
|
||||||
|
import { deviceIdFromKeys, sign } from "./crypto";
|
||||||
|
import {
|
||||||
|
MESSAGE_TYPES,
|
||||||
|
PING_INTERVAL_MS,
|
||||||
|
PONG_TIMEOUT_MS,
|
||||||
|
type Envelope,
|
||||||
|
} from "./protocol";
|
||||||
|
|
||||||
|
export type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error";
|
||||||
|
|
||||||
|
export interface ConnectionEvents {
|
||||||
|
onMessage: (envelope: Envelope) => void;
|
||||||
|
onStatusChange: (status: ConnectionStatus) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ConnectionManager {
|
||||||
|
private ws: WebSocket | null = null;
|
||||||
|
private pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private pongTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private reconnectDelay = 1000;
|
||||||
|
private _status: ConnectionStatus = "disconnected";
|
||||||
|
private serverUrl: string;
|
||||||
|
private token: string;
|
||||||
|
private keys: DeviceKeyPair;
|
||||||
|
private events: ConnectionEvents;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
serverUrl: string,
|
||||||
|
token: string,
|
||||||
|
keys: DeviceKeyPair,
|
||||||
|
events: ConnectionEvents,
|
||||||
|
) {
|
||||||
|
this.serverUrl = serverUrl;
|
||||||
|
this.token = token;
|
||||||
|
this.keys = keys;
|
||||||
|
this.events = events;
|
||||||
|
}
|
||||||
|
|
||||||
|
get status(): ConnectionStatus {
|
||||||
|
return this._status;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setStatus(s: ConnectionStatus) {
|
||||||
|
this._status = s;
|
||||||
|
this.events.onStatusChange(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Connect via WebSocket. */
|
||||||
|
connect() {
|
||||||
|
if (this.ws) this.disconnect();
|
||||||
|
|
||||||
|
this.setStatus("connecting");
|
||||||
|
const wsUrl = this.serverUrl
|
||||||
|
.replace(/^http/, "ws")
|
||||||
|
.replace(/\/$/, "");
|
||||||
|
this.ws = new WebSocket(`${wsUrl}/ws?token=${encodeURIComponent(this.token)}`);
|
||||||
|
|
||||||
|
this.ws.onopen = () => {
|
||||||
|
this.setStatus("connected");
|
||||||
|
this.reconnectDelay = 1000;
|
||||||
|
this.startPing();
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data as string);
|
||||||
|
if (data.type === MESSAGE_TYPES.PONG) {
|
||||||
|
this.handlePong();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.type === MESSAGE_TYPES.PING) {
|
||||||
|
this.sendRaw({ type: MESSAGE_TYPES.PONG });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.events.onMessage(data as Envelope);
|
||||||
|
} catch {
|
||||||
|
// Ignore malformed messages
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onclose = () => {
|
||||||
|
this.cleanup();
|
||||||
|
this.setStatus("disconnected");
|
||||||
|
this.scheduleReconnect();
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onerror = () => {
|
||||||
|
this.cleanup();
|
||||||
|
this.setStatus("error");
|
||||||
|
this.scheduleReconnect();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
if (this.reconnectTimer) {
|
||||||
|
clearTimeout(this.reconnectTimer);
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
}
|
||||||
|
this.cleanup();
|
||||||
|
if (this.ws) {
|
||||||
|
this.ws.close();
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
this.setStatus("disconnected");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send an envelope over WebSocket. */
|
||||||
|
send(envelope: Envelope) {
|
||||||
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||||
|
this.ws.send(JSON.stringify(envelope));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendRaw(data: unknown) {
|
||||||
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||||
|
this.ws.send(JSON.stringify(data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private startPing() {
|
||||||
|
this.stopPing();
|
||||||
|
this.pingTimer = setInterval(() => {
|
||||||
|
this.sendRaw({ type: MESSAGE_TYPES.PING });
|
||||||
|
this.pongTimer = setTimeout(() => {
|
||||||
|
// No pong received, connection is dead
|
||||||
|
this.ws?.close();
|
||||||
|
}, PONG_TIMEOUT_MS);
|
||||||
|
}, PING_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopPing() {
|
||||||
|
if (this.pingTimer) {
|
||||||
|
clearInterval(this.pingTimer);
|
||||||
|
this.pingTimer = null;
|
||||||
|
}
|
||||||
|
if (this.pongTimer) {
|
||||||
|
clearTimeout(this.pongTimer);
|
||||||
|
this.pongTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handlePong() {
|
||||||
|
if (this.pongTimer) {
|
||||||
|
clearTimeout(this.pongTimer);
|
||||||
|
this.pongTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanup() {
|
||||||
|
this.stopPing();
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleReconnect() {
|
||||||
|
if (this.reconnectTimer) return;
|
||||||
|
this.reconnectTimer = setTimeout(() => {
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
this.connect();
|
||||||
|
}, this.reconnectDelay);
|
||||||
|
// Exponential backoff, max 30s
|
||||||
|
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000);
|
||||||
|
}
|
||||||
|
}
|
||||||
217
extension/src/lib/crypto.ts
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
/**
|
||||||
|
* Browser-compatible crypto module using libsodium-wrappers-sumo.
|
||||||
|
* Mirrors the server's sodium-native API but runs in the browser.
|
||||||
|
*/
|
||||||
|
import _sodium from "libsodium-wrappers-sumo";
|
||||||
|
import { toHex, fromHex, toBase64, fromBase64 } from "./hex";
|
||||||
|
|
||||||
|
let sodiumReady: Promise<typeof _sodium> | null = null;
|
||||||
|
|
||||||
|
async function getSodium(): Promise<typeof _sodium> {
|
||||||
|
if (!sodiumReady) {
|
||||||
|
sodiumReady = _sodium.ready.then(() => _sodium);
|
||||||
|
}
|
||||||
|
return sodiumReady;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Key Types ---
|
||||||
|
|
||||||
|
export interface DeviceKeyPair {
|
||||||
|
signPub: Uint8Array;
|
||||||
|
signSec: Uint8Array;
|
||||||
|
encPub: Uint8Array;
|
||||||
|
encSec: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SerializedKeyPair {
|
||||||
|
signPub: string; // hex
|
||||||
|
signSec: string; // hex
|
||||||
|
encPub: string; // hex
|
||||||
|
encSec: string; // hex
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Key Generation ---
|
||||||
|
|
||||||
|
export async function generateKeyPair(): Promise<DeviceKeyPair> {
|
||||||
|
const sodium = await getSodium();
|
||||||
|
const signKp = sodium.crypto_sign_keypair();
|
||||||
|
const encKp = sodium.crypto_box_keypair();
|
||||||
|
return {
|
||||||
|
signPub: signKp.publicKey,
|
||||||
|
signSec: signKp.privateKey,
|
||||||
|
encPub: encKp.publicKey,
|
||||||
|
encSec: encKp.privateKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deviceIdFromKeys(keys: DeviceKeyPair): string {
|
||||||
|
return toHex(keys.signPub);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeKeyPair(keys: DeviceKeyPair): SerializedKeyPair {
|
||||||
|
return {
|
||||||
|
signPub: toHex(keys.signPub),
|
||||||
|
signSec: toHex(keys.signSec),
|
||||||
|
encPub: toHex(keys.encPub),
|
||||||
|
encSec: toHex(keys.encSec),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializeKeyPair(data: SerializedKeyPair): DeviceKeyPair {
|
||||||
|
return {
|
||||||
|
signPub: fromHex(data.signPub),
|
||||||
|
signSec: fromHex(data.signSec),
|
||||||
|
encPub: fromHex(data.encPub),
|
||||||
|
encSec: fromHex(data.encSec),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Encryption ---
|
||||||
|
|
||||||
|
export async function deriveSharedKey(
|
||||||
|
ourEncSec: Uint8Array,
|
||||||
|
peerEncPub: Uint8Array,
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
const sodium = await getSodium();
|
||||||
|
const raw = sodium.crypto_scalarmult(ourEncSec, peerEncPub);
|
||||||
|
return sodium.crypto_generichash(32, raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function encrypt(
|
||||||
|
plaintext: Uint8Array,
|
||||||
|
sharedKey: Uint8Array,
|
||||||
|
): Promise<{ nonce: Uint8Array; ciphertext: Uint8Array }> {
|
||||||
|
const sodium = await getSodium();
|
||||||
|
const nonce = sodium.randombytes_buf(
|
||||||
|
sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES,
|
||||||
|
);
|
||||||
|
const ciphertext = sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(
|
||||||
|
plaintext,
|
||||||
|
null, // no additional data
|
||||||
|
null, // unused nsec
|
||||||
|
nonce,
|
||||||
|
sharedKey,
|
||||||
|
);
|
||||||
|
return { nonce, ciphertext };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decrypt(
|
||||||
|
ciphertext: Uint8Array,
|
||||||
|
nonce: Uint8Array,
|
||||||
|
sharedKey: Uint8Array,
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
const sodium = await getSodium();
|
||||||
|
return sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(
|
||||||
|
null, // unused nsec
|
||||||
|
ciphertext,
|
||||||
|
null, // no additional data
|
||||||
|
nonce,
|
||||||
|
sharedKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Signing ---
|
||||||
|
|
||||||
|
export async function sign(
|
||||||
|
message: Uint8Array,
|
||||||
|
signSec: Uint8Array,
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
const sodium = await getSodium();
|
||||||
|
return sodium.crypto_sign_detached(message, signSec);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verify(
|
||||||
|
message: Uint8Array,
|
||||||
|
sig: Uint8Array,
|
||||||
|
signPub: Uint8Array,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const sodium = await getSodium();
|
||||||
|
try {
|
||||||
|
return sodium.crypto_sign_verify_detached(sig, message, signPub);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSignablePayload(fields: {
|
||||||
|
type: string;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
nonce: string;
|
||||||
|
payload: string;
|
||||||
|
timestamp: string;
|
||||||
|
}): Uint8Array {
|
||||||
|
const str =
|
||||||
|
fields.type +
|
||||||
|
fields.from +
|
||||||
|
fields.to +
|
||||||
|
fields.nonce +
|
||||||
|
fields.payload +
|
||||||
|
fields.timestamp;
|
||||||
|
return new TextEncoder().encode(str);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Envelope helpers ---
|
||||||
|
|
||||||
|
export async function buildEnvelope(
|
||||||
|
type: string,
|
||||||
|
payload: object,
|
||||||
|
senderKeys: DeviceKeyPair,
|
||||||
|
peerEncPub: Uint8Array,
|
||||||
|
peerDeviceId: string,
|
||||||
|
): Promise<{
|
||||||
|
type: string;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
nonce: string;
|
||||||
|
payload: string;
|
||||||
|
timestamp: string;
|
||||||
|
sig: string;
|
||||||
|
}> {
|
||||||
|
const fromId = deviceIdFromKeys(senderKeys);
|
||||||
|
const sharedKey = await deriveSharedKey(senderKeys.encSec, peerEncPub);
|
||||||
|
const plaintext = new TextEncoder().encode(JSON.stringify(payload));
|
||||||
|
const { nonce, ciphertext } = await encrypt(plaintext, sharedKey);
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const nonceHex = toHex(nonce);
|
||||||
|
const payloadB64 = toBase64(ciphertext);
|
||||||
|
|
||||||
|
const signable = buildSignablePayload({
|
||||||
|
type,
|
||||||
|
from: fromId,
|
||||||
|
to: peerDeviceId,
|
||||||
|
nonce: nonceHex,
|
||||||
|
payload: payloadB64,
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
const sig = await sign(signable, senderKeys.signSec);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
from: fromId,
|
||||||
|
to: peerDeviceId,
|
||||||
|
nonce: nonceHex,
|
||||||
|
payload: payloadB64,
|
||||||
|
timestamp,
|
||||||
|
sig: toHex(sig),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openEnvelope(
|
||||||
|
envelope: {
|
||||||
|
nonce: string;
|
||||||
|
payload: string;
|
||||||
|
},
|
||||||
|
receiverKeys: DeviceKeyPair,
|
||||||
|
peerEncPub: Uint8Array,
|
||||||
|
): Promise<unknown> {
|
||||||
|
const sharedKey = await deriveSharedKey(receiverKeys.encSec, peerEncPub);
|
||||||
|
const nonce = fromHex(envelope.nonce);
|
||||||
|
const ciphertext = fromBase64(envelope.payload);
|
||||||
|
const plaintext = await decrypt(ciphertext, nonce, sharedKey);
|
||||||
|
return JSON.parse(new TextDecoder().decode(plaintext));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export hex utils
|
||||||
|
export { toHex, fromHex, toBase64, fromBase64 };
|
||||||
34
extension/src/lib/hex.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
/** Convert Uint8Array to hex string. */
|
||||||
|
export function toHex(buf: Uint8Array): string {
|
||||||
|
return Array.from(buf)
|
||||||
|
.map((b) => b.toString(16).padStart(2, "0"))
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert hex string to Uint8Array. */
|
||||||
|
export function fromHex(hex: string): Uint8Array {
|
||||||
|
const bytes = new Uint8Array(hex.length / 2);
|
||||||
|
for (let i = 0; i < hex.length; i += 2) {
|
||||||
|
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert Uint8Array to base64 string. */
|
||||||
|
export function toBase64(buf: Uint8Array): string {
|
||||||
|
let binary = "";
|
||||||
|
for (let i = 0; i < buf.length; i++) {
|
||||||
|
binary += String.fromCharCode(buf[i]);
|
||||||
|
}
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert base64 string to Uint8Array. */
|
||||||
|
export function fromBase64(b64: string): Uint8Array {
|
||||||
|
const binary = atob(b64);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) {
|
||||||
|
bytes[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
40
extension/src/lib/libsodium-wrappers-sumo.d.ts
vendored
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
declare module "libsodium-wrappers-sumo" {
|
||||||
|
interface KeyPair {
|
||||||
|
publicKey: Uint8Array;
|
||||||
|
privateKey: Uint8Array;
|
||||||
|
keyType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Sodium {
|
||||||
|
ready: Promise<void>;
|
||||||
|
crypto_sign_keypair(): KeyPair;
|
||||||
|
crypto_box_keypair(): KeyPair;
|
||||||
|
crypto_scalarmult(privateKey: Uint8Array, publicKey: Uint8Array): Uint8Array;
|
||||||
|
crypto_generichash(hashLength: number, message: Uint8Array): Uint8Array;
|
||||||
|
crypto_aead_xchacha20poly1305_ietf_encrypt(
|
||||||
|
message: Uint8Array,
|
||||||
|
additionalData: Uint8Array | null,
|
||||||
|
nsec: Uint8Array | null,
|
||||||
|
nonce: Uint8Array,
|
||||||
|
key: Uint8Array,
|
||||||
|
): Uint8Array;
|
||||||
|
crypto_aead_xchacha20poly1305_ietf_decrypt(
|
||||||
|
nsec: Uint8Array | null,
|
||||||
|
ciphertext: Uint8Array,
|
||||||
|
additionalData: Uint8Array | null,
|
||||||
|
nonce: Uint8Array,
|
||||||
|
key: Uint8Array,
|
||||||
|
): Uint8Array;
|
||||||
|
crypto_aead_xchacha20poly1305_ietf_NPUBBYTES: number;
|
||||||
|
crypto_sign_detached(message: Uint8Array, privateKey: Uint8Array): Uint8Array;
|
||||||
|
crypto_sign_verify_detached(
|
||||||
|
signature: Uint8Array,
|
||||||
|
message: Uint8Array,
|
||||||
|
publicKey: Uint8Array,
|
||||||
|
): boolean;
|
||||||
|
randombytes_buf(length: number): Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sodium: Sodium;
|
||||||
|
export default sodium;
|
||||||
|
}
|
||||||
83
extension/src/lib/protocol.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* Protocol types — mirrors the server's protocol/spec.ts for use in the extension.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const PROTOCOL_VERSION = "2.0.0";
|
||||||
|
export const MAX_STORED_COOKIES_PER_DEVICE = 10_000;
|
||||||
|
export const PAIRING_CODE_LENGTH = 6;
|
||||||
|
export const PAIRING_TTL_MS = 5 * 60 * 1000;
|
||||||
|
export const NONCE_BYTES = 24;
|
||||||
|
export const PING_INTERVAL_MS = 30_000;
|
||||||
|
export const PONG_TIMEOUT_MS = 10_000;
|
||||||
|
export const POLL_INTERVAL_MS = 5_000;
|
||||||
|
|
||||||
|
export const MESSAGE_TYPES = {
|
||||||
|
COOKIE_SYNC: "cookie_sync",
|
||||||
|
COOKIE_DELETE: "cookie_delete",
|
||||||
|
ACK: "ack",
|
||||||
|
PING: "ping",
|
||||||
|
PONG: "pong",
|
||||||
|
ERROR: "error",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type MessageType = (typeof MESSAGE_TYPES)[keyof typeof MESSAGE_TYPES];
|
||||||
|
|
||||||
|
export interface Envelope {
|
||||||
|
type: MessageType;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
nonce: string;
|
||||||
|
payload: string;
|
||||||
|
timestamp: string;
|
||||||
|
sig: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CookieEntry {
|
||||||
|
domain: string;
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
path: string;
|
||||||
|
secure: boolean;
|
||||||
|
httpOnly: boolean;
|
||||||
|
sameSite: "strict" | "lax" | "none";
|
||||||
|
expiresAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CookieSyncPayload {
|
||||||
|
action: "set" | "delete";
|
||||||
|
cookies: CookieEntry[];
|
||||||
|
lamportTs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EncryptedCookieBlob {
|
||||||
|
id: string;
|
||||||
|
deviceId: string;
|
||||||
|
domain: string;
|
||||||
|
cookieName: string;
|
||||||
|
path: string;
|
||||||
|
nonce: string;
|
||||||
|
ciphertext: string;
|
||||||
|
lamportTs: number;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceRegisterRequest {
|
||||||
|
deviceId: string;
|
||||||
|
name: string;
|
||||||
|
platform: string;
|
||||||
|
encPub: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceInfo {
|
||||||
|
deviceId: string;
|
||||||
|
name: string;
|
||||||
|
platform: string;
|
||||||
|
encPub: string;
|
||||||
|
token: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PairingResult {
|
||||||
|
peerDeviceId: string;
|
||||||
|
peerX25519PubKey: string;
|
||||||
|
}
|
||||||
109
extension/src/lib/storage.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* Chrome storage wrapper — provides typed access to extension state.
|
||||||
|
*/
|
||||||
|
import type { SerializedKeyPair } from "./crypto";
|
||||||
|
|
||||||
|
export interface PeerDevice {
|
||||||
|
deviceId: string;
|
||||||
|
name: string;
|
||||||
|
platform: string;
|
||||||
|
encPub: string; // hex
|
||||||
|
pairedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtensionState {
|
||||||
|
// Device identity
|
||||||
|
keys: SerializedKeyPair | null;
|
||||||
|
deviceId: string | null;
|
||||||
|
deviceName: string | null;
|
||||||
|
apiToken: string | null;
|
||||||
|
|
||||||
|
// Server config
|
||||||
|
serverUrl: string;
|
||||||
|
connectionMode: "auto" | "websocket" | "polling";
|
||||||
|
pollIntervalSec: number;
|
||||||
|
|
||||||
|
// Sync settings
|
||||||
|
autoSync: boolean;
|
||||||
|
whitelist: string[]; // domains to sync
|
||||||
|
blacklist: string[]; // domains to never sync (banks, etc.)
|
||||||
|
|
||||||
|
// Paired devices
|
||||||
|
peers: PeerDevice[];
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
syncCount: number;
|
||||||
|
lastSyncAt: string | null;
|
||||||
|
|
||||||
|
// Lamport clock
|
||||||
|
lamportClock: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_STATE: ExtensionState = {
|
||||||
|
keys: null,
|
||||||
|
deviceId: null,
|
||||||
|
deviceName: null,
|
||||||
|
apiToken: null,
|
||||||
|
serverUrl: "http://localhost:3000",
|
||||||
|
connectionMode: "auto",
|
||||||
|
pollIntervalSec: 5,
|
||||||
|
autoSync: true,
|
||||||
|
whitelist: [],
|
||||||
|
blacklist: [
|
||||||
|
"*.bank.*",
|
||||||
|
"*.paypal.com",
|
||||||
|
"*.stripe.com",
|
||||||
|
"accounts.google.com",
|
||||||
|
"login.microsoftonline.com",
|
||||||
|
],
|
||||||
|
peers: [],
|
||||||
|
syncCount: 0,
|
||||||
|
lastSyncAt: null,
|
||||||
|
lamportClock: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Get full extension state, merging defaults. */
|
||||||
|
export async function getState(): Promise<ExtensionState> {
|
||||||
|
const data = await chrome.storage.local.get(null);
|
||||||
|
return { ...DEFAULT_STATE, ...data } as ExtensionState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update specific state fields. */
|
||||||
|
export async function setState(
|
||||||
|
partial: Partial<ExtensionState>,
|
||||||
|
): Promise<void> {
|
||||||
|
await chrome.storage.local.set(partial);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset all state to defaults. */
|
||||||
|
export async function clearState(): Promise<void> {
|
||||||
|
await chrome.storage.local.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if a domain matches a pattern (supports leading wildcard *.). */
|
||||||
|
function matchDomain(pattern: string, domain: string): boolean {
|
||||||
|
if (pattern.startsWith("*.")) {
|
||||||
|
const suffix = pattern.slice(1); // ".bank." or ".paypal.com"
|
||||||
|
return domain.includes(suffix);
|
||||||
|
}
|
||||||
|
return domain === pattern;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if a domain is allowed for syncing based on whitelist/blacklist. */
|
||||||
|
export function isDomainAllowed(
|
||||||
|
domain: string,
|
||||||
|
whitelist: string[],
|
||||||
|
blacklist: string[],
|
||||||
|
): boolean {
|
||||||
|
// Blacklist always wins
|
||||||
|
for (const pattern of blacklist) {
|
||||||
|
if (matchDomain(pattern, domain)) return false;
|
||||||
|
}
|
||||||
|
// If whitelist is empty, allow all (except blacklisted)
|
||||||
|
if (whitelist.length === 0) return true;
|
||||||
|
// If whitelist is non-empty, only allow whitelisted
|
||||||
|
for (const pattern of whitelist) {
|
||||||
|
if (matchDomain(pattern, domain)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
269
extension/src/lib/sync.ts
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
/**
|
||||||
|
* Cookie sync engine — monitors chrome.cookies, syncs with relay server.
|
||||||
|
*/
|
||||||
|
import type { CookieEntry, CookieSyncPayload, Envelope } from "./protocol";
|
||||||
|
import { MESSAGE_TYPES } from "./protocol";
|
||||||
|
import type { DeviceKeyPair } from "./crypto";
|
||||||
|
import {
|
||||||
|
deviceIdFromKeys,
|
||||||
|
buildEnvelope,
|
||||||
|
openEnvelope,
|
||||||
|
fromHex,
|
||||||
|
} from "./crypto";
|
||||||
|
import type { ConnectionManager } from "./connection";
|
||||||
|
import type { ApiClient } from "./api-client";
|
||||||
|
import { getState, setState, isDomainAllowed, type PeerDevice } from "./storage";
|
||||||
|
|
||||||
|
type CookieKey = string;
|
||||||
|
|
||||||
|
interface TrackedCookie {
|
||||||
|
entry: CookieEntry;
|
||||||
|
lamportTs: number;
|
||||||
|
sourceDeviceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cookieKey(domain: string, name: string, path: string): CookieKey {
|
||||||
|
return `${domain}|${name}|${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chromeCookieToEntry(cookie: chrome.cookies.Cookie): CookieEntry {
|
||||||
|
return {
|
||||||
|
domain: cookie.domain,
|
||||||
|
name: cookie.name,
|
||||||
|
value: cookie.value,
|
||||||
|
path: cookie.path,
|
||||||
|
secure: cookie.secure,
|
||||||
|
httpOnly: cookie.httpOnly,
|
||||||
|
sameSite: cookie.sameSite === "strict"
|
||||||
|
? "strict"
|
||||||
|
: cookie.sameSite === "lax"
|
||||||
|
? "lax"
|
||||||
|
: "none",
|
||||||
|
expiresAt: cookie.expirationDate
|
||||||
|
? new Date(cookie.expirationDate * 1000).toISOString()
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SyncEngine {
|
||||||
|
private cookies = new Map<CookieKey, TrackedCookie>();
|
||||||
|
private lamportClock = 0;
|
||||||
|
private deviceId: string;
|
||||||
|
private keys: DeviceKeyPair;
|
||||||
|
private connection: ConnectionManager;
|
||||||
|
private api: ApiClient;
|
||||||
|
private suppressLocal = new Set<string>(); // keys to skip on local change (avoid echo)
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
keys: DeviceKeyPair,
|
||||||
|
connection: ConnectionManager,
|
||||||
|
api: ApiClient,
|
||||||
|
) {
|
||||||
|
this.keys = keys;
|
||||||
|
this.deviceId = deviceIdFromKeys(keys);
|
||||||
|
this.connection = connection;
|
||||||
|
this.api = api;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start monitoring cookie changes. */
|
||||||
|
start() {
|
||||||
|
chrome.cookies.onChanged.addListener(this.handleCookieChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
chrome.cookies.onChanged.removeListener(this.handleCookieChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handle incoming envelope from WebSocket. */
|
||||||
|
async handleIncomingEnvelope(envelope: Envelope, peers: PeerDevice[]) {
|
||||||
|
const peer = peers.find((p) => p.deviceId === envelope.from);
|
||||||
|
if (!peer) return; // Unknown peer, ignore
|
||||||
|
|
||||||
|
const peerEncPub = fromHex(peer.encPub);
|
||||||
|
const payload = (await openEnvelope(
|
||||||
|
envelope,
|
||||||
|
this.keys,
|
||||||
|
peerEncPub,
|
||||||
|
)) as CookieSyncPayload;
|
||||||
|
|
||||||
|
const applied = this.applyRemote(payload, envelope.from);
|
||||||
|
for (const entry of applied) {
|
||||||
|
await this.applyCookieToBrowser(entry, payload.action);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update stats
|
||||||
|
const state = await getState();
|
||||||
|
await setState({
|
||||||
|
syncCount: state.syncCount + applied.length,
|
||||||
|
lastSyncAt: new Date().toISOString(),
|
||||||
|
lamportClock: this.lamportClock,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 entries = cookies.map(chromeCookieToEntry);
|
||||||
|
await this.syncEntriesToPeers(entries, "set");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sync the current tab's cookies. */
|
||||||
|
async syncCurrentTab() {
|
||||||
|
const [tab] = await chrome.tabs.query({
|
||||||
|
active: true,
|
||||||
|
currentWindow: true,
|
||||||
|
});
|
||||||
|
if (!tab?.url) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(tab.url);
|
||||||
|
await this.syncDomain(url.hostname);
|
||||||
|
} catch {
|
||||||
|
// Invalid URL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get currentLamportTs(): number {
|
||||||
|
return this.lamportClock;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleCookieChange = async (
|
||||||
|
changeInfo: chrome.cookies.CookieChangeInfo,
|
||||||
|
) => {
|
||||||
|
const { cookie, removed, cause } = changeInfo;
|
||||||
|
|
||||||
|
// Skip changes caused by us applying remote cookies
|
||||||
|
const key = cookieKey(cookie.domain, cookie.name, cookie.path);
|
||||||
|
if (this.suppressLocal.has(key)) {
|
||||||
|
this.suppressLocal.delete(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only sync user-initiated changes (not expired, not evicted)
|
||||||
|
if (cause === "expired" || cause === "evicted") return;
|
||||||
|
|
||||||
|
const state = await getState();
|
||||||
|
if (!state.autoSync) return;
|
||||||
|
if (!isDomainAllowed(cookie.domain, state.whitelist, state.blacklist))
|
||||||
|
return;
|
||||||
|
|
||||||
|
const entry = chromeCookieToEntry(cookie);
|
||||||
|
const action = removed ? "delete" : "set";
|
||||||
|
|
||||||
|
if (!removed) {
|
||||||
|
this.lamportClock++;
|
||||||
|
this.cookies.set(key, {
|
||||||
|
entry,
|
||||||
|
lamportTs: this.lamportClock,
|
||||||
|
sourceDeviceId: this.deviceId,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.cookies.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.syncEntriesToPeers([entry], action);
|
||||||
|
await setState({ lamportClock: this.lamportClock });
|
||||||
|
};
|
||||||
|
|
||||||
|
private async syncEntriesToPeers(entries: CookieEntry[], action: "set" | "delete") {
|
||||||
|
const state = await getState();
|
||||||
|
this.lamportClock++;
|
||||||
|
|
||||||
|
const payload: CookieSyncPayload = {
|
||||||
|
action,
|
||||||
|
cookies: entries,
|
||||||
|
lamportTs: this.lamportClock,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const peer of state.peers) {
|
||||||
|
const peerEncPub = fromHex(peer.encPub);
|
||||||
|
const envelope = await buildEnvelope(
|
||||||
|
MESSAGE_TYPES.COOKIE_SYNC,
|
||||||
|
payload,
|
||||||
|
this.keys,
|
||||||
|
peerEncPub,
|
||||||
|
peer.deviceId,
|
||||||
|
);
|
||||||
|
this.connection.send(envelope as unknown as Envelope);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyRemote(
|
||||||
|
payload: CookieSyncPayload,
|
||||||
|
sourceDeviceId: string,
|
||||||
|
): CookieEntry[] {
|
||||||
|
this.lamportClock = Math.max(this.lamportClock, payload.lamportTs) + 1;
|
||||||
|
const applied: CookieEntry[] = [];
|
||||||
|
|
||||||
|
for (const entry of payload.cookies) {
|
||||||
|
const key = cookieKey(entry.domain, entry.name, entry.path);
|
||||||
|
const existing = this.cookies.get(key);
|
||||||
|
|
||||||
|
if (payload.action === "delete") {
|
||||||
|
if (this.shouldApply(existing, payload.lamportTs, sourceDeviceId)) {
|
||||||
|
this.cookies.delete(key);
|
||||||
|
applied.push(entry);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.shouldApply(existing, payload.lamportTs, sourceDeviceId)) {
|
||||||
|
this.cookies.set(key, {
|
||||||
|
entry,
|
||||||
|
lamportTs: payload.lamportTs,
|
||||||
|
sourceDeviceId,
|
||||||
|
});
|
||||||
|
applied.push(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return applied;
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldApply(
|
||||||
|
existing: TrackedCookie | undefined,
|
||||||
|
incomingTs: number,
|
||||||
|
incomingDeviceId: string,
|
||||||
|
): boolean {
|
||||||
|
if (!existing) return true;
|
||||||
|
if (incomingTs > existing.lamportTs) return true;
|
||||||
|
if (incomingTs === existing.lamportTs) {
|
||||||
|
return incomingDeviceId > existing.sourceDeviceId;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async applyCookieToBrowser(entry: CookieEntry, action: "set" | "delete") {
|
||||||
|
const key = cookieKey(entry.domain, entry.name, entry.path);
|
||||||
|
this.suppressLocal.add(key);
|
||||||
|
|
||||||
|
const url = `http${entry.secure ? "s" : ""}://${entry.domain.replace(/^\./, "")}${entry.path}`;
|
||||||
|
|
||||||
|
if (action === "delete") {
|
||||||
|
await chrome.cookies.remove({ url, name: entry.name });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const details: chrome.cookies.SetDetails = {
|
||||||
|
url,
|
||||||
|
name: entry.name,
|
||||||
|
value: entry.value,
|
||||||
|
path: entry.path,
|
||||||
|
secure: entry.secure,
|
||||||
|
httpOnly: entry.httpOnly,
|
||||||
|
sameSite: entry.sameSite === "strict"
|
||||||
|
? "strict"
|
||||||
|
: entry.sameSite === "lax"
|
||||||
|
? "lax"
|
||||||
|
: "no_restriction",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (entry.expiresAt) {
|
||||||
|
details.expirationDate = new Date(entry.expiresAt).getTime() / 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
await chrome.cookies.set(details);
|
||||||
|
}
|
||||||
|
}
|
||||||
305
extension/src/options/options.css
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #1f2937;
|
||||||
|
background: #f9fafb;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sections */
|
||||||
|
.section {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section h2 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fields */
|
||||||
|
.field {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field label {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field .value {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field .value.mono {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input[type="text"],
|
||||||
|
.field select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input[type="text"]:focus,
|
||||||
|
.field select:focus {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #9ca3af;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Range */
|
||||||
|
.range-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range-group input[type="range"] {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range-group span {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
min-width: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toggle */
|
||||||
|
.toggle-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-label input[type="checkbox"] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle {
|
||||||
|
width: 44px;
|
||||||
|
height: 24px;
|
||||||
|
background: #d1d5db;
|
||||||
|
border-radius: 12px;
|
||||||
|
position: relative;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-label input:checked + .toggle {
|
||||||
|
background: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-label input:checked + .toggle::after {
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tags */
|
||||||
|
.tag-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
min-height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1d4ed8;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag .remove {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #93c5fd;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag .remove:hover {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-tag {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-tag input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-tag input:focus {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Peer List */
|
||||||
|
.peer-list {
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-info .peer-name {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-info .peer-id {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-info .peer-date {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #3b82f6;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: #fee2e2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-small {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Save Bar */
|
||||||
|
.save-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-status {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
114
extension/src/options/options.html
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>CookieBridge Settings</title>
|
||||||
|
<link rel="stylesheet" href="options.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>CookieBridge Settings</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Account Section -->
|
||||||
|
<section class="section">
|
||||||
|
<h2>Account</h2>
|
||||||
|
<div class="field">
|
||||||
|
<label>Device Name</label>
|
||||||
|
<span id="opt-device-name" class="value">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Device ID</label>
|
||||||
|
<span id="opt-device-id" class="value mono">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="field-actions">
|
||||||
|
<button id="btn-logout" class="btn btn-danger">Log Out</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Connection Section -->
|
||||||
|
<section class="section">
|
||||||
|
<h2>Connection</h2>
|
||||||
|
<div class="field">
|
||||||
|
<label for="opt-server-url">Server URL</label>
|
||||||
|
<input type="text" id="opt-server-url" placeholder="http://localhost:3000" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="opt-connection-mode">Connection Mode</label>
|
||||||
|
<select id="opt-connection-mode">
|
||||||
|
<option value="auto">Auto (WebSocket preferred)</option>
|
||||||
|
<option value="websocket">WebSocket Only</option>
|
||||||
|
<option value="polling">HTTP Polling</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field" id="field-poll-interval">
|
||||||
|
<label for="opt-poll-interval">Poll Interval</label>
|
||||||
|
<div class="range-group">
|
||||||
|
<input type="range" id="opt-poll-interval" min="1" max="60" value="5" />
|
||||||
|
<span id="opt-poll-interval-label">5s</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Sync Section -->
|
||||||
|
<section class="section">
|
||||||
|
<h2>Sync</h2>
|
||||||
|
<div class="field">
|
||||||
|
<label class="toggle-label">
|
||||||
|
<span>Auto-sync cookies</span>
|
||||||
|
<input type="checkbox" id="opt-auto-sync" />
|
||||||
|
<span class="toggle"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Whitelist (sync only these domains)</label>
|
||||||
|
<div class="tag-list" id="whitelist-tags"></div>
|
||||||
|
<div class="add-tag">
|
||||||
|
<input type="text" id="whitelist-input" placeholder="example.com" />
|
||||||
|
<button id="btn-add-whitelist" class="btn btn-small btn-secondary">Add</button>
|
||||||
|
</div>
|
||||||
|
<p class="field-hint">Leave empty to sync all domains (except blacklisted)</p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Blacklist (never sync these domains)</label>
|
||||||
|
<div class="tag-list" id="blacklist-tags"></div>
|
||||||
|
<div class="add-tag">
|
||||||
|
<input type="text" id="blacklist-input" placeholder="*.bank.com" />
|
||||||
|
<button id="btn-add-blacklist" class="btn btn-small btn-secondary">Add</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Paired Devices Section -->
|
||||||
|
<section class="section">
|
||||||
|
<h2>Paired Devices</h2>
|
||||||
|
<div id="peer-list" class="peer-list">
|
||||||
|
<p class="empty-state">No paired devices yet</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Security Section -->
|
||||||
|
<section class="section">
|
||||||
|
<h2>Security</h2>
|
||||||
|
<div class="field-actions">
|
||||||
|
<button id="btn-export-keys" class="btn btn-secondary">Export Keys</button>
|
||||||
|
<button id="btn-import-keys" class="btn btn-secondary">Import Keys</button>
|
||||||
|
<input type="file" id="file-import-keys" accept=".json" style="display: none" />
|
||||||
|
</div>
|
||||||
|
<div class="field-actions" style="margin-top: 12px;">
|
||||||
|
<button id="btn-clear-data" class="btn btn-danger">Clear All Local Data</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Save -->
|
||||||
|
<div class="save-bar">
|
||||||
|
<button id="btn-save" class="btn btn-primary">Save Settings</button>
|
||||||
|
<span id="save-status" class="save-status"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="../../dist/options/options.js" type="module"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
257
extension/src/options/options.ts
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
/**
|
||||||
|
* Options page script — manages extension settings.
|
||||||
|
*/
|
||||||
|
export {};
|
||||||
|
|
||||||
|
// --- Messaging helper ---
|
||||||
|
|
||||||
|
function sendMessage(type: string, payload?: unknown): Promise<any> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
chrome.runtime.sendMessage({ type, payload }, (response) => {
|
||||||
|
if (chrome.runtime.lastError) {
|
||||||
|
reject(new Error(chrome.runtime.lastError.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (response?.error) {
|
||||||
|
reject(new Error(response.error));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(response);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Elements ---
|
||||||
|
|
||||||
|
const optDeviceName = document.getElementById("opt-device-name")!;
|
||||||
|
const optDeviceId = document.getElementById("opt-device-id")!;
|
||||||
|
const btnLogout = document.getElementById("btn-logout") as HTMLButtonElement;
|
||||||
|
|
||||||
|
const optServerUrl = document.getElementById("opt-server-url") as HTMLInputElement;
|
||||||
|
const optConnectionMode = document.getElementById("opt-connection-mode") as HTMLSelectElement;
|
||||||
|
const optPollInterval = document.getElementById("opt-poll-interval") as HTMLInputElement;
|
||||||
|
const optPollIntervalLabel = document.getElementById("opt-poll-interval-label")!;
|
||||||
|
const fieldPollInterval = document.getElementById("field-poll-interval")!;
|
||||||
|
|
||||||
|
const optAutoSync = document.getElementById("opt-auto-sync") as HTMLInputElement;
|
||||||
|
const whitelistTags = document.getElementById("whitelist-tags")!;
|
||||||
|
const whitelistInput = document.getElementById("whitelist-input") as HTMLInputElement;
|
||||||
|
const btnAddWhitelist = document.getElementById("btn-add-whitelist") as HTMLButtonElement;
|
||||||
|
const blacklistTags = document.getElementById("blacklist-tags")!;
|
||||||
|
const blacklistInput = document.getElementById("blacklist-input") as HTMLInputElement;
|
||||||
|
const btnAddBlacklist = document.getElementById("btn-add-blacklist") as HTMLButtonElement;
|
||||||
|
|
||||||
|
const peerList = document.getElementById("peer-list")!;
|
||||||
|
|
||||||
|
const btnExportKeys = document.getElementById("btn-export-keys") as HTMLButtonElement;
|
||||||
|
const btnImportKeys = document.getElementById("btn-import-keys") as HTMLButtonElement;
|
||||||
|
const fileImportKeys = document.getElementById("file-import-keys") as HTMLInputElement;
|
||||||
|
const btnClearData = document.getElementById("btn-clear-data") as HTMLButtonElement;
|
||||||
|
|
||||||
|
const btnSave = document.getElementById("btn-save") as HTMLButtonElement;
|
||||||
|
const saveStatus = document.getElementById("save-status")!;
|
||||||
|
|
||||||
|
// --- State ---
|
||||||
|
|
||||||
|
let whitelist: string[] = [];
|
||||||
|
let blacklist: string[] = [];
|
||||||
|
|
||||||
|
// --- Load Settings ---
|
||||||
|
|
||||||
|
async function loadSettings() {
|
||||||
|
const state = await chrome.storage.local.get(null);
|
||||||
|
|
||||||
|
optDeviceName.textContent = state.deviceName || "—";
|
||||||
|
optDeviceId.textContent = state.deviceId || "—";
|
||||||
|
|
||||||
|
optServerUrl.value = state.serverUrl || "http://localhost:3000";
|
||||||
|
optConnectionMode.value = state.connectionMode || "auto";
|
||||||
|
optPollInterval.value = String(state.pollIntervalSec || 5);
|
||||||
|
optPollIntervalLabel.textContent = `${state.pollIntervalSec || 5}s`;
|
||||||
|
updatePollVisibility();
|
||||||
|
|
||||||
|
optAutoSync.checked = state.autoSync !== false;
|
||||||
|
|
||||||
|
whitelist = state.whitelist || [];
|
||||||
|
blacklist = state.blacklist || [
|
||||||
|
"*.bank.*",
|
||||||
|
"*.paypal.com",
|
||||||
|
"*.stripe.com",
|
||||||
|
"accounts.google.com",
|
||||||
|
"login.microsoftonline.com",
|
||||||
|
];
|
||||||
|
|
||||||
|
renderTags(whitelistTags, whitelist, "whitelist");
|
||||||
|
renderTags(blacklistTags, blacklist, "blacklist");
|
||||||
|
renderPeers(state.peers || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTags(container: HTMLElement, items: string[], listName: string) {
|
||||||
|
container.innerHTML = "";
|
||||||
|
for (const item of items) {
|
||||||
|
const tag = document.createElement("span");
|
||||||
|
tag.className = "tag";
|
||||||
|
tag.innerHTML = `${item}<span class="remove" data-list="${listName}" data-value="${item}">×</span>`;
|
||||||
|
container.appendChild(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPeers(peers: Array<{ deviceId: string; name: string; platform: string; pairedAt: string }>) {
|
||||||
|
if (peers.length === 0) {
|
||||||
|
peerList.innerHTML = '<p class="empty-state">No paired devices yet</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
peerList.innerHTML = "";
|
||||||
|
for (const peer of peers) {
|
||||||
|
const item = document.createElement("div");
|
||||||
|
item.className = "peer-item";
|
||||||
|
item.innerHTML = `
|
||||||
|
<div class="peer-info">
|
||||||
|
<div class="peer-name">${peer.name} (${peer.platform})</div>
|
||||||
|
<div class="peer-id">${peer.deviceId.slice(0, 16)}...</div>
|
||||||
|
<div class="peer-date">Paired: ${new Date(peer.pairedAt).toLocaleDateString()}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
peerList.appendChild(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePollVisibility() {
|
||||||
|
fieldPollInterval.style.display =
|
||||||
|
optConnectionMode.value === "polling" ? "block" : "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Event Handlers ---
|
||||||
|
|
||||||
|
optConnectionMode.addEventListener("change", updatePollVisibility);
|
||||||
|
|
||||||
|
optPollInterval.addEventListener("input", () => {
|
||||||
|
optPollIntervalLabel.textContent = `${optPollInterval.value}s`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tag removal (event delegation)
|
||||||
|
document.addEventListener("click", (e) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (!target.classList.contains("remove")) return;
|
||||||
|
|
||||||
|
const listName = target.dataset.list;
|
||||||
|
const value = target.dataset.value;
|
||||||
|
if (!listName || !value) return;
|
||||||
|
|
||||||
|
if (listName === "whitelist") {
|
||||||
|
whitelist = whitelist.filter((d) => d !== value);
|
||||||
|
renderTags(whitelistTags, whitelist, "whitelist");
|
||||||
|
} else {
|
||||||
|
blacklist = blacklist.filter((d) => d !== value);
|
||||||
|
renderTags(blacklistTags, blacklist, "blacklist");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
btnAddWhitelist.addEventListener("click", () => {
|
||||||
|
const domain = whitelistInput.value.trim();
|
||||||
|
if (domain && !whitelist.includes(domain)) {
|
||||||
|
whitelist.push(domain);
|
||||||
|
renderTags(whitelistTags, whitelist, "whitelist");
|
||||||
|
whitelistInput.value = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
btnAddBlacklist.addEventListener("click", () => {
|
||||||
|
const domain = blacklistInput.value.trim();
|
||||||
|
if (domain && !blacklist.includes(domain)) {
|
||||||
|
blacklist.push(domain);
|
||||||
|
renderTags(blacklistTags, blacklist, "blacklist");
|
||||||
|
blacklistInput.value = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save
|
||||||
|
btnSave.addEventListener("click", async () => {
|
||||||
|
await chrome.storage.local.set({
|
||||||
|
serverUrl: optServerUrl.value.trim(),
|
||||||
|
connectionMode: optConnectionMode.value,
|
||||||
|
pollIntervalSec: parseInt(optPollInterval.value),
|
||||||
|
autoSync: optAutoSync.checked,
|
||||||
|
whitelist,
|
||||||
|
blacklist,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reconnect with new settings
|
||||||
|
await sendMessage("RECONNECT");
|
||||||
|
|
||||||
|
saveStatus.textContent = "Saved!";
|
||||||
|
setTimeout(() => {
|
||||||
|
saveStatus.textContent = "";
|
||||||
|
}, 2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Logout
|
||||||
|
btnLogout.addEventListener("click", async () => {
|
||||||
|
if (!confirm("Are you sure you want to log out? This will remove your device identity.")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await sendMessage("LOGOUT");
|
||||||
|
window.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export keys
|
||||||
|
btnExportKeys.addEventListener("click", async () => {
|
||||||
|
const result = await sendMessage("EXPORT_KEYS");
|
||||||
|
if (!result?.keys) {
|
||||||
|
alert("No keys to export");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = new Blob([JSON.stringify(result.keys, null, 2)], {
|
||||||
|
type: "application/json",
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = "cookiebridge-keys.json";
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Import keys
|
||||||
|
btnImportKeys.addEventListener("click", () => {
|
||||||
|
fileImportKeys.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
fileImportKeys.addEventListener("change", async () => {
|
||||||
|
const file = fileImportKeys.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const text = await file.text();
|
||||||
|
const keys = JSON.parse(text);
|
||||||
|
if (!keys.signPub || !keys.signSec || !keys.encPub || !keys.encSec) {
|
||||||
|
throw new Error("Invalid key file");
|
||||||
|
}
|
||||||
|
await sendMessage("IMPORT_KEYS", keys);
|
||||||
|
alert("Keys imported successfully. The extension will reconnect.");
|
||||||
|
await loadSettings();
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Failed to import keys: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear data
|
||||||
|
btnClearData.addEventListener("click", async () => {
|
||||||
|
if (
|
||||||
|
!confirm(
|
||||||
|
"This will delete ALL local data including your encryption keys. Are you sure?",
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await sendMessage("LOGOUT");
|
||||||
|
await chrome.storage.local.clear();
|
||||||
|
alert("All data cleared.");
|
||||||
|
window.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Init ---
|
||||||
|
|
||||||
|
loadSettings();
|
||||||
317
extension/src/popup/popup.css
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
width: 340px;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #1f2937;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Login View */
|
||||||
|
.logo-section {
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"]:focus {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #3b82f6;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #3b82f6;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost:hover {
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-small {
|
||||||
|
width: auto;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
background: #eff6ff;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-id {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status Badge */
|
||||||
|
.status-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge .dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.connected {
|
||||||
|
background: #ecfdf5;
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.connected .dot {
|
||||||
|
background: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.disconnected {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.disconnected .dot {
|
||||||
|
background: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.error {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.error .dot {
|
||||||
|
background: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.connecting {
|
||||||
|
background: #fffbeb;
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.connecting .dot {
|
||||||
|
background: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats Row */
|
||||||
|
.stats-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
padding: 12px 0;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border-top: 1px solid #f3f4f6;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Actions */
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pairing Dialog */
|
||||||
|
.dialog {
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pairing-code {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: monospace;
|
||||||
|
text-align: center;
|
||||||
|
letter-spacing: 8px;
|
||||||
|
color: #3b82f6;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pairing-input {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 6px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #6b7280;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-link {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-link:hover {
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
91
extension/src/popup/popup.html
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>CookieBridge</title>
|
||||||
|
<link rel="stylesheet" href="popup.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Not Logged In View -->
|
||||||
|
<div id="view-login" class="view" style="display: none;">
|
||||||
|
<div class="logo-section">
|
||||||
|
<div class="logo">🍪</div>
|
||||||
|
<h1>CookieBridge</h1>
|
||||||
|
<p class="subtitle">Cross-device cookie sync</p>
|
||||||
|
</div>
|
||||||
|
<div class="login-section">
|
||||||
|
<input type="text" id="device-name" placeholder="Device name (e.g. MacBook Pro)" />
|
||||||
|
<button id="btn-register" class="btn btn-primary">Register Device</button>
|
||||||
|
<a href="#" id="link-first-use" class="link">First time setup guide</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Logged In View -->
|
||||||
|
<div id="view-main" class="view" style="display: none;">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="header">
|
||||||
|
<div class="user-info">
|
||||||
|
<div class="avatar">🍪</div>
|
||||||
|
<div class="user-details">
|
||||||
|
<span id="display-name" class="name"></span>
|
||||||
|
<span id="display-device-id" class="device-id"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="connection-status" class="status-badge disconnected">
|
||||||
|
<span class="dot"></span>
|
||||||
|
<span class="label">Disconnected</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats -->
|
||||||
|
<div class="stats-row">
|
||||||
|
<div class="stat">
|
||||||
|
<span id="stat-cookies" class="stat-value">0</span>
|
||||||
|
<span class="stat-label">Cookies</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span id="stat-devices" class="stat-value">0</span>
|
||||||
|
<span class="stat-label">Devices</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span id="stat-syncs" class="stat-value">0</span>
|
||||||
|
<span class="stat-label">Syncs</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="actions">
|
||||||
|
<button id="btn-sync-tab" class="btn btn-primary">Sync Current Site</button>
|
||||||
|
<button id="btn-add-whitelist" class="btn btn-secondary">Add to Whitelist</button>
|
||||||
|
<button id="btn-pair" class="btn btn-secondary">Pair Device</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pairing Dialog (hidden by default) -->
|
||||||
|
<div id="pairing-dialog" class="dialog" style="display: none;">
|
||||||
|
<div id="pairing-initiate" style="display: none;">
|
||||||
|
<p class="dialog-title">Your Pairing Code</p>
|
||||||
|
<div id="pairing-code" class="pairing-code"></div>
|
||||||
|
<p class="dialog-hint">Enter this code on the other device within 5 minutes</p>
|
||||||
|
</div>
|
||||||
|
<div id="pairing-accept">
|
||||||
|
<p class="dialog-title">Enter Pairing Code</p>
|
||||||
|
<input type="text" id="input-pairing-code" placeholder="000000" maxlength="6" class="pairing-input" />
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button id="btn-pairing-accept" class="btn btn-primary btn-small">Pair</button>
|
||||||
|
<button id="btn-pairing-generate" class="btn btn-secondary btn-small">Generate Code</button>
|
||||||
|
<button id="btn-pairing-cancel" class="btn btn-ghost btn-small">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="footer">
|
||||||
|
<a href="#" id="btn-settings" class="footer-link">Settings</a>
|
||||||
|
<a href="https://github.com/Rc707Agency/cookiebridge" target="_blank" class="footer-link">Help</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="../../dist/popup/popup.js" type="module"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
206
extension/src/popup/popup.ts
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
/**
|
||||||
|
* Popup script — controls the popup UI interactions.
|
||||||
|
*/
|
||||||
|
export {};
|
||||||
|
|
||||||
|
// --- Elements ---
|
||||||
|
|
||||||
|
const viewLogin = document.getElementById("view-login")!;
|
||||||
|
const viewMain = document.getElementById("view-main")!;
|
||||||
|
|
||||||
|
// Login
|
||||||
|
const inputDeviceName = document.getElementById("device-name") as HTMLInputElement;
|
||||||
|
const btnRegister = document.getElementById("btn-register") as HTMLButtonElement;
|
||||||
|
|
||||||
|
// Main header
|
||||||
|
const displayName = document.getElementById("display-name")!;
|
||||||
|
const displayDeviceId = document.getElementById("display-device-id")!;
|
||||||
|
const connectionStatus = document.getElementById("connection-status")!;
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
const statCookies = document.getElementById("stat-cookies")!;
|
||||||
|
const statDevices = document.getElementById("stat-devices")!;
|
||||||
|
const statSyncs = document.getElementById("stat-syncs")!;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
const btnSyncTab = document.getElementById("btn-sync-tab") as HTMLButtonElement;
|
||||||
|
const btnAddWhitelist = document.getElementById("btn-add-whitelist") as HTMLButtonElement;
|
||||||
|
const btnPair = document.getElementById("btn-pair") as HTMLButtonElement;
|
||||||
|
const btnSettings = document.getElementById("btn-settings")!;
|
||||||
|
|
||||||
|
// Pairing
|
||||||
|
const pairingDialog = document.getElementById("pairing-dialog")!;
|
||||||
|
const pairingInitiate = document.getElementById("pairing-initiate")!;
|
||||||
|
const pairingAccept = document.getElementById("pairing-accept")!;
|
||||||
|
const pairingCode = document.getElementById("pairing-code")!;
|
||||||
|
const inputPairingCode = document.getElementById("input-pairing-code") as HTMLInputElement;
|
||||||
|
const btnPairingAccept = document.getElementById("btn-pairing-accept") as HTMLButtonElement;
|
||||||
|
const btnPairingGenerate = document.getElementById("btn-pairing-generate") as HTMLButtonElement;
|
||||||
|
const btnPairingCancel = document.getElementById("btn-pairing-cancel") as HTMLButtonElement;
|
||||||
|
|
||||||
|
// --- Messaging helper ---
|
||||||
|
|
||||||
|
function sendMessage(type: string, payload?: unknown): Promise<any> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
chrome.runtime.sendMessage({ type, payload }, (response) => {
|
||||||
|
if (chrome.runtime.lastError) {
|
||||||
|
reject(new Error(chrome.runtime.lastError.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (response?.error) {
|
||||||
|
reject(new Error(response.error));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(response);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- UI Updates ---
|
||||||
|
|
||||||
|
function showView(loggedIn: boolean) {
|
||||||
|
viewLogin.style.display = loggedIn ? "none" : "block";
|
||||||
|
viewMain.style.display = loggedIn ? "block" : "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateConnectionBadge(status: string) {
|
||||||
|
connectionStatus.className = `status-badge ${status}`;
|
||||||
|
const label = connectionStatus.querySelector(".label")!;
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
connected: "Connected",
|
||||||
|
disconnected: "Disconnected",
|
||||||
|
connecting: "Connecting...",
|
||||||
|
error: "Error",
|
||||||
|
};
|
||||||
|
label.textContent = labels[status] || status;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshStatus() {
|
||||||
|
try {
|
||||||
|
const status = await sendMessage("GET_STATUS");
|
||||||
|
showView(status.loggedIn);
|
||||||
|
|
||||||
|
if (status.loggedIn) {
|
||||||
|
displayName.textContent = status.deviceName || "My Device";
|
||||||
|
displayDeviceId.textContent = status.deviceId
|
||||||
|
? status.deviceId.slice(0, 12) + "..."
|
||||||
|
: "";
|
||||||
|
updateConnectionBadge(status.connectionStatus);
|
||||||
|
statCookies.textContent = String(status.cookieCount);
|
||||||
|
statDevices.textContent = String(status.peerCount);
|
||||||
|
statSyncs.textContent = String(status.syncCount);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to get status:", err);
|
||||||
|
showView(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Event Handlers ---
|
||||||
|
|
||||||
|
btnRegister.addEventListener("click", async () => {
|
||||||
|
const name = inputDeviceName.value.trim();
|
||||||
|
if (!name) {
|
||||||
|
inputDeviceName.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
btnRegister.disabled = true;
|
||||||
|
btnRegister.textContent = "Registering...";
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendMessage("REGISTER_DEVICE", { name });
|
||||||
|
await refreshStatus();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Registration failed:", err);
|
||||||
|
btnRegister.textContent = "Registration Failed";
|
||||||
|
setTimeout(() => {
|
||||||
|
btnRegister.textContent = "Register Device";
|
||||||
|
btnRegister.disabled = false;
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
btnSyncTab.addEventListener("click", async () => {
|
||||||
|
btnSyncTab.disabled = true;
|
||||||
|
btnSyncTab.textContent = "Syncing...";
|
||||||
|
try {
|
||||||
|
await sendMessage("SYNC_CURRENT_TAB");
|
||||||
|
btnSyncTab.textContent = "Synced!";
|
||||||
|
setTimeout(() => {
|
||||||
|
btnSyncTab.textContent = "Sync Current Site";
|
||||||
|
btnSyncTab.disabled = false;
|
||||||
|
}, 1500);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Sync failed:", err);
|
||||||
|
btnSyncTab.textContent = "Sync Failed";
|
||||||
|
setTimeout(() => {
|
||||||
|
btnSyncTab.textContent = "Sync Current Site";
|
||||||
|
btnSyncTab.disabled = false;
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
btnAddWhitelist.addEventListener("click", async () => {
|
||||||
|
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||||
|
if (!tab?.url) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(tab.url);
|
||||||
|
await sendMessage("ADD_WHITELIST", { domain: url.hostname });
|
||||||
|
btnAddWhitelist.textContent = "Added!";
|
||||||
|
setTimeout(() => {
|
||||||
|
btnAddWhitelist.textContent = "Add to Whitelist";
|
||||||
|
}, 1500);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to add whitelist:", err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pairing
|
||||||
|
btnPair.addEventListener("click", () => {
|
||||||
|
pairingDialog.style.display = "block";
|
||||||
|
pairingInitiate.style.display = "none";
|
||||||
|
pairingAccept.style.display = "block";
|
||||||
|
inputPairingCode.value = "";
|
||||||
|
inputPairingCode.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
btnPairingGenerate.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
const result = await sendMessage("INITIATE_PAIRING");
|
||||||
|
pairingAccept.style.display = "none";
|
||||||
|
pairingInitiate.style.display = "block";
|
||||||
|
pairingCode.textContent = result.pairingCode;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to generate pairing code:", err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
btnPairingAccept.addEventListener("click", async () => {
|
||||||
|
const code = inputPairingCode.value.trim();
|
||||||
|
if (code.length !== 6) return;
|
||||||
|
|
||||||
|
btnPairingAccept.disabled = true;
|
||||||
|
try {
|
||||||
|
await sendMessage("ACCEPT_PAIRING", { code });
|
||||||
|
pairingDialog.style.display = "none";
|
||||||
|
await refreshStatus();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Pairing failed:", err);
|
||||||
|
btnPairingAccept.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
btnPairingCancel.addEventListener("click", () => {
|
||||||
|
pairingDialog.style.display = "none";
|
||||||
|
});
|
||||||
|
|
||||||
|
btnSettings.addEventListener("click", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
chrome.runtime.openOptionsPage();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Init ---
|
||||||
|
|
||||||
|
refreshStatus();
|
||||||
18
extension/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"declaration": false,
|
||||||
|
"sourceMap": true,
|
||||||
|
"lib": ["ES2022", "DOM"],
|
||||||
|
"types": ["chrome"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||