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:
139
extension/generate-icons.mjs
Normal file
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}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user