PairDevice.tsx

10.3 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. LAN pairing codes10// are retired along with LAN sync (docs/sync-lan-retirement.md), so there is no11// code to type here either.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 { AdoptionConsentModal } from "../components/AdoptionConsentModal";26import { PairingQrCode } from "../components/PairingQrCode";27import { SyncModeSelector } from "../components/SyncModeSelector";2829export interface PairDeviceViewProps {30  isPro: boolean;31  onBack(): void;32  onLearnPro(): void;33  /** Called after a peer's Pro license is activated, so the app re-reads Pro status. */34  onProChanged?: () => void;35}3637type SharePhase = "opening" | "invitation" | "sas" | "connecting" | "paired" | "error";3839export const PairDeviceView: Component<PairDeviceViewProps> = (props) => {40  const [sharePhase, setSharePhase] = createSignal<SharePhase>("opening");41  const [invitation, setInvitation] = createSignal("");42  const [sourceSas, setSourceSas] = createSignal("");43  const [receiverName, setReceiverName] = createSignal("New device");44  const [shareError, setShareError] = createSignal("");45  const [changingRelay, setChangingRelay] = createSignal(false);4647  async function startShareFlow(): Promise<void> {48    setSharePhase("opening");49    setShareError("");50    setInvitation("");51    setSourceSas("");52    try {53      const response = (await sendMessage({54        type: "START_ADOPTION_SOURCE",55      })) as {56        ok: boolean;57        data?: { invitation?: string };58        error?: string;59      };60      if (!response.ok || typeof response.data?.invitation !== "string") {61        throw new Error(response.error ?? "adoption_source_failed");62      }63      setInvitation(response.data.invitation);64      setSharePhase("invitation");65    } catch (error) {66      setShareError(error instanceof Error ? error.message : "adoption_source_failed");67      setSharePhase("error");68    }69  }7071  async function stopShareFlow(): Promise<void> {72    await sendMessage({ type: "CANCEL_ADOPTION_SOURCE" }).catch(() => undefined);73  }7475  /** The invitation embeds the relay, so a relay change invalidates it. */76  async function restartShareFlow(): Promise<void> {77    await stopShareFlow();78    await startShareFlow();79  }8081  async function verifySourcePassword(password: string): Promise<boolean> {82    const response = (await sendMessage({83      type: "ADOPTION_SOURCE_CONFIRM",84      payload: { password },85    })) as { ok: boolean; data?: { valid?: boolean } };86    return response.ok && response.data?.valid === true;87  }8889  onMount(() => {90    void startShareFlow();9192    // Pairing pushes from the background (bg → popup). The whole NK session93    // runs in the service worker; the SAS to confirm and the terminal result94    // arrive here as runtime messages.95    if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {96      const handler = (msg: { type?: string; payload?: unknown }) => {97        if (msg?.type === "ADOPTION_SOURCE_SAS") {98          const p = msg.payload as {99            sas?: string;100            receiverDeviceName?: string;101          } | undefined;102          if (typeof p?.sas === "string") {103            setSourceSas(p.sas);104            setReceiverName(105              typeof p.receiverDeviceName === "string" && p.receiverDeviceName106                ? p.receiverDeviceName107                : "New device",108            );109            setSharePhase("sas");110          }111        } else if (msg?.type === "ADOPTION_SOURCE_RESULT") {112          const p = msg.payload as {113            status?: "complete" | "error";114            error?: string;115          } | undefined;116          if (p?.status === "complete") setSharePhase("paired");117          else if (p?.status === "error") {118            setShareError(p.error ?? "adoption_failed");119            setSharePhase("error");120          }121        }122      };123      chrome.runtime.onMessage.addListener(handler);124      onCleanup(() => chrome.runtime.onMessage.removeListener(handler));125    }126127    // Closing the popup mid-session must release the relay room, or the next128    // attempt is refused with adoption_source_in_progress.129    onCleanup(() => {130      if (sharePhase() !== "paired") {131        void sendMessage({ type: "CANCEL_ADOPTION_SOURCE" });132      }133    });134  });135136  const backArrow = (137    <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">138      <path d="M10 3 L5 8 L10 13" />139    </svg>140  );141142  // The bird stage — always animating — shared by every pairing screen.143  const birdStage = (active: boolean) => (144    <div class={`peach-pair-stage ${active ? "act-handshake" : "act-listening"}`} aria-hidden="true">145      <div class="peach-pair-bird-slot peach-pair-bird-slot-desktop">146        <DesktopBird pose="chirping" />147      </div>148      <div class="peach-pair-bird-slot peach-pair-bird-slot-mobile">149        <MobileBird pose="attentive" />150      </div>151    </div>152  );153154  function errorCopy(): string {155    switch (shareError()) {156      case "vault_locked":157        return "Unlock Peach first, then try again.";158      case "sync_disabled":159        return "Sync is turned off. Choose a relay in Settings → Sync, then try again.";160      case "private_relay_unset":161        return "Private relay is selected but has no URL yet. Enter it in Settings → Sync, then try again.";162      default:163        return "The secure pairing session ended. Start again with a fresh code.";164    }165  }166167  function liveText(): string {168    switch (sharePhase()) {169      case "opening": return "Opening a secure pairing room";170      case "invitation": return "Scan this code on your other device";171      case "sas": return "Confirm the code matches";172      case "connecting": return "Waiting for the new device";173      case "paired": return "Device added";174      case "error": return "Could not add the device";175    }176  }177178  return (179    <div180      class="peach-pair-pair peach-desktop-pair peach-adoption-source"181      data-phase={sharePhase()}182      role="dialog"183      aria-label="Add a device to this vault"184    >185      <header class="peach-pair-head">186        <button187          class="peach-pair-back"188          aria-label="Back"189          onClick={() => { void stopShareFlow(); props.onBack(); }}190        >191          {backArrow}192        </button>193        <div class="peach-pair-title">Add a new device</div>194        <div class="peach-pair-head-spacer" />195      </header>196197      {birdStage(sharePhase() === "sas" || sharePhase() === "connecting")}198199      <Show when={sharePhase() === "opening"}>200        <div class="peach-desktop-searching">201          <div class="peach-pair-headline">Opening a secure pairing room…</div>202          <div class="peach-pair-sub">The code appears once this browser is waiting in the relay.</div>203        </div>204      </Show>205206      <Show when={sharePhase() === "invitation"}>207        <div class="peach-adoption-source-invitation">208          <Show when={!changingRelay()}>209            <div class="peach-pair-headline">Scan this in Peach</div>210            <div class="peach-pair-sub">211              Open Peach on your phone, choose “Scan a code”, and point it here.212              The code carries only the relay address and a single-use token.213            </div>214            <PairingQrCode value={invitation()} size={188} />215          </Show>216          {/* Moment 1 (docs/sync-relay-surfacing.md): the relay choice belongs217              here because a relay is about to be used, but it is not a step —218              pairing already works on the default. Changing it rebuilds the219              invitation, which carries the relay address. */}220          <SyncModeSelector221            compact222            onExpand={setChangingRelay}223            onChange={() => void restartShareFlow()}224          />225        </div>226      </Show>227228      <Show when={sharePhase() === "connecting"}>229        <div class="peach-desktop-searching">230          <div class="peach-pair-headline">Waiting for the new device…</div>231          <div class="peach-pair-sub">The vault key stays put until that device confirms the same code too.</div>232        </div>233      </Show>234235      <Show when={sharePhase() === "paired"}>236        <div class="peach-desktop-paired">237          <div class="peach-pair-headline">Device added</div>238          <div class="peach-pair-sub">The new device can now sync this vault through the relay.</div>239          <div class="peach-pair-action">240            <button class="peach-btn peach-btn-primary" onClick={props.onBack}>Done</button>241          </div>242        </div>243      </Show>244245      <Show when={sharePhase() === "error"}>246        <div class="peach-desktop-error">247          <div class="peach-pair-headline">Could not add the device</div>248          <div class="peach-pair-sub">{errorCopy()}</div>249          <div class="peach-pair-action">250            <button class="peach-btn peach-btn-ghost" onClick={props.onBack}>Cancel</button>251            <button class="peach-btn peach-btn-primary" onClick={() => void startShareFlow()}>Try again</button>252          </div>253        </div>254      </Show>255256      <Show when={sharePhase() === "sas"}>257        <AdoptionConsentModal258          receiverDeviceName={receiverName()}259          sas={sourceSas()}260          verifyMasterPassword={verifySourcePassword}261          onCancel={() => {262            void sendMessage({263              type: "ADOPTION_SOURCE_CONFIRM",264              payload: { cancel: true },265            });266            setShareError("adoption_rejected");267            setSharePhase("error");268          }}269          onApprove={async () => {270            setSharePhase("connecting");271          }}272        />273      </Show>274275      <div class="peach-sr-only" aria-live="polite">{liveText()}</div>276    </div>277  );278};