// PairDevice — 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, returning to Settings (`onBack`) when done // or cancelled: the user types the pairing code their phone displays, the // browser dials out to the address inside it, and the NK handshake + SAS run // from there (enter code → connecting → SAS → paired). // // There is one path because there can only be one: the browser cannot listen, // so it is always the side that dials. // // The pairing itself runs in the background (code-pairing.ts) and reaches // this view via the PAIR_WITH_CODE / CODE_PAIR_* messages. import { Component, Show, createEffect, createSignal, onCleanup, onMount, } from "solid-js"; import { sendMessage } from "../../shared/bridge"; import type { PairedDevice } from "../../shared/sync-types"; import { DesktopBird, MobileBird } from "../components/PairingBirds"; import { formatPairingCode, parsePairingCode } from "../../shared/pairing-code"; export interface PairDeviceViewProps { isPro: boolean; onBack(): void; onLearnPro(): void; /** * Set when the user taps the pairing option on the Settings sync card. With * no method there is nothing to do here, so the view returns to Settings. * Read once on mount. */ initialPair?: "code" | null; /** Called after a peer's Pro license is activated, so the app re-reads Pro status. */ onProChanged?: () => void; } // Code-pairing phases. Mirrors the background CodePairingError union. type PairPhase = "enter" | "connecting" | "sas" | "paired" | "error"; type CodePairingError = | "invalid_code" | "no_host_found" | "handshake_failed" | "sas_mismatch" | "vault_locked"; const PAIRED_DISMISS_MS = 2_000; function errorCopy(error: CodePairingError | "unknown"): { headline: string; sub: string } { switch (error) { case "invalid_code": // Ground truth §0.3: wrong vs expired is indistinguishable on the wire — // the listener closes the socket either way, with no error frame. return { headline: "Code not found or expired", sub: "Check the code on your phone and try again. Codes expire, so grab a fresh one if it's been a while.", }; case "no_host_found": return { headline: "Couldn't reach your phone", sub: "Make sure both devices are on the same Wi-Fi and the pairing screen is still open on your phone.", }; 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 PairDeviceView: Component = (props) => { // The whole NK pairing runs in the background; this view drives the UI and // the out-of-band SAS confirmation round-trip via the PAIR_WITH_CODE / // CODE_PAIR_* messages. The SAS gate is what authenticates the peer // (audit HIGH — pairing initiator authentication), not the pairing token. const [phase, setPhase] = createSignal("enter"); const [code, setCode] = createSignal(""); const [sas, setSas] = createSignal(null); const [errorKind, setErrorKind] = createSignal("unknown"); const [pairedDevice, setPairedDevice] = createSignal(undefined); const [busy, setBusy] = createSignal(false); // Two accepted forms: the pairing code a phone displays, and the legacy // 6-digit desktop-helper code. `parsePairingCode` returning null just means // "not that form", so the check falls through to the digit shape. const codeReady = () => parsePairingCode(code()) !== null || /^\d{6}$/.test(code()); function startFlow(): void { setCode(""); setSas(null); setErrorKind("unknown"); setPairedDevice(undefined); setBusy(false); setPhase("enter"); } function onCodeInput(value: string): void { const cleaned = value.toUpperCase().replace(/[^0-9A-Z]/g, "").slice(0, 32); // Group once the input outgrows a 6-digit helper code, so a pairing code // regains its dashes as it is typed without mangling the short form. Length // rather than character class, so an all-digit pairing code still groups. setCode(cleaned.length > 6 ? formatPairingCode(cleaned) : cleaned); } async function handleConnect(): Promise { if (!codeReady() || busy()) return; setBusy(true); setPhase("connecting"); try { const res = (await sendMessage({ type: "PAIR_WITH_CODE", payload: { code: code() }, })) 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) { setErrorKind((res.error as CodePairingError) ?? "unknown"); setPhase("error"); } } catch { setErrorKind("unknown"); setPhase("error"); } finally { setBusy(false); } } async function respondConfirm(confirmed: boolean): Promise { if (busy()) return; setBusy(true); // Optimistically move on; the terminal CODE_PAIR_RESULT push decides the // final phase (paired vs sas_mismatch error). if (confirmed) setPhase("connecting"); else { setErrorKind("sas_mismatch"); setPhase("error"); } try { await sendMessage({ type: "CODE_PAIR_CONFIRM", payload: { confirmed } }); } finally { setBusy(false); } } // Auto-dismiss after the paired state settles — back to the Settings hub, // which reloads the paired-device list on mount. createEffect(() => { if (phase() !== "paired") return; const t = setTimeout(() => props.onBack(), 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 === "code") startFlow(); else props.onBack(); // 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 (msg?.type === "CODE_PAIR_SAS") { const p = msg.payload as { sas?: string } | undefined; if (typeof p?.sas === "string") { setSas(p.sas); setPhase("sas"); } } else if (msg?.type === "CODE_PAIR_RESULT") { const p = msg.payload as | { status: "complete"; device: PairedDevice } | { status: "error"; error: CodePairingError } | undefined; if (p?.status === "complete") { setPairedDevice(p.device); setPhase("paired"); } else if (p?.status === "error") { setErrorKind(p.error); setPhase("error"); } } }; chrome.runtime.onMessage.addListener(handler); onCleanup(() => chrome.runtime.onMessage.removeListener(handler)); } // If the popup closes while the user is on the SAS screen, resolve the // pending background confirm as false so the service worker clears // inFlight and future pairing attempts aren't stuck with "pairing_in_progress". onCleanup(() => { if (phase() === "sas") { void sendMessage({ type: "CODE_PAIR_CONFIRM", payload: { confirmed: false } }); } }); }); // --- Sub-views -------------------------------------------------- const backArrow = ( ); // The bird stage — always animating — shared by every pairing screen. const birdStage = (active: boolean) => ( ); function liveText(): string { switch (phase()) { case "enter": return "Enter the pairing code from your phone"; case "connecting": return "Connecting to your phone"; case "sas": return "Confirm the code matches on your phone"; case "paired": return "Paired"; case "error": return errorCopy(errorKind()).headline; } } // Code pairing — birds animate on every screen below. const pairingScreen = () => ( ); // 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 pairingScreen(); };