PairDevice.tsx

9.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";2728export interface PairDeviceViewProps {29  isPro: boolean;30  onBack(): void;31  onLearnPro(): void;32  /** Called after a peer's Pro license is activated, so the app re-reads Pro status. */33  onProChanged?: () => void;34}3536type SharePhase = "opening" | "invitation" | "sas" | "connecting" | "paired" | "error";3738export const PairDeviceView: Component<PairDeviceViewProps> = (props) => {39  const [sharePhase, setSharePhase] = createSignal<SharePhase>("opening");40  const [invitation, setInvitation] = createSignal("");41  const [sourceSas, setSourceSas] = createSignal("");42  const [receiverName, setReceiverName] = createSignal("New device");43  const [shareError, setShareError] = createSignal("");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  async function verifySourcePassword(password: string): Promise<boolean> {74    const response = (await sendMessage({75      type: "ADOPTION_SOURCE_CONFIRM",76      payload: { password },77    })) as { ok: boolean; data?: { valid?: boolean } };78    return response.ok && response.data?.valid === true;79  }8081  onMount(() => {82    void startShareFlow();8384    // Pairing pushes from the background (bg → popup). The whole NK session85    // runs in the service worker; the SAS to confirm and the terminal result86    // arrive here as runtime messages.87    if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {88      const handler = (msg: { type?: string; payload?: unknown }) => {89        if (msg?.type === "ADOPTION_SOURCE_SAS") {90          const p = msg.payload as {91            sas?: string;92            receiverDeviceName?: string;93          } | undefined;94          if (typeof p?.sas === "string") {95            setSourceSas(p.sas);96            setReceiverName(97              typeof p.receiverDeviceName === "string" && p.receiverDeviceName98                ? p.receiverDeviceName99                : "New device",100            );101            setSharePhase("sas");102          }103        } else if (msg?.type === "ADOPTION_SOURCE_RESULT") {104          const p = msg.payload as {105            status?: "complete" | "error";106            error?: string;107          } | undefined;108          if (p?.status === "complete") setSharePhase("paired");109          else if (p?.status === "error") {110            setShareError(p.error ?? "adoption_failed");111            setSharePhase("error");112          }113        }114      };115      chrome.runtime.onMessage.addListener(handler);116      onCleanup(() => chrome.runtime.onMessage.removeListener(handler));117    }118119    // Closing the popup mid-session must release the relay room, or the next120    // attempt is refused with adoption_source_in_progress.121    onCleanup(() => {122      if (sharePhase() !== "paired") {123        void sendMessage({ type: "CANCEL_ADOPTION_SOURCE" });124      }125    });126  });127128  const backArrow = (129    <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">130      <path d="M10 3 L5 8 L10 13" />131    </svg>132  );133134  // The bird stage — always animating — shared by every pairing screen.135  const birdStage = (active: boolean) => (136    <div class={`peach-pair-stage ${active ? "act-handshake" : "act-listening"}`} aria-hidden="true">137      <div class="peach-pair-bird-slot peach-pair-bird-slot-desktop">138        <DesktopBird pose="chirping" />139      </div>140      <div class="peach-pair-bird-slot peach-pair-bird-slot-mobile">141        <MobileBird pose="attentive" />142      </div>143    </div>144  );145146  function errorCopy(): string {147    switch (shareError()) {148      case "vault_locked":149        return "Unlock Peach first, then try again.";150      case "sync_disabled":151        return "Sync is turned off. Choose a relay in Settings → Sync, then try again.";152      default:153        return "The secure pairing session ended. Start again with a fresh code.";154    }155  }156157  function liveText(): string {158    switch (sharePhase()) {159      case "opening": return "Opening a secure pairing room";160      case "invitation": return "Scan this code on your other device";161      case "sas": return "Confirm the code matches";162      case "connecting": return "Waiting for the new device";163      case "paired": return "Device added";164      case "error": return "Could not add the device";165    }166  }167168  return (169    <div170      class="peach-pair-pair peach-desktop-pair peach-adoption-source"171      data-phase={sharePhase()}172      role="dialog"173      aria-label="Add a device to this vault"174    >175      <header class="peach-pair-head">176        <button177          class="peach-pair-back"178          aria-label="Back"179          onClick={() => { void stopShareFlow(); props.onBack(); }}180        >181          {backArrow}182        </button>183        <div class="peach-pair-title">Add a new device</div>184        <div class="peach-pair-head-spacer" />185      </header>186187      {birdStage(sharePhase() === "sas" || sharePhase() === "connecting")}188189      <Show when={sharePhase() === "opening"}>190        <div class="peach-desktop-searching">191          <div class="peach-pair-headline">Opening a secure pairing room…</div>192          <div class="peach-pair-sub">The code appears once this browser is waiting in the relay.</div>193        </div>194      </Show>195196      <Show when={sharePhase() === "invitation"}>197        <div class="peach-adoption-source-invitation">198          <div class="peach-pair-headline">Scan this in Peach</div>199          <div class="peach-pair-sub">200            Open Peach on your phone, choose “Scan a code”, and point it here.201            The code carries only the relay address and a single-use token.202          </div>203          <PairingQrCode value={invitation()} size={188} />204        </div>205      </Show>206207      <Show when={sharePhase() === "connecting"}>208        <div class="peach-desktop-searching">209          <div class="peach-pair-headline">Waiting for the new device…</div>210          <div class="peach-pair-sub">The vault key stays put until that device confirms the same code too.</div>211        </div>212      </Show>213214      <Show when={sharePhase() === "paired"}>215        <div class="peach-desktop-paired">216          <div class="peach-pair-headline">Device added</div>217          <div class="peach-pair-sub">The new device can now sync this vault through the relay.</div>218          <div class="peach-pair-action">219            <button class="peach-btn peach-btn-primary" onClick={props.onBack}>Done</button>220          </div>221        </div>222      </Show>223224      <Show when={sharePhase() === "error"}>225        <div class="peach-desktop-error">226          <div class="peach-pair-headline">Could not add the device</div>227          <div class="peach-pair-sub">{errorCopy()}</div>228          <div class="peach-pair-action">229            <button class="peach-btn peach-btn-ghost" onClick={props.onBack}>Cancel</button>230            <button class="peach-btn peach-btn-primary" onClick={() => void startShareFlow()}>Try again</button>231          </div>232        </div>233      </Show>234235      <Show when={sharePhase() === "sas"}>236        <AdoptionConsentModal237          receiverDeviceName={receiverName()}238          sas={sourceSas()}239          verifyMasterPassword={verifySourcePassword}240          onCancel={() => {241            void sendMessage({242              type: "ADOPTION_SOURCE_CONFIRM",243              payload: { cancel: true },244            });245            setShareError("adoption_rejected");246            setSharePhase("error");247          }}248          onApprove={async () => {249            setSharePhase("connecting");250          }}251        />252      </Show>253254      <div class="peach-sr-only" aria-live="polite">{liveText()}</div>255    </div>256  );257};