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"]
|
||||
}
|
||||