12345678910111213141516171819202122232425import 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; 60 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}124125126127128129130131132133134135136export 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 { } };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 155 156 157 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 168 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}