// Popup-side orchestrator for Phase-1 chirp pairing. // // Runs end-to-end inside the popup or the settings page (it depends on // `getUserMedia`, which is unavailable in the MV3 service worker). The // background's role is limited to handing us the local identity and // persisting the resulting PairedDevice record. // // Flow: // 1. fetch local identity via CHIRP_GET_LOCAL_IDENTITY // 2. open Web Audio + microphone // 3. encode our chirp and broadcast on a 4s loop while listening // 4. on chirp decoded, transition to handshake phase // 5. probe local subnet on the well-known pairing port for the peer's // WebSocket; complete Noise NK over it; exchange long-term identities // 6. send CHIRP_COMPLETE_PAIRING to persist the new pair // // The orchestrator notifies the UI through an `onProgress` callback so // `ChirpPairingFlow` can drive its three-act animation in lockstep with // the real protocol state. import { ChirpExchangeError, createPairingAttempt, openBrowserAudioIO, runChirpExchange, } from "../shared/chirp-pairing"; import { runPairingResponder, type LocalIdentity, } from "../shared/chirp-handshake"; import { DEVICE_TYPE_EXTENSION, type PairedDevice, type PairingProgress, } from "../shared/sync-types"; /** * Well-known TCP port used during Phase-1 pairing. Android opens a * WebSocket server on this port only while the pairing screen is active; * the extension scans the /24 for it after the chirp exchange completes. * The persistent sync port (random high port) is announced via the * encrypted PAIR_IDENTITY frame and stored on the PairedDevice. */ export const CHIRP_PAIRING_PORT = 49142; export interface ChirpPairingDeps { /** Background round-trip — returns the local public identity. */ fetchLocalIdentity(): Promise; /** Background round-trip — persists the paired device. */ completePairing(paired: PairedDevice): Promise; /** Returns "192.168.1" for a /24; null if not detectable. */ getLocalSubnet(): Promise; /** Inject the WebSocket transport. Override in tests. */ openPairingTransport(endpoint: { ip: string; port: number }): Promise<{ send(msg: Uint8Array): Promise; receive(): Promise; close(): Promise; }>; /** Override the chirp exchange's `AudioIO` for tests. */ openAudio?(): Promise>>; /** Override the audio-exchange timeout — defaults to 90s per the brief. */ chirpTimeoutMs?: number; onProgress(p: PairingProgress): void; } export class ChirpPairingFailure extends Error { readonly phase: PairingProgress["phase"]; constructor(phase: PairingProgress["phase"], message: string) { super(message); this.phase = phase; } } export async function runChirpPairing(deps: ChirpPairingDeps): Promise { deps.onProgress({ phase: "idle" }); const localIdentity = await deps.fetchLocalIdentity(); if (localIdentity.deviceType !== "extension") { // The Capacitor build runs the host-side flow; this orchestrator is // for the extension responder side only. throw new ChirpPairingFailure("handshake_failed", "wrong-platform orchestrator"); } deps.onProgress({ phase: "listening" }); const attempt = createPairingAttempt(DEVICE_TYPE_EXTENSION); try { const audio = await (deps.openAudio ?? openBrowserAudioIO)(); await runChirpExchange(audio, { attempt, timeoutMs: deps.chirpTimeoutMs }); } catch (err) { if (err instanceof ChirpExchangeError) { const phase: PairingProgress["phase"] = err.kind === "timeout" ? "timeout" : err.kind === "version_mismatch" ? "version_mismatch" : err.kind === "mic_denied" ? "mic_denied" : "handshake_failed"; deps.onProgress({ phase, errorMessage: err.message }); throw new ChirpPairingFailure(phase, err.message); } throw err; } deps.onProgress({ phase: "handshake" }); // The peer is somewhere on the LAN; we don't know its IP yet because // the chirp payload deliberately omits it. Scan the /24 for the // pairing-mode WebSocket port. const subnet = await deps.getLocalSubnet(); if (!subnet) { deps.onProgress({ phase: "handshake_failed", errorMessage: "no_subnet" }); throw new ChirpPairingFailure("handshake_failed", "could not determine local subnet"); } const peerEndpoint = await firstResponsivePeer(deps, subnet, CHIRP_PAIRING_PORT); if (!peerEndpoint) { deps.onProgress({ phase: "handshake_failed", errorMessage: "peer_not_found" }); throw new ChirpPairingFailure("handshake_failed", "could not reach paired peer on subnet"); } const transport = await deps.openPairingTransport(peerEndpoint); let paired: PairedDevice; try { paired = await runPairingResponder( attempt.ephemeralPrivateKey, attempt.ephemeralPublicKey, localIdentity, transport, ); } catch (err) { await transport.close(); deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message }); throw new ChirpPairingFailure("handshake_failed", (err as Error).message); } await transport.close(); // Use the connection's IP as our cached `lastKnownIp`; the wsPort came // in the PAIR_IDENTITY frame. paired = { ...paired, lastKnownIp: peerEndpoint.ip }; await deps.completePairing(paired); deps.onProgress({ phase: "paired", peerDeviceName: paired.deviceName }); return paired; } async function firstResponsivePeer( deps: ChirpPairingDeps, subnet: string, port: number, ): Promise<{ ip: string; port: number } | null> { // We don't have the peer's long-term key yet (that's what we're about // to exchange), so we can't run a Noise XX probe like the steady-state // sync path does. The pairing-port server accepts any incoming Noise NK // request during its short pairing window; the first WebSocket that // accepts our connection is the peer. const batch = 16; for (let start = 1; start <= 254; start += batch) { const end = Math.min(start + batch - 1, 254); const ips: string[] = []; for (let i = start; i <= end; i++) ips.push(`${subnet}.${i}`); const results = await Promise.all( ips.map(async (ip) => { try { const t = await deps.openPairingTransport({ ip, port }); await t.close(); return { ip, port }; } catch { return null; } }), ); const hit = results.find((r): r is { ip: string; port: number } => r !== null); if (hit) return hit; } return null; }