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
↓ peach-browser-78cf60b2.tar.gzatlas at this save
files changed6 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 −1manifest.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": {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",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": {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 stalesrc/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+});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 {