chirp-pair-capacitor.ts

7.5 KB · raw

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.4//5// The native SyncPlugin on Android (and eventually iOS) owns the6// WebSocket server on CHIRP_PAIRING_PORT. JS does not have raw socket7// listeners. This adapter wraps the plugin's start/stop calls and its8// event stream into the PairingTransport shape the orchestrator expects.9//10// Protocol with native:11//   - startPairingServer({port}) opens the server and returns immediately12//   - The plugin emits 'peach:pair-connected' when the first peer connects:13//       { connectionId: string, peerIp: string }14//   - The plugin emits 'peach:pair-frame' for each inbound binary frame:15//       { connectionId: string, frameB64: string }16//   - The plugin emits 'peach:pair-closed' when the connection drops:17//       { connectionId: string }18//   - sendFrame({connectionId, frameB64}) sends a binary frame out19//   - stopPairingServer() closes the server and any open connection20//21// The 90 s accept timeout matches the chirp window. If no peer connects22// in that window the adapter rejects with a `pair_timeout` error so the23// orchestrator surfaces a `handshake_failed` phase.2425import type { PairingTransport } from "./chirp-pair";26import { CHIRP_PAIRING_PORT } from "./chirp-pair";2728const ACCEPT_TIMEOUT_MS = 90_000;2930interface SyncPluginShape {31  startPairingServer(opts: { port: number }): Promise<void>;32  stopPairingServer(): Promise<void>;33  sendFrame(opts: { connectionId: string; frameB64: string }): Promise<void>;34  addListener(event: string, handler: (data: unknown) => void): { remove: () => void };35}3637function getSyncPlugin(): SyncPluginShape | null {38  const cap = (globalThis as { Capacitor?: { Plugins?: Record<string, unknown> } }).Capacitor;39  const plugin = cap?.Plugins?.SyncPlugin as SyncPluginShape | undefined;40  return plugin ?? null;41}4243export async function openCapacitorPairingServer(): Promise<{44  transport: PairingTransport;45  peerIp: string;46}> {47  const plugin = getSyncPlugin();48  if (!plugin) throw new Error("SyncPlugin unavailable — not running on Capacitor");4950  await plugin.startPairingServer({ port: CHIRP_PAIRING_PORT });5152  let connectionId: string | null = null;53  const inbox: Uint8Array[] = [];54  const waiters: Array<(b: Uint8Array | null) => void> = [];55  let closed = false;5657  const onConnected = plugin.addListener("peach:pair-connected", (data) => {58    const d = data as { connectionId: string; peerIp: string };59    if (connectionId !== null) return; // ignore extra connections — first wins60    connectionId = d.connectionId;61    acceptResolve?.({ connectionId: d.connectionId, peerIp: d.peerIp });62  });6364  const onFrame = plugin.addListener("peach:pair-frame", (data) => {65    const d = data as { connectionId: string; frameB64: string };66    if (d.connectionId !== connectionId) return;67    const bytes = base64ToBytes(d.frameB64);68    const w = waiters.shift();69    if (w) w(bytes); else inbox.push(bytes);70  });7172  const onClosed = plugin.addListener("peach:pair-closed", (data) => {73    const d = data as { connectionId: string };74    if (d.connectionId !== connectionId) return;75    closed = true;76    while (waiters.length > 0) waiters.shift()!(null);77  });7879  let acceptResolve: ((v: { connectionId: string; peerIp: string }) => void) | null = null;80  let acceptReject: ((e: Error) => void) | null = null;81  const acceptPromise = new Promise<{ connectionId: string; peerIp: string }>((res, rej) => {82    acceptResolve = res;83    acceptReject = rej;84  });85  const timer = setTimeout(() => acceptReject?.(new Error("pair_timeout")), ACCEPT_TIMEOUT_MS);8687  let accepted: { connectionId: string; peerIp: string };88  try {89    accepted = await acceptPromise;90  } catch (err) {91    clearTimeout(timer);92    onConnected.remove(); onFrame.remove(); onClosed.remove();93    await plugin.stopPairingServer().catch(() => undefined);94    throw err;95  }96  clearTimeout(timer);9798  const transport: PairingTransport = {99    async send(msg: Uint8Array) {100      await plugin.sendFrame({101        connectionId: accepted.connectionId,102        frameB64: bytesToBase64(msg),103      });104    },105    async receive(): Promise<Uint8Array> {106      if (closed) throw new Error("pair_transport_closed");107      const v = inbox.shift();108      if (v) return v;109      return new Promise<Uint8Array>((resolve, reject) => {110        waiters.push((bytes) => {111          if (bytes === null) reject(new Error("pair_transport_closed"));112          else resolve(bytes);113        });114      });115    },116    async close() {117      onConnected.remove(); onFrame.remove(); onClosed.remove();118      await plugin.stopPairingServer().catch(() => undefined);119    },120  };121122  return { transport, peerIp: accepted.peerIp };123}124125/**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 */136export 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}149150async 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;153154  // 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;160161  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}171172interface ExtendedSyncPlugin extends SyncPluginShape {173  addListener(event: "peerDiscovered", handler: (data: unknown) => void): { remove: () => void };174}175176function getSyncPluginExtended(): ExtendedSyncPlugin | null {177  return getSyncPlugin() as ExtendedSyncPlugin | null;178}179180async 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  }192}193194function base64ToBytes(b64: string): Uint8Array {195  const bin = atob(b64);196  const out = new Uint8Array(bin.length);197  for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);198  return out;199}200201function bytesToBase64(bytes: Uint8Array): string {202  let bin = "";203  for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);204  return btoa(bin);205}