/** * Sync lifecycle — auto-advertise + incoming pairing subscriber. * * Keeps mDNS advertisement in sync with vault state and license: * - Pro + unlocked + at least one paired device → advertise * - Anything missing → stop advertising * * Also listens for incoming pairing-only connections from the native host, * reads the first PAIR_REQUEST frame, stores the request in session storage * (with the connection id) and pings the popup so the banner appears. * * The sync stack is best-effort: if the native host is not installed yet, * every advertise/listen call no-ops gracefully and the lifecycle still * progresses through state changes for when the host arrives. */ import { Capacitor } from "@capacitor/core"; import * as sync from "./sync"; import { isProUser } from "./license"; import * as vault from "./vault"; import { addPairedDevice, getPairedDevices, setIncomingPairingRequest } from "./sync-storage"; import { decodeSyncMessage, encodeSyncMessage } from "../shared/sync-wire"; import { getActivePairingToken, setActivePairingToken, setRecentPairing, } from "./pairing-server-state"; const DEVICE_ID_KEY = "peach_device_id"; const DEVICE_NAME_KEY = "peach_device_name"; let advertisingActive = false; let nativeHostConnected = false; let incomingSubscriberInstalled = false; let peerCacheInstalled = false; const peerCache = new Map(); const LAST_SYNC_KEY = "peach_last_sync_at"; const AUTO_SYNC_DEBOUNCE_MS = 60_000; function ensurePeerCache(): void { if (peerCacheInstalled) return; peerCacheInstalled = true; sync.onPeerDiscovered((peer) => { peerCache.set(peer.x25519PublicKey, peer); void maybeAutoSync(peer); }); sync.onPeerLost((deviceId) => { for (const [key, peer] of peerCache) { if (peer.deviceId === deviceId) { peerCache.delete(key); break; } } }); } export function getDiscoveredPeerCache(): sync.PeerInfo[] { return Array.from(peerCache.values()); } async function maybeAutoSync(peer: sync.PeerInfo): Promise { if (Capacitor.isNativePlatform()) return; try { const paired = await getPairedDevices(); if (!paired.some((d) => d.x25519PublicKey === peer.x25519PublicKey)) return; if (!vault.isUnlocked()) return; if (!(await isProUser())) return; // 60s debounce — don't hammer a peer that just appeared after a short blip. const lastRaw = await readStorage(LAST_SYNC_KEY); if (lastRaw) { const last = Number.parseInt(lastRaw, 10); if (Number.isFinite(last) && Date.now() - last < AUTO_SYNC_DEBOUNCE_MS) return; } const { createNativeSyncTransport, runBlobSyncSession } = await import( "./sync-coordinator" ); const { storageGet, storageSet } = await import("../shared/storage"); const connectionId = await sync.connectToPeer(peer.host, peer.port, peer.x25519PublicKey); const transport = await createNativeSyncTransport(connectionId); await runBlobSyncSession("initiator", transport, { isPro: () => isProUser(), isUnlocked: () => vault.isUnlocked(), getVaultBlobs: async () => ({ vault_a: (await storageGet("peach_vault_a")) ?? "", vault_b: (await storageGet("peach_vault_b")) ?? "", }), }); await storageSet(LAST_SYNC_KEY, String(Date.now())); } catch (err) { // Auto-sync is silent — user can retry manually from Settings. console.warn("auto-sync failed:", err); } } async function readStorage(key: string): Promise { try { const result = await chrome.storage.local.get(key); const value = (result as Record)[key]; return typeof value === "string" ? value : null; } catch { return null; } } async function getOrCreateDeviceId(): Promise { try { const stored = await chrome.storage.local.get(DEVICE_ID_KEY); const value = (stored as Record)[DEVICE_ID_KEY]; if (typeof value === "string" && value) return value; } catch { // fall through } // base64url-encoded 16-byte random — matches the byte length the native // host expects (see Advertise message decoding). const random = crypto.getRandomValues(new Uint8Array(16)); let binary = ""; for (const byte of random) binary += String.fromCharCode(byte); const id = btoa(binary) .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=+$/g, ""); try { await chrome.storage.local.set({ [DEVICE_ID_KEY]: id }); } catch { // best-effort } return id; } async function getDeviceName(): Promise { try { const stored = await chrome.storage.local.get(DEVICE_NAME_KEY); const value = (stored as Record)[DEVICE_NAME_KEY]; if (typeof value === "string" && value.trim()) return value.trim(); } catch { // fall through } if (Capacitor.getPlatform() === "android") return "Peach Android"; if (Capacitor.getPlatform() === "ios") return "Peach iOS"; return "Peach"; } /** * Reconcile mDNS advertisement with current state. Idempotent. */ export async function syncLifecycleTick(): Promise { // Capacitor (Android) does not run a native messaging host; sync there // happens via the Capacitor bridge which manages its own discovery. if (Capacitor.isNativePlatform()) return; if (!nativeHostConnected) { advertisingActive = false; return; } let shouldAdvertise = false; try { const [pro, paired] = await Promise.all([isProUser(), getPairedDevices()]); shouldAdvertise = pro && vault.isUnlocked() && paired.length > 0; } catch { shouldAdvertise = false; } if (shouldAdvertise && !advertisingActive) { try { const [deviceId, name] = await Promise.all([getOrCreateDeviceId(), getDeviceName()]); await sync.advertise({ deviceId, name }); advertisingActive = true; } catch (err) { console.warn("sync advertise failed:", err); } } else if (!shouldAdvertise && advertisingActive) { try { await sync.stopAdvertising(); } catch { // best-effort } advertisingActive = false; } } /** * Subscribe once to incoming sync connections from the native host. Routes * pairing-only connections into the session-stored IncomingPairingRequest the * popup banner reads from; data-sync connections are handed off to a * runBlobSyncSession in responder mode. */ export function setupIncomingPairingSubscriber(): void { if (incomingSubscriberInstalled) return; incomingSubscriberInstalled = true; sync.onIncomingConnection((info) => { void handleIncomingConnection(info); }); } async function handleIncomingConnection(info: sync.IncomingConnectionInfo): Promise { if (info.isPairingOnly) { await waitForPairRequest(info); return; } // Trusted data-sync connection — accept and run a responder session. await runResponderSession(info.connectionId); } async function waitForPairRequest(info: sync.IncomingConnectionInfo): Promise { const unsubscribe = sync.onSyncMessage((id, frame) => { if (id !== info.connectionId) return; unsubscribe(); let publicKeyHex = info.peerPublicKeyHex ?? ""; let deviceName = "Unknown device"; try { const decoded = decodeSyncMessage(frame); if (decoded.type === "PAIR_REQUEST") { publicKeyHex = decoded.public_key_hex || publicKeyHex; deviceName = decoded.device_name || deviceName; } else { // Unexpected first frame on a pairing-only connection — drop. try { sync.disconnectFromPeer(info.connectionId); } catch { /* best-effort */ } return; } } catch { try { sync.disconnectFromPeer(info.connectionId); } catch { /* best-effort */ } return; } void completePairRequest(info.connectionId, publicKeyHex, deviceName); }); // Auto-cleanup if the connection drops before any frame arrives. const offClose = sync.onDisconnected((id) => { if (id === undefined || id === info.connectionId) { try { unsubscribe(); } catch { /* best-effort */ } try { offClose(); } catch { /* best-effort */ } } }); } /** * Either auto-accept (active 6-digit code session) or queue the banner. * The user's act of showing a code IS the consent, so a PAIR_REQUEST that * arrives within an active code window does not need a second confirmation. */ async function completePairRequest( connectionId: string, publicKeyHex: string, deviceName: string, ): Promise { const activeToken = await getActivePairingToken(); if (activeToken) { try { const ourKey = await sync.getIdentityPublicKey(); const ourName = await getDeviceName(); sync.sendSyncData( connectionId, encodeSyncMessage({ type: "PAIR_ACCEPT", public_key_hex: ourKey, device_name: ourName, }), ); await sync.trustDevice(publicKeyHex, deviceName); try { await addPairedDevice({ deviceId: publicKeyHex, displayName: deviceName, x25519PublicKey: publicKeyHex, pairedAt: Date.now(), lastSeenAt: Date.now(), }); } catch { // Already paired — fine, idempotent. } await setRecentPairing({ deviceName, pairedAt: Date.now(), side: "showing", }); // One code = one pairing. Clear the token so a second peer can't // sneak in on the same window. await setActivePairingToken(null); try { sync.stopPairingServer(); } catch { /* best-effort */ } chrome.runtime .sendMessage({ type: "PAIRING_COMPLETED", payload: { deviceName } }) .catch(() => {}); } catch (err) { console.warn("auto-accept pairing failed:", err); } finally { try { sync.disconnectFromPeer(connectionId); } catch { /* best-effort */ } } return; } // No active code — fall back to the legacy banner flow (kept as a no-op // unless something off-flow drops a pairing-only connection on us). const now = Date.now(); await setIncomingPairingRequest({ deviceName, publicKeyHex, receivedAt: now, expiresAt: now + 60_000, connectionId, }); chrome.runtime .sendMessage({ type: "INCOMING_PAIRING_REQUEST", payload: { deviceName } }) .catch(() => {}); } async function runResponderSession(connectionId: string): Promise { try { const { createNativeSyncTransport, runBlobSyncSession } = await import( "./sync-coordinator" ); const { storageGet } = await import("../shared/storage"); const transport = await createNativeSyncTransport(connectionId); await runBlobSyncSession("responder", transport, { isPro: () => isProUser(), isUnlocked: () => vault.isUnlocked(), getVaultBlobs: async () => ({ vault_a: (await storageGet("peach_vault_a")) ?? "", vault_b: (await storageGet("peach_vault_b")) ?? "", }), }); } catch (err) { console.warn("responder sync session failed:", err); } } // --------------------------------------------------------------------------- // External-event hooks. The background's lifecycle layer (entry.ts) calls // these on relevant transitions so the lifecycle tick is always coherent. // --------------------------------------------------------------------------- export function onNativeHostConnected(): void { nativeHostConnected = true; setupIncomingPairingSubscriber(); ensurePeerCache(); // Kick off discovery so the cache populates and auto-sync can fire. try { sync.discoverPeers(); } catch { // best-effort } void syncLifecycleTick(); } export function onNativeHostDisconnected(): void { nativeHostConnected = false; advertisingActive = false; } export async function onVaultUnlocked(): Promise { await syncLifecycleTick(); } export async function onVaultLocked(): Promise { // A locked Peach must not be accepting new trust relationships — tear down // any active 6-digit pairing window before reconciling mDNS. try { await setActivePairingToken(null); if (nativeHostConnected) { try { await sync.stopPairingServer(); } catch { /* best-effort */ } } } catch { // best-effort } await syncLifecycleTick(); } export async function onProActivated(): Promise { await syncLifecycleTick(); } /** * Test-only — reset the module-local mutable state so each test starts from a * known baseline. Production code never calls this. */ export function __resetForTests(): void { advertisingActive = false; nativeHostConnected = false; incomingSubscriberInstalled = false; }