sync-lifecycle.ts

7.6 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;3031async function getOrCreateDeviceId(): Promise<string> {32  try {33    const stored = await chrome.storage.local.get(DEVICE_ID_KEY);34    const value = (stored as Record<string, string | undefined>)[DEVICE_ID_KEY];35    if (typeof value === "string" && value) return value;36  } catch {37    // fall through38  }39  // base64url-encoded 16-byte random — matches the byte length the native40  // host expects (see Advertise message decoding).41  const random = crypto.getRandomValues(new Uint8Array(16));42  let binary = "";43  for (const byte of random) binary += String.fromCharCode(byte);44  const id = btoa(binary)45    .replace(/\+/g, "-")46    .replace(/\//g, "_")47    .replace(/=+$/g, "");48  try {49    await chrome.storage.local.set({ [DEVICE_ID_KEY]: id });50  } catch {51    // best-effort52  }53  return id;54}5556async function getDeviceName(): Promise<string> {57  try {58    const stored = await chrome.storage.local.get(DEVICE_NAME_KEY);59    const value = (stored as Record<string, string | undefined>)[DEVICE_NAME_KEY];60    if (typeof value === "string" && value.trim()) return value.trim();61  } catch {62    // fall through63  }64  if (Capacitor.getPlatform() === "android") return "Peach Android";65  if (Capacitor.getPlatform() === "ios") return "Peach iOS";66  return "Peach";67}6869/**70 * Reconcile mDNS advertisement with current state. Idempotent.71 */72export async function syncLifecycleTick(): Promise<void> {73  // Capacitor (Android) does not run a native messaging host; sync there74  // happens via the Capacitor bridge which manages its own discovery.75  if (Capacitor.isNativePlatform()) return;7677  if (!nativeHostConnected) {78    advertisingActive = false;79    return;80  }8182  let shouldAdvertise = false;83  try {84    const [pro, paired] = await Promise.all([isProUser(), getPairedDevices()]);85    shouldAdvertise = pro && vault.isUnlocked() && paired.length > 0;86  } catch {87    shouldAdvertise = false;88  }8990  if (shouldAdvertise && !advertisingActive) {91    try {92      const [deviceId, name] = await Promise.all([getOrCreateDeviceId(), getDeviceName()]);93      await sync.advertise({ deviceId, name });94      advertisingActive = true;95    } catch (err) {96      console.warn("sync advertise failed:", err);97    }98  } else if (!shouldAdvertise && advertisingActive) {99    try {100      await sync.stopAdvertising();101    } catch {102      // best-effort103    }104    advertisingActive = false;105  }106}107108/**109 * Subscribe once to incoming sync connections from the native host. Routes110 * pairing-only connections into the session-stored IncomingPairingRequest the111 * popup banner reads from; data-sync connections are handed off to a112 * runBlobSyncSession in responder mode.113 */114export function setupIncomingPairingSubscriber(): void {115  if (incomingSubscriberInstalled) return;116  incomingSubscriberInstalled = true;117118  sync.onIncomingConnection((info) => {119    void handleIncomingConnection(info);120  });121}122123async function handleIncomingConnection(info: sync.IncomingConnectionInfo): Promise<void> {124  if (info.isPairingOnly) {125    await waitForPairRequest(info);126    return;127  }128  // Trusted data-sync connection — accept and run a responder session.129  await runResponderSession(info.connectionId);130}131132async function waitForPairRequest(info: sync.IncomingConnectionInfo): Promise<void> {133  const unsubscribe = sync.onSyncMessage((id, frame) => {134    if (id !== info.connectionId) return;135    unsubscribe();136    let publicKeyHex = info.peerPublicKeyHex ?? "";137    let deviceName = "Unknown device";138    try {139      const decoded = decodeSyncMessage(frame);140      if (decoded.type === "PAIR_REQUEST") {141        publicKeyHex = decoded.public_key_hex || publicKeyHex;142        deviceName = decoded.device_name || deviceName;143      } else {144        // Unexpected first frame on a pairing-only connection — drop.145        try { sync.disconnectFromPeer(info.connectionId); } catch { /* best-effort */ }146        return;147      }148    } catch {149      try { sync.disconnectFromPeer(info.connectionId); } catch { /* best-effort */ }150      return;151    }152153    const now = Date.now();154    void setIncomingPairingRequest({155      deviceName,156      publicKeyHex,157      receivedAt: now,158      expiresAt: now + 60_000,159      connectionId: info.connectionId,160    });161162    // Tell the popup. If it isn't open the runtime message no-ops; the163    // request still sits in session storage for the next popup open.164    chrome.runtime165      .sendMessage({ type: "INCOMING_PAIRING_REQUEST", payload: { deviceName } })166      .catch(() => {});167  });168169  // Auto-cleanup if the connection drops before any frame arrives.170  const offClose = sync.onDisconnected((id) => {171    if (id === undefined || id === info.connectionId) {172      try { unsubscribe(); } catch { /* best-effort */ }173      try { offClose(); } catch { /* best-effort */ }174    }175  });176}177178async function runResponderSession(connectionId: string): Promise<void> {179  try {180    const { createNativeSyncTransport, runBlobSyncSession } = await import(181      "./sync-coordinator"182    );183    const { storageGet } = await import("../shared/storage");184    const transport = await createNativeSyncTransport(connectionId);185    await runBlobSyncSession("responder", transport, {186      isPro: () => isProUser(),187      isUnlocked: () => vault.isUnlocked(),188      getVaultBlobs: async () => ({189        vault_a: (await storageGet("peach_vault_a")) ?? "",190        vault_b: (await storageGet("peach_vault_b")) ?? "",191      }),192    });193  } catch (err) {194    console.warn("responder sync session failed:", err);195  }196}197198// ---------------------------------------------------------------------------199// External-event hooks. The background's lifecycle layer (entry.ts) calls200// these on relevant transitions so the lifecycle tick is always coherent.201// ---------------------------------------------------------------------------202203export function onNativeHostConnected(): void {204  nativeHostConnected = true;205  setupIncomingPairingSubscriber();206  void syncLifecycleTick();207}208209export function onNativeHostDisconnected(): void {210  nativeHostConnected = false;211  advertisingActive = false;212}213214export async function onVaultUnlocked(): Promise<void> {215  await syncLifecycleTick();216}217218export async function onVaultLocked(): Promise<void> {219  await syncLifecycleTick();220}221222export async function onProActivated(): Promise<void> {223  await syncLifecycleTick();224}225226/**227 * Test-only — reset the module-local mutable state so each test starts from a228 * known baseline. Production code never calls this.229 */230export function __resetForTests(): void {231  advertisingActive = false;232  nativeHostConnected = false;233  incomingSubscriberInstalled = false;234}