89df69da73ab

auto-advertise lifecycle + incoming pairing subscriber wired into entry.ts (v2.14.3)

time
2026-04-30 20:38 UTC · 6 months ago
machine
koh2
parent
ccf8a297e1d5
7 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/alarms.ts+5 −0src/background/entry.ts+22 −0src/background/messages.ts+4 −0src/background/sync-lifecycle.ts+234 −0
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.14.2",4+  "version": "2.14.3",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.14.2",4+  "version": "2.14.3",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "2.14.2",3+  "version": "2.14.3",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/background/alarms.ts+5 −0
@@ -32,6 +32,11 @@3232 }3333 3434 function broadcastLocked(): void {35+  // Notify the sync lifecycle so advertising stops while locked. Lazy-import36+  // to avoid pulling sync.ts into every consumer of alarms.37+  void import("./sync-lifecycle")38+    .then((m) => m.onVaultLocked())39+    .catch(() => {});3540   // Reach the popup (and any other extension UI context) via runtime.3641   chrome.runtime?.sendMessage?.({ type: "VAULT_LOCKED" })?.catch?.(() => {});3742   // Reach content scripts via tabs. Tabs without Peach content scripts

view file

src/background/entry.ts+22 −0
@@ -1,3 +1,4 @@1+import { Capacitor } from "@capacitor/core";12 import { setupMessageHandler } from "./messages";23 import { setupAutoLock } from "./alarms";34 import { setupPasskeyProxy } from "./passkey";@@ -9,11 +10,32 @@910 // The chrome.idle.onStateChanged listener lives in ./alarms (the architectural1011 // home for auto-lock timing). Keeping it in only one place avoids drift.1112 13+async function setupSyncLifecycle(): Promise<void> {14+  if (Capacitor.isNativePlatform()) return;15+  try {16+    const sync = await import("./sync");17+    const lifecycle = await import("./sync-lifecycle");18+    // Register the incoming pairing subscriber once. Best-effort: subsequent19+    // sync.connectSyncHost / advertise calls trigger PEER_CONNECTED events20+    // that route through the subscriber.21+    lifecycle.setupIncomingPairingSubscriber();22+    try {23+      sync.connectSyncHost();24+      lifecycle.onNativeHostConnected();25+    } catch {26+      // Native host not installed yet — lifecycle will engage when it is.27+    }28+  } catch (err) {29+    console.warn("setupSyncLifecycle failed:", err);30+  }31+}32+1233 export function start() {1334   setupMessageHandler();1435   void setupAutoLock();1536   setupCommands();1637   void setupPasskeyProxy();38+  void setupSyncLifecycle();1739 }1840 1941 start();

view file

src/background/messages.ts+4 −0
@@ -12,6 +12,7 @@1212 import { copyToClipboard } from "./clipboard";1313 import { setupAutoLock, touchAutoLock } from "./alarms";1414 import * as vault from "./vault";15+import * as syncLifecycle from "./sync-lifecycle";1516 import {1617   activateLicense,1718   deactivateLicense,@@ -227,6 +228,7 @@227228         await vault.createVault(password);228229         await setupAutoLock();229230         broadcastToContentScripts({ type: "VAULT_UNLOCKED" });231+        void syncLifecycle.onVaultUnlocked();230232         return { ok: true };231233       }232234       case "UNLOCK": {@@ -235,11 +237,13 @@235237         if (ok) {236238           await setupAutoLock();237239           broadcastToContentScripts({ type: "VAULT_UNLOCKED" });240+          void syncLifecycle.onVaultUnlocked();238241         }239242         return { ok };240243       }241244       case "LOCK":242245         vault.lock();246+        void syncLifecycle.onVaultLocked();243247         return { ok: true };244248       case "IS_UNLOCKED":245249         return { ok: true, data: vault.isUnlocked() };

view file

src/background/sync-lifecycle.ts+234 −0
@@ -0,0 +1,234 @@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+ */16+17+import { Capacitor } from "@capacitor/core";18+import * as sync from "./sync";19+import { isProUser } from "./license";20+import * as vault from "./vault";21+import { getPairedDevices, setIncomingPairingRequest } from "./sync-storage";22+import { decodeSyncMessage } from "../shared/sync-wire";23+24+const DEVICE_ID_KEY = "peach_device_id";25+const DEVICE_NAME_KEY = "peach_device_name";26+27+let advertisingActive = false;28+let nativeHostConnected = false;29+let incomingSubscriberInstalled = false;30+31+async 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+}55+56+async 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+}68+69+/**70+ * Reconcile mDNS advertisement with current state. Idempotent.71+ */72+export 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;76+77+  if (!nativeHostConnected) {78+    advertisingActive = false;79+    return;80+  }81+82+  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+  }89+90+  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+}107+108+/**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+ */114+export function setupIncomingPairingSubscriber(): void {115+  if (incomingSubscriberInstalled) return;116+  incomingSubscriberInstalled = true;117+118+  sync.onIncomingConnection((info) => {119+    void handleIncomingConnection(info);120+  });121+}122+123+async 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+}131+132+async 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+    }152+153+    const now = Date.now();154+    void setIncomingPairingRequest({155+      deviceName,156+      publicKeyHex,157+      receivedAt: now,158+      expiresAt: now + 60_000,159+      connectionId: info.connectionId,160+    });161+162+    // 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+  });168+169+  // 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+}177+178+async 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+}197+198+// ---------------------------------------------------------------------------199+// External-event hooks. The background's lifecycle layer (entry.ts) calls200+// these on relevant transitions so the lifecycle tick is always coherent.201+// ---------------------------------------------------------------------------202+203+export function onNativeHostConnected(): void {204+  nativeHostConnected = true;205+  setupIncomingPairingSubscriber();206+  void syncLifecycleTick();207+}208+209+export function onNativeHostDisconnected(): void {210+  nativeHostConnected = false;211+  advertisingActive = false;212+}213+214+export async function onVaultUnlocked(): Promise<void> {215+  await syncLifecycleTick();216+}217+218+export async function onVaultLocked(): Promise<void> {219+  await syncLifecycleTick();220+}221+222+export async function onProActivated(): Promise<void> {223+  await syncLifecycleTick();224+}225+226+/**227+ * Test-only — reset the module-local mutable state so each test starts from a228+ * known baseline. Production code never calls this.229+ */230+export function __resetForTests(): void {231+  advertisingActive = false;232+  nativeHostConnected = false;233+  incomingSubscriberInstalled = false;234+}

view file