12345678910111213141516import {17 Component,18 Show,19 createEffect,20 createSignal,21 onCleanup,22 onMount,23} from "solid-js";24import { sendMessage } from "../../shared/bridge";25import type { PairedDevice } from "../../shared/sync-types";26import { DesktopBird, MobileBird } from "../components/PairingBirds";27import { formatPairingCode, parsePairingCode } from "../../shared/pairing-code";2829export interface PairDeviceViewProps {30 isPro: boolean;31 onBack(): void;32 onLearnPro(): void;33 3435363738 initialPair?: "code" | null;39 40 onProChanged?: () => void;41}424344type PairPhase = "enter" | "connecting" | "sas" | "paired" | "error";45type CodePairingError =46 | "invalid_code"47 | "no_host_found"48 | "handshake_failed"49 | "sas_mismatch"50 | "vault_locked";5152const PAIRED_DISMISS_MS = 2_000;5354function errorCopy(error: CodePairingError | "unknown"): { headline: string; sub: string } {55 switch (error) {56 case "invalid_code":57 58 59 return {60 headline: "Code not found or expired",61 sub: "Check the code on your phone and try again. Codes expire, so grab a fresh one if it's been a while.",62 };63 case "no_host_found":64 return {65 headline: "Couldn't reach your phone",66 sub: "Make sure both devices are on the same Wi-Fi and the pairing screen is still open on your phone.",67 };68 case "sas_mismatch":69 return {70 headline: "Codes don't match",71 sub: "For your safety, pairing was stopped. Start over and compare the two codes carefully.",72 };73 case "vault_locked":74 return {75 headline: "Unlock Peach first",76 sub: "Your vault needs to be unlocked before you can pair a device.",77 };78 case "handshake_failed":79 default:80 return {81 headline: "Pairing failed",82 sub: "Something went wrong while connecting. Please try again.",83 };84 }85}8687export const PairDeviceView: Component<PairDeviceViewProps> = (props) => {88 89 90 91 92 const [phase, setPhase] = createSignal<PairPhase>("enter");93 const [code, setCode] = createSignal("");94 const [sas, setSas] = createSignal<string | null>(null);95 const [errorKind, setErrorKind] = createSignal<CodePairingError | "unknown">("unknown");96 const [pairedDevice, setPairedDevice] = createSignal<PairedDevice | undefined>(undefined);97 const [busy, setBusy] = createSignal(false);9899 100 101 102 const codeReady = () =>103 parsePairingCode(code()) !== null || /^\d{6}$/.test(code());104105 function startFlow(): void {106 setCode("");107 setSas(null);108 setErrorKind("unknown");109 setPairedDevice(undefined);110 setBusy(false);111 setPhase("enter");112 }113114 function onCodeInput(value: string): void {115 const cleaned = value.toUpperCase().replace(/[^0-9A-Z]/g, "").slice(0, 32);116 117 118 119 setCode(cleaned.length > 6 ? formatPairingCode(cleaned) : cleaned);120 }121122 async function handleConnect(): Promise<void> {123 if (!codeReady() || busy()) return;124 setBusy(true);125 setPhase("connecting");126 try {127 const res = (await sendMessage({128 type: "PAIR_WITH_CODE",129 payload: { code: code() },130 })) as { ok: boolean; error?: string };131 132 133 if (!res.ok) {134 setErrorKind((res.error as CodePairingError) ?? "unknown");135 setPhase("error");136 }137 } catch {138 setErrorKind("unknown");139 setPhase("error");140 } finally {141 setBusy(false);142 }143 }144145 async function respondConfirm(confirmed: boolean): Promise<void> {146 if (busy()) return;147 setBusy(true);148 149 150 if (confirmed) setPhase("connecting");151 else { setErrorKind("sas_mismatch"); setPhase("error"); }152 try {153 await sendMessage({ type: "CODE_PAIR_CONFIRM", payload: { confirmed } });154 } finally {155 setBusy(false);156 }157 }158159 160 161 createEffect(() => {162 if (phase() !== "paired") return;163 const t = setTimeout(() => props.onBack(), PAIRED_DISMISS_MS);164 onCleanup(() => clearTimeout(t));165 });166167 onMount(() => {168 169 170 if (props.initialPair === "code") startFlow();171 else props.onBack();172173 174 175 176 if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {177 const handler = (msg: { type?: string; payload?: unknown }) => {178 if (msg?.type === "CODE_PAIR_SAS") {179 const p = msg.payload as { sas?: string } | undefined;180 if (typeof p?.sas === "string") { setSas(p.sas); setPhase("sas"); }181 } else if (msg?.type === "CODE_PAIR_RESULT") {182 const p = msg.payload as183 | { status: "complete"; device: PairedDevice }184 | { status: "error"; error: CodePairingError }185 | undefined;186 if (p?.status === "complete") { setPairedDevice(p.device); setPhase("paired"); }187 else if (p?.status === "error") { setErrorKind(p.error); setPhase("error"); }188 }189 };190 chrome.runtime.onMessage.addListener(handler);191 onCleanup(() => chrome.runtime.onMessage.removeListener(handler));192 }193 194 195 196 onCleanup(() => {197 if (phase() === "sas") {198 void sendMessage({ type: "CODE_PAIR_CONFIRM", payload: { confirmed: false } });199 }200 });201 });202203 204205 const backArrow = (206 <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">207 <path d="M10 3 L5 8 L10 13" />208 </svg>209 );210211 212 const birdStage = (active: boolean) => (213 <div class={`peach-chirp-stage ${active ? "act-handshake" : "act-listening"}`} aria-hidden="true">214 <div class="peach-chirp-bird-slot peach-chirp-bird-slot-desktop">215 <DesktopBird pose="chirping" />216 </div>217 <div class="peach-chirp-bird-slot peach-chirp-bird-slot-mobile">218 <MobileBird pose="attentive" />219 </div>220 </div>221 );222223 function liveText(): string {224 switch (phase()) {225 case "enter": return "Enter the pairing code from your phone";226 case "connecting": return "Connecting to your phone";227 case "sas": return "Confirm the code matches on your phone";228 case "paired": return "Paired";229 case "error": return errorCopy(errorKind()).headline;230 }231 }232233 234 const pairingScreen = () => (235 <div class="peach-chirp-pair peach-desktop-pair" data-phase={phase()} role="dialog" aria-label="Pair a device with a code">236 <header class="peach-chirp-head">237 <button class="peach-chirp-back" aria-label="Back" onClick={() => props.onBack()}>{backArrow}</button>238 <div class="peach-chirp-title">Enter a code</div>239 <div class="peach-chirp-head-spacer" />240 </header>241242 {birdStage(phase() !== "enter")}243244 <Show when={phase() === "enter"}>245 <div class="peach-desktop-enter">246 <div class="peach-chirp-headline">Enter the code from your phone</div>247 <div class="peach-chirp-sub">248 Open Peach on your phone, tap “Pair a device”, and type the code it shows.249 Both devices need to be on the same Wi-Fi.250 </div>251 <label class="peach-label" for="pair-code">pairing code</label>252 <input253 id="pair-code"254 class="peach-underline-input peach-unlock-input peach-code-input"255 type="text"256 autocapitalize="characters"257 autocomplete="one-time-code"258 spellcheck={false}259 maxlength={32}260 placeholder="A74H-48J3-KI32-OEP4"261 aria-label="Pairing code"262 value={code()}263 ref={(el) => setTimeout(() => el.focus(), 80)}264 onInput={(e) => onCodeInput((e.target as HTMLInputElement).value)}265 onKeyDown={(e) => { if (e.key === "Enter") void handleConnect(); }}266 />267 <div class="peach-chirp-action">268 <button class="peach-btn peach-btn-ghost" onClick={() => props.onBack()}>Cancel</button>269 <button270 class="peach-btn peach-btn-primary peach-desktop-connect"271 disabled={!codeReady() || busy()}272 onClick={() => void handleConnect()}273 >274 Connect275 </button>276 </div>277 </div>278 </Show>279280 <Show when={phase() === "connecting"}>281 <div class="peach-desktop-searching">282 <div class="peach-chirp-headline">Connecting…</div>283 <div class="peach-chirp-sub">Reaching your phone over the local network.</div>284 </div>285 </Show>286287 <Show when={phase() === "sas"}>288 <div class="peach-desktop-confirm" style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:8px 18px 16px;text-align:center;">289 <div class="peach-chirp-headline">Confirm this code matches on your phone:</div>290 <div291 class="peach-desktop-sas"292 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;"293 >294 {sas()}295 </div>296 <div class="peach-chirp-sub" style="max-width:240px;">297 Only continue if this 6-digit code is identical on both screens. This is298 what proves you're talking to your own device.299 </div>300 <div style="display:flex;flex-direction:column;gap:8px;width:100%;max-width:240px;">301 <button class="peach-btn peach-btn-primary" disabled={busy()} onClick={() => void respondConfirm(true)}>302 Confirm303 </button>304 <button class="peach-btn peach-btn-ghost" disabled={busy()} onClick={() => void respondConfirm(false)}>305 Cancel306 </button>307 </div>308 </div>309 </Show>310311 <Show when={phase() === "paired"}>312 <div class="peach-desktop-paired">313 <div class="peach-chirp-headline">Paired!</div>314 <div class="peach-chirp-sub">315 {pairedDevice()?.deviceName ? `Connected to ${pairedDevice()!.deviceName}` : "Connected to your phone"}316 </div>317 <div class="peach-chirp-action">318 <button class="peach-btn peach-btn-primary" onClick={() => props.onBack()}>Done</button>319 </div>320 </div>321 </Show>322323 <Show when={phase() === "error"}>324 <div class="peach-desktop-error">325 <div class="peach-chirp-headline">{errorCopy(errorKind()).headline}</div>326 <div class="peach-chirp-sub">{errorCopy(errorKind()).sub}</div>327 <div class="peach-chirp-action">328 <button class="peach-btn peach-btn-ghost" onClick={() => props.onBack()}>Cancel</button>329 <button class="peach-btn peach-btn-primary" onClick={() => startFlow()}>Try again</button>330 </div>331 </div>332 </Show>333334 <div class="peach-sr-only" aria-live="polite">{liveText()}</div>335 </div>336 );337338 339 340 return pairingScreen();341};