// ChirpSync — the v3 Settings → Sync surface. // // Three modes the user can be in: // // 1. Locked behind Pro (free user) — soft upsell card, "Learn more" routes // to the License section. // 2. Idle — list of paired devices + "Pair a device" hero button. Stale // devices (>7 days since last sync) show a soft warning indicator; // vouched devices (received via device manifest, v2 brief §4.2) show // an "Added automatically" badge. // 3. Pairing — ChirpPairingFlow takes over the full surface and drives // the three-act bird animation in lockstep with the audio protocol. // // The pairing orchestrator lives in `popup/chirp-pair.ts`; this view is // the binding between that orchestrator, the background-message // CHIRP_* handlers, and the Settings navigation surrounding it. import { Component, Show, createEffect, createMemo, createSignal, For, onCleanup, onMount, } from "solid-js"; import { sendMessage } from "../../shared/bridge"; import type { DeviceType, 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; } const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000; type Mode = "rest" | "pairing"; // Which pairing path the user picked on the method-select screen. `null` = // still choosing. 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 the Peach helper is running and both devices are on the same Wi-Fi.", }; 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 [devices, setDevices] = createSignal([]); const [mode, setMode] = createSignal("rest"); const [pairing, setPairing] = createSignal({ phase: "idle" }); const [error, setError] = createSignal(null); const [confirmUnpair, setConfirmUnpair] = createSignal(null); const [syncStatus, setSyncStatus] = createSignal<"idle" | "syncing" | "error">("idle"); const [syncFlash, setSyncFlash] = createSignal(null); // 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(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 openPairMethodSelect(): void { setError(null); setPairKind(null); setMode("pairing"); } 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, like the chirp flow. createEffect(() => { if (desktopPhase() !== "paired") return; const t = setTimeout(() => { setMode("rest"); setPairKind(null); void refresh(); }, DESKTOP_PAIRED_DISMISS_MS); onCleanup(() => clearTimeout(t)); }); async function handleSyncNow(): Promise { setSyncStatus("syncing"); setSyncFlash(null); const res = await sendMessage({ type: "TRIGGER_SYNC", payload: { reason: "manual", force: true }, }) as { ok: boolean; error?: string }; // Poll briefly for the new state — the dispatcher broadcasts but the // popup doesn't yet listen, so a short re-fetch covers it. setTimeout(async () => { const state = await sendMessage({ type: "GET_SYNC_STATE" }) as { ok: boolean; data?: { status: "idle" | "syncing" | "error"; entriesReceived?: number; error?: string }; }; const s = state.data; if (!s) return; setSyncStatus(s.status); if (s.status === "idle") { setSyncFlash( (s.entriesReceived ?? 0) > 0 ? `Synced — ${s.entriesReceived} entries updated` : "Vault up to date", ); setTimeout(() => setSyncFlash(null), 3000); await refresh(); } else if (s.status === "error") { setSyncFlash(`Sync failed — ${s.error ?? "no mobile host reachable"}`); setTimeout(() => setSyncFlash(null), 4000); } }, 250); if (!res.ok) { setSyncStatus("error"); setSyncFlash(`Sync failed — ${res.error ?? "unknown"}`); setTimeout(() => setSyncFlash(null), 4000); } } onMount(() => { void refresh(); // 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 refresh(): Promise { if (!props.isPro) return; const res = await sendMessage({ type: "CHIRP_LIST_PAIRED_DEVICES" }); const data = (res as { ok: boolean; data?: { devices: PairedDevice[] } }).data; setDevices(data?.devices ?? []); } async function handleStartPairing(): Promise { setError(null); // 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" }); setMode("pairing"); return; } } setPairing({ phase: "idle" }); setMode("pairing"); 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); }), 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); await refresh(); } catch (err) { if (err instanceof ChirpPairingFailure) { // The orchestrator already pushed the terminal phase into // `pairing()`; the inline error copy will render. No further // action needed — the user can tap "Try again" or "Cancel". return; } setError((err as Error).message); setMode("rest"); } } async function handleUnpair(deviceId: string): Promise { await sendMessage({ type: "CHIRP_UNPAIR_DEVICE", payload: { deviceId } }); setConfirmUnpair(null); await refresh(); } function relativeTime(when: number | undefined): string { if (!when) return "Not synced yet"; const elapsed = Date.now() - when; if (elapsed < 60_000) return "Just now"; const minutes = Math.floor(elapsed / 60_000); if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`; const days = Math.floor(hours / 24); if (days < 7) return `${days} day${days === 1 ? "" : "s"} ago`; return "Not seen recently"; } function isStale(d: PairedDevice): boolean { if (d.lastSyncedAt === undefined) return false; return Date.now() - d.lastSyncedAt > STALE_THRESHOLD_MS; } // --- Sub-views -------------------------------------------------- const proGateView = () => (

Sync

A little bird that carries your vault

Pairs over an inaudible chirp. Syncs locally across your devices. No codes, no servers, no cloud.

Peach Pro · one-time purchase

); const restView = () => (
Sync
ready
{devices().length === 0 ? "Bring your phone close" : "Sync is ready"}
{devices().length === 0 ? "Peach pairs over a short, inaudible chirp — no codes, no servers." : "Pair another device, or manage the ones already connected below."}
0}>

Paired devices

    {(d) => (
  • {d.deviceName ?? "Unnamed device"} {relativeTime(d.lastSyncedAt)} Added automatically Not seen recently
} > )} 0}>
{syncFlash()}

Pair over a chirp in the same room, or with the desktop helper by code.

); 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; } } // Method-select — the first screen, before any birds. Two paths: a same-room // audio chirp, or a typed code from the peach-sync desktop helper. const methodSelectView = () => (
Pair a device
How do you want to pair?
In the same room? Use a chirp. Pairing with the Peach desktop helper? Enter its code.
); // Desktop helper code pairing — birds animate on every screen below. const desktopView = () => ( ); const chirpPairingView = () => ( setMode("rest")} onRetry={() => void handleStartPairing()} onDismiss={() => { setMode("rest"); void refresh(); }} /> } > {/* 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: method-select first (no birds yet), then either // the chirp flow or the desktop-helper flow depending on the chosen method. const pairingView = () => ( {desktopView()} ); const currentMode = createMemo(() => { if (!props.isPro) return "gated"; if (mode() === "pairing") return "pairing"; return "rest"; }); return ( {pairingView()} } > {proGateView()} ); }; const DeviceTypeIcon: Component<{ type: DeviceType }> = (props) => { return (
); };