sync-lifecycle.ts

12.4 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 { addPairedDevice, getPairedDevices, setIncomingPairingRequest } from "./sync-storage";22import { decodeSyncMessage, encodeSyncMessage } from "../shared/sync-wire";23import {24  getActivePairingToken,25  setActivePairingToken,26  setRecentPairing,27} from "./pairing-server-state";2829const DEVICE_ID_KEY = "peach_device_id";30const DEVICE_NAME_KEY = "peach_device_name";3132let advertisingActive = false;33let nativeHostConnected = false;34let incomingSubscriberInstalled = false;35let peerCacheInstalled = false;36const peerCache = new Map<string, sync.PeerInfo>();37const LAST_SYNC_KEY = "peach_last_sync_at";38const AUTO_SYNC_DEBOUNCE_MS = 60_000;3940function ensurePeerCache(): void {41  if (peerCacheInstalled) return;42  peerCacheInstalled = true;43  sync.onPeerDiscovered((peer) => {44    peerCache.set(peer.x25519PublicKey, peer);45    void maybeAutoSync(peer);46  });47  sync.onPeerLost((deviceId) => {48    for (const [key, peer] of peerCache) {49      if (peer.deviceId === deviceId) {50        peerCache.delete(key);51        break;52      }53    }54  });55}5657export function getDiscoveredPeerCache(): sync.PeerInfo[] {58  return Array.from(peerCache.values());59}6061async function maybeAutoSync(peer: sync.PeerInfo): Promise<void> {62  if (Capacitor.isNativePlatform()) return;63  try {64    const paired = await getPairedDevices();65    if (!paired.some((d) => d.x25519PublicKey === peer.x25519PublicKey)) return;66    if (!vault.isUnlocked()) return;67    if (!(await isProUser())) return;6869    // 60s debounce — don't hammer a peer that just appeared after a short blip.70    const lastRaw = await readStorage(LAST_SYNC_KEY);71    if (lastRaw) {72      const last = Number.parseInt(lastRaw, 10);73      if (Number.isFinite(last) && Date.now() - last < AUTO_SYNC_DEBOUNCE_MS) return;74    }7576    const { createNativeSyncTransport, runBlobSyncSession } = await import(77      "./sync-coordinator"78    );79    const { storageGet, storageSet } = await import("../shared/storage");8081    const connectionId = await sync.connectToPeer(peer.host, peer.port, peer.x25519PublicKey);82    const transport = await createNativeSyncTransport(connectionId);83    await runBlobSyncSession("initiator", transport, {84      isPro: () => isProUser(),85      isUnlocked: () => vault.isUnlocked(),86      getVaultBlobs: async () => ({87        vault_a: (await storageGet("peach_vault_a")) ?? "",88        vault_b: (await storageGet("peach_vault_b")) ?? "",89      }),90    });91    await storageSet(LAST_SYNC_KEY, String(Date.now()));92  } catch (err) {93    // Auto-sync is silent — user can retry manually from Settings.94    console.warn("auto-sync failed:", err);95  }96}9798async function readStorage(key: string): Promise<string | null> {99  try {100    const result = await chrome.storage.local.get(key);101    const value = (result as Record<string, string | undefined>)[key];102    return typeof value === "string" ? value : null;103  } catch {104    return null;105  }106}107108async function getOrCreateDeviceId(): Promise<string> {109  try {110    const stored = await chrome.storage.local.get(DEVICE_ID_KEY);111    const value = (stored as Record<string, string | undefined>)[DEVICE_ID_KEY];112    if (typeof value === "string" && value) return value;113  } catch {114    // fall through115  }116  // base64url-encoded 16-byte random — matches the byte length the native117  // host expects (see Advertise message decoding).118  const random = crypto.getRandomValues(new Uint8Array(16));119  let binary = "";120  for (const byte of random) binary += String.fromCharCode(byte);121  const id = btoa(binary)122    .replace(/\+/g, "-")123    .replace(/\//g, "_")124    .replace(/=+$/g, "");125  try {126    await chrome.storage.local.set({ [DEVICE_ID_KEY]: id });127  } catch {128    // best-effort129  }130  return id;131}132133async function getDeviceName(): Promise<string> {134  try {135    const stored = await chrome.storage.local.get(DEVICE_NAME_KEY);136    const value = (stored as Record<string, string | undefined>)[DEVICE_NAME_KEY];137    if (typeof value === "string" && value.trim()) return value.trim();138  } catch {139    // fall through140  }141  if (Capacitor.getPlatform() === "android") return "Peach Android";142  if (Capacitor.getPlatform() === "ios") return "Peach iOS";143  return "Peach";144}145146/**147 * Reconcile mDNS advertisement with current state. Idempotent.148 */149export async function syncLifecycleTick(): Promise<void> {150  // Capacitor (Android) does not run a native messaging host; sync there151  // happens via the Capacitor bridge which manages its own discovery.152  if (Capacitor.isNativePlatform()) return;153154  if (!nativeHostConnected) {155    advertisingActive = false;156    return;157  }158159  let shouldAdvertise = false;160  try {161    const [pro, paired] = await Promise.all([isProUser(), getPairedDevices()]);162    shouldAdvertise = pro && vault.isUnlocked() && paired.length > 0;163  } catch {164    shouldAdvertise = false;165  }166167  if (shouldAdvertise && !advertisingActive) {168    try {169      const [deviceId, name] = await Promise.all([getOrCreateDeviceId(), getDeviceName()]);170      await sync.advertise({ deviceId, name });171      advertisingActive = true;172    } catch (err) {173      console.warn("sync advertise failed:", err);174    }175  } else if (!shouldAdvertise && advertisingActive) {176    try {177      await sync.stopAdvertising();178    } catch {179      // best-effort180    }181    advertisingActive = false;182  }183}184185/**186 * Subscribe once to incoming sync connections from the native host. Routes187 * pairing-only connections into the session-stored IncomingPairingRequest the188 * popup banner reads from; data-sync connections are handed off to a189 * runBlobSyncSession in responder mode.190 */191export function setupIncomingPairingSubscriber(): void {192  if (incomingSubscriberInstalled) return;193  incomingSubscriberInstalled = true;194195  sync.onIncomingConnection((info) => {196    void handleIncomingConnection(info);197  });198}199200async function handleIncomingConnection(info: sync.IncomingConnectionInfo): Promise<void> {201  if (info.isPairingOnly) {202    await waitForPairRequest(info);203    return;204  }205  // Trusted data-sync connection — accept and run a responder session.206  await runResponderSession(info.connectionId);207}208209async function waitForPairRequest(info: sync.IncomingConnectionInfo): Promise<void> {210  const unsubscribe = sync.onSyncMessage((id, frame) => {211    if (id !== info.connectionId) return;212    unsubscribe();213    let publicKeyHex = info.peerPublicKeyHex ?? "";214    let deviceName = "Unknown device";215    try {216      const decoded = decodeSyncMessage(frame);217      if (decoded.type === "PAIR_REQUEST") {218        publicKeyHex = decoded.public_key_hex || publicKeyHex;219        deviceName = decoded.device_name || deviceName;220      } else {221        // Unexpected first frame on a pairing-only connection — drop.222        try { sync.disconnectFromPeer(info.connectionId); } catch { /* best-effort */ }223        return;224      }225    } catch {226      try { sync.disconnectFromPeer(info.connectionId); } catch { /* best-effort */ }227      return;228    }229230    void completePairRequest(info.connectionId, publicKeyHex, deviceName);231  });232233  // Auto-cleanup if the connection drops before any frame arrives.234  const offClose = sync.onDisconnected((id) => {235    if (id === undefined || id === info.connectionId) {236      try { unsubscribe(); } catch { /* best-effort */ }237      try { offClose(); } catch { /* best-effort */ }238    }239  });240}241242/**243 * Either auto-accept (active 6-digit code session) or queue the banner.244 * The user's act of showing a code IS the consent, so a PAIR_REQUEST that245 * arrives within an active code window does not need a second confirmation.246 */247async function completePairRequest(248  connectionId: string,249  publicKeyHex: string,250  deviceName: string,251): Promise<void> {252  const activeToken = await getActivePairingToken();253  if (activeToken) {254    try {255      const ourKey = await sync.getIdentityPublicKey();256      const ourName = await getDeviceName();257      sync.sendSyncData(258        connectionId,259        encodeSyncMessage({260          type: "PAIR_ACCEPT",261          public_key_hex: ourKey,262          device_name: ourName,263        }),264      );265      await sync.trustDevice(publicKeyHex, deviceName);266      try {267        await addPairedDevice({268          deviceId: publicKeyHex,269          displayName: deviceName,270          x25519PublicKey: publicKeyHex,271          pairedAt: Date.now(),272          lastSeenAt: Date.now(),273        });274      } catch {275        // Already paired — fine, idempotent.276      }277      await setRecentPairing({278        deviceName,279        pairedAt: Date.now(),280        side: "showing",281      });282      // One code = one pairing. Clear the token so a second peer can't283      // sneak in on the same window.284      await setActivePairingToken(null);285      try { sync.stopPairingServer(); } catch { /* best-effort */ }286      chrome.runtime287        .sendMessage({ type: "PAIRING_COMPLETED", payload: { deviceName } })288        .catch(() => {});289    } catch (err) {290      console.warn("auto-accept pairing failed:", err);291    } finally {292      try { sync.disconnectFromPeer(connectionId); } catch { /* best-effort */ }293    }294    return;295  }296297  // No active code — fall back to the legacy banner flow (kept as a no-op298  // unless something off-flow drops a pairing-only connection on us).299  const now = Date.now();300  await setIncomingPairingRequest({301    deviceName,302    publicKeyHex,303    receivedAt: now,304    expiresAt: now + 60_000,305    connectionId,306  });307  chrome.runtime308    .sendMessage({ type: "INCOMING_PAIRING_REQUEST", payload: { deviceName } })309    .catch(() => {});310}311312async function runResponderSession(connectionId: string): Promise<void> {313  try {314    const { createNativeSyncTransport, runBlobSyncSession } = await import(315      "./sync-coordinator"316    );317    const { storageGet } = await import("../shared/storage");318    const transport = await createNativeSyncTransport(connectionId);319    await runBlobSyncSession("responder", transport, {320      isPro: () => isProUser(),321      isUnlocked: () => vault.isUnlocked(),322      getVaultBlobs: async () => ({323        vault_a: (await storageGet("peach_vault_a")) ?? "",324        vault_b: (await storageGet("peach_vault_b")) ?? "",325      }),326    });327  } catch (err) {328    console.warn("responder sync session failed:", err);329  }330}331332// ---------------------------------------------------------------------------333// External-event hooks. The background's lifecycle layer (entry.ts) calls334// these on relevant transitions so the lifecycle tick is always coherent.335// ---------------------------------------------------------------------------336337export function onNativeHostConnected(): void {338  nativeHostConnected = true;339  setupIncomingPairingSubscriber();340  ensurePeerCache();341  // Kick off discovery so the cache populates and auto-sync can fire.342  try {343    sync.discoverPeers();344  } catch {345    // best-effort346  }347  void syncLifecycleTick();348}349350export function onNativeHostDisconnected(): void {351  nativeHostConnected = false;352  advertisingActive = false;353}354355export async function onVaultUnlocked(): Promise<void> {356  await syncLifecycleTick();357}358359export async function onVaultLocked(): Promise<void> {360  // A locked Peach must not be accepting new trust relationships — tear down361  // any active 6-digit pairing window before reconciling mDNS.362  try {363    await setActivePairingToken(null);364    if (nativeHostConnected) {365      try { await sync.stopPairingServer(); } catch { /* best-effort */ }366    }367  } catch {368    // best-effort369  }370  await syncLifecycleTick();371}372373export async function onProActivated(): Promise<void> {374  await syncLifecycleTick();375}376377/**378 * Test-only — reset the module-local mutable state so each test starts from a379 * known baseline. Production code never calls this.380 */381export function __resetForTests(): void {382  advertisingActive = false;383  nativeHostConnected = false;384  incomingSubscriberInstalled = false;385}