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, where camera/manual invitation fallback belongs. Keeping10// the raw invitation off this source screen prevents it from being mistaken for11// the later 8-digit safety code.12//13// The pairing itself runs in the background (relay-adoption-bg.ts) and14// reaches this view via the ADOPTION_SOURCE_* messages.1516import {17  type Component,18  Show,19  createSignal,20  onCleanup,21  onMount,22} from "solid-js";23import { sendMessage } from "../../shared/bridge";24import { DesktopBird, MobileBird } from "../components/PairingBirds";25import { PairingQrCode } from "../components/PairingQrCode";26import { PairingProRequired } from "../components/PairingProRequired";27import { SyncModeSelector } from "../components/SyncModeSelector";2829export interface PairDeviceViewProps {30  isPro: boolean;31  onBack(): void;32  onLearnPro(): void;33  /** Called after a peer's Pro license is activated, so the app re-reads Pro status. */34  onProChanged?: () => void;35}3637type SharePhase = "opening" | "invitation" | "sas" | "paired" | "error";3839export const PairDeviceView: Component<PairDeviceViewProps> = (props) => {40  const [sharePhase, setSharePhase] = createSignal<SharePhase>("opening");41  const [invitation, setInvitation] = createSignal("");42  const [sourceSas, setSourceSas] = createSignal("");43  const [pendingSasId, setPendingSasId] = createSignal("");44  const [receiverName, setReceiverName] = createSignal("New device");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          } | undefined;110          if (typeof p?.sas === "string") {111            setSourceSas(p.sas);112            setPendingSasId(typeof p.id === "string" ? p.id : "");113            setReceiverName(114              typeof p.receiverDeviceName === "string" && p.receiverDeviceName115                ? p.receiverDeviceName116                : "New device",117            );118            setSharePhase("sas");119          }120        } else if (msg?.type === "ADOPTION_SOURCE_RESULT") {121          const p = msg.payload as {122            status?: "complete" | "error";123            error?: string;124          } | undefined;125          if (p?.status === "complete") {126            setPendingSasId("");127            setSharePhase("paired");128          } else if (p?.status === "error") {129            setPendingSasId("");130            setShareError(p.error ?? "adoption_failed");131            setSharePhase("error");132          }133        }134      };135      chrome.runtime.onMessage.addListener(handler);136      onCleanup(() => chrome.runtime.onMessage.removeListener(handler));137    }138139    // Closing the popup mid-session must release the relay room, or the next140    // attempt is refused with adoption_source_in_progress.141    onCleanup(() => {142      const id = pendingSasId();143      if (id) {144        void sendMessage({145          type: "SOURCE_SAS_DECISION",146          payload: { id, ok: false },147        }).catch(() => undefined);148      }149      if (sharePhase() !== "paired") {150        void sendMessage({ type: "CANCEL_ADOPTION_SOURCE" });151      }152    });153  });154155  const backArrow = (156    <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">157      <path d="M10 3 L5 8 L10 13" />158    </svg>159  );160161  // The bird stage — always animating — shared by every pairing screen.162  const birdStage = (phase: SharePhase) => (163    <div class={`peach-pair-stage ${phase === "sas" || phase === "paired" ? "act-handshake" : "act-listening"}`} aria-hidden="true">164      <div class="peach-pair-bird-slot peach-pair-bird-slot-desktop">165        <DesktopBird pose={phase === "paired" ? "happy" : "chirping"} />166      </div>167      <div class="peach-pair-bird-slot peach-pair-bird-slot-mobile">168        <MobileBird pose={phase === "paired" ? "happy" : "attentive"} />169      </div>170    </div>171  );172173  function errorCopy(): string {174    switch (shareError()) {175      case "vault_locked":176        return "Unlock Peach first, then try again.";177      case "sync_disabled":178        return "Sync is turned off. Choose a relay in Settings → Sync, then try again.";179      case "private_relay_unset":180        return "Private relay is selected but has no URL yet. Enter it in Settings → Sync, then try again.";181      default:182        return "The secure pairing session ended. Start again with a fresh code.";183    }184  }185186  function liveText(): string {187    switch (sharePhase()) {188      case "opening": return "Opening a secure pairing room";189      case "invitation": return "Scan this QR on the empty device";190      case "sas": return "Confirm the code matches";191      case "paired": return "Device added";192      case "error": return "Could not add the device";193    }194  }195196  return (197    <div198      class="peach-pair-pair peach-desktop-pair peach-adoption-source"199      data-phase={sharePhase()}200      role="dialog"201      aria-label="Add a device to this vault"202    >203      <header class="peach-pair-head">204        <button205          class="peach-pair-back"206          aria-label="Back"207          onClick={() => { void stopShareFlow(); props.onBack(); }}208        >209          {backArrow}210        </button>211        <div class="peach-pair-title">Add a new device</div>212        <div class="peach-pair-head-spacer" />213      </header>214215      {birdStage(sharePhase())}216217      <Show when={sharePhase() === "opening"}>218        <div class="peach-desktop-searching">219          <div class="peach-pair-headline">Opening a secure pairing room…</div>220          <div class="peach-pair-sub">The code appears once this browser is waiting in the relay.</div>221        </div>222      </Show>223224      <Show when={sharePhase() === "invitation"}>225        <div class="peach-adoption-source-invitation">226          <Show when={!changingRelay()}>227            <div class="peach-pair-headline">Pair a device</div>228            <div class="peach-pair-sub">229              This extension has the vault. Scan this QR on the empty device,230              then compare the 8-digit safety code.231            </div>232            <PairingQrCode value={invitation()} size={176} />233          </Show>234          {/* Moment 1 (docs/sync-relay-surfacing.md): the relay choice belongs235              here because a relay is about to be used, but it is not a step —236              pairing already works on the default. Changing it rebuilds the237              invitation, which carries the relay address. */}238          <SyncModeSelector239            compact240            onExpand={setChangingRelay}241            onChange={() => void restartShareFlow()}242          />243        </div>244      </Show>245246      <Show when={sharePhase() === "paired"}>247        <div class="peach-desktop-paired">248          <div class="peach-pair-headline">Device added</div>249          <div class="peach-pair-sub">The new device can now sync this vault through the relay.</div>250          <div class="peach-pair-action">251            <button class="peach-btn peach-btn-primary" onClick={props.onBack}>Done</button>252          </div>253        </div>254      </Show>255256      <Show when={sharePhase() === "error"}>257        <Show258          when={shareError() === "ADOPT_PRO_REQUIRED"}259          fallback={260            <div class="peach-desktop-error">261              <div class="peach-pair-headline">Could not add the device</div>262              <div class="peach-pair-sub">{errorCopy()}</div>263              <div class="peach-pair-action">264                <button class="peach-btn peach-btn-ghost" onClick={props.onBack}>Cancel</button>265                <button class="peach-btn peach-btn-primary" onClick={() => void startShareFlow()}>Try again</button>266              </div>267            </div>268          }269        >270          <div class="peach-desktop-error peach-desktop-error--pro">271            <PairingProRequired272              onRetry={() => void startShareFlow()}273              onOpenPro={props.onLearnPro}274            />275          </div>276        </Show>277      </Show>278279      <Show when={sharePhase() === "sas"}>280        <div class="peach-desktop-searching">281          <div class="peach-pair-headline">Compare the 8-digit safety code</div>282          <div class="peach-pair-sub">283            If {receiverName()} shows the same code, tap “They match” on that284            device too. Peach will only finish after both devices confirm.285          </div>286          <div class="peach-adoption-sas" aria-label="Pairing confirmation code">287            <strong>{sourceSas()}</strong>288          </div>289          <div class="peach-pair-action">290            <button class="peach-btn peach-btn-ghost" onClick={() => answerSas(false)}>291              They don't match292            </button>293            <button class="peach-btn peach-btn-primary" onClick={() => answerSas(true)}>294              They match295            </button>296          </div>297        </div>298      </Show>299300      <div class="peach-sr-only" aria-live="polite">{liveText()}</div>301    </div>302  );303};