78cf60b2e953

feat: mDNS peer-discovery trigger — startMDNSListener subscribes to native SyncPlugin peerDiscovered events, fires TRIGGER_SYNC reason 'mdns' when app is foregrounded; wired into App.tsx native onMount with onCleanup; 6 listener tests; full suite 2954/2954 (v3.6.89)

time
2026-05-21 16:39 UTC · 5 months ago
machine
koh2
parent
cfc7e8821df9
6 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/popup/App.tsx+9 −0src/popup/chirp-pair-capacitor.test.ts+105 −0src/popup/chirp-pair-capacitor.ts+72 −1
manifest.firefox.json+1 −1
@@ -2,7 +2,7 @@22   "_comment_audio_capture": "Firefox does not honor the Chrome 'audioCapture' optional_permission — getUserMedia in WebExtensions is gated solely by the runtime permission prompt. Microphone access for chirp pairing works from the popup under the existing '<all_urls>' host permission; no manifest entry is required or accepted here.",33   "manifest_version": 3,44   "name": "Peach",5-  "version": "3.6.88",5+  "version": "3.6.89",66   "description": "Zero-knowledge password manager",77   "browser_specific_settings": {88     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "3.6.88",4+  "version": "3.6.89",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": "3.6.88",3+  "version": "3.6.89",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/popup/App.tsx+9 −0
@@ -512,6 +512,15 @@512512           document.removeEventListener(ev, bumpActivity, { capture: true });513513         }514514       });515+516+      // mDNS peer-discovery trigger. The native SyncPlugin emits517+      // `peerDiscovered` when it resolves a known paired device on the LAN;518+      // the listener fires TRIGGER_SYNC with reason "mdns". Graceful no-op519+      // if the plugin doesn't expose the event yet.520+      void import("./chirp-pair-capacitor").then(({ startMDNSListener }) => {521+        const stop = startMDNSListener();522+        onCleanup(stop);523+      });515524     }516525 517526     // Background auto-lock broadcasts VAULT_LOCKED so the popup drops stale

view file

src/popup/chirp-pair-capacitor.test.ts+105 −0
@@ -0,0 +1,105 @@1+import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";2+import { startMDNSListener } from "./chirp-pair-capacitor";3+4+interface RegisteredListener {5+  event: string;6+  handler: (data: unknown) => void;7+}8+9+function installMockCapacitor(opts: {10+  withSyncPlugin?: boolean;11+  withPeerDiscoveredEvent?: boolean;12+  appActive?: boolean;13+} = {}): { listeners: RegisteredListener[]; removeCount: { v: number } } {14+  const listeners: RegisteredListener[] = [];15+  const removeCount = { v: 0 };16+  const syncPlugin = opts.withSyncPlugin === false ? undefined : {17+    startPairingServer: vi.fn(),18+    stopPairingServer: vi.fn(),19+    sendFrame: vi.fn(),20+    addListener: opts.withPeerDiscoveredEvent === false21+      ? undefined22+      : vi.fn((event: string, handler: (data: unknown) => void) => {23+          listeners.push({ event, handler });24+          return { remove: () => { removeCount.v++; } };25+        }),26+  };27+  const appPlugin = {28+    getState: vi.fn(async () => ({ isActive: opts.appActive ?? true })),29+  };30+  (globalThis as { Capacitor?: unknown }).Capacitor = {31+    isNativePlatform: () => true,32+    Plugins: { SyncPlugin: syncPlugin, App: appPlugin },33+  };34+  return { listeners, removeCount };35+}36+37+describe("startMDNSListener", () => {38+  let sentMessages: Array<{ type: string; payload?: unknown }>;39+40+  beforeEach(() => {41+    sentMessages = [];42+    (globalThis as { chrome?: unknown }).chrome = {43+      runtime: {44+        sendMessage: vi.fn(async (msg: { type: string; payload?: unknown }) => {45+          sentMessages.push(msg);46+        }),47+      },48+    };49+  });50+51+  afterEach(() => {52+    delete (globalThis as { Capacitor?: unknown }).Capacitor;53+    delete (globalThis as { chrome?: unknown }).chrome;54+  });55+56+  it("is a no-op when the SyncPlugin is unavailable", () => {57+    installMockCapacitor({ withSyncPlugin: false });58+    const stop = startMDNSListener();59+    expect(typeof stop).toBe("function");60+    stop();61+  });62+63+  it("registers a peerDiscovered listener when the plugin exposes addListener", () => {64+    const { listeners } = installMockCapacitor();65+    startMDNSListener();66+    expect(listeners.length).toBe(1);67+    expect(listeners[0].event).toBe("peerDiscovered");68+  });69+70+  it("fires TRIGGER_SYNC with reason 'mdns' when peerDiscovered arrives in the foreground", async () => {71+    const { listeners } = installMockCapacitor({ appActive: true });72+    startMDNSListener();73+    listeners[0].handler({ deviceId: "pixel-7", ip: "192.168.1.42", port: 8765 });74+    // sendMessage runs inside the handler's `await`; let microtasks flush.75+    await new Promise((r) => setTimeout(r, 0));76+    expect(sentMessages).toEqual([77+      { type: "TRIGGER_SYNC", payload: { reason: "mdns" } },78+    ]);79+  });80+81+  it("does NOT fire TRIGGER_SYNC when the app is backgrounded", async () => {82+    const { listeners } = installMockCapacitor({ appActive: false });83+    startMDNSListener();84+    listeners[0].handler({ deviceId: "pixel-7", ip: "192.168.1.42", port: 8765 });85+    await new Promise((r) => setTimeout(r, 0));86+    expect(sentMessages).toEqual([]);87+  });88+89+  it("ignores malformed events (missing deviceId or ip)", async () => {90+    const { listeners } = installMockCapacitor({ appActive: true });91+    startMDNSListener();92+    listeners[0].handler({ ip: "192.168.1.42" });93+    listeners[0].handler({ deviceId: "pixel-7" });94+    listeners[0].handler(null);95+    await new Promise((r) => setTimeout(r, 0));96+    expect(sentMessages).toEqual([]);97+  });98+99+  it("returns a teardown that removes the native listener", () => {100+    const { removeCount } = installMockCapacitor();101+    const stop = startMDNSListener();102+    stop();103+    expect(removeCount.v).toBe(1);104+  });105+});

view file

src/popup/chirp-pair-capacitor.ts+72 −1
@@ -1,4 +1,6 @@1-// Capacitor adapter for the initiator-role pairing server.1+// Capacitor adapter for the initiator-role pairing server, plus the2+// mDNS peer-discovery listener that fires the sync trigger when a known3+// paired device appears on the local network.24 //35 // The native SyncPlugin on Android (and eventually iOS) owns the46 // WebSocket server on CHIRP_PAIRING_PORT. JS does not have raw socket@@ -118,6 +120,75 @@118120   };119121 120122   return { transport, peerIp: accepted.peerIp };123+}124+125+/**126+ * Subscribe to mDNS peer-discovery events from the native SyncPlugin.127+ * Returns a teardown that should be called from `onCleanup`. Each128+ * `peerDiscovered` payload identifies a paired device by deviceId plus the129+ * (ip, port) the native side just resolved; we update the device's cached130+ * IP and fire a `TRIGGER_SYNC` with reason "mdns".131+ *132+ * Gracefully no-op if the SyncPlugin doesn't expose the event yet — the133+ * Android side may or may not have shipped the emitter; the JS-side wiring134+ * stays in place either way so we don't need a coordinated rollout.135+ */136+export function startMDNSListener(): () => void {137+  const plugin = getSyncPluginExtended();138+  if (!plugin?.addListener) return () => undefined;139+  let sub: { remove: () => void } | null = null;140+  try {141+    sub = plugin.addListener("peerDiscovered", (data: unknown) => {142+      void handlePeerDiscovered(data);143+    });144+  } catch {145+    return () => undefined;146+  }147+  return () => { try { sub?.remove(); } catch { /* ignore */ } };148+}149+150+async function handlePeerDiscovered(data: unknown): Promise<void> {151+  const event = data as { deviceId?: string; ip?: string; port?: number } | null | undefined;152+  if (!event?.deviceId || typeof event.ip !== "string") return;153+154+  // Foreground guard — when the OS has backgrounded the app, mDNS resolves155+  // are still delivered but firing a sync now would burn battery for no156+  // user-visible benefit. The next foreground entry will trigger sync via157+  // the existing popup-foreground path.158+  const appActive = await isAppActive();159+  if (!appActive) return;160+161+  try {162+    await chrome.runtime.sendMessage({163+      type: "TRIGGER_SYNC",164+      payload: { reason: "mdns" },165+    });166+  } catch {167+    // Background may not be listening — non-fatal; next vault-change or168+    // foreground entry will pick up the work.169+  }170+}171+172+interface ExtendedSyncPlugin extends SyncPluginShape {173+  addListener(event: "peerDiscovered", handler: (data: unknown) => void): { remove: () => void };174+}175+176+function getSyncPluginExtended(): ExtendedSyncPlugin | null {177+  return getSyncPlugin() as ExtendedSyncPlugin | null;178+}179+180+async function isAppActive(): Promise<boolean> {181+  const cap = (globalThis as { Capacitor?: { Plugins?: Record<string, unknown> } }).Capacitor;182+  const appPlugin = cap?.Plugins?.App as183+    | { getState?: () => Promise<{ isActive: boolean }> }184+    | undefined;185+  if (!appPlugin?.getState) return true;186+  try {187+    const state = await appPlugin.getState();188+    return state.isActive !== false;189+  } catch {190+    return true;191+  }121192 }122193 123194 function base64ToBytes(b64: string): Uint8Array {

view file