PairDevice.tsx

11.0 KB · raw

1// PairDevice — the pairing surface, opened from the Settings → Sync card.2//3// Settings owns the sync "hub" (paired-device list, revoke, sync-now); this4// view is purely the pairing flow, returning to Settings (`onBack`) when done5// or cancelled.6//7// A populated extension is the source, so this screen displays one QR for the8// empty device to scan. A fresh extension takes the complementary receiver path9// in AdoptVaultView. Keeping the raw invitation out of the visible copy prevents10// it from being mistaken for the later 8-digit safety code.11//12// The pairing itself runs in the background (relay-adoption-bg.ts) and13// reaches this view via the ADOPTION_SOURCE_* messages.1415import {16  type Component,17  Show,18  createSignal,19  onCleanup,20  onMount,21} from "solid-js";22import { sendMessage } from "../../shared/bridge";23import { DesktopBird, MobileBird } from "../components/PairingBirds";24import { PairingQrCode } from "../components/PairingQrCode";25import { PairingProRequired } from "../components/PairingProRequired";26import { SyncModeSelector } from "../components/SyncModeSelector";2728export interface PairDeviceViewProps {29  isPro: boolean;30  onBack(): void;31  onLearnPro(): void;32  /** Called after a peer's Pro license is activated, so the app re-reads Pro status. */33  onProChanged?: () => void;34}3536type SharePhase = "opening" | "invitation" | "sas" | "paired" | "error";3738export const PairDeviceView: Component<PairDeviceViewProps> = (props) => {39  const [sharePhase, setSharePhase] = createSignal<SharePhase>("opening");40  const [invitation, setInvitation] = createSignal("");41  const [sourceSas, setSourceSas] = createSignal("");42  const [pendingSasId, setPendingSasId] = createSignal("");43  const [receiverName, setReceiverName] = createSignal("New device");44  const [shareError, setShareError] = createSignal("");45  const [changingRelay, setChangingRelay] = createSignal(false);4647  async function startShareFlow(): Promise<void> {48    setSharePhase("opening");49    setShareError("");50    setInvitation("");51    setSourceSas("");52    try {53      const response = (await sendMessage({54        type: "START_ADOPTION_SOURCE",55      })) as {56        ok: boolean;57        data?: { invitation?: string };58        error?: string;59      };60      if (!response.ok || typeof response.data?.invitation !== "string") {61        throw new Error(response.error ?? "adoption_source_failed");62      }63      setInvitation(response.data.invitation);64      setSharePhase("invitation");65    } catch (error) {66      setShareError(error instanceof Error ? error.message : "adoption_source_failed");67      setSharePhase("error");68    }69  }7071  async function stopShareFlow(): Promise<void> {72    await sendMessage({ type: "CANCEL_ADOPTION_SOURCE" }).catch(() => undefined);73  }7475  /** The invitation embeds the relay, so a relay change invalidates it. */76  async function restartShareFlow(): Promise<void> {77    await stopShareFlow();78    await startShareFlow();79  }8081  function answerSas(ok: boolean): void {82    const id = pendingSasId();83    if (!id) return;84    setPendingSasId("");85    void sendMessage({86      type: "SOURCE_SAS_DECISION",87      payload: { id, ok },88    }).catch(() => undefined);89    if (!ok) {90      setShareError("adoption_rejected");91      setSharePhase("error");92    }93  }9495  onMount(() => {96    void startShareFlow();9798    // Pairing pushes from the background (bg → popup). The whole NK session99    // runs in the service worker; the SAS to confirm and the terminal result100    // arrive here as runtime messages.101    if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {102      const handler = (msg: { type?: string; payload?: unknown }) => {103        if (msg?.type === "ADOPTION_SOURCE_SAS") {104          const p = msg.payload as {105            id?: string;106            sas?: string;107            receiverDeviceName?: string;108          } | undefined;109          if (typeof p?.sas === "string") {110            setSourceSas(p.sas);111            setPendingSasId(typeof p.id === "string" ? p.id : "");112            setReceiverName(113              typeof p.receiverDeviceName === "string" && p.receiverDeviceName114                ? p.receiverDeviceName115                : "New device",116            );117            setSharePhase("sas");118          }119        } else if (msg?.type === "ADOPTION_SOURCE_RESULT") {120          const p = msg.payload as {121            status?: "complete" | "error";122            error?: string;123          } | undefined;124          if (p?.status === "complete") {125            setPendingSasId("");126            setSharePhase("paired");127          } else if (p?.status === "error") {128            setPendingSasId("");129            setShareError(p.error ?? "adoption_failed");130            setSharePhase("error");131          }132        }133      };134      chrome.runtime.onMessage.addListener(handler);135      onCleanup(() => chrome.runtime.onMessage.removeListener(handler));136    }137138    // Closing the popup mid-session must release the relay room, or the next139    // attempt is refused with adoption_source_in_progress.140    onCleanup(() => {141      const id = pendingSasId();142      if (id) {143        void sendMessage({144          type: "SOURCE_SAS_DECISION",145          payload: { id, ok: false },146        }).catch(() => undefined);147      }148      if (sharePhase() !== "paired") {149        void sendMessage({ type: "CANCEL_ADOPTION_SOURCE" });150      }151    });152  });153154  const backArrow = (155    <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">156      <path d="M10 3 L5 8 L10 13" />157    </svg>158  );159160  // The bird stage — always animating — shared by every pairing screen.161  const birdStage = (phase: SharePhase) => (162    <div class={`peach-pair-stage ${phase === "sas" || phase === "paired" ? "act-handshake" : "act-listening"}`} aria-hidden="true">163      <div class="peach-pair-bird-slot peach-pair-bird-slot-desktop">164        <DesktopBird pose={phase === "paired" ? "happy" : "chirping"} />165      </div>166      <div class="peach-pair-bird-slot peach-pair-bird-slot-mobile">167        <MobileBird pose={phase === "paired" ? "happy" : "attentive"} />168      </div>169    </div>170  );171172  function errorCopy(): string {173    switch (shareError()) {174      case "vault_locked":175        return "Unlock Peach first, then try again.";176      case "sync_disabled":177        return "Sync is turned off. Choose a relay in Settings → Sync, then try again.";178      case "private_relay_unset":179        return "Private relay is selected but has no URL yet. Enter it in Settings → Sync, then try again.";180      default:181        return "The secure pairing session ended. Start again with a fresh code.";182    }183  }184185  function liveText(): string {186    switch (sharePhase()) {187      case "opening": return "Opening a secure pairing room";188      case "invitation": return "Scan this QR on the empty device";189      case "sas": return "Confirm the code matches";190      case "paired": return "Device added";191      case "error": return "Could not add the device";192    }193  }194195  return (196    <div197      class="peach-pair-pair peach-desktop-pair peach-adoption-source"198      data-phase={sharePhase()}199      role="dialog"200      aria-label="Add a device to this vault"201    >202      <header class="peach-pair-head">203        <button204          class="peach-pair-back"205          aria-label="Back"206          onClick={() => { void stopShareFlow(); props.onBack(); }}207        >208          {backArrow}209        </button>210        <div class="peach-pair-title">Add a new device</div>211        <div class="peach-pair-head-spacer" />212      </header>213214      {birdStage(sharePhase())}215216      <Show when={sharePhase() === "opening"}>217        <div class="peach-desktop-searching">218          <div class="peach-pair-headline">Opening a secure pairing room…</div>219          <div class="peach-pair-sub">The code appears once this browser is waiting in the relay.</div>220        </div>221      </Show>222223      <Show when={sharePhase() === "invitation"}>224        <div class="peach-adoption-source-invitation">225          <Show when={!changingRelay()}>226            <div class="peach-pair-headline">Pair a device</div>227            <div class="peach-pair-sub">228              This extension has the vault. Scan this QR on the empty device,229              then compare the 8-digit safety code.230            </div>231            <PairingQrCode value={invitation()} size={176} />232          </Show>233          {/* Moment 1 (docs/sync-relay-surfacing.md): the relay choice belongs234              here because a relay is about to be used, but it is not a step —235              pairing already works on the default. Changing it rebuilds the236              invitation, which carries the relay address. */}237          <SyncModeSelector238            compact239            onExpand={setChangingRelay}240            onChange={() => void restartShareFlow()}241          />242        </div>243      </Show>244245      <Show when={sharePhase() === "paired"}>246        <div class="peach-desktop-paired">247          <div class="peach-pair-headline">Device added</div>248          <div class="peach-pair-sub">The new device can now sync this vault through the relay.</div>249          <div class="peach-pair-action">250            <button class="peach-btn peach-btn-primary" onClick={props.onBack}>Done</button>251          </div>252        </div>253      </Show>254255      <Show when={sharePhase() === "error"}>256        <Show257          when={shareError() === "ADOPT_PRO_REQUIRED"}258          fallback={259            <div class="peach-desktop-error">260              <div class="peach-pair-headline">Could not add the device</div>261              <div class="peach-pair-sub">{errorCopy()}</div>262              <div class="peach-pair-action">263                <button class="peach-btn peach-btn-ghost" onClick={props.onBack}>Cancel</button>264                <button class="peach-btn peach-btn-primary" onClick={() => void startShareFlow()}>Try again</button>265              </div>266            </div>267          }268        >269          <div class="peach-desktop-error peach-desktop-error--pro">270            <PairingProRequired271              onRetry={() => void startShareFlow()}272              onOpenPro={props.onLearnPro}273            />274          </div>275        </Show>276      </Show>277278      <Show when={sharePhase() === "sas"}>279        <div class="peach-desktop-searching">280          <div class="peach-pair-headline">Compare the 8-digit safety code</div>281          <div class="peach-pair-sub">282            If {receiverName()} shows the same code, tap “They match” on that283            device too. Peach will only finish after both devices confirm.284          </div>285          <div class="peach-adoption-sas" aria-label="Pairing confirmation code">286            <strong>{sourceSas()}</strong>287          </div>288          <div class="peach-pair-action">289            <button class="peach-btn peach-btn-ghost" onClick={() => answerSas(false)}>290              They don't match291            </button>292            <button class="peach-btn peach-btn-primary" onClick={() => answerSas(true)}>293              They match294            </button>295          </div>296        </div>297      </Show>298299      <div class="peach-sr-only" aria-live="polite">{liveText()}</div>300    </div>301  );302};