// ChirpSync — the pairing surface, opened from the Settings → Sync card. // // Settings owns the sync "hub" (paired-device list, revoke, sync-now); this // view is purely the pairing flow, entered straight into a method via // `initialPair` and returning to Settings (`onBack`) when done or cancelled: // // - Locked behind Pro (free user) — soft upsell card, "Learn more" routes // to the License section. // - Chirp — ChirpPairingFlow takes over the surface and drives the three-act // bird animation in lockstep with the audio protocol. // - Code — the peach-sync desktop-helper NK flow (enter code → SAS → paired). // // The chirp orchestrator lives in `popup/chirp-pair.ts`; the desktop NK pairing // runs in the background and reaches this view via the PAIR_*/DESKTOP_PAIR_* // messages. import { Component, Show, createEffect, createSignal, onCleanup, onMount, } from "solid-js"; import { sendMessage } from "../../shared/bridge"; import type { PairedDevice, PairingProgress, } from "../../shared/sync-types"; import { ChirpPairingFlow, DesktopBird, MobileBird } from "../components/ChirpPairingFlow"; import { ChirpPairingFailure, runChirpPairing, type ChirpPairingDeps, type PairingRole, } from "../chirp-pair"; import { getLocalSubnet } from "../../shared/local-network"; import { isNativePlatform } from "../../shared/native"; import { openCapacitorPairingServer } from "../chirp-pair-capacitor"; export interface ChirpSyncViewProps { isPro: boolean; onBack(): void; onLearnPro(): void; /** * Open straight into a pairing path, skipping the rest screen — set when the * user taps "Chirp" or "Code" on the Settings sync card. Read once on mount. */ initialPair?: "chirp" | "desktop" | null; /** Called after a peer's Pro license is activated, so the app re-reads Pro status. */ onProChanged?: () => void; } // Which pairing path the user picked, passed in via `initialPair`. The birds // animate the same way on both paths. type PairKind = "chirp" | "desktop" | null; // Desktop helper (peach-sync) code-pairing phases. The SAS and success // screens are shared with the chirp path; "enter"/"connecting" are unique to // the typed-code flow. Mirrors the background DesktopPairingError union. type DesktopPhase = "enter" | "connecting" | "sas" | "paired" | "error"; type DesktopPairingError = | "invalid_code" | "no_host_found" | "handshake_failed" | "sas_mismatch" | "vault_locked"; const DESKTOP_PAIRED_DISMISS_MS = 2_000; function desktopErrorCopy(error: DesktopPairingError | "unknown"): { headline: string; sub: string } { switch (error) { case "invalid_code": // Ground truth §0.3: wrong vs expired is indistinguishable on the wire. return { headline: "Code not found or expired", sub: "Check the code on your computer and try again. Codes rotate, so grab a fresh one if it's been a while.", }; case "no_host_found": return { headline: "Couldn't find your computer", sub: "Make sure both devices are on the same Wi-Fi, then run “peach-sync pair” in a terminal to start pairing.", }; case "sas_mismatch": return { headline: "Codes don't match", sub: "For your safety, pairing was stopped. Start over and compare the two codes carefully.", }; case "vault_locked": return { headline: "Unlock Peach first", sub: "Your vault needs to be unlocked before you can pair a device.", }; case "handshake_failed": default: return { headline: "Pairing failed", sub: "Something went wrong while connecting. Please try again.", }; } } export const ChirpSyncView: Component = (props) => { const [pairing, setPairing] = createSignal({ phase: "idle" }); // Out-of-band pairing verification: the 6-digit code both devices derive // from the shared handshake; the user confirms it matches before the pair // is persisted (audit HIGH — pairing initiator authentication). const [sasCode, setSasCode] = createSignal(null); const [sasResolve, setSasResolve] = createSignal<((ok: boolean) => void) | null>(null); function resolveSAS(ok: boolean) { const r = sasResolve(); setSasCode(null); setSasResolve(null); r?.(ok); } // --- Desktop helper (peach-sync) code pairing ------------------------- // The whole NK pairing runs in the background; this view drives the UI and // the SAS confirmation round-trip via the four PAIR_*/DESKTOP_PAIR_* messages. const [pairKind, setPairKind] = createSignal(props.initialPair ?? null); const [desktopPhase, setDesktopPhase] = createSignal("enter"); const [desktopCode, setDesktopCode] = createSignal(""); const [desktopSas, setDesktopSas] = createSignal(null); const [desktopError, setDesktopError] = createSignal("unknown"); const [desktopDevice, setDesktopDevice] = createSignal(undefined); const [desktopBusy, setDesktopBusy] = createSignal(false); const desktopCodeReady = () => /^\d{6}$/.test(desktopCode()); function startDesktopFlow(): void { setDesktopCode(""); setDesktopSas(null); setDesktopError("unknown"); setDesktopDevice(undefined); setDesktopBusy(false); setDesktopPhase("enter"); setPairKind("desktop"); } function startChirpFlow(): void { setPairKind("chirp"); void handleStartPairing(); } function onDesktopCodeInput(value: string): void { setDesktopCode(value.replace(/\D/g, "").slice(0, 6)); } async function handleDesktopConnect(): Promise { if (!desktopCodeReady() || desktopBusy()) return; setDesktopBusy(true); setDesktopPhase("connecting"); try { const res = (await sendMessage({ type: "PAIR_WITH_DESKTOP", payload: { code: desktopCode() }, })) as { ok: boolean; error?: string }; // A synchronous start failure (e.g. vault_locked) returns here; the SAS // and the terminal result arrive later as pushes. if (!res.ok) { setDesktopError((res.error as DesktopPairingError) ?? "unknown"); setDesktopPhase("error"); } } catch { setDesktopError("unknown"); setDesktopPhase("error"); } finally { setDesktopBusy(false); } } async function respondDesktopConfirm(confirmed: boolean): Promise { if (desktopBusy()) return; setDesktopBusy(true); // Optimistically move on; the terminal DESKTOP_PAIR_RESULT push decides the // final phase (paired vs sas_mismatch error). if (confirmed) setDesktopPhase("connecting"); else { setDesktopError("sas_mismatch"); setDesktopPhase("error"); } try { await sendMessage({ type: "DESKTOP_PAIR_CONFIRM", payload: { confirmed } }); } finally { setDesktopBusy(false); } } // Auto-dismiss after the paired state settles — back to the Settings hub, // which reloads the paired-device list on mount. createEffect(() => { if (desktopPhase() !== "paired") return; const t = setTimeout(() => props.onBack(), DESKTOP_PAIRED_DISMISS_MS); onCleanup(() => clearTimeout(t)); }); onMount(() => { // Opened straight into a pairing path from the Settings sync card. With no // method there's nothing to do here — return to Settings (the hub). if (props.initialPair === "chirp") startChirpFlow(); else if (props.initialPair === "desktop") startDesktopFlow(); else props.onBack(); // Desktop pairing pushes from the background (bg → popup). The whole NK // pairing runs in the service worker; the SAS to confirm and the terminal // result arrive here as runtime messages. if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) { const handler = (msg: { type?: string; payload?: unknown }) => { if (pairKind() !== "desktop") return; if (msg?.type === "DESKTOP_PAIR_SAS") { const p = msg.payload as { sas?: string } | undefined; if (typeof p?.sas === "string") { setDesktopSas(p.sas); setDesktopPhase("sas"); } } else if (msg?.type === "DESKTOP_PAIR_RESULT") { const p = msg.payload as | { status: "complete"; device: PairedDevice } | { status: "error"; error: DesktopPairingError } | undefined; if (p?.status === "complete") { setDesktopDevice(p.device); setDesktopPhase("paired"); } else if (p?.status === "error") { setDesktopError(p.error); setDesktopPhase("error"); } } }; chrome.runtime.onMessage.addListener(handler); onCleanup(() => chrome.runtime.onMessage.removeListener(handler)); } }); async function handleStartPairing(): Promise { // audioCapture is declared as optional_permissions in the manifest. // Chrome will silently deny getUserMedia unless we request it here, // during a user gesture. Firefox ignores this API but getUserMedia works // under its existing host permission grant. if (typeof chrome !== "undefined" && chrome.permissions?.request) { const granted = await chrome.permissions.request({ permissions: ["audioCapture" as chrome.runtime.ManifestPermissions], }); if (!granted) { setPairing({ phase: "mic_denied" }); return; } } setPairing({ phase: "idle" }); const deps: ChirpPairingDeps = { onProgress: (p) => setPairing(p), fetchLocalIdentity: async () => { const res = await sendMessage({ type: "CHIRP_GET_LOCAL_IDENTITY" }); const r = res as { ok: boolean; data?: Parameters[0] & object; error?: string }; if (!r.ok || !r.data) throw new Error(r.error ?? "identity_unavailable"); // The handler returns a LocalIdentity; we trust the background's shape. return r.data as unknown as Awaited>; }, completePairing: async (paired) => { const res = await sendMessage({ type: "CHIRP_COMPLETE_PAIRING", payload: { paired }, }); const r = res as { ok: boolean; error?: string }; if (!r.ok) throw new Error(r.error ?? "complete_pairing_failed"); }, confirmSAS: (sas) => new Promise((resolve) => { setSasResolve(() => resolve); setSasCode(sas); }), // Receive Pro from an already-Pro peer (signature-verified in the bg). // Skip if we're already Pro so we keep our own license. activatePeerLicense: async (license) => { if (props.isPro) return; const res = (await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: license }, })) as { ok: boolean; data?: { valid?: boolean; plan?: string } }; if (res.ok && res.data?.valid && res.data?.plan === "pro") props.onProChanged?.(); }, getLocalSubnet, openPairingTransport: async (endpoint) => { // Native WebSocket transport for the pairing-mode peer scan. The // chirp-pair orchestrator only uses send/receive/close, so a thin // adapter over the platform WebSocket suffices. return new Promise((resolve, reject) => { let ws: WebSocket; try { ws = new WebSocket(`ws://${endpoint.ip}:${endpoint.port}/`); } catch (err) { reject(err); return; } const inbox: Uint8Array[] = []; const waiters: Array<(b: Uint8Array) => void> = []; let closed = false; ws.binaryType = "arraybuffer"; const timer = setTimeout(() => { try { ws.close(); } catch { /* ignore */ } reject(new Error("pairing_transport_timeout")); }, 2_000); ws.onopen = () => { clearTimeout(timer); resolve({ async send(data: Uint8Array) { ws.send(data as Uint8Array); }, receive(): Promise { if (closed) return Promise.reject(new Error("closed")); const v = inbox.shift(); if (v) return Promise.resolve(v); return new Promise((res) => waiters.push(res)); }, async close() { try { ws.close(); } catch { /* ignore */ } }, }); }; ws.onmessage = (ev) => { if (!(ev.data instanceof ArrayBuffer)) return; const bytes = new Uint8Array(ev.data); const w = waiters.shift(); if (w) w(bytes); else inbox.push(bytes); }; ws.onerror = () => { clearTimeout(timer); reject(new Error("pairing_transport_error")); }; ws.onclose = () => { closed = true; while (waiters.length > 0) waiters.shift()!(new Uint8Array(0)); }; }); }, }; const role: PairingRole = isNativePlatform() ? "initiator" : "responder"; if (role === "initiator") deps.openPairingServer = openCapacitorPairingServer; try { await runChirpPairing(deps, role); // Success surfaces in `pairing()` as the "paired" phase; ChirpPairingFlow // shows it and onDismiss returns to Settings. } catch (err) { if (err instanceof ChirpPairingFailure) { // The orchestrator already pushed the terminal phase into `pairing()`; // the inline error copy renders. The user can tap "Try again"/"Cancel". return; } setPairing({ phase: "handshake_failed", errorMessage: (err as Error).message }); } } // --- Sub-views -------------------------------------------------- const backArrow = ( ); // The bird stage — always animating — shared by every desktop pairing screen // so the bird animation is present regardless of sync method. const desktopBirdStage = (active: boolean) => ( ); function desktopLiveText(): string { switch (desktopPhase()) { case "enter": return "Enter the pairing code from your computer"; case "connecting": return "Searching for the helper"; case "sas": return "Confirm the code matches on your computer"; case "paired": return "Paired"; case "error": return desktopErrorCopy(desktopError()).headline; } } // Desktop helper code pairing — birds animate on every screen below. const desktopView = () => ( ); const chirpPairingView = () => ( props.onBack()} onRetry={() => void handleStartPairing()} onDismiss={() => props.onBack()} /> } > {/* Out-of-band verification gate — pairing is NOT persisted until the user confirms this code matches the one on the other device. */}
Verify it's really your device
Confirm this code matches the one shown on your other device before pairing.
{sasCode()}
); // Pairing surface dispatch: the user already chose chirp vs. code on the rest // screen (or the sync card), so there's no method-select step. const pairingView = () => ( {desktopView()} ); // Sync is no longer Pro-gated: anyone may attempt to pair so a free device can // receive Pro from an already-Pro peer. The upsell lives on the Settings card. return pairingView(); };