PairDevice.tsx

11.7 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// There is one path because there can only be one: a browser extension cannot8// listen and has no camera it can point at anything, so it is always the side9// that *displays* an invitation for another device to scan. The invitation can10// be shown as a QR or as its text, which are the same string — the text is the11// fallback when a camera won't cooperate, not a second pairing mode. LAN12// pairing codes are retired along with LAN sync13// (docs/sync-lan-retirement.md), so there is nothing to type in here.14//15// The pairing itself runs in the background (relay-adoption-source-bg.ts) and16// reaches this view via the ADOPTION_SOURCE_* messages.1718import {19  Component,20  Show,21  createSignal,22  onCleanup,23  onMount,24} from "solid-js";25import { sendMessage } from "../../shared/bridge";26import { DesktopBird, MobileBird } from "../components/PairingBirds";27import { PairingQrCode } from "../components/PairingQrCode";28import { SyncModeSelector } from "../components/SyncModeSelector";2930export interface PairDeviceViewProps {31  isPro: boolean;32  onBack(): void;33  onLearnPro(): void;34  /** Called after a peer's Pro license is activated, so the app re-reads Pro status. */35  onProChanged?: () => void;36}3738type SharePhase = "opening" | "invitation" | "sas" | "paired" | "error";3940export const PairDeviceView: Component<PairDeviceViewProps> = (props) => {41  const [sharePhase, setSharePhase] = createSignal<SharePhase>("opening");42  const [invitation, setInvitation] = createSignal("");43  const [sourceSas, setSourceSas] = createSignal("");44  const [receiverName, setReceiverName] = createSignal("New device");45  const [shareError, setShareError] = createSignal("");46  const [changingRelay, setChangingRelay] = createSignal(false);47  const [showTextCode, setShowTextCode] = createSignal(false);48  const [copied, setCopied] = createSignal(false);4950  async function startShareFlow(): Promise<void> {51    setSharePhase("opening");52    setShareError("");53    setInvitation("");54    setSourceSas("");55    setCopied(false);56    try {57      const response = (await sendMessage({58        type: "START_ADOPTION_SOURCE",59      })) as {60        ok: boolean;61        data?: { invitation?: string };62        error?: string;63      };64      if (!response.ok || typeof response.data?.invitation !== "string") {65        throw new Error(response.error ?? "adoption_source_failed");66      }67      setInvitation(response.data.invitation);68      setSharePhase("invitation");69    } catch (error) {70      setShareError(error instanceof Error ? error.message : "adoption_source_failed");71      setSharePhase("error");72    }73  }7475  async function stopShareFlow(): Promise<void> {76    await sendMessage({ type: "CANCEL_ADOPTION_SOURCE" }).catch(() => undefined);77  }7879  /** The invitation embeds the relay, so a relay change invalidates it. */80  async function restartShareFlow(): Promise<void> {81    await stopShareFlow();82    await startShareFlow();83  }8485  onMount(() => {86    void startShareFlow();8788    // Pairing pushes from the background (bg → popup). The whole NK session89    // runs in the service worker; the SAS to confirm and the terminal result90    // arrive here as runtime messages.91    if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {92      const handler = (msg: { type?: string; payload?: unknown }) => {93        if (msg?.type === "ADOPTION_SOURCE_SAS") {94          const p = msg.payload as {95            sas?: string;96            receiverDeviceName?: string;97          } | undefined;98          if (typeof p?.sas === "string") {99            setSourceSas(p.sas);100            setReceiverName(101              typeof p.receiverDeviceName === "string" && p.receiverDeviceName102                ? p.receiverDeviceName103                : "New device",104            );105            setSharePhase("sas");106          }107        } else if (msg?.type === "ADOPTION_SOURCE_RESULT") {108          const p = msg.payload as {109            status?: "complete" | "error";110            error?: string;111          } | undefined;112          if (p?.status === "complete") setSharePhase("paired");113          else if (p?.status === "error") {114            setShareError(p.error ?? "adoption_failed");115            setSharePhase("error");116          }117        }118      };119      chrome.runtime.onMessage.addListener(handler);120      onCleanup(() => chrome.runtime.onMessage.removeListener(handler));121    }122123    // Closing the popup mid-session must release the relay room, or the next124    // attempt is refused with adoption_source_in_progress.125    onCleanup(() => {126      if (sharePhase() !== "paired") {127        void sendMessage({ type: "CANCEL_ADOPTION_SOURCE" });128      }129    });130  });131132  const backArrow = (133    <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">134      <path d="M10 3 L5 8 L10 13" />135    </svg>136  );137138  // The bird stage — always animating — shared by every pairing screen.139  const birdStage = (phase: SharePhase) => (140    <div class={`peach-pair-stage ${phase === "sas" || phase === "paired" ? "act-handshake" : "act-listening"}`} aria-hidden="true">141      <div class="peach-pair-bird-slot peach-pair-bird-slot-desktop">142        <DesktopBird pose={phase === "paired" ? "happy" : "chirping"} />143      </div>144      <div class="peach-pair-bird-slot peach-pair-bird-slot-mobile">145        <MobileBird pose={phase === "paired" ? "happy" : "attentive"} />146      </div>147    </div>148  );149150  function errorCopy(): string {151    switch (shareError()) {152      case "vault_locked":153        return "Unlock Peach first, then try again.";154      case "sync_disabled":155        return "Sync is turned off. Choose a relay in Settings → Sync, then try again.";156      case "private_relay_unset":157        return "Private relay is selected but has no URL yet. Enter it in Settings → Sync, then try again.";158      default:159        return "The secure pairing session ended. Start again with a fresh code.";160    }161  }162163  function liveText(): string {164    switch (sharePhase()) {165      case "opening": return "Opening a secure pairing room";166      case "invitation": return "Scan this code on your other device";167      case "sas": return "Confirm the code matches";168      case "paired": return "Device added";169      case "error": return "Could not add the device";170    }171  }172173  return (174    <div175      class="peach-pair-pair peach-desktop-pair peach-adoption-source"176      data-phase={sharePhase()}177      role="dialog"178      aria-label="Add a device to this vault"179    >180      <header class="peach-pair-head">181        <button182          class="peach-pair-back"183          aria-label="Back"184          onClick={() => { void stopShareFlow(); props.onBack(); }}185        >186          {backArrow}187        </button>188        <div class="peach-pair-title">Add a new device</div>189        <div class="peach-pair-head-spacer" />190      </header>191192      {birdStage(sharePhase())}193194      <Show when={sharePhase() === "opening"}>195        <div class="peach-desktop-searching">196          <div class="peach-pair-headline">Opening a secure pairing room…</div>197          <div class="peach-pair-sub">The code appears once this browser is waiting in the relay.</div>198        </div>199      </Show>200201      <Show when={sharePhase() === "invitation"}>202        <div class="peach-adoption-source-invitation">203          <Show when={!changingRelay()}>204            <div class="peach-pair-headline">205              {showTextCode() ? "Enter this in Peach" : "Scan this in Peach"}206            </div>207            {/* The QR screen is the tallest in the flow and the popup does not208                scroll, so what the code contains is said on the text view —209                where the user is looking straight at the string — rather than210                pushing the QR below the fold to say it twice. */}211            <Show212              when={showTextCode()}213              fallback={214                <>215                  <div class="peach-pair-sub">216                    Open Peach on your phone, choose “Scan a code”, and point it here.217                  </div>218                  <PairingQrCode value={invitation()} size={176} />219                </>220              }221            >222              {/* Fallback for a camera that won't focus, a phone with no223                  camera permission, or pairing two windows on one machine. */}224              <div class="peach-pair-sub">225                Type or paste this into Peach on your other device. The whole226                code is a relay address and a single-use token.227              </div>228              <textarea229                id="pairing-invitation-manual"230                class="peach-pair-invitation-text"231                readonly232                rows={4}233                aria-label="Pairing invitation"234                onFocus={(event) => (event.target as HTMLTextAreaElement).select()}235              >{invitation()}</textarea>236            </Show>237            <button238              class="peach-btn peach-btn--ghost peach-btn--sm"239              onClick={() => {240                setCopied(false);241                setShowTextCode(!showTextCode());242              }}243            >244              {showTextCode() ? "Show the QR instead" : "Show a code instead"}245            </button>246            <Show when={showTextCode()}>247              <button248                class="peach-btn peach-btn--subtle peach-btn--sm"249                onClick={() => {250                  void navigator.clipboard?.writeText(invitation()).catch(() => undefined);251                  setCopied(true);252                }}253              >{copied() ? "copied" : "copy"}</button>254            </Show>255          </Show>256          {/* Moment 1 (docs/sync-relay-surfacing.md): the relay choice belongs257              here because a relay is about to be used, but it is not a step —258              pairing already works on the default. Changing it rebuilds the259              invitation, which carries the relay address. */}260          <SyncModeSelector261            compact262            onExpand={setChangingRelay}263            onChange={() => void restartShareFlow()}264          />265        </div>266      </Show>267268      <Show when={sharePhase() === "paired"}>269        <div class="peach-desktop-paired">270          <div class="peach-pair-headline">Device added</div>271          <div class="peach-pair-sub">The new device can now sync this vault through the relay.</div>272          <div class="peach-pair-action">273            <button class="peach-btn peach-btn-primary" onClick={props.onBack}>Done</button>274          </div>275        </div>276      </Show>277278      <Show when={sharePhase() === "error"}>279        <div class="peach-desktop-error">280          <div class="peach-pair-headline">Could not add the device</div>281          <div class="peach-pair-sub">{errorCopy()}</div>282          <div class="peach-pair-action">283            <button class="peach-btn peach-btn-ghost" onClick={props.onBack}>Cancel</button>284            <button class="peach-btn peach-btn-primary" onClick={() => void startShareFlow()}>Try again</button>285          </div>286        </div>287      </Show>288289      <Show when={sharePhase() === "sas"}>290        <div class="peach-desktop-searching">291          <div class="peach-pair-headline">Confirm this code on your phone</div>292          <div class="peach-pair-sub">293            If {receiverName()} shows the same code, tap “They match” there. Peach294            will finish only after both devices save the pairing.295          </div>296          <div class="peach-adoption-sas" aria-label="Pairing confirmation code">297            <strong>{sourceSas()}</strong>298          </div>299        </div>300      </Show>301302      <div class="peach-sr-only" aria-live="polite">{liveText()}</div>303    </div>304  );305};