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>
This commit is contained in:
徐枫
2026-03-17 16:30:18 +08:00
parent 1bd7a34de8
commit dc3be4d73f
36 changed files with 3549 additions and 0 deletions

View 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}`);
}
}