PairDevice.tsx

12.4 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">215              {showTextCode() ? "Enter this in Peach" : "Scan this in Peach"}216            </div>217            {/* The QR screen is the tallest in the flow and the popup does not218                scroll, so what the code contains is said on the text view —219                where the user is looking straight at the string — rather than220                pushing the QR below the fold to say it twice. */}221            <Show222              when={showTextCode()}223              fallback={224                <>225                  <div class="peach-pair-sub">226                    Open Peach on your phone, choose “Scan a code”, and point it here.227                  </div>228                  <PairingQrCode value={invitation()} size={176} />229                </>230              }231            >232              {/* Fallback for a camera that won't focus, a phone with no233                  camera permission, or pairing two windows on one machine. */}234              <div class="peach-pair-sub">235                Type or paste this into Peach on your other device. The whole236                code is a relay address and a single-use token.237              </div>238              <textarea239                id="pairing-invitation-manual"240                class="peach-pair-invitation-text"241                readonly242                rows={4}243                aria-label="Pairing invitation"244                onFocus={(event) => (event.target as HTMLTextAreaElement).select()}245              >{invitation()}</textarea>246            </Show>247            <button248              class="peach-btn peach-btn--ghost peach-btn--sm"249              onClick={() => {250                setCopied(false);251                setShowTextCode(!showTextCode());252              }}253            >254              {showTextCode() ? "Show the QR instead" : "Show a code instead"}255            </button>256            <Show when={showTextCode()}>257              <button258                class="peach-btn peach-btn--subtle peach-btn--sm"259                onClick={() => {260                  void navigator.clipboard?.writeText(invitation()).catch(() => undefined);261                  setCopied(true);262                }}263              >{copied() ? "copied" : "copy"}</button>264            </Show>265          </Show>266          {/* Moment 1 (docs/sync-relay-surfacing.md): the relay choice belongs267              here because a relay is about to be used, but it is not a step —268              pairing already works on the default. Changing it rebuilds the269              invitation, which carries the relay address. */}270          <SyncModeSelector271            compact272            onExpand={setChangingRelay}273            onChange={() => void restartShareFlow()}274          />275        </div>276      </Show>277278      <Show when={sharePhase() === "connecting"}>279        <div class="peach-desktop-searching">280          <div class="peach-pair-headline">Waiting for the new device…</div>281          <div class="peach-pair-sub">The vault key stays put until that device confirms the same code too.</div>282        </div>283      </Show>284285      <Show when={sharePhase() === "paired"}>286        <div class="peach-desktop-paired">287          <div class="peach-pair-headline">Device added</div>288          <div class="peach-pair-sub">The new device can now sync this vault through the relay.</div>289          <div class="peach-pair-action">290            <button class="peach-btn peach-btn-primary" onClick={props.onBack}>Done</button>291          </div>292        </div>293      </Show>294295      <Show when={sharePhase() === "error"}>296        <div class="peach-desktop-error">297          <div class="peach-pair-headline">Could not add the device</div>298          <div class="peach-pair-sub">{errorCopy()}</div>299          <div class="peach-pair-action">300            <button class="peach-btn peach-btn-ghost" onClick={props.onBack}>Cancel</button>301            <button class="peach-btn peach-btn-primary" onClick={() => void startShareFlow()}>Try again</button>302          </div>303        </div>304      </Show>305306      <Show when={sharePhase() === "sas"}>307        <AdoptionConsentModal308          receiverDeviceName={receiverName()}309          sas={sourceSas()}310          verifyMasterPassword={verifySourcePassword}311          onCancel={() => {312            void sendMessage({313              type: "ADOPTION_SOURCE_CONFIRM",314              payload: { cancel: true },315            });316            setShareError("adoption_rejected");317            setSharePhase("error");318          }}319          onApprove={async () => {320            setSharePhase("connecting");321          }}322        />323      </Show>324325      <div class="peach-sr-only" aria-live="polite">{liveText()}</div>326    </div>327  );328};