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>
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();
|
||||