// Capacitor adapter for the initiator-role pairing server, plus the // mDNS peer-discovery listener that fires the sync trigger when a known // paired device appears on the local network. // // The native SyncPlugin on Android (and eventually iOS) owns the // WebSocket server on CHIRP_PAIRING_PORT. JS does not have raw socket // listeners. This adapter wraps the plugin's start/stop calls and its // event stream into the PairingTransport shape the orchestrator expects. // // Protocol with native: // - startPairingServer({port}) opens the server and returns immediately // - The plugin emits 'peach:pair-connected' when the first peer connects: // { connectionId: string, peerIp: string } // - The plugin emits 'peach:pair-frame' for each inbound binary frame: // { connectionId: string, frameB64: string } // - The plugin emits 'peach:pair-closed' when the connection drops: // { connectionId: string } // - sendFrame({connectionId, frameB64}) sends a binary frame out // - stopPairingServer() closes the server and any open connection // // The 90 s accept timeout matches the chirp window. If no peer connects // in that window the adapter rejects with a `pair_timeout` error so the // orchestrator surfaces a `handshake_failed` phase. import type { PairingTransport } from "./chirp-pair"; import { CHIRP_PAIRING_PORT } from "./chirp-pair"; const ACCEPT_TIMEOUT_MS = 90_000; interface SyncPluginShape { startPairingServer(opts: { port: number }): Promise; stopPairingServer(): Promise; sendFrame(opts: { connectionId: string; frameB64: string }): Promise; addListener(event: string, handler: (data: unknown) => void): { remove: () => void }; } function getSyncPlugin(): SyncPluginShape | null { const cap = (globalThis as { Capacitor?: { Plugins?: Record } }).Capacitor; const plugin = cap?.Plugins?.SyncPlugin as SyncPluginShape | undefined; return plugin ?? null; } export async function openCapacitorPairingServer(): Promise<{ transport: PairingTransport; peerIp: string; }> { const plugin = getSyncPlugin(); if (!plugin) throw new Error("SyncPlugin unavailable — not running on Capacitor"); await plugin.startPairingServer({ port: CHIRP_PAIRING_PORT }); let connectionId: string | null = null; const inbox: Uint8Array[] = []; const waiters: Array<(b: Uint8Array | null) => void> = []; let closed = false; const onConnected = plugin.addListener("peach:pair-connected", (data) => { const d = data as { connectionId: string; peerIp: string }; if (connectionId !== null) return; // ignore extra connections — first wins connectionId = d.connectionId; acceptResolve?.({ connectionId: d.connectionId, peerIp: d.peerIp }); }); const onFrame = plugin.addListener("peach:pair-frame", (data) => { const d = data as { connectionId: string; frameB64: string }; if (d.connectionId !== connectionId) return; const bytes = base64ToBytes(d.frameB64); const w = waiters.shift(); if (w) w(bytes); else inbox.push(bytes); }); const onClosed = plugin.addListener("peach:pair-closed", (data) => { const d = data as { connectionId: string }; if (d.connectionId !== connectionId) return; closed = true; while (waiters.length > 0) waiters.shift()!(null); }); let acceptResolve: ((v: { connectionId: string; peerIp: string }) => void) | null = null; let acceptReject: ((e: Error) => void) | null = null; const acceptPromise = new Promise<{ connectionId: string; peerIp: string }>((res, rej) => { acceptResolve = res; acceptReject = rej; }); const timer = setTimeout(() => acceptReject?.(new Error("pair_timeout")), ACCEPT_TIMEOUT_MS); let accepted: { connectionId: string; peerIp: string }; try { accepted = await acceptPromise; } catch (err) { clearTimeout(timer); onConnected.remove(); onFrame.remove(); onClosed.remove(); await plugin.stopPairingServer().catch(() => undefined); throw err; } clearTimeout(timer); const transport: PairingTransport = { async send(msg: Uint8Array) { await plugin.sendFrame({ connectionId: accepted.connectionId, frameB64: bytesToBase64(msg), }); }, async receive(): Promise { if (closed) throw new Error("pair_transport_closed"); const v = inbox.shift(); if (v) return v; return new Promise((resolve, reject) => { waiters.push((bytes) => { if (bytes === null) reject(new Error("pair_transport_closed")); else resolve(bytes); }); }); }, async close() { onConnected.remove(); onFrame.remove(); onClosed.remove(); await plugin.stopPairingServer().catch(() => undefined); }, }; return { transport, peerIp: accepted.peerIp }; } /** * Subscribe to mDNS peer-discovery events from the native SyncPlugin. * Returns a teardown that should be called from `onCleanup`. Each * `peerDiscovered` payload identifies a paired device by deviceId plus the * (ip, port) the native side just resolved; we update the device's cached * IP and fire a `TRIGGER_SYNC` with reason "mdns". * * Gracefully no-op if the SyncPlugin doesn't expose the event yet — the * Android side may or may not have shipped the emitter; the JS-side wiring * stays in place either way so we don't need a coordinated rollout. */ export function startMDNSListener(): () => void { const plugin = getSyncPluginExtended(); if (!plugin?.addListener) return () => undefined; let sub: { remove: () => void } | null = null; try { sub = plugin.addListener("peerDiscovered", (data: unknown) => { void handlePeerDiscovered(data); }); } catch { return () => undefined; } return () => { try { sub?.remove(); } catch { /* ignore */ } }; } async function handlePeerDiscovered(data: unknown): Promise { const event = data as { deviceId?: string; ip?: string; port?: number } | null | undefined; if (!event?.deviceId || typeof event.ip !== "string") return; // Foreground guard — when the OS has backgrounded the app, mDNS resolves // are still delivered but firing a sync now would burn battery for no // user-visible benefit. The next foreground entry will trigger sync via // the existing popup-foreground path. const appActive = await isAppActive(); if (!appActive) return; try { await chrome.runtime.sendMessage({ type: "TRIGGER_SYNC", payload: { reason: "mdns" }, }); } catch { // Background may not be listening — non-fatal; next vault-change or // foreground entry will pick up the work. } } interface ExtendedSyncPlugin extends SyncPluginShape { addListener(event: "peerDiscovered", handler: (data: unknown) => void): { remove: () => void }; } function getSyncPluginExtended(): ExtendedSyncPlugin | null { return getSyncPlugin() as ExtendedSyncPlugin | null; } async function isAppActive(): Promise { const cap = (globalThis as { Capacitor?: { Plugins?: Record } }).Capacitor; const appPlugin = cap?.Plugins?.App as | { getState?: () => Promise<{ isActive: boolean }> } | undefined; if (!appPlugin?.getState) return true; try { const state = await appPlugin.getState(); return state.isActive !== false; } catch { return true; } } function base64ToBytes(b64: string): Uint8Array { const bin = atob(b64); const out = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); return out; } function bytesToBase64(bytes: Uint8Array): string { let bin = ""; for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); return btoa(bin); }