1234567891011121314151617181920212223import 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; 58 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}