PairDevice.tsx

8.9 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 { PairingQrCode } from "../components/PairingQrCode";25import { SyncModeSelector } from "../components/SyncModeSelector";2627export interface PairDeviceViewProps {28  isPro: boolean;29  onBack(): void;30  onLearnPro(): void;31  /** Called after a peer's Pro license is activated, so the app re-reads Pro status. */32  onProChanged?: () => void;33}3435type SharePhase = "opening" | "invitation" | "sas" | "paired" | "error";3637export const PairDeviceView: Component<PairDeviceViewProps> = (props) => {38  const [sharePhase, setSharePhase] = createSignal<SharePhase>("opening");39  const [invitation, setInvitation] = createSignal("");40  const [sourceSas, setSourceSas] = createSignal("");41  const [receiverName, setReceiverName] = createSignal("New device");42  const [shareError, setShareError] = createSignal("");43  const [changingRelay, setChangingRelay] = createSignal(false);4445  async function startShareFlow(): Promise<void> {46    setSharePhase("opening");47    setShareError("");48    setInvitation("");49    setSourceSas("");50    try {51      const response = (await sendMessage({52        type: "START_ADOPTION_SOURCE",53      })) as {54        ok: boolean;55        data?: { invitation?: string };56        error?: string;57      };58      if (!response.ok || typeof response.data?.invitation !== "string") {59        throw new Error(response.error ?? "adoption_source_failed");60      }61      setInvitation(response.data.invitation);62      setSharePhase("invitation");63    } catch (error) {64      setShareError(error instanceof Error ? error.message : "adoption_source_failed");65      setSharePhase("error");66    }67  }6869  async function stopShareFlow(): Promise<void> {70    await sendMessage({ type: "CANCEL_ADOPTION_SOURCE" }).catch(() => undefined);71  }7273  /** The invitation embeds the relay, so a relay change invalidates it. */74  async function restartShareFlow(): Promise<void> {75    await stopShareFlow();76    await startShareFlow();77  }7879  onMount(() => {80    void startShareFlow();8182    // Pairing pushes from the background (bg → popup). The whole NK session83    // runs in the service worker; the SAS to confirm and the terminal result84    // arrive here as runtime messages.85    if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {86      const handler = (msg: { type?: string; payload?: unknown }) => {87        if (msg?.type === "ADOPTION_SOURCE_SAS") {88          const p = msg.payload as {89            sas?: string;90            receiverDeviceName?: string;91          } | undefined;92          if (typeof p?.sas === "string") {93            setSourceSas(p.sas);94            setReceiverName(95              typeof p.receiverDeviceName === "string" && p.receiverDeviceName96                ? p.receiverDeviceName97                : "New device",98            );99            setSharePhase("sas");100          }101        } else if (msg?.type === "ADOPTION_SOURCE_RESULT") {102          const p = msg.payload as {103            status?: "complete" | "error";104            error?: string;105          } | undefined;106          if (p?.status === "complete") setSharePhase("paired");107          else if (p?.status === "error") {108            setShareError(p.error ?? "adoption_failed");109            setSharePhase("error");110          }111        }112      };113      chrome.runtime.onMessage.addListener(handler);114      onCleanup(() => chrome.runtime.onMessage.removeListener(handler));115    }116117    // Closing the popup mid-session must release the relay room, or the next118    // attempt is refused with adoption_source_in_progress.119    onCleanup(() => {120      if (sharePhase() !== "paired") {121        void sendMessage({ type: "CANCEL_ADOPTION_SOURCE" });122      }123    });124  });125126  const backArrow = (127    <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">128      <path d="M10 3 L5 8 L10 13" />129    </svg>130  );131132  function errorCopy(): string {133    switch (shareError()) {134      case "vault_locked":135        return "Unlock Peach first, then try again.";136      case "sync_disabled":137        return "Sync is turned off. Choose a relay in Settings → Sync, then try again.";138      case "private_relay_unset":139        return "Private relay is selected but has no URL yet. Enter it in Settings → Sync, then try again.";140      default:141        return "The secure pairing session ended. Start again with a fresh code.";142    }143  }144145  function liveText(): string {146    switch (sharePhase()) {147      case "opening": return "Opening a secure pairing room";148      case "invitation": return "Scan this QR on the empty device";149      case "sas": return "Confirm the code matches";150      case "paired": return "Device added";151      case "error": return "Could not add the device";152    }153  }154155  return (156    <div157      class="peach-pair-pair peach-desktop-pair peach-adoption-source"158      data-phase={sharePhase()}159      role="dialog"160      aria-label="Add a device to this vault"161    >162      <header class="peach-pair-head">163        <button164          class="peach-pair-back"165          aria-label="Back"166          onClick={() => { void stopShareFlow(); props.onBack(); }}167        >168          {backArrow}169        </button>170        <div class="peach-pair-title">Add a new device</div>171        <div class="peach-pair-head-spacer" />172      </header>173174      <Show when={sharePhase() === "opening"}>175        <div class="peach-desktop-searching">176          <div class="peach-pair-headline">Opening a secure pairing room…</div>177          <div class="peach-pair-sub">The code appears once this browser is waiting in the relay.</div>178        </div>179      </Show>180181      <Show when={sharePhase() === "invitation"}>182        <div class="peach-adoption-source-invitation">183          <Show when={!changingRelay()}>184            <div class="peach-pair-headline">Pair a device</div>185            <div class="peach-pair-sub">186              This extension has the vault. Scan this QR on the empty device,187              then compare the 8-digit safety code.188            </div>189            <PairingQrCode value={invitation()} size={176} />190          </Show>191          {/* Moment 1 (docs/sync-relay-surfacing.md): the relay choice belongs192              here because a relay is about to be used, but it is not a step —193              pairing already works on the default. Changing it rebuilds the194              invitation, which carries the relay address. */}195          <SyncModeSelector196            compact197            onExpand={setChangingRelay}198            onChange={() => void restartShareFlow()}199          />200        </div>201      </Show>202203      <Show when={sharePhase() === "paired"}>204        <div class="peach-desktop-paired">205          <div class="peach-pair-headline">Device added</div>206          <div class="peach-pair-sub">The new device can now sync this vault through the relay.</div>207          <div class="peach-pair-action">208            <button class="peach-btn peach-btn-primary" onClick={props.onBack}>Done</button>209          </div>210        </div>211      </Show>212213      <Show when={sharePhase() === "error"}>214        <div class="peach-desktop-error">215          <div class="peach-pair-headline">Could not add the device</div>216          <div class="peach-pair-sub">{errorCopy()}</div>217          <div class="peach-pair-action">218            <button class="peach-btn peach-btn-ghost" onClick={props.onBack}>Cancel</button>219            <button class="peach-btn peach-btn-primary" onClick={() => void startShareFlow()}>Try again</button>220          </div>221        </div>222      </Show>223224      <Show when={sharePhase() === "sas"}>225        <div class="peach-desktop-searching">226          <div class="peach-pair-headline">Compare the 8-digit safety code</div>227          <div class="peach-pair-sub">228            If {receiverName()} shows the same code, tap “They match” there. Peach229            will finish only after both devices save the pairing.230          </div>231          <div class="peach-adoption-sas" aria-label="Pairing confirmation code">232            <strong>{sourceSas()}</strong>233          </div>234        </div>235      </Show>236237      <div class="peach-sr-only" aria-live="polite">{liveText()}</div>238    </div>239  );240};