123456789101112131415161718import {19 Component,20 Show,21 createEffect,22 createMemo,23 createSignal,24 For,25 onCleanup,26 onMount,27} from "solid-js";28import { sendMessage } from "../../shared/bridge";29import type {30 DeviceType,31 PairedDevice,32 PairingProgress,33} from "../../shared/sync-types";34import { ChirpPairingFlow, DesktopBird, MobileBird } from "../components/ChirpPairingFlow";35import {36 ChirpPairingFailure,37 runChirpPairing,38 type ChirpPairingDeps,39 type PairingRole,40} from "../chirp-pair";41import { getLocalSubnet } from "../../shared/local-network";42import { isNativePlatform } from "../../shared/native";43import { openCapacitorPairingServer } from "../chirp-pair-capacitor";4445export interface ChirpSyncViewProps {46 isPro: boolean;47 onBack(): void;48 onLearnPro(): void;49}5051const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;5253type Mode = "rest" | "pairing";54555657type PairKind = "chirp" | "desktop" | null;5859606162type DesktopPhase = "enter" | "connecting" | "sas" | "paired" | "error";63type DesktopPairingError =64 | "invalid_code"65 | "no_host_found"66 | "handshake_failed"67 | "sas_mismatch"68 | "vault_locked";6970const DESKTOP_PAIRED_DISMISS_MS = 2_000;7172function desktopErrorCopy(error: DesktopPairingError | "unknown"): { headline: string; sub: string } {73 switch (error) {74 case "invalid_code":75 76 return {77 headline: "Code not found or expired",78 sub: "Check the code on your computer and try again. Codes rotate, so grab a fresh one if it's been a while.",79 };80 case "no_host_found":81 return {82 headline: "Couldn't find your computer",83 sub: "Make sure both devices are on the same Wi-Fi, then run “peach-sync pair” in a terminal to start pairing.",84 };85 case "sas_mismatch":86 return {87 headline: "Codes don't match",88 sub: "For your safety, pairing was stopped. Start over and compare the two codes carefully.",89 };90 case "vault_locked":91 return {92 headline: "Unlock Peach first",93 sub: "Your vault needs to be unlocked before you can pair a device.",94 };95 case "handshake_failed":96 default:97 return {98 headline: "Pairing failed",99 sub: "Something went wrong while connecting. Please try again.",100 };101 }102}103104export const ChirpSyncView: Component<ChirpSyncViewProps> = (props) => {105 const [devices, setDevices] = createSignal<PairedDevice[]>([]);106 const [mode, setMode] = createSignal<Mode>("rest");107 const [pairing, setPairing] = createSignal<PairingProgress>({ phase: "idle" });108 const [error, setError] = createSignal<string | null>(null);109 const [confirmUnpair, setConfirmUnpair] = createSignal<string | null>(null);110 const [syncStatus, setSyncStatus] = createSignal<"idle" | "syncing" | "error">("idle");111 const [syncFlash, setSyncFlash] = createSignal<string | null>(null);112 113 114 115 const [sasCode, setSasCode] = createSignal<string | null>(null);116 const [sasResolve, setSasResolve] = createSignal<((ok: boolean) => void) | null>(null);117118 function resolveSAS(ok: boolean) {119 const r = sasResolve();120 setSasCode(null);121 setSasResolve(null);122 r?.(ok);123 }124125 126 127 128 const [pairKind, setPairKind] = createSignal<PairKind>(null);129 const [desktopPhase, setDesktopPhase] = createSignal<DesktopPhase>("enter");130 const [desktopCode, setDesktopCode] = createSignal("");131 const [desktopSas, setDesktopSas] = createSignal<string | null>(null);132 const [desktopError, setDesktopError] = createSignal<DesktopPairingError | "unknown">("unknown");133 const [desktopDevice, setDesktopDevice] = createSignal<PairedDevice | undefined>(undefined);134 const [desktopBusy, setDesktopBusy] = createSignal(false);135136 const desktopCodeReady = () => /^\d{6}$/.test(desktopCode());137138 function openPairMethodSelect(): void {139 setError(null);140 setPairKind(null);141 setMode("pairing");142 }143144 function startDesktopFlow(): void {145 setDesktopCode("");146 setDesktopSas(null);147 setDesktopError("unknown");148 setDesktopDevice(undefined);149 setDesktopBusy(false);150 setDesktopPhase("enter");151 setPairKind("desktop");152 }153154 function startChirpFlow(): void {155 setPairKind("chirp");156 void handleStartPairing();157 }158159 function onDesktopCodeInput(value: string): void {160 setDesktopCode(value.replace(/\D/g, "").slice(0, 6));161 }162163 async function handleDesktopConnect(): Promise<void> {164 if (!desktopCodeReady() || desktopBusy()) return;165 setDesktopBusy(true);166 setDesktopPhase("connecting");167 try {168 const res = (await sendMessage({169 type: "PAIR_WITH_DESKTOP",170 payload: { code: desktopCode() },171 })) as { ok: boolean; error?: string };172 173 174 if (!res.ok) {175 setDesktopError((res.error as DesktopPairingError) ?? "unknown");176 setDesktopPhase("error");177 }178 } catch {179 setDesktopError("unknown");180 setDesktopPhase("error");181 } finally {182 setDesktopBusy(false);183 }184 }185186 async function respondDesktopConfirm(confirmed: boolean): Promise<void> {187 if (desktopBusy()) return;188 setDesktopBusy(true);189 190 191 if (confirmed) setDesktopPhase("connecting");192 else { setDesktopError("sas_mismatch"); setDesktopPhase("error"); }193 try {194 await sendMessage({ type: "DESKTOP_PAIR_CONFIRM", payload: { confirmed } });195 } finally {196 setDesktopBusy(false);197 }198 }199200 201 createEffect(() => {202 if (desktopPhase() !== "paired") return;203 const t = setTimeout(() => {204 setMode("rest");205 setPairKind(null);206 void refresh();207 }, DESKTOP_PAIRED_DISMISS_MS);208 onCleanup(() => clearTimeout(t));209 });210211 async function handleSyncNow(): Promise<void> {212 setSyncStatus("syncing");213 setSyncFlash(null);214 const res = await sendMessage({215 type: "TRIGGER_SYNC",216 payload: { reason: "manual", force: true },217 }) as { ok: boolean; error?: string };218 219 220 setTimeout(async () => {221 const state = await sendMessage({ type: "GET_SYNC_STATE" }) as {222 ok: boolean;223 data?: { status: "idle" | "syncing" | "error"; entriesReceived?: number; error?: string };224 };225 const s = state.data;226 if (!s) return;227 setSyncStatus(s.status);228 if (s.status === "idle") {229 setSyncFlash(230 (s.entriesReceived ?? 0) > 0231 ? `Synced — ${s.entriesReceived} entries updated`232 : "Vault up to date",233 );234 setTimeout(() => setSyncFlash(null), 3000);235 await refresh();236 } else if (s.status === "error") {237 setSyncFlash(`Sync failed — ${s.error ?? "no mobile host reachable"}`);238 setTimeout(() => setSyncFlash(null), 4000);239 }240 }, 250);241 if (!res.ok) {242 setSyncStatus("error");243 setSyncFlash(`Sync failed — ${res.error ?? "unknown"}`);244 setTimeout(() => setSyncFlash(null), 4000);245 }246 }247248 onMount(() => {249 void refresh();250251 252 253 254 if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {255 const handler = (msg: { type?: string; payload?: unknown }) => {256 if (pairKind() !== "desktop") return;257 if (msg?.type === "DESKTOP_PAIR_SAS") {258 const p = msg.payload as { sas?: string } | undefined;259 if (typeof p?.sas === "string") { setDesktopSas(p.sas); setDesktopPhase("sas"); }260 } else if (msg?.type === "DESKTOP_PAIR_RESULT") {261 const p = msg.payload as262 | { status: "complete"; device: PairedDevice }263 | { status: "error"; error: DesktopPairingError }264 | undefined;265 if (p?.status === "complete") { setDesktopDevice(p.device); setDesktopPhase("paired"); }266 else if (p?.status === "error") { setDesktopError(p.error); setDesktopPhase("error"); }267 }268 };269 chrome.runtime.onMessage.addListener(handler);270 onCleanup(() => chrome.runtime.onMessage.removeListener(handler));271 }272 });273274 async function refresh(): Promise<void> {275 if (!props.isPro) return;276 const res = await sendMessage({ type: "CHIRP_LIST_PAIRED_DEVICES" });277 const data = (res as { ok: boolean; data?: { devices: PairedDevice[] } }).data;278 setDevices(data?.devices ?? []);279 }280281 async function handleStartPairing(): Promise<void> {282 setError(null);283284 285 286 287 288 if (typeof chrome !== "undefined" && chrome.permissions?.request) {289 const granted = await chrome.permissions.request({290 permissions: ["audioCapture" as chrome.runtime.ManifestPermissions],291 });292 if (!granted) {293 setPairing({ phase: "mic_denied" });294 setMode("pairing");295 return;296 }297 }298299 setPairing({ phase: "idle" });300 setMode("pairing");301302 const deps: ChirpPairingDeps = {303 onProgress: (p) => setPairing(p),304 fetchLocalIdentity: async () => {305 const res = await sendMessage({ type: "CHIRP_GET_LOCAL_IDENTITY" });306 const r = res as { ok: boolean; data?: Parameters<ChirpPairingDeps["onProgress"]>[0] & object; error?: string };307 if (!r.ok || !r.data) throw new Error(r.error ?? "identity_unavailable");308 309 return r.data as unknown as Awaited<ReturnType<ChirpPairingDeps["fetchLocalIdentity"]>>;310 },311 completePairing: async (paired) => {312 const res = await sendMessage({313 type: "CHIRP_COMPLETE_PAIRING",314 payload: { paired },315 });316 const r = res as { ok: boolean; error?: string };317 if (!r.ok) throw new Error(r.error ?? "complete_pairing_failed");318 },319 confirmSAS: (sas) =>320 new Promise<boolean>((resolve) => {321 setSasResolve(() => resolve);322 setSasCode(sas);323 }),324 getLocalSubnet,325 openPairingTransport: async (endpoint) => {326 327 328 329 return new Promise((resolve, reject) => {330 let ws: WebSocket;331 try {332 ws = new WebSocket(`ws://${endpoint.ip}:${endpoint.port}/`);333 } catch (err) {334 reject(err);335 return;336 }337 const inbox: Uint8Array[] = [];338 const waiters: Array<(b: Uint8Array) => void> = [];339 let closed = false;340 ws.binaryType = "arraybuffer";341 const timer = setTimeout(() => {342 try { ws.close(); } catch { }343 reject(new Error("pairing_transport_timeout"));344 }, 2_000);345 ws.onopen = () => {346 clearTimeout(timer);347 resolve({348 async send(data: Uint8Array) {349 ws.send(data as Uint8Array<ArrayBuffer>);350 },351 receive(): Promise<Uint8Array> {352 if (closed) return Promise.reject(new Error("closed"));353 const v = inbox.shift();354 if (v) return Promise.resolve(v);355 return new Promise<Uint8Array>((res) => waiters.push(res));356 },357 async close() {358 try { ws.close(); } catch { }359 },360 });361 };362 ws.onmessage = (ev) => {363 if (!(ev.data instanceof ArrayBuffer)) return;364 const bytes = new Uint8Array(ev.data);365 const w = waiters.shift();366 if (w) w(bytes); else inbox.push(bytes);367 };368 ws.onerror = () => {369 clearTimeout(timer);370 reject(new Error("pairing_transport_error"));371 };372 ws.onclose = () => {373 closed = true;374 while (waiters.length > 0) waiters.shift()!(new Uint8Array(0));375 };376 });377 },378 };379380 const role: PairingRole = isNativePlatform() ? "initiator" : "responder";381 if (role === "initiator") deps.openPairingServer = openCapacitorPairingServer;382383 try {384 await runChirpPairing(deps, role);385 await refresh();386 } catch (err) {387 if (err instanceof ChirpPairingFailure) {388 389 390 391 return;392 }393 setError((err as Error).message);394 setMode("rest");395 }396 }397398 async function handleUnpair(deviceId: string): Promise<void> {399 await sendMessage({ type: "CHIRP_UNPAIR_DEVICE", payload: { deviceId } });400 setConfirmUnpair(null);401 await refresh();402 }403404 function relativeTime(when: number | undefined): string {405 if (!when) return "Not synced yet";406 const elapsed = Date.now() - when;407 if (elapsed < 60_000) return "Just now";408 const minutes = Math.floor(elapsed / 60_000);409 if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;410 const hours = Math.floor(minutes / 60);411 if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;412 const days = Math.floor(hours / 24);413 if (days < 7) return `${days} day${days === 1 ? "" : "s"} ago`;414 return "Not seen recently";415 }416417 function isStale(d: PairedDevice): boolean {418 if (d.lastSyncedAt === undefined) return false;419 return Date.now() - d.lastSyncedAt > STALE_THRESHOLD_MS;420 }421422 423424 const proGateView = () => (425 <div class="peach-chirp-settings peach-chirp-settings--gated">426 <header class="peach-chirp-settings-header">427 <button class="peach-icon-btn" aria-label="Back" onClick={() => props.onBack()}>428 <svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">429 <path d="M11 4 L5 9 L11 14" />430 </svg>431 </button>432 <h1 class="peach-chirp-settings-title">Sync</h1>433 </header>434 <div class="peach-chirp-pro-card">435 <div class="peach-chirp-pro-stage" aria-hidden="true">436 <svg class="peach-chirp-pro-chirp" viewBox="0 0 200 120" fill="none" stroke="currentColor"437 stroke-linecap="round" stroke-linejoin="round">438 <path d="M 124 36 Q 150 30 168 44" stroke-width="1.6" opacity="0.9" />439 <path d="M 128 52 Q 162 50 184 64" stroke-width="1.3" opacity="0.55" />440 <path d="M 124 68 Q 158 76 178 92" stroke-width="1.1" opacity="0.32" />441 </svg>442 <div class="peach-chirp-pro-bird-slot">443 <MobileBird pose="idle" />444 </div>445 </div>446 <h2 class="peach-chirp-pro-headline">A little bird that carries your vault</h2>447 <p class="peach-chirp-pro-body">448 Pairs over an inaudible chirp. Syncs locally across your devices.449 No codes, no servers, no cloud.450 </p>451 <button class="peach-btn peach-btn-primary peach-chirp-pro-cta" onClick={() => props.onLearnPro()}>452 Unlock with Peach Pro453 </button>454 <p class="peach-chirp-pro-foot">Peach Pro · one-time purchase</p>455 </div>456 </div>457 );458459 const restView = () => (460 <div class="peach-chirp-pair" data-phase="idle" role="region" aria-label="Sync">461 <header class="peach-chirp-head">462 <button class="peach-chirp-back" aria-label="Back" onClick={() => props.onBack()}>463 <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">464 <path d="M10 3 L5 8 L10 13" />465 </svg>466 </button>467 <div class="peach-chirp-title">Sync</div>468 <div class="peach-chirp-head-spacer" />469 </header>470471 <div class="peach-chirp-chip-row">472 <div class="peach-chirp-chip" data-phase="idle">473 <span class="peach-chirp-chip-dot" />474 <span class="peach-chirp-chip-text">ready</span>475 </div>476 </div>477478 <Show when={error()}>479 <div class="peach-chirp-settings-error" role="alert">{error()}</div>480 </Show>481482 <div class="peach-chirp-stage act-idle" aria-hidden="true">483 <div class="peach-chirp-bird-slot peach-chirp-bird-slot-desktop">484 <DesktopBird pose="idle" />485 </div>486 <div class="peach-chirp-bird-slot peach-chirp-bird-slot-mobile">487 <MobileBird pose="hidden" />488 </div>489 </div>490491 <div class="peach-chirp-copy-host">492 <div class="peach-chirp-copy peach-chirp-copy-rest">493 <div class="peach-chirp-headline">494 {devices().length === 0 ? "Pair a device" : "Sync is ready"}495 </div>496 <div class="peach-chirp-sub">497 {devices().length === 0498 ? "Sync over your local network. No cloud, no account, no servers."499 : "Pair another device, or manage the ones already connected below."}500 </div>501 </div>502 </div>503504 <Show when={devices().length > 0}>505 <section class="peach-chirp-paired-section">506 <h2 class="peach-chirp-paired-heading">Paired devices</h2>507 <ul class="peach-chirp-device-list">508 <For each={devices()}>509 {(d) => (510 <li class="peach-chirp-device-row" data-stale={isStale(d) ? "true" : "false"}>511 <DeviceTypeIcon type={d.deviceType ?? "extension"} />512 <div class="peach-chirp-device-info">513 <span class="peach-chirp-device-name">{d.deviceName ?? "Unnamed device"}</span>514 <span class="peach-chirp-device-meta">515 {relativeTime(d.lastSyncedAt)}516 <Show when={d.trustSource === "vouched"}>517 <span class="peach-chirp-device-badge">Added automatically</span>518 </Show>519 <Show when={isStale(d)}>520 <span class="peach-chirp-device-stale">Not seen recently</span>521 </Show>522 </span>523 </div>524 <Show525 when={confirmUnpair() !== d.deviceId}526 fallback={527 <div class="peach-chirp-unpair-confirm">528 <button class="peach-btn peach-btn-ghost peach-btn-sm" onClick={() => setConfirmUnpair(null)}>529 Cancel530 </button>531 <button class="peach-btn peach-btn-destructive peach-btn-sm" onClick={() => void handleUnpair(d.deviceId)}>532 Remove533 </button>534 </div>535 }536 >537 <button538 class="peach-icon-btn peach-chirp-device-remove"539 aria-label={`Unpair ${d.deviceName}`}540 onClick={() => setConfirmUnpair(d.deviceId)}541 >542 <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">543 <path d="M3 3 L11 11 M11 3 L3 11" />544 </svg>545 </button>546 </Show>547 </li>548 )}549 </For>550 </ul>551 </section>552 </Show>553554 <Show when={devices().length > 0}>555 <div class="peach-chirp-sync-now-row">556 <button557 class="peach-btn peach-btn-ghost peach-btn-sm peach-chirp-sync-now"558 disabled={syncStatus() === "syncing"}559 onClick={() => void handleSyncNow()}560 >561 {syncStatus() === "syncing" ? "Syncing…" : "Sync now"}562 </button>563 <Show when={syncFlash()}>564 <span class="peach-chirp-sync-flash" aria-live="polite">565 {syncFlash()}566 </span>567 </Show>568 </div>569 </Show>570571 <div class="peach-chirp-action">572 <button class="peach-chirp-pair-start" onClick={() => openPairMethodSelect()}>573 <span>{devices().length === 0 ? "Pair a device" : "Pair another device"}</span>574 </button>575 </div>576577 </div>578 );579580 const backArrow = (581 <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">582 <path d="M10 3 L5 8 L10 13" />583 </svg>584 );585586 587 588 const desktopBirdStage = (active: boolean) => (589 <div class={`peach-chirp-stage ${active ? "act-handshake" : "act-listening"}`} aria-hidden="true">590 <div class="peach-chirp-bird-slot peach-chirp-bird-slot-desktop">591 <DesktopBird pose="chirping" />592 </div>593 <div class="peach-chirp-bird-slot peach-chirp-bird-slot-mobile">594 <MobileBird pose="attentive" />595 </div>596 </div>597 );598599 function desktopLiveText(): string {600 switch (desktopPhase()) {601 case "enter": return "Enter the pairing code from your computer";602 case "connecting": return "Searching for the helper";603 case "sas": return "Confirm the code matches on your computer";604 case "paired": return "Paired";605 case "error": return desktopErrorCopy(desktopError()).headline;606 }607 }608609 610 611 const methodSelectView = () => (612 <div class="peach-chirp-pair peach-desktop-pair" data-phase="method" role="region" aria-label="Choose a pairing method">613 <header class="peach-chirp-head">614 <button class="peach-chirp-back" aria-label="Back" onClick={() => setMode("rest")}>{backArrow}</button>615 <div class="peach-chirp-title">Pair a device</div>616 <div class="peach-chirp-head-spacer" />617 </header>618 <div class="peach-chirp-copy-host">619 <div class="peach-chirp-copy">620 <div class="peach-chirp-headline">How do you want to pair?</div>621 </div>622 </div>623 <div class="peach-pair-method-list">624 <button class="peach-pair-method" onClick={() => startChirpFlow()}>625 <span class="peach-pair-method-icon" aria-hidden="true">626 <svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">627 <path d="M3 8 L6 8 L10.5 4 L10.5 16 L6 12 L3 12 Z" />628 <path d="M14 7.5 Q15.6 10 14 12.5" />629 <path d="M16.4 5.5 Q19 10 16.4 14.5" opacity="0.5" />630 </svg>631 </span>632 <span class="peach-pair-method-text">633 <span class="peach-pair-method-title">Same room</span>634 <span class="peach-pair-method-sub">Chirp — plays an inaudible sound</span>635 </span>636 </button>637 <button class="peach-pair-method" onClick={() => startDesktopFlow()}>638 <span class="peach-pair-method-icon" aria-hidden="true">639 <svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">640 <rect x="2" y="4" width="16" height="10" rx="1.4" />641 <path d="M7 17 L13 17" />642 <path d="M9 14 L9 17 M11 14 L11 17" />643 </svg>644 </span>645 <span class="peach-pair-method-text">646 <span class="peach-pair-method-title">Desktop helper</span>647 <span class="peach-pair-method-sub">Enter the code shown by peach-sync</span>648 </span>649 </button>650 </div>651 <div class="peach-chirp-action">652 <button class="peach-btn peach-btn-ghost" onClick={() => setMode("rest")}>Cancel</button>653 </div>654 </div>655 );656657 658 const desktopView = () => (659 <div class="peach-chirp-pair peach-desktop-pair" data-phase={desktopPhase()} role="dialog" aria-label="Pair a desktop helper">660 <header class="peach-chirp-head">661 <button class="peach-chirp-back" aria-label="Back" onClick={() => setPairKind(null)}>{backArrow}</button>662 <div class="peach-chirp-title">Desktop Helper</div>663 <div class="peach-chirp-head-spacer" />664 </header>665666 {desktopBirdStage(desktopPhase() !== "enter")}667668 <Show when={desktopPhase() === "enter"}>669 <div class="peach-desktop-enter">670 <div class="peach-chirp-headline">Enter the code from your computer</div>671 <div class="peach-chirp-sub">672 Open the Peach helper on your computer and type the 6-digit code it shows.673 </div>674 <label class="peach-label" for="desktop-pair-code">pairing code</label>675 <input676 id="desktop-pair-code"677 class="peach-underline-input peach-unlock-input peach-code-input"678 type="text"679 inputmode="numeric"680 autocomplete="one-time-code"681 maxlength={6}682 placeholder="000000"683 aria-label="6-digit pairing code"684 value={desktopCode()}685 ref={(el) => setTimeout(() => el.focus(), 80)}686 onInput={(e) => onDesktopCodeInput((e.target as HTMLInputElement).value)}687 onKeyDown={(e) => { if (e.key === "Enter") void handleDesktopConnect(); }}688 />689 <div class="peach-chirp-action">690 <button class="peach-btn peach-btn-ghost" onClick={() => setPairKind(null)}>Cancel</button>691 <button692 class="peach-btn peach-btn-primary peach-desktop-connect"693 disabled={!desktopCodeReady() || desktopBusy()}694 onClick={() => void handleDesktopConnect()}695 >696 Connect697 </button>698 </div>699 </div>700 </Show>701702 <Show when={desktopPhase() === "connecting"}>703 <div class="peach-desktop-searching">704 <div class="peach-chirp-headline">Searching for helper…</div>705 <div class="peach-chirp-sub">Connecting to the Peach helper on your computer.</div>706 </div>707 </Show>708709 <Show when={desktopPhase() === "sas"}>710 <div class="peach-desktop-confirm" style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:8px 18px 16px;text-align:center;">711 <div class="peach-chirp-headline">Confirm this code matches on your computer:</div>712 <div713 class="peach-desktop-sas"714 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;"715 >716 {desktopSas()}717 </div>718 <div class="peach-chirp-sub" style="max-width:240px;">719 Only continue if this 6-digit code is identical on both screens.720 </div>721 <div style="display:flex;flex-direction:column;gap:8px;width:100%;max-width:240px;">722 <button class="peach-btn peach-btn-primary" disabled={desktopBusy()} onClick={() => void respondDesktopConfirm(true)}>723 Confirm724 </button>725 <button class="peach-btn peach-btn-ghost" disabled={desktopBusy()} onClick={() => void respondDesktopConfirm(false)}>726 Cancel727 </button>728 </div>729 </div>730 </Show>731732 <Show when={desktopPhase() === "paired"}>733 <div class="peach-desktop-paired">734 <div class="peach-chirp-headline">Paired!</div>735 <div class="peach-chirp-sub">736 {desktopDevice()?.deviceName ? `Connected to ${desktopDevice()!.deviceName}` : "Connected to your computer"}737 </div>738 <div class="peach-chirp-action">739 <button class="peach-btn peach-btn-primary" onClick={() => { setMode("rest"); setPairKind(null); void refresh(); }}>Done</button>740 </div>741 </div>742 </Show>743744 <Show when={desktopPhase() === "error"}>745 <div class="peach-desktop-error">746 <div class="peach-chirp-headline">{desktopErrorCopy(desktopError()).headline}</div>747 <div class="peach-chirp-sub">{desktopErrorCopy(desktopError()).sub}</div>748 <div class="peach-chirp-action">749 <button class="peach-btn peach-btn-ghost" onClick={() => setMode("rest")}>Cancel</button>750 <button class="peach-btn peach-btn-primary" onClick={() => startDesktopFlow()}>Try again</button>751 </div>752 </div>753 </Show>754755 <div class="peach-sr-only" aria-live="polite">{desktopLiveText()}</div>756 </div>757 );758759 const chirpPairingView = () => (760 <Show761 when={sasCode()}762 fallback={763 <ChirpPairingFlow764 progress={pairing()}765 onCancel={() => setMode("rest")}766 onRetry={() => void handleStartPairing()}767 onDismiss={() => {768 setMode("rest");769 void refresh();770 }}771 />772 }773 >774 {775}776 <div777 class="chirp-sas-verify"778 style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:24px 18px;text-align:center;"779 >780 <div style="font-size:14px;font-weight:600;color:var(--peach-text);">Verify it's really your device</div>781 <div style="font-size:11.5px;line-height:1.45;color:var(--peach-text-muted);max-width:240px;">782 Confirm this code matches the one shown on your other device before pairing.783 </div>784 <div 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;">785 {sasCode()}786 </div>787 <div style="display:flex;flex-direction:column;gap:8px;width:100%;max-width:240px;">788 <button789 class="peach-btn peach-btn--accent peach-btn--md"790 type="button"791 onClick={() => resolveSAS(true)}792 >793 codes match — pair794 </button>795 <button796 class="peach-btn peach-btn--ghost peach-btn--md"797 type="button"798 onClick={() => resolveSAS(false)}799 >800 they don't match801 </button>802 </div>803 </div>804 </Show>805 );806807 808 809 const pairingView = () => (810 <Show when={pairKind() !== null} fallback={methodSelectView()}>811 <Show when={pairKind() === "desktop"} fallback={chirpPairingView()}>812 {desktopView()}813 </Show>814 </Show>815 );816817 const currentMode = createMemo(() => {818 if (!props.isPro) return "gated";819 if (mode() === "pairing") return "pairing";820 return "rest";821 });822823 return (824 <Show825 when={currentMode() === "gated"}826 fallback={827 <Show when={currentMode() === "pairing"} fallback={restView()}>828 {pairingView()}829 </Show>830 }831 >832 {proGateView()}833 </Show>834 );835};836837const DeviceTypeIcon: Component<{ type: DeviceType }> = (props) => {838 return (839 <div class="peach-chirp-device-icon" data-type={props.type}>840 <Show when={props.type === "android" || props.type === "ios"}>841 <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">842 <rect x="6" y="2" width="8" height="16" rx="1.5" />843 <circle cx="10" cy="15.4" r="0.6" fill="currentColor" />844 </svg>845 </Show>846 <Show when={props.type === "extension" || props.type === "desktop"}>847 <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">848 <rect x="2" y="4" width="16" height="10" rx="1.4" />849 <path d="M7 17 L13 17" />850 <path d="M9 14 L9 17 M11 14 L11 17" />851 </svg>852 </Show>853 </div>854 );855};