"use client"; /** * Local bookkeeping for opt-in encrypted sync: whether it is on, the random * sync ID, when this device last synced and whether local data changed since. * The passphrase itself is never stored here — the derived key lives in * IndexedDB as a non-extractable CryptoKey (lib/sync-key.ts) and nowhere else. */ const STORAGE_KEY = "freezmo.sync.v1"; export type SyncRecord = { v: 1; enabled: boolean; /** Random 128-bit id — the only handle the server ever sees. */ id: string; /** KDF salt (not secret; travels in the envelope too). */ salt: string; lastSyncedAt: number | null; /** Set whenever local data changes; cleared after a successful sync. */ dirtyAt: number | null; /** True when a sync found both sides changed (the UI asks what to keep). */ conflict: boolean; }; const EMPTY: SyncRecord = { v: 1, enabled: false, id: "", salt: "", lastSyncedAt: null, dirtyAt: null, conflict: false, }; type Listener = () => void; let record: SyncRecord | null = null; let cached: SyncRecord = EMPTY; const listeners = new Set(); let suppressDirty = false; function read(): SyncRecord { if (typeof window === "undefined") return EMPTY; try { const raw = window.localStorage.getItem(STORAGE_KEY); if (!raw) return EMPTY; const parsed = JSON.parse(raw) as Partial; if (parsed.v !== 1) return EMPTY; return { v: 1, enabled: parsed.enabled === true, id: typeof parsed.id === "string" ? parsed.id : "", salt: typeof parsed.salt === "string" ? parsed.salt : "", lastSyncedAt: typeof parsed.lastSyncedAt === "number" ? parsed.lastSyncedAt : null, dirtyAt: typeof parsed.dirtyAt === "number" ? parsed.dirtyAt : null, conflict: parsed.conflict === true, }; } catch { return EMPTY; } } function ensureLoaded(): void { if (record !== null) return; record = read(); cached = record; } function write(next: SyncRecord): void { record = next; cached = next; try { window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); } catch { /* private mode */ } listeners.forEach((listener) => listener()); } export const syncStore = { subscribe(listener: Listener): () => void { ensureLoaded(); listeners.add(listener); const onStorage = (event: StorageEvent) => { if (event.key === STORAGE_KEY) { record = null; ensureLoaded(); listeners.forEach((l) => l()); } }; window.addEventListener("storage", onStorage); return () => { listeners.delete(listener); window.removeEventListener("storage", onStorage); }; }, getSnapshot(): SyncRecord { ensureLoaded(); return cached; }, getServerSnapshot(): SyncRecord { return EMPTY; }, }; export function setSyncRecord(next: Partial): void { ensureLoaded(); write({ ...syncStore.getSnapshot(), ...next }); } /** Called by every data store's write(); no-op when sync is off. */ export function markSyncDirty(): void { if (suppressDirty) return; ensureLoaded(); const current = syncStore.getSnapshot(); if (!current.enabled || current.dirtyAt !== null) return; write({ ...current, dirtyAt: Date.now() }); } /** Applying a pulled payload must not mark the local data dirty again. */ export function withoutDirtyTracking(fn: () => T): T { suppressDirty = true; try { return fn(); } finally { suppressDirty = false; } } /** 16 random bytes as hex = 128 bits, unguessable. */ export function newSyncId(): string { const bytes = new Uint8Array(16); crypto.getRandomValues(bytes); return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); }