123456789101112131415import {16 Component,17 Show,18 createEffect,19 createSignal,20 onCleanup,21 onMount,22} from "solid-js";23import { sendMessage } from "../../shared/bridge";24import type { PairedDevice } from "../../shared/sync-types";25import { DesktopBird, MobileBird } from "../components/PairingBirds";26import { formatPairingCode, parsePairingCode } from "../../shared/pairing-code";27import { AdoptionConsentModal } from "../components/AdoptionConsentModal";28import { PairingQrCode } from "../components/PairingQrCode";2930export interface PairDeviceViewProps {31 isPro: boolean;32 onBack(): void;33 onLearnPro(): void;34 3536373839 initialPair?: "code" | null;40 41 onProChanged?: () => void;42}434445type PairPhase = "enter" | "connecting" | "sas" | "paired" | "error";46type SharePhase = "opening" | "invitation" | "sas" | "connecting" | "paired" | "error";47type CodePairingError =48 | "invalid_code"49 | "no_host_found"50 | "handshake_failed"51 | "sas_mismatch"52 | "vault_locked";5354const PAIRED_DISMISS_MS = 2_000;5556function errorCopy(error: CodePairingError | "unknown"): { headline: string; sub: string } {57 switch (error) {58 case "invalid_code":59 60 61 return {62 headline: "Code not found or expired",63 sub: "Check the code on your phone and try again. Codes expire, so grab a fresh one if it's been a while.",64 };65 case "no_host_found":66 return {67 headline: "Couldn't reach your phone",68 sub: "Make sure both devices are on the same Wi-Fi and the pairing screen is still open on your phone.",69 };70 case "sas_mismatch":71 return {72 headline: "Codes don't match",73 sub: "For your safety, pairing was stopped. Start over and compare the two codes carefully.",74 };75 case "vault_locked":76 return {77 headline: "Unlock Peach first",78 sub: "Your vault needs to be unlocked before you can pair a device.",79 };80 case "handshake_failed":81 default:82 return {83 headline: "Pairing failed",84 sub: "Something went wrong while connecting. Please try again.",85 };86 }87}8889export const PairDeviceView: Component<PairDeviceViewProps> = (props) => {90 91 92 93 94 const [phase, setPhase] = createSignal<PairPhase>("enter");95 const [code, setCode] = createSignal("");96 const [sas, setSas] = createSignal<string | null>(null);97 const [errorKind, setErrorKind] = createSignal<CodePairingError | "unknown">("unknown");98 const [pairedDevice, setPairedDevice] = createSignal<PairedDevice | undefined>(undefined);99 const [busy, setBusy] = createSignal(false);100 const [mode, setMode] = createSignal<"share" | "connect">("share");101 const [sharePhase, setSharePhase] = createSignal<SharePhase>("opening");102 const [invitation, setInvitation] = createSignal("");103 const [sourceSas, setSourceSas] = createSignal("");104 const [receiverName, setReceiverName] = createSignal("New device");105 const [shareError, setShareError] = createSignal("");106107 const codeReady = () => parsePairingCode(code()) !== null;108109 function startFlow(): void {110 setMode("connect");111 setCode("");112 setSas(null);113 setErrorKind("unknown");114 setPairedDevice(undefined);115 setBusy(false);116 setPhase("enter");117 }118119 async function startShareFlow(): Promise<void> {120 setMode("share");121 setSharePhase("opening");122 setShareError("");123 setInvitation("");124 setSourceSas("");125 try {126 const response = (await sendMessage({127 type: "START_ADOPTION_SOURCE",128 })) as {129 ok: boolean;130 data?: { invitation?: string };131 error?: string;132 };133 if (!response.ok || typeof response.data?.invitation !== "string") {134 throw new Error(response.error ?? "adoption_source_failed");135 }136 setInvitation(response.data.invitation);137 setSharePhase("invitation");138 } catch (error) {139 setShareError(error instanceof Error ? error.message : "adoption_source_failed");140 setSharePhase("error");141 }142 }143144 async function stopShareFlow(): Promise<void> {145 await sendMessage({ type: "CANCEL_ADOPTION_SOURCE" }).catch(() => undefined);146 }147148 async function useCodeInstead(): Promise<void> {149 await stopShareFlow();150 startFlow();151 }152153 async function verifySourcePassword(password: string): Promise<boolean> {154 const response = (await sendMessage({155 type: "ADOPTION_SOURCE_CONFIRM",156 payload: { password },157 })) as { ok: boolean; data?: { valid?: boolean } };158 return response.ok && response.data?.valid === true;159 }160161 function onCodeInput(value: string): void {162 const cleaned = value.toUpperCase().replace(/[^0-9A-Z]/g, "").slice(0, 32);163 setCode(formatPairingCode(cleaned));164 }165166 async function handleConnect(): Promise<void> {167 if (!codeReady() || busy()) return;168 setBusy(true);169 setPhase("connecting");170 try {171 const res = (await sendMessage({172 type: "PAIR_WITH_CODE",173 payload: { code: code() },174 })) as { ok: boolean; error?: string };175 176 177 if (!res.ok) {178 setErrorKind((res.error as CodePairingError) ?? "unknown");179 setPhase("error");180 }181 } catch {182 setErrorKind("unknown");183 setPhase("error");184 } finally {185 setBusy(false);186 }187 }188189 async function respondConfirm(confirmed: boolean): Promise<void> {190 if (busy()) return;191 setBusy(true);192 193 194 if (confirmed) setPhase("connecting");195 else { setErrorKind("sas_mismatch"); setPhase("error"); }196 try {197 await sendMessage({ type: "CODE_PAIR_CONFIRM", payload: { confirmed } });198 } finally {199 setBusy(false);200 }201 }202203 204 205 createEffect(() => {206 if (phase() !== "paired") return;207 const t = setTimeout(() => props.onBack(), PAIRED_DISMISS_MS);208 onCleanup(() => clearTimeout(t));209 });210211 onMount(() => {212 213 214 if (props.initialPair === "code") {215 void sendMessage({ type: "GET_RELAY_SETTINGS" })216 .then((response) => {217 const settings = response as {218 ok: boolean;219 data?: { lanOnly?: boolean };220 };221 if (settings.ok && settings.data?.lanOnly !== true) {222 return startShareFlow();223 }224 startFlow();225 })226 .catch(() => startFlow());227 } else props.onBack();228229 230 231 232 if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {233 const handler = (msg: { type?: string; payload?: unknown }) => {234 if (msg?.type === "CODE_PAIR_SAS") {235 const p = msg.payload as { sas?: string } | undefined;236 if (typeof p?.sas === "string") { setSas(p.sas); setPhase("sas"); }237 } else if (msg?.type === "CODE_PAIR_RESULT") {238 const p = msg.payload as239 | { status: "complete"; device: PairedDevice }240 | { status: "error"; error: CodePairingError }241 | undefined;242 if (p?.status === "complete") { setPairedDevice(p.device); setPhase("paired"); }243 else if (p?.status === "error") { setErrorKind(p.error); setPhase("error"); }244 } else if (msg?.type === "ADOPTION_SOURCE_SAS") {245 const p = msg.payload as {246 sas?: string;247 receiverDeviceName?: string;248 } | undefined;249 if (typeof p?.sas === "string") {250 setSourceSas(p.sas);251 setReceiverName(252 typeof p.receiverDeviceName === "string" && p.receiverDeviceName253 ? p.receiverDeviceName254 : "New device",255 );256 setSharePhase("sas");257 }258 } else if (msg?.type === "ADOPTION_SOURCE_RESULT") {259 const p = msg.payload as {260 status?: "complete" | "error";261 error?: string;262 } | undefined;263 if (p?.status === "complete") setSharePhase("paired");264 else if (p?.status === "error") {265 setShareError(p.error ?? "adoption_failed");266 setSharePhase("error");267 }268 }269 };270 chrome.runtime.onMessage.addListener(handler);271 onCleanup(() => chrome.runtime.onMessage.removeListener(handler));272 }273 274 275 276 onCleanup(() => {277 if (phase() === "sas") {278 void sendMessage({ type: "CODE_PAIR_CONFIRM", payload: { confirmed: false } });279 }280 if (mode() === "share" && sharePhase() !== "paired") {281 void sendMessage({ type: "CANCEL_ADOPTION_SOURCE" });282 }283 });284 });285286 287288 const backArrow = (289 <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">290 <path d="M10 3 L5 8 L10 13" />291 </svg>292 );293294 295 const birdStage = (active: boolean) => (296 <div class={`peach-pair-stage ${active ? "act-handshake" : "act-listening"}`} aria-hidden="true">297 <div class="peach-pair-bird-slot peach-pair-bird-slot-desktop">298 <DesktopBird pose="chirping" />299 </div>300 <div class="peach-pair-bird-slot peach-pair-bird-slot-mobile">301 <MobileBird pose="attentive" />302 </div>303 </div>304 );305306 function liveText(): string {307 switch (phase()) {308 case "enter": return "Enter the pairing code from your phone";309 case "connecting": return "Connecting to your phone";310 case "sas": return "Confirm the code matches on your phone";311 case "paired": return "Paired";312 case "error": return errorCopy(errorKind()).headline;313 }314 }315316 317 const pairingScreen = () => (318 <div class="peach-pair-pair peach-desktop-pair" data-phase={phase()} role="dialog" aria-label="Pair a device with a code">319 <header class="peach-pair-head">320 <button class="peach-pair-back" aria-label="Back" onClick={() => props.onBack()}>{backArrow}</button>321 <div class="peach-pair-title">Enter a code</div>322 <div class="peach-pair-head-spacer" />323 </header>324325 {birdStage(phase() !== "enter")}326327 <Show when={phase() === "enter"}>328 <div class="peach-desktop-enter">329 <div class="peach-pair-headline">Enter the code from your phone</div>330 <div class="peach-pair-sub">331 Open Peach on your phone, tap “Pair a device”, and type the code it shows.332 Both devices need to be on the same Wi-Fi.333 </div>334 <label class="peach-label" for="pair-code">pairing code</label>335 <input336 id="pair-code"337 class="peach-underline-input peach-unlock-input peach-code-input"338 type="text"339 autocapitalize="characters"340 autocomplete="one-time-code"341 spellcheck={false}342 maxlength={32}343 placeholder="A74H-48J3-KI32-OEP4"344 aria-label="Pairing code"345 value={code()}346 ref={(el) => setTimeout(() => el.focus(), 80)}347 onInput={(e) => onCodeInput((e.target as HTMLInputElement).value)}348 onKeyDown={(e) => { if (e.key === "Enter") void handleConnect(); }}349 />350 <div class="peach-pair-action">351 <button class="peach-btn peach-btn-ghost" onClick={() => props.onBack()}>Cancel</button>352 <button353 class="peach-btn peach-btn-primary peach-desktop-connect"354 disabled={!codeReady() || busy()}355 onClick={() => void handleConnect()}356 >357 Connect358 </button>359 </div>360 </div>361 </Show>362363 <Show when={phase() === "connecting"}>364 <div class="peach-desktop-searching">365 <div class="peach-pair-headline">Connecting…</div>366 <div class="peach-pair-sub">Reaching your phone over the local network.</div>367 </div>368 </Show>369370 <Show when={phase() === "sas"}>371 <div class="peach-desktop-confirm" style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:8px 18px 16px;text-align:center;">372 <div class="peach-pair-headline">Confirm this code matches on your phone:</div>373 <div374 class="peach-desktop-sas"375 style="font-size:30px;font-weight:700;letter-spacing:0.32em;font-family:'IBM Plex Mono',monospace;color:var(--peach-accent);padding:6px 0 2px;"376 >377 {sas()}378 </div>379 <div class="peach-pair-sub" style="max-width:240px;">380 Only continue if this 6-digit code is identical on both screens. This is381 what proves you're talking to your own device.382 </div>383 <div style="display:flex;flex-direction:column;gap:8px;width:100%;max-width:240px;">384 <button class="peach-btn peach-btn-primary" disabled={busy()} onClick={() => void respondConfirm(true)}>385 Confirm386 </button>387 <button class="peach-btn peach-btn-ghost" disabled={busy()} onClick={() => void respondConfirm(false)}>388 Cancel389 </button>390 </div>391 </div>392 </Show>393394 <Show when={phase() === "paired"}>395 <div class="peach-desktop-paired">396 <div class="peach-pair-headline">Paired!</div>397 <div class="peach-pair-sub">398 {pairedDevice()?.deviceName ? `Connected to ${pairedDevice()!.deviceName}` : "Connected to your phone"}399 </div>400 <div class="peach-pair-action">401 <button class="peach-btn peach-btn-primary" onClick={() => props.onBack()}>Done</button>402 </div>403 </div>404 </Show>405406 <Show when={phase() === "error"}>407 <div class="peach-desktop-error">408 <div class="peach-pair-headline">{errorCopy(errorKind()).headline}</div>409 <div class="peach-pair-sub">{errorCopy(errorKind()).sub}</div>410 <div class="peach-pair-action">411 <button class="peach-btn peach-btn-ghost" onClick={() => props.onBack()}>Cancel</button>412 <button class="peach-btn peach-btn-primary" onClick={() => startFlow()}>Try again</button>413 </div>414 </div>415 </Show>416417 <div class="peach-sr-only" aria-live="polite">{liveText()}</div>418 </div>419 );420421 const shareScreen = () => (422 <div423 class="peach-pair-pair peach-desktop-pair peach-adoption-source"424 data-phase={sharePhase()}425 role="dialog"426 aria-label="Share this vault with a new device"427 >428 <header class="peach-pair-head">429 <button430 class="peach-pair-back"431 aria-label="Back"432 onClick={() => { void stopShareFlow(); props.onBack(); }}433 >434 {backArrow}435 </button>436 <div class="peach-pair-title">Add a new device</div>437 <div class="peach-pair-head-spacer" />438 </header>439440 {birdStage(sharePhase() === "sas" || sharePhase() === "connecting")}441442 <Show when={sharePhase() === "opening"}>443 <div class="peach-desktop-searching">444 <div class="peach-pair-headline">Opening a secure pairing room…</div>445 <div class="peach-pair-sub">The QR appears only after this browser is waiting in the relay.</div>446 </div>447 </Show>448449 <Show when={sharePhase() === "invitation"}>450 <div class="peach-adoption-source-invitation">451 <div class="peach-pair-headline">Scan this in Peach</div>452 <div class="peach-pair-sub">453 Connecting via secure relay — works anywhere. The QR contains only454 the relay address and a single-use random token.455 </div>456 <PairingQrCode value={invitation()} size={224} />457 <label class="peach-label" for="pairing-invitation-manual">or copy the invitation</label>458 <textarea459 id="pairing-invitation-manual"460 class="peach-adoption-source-code"461 readonly462 rows={3}463 value={invitation()}464 />465 <div class="peach-pair-action">466 <button467 class="peach-btn peach-btn-ghost"468 onClick={() => void useCodeInstead()}469 >470 Enter a code instead471 </button>472 <button473 class="peach-btn peach-btn-primary"474 onClick={() => void navigator.clipboard.writeText(invitation())}475 >476 Copy477 </button>478 </div>479 </div>480 </Show>481482 <Show when={sharePhase() === "connecting"}>483 <div class="peach-desktop-searching">484 <div class="peach-pair-headline">Waiting for the new device…</div>485 <div class="peach-pair-sub">The vault key stays put until that device confirms the same code too.</div>486 </div>487 </Show>488489 <Show when={sharePhase() === "paired"}>490 <div class="peach-desktop-paired">491 <div class="peach-pair-headline">Device added</div>492 <div class="peach-pair-sub">The new device can now sync this vault through the secure relay.</div>493 <div class="peach-pair-action">494 <button class="peach-btn peach-btn-primary" onClick={props.onBack}>Done</button>495 </div>496 </div>497 </Show>498499 <Show when={sharePhase() === "error"}>500 <div class="peach-desktop-error">501 <div class="peach-pair-headline">Could not add the device</div>502 <div class="peach-pair-sub">503 {shareError() === "vault_locked"504 ? "Unlock Peach first, then try again."505 : "The secure pairing session ended. Start again with a fresh QR."}506 </div>507 <div class="peach-pair-action">508 <button class="peach-btn peach-btn-ghost" onClick={props.onBack}>Cancel</button>509 <button class="peach-btn peach-btn-primary" onClick={() => void startShareFlow()}>Try again</button>510 </div>511 </div>512 </Show>513514 <Show when={sharePhase() === "sas"}>515 <AdoptionConsentModal516 receiverDeviceName={receiverName()}517 sas={sourceSas()}518 verifyMasterPassword={verifySourcePassword}519 onCancel={() => {520 void sendMessage({521 type: "ADOPTION_SOURCE_CONFIRM",522 payload: { cancel: true },523 });524 setShareError("adoption_rejected");525 setSharePhase("error");526 }}527 onApprove={async () => {528 setSharePhase("connecting");529 }}530 />531 </Show>532 </div>533 );534535 536 537 return mode() === "share" ? shareScreen() : pairingScreen();538};