123456789101112131415161718192021import {22 ChirpExchangeError,23 createPairingAttempt,24 openBrowserAudioIO,25 runChirpExchange,26} from "../shared/chirp-pairing";27import {28 runPairingResponder,29 type LocalIdentity,30} from "../shared/chirp-handshake";31import {32 DEVICE_TYPE_EXTENSION,33 type PairedDevice,34 type PairingProgress,35} from "../shared/sync-types";363738394041424344export const CHIRP_PAIRING_PORT = 49142;4546export interface ChirpPairingDeps {47 48 fetchLocalIdentity(): Promise<LocalIdentity>;49 50 completePairing(paired: PairedDevice): Promise<void>;51 52 cacheLastKnownIp?(pubkeyHex: string, ip: string): Promise<void>;53 54 getLocalSubnet(): Promise<string | null>;55 56 openPairingTransport(endpoint: { ip: string; port: number }): Promise<{57 send(msg: Uint8Array): Promise<void>;58 receive(): Promise<Uint8Array>;59 close(): Promise<void>;60 }>;61 62 openAudio?(): Promise<Awaited<ReturnType<typeof openBrowserAudioIO>>>;63 64 chirpTimeoutMs?: number;65 onProgress(p: PairingProgress): void;66}6768export class ChirpPairingFailure extends Error {69 readonly phase: PairingProgress["phase"];70 constructor(phase: PairingProgress["phase"], message: string) {71 super(message);72 this.phase = phase;73 }74}7576export async function runChirpPairing(deps: ChirpPairingDeps): Promise<PairedDevice> {77 deps.onProgress({ phase: "idle" });7879 const localIdentity = await deps.fetchLocalIdentity();80 if (localIdentity.deviceType !== "extension") {81 82 83 throw new ChirpPairingFailure("handshake_failed", "wrong-platform orchestrator");84 }8586 deps.onProgress({ phase: "listening" });8788 const attempt = createPairingAttempt(DEVICE_TYPE_EXTENSION);89 try {90 const audio = await (deps.openAudio ?? openBrowserAudioIO)();91 await runChirpExchange(audio, { attempt, timeoutMs: deps.chirpTimeoutMs });92 } catch (err) {93 if (err instanceof ChirpExchangeError) {94 const phase: PairingProgress["phase"] =95 err.kind === "timeout" ? "timeout" :96 err.kind === "version_mismatch" ? "version_mismatch" :97 err.kind === "mic_denied" ? "mic_denied" :98 "handshake_failed";99 deps.onProgress({ phase, errorMessage: err.message });100 throw new ChirpPairingFailure(phase, err.message);101 }102 throw err;103 }104105 deps.onProgress({ phase: "handshake" });106107 108 109 110 const subnet = await deps.getLocalSubnet();111 if (!subnet) {112 deps.onProgress({ phase: "handshake_failed", errorMessage: "no_subnet" });113 throw new ChirpPairingFailure("handshake_failed", "could not determine local subnet");114 }115116 const peerEndpoint = await firstResponsivePeer(deps, subnet, CHIRP_PAIRING_PORT);117 if (!peerEndpoint) {118 deps.onProgress({ phase: "handshake_failed", errorMessage: "peer_not_found" });119 throw new ChirpPairingFailure("handshake_failed", "could not reach paired peer on subnet");120 }121122 const transport = await deps.openPairingTransport(peerEndpoint);123 let paired: PairedDevice;124 try {125 paired = await runPairingResponder(126 attempt.ephemeralPrivateKey,127 attempt.ephemeralPublicKey,128 localIdentity,129 transport,130 );131 } catch (err) {132 await transport.close();133 deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });134 throw new ChirpPairingFailure("handshake_failed", (err as Error).message);135 }136 await transport.close();137138 139 140 141 142 await deps.completePairing(paired);143 await deps.cacheLastKnownIp?.(paired.longTermPublicKeyHex ?? "", peerEndpoint.ip);144 deps.onProgress({ phase: "paired", peerDeviceName: paired.deviceName });145 return paired;146}147148async function firstResponsivePeer(149 deps: ChirpPairingDeps,150 subnet: string,151 port: number,152): Promise<{ ip: string; port: number } | null> {153 154 155 156 157 158 const batch = 16;159 for (let start = 1; start <= 254; start += batch) {160 const end = Math.min(start + batch - 1, 254);161 const ips: string[] = [];162 for (let i = start; i <= end; i++) ips.push(`${subnet}.${i}`);163164 const results = await Promise.all(165 ips.map(async (ip) => {166 try {167 const t = await deps.openPairingTransport({ ip, port });168 await t.close();169 return { ip, port };170 } catch {171 return null;172 }173 }),174 );175 const hit = results.find((r): r is { ip: string; port: number } => r !== null);176 if (hit) return hit;177 }178 return null;179}