/** * 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 { getPairedDevices, setIncomingPairingRequest } from "./sync-storage"; import { decodeSyncMessage } from "../shared/sync-wire"; const DEVICE_ID_KEY = "peach_device_id"; const DEVICE_NAME_KEY = "peach_device_name"; let advertisingActive = false; let nativeHostConnected = false; let incomingSubscriberInstalled = false; 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; } const now = Date.now(); void setIncomingPairingRequest({ deviceName, publicKeyHex, receivedAt: now, expiresAt: now + 60_000, connectionId: info.connectionId, }); // Tell the popup. If it isn't open the runtime message no-ops; the // request still sits in session storage for the next popup open. chrome.runtime .sendMessage({ type: "INCOMING_PAIRING_REQUEST", payload: { deviceName } }) .catch(() => {}); }); // 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 */ } } }); } 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(); void syncLifecycleTick(); } export function onNativeHostDisconnected(): void { nativeHostConnected = false; advertisingActive = false; } export async function onVaultUnlocked(): Promise { await syncLifecycleTick(); } export async function onVaultLocked(): Promise { 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; }