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 const codeReady = () => parsePairingCode(code()) !== null;99100 function startFlow(): void {101 setCode("");102 setSas(null);103 setErrorKind("unknown");104 setPairedDevice(undefined);105 setBusy(false);106 setPhase("enter");107 }108109 function onCodeInput(value: string): void {110 const cleaned = value.toUpperCase().replace(/[^0-9A-Z]/g, "").slice(0, 32);111 setCode(formatPairingCode(cleaned));112 }113114 async function handleConnect(): Promise<void> {115 if (!codeReady() || busy()) return;116 setBusy(true);117 setPhase("connecting");118 try {119 const res = (await sendMessage({120 type: "PAIR_WITH_CODE",121 payload: { code: code() },122 })) as { ok: boolean; error?: string };123 124 125 if (!res.ok) {126 setErrorKind((res.error as CodePairingError) ?? "unknown");127 setPhase("error");128 }129 } catch {130 setErrorKind("unknown");131 setPhase("error");132 } finally {133 setBusy(false);134 }135 }136137 async function respondConfirm(confirmed: boolean): Promise<void> {138 if (busy()) return;139 setBusy(true);140 141 142 if (confirmed) setPhase("connecting");143 else { setErrorKind("sas_mismatch"); setPhase("error"); }144 try {145 await sendMessage({ type: "CODE_PAIR_CONFIRM", payload: { confirmed } });146 } finally {147 setBusy(false);148 }149 }150151 152 153 createEffect(() => {154 if (phase() !== "paired") return;155 const t = setTimeout(() => props.onBack(), PAIRED_DISMISS_MS);156 onCleanup(() => clearTimeout(t));157 });158159 onMount(() => {160 161 162 if (props.initialPair === "code") startFlow();163 else props.onBack();164165 166 167 168 if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {169 const handler = (msg: { type?: string; payload?: unknown }) => {170 if (msg?.type === "CODE_PAIR_SAS") {171 const p = msg.payload as { sas?: string } | undefined;172 if (typeof p?.sas === "string") { setSas(p.sas); setPhase("sas"); }173 } else if (msg?.type === "CODE_PAIR_RESULT") {174 const p = msg.payload as175 | { status: "complete"; device: PairedDevice }176 | { status: "error"; error: CodePairingError }177 | undefined;178 if (p?.status === "complete") { setPairedDevice(p.device); setPhase("paired"); }179 else if (p?.status === "error") { setErrorKind(p.error); setPhase("error"); }180 }181 };182 chrome.runtime.onMessage.addListener(handler);183 onCleanup(() => chrome.runtime.onMessage.removeListener(handler));184 }185 186 187 188 onCleanup(() => {189 if (phase() === "sas") {190 void sendMessage({ type: "CODE_PAIR_CONFIRM", payload: { confirmed: false } });191 }192 });193 });194195 196197 const backArrow = (198 <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">199 <path d="M10 3 L5 8 L10 13" />200 </svg>201 );202203 204 const birdStage = (active: boolean) => (205 <div class={`peach-pair-stage ${active ? "act-handshake" : "act-listening"}`} aria-hidden="true">206 <div class="peach-pair-bird-slot peach-pair-bird-slot-desktop">207 <DesktopBird pose="chirping" />208 </div>209 <div class="peach-pair-bird-slot peach-pair-bird-slot-mobile">210 <MobileBird pose="attentive" />211 </div>212 </div>213 );214215 function liveText(): string {216 switch (phase()) {217 case "enter": return "Enter the pairing code from your phone";218 case "connecting": return "Connecting to your phone";219 case "sas": return "Confirm the code matches on your phone";220 case "paired": return "Paired";221 case "error": return errorCopy(errorKind()).headline;222 }223 }224225 226 const pairingScreen = () => (227 <div class="peach-pair-pair peach-desktop-pair" data-phase={phase()} role="dialog" aria-label="Pair a device with a code">228 <header class="peach-pair-head">229 <button class="peach-pair-back" aria-label="Back" onClick={() => props.onBack()}>{backArrow}</button>230 <div class="peach-pair-title">Enter a code</div>231 <div class="peach-pair-head-spacer" />232 </header>233234 {birdStage(phase() !== "enter")}235236 <Show when={phase() === "enter"}>237 <div class="peach-desktop-enter">238 <div class="peach-pair-headline">Enter the code from your phone</div>239 <div class="peach-pair-sub">240 Open Peach on your phone, tap “Pair a device”, and type the code it shows.241 Both devices need to be on the same Wi-Fi.242 </div>243 <label class="peach-label" for="pair-code">pairing code</label>244 <input245 id="pair-code"246 class="peach-underline-input peach-unlock-input peach-code-input"247 type="text"248 autocapitalize="characters"249 autocomplete="one-time-code"250 spellcheck={false}251 maxlength={32}252 placeholder="A74H-48J3-KI32-OEP4"253 aria-label="Pairing code"254 value={code()}255 ref={(el) => setTimeout(() => el.focus(), 80)}256 onInput={(e) => onCodeInput((e.target as HTMLInputElement).value)}257 onKeyDown={(e) => { if (e.key === "Enter") void handleConnect(); }}258 />259 <div class="peach-pair-action">260 <button class="peach-btn peach-btn-ghost" onClick={() => props.onBack()}>Cancel</button>261 <button262 class="peach-btn peach-btn-primary peach-desktop-connect"263 disabled={!codeReady() || busy()}264 onClick={() => void handleConnect()}265 >266 Connect267 </button>268 </div>269 </div>270 </Show>271272 <Show when={phase() === "connecting"}>273 <div class="peach-desktop-searching">274 <div class="peach-pair-headline">Connecting…</div>275 <div class="peach-pair-sub">Reaching your phone over the local network.</div>276 </div>277 </Show>278279 <Show when={phase() === "sas"}>280 <div class="peach-desktop-confirm" style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:8px 18px 16px;text-align:center;">281 <div class="peach-pair-headline">Confirm this code matches on your phone:</div>282 <div283 class="peach-desktop-sas"284 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;"285 >286 {sas()}287 </div>288 <div class="peach-pair-sub" style="max-width:240px;">289 Only continue if this 6-digit code is identical on both screens. This is290 what proves you're talking to your own device.291 </div>292 <div style="display:flex;flex-direction:column;gap:8px;width:100%;max-width:240px;">293 <button class="peach-btn peach-btn-primary" disabled={busy()} onClick={() => void respondConfirm(true)}>294 Confirm295 </button>296 <button class="peach-btn peach-btn-ghost" disabled={busy()} onClick={() => void respondConfirm(false)}>297 Cancel298 </button>299 </div>300 </div>301 </Show>302303 <Show when={phase() === "paired"}>304 <div class="peach-desktop-paired">305 <div class="peach-pair-headline">Paired!</div>306 <div class="peach-pair-sub">307 {pairedDevice()?.deviceName ? `Connected to ${pairedDevice()!.deviceName}` : "Connected to your phone"}308 </div>309 <div class="peach-pair-action">310 <button class="peach-btn peach-btn-primary" onClick={() => props.onBack()}>Done</button>311 </div>312 </div>313 </Show>314315 <Show when={phase() === "error"}>316 <div class="peach-desktop-error">317 <div class="peach-pair-headline">{errorCopy(errorKind()).headline}</div>318 <div class="peach-pair-sub">{errorCopy(errorKind()).sub}</div>319 <div class="peach-pair-action">320 <button class="peach-btn peach-btn-ghost" onClick={() => props.onBack()}>Cancel</button>321 <button class="peach-btn peach-btn-primary" onClick={() => startFlow()}>Try again</button>322 </div>323 </div>324 </Show>325326 <div class="peach-sr-only" aria-live="polite">{liveText()}</div>327 </div>328 );329330 331 332 return pairingScreen();333};