123456789101112131415161718192021import {22 ChirpExchangeError,23 createPairingAttempt,24 openBrowserAudioIO,25 runChirpExchange,26} from "../shared/chirp-pairing";27import {28 runPairingInitiator,29 runPairingResponder,30 type LocalIdentity,31} from "../shared/chirp-handshake";32import {33 DEVICE_TYPE_EXTENSION,34 DEVICE_TYPE_ANDROID,35 DEVICE_TYPE_IOS,36 type PairedDevice,37 type PairingProgress,38} from "../shared/sync-types";394041424344454647export const CHIRP_PAIRING_PORT = 49142;4849export interface PairingTransport {50 send(msg: Uint8Array): Promise<void>;51 receive(): Promise<Uint8Array>;52 close(): Promise<void>;53}5455export interface ChirpPairingDeps {56 57 fetchLocalIdentity(): Promise<LocalIdentity>;58 59 completePairing(paired: PairedDevice): Promise<void>;60 61 cacheLastKnownIp?(pubkeyHex: string, ip: string): Promise<void>;62 63646566 getLocalSubnet(): Promise<string | null>;67 6869707172 openPairingTransport(endpoint: { ip: string; port: number }): Promise<PairingTransport>;73 747576777879808182 openPairingServer?(): Promise<{83 transport: PairingTransport;84 peerIp: string;85 }>;86 87 openAudio?(): Promise<Awaited<ReturnType<typeof openBrowserAudioIO>>>;88 89 chirpTimeoutMs?: number;90 onProgress(p: PairingProgress): void;91}9293export type PairingRole = "initiator" | "responder";9495export class ChirpPairingFailure extends Error {96 readonly phase: PairingProgress["phase"];97 constructor(phase: PairingProgress["phase"], message: string) {98 super(message);99 this.phase = phase;100 }101}102103export async function runChirpPairing(104 deps: ChirpPairingDeps,105 role: PairingRole = "responder",106): Promise<PairedDevice> {107 deps.onProgress({ phase: "idle" });108109 const localIdentity = await deps.fetchLocalIdentity();110111 112 113 114 if (role === "responder" && localIdentity.deviceType !== "extension") {115 throw new ChirpPairingFailure("handshake_failed", "responder requires extension identity");116 }117 if (role === "initiator" && localIdentity.deviceType === "extension") {118 throw new ChirpPairingFailure("handshake_failed", "initiator requires non-extension identity");119 }120 if (role === "initiator" && !deps.openPairingServer) {121 throw new ChirpPairingFailure("handshake_failed", "initiator requires openPairingServer dep");122 }123124 deps.onProgress({ phase: "listening" });125126 const deviceTypeByte =127 localIdentity.deviceType === "android" ? DEVICE_TYPE_ANDROID :128 localIdentity.deviceType === "ios" ? DEVICE_TYPE_IOS :129 DEVICE_TYPE_EXTENSION;130 const attempt = createPairingAttempt(deviceTypeByte);131132 let remoteChirpPublicKey: Uint8Array;133 try {134 const audio = await (deps.openAudio ?? openBrowserAudioIO)();135 const result = await runChirpExchange(audio, { attempt, timeoutMs: deps.chirpTimeoutMs });136 remoteChirpPublicKey = result.remoteChirp.ephemeralPublicKey;137 } catch (err) {138 if (err instanceof ChirpExchangeError) {139 const phase: PairingProgress["phase"] =140 err.kind === "timeout" ? "timeout" :141 err.kind === "version_mismatch" ? "version_mismatch" :142 err.kind === "mic_denied" ? "mic_denied" :143 "handshake_failed";144 deps.onProgress({ phase, errorMessage: err.message });145 throw new ChirpPairingFailure(phase, err.message);146 }147 throw err;148 }149150 deps.onProgress({ phase: "handshake" });151152 if (role === "responder") {153 return runResponderHandshake(deps, attempt, localIdentity);154 }155 return runInitiatorHandshake(deps, remoteChirpPublicKey, localIdentity);156}157158async function runResponderHandshake(159 deps: ChirpPairingDeps,160 attempt: ReturnType<typeof createPairingAttempt>,161 localIdentity: LocalIdentity,162): Promise<PairedDevice> {163 164 165 166 const subnet = await deps.getLocalSubnet();167 if (!subnet) {168 deps.onProgress({ phase: "handshake_failed", errorMessage: "no_subnet" });169 throw new ChirpPairingFailure("handshake_failed", "could not determine local subnet");170 }171172 const peerEndpoint = await firstResponsivePeer(deps, subnet, CHIRP_PAIRING_PORT);173 if (!peerEndpoint) {174 deps.onProgress({ phase: "handshake_failed", errorMessage: "peer_not_found" });175 throw new ChirpPairingFailure("handshake_failed", "could not reach paired peer on subnet");176 }177178 const transport = await deps.openPairingTransport(peerEndpoint);179 let paired: PairedDevice;180 try {181 paired = await runPairingResponder(182 attempt.ephemeralPrivateKey,183 attempt.ephemeralPublicKey,184 localIdentity,185 transport,186 );187 } catch (err) {188 await transport.close();189 deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });190 throw new ChirpPairingFailure("handshake_failed", (err as Error).message);191 }192 await transport.close();193194 195 196 197 198 await deps.completePairing(paired);199 await deps.cacheLastKnownIp?.(paired.longTermPublicKeyHex ?? "", peerEndpoint.ip);200 deps.onProgress({ phase: "paired", peerDeviceName: paired.deviceName });201 return paired;202}203204async function runInitiatorHandshake(205 deps: ChirpPairingDeps,206 remoteChirpPublicKey: Uint8Array,207 localIdentity: LocalIdentity,208): Promise<PairedDevice> {209 210 211 212 let server: { transport: PairingTransport; peerIp: string };213 try {214 server = await deps.openPairingServer!();215 } catch (err) {216 deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });217 throw new ChirpPairingFailure("handshake_failed", (err as Error).message);218 }219220 let paired: PairedDevice;221 try {222 paired = await runPairingInitiator(223 remoteChirpPublicKey,224 localIdentity,225 server.transport,226 );227 } catch (err) {228 await server.transport.close();229 deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });230 throw new ChirpPairingFailure("handshake_failed", (err as Error).message);231 }232 await server.transport.close();233234 await deps.completePairing(paired);235 await deps.cacheLastKnownIp?.(paired.longTermPublicKeyHex ?? "", server.peerIp);236 deps.onProgress({ phase: "paired", peerDeviceName: paired.deviceName });237 return paired;238}239240async function firstResponsivePeer(241 deps: ChirpPairingDeps,242 subnet: string,243 port: number,244): Promise<{ ip: string; port: number } | null> {245 246 247 248 249 250 251 252 253 254 255 256 257 258 const PROBE_TIMEOUT_MS = 250;259 const batch = 16;260 for (let start = 1; start <= 254; start += batch) {261 const end = Math.min(start + batch - 1, 254);262 const ips: string[] = [];263 for (let i = start; i <= end; i++) ips.push(`${subnet}.${i}`);264265 const results = await Promise.all(266 ips.map(async (ip) => {267 let timer: ReturnType<typeof setTimeout> | undefined;268 const timeout = new Promise<null>((resolve) => {269 timer = setTimeout(() => resolve(null), PROBE_TIMEOUT_MS);270 });271 const probe = (async () => {272 try {273 const t = await deps.openPairingTransport({ ip, port });274 return { transport: t, ip, port };275 } catch {276 return null;277 }278 })();279 const winner = await Promise.race([probe, timeout]);280 if (winner && "transport" in winner) {281 if (timer !== undefined) clearTimeout(timer);282 await winner.transport.close();283 return { ip: winner.ip, port: winner.port };284 }285 286 287 probe.then((late) => {288 if (late) void late.transport.close();289 }).catch(() => { });290 return null;291 }),292 );293 const hit = results.find((r): r is { ip: string; port: number } => r !== null);294 if (hit) return hit;295 }296 return null;297}