PairDevice.tsx

11.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// 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 { AdoptionConsentModal } from "../components/AdoptionConsentModal";28import { PairingQrCode } from "../components/PairingQrCode";29import { SyncModeSelector } from "../components/SyncModeSelector";3031export interface PairDeviceViewProps {32  isPro: boolean;33  onBack(): void;34  onLearnPro(): void;35  /** Called after a peer's Pro license is activated, so the app re-reads Pro status. */36  onProChanged?: () => void;37}3839type SharePhase = "opening" | "invitation" | "sas" | "connecting" | "paired" | "error";4041export const PairDeviceView: Component<PairDeviceViewProps> = (props) => {42  const [sharePhase, setSharePhase] = createSignal<SharePhase>("opening");43  const [invitation, setInvitation] = createSignal("");44  const [sourceSas, setSourceSas] = createSignal("");45  const [receiverName, setReceiverName] = createSignal("New device");46  const [shareError, setShareError] = createSignal("");47  const [changingRelay, setChangingRelay] = createSignal(false);48  const [showTextCode, setShowTextCode] = createSignal(false);49  const [copied, setCopied] = createSignal(false);5051  async function startShareFlow(): Promise<void> {52    setSharePhase("opening");53    setShareError("");54    setInvitation("");55    setSourceSas("");56    setCopied(false);57    try {58      const response = (await sendMessage({59        type: "START_ADOPTION_SOURCE",60      })) as {61        ok: boolean;62        data?: { invitation?: string };63        error?: string;64      };65      if (!response.ok || typeof response.data?.invitation !== "string") {66        throw new Error(response.error ?? "adoption_source_failed");67      }68      setInvitation(response.data.invitation);69      setSharePhase("invitation");70    } catch (error) {71      setShareError(error instanceof Error ? error.message : "adoption_source_failed");72      setSharePhase("error");73    }74  }7576  async function stopShareFlow(): Promise<void> {77    await sendMessage({ type: "CANCEL_ADOPTION_SOURCE" }).catch(() => undefined);78  }7980  /** The invitation embeds the relay, so a relay change invalidates it. */81  async function restartShareFlow(): Promise<void> {82    await stopShareFlow();83    await startShareFlow();84  }8586  async function verifySourcePassword(password: string): Promise<boolean> {87    const response = (await sendMessage({88      type: "ADOPTION_SOURCE_CONFIRM",89      payload: { password },90    })) as { ok: boolean; data?: { valid?: boolean } };91    return response.ok && response.data?.valid === true;92  }9394  onMount(() => {95    void startShareFlow();9697    // Pairing pushes from the background (bg → popup). The whole NK session98    // runs in the service worker; the SAS to confirm and the terminal result99    // arrive here as runtime messages.100    if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {101      const handler = (msg: { type?: string; payload?: unknown }) => {102        if (msg?.type === "ADOPTION_SOURCE_SAS") {103          const p = msg.payload as {104            sas?: string;105            receiverDeviceName?: string;106          } | undefined;107          if (typeof p?.sas === "string") {108            setSourceSas(p.sas);109            setReceiverName(110              typeof p.receiverDeviceName === "string" && p.receiverDeviceName111                ? p.receiverDeviceName112                : "New device",113            );114            setSharePhase("sas");115          }116        } else if (msg?.type === "ADOPTION_SOURCE_RESULT") {117          const p = msg.payload as {118            status?: "complete" | "error";119            error?: string;120          } | undefined;121          if (p?.status === "complete") setSharePhase("paired");122          else if (p?.status === "error") {123            setShareError(p.error ?? "adoption_failed");124            setSharePhase("error");125          }126        }127      };128      chrome.runtime.onMessage.addListener(handler);129      onCleanup(() => chrome.runtime.onMessage.removeListener(handler));130    }131132    // Closing the popup mid-session must release the relay room, or the next133    // attempt is refused with adoption_source_in_progress.134    onCleanup(() => {135      if (sharePhase() !== "paired") {136        void sendMessage({ type: "CANCEL_ADOPTION_SOURCE" });137      }138    });139  });140141  const backArrow = (142    <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">143      <path d="M10 3 L5 8 L10 13" />144    </svg>145  );146147  // The bird stage — always animating — shared by every pairing screen.148  const birdStage = (active: boolean) => (149    <div class={`peach-pair-stage ${active ? "act-handshake" : "act-listening"}`} aria-hidden="true">150      <div class="peach-pair-bird-slot peach-pair-bird-slot-desktop">151        <DesktopBird pose="chirping" />152      </div>153      <div class="peach-pair-bird-slot peach-pair-bird-slot-mobile">154        <MobileBird pose="attentive" />155      </div>156    </div>157  );158159  function errorCopy(): string {160    switch (shareError()) {161      case "vault_locked":162        return "Unlock Peach first, then try again.";163      case "sync_disabled":164        return "Sync is turned off. Choose a relay in Settings → Sync, then try again.";165      case "private_relay_unset":166        return "Private relay is selected but has no URL yet. Enter it in Settings → Sync, then try again.";167      default:168        return "The secure pairing session ended. Start again with a fresh code.";169    }170  }171172  function liveText(): string {173    switch (sharePhase()) {174      case "opening": return "Opening a secure pairing room";175      case "invitation": return "Scan this code on your other device";176      case "sas": return "Confirm the code matches";177      case "connecting": return "Waiting for the new device";178      case "paired": return "Device added";179      case "error": return "Could not add the device";180    }181  }182183  return (184    <div185      class="peach-pair-pair peach-desktop-pair peach-adoption-source"186      data-phase={sharePhase()}187      role="dialog"188      aria-label="Add a device to this vault"189    >190      <header class="peach-pair-head">191        <button192          class="peach-pair-back"193          aria-label="Back"194          onClick={() => { void stopShareFlow(); props.onBack(); }}195        >196          {backArrow}197        </button>198        <div class="peach-pair-title">Add a new device</div>199        <div class="peach-pair-head-spacer" />200      </header>201202      {birdStage(sharePhase() === "sas" || sharePhase() === "connecting")}203204      <Show when={sharePhase() === "opening"}>205        <div class="peach-desktop-searching">206          <div class="peach-pair-headline">Opening a secure pairing room…</div>207          <div class="peach-pair-sub">The code appears once this browser is waiting in the relay.</div>208        </div>209      </Show>210211      <Show when={sharePhase() === "invitation"}>212        <div class="peach-adoption-source-invitation">213          <Show when={!changingRelay()}>214            <div class="peach-pair-headline">Scan this in Peach</div>215            <div class="peach-pair-sub">216              Open Peach on your phone, choose “Scan a code”, and point it here.217              The code carries only the relay address and a single-use token.218            </div>219            <Show220              when={showTextCode()}221              fallback={<PairingQrCode value={invitation()} size={188} />}222            >223              {/* Fallback for a camera that won't focus, a phone with no224                  camera permission, or pairing two windows on one machine. */}225              <textarea226                id="pairing-invitation-manual"227                class="peach-pair-invitation-text"228                readonly229                rows={4}230                aria-label="Pairing invitation"231                onFocus={(event) => (event.target as HTMLTextAreaElement).select()}232              >{invitation()}</textarea>233            </Show>234            <button235              class="peach-btn peach-btn--ghost peach-btn--sm"236              onClick={() => {237                setCopied(false);238                setShowTextCode(!showTextCode());239              }}240            >241              {showTextCode() ? "Show the QR instead" : "Show a code instead"}242            </button>243            <Show when={showTextCode()}>244              <button245                class="peach-btn peach-btn--subtle peach-btn--sm"246                onClick={() => {247                  void navigator.clipboard?.writeText(invitation()).catch(() => undefined);248                  setCopied(true);249                }}250              >{copied() ? "copied" : "copy"}</button>251            </Show>252          </Show>253          {/* Moment 1 (docs/sync-relay-surfacing.md): the relay choice belongs254              here because a relay is about to be used, but it is not a step —255              pairing already works on the default. Changing it rebuilds the256              invitation, which carries the relay address. */}257          <SyncModeSelector258            compact259            onExpand={setChangingRelay}260            onChange={() => void restartShareFlow()}261          />262        </div>263      </Show>264265      <Show when={sharePhase() === "connecting"}>266        <div class="peach-desktop-searching">267          <div class="peach-pair-headline">Waiting for the new device…</div>268          <div class="peach-pair-sub">The vault key stays put until that device confirms the same code too.</div>269        </div>270      </Show>271272      <Show when={sharePhase() === "paired"}>273        <div class="peach-desktop-paired">274          <div class="peach-pair-headline">Device added</div>275          <div class="peach-pair-sub">The new device can now sync this vault through the relay.</div>276          <div class="peach-pair-action">277            <button class="peach-btn peach-btn-primary" onClick={props.onBack}>Done</button>278          </div>279        </div>280      </Show>281282      <Show when={sharePhase() === "error"}>283        <div class="peach-desktop-error">284          <div class="peach-pair-headline">Could not add the device</div>285          <div class="peach-pair-sub">{errorCopy()}</div>286          <div class="peach-pair-action">287            <button class="peach-btn peach-btn-ghost" onClick={props.onBack}>Cancel</button>288            <button class="peach-btn peach-btn-primary" onClick={() => void startShareFlow()}>Try again</button>289          </div>290        </div>291      </Show>292293      <Show when={sharePhase() === "sas"}>294        <AdoptionConsentModal295          receiverDeviceName={receiverName()}296          sas={sourceSas()}297          verifyMasterPassword={verifySourcePassword}298          onCancel={() => {299            void sendMessage({300              type: "ADOPTION_SOURCE_CONFIRM",301              payload: { cancel: true },302            });303            setShareError("adoption_rejected");304            setSharePhase("error");305          }}306          onApprove={async () => {307            setSharePhase("connecting");308          }}309        />310      </Show>311312      <div class="peach-sr-only" aria-live="polite">{liveText()}</div>313    </div>314  );315};