chirp-pair-capacitor.ts

5.0 KB · raw

1// Capacitor adapter for the initiator-role pairing server.2//3// The native SyncPlugin on Android (and eventually iOS) owns the4// WebSocket server on CHIRP_PAIRING_PORT. JS does not have raw socket5// listeners. This adapter wraps the plugin's start/stop calls and its6// event stream into the PairingTransport shape the orchestrator expects.7//8// Protocol with native:9//   - startPairingServer({port}) opens the server and returns immediately10//   - The plugin emits 'peach:pair-connected' when the first peer connects:11//       { connectionId: string, peerIp: string }12//   - The plugin emits 'peach:pair-frame' for each inbound binary frame:13//       { connectionId: string, frameB64: string }14//   - The plugin emits 'peach:pair-closed' when the connection drops:15//       { connectionId: string }16//   - sendFrame({connectionId, frameB64}) sends a binary frame out17//   - stopPairingServer() closes the server and any open connection18//19// The 90 s accept timeout matches the chirp window. If no peer connects20// in that window the adapter rejects with a `pair_timeout` error so the21// orchestrator surfaces a `handshake_failed` phase.2223import type { PairingTransport } from "./chirp-pair";24import { CHIRP_PAIRING_PORT } from "./chirp-pair";2526const ACCEPT_TIMEOUT_MS = 90_000;2728interface SyncPluginShape {29  startPairingServer(opts: { port: number }): Promise<void>;30  stopPairingServer(): Promise<void>;31  sendFrame(opts: { connectionId: string; frameB64: string }): Promise<void>;32  addListener(event: string, handler: (data: unknown) => void): { remove: () => void };33}3435function getSyncPlugin(): SyncPluginShape | null {36  const cap = (globalThis as { Capacitor?: { Plugins?: Record<string, unknown> } }).Capacitor;37  const plugin = cap?.Plugins?.SyncPlugin as SyncPluginShape | undefined;38  return plugin ?? null;39}4041export async function openCapacitorPairingServer(): Promise<{42  transport: PairingTransport;43  peerIp: string;44}> {45  const plugin = getSyncPlugin();46  if (!plugin) throw new Error("SyncPlugin unavailable — not running on Capacitor");4748  await plugin.startPairingServer({ port: CHIRP_PAIRING_PORT });4950  let connectionId: string | null = null;51  const inbox: Uint8Array[] = [];52  const waiters: Array<(b: Uint8Array | null) => void> = [];53  let closed = false;5455  const onConnected = plugin.addListener("peach:pair-connected", (data) => {56    const d = data as { connectionId: string; peerIp: string };57    if (connectionId !== null) return; // ignore extra connections — first wins58    connectionId = d.connectionId;59    acceptResolve?.({ connectionId: d.connectionId, peerIp: d.peerIp });60  });6162  const onFrame = plugin.addListener("peach:pair-frame", (data) => {63    const d = data as { connectionId: string; frameB64: string };64    if (d.connectionId !== connectionId) return;65    const bytes = base64ToBytes(d.frameB64);66    const w = waiters.shift();67    if (w) w(bytes); else inbox.push(bytes);68  });6970  const onClosed = plugin.addListener("peach:pair-closed", (data) => {71    const d = data as { connectionId: string };72    if (d.connectionId !== connectionId) return;73    closed = true;74    while (waiters.length > 0) waiters.shift()!(null);75  });7677  let acceptResolve: ((v: { connectionId: string; peerIp: string }) => void) | null = null;78  let acceptReject: ((e: Error) => void) | null = null;79  const acceptPromise = new Promise<{ connectionId: string; peerIp: string }>((res, rej) => {80    acceptResolve = res;81    acceptReject = rej;82  });83  const timer = setTimeout(() => acceptReject?.(new Error("pair_timeout")), ACCEPT_TIMEOUT_MS);8485  let accepted: { connectionId: string; peerIp: string };86  try {87    accepted = await acceptPromise;88  } catch (err) {89    clearTimeout(timer);90    onConnected.remove(); onFrame.remove(); onClosed.remove();91    await plugin.stopPairingServer().catch(() => undefined);92    throw err;93  }94  clearTimeout(timer);9596  const transport: PairingTransport = {97    async send(msg: Uint8Array) {98      await plugin.sendFrame({99        connectionId: accepted.connectionId,100        frameB64: bytesToBase64(msg),101      });102    },103    async receive(): Promise<Uint8Array> {104      if (closed) throw new Error("pair_transport_closed");105      const v = inbox.shift();106      if (v) return v;107      return new Promise<Uint8Array>((resolve, reject) => {108        waiters.push((bytes) => {109          if (bytes === null) reject(new Error("pair_transport_closed"));110          else resolve(bytes);111        });112      });113    },114    async close() {115      onConnected.remove(); onFrame.remove(); onClosed.remove();116      await plugin.stopPairingServer().catch(() => undefined);117    },118  };119120  return { transport, peerIp: accepted.peerIp };121}122123function base64ToBytes(b64: string): Uint8Array {124  const bin = atob(b64);125  const out = new Uint8Array(bin.length);126  for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);127  return out;128}129130function bytesToBase64(bytes: Uint8Array): string {131  let bin = "";132  for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);133  return btoa(bin);134}