PairDevice.tsx

9.5 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-source-bg.ts) and14// reaches this view via the ADOPTION_SOURCE_* messages.1516import {17  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 { 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 [receiverName, setReceiverName] = createSignal("New device");43  const [shareError, setShareError] = createSignal("");44  const [changingRelay, setChangingRelay] = createSignal(false);4546  async function startShareFlow(): Promise<void> {47    setSharePhase("opening");48    setShareError("");49    setInvitation("");50    setSourceSas("");51    try {52      const response = (await sendMessage({53        type: "START_ADOPTION_SOURCE",54      })) as {55        ok: boolean;56        data?: { invitation?: string };57        error?: string;58      };59      if (!response.ok || typeof response.data?.invitation !== "string") {60        throw new Error(response.error ?? "adoption_source_failed");61      }62      setInvitation(response.data.invitation);63      setSharePhase("invitation");64    } catch (error) {65      setShareError(error instanceof Error ? error.message : "adoption_source_failed");66      setSharePhase("error");67    }68  }6970  async function stopShareFlow(): Promise<void> {71    await sendMessage({ type: "CANCEL_ADOPTION_SOURCE" }).catch(() => undefined);72  }7374  /** The invitation embeds the relay, so a relay change invalidates it. */75  async function restartShareFlow(): Promise<void> {76    await stopShareFlow();77    await startShareFlow();78  }7980  onMount(() => {81    void startShareFlow();8283    // Pairing pushes from the background (bg → popup). The whole NK session84    // runs in the service worker; the SAS to confirm and the terminal result85    // arrive here as runtime messages.86    if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {87      const handler = (msg: { type?: string; payload?: unknown }) => {88        if (msg?.type === "ADOPTION_SOURCE_SAS") {89          const p = msg.payload as {90            sas?: string;91            receiverDeviceName?: string;92          } | undefined;93          if (typeof p?.sas === "string") {94            setSourceSas(p.sas);95            setReceiverName(96              typeof p.receiverDeviceName === "string" && p.receiverDeviceName97                ? p.receiverDeviceName98                : "New device",99            );100            setSharePhase("sas");101          }102        } else if (msg?.type === "ADOPTION_SOURCE_RESULT") {103          const p = msg.payload as {104            status?: "complete" | "error";105            error?: string;106          } | undefined;107          if (p?.status === "complete") setSharePhase("paired");108          else if (p?.status === "error") {109            setShareError(p.error ?? "adoption_failed");110            setSharePhase("error");111          }112        }113      };114      chrome.runtime.onMessage.addListener(handler);115      onCleanup(() => chrome.runtime.onMessage.removeListener(handler));116    }117118    // Closing the popup mid-session must release the relay room, or the next119    // attempt is refused with adoption_source_in_progress.120    onCleanup(() => {121      if (sharePhase() !== "paired") {122        void sendMessage({ type: "CANCEL_ADOPTION_SOURCE" });123      }124    });125  });126127  const backArrow = (128    <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">129      <path d="M10 3 L5 8 L10 13" />130    </svg>131  );132133  // The bird stage — always animating — shared by every pairing screen.134  const birdStage = (phase: SharePhase) => (135    <div class={`peach-pair-stage ${phase === "sas" || phase === "paired" ? "act-handshake" : "act-listening"}`} aria-hidden="true">136      <div class="peach-pair-bird-slot peach-pair-bird-slot-desktop">137        <DesktopBird pose={phase === "paired" ? "happy" : "chirping"} />138      </div>139      <div class="peach-pair-bird-slot peach-pair-bird-slot-mobile">140        <MobileBird pose={phase === "paired" ? "happy" : "attentive"} />141      </div>142    </div>143  );144145  function errorCopy(): string {146    switch (shareError()) {147      case "vault_locked":148        return "Unlock Peach first, then try again.";149      case "sync_disabled":150        return "Sync is turned off. Choose a relay in Settings → Sync, then try again.";151      case "private_relay_unset":152        return "Private relay is selected but has no URL yet. Enter it in Settings → Sync, then try again.";153      default:154        return "The secure pairing session ended. Start again with a fresh code.";155    }156  }157158  function liveText(): string {159    switch (sharePhase()) {160      case "opening": return "Opening a secure pairing room";161      case "invitation": return "Scan this QR on the empty device";162      case "sas": return "Confirm the code matches";163      case "paired": return "Device added";164      case "error": return "Could not add the device";165    }166  }167168  return (169    <div170      class="peach-pair-pair peach-desktop-pair peach-adoption-source"171      data-phase={sharePhase()}172      role="dialog"173      aria-label="Add a device to this vault"174    >175      <header class="peach-pair-head">176        <button177          class="peach-pair-back"178          aria-label="Back"179          onClick={() => { void stopShareFlow(); props.onBack(); }}180        >181          {backArrow}182        </button>183        <div class="peach-pair-title">Add a new device</div>184        <div class="peach-pair-head-spacer" />185      </header>186187      {birdStage(sharePhase())}188189      <Show when={sharePhase() === "opening"}>190        <div class="peach-desktop-searching">191          <div class="peach-pair-headline">Opening a secure pairing room…</div>192          <div class="peach-pair-sub">The code appears once this browser is waiting in the relay.</div>193        </div>194      </Show>195196      <Show when={sharePhase() === "invitation"}>197        <div class="peach-adoption-source-invitation">198          <Show when={!changingRelay()}>199            <div class="peach-pair-headline">Pair a device</div>200            <div class="peach-pair-sub">201              This extension has the vault. Scan this QR on the empty device,202              then compare the 8-digit safety code.203            </div>204            <PairingQrCode value={invitation()} size={176} />205          </Show>206          {/* Moment 1 (docs/sync-relay-surfacing.md): the relay choice belongs207              here because a relay is about to be used, but it is not a step —208              pairing already works on the default. Changing it rebuilds the209              invitation, which carries the relay address. */}210          <SyncModeSelector211            compact212            onExpand={setChangingRelay}213            onChange={() => void restartShareFlow()}214          />215        </div>216      </Show>217218      <Show when={sharePhase() === "paired"}>219        <div class="peach-desktop-paired">220          <div class="peach-pair-headline">Device added</div>221          <div class="peach-pair-sub">The new device can now sync this vault through the relay.</div>222          <div class="peach-pair-action">223            <button class="peach-btn peach-btn-primary" onClick={props.onBack}>Done</button>224          </div>225        </div>226      </Show>227228      <Show when={sharePhase() === "error"}>229        <div class="peach-desktop-error">230          <div class="peach-pair-headline">Could not add the device</div>231          <div class="peach-pair-sub">{errorCopy()}</div>232          <div class="peach-pair-action">233            <button class="peach-btn peach-btn-ghost" onClick={props.onBack}>Cancel</button>234            <button class="peach-btn peach-btn-primary" onClick={() => void startShareFlow()}>Try again</button>235          </div>236        </div>237      </Show>238239      <Show when={sharePhase() === "sas"}>240        <div class="peach-desktop-searching">241          <div class="peach-pair-headline">Compare the 8-digit safety code</div>242          <div class="peach-pair-sub">243            If {receiverName()} shows the same code, tap “They match” there. Peach244            will finish only after both devices save the pairing.245          </div>246          <div class="peach-adoption-sas" aria-label="Pairing confirmation code">247            <strong>{sourceSas()}</strong>248          </div>249        </div>250      </Show>251252      <div class="peach-sr-only" aria-live="polite">{liveText()}</div>253    </div>254  );255};