// 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. // // A populated extension is the source, so this screen displays one QR for the // empty device to scan. A fresh extension takes the complementary receiver path // in AdoptVaultView. Keeping the raw invitation out of the visible copy prevents // it from being mistaken for the later 8-digit safety code. // // The pairing itself runs in the background (relay-adoption-bg.ts) and // reaches this view via the ADOPTION_SOURCE_* messages. import { type Component, Show, createSignal, onCleanup, onMount, } from "solid-js"; import { sendMessage } from "../../shared/bridge"; import { DesktopBird, MobileBird } from "../components/PairingBirds"; import { PairingQrCode } from "../components/PairingQrCode"; import { PairingProRequired } from "../components/PairingProRequired"; import { SyncModeSelector } from "../components/SyncModeSelector"; export interface PairDeviceViewProps { isPro: boolean; onBack(): void; onLearnPro(): void; /** Called after a peer's Pro license is activated, so the app re-reads Pro status. */ onProChanged?: () => void; } type SharePhase = "opening" | "invitation" | "sas" | "paired" | "error"; export const PairDeviceView: Component = (props) => { const [sharePhase, setSharePhase] = createSignal("opening"); const [invitation, setInvitation] = createSignal(""); const [sourceSas, setSourceSas] = createSignal(""); const [pendingSasId, setPendingSasId] = createSignal(""); const [receiverName, setReceiverName] = createSignal("New device"); const [shareError, setShareError] = createSignal(""); const [changingRelay, setChangingRelay] = createSignal(false); async function startShareFlow(): Promise { 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); } /** The invitation embeds the relay, so a relay change invalidates it. */ async function restartShareFlow(): Promise { await stopShareFlow(); await startShareFlow(); } function answerSas(ok: boolean): void { const id = pendingSasId(); if (!id) return; setPendingSasId(""); void sendMessage({ type: "SOURCE_SAS_DECISION", payload: { id, ok }, }).catch(() => undefined); if (!ok) { setShareError("adoption_rejected"); setSharePhase("error"); } } onMount(() => { void startShareFlow(); // Pairing pushes from the background (bg → popup). The whole NK session // 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 === "ADOPTION_SOURCE_SAS") { const p = msg.payload as { id?: string; sas?: string; receiverDeviceName?: string; } | undefined; if (typeof p?.sas === "string") { setSourceSas(p.sas); setPendingSasId(typeof p.id === "string" ? p.id : ""); 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") { setPendingSasId(""); setSharePhase("paired"); } else if (p?.status === "error") { setPendingSasId(""); setShareError(p.error ?? "adoption_failed"); setSharePhase("error"); } } }; chrome.runtime.onMessage.addListener(handler); onCleanup(() => chrome.runtime.onMessage.removeListener(handler)); } // Closing the popup mid-session must release the relay room, or the next // attempt is refused with adoption_source_in_progress. onCleanup(() => { const id = pendingSasId(); if (id) { void sendMessage({ type: "SOURCE_SAS_DECISION", payload: { id, ok: false }, }).catch(() => undefined); } if (sharePhase() !== "paired") { void sendMessage({ type: "CANCEL_ADOPTION_SOURCE" }); } }); }); const backArrow = ( ); // The bird stage — always animating — shared by every pairing screen. const birdStage = (phase: SharePhase) => ( ); function errorCopy(): string { switch (shareError()) { case "vault_locked": return "Unlock Peach first, then try again."; case "sync_disabled": return "Sync is turned off. Choose a relay in Settings → Sync, then try again."; case "private_relay_unset": return "Private relay is selected but has no URL yet. Enter it in Settings → Sync, then try again."; default: return "The secure pairing session ended. Start again with a fresh code."; } } function liveText(): string { switch (sharePhase()) { case "opening": return "Opening a secure pairing room"; case "invitation": return "Scan this QR on the empty device"; case "sas": return "Confirm the code matches"; case "paired": return "Device added"; case "error": return "Could not add the device"; } } return ( } >
void startShareFlow()} onOpenPro={props.onLearnPro} />
Compare the 8-digit safety code
If {receiverName()} shows the same code, tap “They match” on that device too. Peach will only finish after both devices confirm.
{sourceSas()}
{liveText()}
); };