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";2728export interface PairDeviceViewProps {29 isPro: boolean;30 onBack(): void;31 onLearnPro(): void;32 3334353637 initialPair?: "code" | null;38 39 onProChanged?: () => void;40}414243type PairPhase = "enter" | "connecting" | "sas" | "paired" | "error";44type CodePairingError =45 | "invalid_code"46 | "no_host_found"47 | "handshake_failed"48 | "sas_mismatch"49 | "vault_locked";5051const PAIRED_DISMISS_MS = 2_000;5253function errorCopy(error: CodePairingError | "unknown"): { headline: string; sub: string } {54 switch (error) {55 case "invalid_code":56 57 58 return {59 headline: "Code not found or expired",60 sub: "Check the code on your phone and try again. Codes expire, so grab a fresh one if it's been a while.",61 };62 case "no_host_found":63 return {64 headline: "Couldn't reach your phone",65 sub: "Make sure both devices are on the same Wi-Fi and the pairing screen is still open on your phone.",66 };67 case "sas_mismatch":68 return {69 headline: "Codes don't match",70 sub: "For your safety, pairing was stopped. Start over and compare the two codes carefully.",71 };72 case "vault_locked":73 return {74 headline: "Unlock Peach first",75 sub: "Your vault needs to be unlocked before you can pair a device.",76 };77 case "handshake_failed":78 default:79 return {80 headline: "Pairing failed",81 sub: "Something went wrong while connecting. Please try again.",82 };83 }84}8586export const PairDeviceView: Component<PairDeviceViewProps> = (props) => {87 88 89 90 91 const [phase, setPhase] = createSignal<PairPhase>("enter");92 const [code, setCode] = createSignal("");93 const [sas, setSas] = createSignal<string | null>(null);94 const [errorKind, setErrorKind] = createSignal<CodePairingError | "unknown">("unknown");95 const [pairedDevice, setPairedDevice] = createSignal<PairedDevice | undefined>(undefined);96 const [busy, setBusy] = createSignal(false);9798 99 100 101 const codeReady = () =>102 parsePairingCode(code()) !== null || /^\d{6}$/.test(code());103104 function startFlow(): void {105 setCode("");106 setSas(null);107 setErrorKind("unknown");108 setPairedDevice(undefined);109 setBusy(false);110 setPhase("enter");111 }112113 function onCodeInput(value: string): void {114 const cleaned = value.toUpperCase().replace(/[^0-9A-Z]/g, "").slice(0, 32);115 116 117 118 setCode(cleaned.length > 6 ? formatPairingCode(cleaned) : cleaned);119 }120121 async function handleConnect(): Promise<void> {122 if (!codeReady() || busy()) return;123 setBusy(true);124 setPhase("connecting");125 try {126 const res = (await sendMessage({127 type: "PAIR_WITH_CODE",128 payload: { code: code() },129 })) as { ok: boolean; error?: string };130 131 132 if (!res.ok) {133 setErrorKind((res.error as CodePairingError) ?? "unknown");134 setPhase("error");135 }136 } catch {137 setErrorKind("unknown");138 setPhase("error");139 } finally {140 setBusy(false);141 }142 }143144 async function respondConfirm(confirmed: boolean): Promise<void> {145 if (busy()) return;146 setBusy(true);147 148 149 if (confirmed) setPhase("connecting");150 else { setErrorKind("sas_mismatch"); setPhase("error"); }151 try {152 await sendMessage({ type: "CODE_PAIR_CONFIRM", payload: { confirmed } });153 } finally {154 setBusy(false);155 }156 }157158 159 160 createEffect(() => {161 if (phase() !== "paired") return;162 const t = setTimeout(() => props.onBack(), PAIRED_DISMISS_MS);163 onCleanup(() => clearTimeout(t));164 });165166 onMount(() => {167 168 169 if (props.initialPair === "code") startFlow();170 else props.onBack();171172 173 174 175 if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {176 const handler = (msg: { type?: string; payload?: unknown }) => {177 if (msg?.type === "CODE_PAIR_SAS") {178 const p = msg.payload as { sas?: string } | undefined;179 if (typeof p?.sas === "string") { setSas(p.sas); setPhase("sas"); }180 } else if (msg?.type === "CODE_PAIR_RESULT") {181 const p = msg.payload as182 | { status: "complete"; device: PairedDevice }183 | { status: "error"; error: CodePairingError }184 | undefined;185 if (p?.status === "complete") { setPairedDevice(p.device); setPhase("paired"); }186 else if (p?.status === "error") { setErrorKind(p.error); setPhase("error"); }187 }188 };189 chrome.runtime.onMessage.addListener(handler);190 onCleanup(() => chrome.runtime.onMessage.removeListener(handler));191 }192 193 194 195 onCleanup(() => {196 if (phase() === "sas") {197 void sendMessage({ type: "CODE_PAIR_CONFIRM", payload: { confirmed: false } });198 }199 });200 });201202 203204 const backArrow = (205 <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">206 <path d="M10 3 L5 8 L10 13" />207 </svg>208 );209210 211 const birdStage = (active: boolean) => (212 <div class={`peach-pair-stage ${active ? "act-handshake" : "act-listening"}`} aria-hidden="true">213 <div class="peach-pair-bird-slot peach-pair-bird-slot-desktop">214 <DesktopBird pose="chirping" />215 </div>216 <div class="peach-pair-bird-slot peach-pair-bird-slot-mobile">217 <MobileBird pose="attentive" />218 </div>219 </div>220 );221222 function liveText(): string {223 switch (phase()) {224 case "enter": return "Enter the pairing code from your phone";225 case "connecting": return "Connecting to your phone";226 case "sas": return "Confirm the code matches on your phone";227 case "paired": return "Paired";228 case "error": return errorCopy(errorKind()).headline;229 }230 }231232 233 const pairingScreen = () => (234 <div class="peach-pair-pair peach-desktop-pair" data-phase={phase()} role="dialog" aria-label="Pair a device with a code">235 <header class="peach-pair-head">236 <button class="peach-pair-back" aria-label="Back" onClick={() => props.onBack()}>{backArrow}</button>237 <div class="peach-pair-title">Enter a code</div>238 <div class="peach-pair-head-spacer" />239 </header>240241 {birdStage(phase() !== "enter")}242243 <Show when={phase() === "enter"}>244 <div class="peach-desktop-enter">245 <div class="peach-pair-headline">Enter the code from your phone</div>246 <div class="peach-pair-sub">247 Open Peach on your phone, tap “Pair a device”, and type the code it shows.248 Both devices need to be on the same Wi-Fi.249 </div>250 <label class="peach-label" for="pair-code">pairing code</label>251 <input252 id="pair-code"253 class="peach-underline-input peach-unlock-input peach-code-input"254 type="text"255 autocapitalize="characters"256 autocomplete="one-time-code"257 spellcheck={false}258 maxlength={32}259 placeholder="A74H-48J3-KI32-OEP4"260 aria-label="Pairing code"261 value={code()}262 ref={(el) => setTimeout(() => el.focus(), 80)}263 onInput={(e) => onCodeInput((e.target as HTMLInputElement).value)}264 onKeyDown={(e) => { if (e.key === "Enter") void handleConnect(); }}265 />266 <div class="peach-pair-action">267 <button class="peach-btn peach-btn-ghost" onClick={() => props.onBack()}>Cancel</button>268 <button269 class="peach-btn peach-btn-primary peach-desktop-connect"270 disabled={!codeReady() || busy()}271 onClick={() => void handleConnect()}272 >273 Connect274 </button>275 </div>276 </div>277 </Show>278279 <Show when={phase() === "connecting"}>280 <div class="peach-desktop-searching">281 <div class="peach-pair-headline">Connecting…</div>282 <div class="peach-pair-sub">Reaching your phone over the local network.</div>283 </div>284 </Show>285286 <Show when={phase() === "sas"}>287 <div class="peach-desktop-confirm" style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:8px 18px 16px;text-align:center;">288 <div class="peach-pair-headline">Confirm this code matches on your phone:</div>289 <div290 class="peach-desktop-sas"291 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;"292 >293 {sas()}294 </div>295 <div class="peach-pair-sub" style="max-width:240px;">296 Only continue if this 6-digit code is identical on both screens. This is297 what proves you're talking to your own device.298 </div>299 <div style="display:flex;flex-direction:column;gap:8px;width:100%;max-width:240px;">300 <button class="peach-btn peach-btn-primary" disabled={busy()} onClick={() => void respondConfirm(true)}>301 Confirm302 </button>303 <button class="peach-btn peach-btn-ghost" disabled={busy()} onClick={() => void respondConfirm(false)}>304 Cancel305 </button>306 </div>307 </div>308 </Show>309310 <Show when={phase() === "paired"}>311 <div class="peach-desktop-paired">312 <div class="peach-pair-headline">Paired!</div>313 <div class="peach-pair-sub">314 {pairedDevice()?.deviceName ? `Connected to ${pairedDevice()!.deviceName}` : "Connected to your phone"}315 </div>316 <div class="peach-pair-action">317 <button class="peach-btn peach-btn-primary" onClick={() => props.onBack()}>Done</button>318 </div>319 </div>320 </Show>321322 <Show when={phase() === "error"}>323 <div class="peach-desktop-error">324 <div class="peach-pair-headline">{errorCopy(errorKind()).headline}</div>325 <div class="peach-pair-sub">{errorCopy(errorKind()).sub}</div>326 <div class="peach-pair-action">327 <button class="peach-btn peach-btn-ghost" onClick={() => props.onBack()}>Cancel</button>328 <button class="peach-btn peach-btn-primary" onClick={() => startFlow()}>Try again</button>329 </div>330 </div>331 </Show>332333 <div class="peach-sr-only" aria-live="polite">{liveText()}</div>334 </div>335 );336337 338 339 return pairingScreen();340};