sync-lifecycle.ts

10.2 KB · raw

1/**2 * Sync lifecycle — auto-advertise + incoming pairing subscriber.3 *4 * Keeps mDNS advertisement in sync with vault state and license:5 *   - Pro + unlocked + at least one paired device → advertise6 *   - Anything missing → stop advertising7 *8 * Also listens for incoming pairing-only connections from the native host,9 * reads the first PAIR_REQUEST frame, stores the request in session storage10 * (with the connection id) and pings the popup so the banner appears.11 *12 * The sync stack is best-effort: if the native host is not installed yet,13 * every advertise/listen call no-ops gracefully and the lifecycle still14 * progresses through state changes for when the host arrives.15 */1617import { Capacitor } from "@capacitor/core";18import * as sync from "./sync";19import { isProUser } from "./license";20import * as vault from "./vault";21import { getPairedDevices, setIncomingPairingRequest } from "./sync-storage";22import { decodeSyncMessage } from "../shared/sync-wire";2324const DEVICE_ID_KEY = "peach_device_id";25const DEVICE_NAME_KEY = "peach_device_name";2627let advertisingActive = false;28let nativeHostConnected = false;29let incomingSubscriberInstalled = false;30let peerCacheInstalled = false;31const peerCache = new Map<string, sync.PeerInfo>();32const LAST_SYNC_KEY = "peach_last_sync_at";33const AUTO_SYNC_DEBOUNCE_MS = 60_000;3435function ensurePeerCache(): void {36  if (peerCacheInstalled) return;37  peerCacheInstalled = true;38  sync.onPeerDiscovered((peer) => {39    peerCache.set(peer.x25519PublicKey, peer);40    void maybeAutoSync(peer);41  });42  sync.onPeerLost((deviceId) => {43    for (const [key, peer] of peerCache) {44      if (peer.deviceId === deviceId) {45        peerCache.delete(key);46        break;47      }48    }49  });50}5152export function getDiscoveredPeerCache(): sync.PeerInfo[] {53  return Array.from(peerCache.values());54}5556async function maybeAutoSync(peer: sync.PeerInfo): Promise<void> {57  if (Capacitor.isNativePlatform()) return;58  try {59    const paired = await getPairedDevices();60    if (!paired.some((d) => d.x25519PublicKey === peer.x25519PublicKey)) return;61    if (!vault.isUnlocked()) return;62    if (!(await isProUser())) return;6364    // 60s debounce — don't hammer a peer that just appeared after a short blip.65    const lastRaw = await readStorage(LAST_SYNC_KEY);66    if (lastRaw) {67      const last = Number.parseInt(lastRaw, 10);68      if (Number.isFinite(last) && Date.now() - last < AUTO_SYNC_DEBOUNCE_MS) return;69    }7071    const { createNativeSyncTransport, runBlobSyncSession } = await import(72      "./sync-coordinator"73    );74    const { storageGet, storageSet } = await import("../shared/storage");7576    const connectionId = await sync.connectToPeer(peer.host, peer.port, peer.x25519PublicKey);77    const transport = await createNativeSyncTransport(connectionId);78    await runBlobSyncSession("initiator", transport, {79      isPro: () => isProUser(),80      isUnlocked: () => vault.isUnlocked(),81      getVaultBlobs: async () => ({82        vault_a: (await storageGet("peach_vault_a")) ?? "",83        vault_b: (await storageGet("peach_vault_b")) ?? "",84      }),85    });86    await storageSet(LAST_SYNC_KEY, String(Date.now()));87  } catch (err) {88    // Auto-sync is silent — user can retry manually from Settings.89    console.warn("auto-sync failed:", err);90  }91}9293async function readStorage(key: string): Promise<string | null> {94  try {95    const result = await chrome.storage.local.get(key);96    const value = (result as Record<string, string | undefined>)[key];97    return typeof value === "string" ? value : null;98  } catch {99    return null;100  }101}102103async function getOrCreateDeviceId(): Promise<string> {104  try {105    const stored = await chrome.storage.local.get(DEVICE_ID_KEY);106    const value = (stored as Record<string, string | undefined>)[DEVICE_ID_KEY];107    if (typeof value === "string" && value) return value;108  } catch {109    // fall through110  }111  // base64url-encoded 16-byte random — matches the byte length the native112  // host expects (see Advertise message decoding).113  const random = crypto.getRandomValues(new Uint8Array(16));114  let binary = "";115  for (const byte of random) binary += String.fromCharCode(byte);116  const id = btoa(binary)117    .replace(/\+/g, "-")118    .replace(/\//g, "_")119    .replace(/=+$/g, "");120  try {121    await chrome.storage.local.set({ [DEVICE_ID_KEY]: id });122  } catch {123    // best-effort124  }125  return id;126}127128async function getDeviceName(): Promise<string> {129  try {130    const stored = await chrome.storage.local.get(DEVICE_NAME_KEY);131    const value = (stored as Record<string, string | undefined>)[DEVICE_NAME_KEY];132    if (typeof value === "string" && value.trim()) return value.trim();133  } catch {134    // fall through135  }136  if (Capacitor.getPlatform() === "android") return "Peach Android";137  if (Capacitor.getPlatform() === "ios") return "Peach iOS";138  return "Peach";139}140141/**142 * Reconcile mDNS advertisement with current state. Idempotent.143 */144export async function syncLifecycleTick(): Promise<void> {145  // Capacitor (Android) does not run a native messaging host; sync there146  // happens via the Capacitor bridge which manages its own discovery.147  if (Capacitor.isNativePlatform()) return;148149  if (!nativeHostConnected) {150    advertisingActive = false;151    return;152  }153154  let shouldAdvertise = false;155  try {156    const [pro, paired] = await Promise.all([isProUser(), getPairedDevices()]);157    shouldAdvertise = pro && vault.isUnlocked() && paired.length > 0;158  } catch {159    shouldAdvertise = false;160  }161162  if (shouldAdvertise && !advertisingActive) {163    try {164      const [deviceId, name] = await Promise.all([getOrCreateDeviceId(), getDeviceName()]);165      await sync.advertise({ deviceId, name });166      advertisingActive = true;167    } catch (err) {168      console.warn("sync advertise failed:", err);169    }170  } else if (!shouldAdvertise && advertisingActive) {171    try {172      await sync.stopAdvertising();173    } catch {174      // best-effort175    }176    advertisingActive = false;177  }178}179180/**181 * Subscribe once to incoming sync connections from the native host. Routes182 * pairing-only connections into the session-stored IncomingPairingRequest the183 * popup banner reads from; data-sync connections are handed off to a184 * runBlobSyncSession in responder mode.185 */186export function setupIncomingPairingSubscriber(): void {187  if (incomingSubscriberInstalled) return;188  incomingSubscriberInstalled = true;189190  sync.onIncomingConnection((info) => {191    void handleIncomingConnection(info);192  });193}194195async function handleIncomingConnection(info: sync.IncomingConnectionInfo): Promise<void> {196  if (info.isPairingOnly) {197    await waitForPairRequest(info);198    return;199  }200  // Trusted data-sync connection — accept and run a responder session.201  await runResponderSession(info.connectionId);202}203204async function waitForPairRequest(info: sync.IncomingConnectionInfo): Promise<void> {205  const unsubscribe = sync.onSyncMessage((id, frame) => {206    if (id !== info.connectionId) return;207    unsubscribe();208    let publicKeyHex = info.peerPublicKeyHex ?? "";209    let deviceName = "Unknown device";210    try {211      const decoded = decodeSyncMessage(frame);212      if (decoded.type === "PAIR_REQUEST") {213        publicKeyHex = decoded.public_key_hex || publicKeyHex;214        deviceName = decoded.device_name || deviceName;215      } else {216        // Unexpected first frame on a pairing-only connection — drop.217        try { sync.disconnectFromPeer(info.connectionId); } catch { /* best-effort */ }218        return;219      }220    } catch {221      try { sync.disconnectFromPeer(info.connectionId); } catch { /* best-effort */ }222      return;223    }224225    const now = Date.now();226    void setIncomingPairingRequest({227      deviceName,228      publicKeyHex,229      receivedAt: now,230      expiresAt: now + 60_000,231      connectionId: info.connectionId,232    });233234    // Tell the popup. If it isn't open the runtime message no-ops; the235    // request still sits in session storage for the next popup open.236    chrome.runtime237      .sendMessage({ type: "INCOMING_PAIRING_REQUEST", payload: { deviceName } })238      .catch(() => {});239  });240241  // Auto-cleanup if the connection drops before any frame arrives.242  const offClose = sync.onDisconnected((id) => {243    if (id === undefined || id === info.connectionId) {244      try { unsubscribe(); } catch { /* best-effort */ }245      try { offClose(); } catch { /* best-effort */ }246    }247  });248}249250async function runResponderSession(connectionId: string): Promise<void> {251  try {252    const { createNativeSyncTransport, runBlobSyncSession } = await import(253      "./sync-coordinator"254    );255    const { storageGet } = await import("../shared/storage");256    const transport = await createNativeSyncTransport(connectionId);257    await runBlobSyncSession("responder", transport, {258      isPro: () => isProUser(),259      isUnlocked: () => vault.isUnlocked(),260      getVaultBlobs: async () => ({261        vault_a: (await storageGet("peach_vault_a")) ?? "",262        vault_b: (await storageGet("peach_vault_b")) ?? "",263      }),264    });265  } catch (err) {266    console.warn("responder sync session failed:", err);267  }268}269270// ---------------------------------------------------------------------------271// External-event hooks. The background's lifecycle layer (entry.ts) calls272// these on relevant transitions so the lifecycle tick is always coherent.273// ---------------------------------------------------------------------------274275export function onNativeHostConnected(): void {276  nativeHostConnected = true;277  setupIncomingPairingSubscriber();278  ensurePeerCache();279  // Kick off discovery so the cache populates and auto-sync can fire.280  try {281    sync.discoverPeers();282  } catch {283    // best-effort284  }285  void syncLifecycleTick();286}287288export function onNativeHostDisconnected(): void {289  nativeHostConnected = false;290  advertisingActive = false;291}292293export async function onVaultUnlocked(): Promise<void> {294  await syncLifecycleTick();295}296297export async function onVaultLocked(): Promise<void> {298  await syncLifecycleTick();299}300301export async function onProActivated(): Promise<void> {302  await syncLifecycleTick();303}304305/**306 * Test-only — reset the module-local mutable state so each test starts from a307 * known baseline. Production code never calls this.308 */309export function __resetForTests(): void {310  advertisingActive = false;311  nativeHostConnected = false;312  incomingSubscriberInstalled = false;313}