PairDevice.tsx

11.3 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 [peerFingerprint, setPeerFingerprint] = createSignal("");45  const [shareError, setShareError] = createSignal("");46  const [changingRelay, setChangingRelay] = createSignal(false);4748  async function startShareFlow(): Promise<void> {49    setSharePhase("opening");50    setShareError("");51    setInvitation("");52    setSourceSas("");53    try {54      const response = (await sendMessage({55        type: "START_ADOPTION_SOURCE",56      })) as {57        ok: boolean;58        data?: { invitation?: string };59        error?: string;60      };61      if (!response.ok || typeof response.data?.invitation !== "string") {62        throw new Error(response.error ?? "adoption_source_failed");63      }64      setInvitation(response.data.invitation);65      setSharePhase("invitation");66    } catch (error) {67      setShareError(error instanceof Error ? error.message : "adoption_source_failed");68      setSharePhase("error");69    }70  }7172  async function stopShareFlow(): Promise<void> {73    await sendMessage({ type: "CANCEL_ADOPTION_SOURCE" }).catch(() => undefined);74  }7576  /** The invitation embeds the relay, so a relay change invalidates it. */77  async function restartShareFlow(): Promise<void> {78    await stopShareFlow();79    await startShareFlow();80  }8182  function answerSas(ok: boolean): void {83    const id = pendingSasId();84    if (!id) return;85    setPendingSasId("");86    void sendMessage({87      type: "SOURCE_SAS_DECISION",88      payload: { id, ok },89    }).catch(() => undefined);90    if (!ok) {91      setShareError("adoption_rejected");92      setSharePhase("error");93    }94  }9596  onMount(() => {97    void startShareFlow();9899    // Pairing pushes from the background (bg → popup). The whole NK session100    // runs in the service worker; the SAS to confirm and the terminal result101    // arrive here as runtime messages.102    if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {103      const handler = (msg: { type?: string; payload?: unknown }) => {104        if (msg?.type === "ADOPTION_SOURCE_SAS") {105          const p = msg.payload as {106            id?: string;107            sas?: string;108            receiverDeviceName?: string;109            peerFingerprint?: string;110          } | undefined;111          if (typeof p?.sas === "string") {112            setSourceSas(p.sas);113            setPendingSasId(typeof p.id === "string" ? p.id : "");114            setReceiverName(115              typeof p.receiverDeviceName === "string" && p.receiverDeviceName116                ? p.receiverDeviceName117                : "New device",118            );119            setPeerFingerprint(typeof p.peerFingerprint === "string" ? p.peerFingerprint : "");120            setSharePhase("sas");121          }122        } else if (msg?.type === "ADOPTION_SOURCE_RESULT") {123          const p = msg.payload as {124            status?: "complete" | "error";125            error?: string;126          } | undefined;127          if (p?.status === "complete") {128            setPendingSasId("");129            setSharePhase("paired");130          } else if (p?.status === "error") {131            setPendingSasId("");132            setShareError(p.error ?? "adoption_failed");133            setSharePhase("error");134          }135        }136      };137      chrome.runtime.onMessage.addListener(handler);138      onCleanup(() => chrome.runtime.onMessage.removeListener(handler));139    }140141    // Closing the popup mid-session must release the relay room, or the next142    // attempt is refused with adoption_source_in_progress.143    onCleanup(() => {144      const id = pendingSasId();145      if (id) {146        void sendMessage({147          type: "SOURCE_SAS_DECISION",148          payload: { id, ok: false },149        }).catch(() => undefined);150      }151      if (sharePhase() !== "paired") {152        void sendMessage({ type: "CANCEL_ADOPTION_SOURCE" });153      }154    });155  });156157  const backArrow = (158    <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">159      <path d="M10 3 L5 8 L10 13" />160    </svg>161  );162163  // The bird stage — always animating — shared by every pairing screen.164  const birdStage = (phase: SharePhase) => (165    <div class={`peach-pair-stage ${phase === "sas" || phase === "paired" ? "act-handshake" : "act-listening"}`} aria-hidden="true">166      <div class="peach-pair-bird-slot peach-pair-bird-slot-desktop">167        <DesktopBird pose={phase === "paired" ? "happy" : "chirping"} />168      </div>169      <div class="peach-pair-bird-slot peach-pair-bird-slot-mobile">170        <MobileBird pose={phase === "paired" ? "happy" : "attentive"} />171      </div>172    </div>173  );174175  function errorCopy(): string {176    switch (shareError()) {177      case "vault_locked":178        return "Unlock Peach first, then try again.";179      case "sync_disabled":180        return "Sync is turned off. Choose a relay in Settings → Sync, then try again.";181      case "private_relay_unset":182        return "Private relay is selected but has no URL yet. Enter it in Settings → Sync, then try again.";183      default:184        return "The secure pairing session ended. Start again with a fresh code.";185    }186  }187188  function liveText(): string {189    switch (sharePhase()) {190      case "opening": return "Opening a secure pairing room";191      case "invitation": return "Scan this QR on the empty device";192      case "sas": return "Confirm the code matches";193      case "paired": return "Device added";194      case "error": return "Could not add the device";195    }196  }197198  return (199    <div200      class="peach-pair-pair peach-desktop-pair peach-adoption-source"201      data-phase={sharePhase()}202      role="dialog"203      aria-label="Add a device to this vault"204    >205      <header class="peach-pair-head">206        <button207          class="peach-pair-back"208          aria-label="Back"209          onClick={() => { void stopShareFlow(); props.onBack(); }}210        >211          {backArrow}212        </button>213        <div class="peach-pair-title">Add a new device</div>214        <div class="peach-pair-head-spacer" />215      </header>216217      {birdStage(sharePhase())}218219      <Show when={sharePhase() === "opening"}>220        <div class="peach-desktop-searching">221          <div class="peach-pair-headline">Opening a secure pairing room…</div>222          <div class="peach-pair-sub">The code appears once this browser is waiting in the relay.</div>223        </div>224      </Show>225226      <Show when={sharePhase() === "invitation"}>227        <div class="peach-adoption-source-invitation">228          <Show when={!changingRelay()}>229            <div class="peach-pair-headline">Pair a device</div>230            <div class="peach-pair-sub">231              This extension has the vault. Scan this QR on the empty device,232              then compare the 8-digit safety code.233            </div>234            <PairingQrCode value={invitation()} size={176} />235          </Show>236          {/* Moment 1 (docs/sync-relay-surfacing.md): the relay choice belongs237              here because a relay is about to be used, but it is not a step —238              pairing already works on the default. Changing it rebuilds the239              invitation, which carries the relay address. */}240          <SyncModeSelector241            compact242            onExpand={setChangingRelay}243            onChange={() => void restartShareFlow()}244          />245        </div>246      </Show>247248      <Show when={sharePhase() === "paired"}>249        <div class="peach-desktop-paired">250          <div class="peach-pair-headline">Device added</div>251          <div class="peach-pair-sub">The new device can now sync this vault through the relay.</div>252          <div class="peach-pair-action">253            <button class="peach-btn peach-btn-primary" onClick={props.onBack}>Done</button>254          </div>255        </div>256      </Show>257258      <Show when={sharePhase() === "error"}>259        <Show260          when={shareError() === "ADOPT_PRO_REQUIRED"}261          fallback={262            <div class="peach-desktop-error">263              <div class="peach-pair-headline">Could not add the device</div>264              <div class="peach-pair-sub">{errorCopy()}</div>265              <div class="peach-pair-action">266                <button class="peach-btn peach-btn-ghost" onClick={props.onBack}>Cancel</button>267                <button class="peach-btn peach-btn-primary" onClick={() => void startShareFlow()}>Try again</button>268              </div>269            </div>270          }271        >272          <div class="peach-desktop-error peach-desktop-error--pro">273            <PairingProRequired274              onRetry={() => void startShareFlow()}275              onOpenPro={props.onLearnPro}276            />277          </div>278        </Show>279      </Show>280281      <Show when={sharePhase() === "sas"}>282        <div class="peach-desktop-searching">283          <div class="peach-pair-headline">Compare the 8-digit safety code</div>284          <div class="peach-pair-sub">285            If {receiverName()} shows the same code, tap “They match” on that286            device too. Session ID {peerFingerprint()}. Approving gives that287            device a complete copy of this vault, including existing passwords,288            passkeys, notes, and recovery access.289          </div>290          <div class="peach-adoption-sas" aria-label="Pairing confirmation code">291            <strong>{sourceSas()}</strong>292          </div>293          <div class="peach-pair-action">294            <button class="peach-btn peach-btn-ghost" onClick={() => answerSas(false)}>295              They don't match296            </button>297            <button class="peach-btn peach-btn-primary" onClick={() => answerSas(true)}>298              They match299            </button>300          </div>301        </div>302      </Show>303304      <div class="peach-sr-only" aria-live="polite">{liveText()}</div>305    </div>306  );307};