// 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"; import { AdoptionConsentModal } from "../components/AdoptionConsentModal"; import { PairingQrCode } from "../components/PairingQrCode"; 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 SharePhase = "opening" | "invitation" | "sas" | "connecting" | "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); const [mode, setMode] = createSignal<"share" | "connect">("share"); const [sharePhase, setSharePhase] = createSignal("opening"); const [invitation, setInvitation] = createSignal(""); const [sourceSas, setSourceSas] = createSignal(""); const [receiverName, setReceiverName] = createSignal("New device"); const [shareError, setShareError] = createSignal(""); const codeReady = () => parsePairingCode(code()) !== null; function startFlow(): void { setMode("connect"); setCode(""); setSas(null); setErrorKind("unknown"); setPairedDevice(undefined); setBusy(false); setPhase("enter"); } async function startShareFlow(): Promise { setMode("share"); setSharePhase("opening"); setShareError(""); setInvitation(""); setSourceSas(""); try { const response = (await sendMessage({ type: "START_ADOPTION_SOURCE", })) as { ok: boolean; data?: { invitation?: string }; error?: string; }; if (!response.ok || typeof response.data?.invitation !== "string") { throw new Error(response.error ?? "adoption_source_failed"); } setInvitation(response.data.invitation); setSharePhase("invitation"); } catch (error) { setShareError(error instanceof Error ? error.message : "adoption_source_failed"); setSharePhase("error"); } } async function stopShareFlow(): Promise { await sendMessage({ type: "CANCEL_ADOPTION_SOURCE" }).catch(() => undefined); } async function useCodeInstead(): Promise { await stopShareFlow(); startFlow(); } async function verifySourcePassword(password: string): Promise { const response = (await sendMessage({ type: "ADOPTION_SOURCE_CONFIRM", payload: { password }, })) as { ok: boolean; data?: { valid?: boolean } }; return response.ok && response.data?.valid === true; } function onCodeInput(value: string): void { const cleaned = value.toUpperCase().replace(/[^0-9A-Z]/g, "").slice(0, 32); setCode(formatPairingCode(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") { void sendMessage({ type: "GET_RELAY_SETTINGS" }) .then((response) => { const settings = response as { ok: boolean; data?: { lanOnly?: boolean }; }; if (settings.ok && settings.data?.lanOnly !== true) { return startShareFlow(); } startFlow(); }) .catch(() => 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"); } } else if (msg?.type === "ADOPTION_SOURCE_SAS") { const p = msg.payload as { sas?: string; receiverDeviceName?: string; } | undefined; if (typeof p?.sas === "string") { setSourceSas(p.sas); setReceiverName( typeof p.receiverDeviceName === "string" && p.receiverDeviceName ? p.receiverDeviceName : "New device", ); setSharePhase("sas"); } } else if (msg?.type === "ADOPTION_SOURCE_RESULT") { const p = msg.payload as { status?: "complete" | "error"; error?: string; } | undefined; if (p?.status === "complete") setSharePhase("paired"); else if (p?.status === "error") { setShareError(p.error ?? "adoption_failed"); setSharePhase("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 } }); } if (mode() === "share" && sharePhase() !== "paired") { void sendMessage({ type: "CANCEL_ADOPTION_SOURCE" }); } }); }); // --- 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 = () => ( ); const shareScreen = () => ( ); // 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 mode() === "share" ? shareScreen() : pairingScreen(); };