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 50515253 initialPair?: "chirp" | "desktop" | null;54}5556const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;5758type Mode = "rest" | "pairing";59606162type PairKind = "chirp" | "desktop" | null;6364656667type DesktopPhase = "enter" | "connecting" | "sas" | "paired" | "error";68type DesktopPairingError =69 | "invalid_code"70 | "no_host_found"71 | "handshake_failed"72 | "sas_mismatch"73 | "vault_locked";7475const DESKTOP_PAIRED_DISMISS_MS = 2_000;7677function desktopErrorCopy(error: DesktopPairingError | "unknown"): { headline: string; sub: string } {78 switch (error) {79 case "invalid_code":80 81 return {82 headline: "Code not found or expired",83 sub: "Check the code on your computer and try again. Codes rotate, so grab a fresh one if it's been a while.",84 };85 case "no_host_found":86 return {87 headline: "Couldn't find your computer",88 sub: "Make sure both devices are on the same Wi-Fi, then run “peach-sync pair” in a terminal to start pairing.",89 };90 case "sas_mismatch":91 return {92 headline: "Codes don't match",93 sub: "For your safety, pairing was stopped. Start over and compare the two codes carefully.",94 };95 case "vault_locked":96 return {97 headline: "Unlock Peach first",98 sub: "Your vault needs to be unlocked before you can pair a device.",99 };100 case "handshake_failed":101 default:102 return {103 headline: "Pairing failed",104 sub: "Something went wrong while connecting. Please try again.",105 };106 }107}108109export const ChirpSyncView: Component<ChirpSyncViewProps> = (props) => {110 const [devices, setDevices] = createSignal<PairedDevice[]>([]);111 const [mode, setMode] = createSignal<Mode>("rest");112 const [pairing, setPairing] = createSignal<PairingProgress>({ phase: "idle" });113 const [error, setError] = createSignal<string | null>(null);114 const [confirmUnpair, setConfirmUnpair] = createSignal<string | null>(null);115 const [syncStatus, setSyncStatus] = createSignal<"idle" | "syncing" | "error">("idle");116 const [syncFlash, setSyncFlash] = createSignal<string | null>(null);117 118 119 120 const [sasCode, setSasCode] = createSignal<string | null>(null);121 const [sasResolve, setSasResolve] = createSignal<((ok: boolean) => void) | null>(null);122123 function resolveSAS(ok: boolean) {124 const r = sasResolve();125 setSasCode(null);126 setSasResolve(null);127 r?.(ok);128 }129130 131 132 133 const [pairKind, setPairKind] = createSignal<PairKind>(null);134 const [desktopPhase, setDesktopPhase] = createSignal<DesktopPhase>("enter");135 const [desktopCode, setDesktopCode] = createSignal("");136 const [desktopSas, setDesktopSas] = createSignal<string | null>(null);137 const [desktopError, setDesktopError] = createSignal<DesktopPairingError | "unknown">("unknown");138 const [desktopDevice, setDesktopDevice] = createSignal<PairedDevice | undefined>(undefined);139 const [desktopBusy, setDesktopBusy] = createSignal(false);140141 const desktopCodeReady = () => /^\d{6}$/.test(desktopCode());142143 144 function pairViaChirp(): void {145 setError(null);146 setMode("pairing");147 startChirpFlow();148 }149150 function pairViaDesktop(): void {151 setError(null);152 setMode("pairing");153 startDesktopFlow();154 }155156 function startDesktopFlow(): void {157 setDesktopCode("");158 setDesktopSas(null);159 setDesktopError("unknown");160 setDesktopDevice(undefined);161 setDesktopBusy(false);162 setDesktopPhase("enter");163 setPairKind("desktop");164 }165166 function startChirpFlow(): void {167 setPairKind("chirp");168 void handleStartPairing();169 }170171 function onDesktopCodeInput(value: string): void {172 setDesktopCode(value.replace(/\D/g, "").slice(0, 6));173 }174175 async function handleDesktopConnect(): Promise<void> {176 if (!desktopCodeReady() || desktopBusy()) return;177 setDesktopBusy(true);178 setDesktopPhase("connecting");179 try {180 const res = (await sendMessage({181 type: "PAIR_WITH_DESKTOP",182 payload: { code: desktopCode() },183 })) as { ok: boolean; error?: string };184 185 186 if (!res.ok) {187 setDesktopError((res.error as DesktopPairingError) ?? "unknown");188 setDesktopPhase("error");189 }190 } catch {191 setDesktopError("unknown");192 setDesktopPhase("error");193 } finally {194 setDesktopBusy(false);195 }196 }197198 async function respondDesktopConfirm(confirmed: boolean): Promise<void> {199 if (desktopBusy()) return;200 setDesktopBusy(true);201 202 203 if (confirmed) setDesktopPhase("connecting");204 else { setDesktopError("sas_mismatch"); setDesktopPhase("error"); }205 try {206 await sendMessage({ type: "DESKTOP_PAIR_CONFIRM", payload: { confirmed } });207 } finally {208 setDesktopBusy(false);209 }210 }211212 213 createEffect(() => {214 if (desktopPhase() !== "paired") return;215 const t = setTimeout(() => {216 setMode("rest");217 setPairKind(null);218 void refresh();219 }, DESKTOP_PAIRED_DISMISS_MS);220 onCleanup(() => clearTimeout(t));221 });222223 async function handleSyncNow(): Promise<void> {224 setSyncStatus("syncing");225 setSyncFlash(null);226 const res = await sendMessage({227 type: "TRIGGER_SYNC",228 payload: { reason: "manual", force: true },229 }) as { ok: boolean; error?: string };230 231 232 setTimeout(async () => {233 const state = await sendMessage({ type: "GET_SYNC_STATE" }) as {234 ok: boolean;235 data?: { status: "idle" | "syncing" | "error"; entriesReceived?: number; error?: string };236 };237 const s = state.data;238 if (!s) return;239 setSyncStatus(s.status);240 if (s.status === "idle") {241 setSyncFlash(242 (s.entriesReceived ?? 0) > 0243 ? `Synced — ${s.entriesReceived} entries updated`244 : "Vault up to date",245 );246 setTimeout(() => setSyncFlash(null), 3000);247 await refresh();248 } else if (s.status === "error") {249 setSyncFlash(`Sync failed — ${s.error ?? "no mobile host reachable"}`);250 setTimeout(() => setSyncFlash(null), 4000);251 }252 }, 250);253 if (!res.ok) {254 setSyncStatus("error");255 setSyncFlash(`Sync failed — ${res.error ?? "unknown"}`);256 setTimeout(() => setSyncFlash(null), 4000);257 }258 }259260 onMount(() => {261 void refresh();262263 264 if (props.initialPair === "chirp") pairViaChirp();265 else if (props.initialPair === "desktop") pairViaDesktop();266267 268 269 270 if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {271 const handler = (msg: { type?: string; payload?: unknown }) => {272 if (pairKind() !== "desktop") return;273 if (msg?.type === "DESKTOP_PAIR_SAS") {274 const p = msg.payload as { sas?: string } | undefined;275 if (typeof p?.sas === "string") { setDesktopSas(p.sas); setDesktopPhase("sas"); }276 } else if (msg?.type === "DESKTOP_PAIR_RESULT") {277 const p = msg.payload as278 | { status: "complete"; device: PairedDevice }279 | { status: "error"; error: DesktopPairingError }280 | undefined;281 if (p?.status === "complete") { setDesktopDevice(p.device); setDesktopPhase("paired"); }282 else if (p?.status === "error") { setDesktopError(p.error); setDesktopPhase("error"); }283 }284 };285 chrome.runtime.onMessage.addListener(handler);286 onCleanup(() => chrome.runtime.onMessage.removeListener(handler));287 }288 });289290 async function refresh(): Promise<void> {291 if (!props.isPro) return;292 const res = await sendMessage({ type: "CHIRP_LIST_PAIRED_DEVICES" });293 const data = (res as { ok: boolean; data?: { devices: PairedDevice[] } }).data;294 setDevices(data?.devices ?? []);295 }296297 async function handleStartPairing(): Promise<void> {298 setError(null);299300 301 302 303 304 if (typeof chrome !== "undefined" && chrome.permissions?.request) {305 const granted = await chrome.permissions.request({306 permissions: ["audioCapture" as chrome.runtime.ManifestPermissions],307 });308 if (!granted) {309 setPairing({ phase: "mic_denied" });310 setMode("pairing");311 return;312 }313 }314315 setPairing({ phase: "idle" });316 setMode("pairing");317318 const deps: ChirpPairingDeps = {319 onProgress: (p) => setPairing(p),320 fetchLocalIdentity: async () => {321 const res = await sendMessage({ type: "CHIRP_GET_LOCAL_IDENTITY" });322 const r = res as { ok: boolean; data?: Parameters<ChirpPairingDeps["onProgress"]>[0] & object; error?: string };323 if (!r.ok || !r.data) throw new Error(r.error ?? "identity_unavailable");324 325 return r.data as unknown as Awaited<ReturnType<ChirpPairingDeps["fetchLocalIdentity"]>>;326 },327 completePairing: async (paired) => {328 const res = await sendMessage({329 type: "CHIRP_COMPLETE_PAIRING",330 payload: { paired },331 });332 const r = res as { ok: boolean; error?: string };333 if (!r.ok) throw new Error(r.error ?? "complete_pairing_failed");334 },335 confirmSAS: (sas) =>336 new Promise<boolean>((resolve) => {337 setSasResolve(() => resolve);338 setSasCode(sas);339 }),340 getLocalSubnet,341 openPairingTransport: async (endpoint) => {342 343 344 345 return new Promise((resolve, reject) => {346 let ws: WebSocket;347 try {348 ws = new WebSocket(`ws://${endpoint.ip}:${endpoint.port}/`);349 } catch (err) {350 reject(err);351 return;352 }353 const inbox: Uint8Array[] = [];354 const waiters: Array<(b: Uint8Array) => void> = [];355 let closed = false;356 ws.binaryType = "arraybuffer";357 const timer = setTimeout(() => {358 try { ws.close(); } catch { }359 reject(new Error("pairing_transport_timeout"));360 }, 2_000);361 ws.onopen = () => {362 clearTimeout(timer);363 resolve({364 async send(data: Uint8Array) {365 ws.send(data as Uint8Array<ArrayBuffer>);366 },367 receive(): Promise<Uint8Array> {368 if (closed) return Promise.reject(new Error("closed"));369 const v = inbox.shift();370 if (v) return Promise.resolve(v);371 return new Promise<Uint8Array>((res) => waiters.push(res));372 },373 async close() {374 try { ws.close(); } catch { }375 },376 });377 };378 ws.onmessage = (ev) => {379 if (!(ev.data instanceof ArrayBuffer)) return;380 const bytes = new Uint8Array(ev.data);381 const w = waiters.shift();382 if (w) w(bytes); else inbox.push(bytes);383 };384 ws.onerror = () => {385 clearTimeout(timer);386 reject(new Error("pairing_transport_error"));387 };388 ws.onclose = () => {389 closed = true;390 while (waiters.length > 0) waiters.shift()!(new Uint8Array(0));391 };392 });393 },394 };395396 const role: PairingRole = isNativePlatform() ? "initiator" : "responder";397 if (role === "initiator") deps.openPairingServer = openCapacitorPairingServer;398399 try {400 await runChirpPairing(deps, role);401 await refresh();402 } catch (err) {403 if (err instanceof ChirpPairingFailure) {404 405 406 407 return;408 }409 setError((err as Error).message);410 setMode("rest");411 }412 }413414 async function handleUnpair(deviceId: string): Promise<void> {415 await sendMessage({ type: "CHIRP_UNPAIR_DEVICE", payload: { deviceId } });416 setConfirmUnpair(null);417 await refresh();418 }419420 function relativeTime(when: number | undefined): string {421 if (!when) return "Not synced yet";422 const elapsed = Date.now() - when;423 if (elapsed < 60_000) return "Just now";424 const minutes = Math.floor(elapsed / 60_000);425 if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;426 const hours = Math.floor(minutes / 60);427 if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;428 const days = Math.floor(hours / 24);429 if (days < 7) return `${days} day${days === 1 ? "" : "s"} ago`;430 return "Not seen recently";431 }432433 function isStale(d: PairedDevice): boolean {434 if (d.lastSyncedAt === undefined) return false;435 return Date.now() - d.lastSyncedAt > STALE_THRESHOLD_MS;436 }437438 439440 const proGateView = () => (441 <div class="peach-chirp-settings peach-chirp-settings--gated">442 <header class="peach-chirp-settings-header">443 <button class="peach-icon-btn" aria-label="Back" onClick={() => props.onBack()}>444 <svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">445 <path d="M11 4 L5 9 L11 14" />446 </svg>447 </button>448 <h1 class="peach-chirp-settings-title">Sync</h1>449 </header>450 <div class="peach-chirp-pro-card">451 <div class="peach-chirp-pro-stage" aria-hidden="true">452 <svg class="peach-chirp-pro-chirp" viewBox="0 0 200 120" fill="none" stroke="currentColor"453 stroke-linecap="round" stroke-linejoin="round">454 <path d="M 124 36 Q 150 30 168 44" stroke-width="1.6" opacity="0.9" />455 <path d="M 128 52 Q 162 50 184 64" stroke-width="1.3" opacity="0.55" />456 <path d="M 124 68 Q 158 76 178 92" stroke-width="1.1" opacity="0.32" />457 </svg>458 <div class="peach-chirp-pro-bird-slot">459 <MobileBird pose="idle" />460 </div>461 </div>462 <h2 class="peach-chirp-pro-headline">A little bird that carries your vault</h2>463 <p class="peach-chirp-pro-body">464 Pairs over an inaudible chirp. Syncs locally across your devices.465 No codes, no servers, no cloud.466 </p>467 <button class="peach-btn peach-btn-primary peach-chirp-pro-cta" onClick={() => props.onLearnPro()}>468 Unlock with Peach Pro469 </button>470 <p class="peach-chirp-pro-foot">Peach Pro · one-time purchase</p>471 </div>472 </div>473 );474475 const restView = () => (476 <div class="peach-chirp-pair" data-phase="idle" role="region" aria-label="Sync">477 <header class="peach-chirp-head">478 <button class="peach-chirp-back" aria-label="Back" onClick={() => props.onBack()}>479 <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">480 <path d="M10 3 L5 8 L10 13" />481 </svg>482 </button>483 <div class="peach-chirp-title">Sync</div>484 <div class="peach-chirp-head-spacer" />485 </header>486487 <div class="peach-chirp-chip-row">488 <div class="peach-chirp-chip" data-phase="idle">489 <span class="peach-chirp-chip-dot" />490 <span class="peach-chirp-chip-text">ready</span>491 </div>492 </div>493494 <Show when={error()}>495 <div class="peach-chirp-settings-error" role="alert">{error()}</div>496 </Show>497498 <div class="peach-chirp-stage act-idle" aria-hidden="true">499 <div class="peach-chirp-bird-slot peach-chirp-bird-slot-desktop">500 <DesktopBird pose="idle" />501 </div>502 <div class="peach-chirp-bird-slot peach-chirp-bird-slot-mobile">503 <MobileBird pose="hidden" />504 </div>505 </div>506507 <div class="peach-chirp-copy-host">508 <div class="peach-chirp-copy peach-chirp-copy-rest">509 <div class="peach-chirp-headline">510 {devices().length === 0 ? "Pair a device" : "Sync is ready"}511 </div>512 <div class="peach-chirp-sub">513 {devices().length === 0514 ? "Sync over your local network. No cloud, no account, no servers."515 : "Pair another device, or manage the ones already connected below."}516 </div>517 </div>518 </div>519520 <Show when={devices().length > 0}>521 <section class="peach-chirp-paired-section">522 <h2 class="peach-chirp-paired-heading">Paired devices</h2>523 <ul class="peach-chirp-device-list">524 <For each={devices()}>525 {(d) => (526 <li class="peach-chirp-device-row" data-stale={isStale(d) ? "true" : "false"}>527 <DeviceTypeIcon type={d.deviceType ?? "extension"} />528 <div class="peach-chirp-device-info">529 <span class="peach-chirp-device-name">{d.deviceName ?? "Unnamed device"}</span>530 <span class="peach-chirp-device-meta">531 {relativeTime(d.lastSyncedAt)}532 <Show when={d.trustSource === "vouched"}>533 <span class="peach-chirp-device-badge">Added automatically</span>534 </Show>535 <Show when={isStale(d)}>536 <span class="peach-chirp-device-stale">Not seen recently</span>537 </Show>538 </span>539 </div>540 <Show541 when={confirmUnpair() !== d.deviceId}542 fallback={543 <div class="peach-chirp-unpair-confirm">544 <button class="peach-btn peach-btn-ghost peach-btn-sm" onClick={() => setConfirmUnpair(null)}>545 Cancel546 </button>547 <button class="peach-btn peach-btn-destructive peach-btn-sm" onClick={() => void handleUnpair(d.deviceId)}>548 Remove549 </button>550 </div>551 }552 >553 <button554 class="peach-icon-btn peach-chirp-device-remove"555 aria-label={`Unpair ${d.deviceName}`}556 onClick={() => setConfirmUnpair(d.deviceId)}557 >558 <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">559 <path d="M3 3 L11 11 M11 3 L3 11" />560 </svg>561 </button>562 </Show>563 </li>564 )}565 </For>566 </ul>567 </section>568 </Show>569570 <Show when={devices().length > 0}>571 <div class="peach-chirp-sync-now-row">572 <button573 class="peach-btn peach-btn-ghost peach-btn-sm peach-chirp-sync-now"574 disabled={syncStatus() === "syncing"}575 onClick={() => void handleSyncNow()}576 >577 {syncStatus() === "syncing" ? "Syncing…" : "Sync now"}578 </button>579 <Show when={syncFlash()}>580 <span class="peach-chirp-sync-flash" aria-live="polite">581 {syncFlash()}582 </span>583 </Show>584 </div>585 </Show>586587 <div class="peach-pair-method-list peach-pair-method-list--rest">588 <button class="peach-pair-method" onClick={() => pairViaChirp()}>589 <span class="peach-pair-method-icon" aria-hidden="true">590 <svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">591 <path d="M3 8 L6 8 L10.5 4 L10.5 16 L6 12 L3 12 Z" />592 <path d="M14 7.5 Q15.6 10 14 12.5" />593 <path d="M16.4 5.5 Q19 10 16.4 14.5" opacity="0.5" />594 </svg>595 </span>596 <span class="peach-pair-method-text">597 <span class="peach-pair-method-title">Same room</span>598 <span class="peach-pair-method-sub">Pair a nearby phone or browser by chirp</span>599 </span>600 </button>601 <button class="peach-pair-method" onClick={() => pairViaDesktop()}>602 <span class="peach-pair-method-icon" aria-hidden="true">603 <svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">604 <rect x="2" y="4" width="16" height="10" rx="1.4" />605 <path d="M7 17 L13 17" />606 <path d="M9 14 L9 17 M11 14 L11 17" />607 </svg>608 </span>609 <span class="peach-pair-method-text">610 <span class="peach-pair-method-title">Enter a code</span>611 <span class="peach-pair-method-sub">Type the code from your desktop sync helper</span>612 </span>613 </button>614 </div>615616 </div>617 );618619 const backArrow = (620 <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">621 <path d="M10 3 L5 8 L10 13" />622 </svg>623 );624625 626 627 const desktopBirdStage = (active: boolean) => (628 <div class={`peach-chirp-stage ${active ? "act-handshake" : "act-listening"}`} aria-hidden="true">629 <div class="peach-chirp-bird-slot peach-chirp-bird-slot-desktop">630 <DesktopBird pose="chirping" />631 </div>632 <div class="peach-chirp-bird-slot peach-chirp-bird-slot-mobile">633 <MobileBird pose="attentive" />634 </div>635 </div>636 );637638 function desktopLiveText(): string {639 switch (desktopPhase()) {640 case "enter": return "Enter the pairing code from your computer";641 case "connecting": return "Searching for the helper";642 case "sas": return "Confirm the code matches on your computer";643 case "paired": return "Paired";644 case "error": return desktopErrorCopy(desktopError()).headline;645 }646 }647648 649 const desktopView = () => (650 <div class="peach-chirp-pair peach-desktop-pair" data-phase={desktopPhase()} role="dialog" aria-label="Pair a desktop helper">651 <header class="peach-chirp-head">652 <button class="peach-chirp-back" aria-label="Back" onClick={() => { setMode("rest"); setPairKind(null); }}>{backArrow}</button>653 <div class="peach-chirp-title">Enter a code</div>654 <div class="peach-chirp-head-spacer" />655 </header>656657 {desktopBirdStage(desktopPhase() !== "enter")}658659 <Show when={desktopPhase() === "enter"}>660 <div class="peach-desktop-enter">661 <div class="peach-chirp-headline">Enter the code from your computer</div>662 <div class="peach-chirp-sub">663 Open the Peach helper on your computer and type the 6-digit code it shows.664 </div>665 <label class="peach-label" for="desktop-pair-code">pairing code</label>666 <input667 id="desktop-pair-code"668 class="peach-underline-input peach-unlock-input peach-code-input"669 type="text"670 inputmode="numeric"671 autocomplete="one-time-code"672 maxlength={6}673 placeholder="000000"674 aria-label="6-digit pairing code"675 value={desktopCode()}676 ref={(el) => setTimeout(() => el.focus(), 80)}677 onInput={(e) => onDesktopCodeInput((e.target as HTMLInputElement).value)}678 onKeyDown={(e) => { if (e.key === "Enter") void handleDesktopConnect(); }}679 />680 <div class="peach-chirp-action">681 <button class="peach-btn peach-btn-ghost" onClick={() => { setMode("rest"); setPairKind(null); }}>Cancel</button>682 <button683 class="peach-btn peach-btn-primary peach-desktop-connect"684 disabled={!desktopCodeReady() || desktopBusy()}685 onClick={() => void handleDesktopConnect()}686 >687 Connect688 </button>689 </div>690 </div>691 </Show>692693 <Show when={desktopPhase() === "connecting"}>694 <div class="peach-desktop-searching">695 <div class="peach-chirp-headline">Searching for helper…</div>696 <div class="peach-chirp-sub">Connecting to the Peach helper on your computer.</div>697 </div>698 </Show>699700 <Show when={desktopPhase() === "sas"}>701 <div class="peach-desktop-confirm" style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:8px 18px 16px;text-align:center;">702 <div class="peach-chirp-headline">Confirm this code matches on your computer:</div>703 <div704 class="peach-desktop-sas"705 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;"706 >707 {desktopSas()}708 </div>709 <div class="peach-chirp-sub" style="max-width:240px;">710 Only continue if this 6-digit code is identical on both screens.711 </div>712 <div style="display:flex;flex-direction:column;gap:8px;width:100%;max-width:240px;">713 <button class="peach-btn peach-btn-primary" disabled={desktopBusy()} onClick={() => void respondDesktopConfirm(true)}>714 Confirm715 </button>716 <button class="peach-btn peach-btn-ghost" disabled={desktopBusy()} onClick={() => void respondDesktopConfirm(false)}>717 Cancel718 </button>719 </div>720 </div>721 </Show>722723 <Show when={desktopPhase() === "paired"}>724 <div class="peach-desktop-paired">725 <div class="peach-chirp-headline">Paired!</div>726 <div class="peach-chirp-sub">727 {desktopDevice()?.deviceName ? `Connected to ${desktopDevice()!.deviceName}` : "Connected to your computer"}728 </div>729 <div class="peach-chirp-action">730 <button class="peach-btn peach-btn-primary" onClick={() => { setMode("rest"); setPairKind(null); void refresh(); }}>Done</button>731 </div>732 </div>733 </Show>734735 <Show when={desktopPhase() === "error"}>736 <div class="peach-desktop-error">737 <div class="peach-chirp-headline">{desktopErrorCopy(desktopError()).headline}</div>738 <div class="peach-chirp-sub">{desktopErrorCopy(desktopError()).sub}</div>739 <div class="peach-chirp-action">740 <button class="peach-btn peach-btn-ghost" onClick={() => setMode("rest")}>Cancel</button>741 <button class="peach-btn peach-btn-primary" onClick={() => startDesktopFlow()}>Try again</button>742 </div>743 </div>744 </Show>745746 <div class="peach-sr-only" aria-live="polite">{desktopLiveText()}</div>747 </div>748 );749750 const chirpPairingView = () => (751 <Show752 when={sasCode()}753 fallback={754 <ChirpPairingFlow755 progress={pairing()}756 onCancel={() => setMode("rest")}757 onRetry={() => void handleStartPairing()}758 onDismiss={() => {759 setMode("rest");760 void refresh();761 }}762 />763 }764 >765 {766}767 <div768 class="chirp-sas-verify"769 style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:24px 18px;text-align:center;"770 >771 <div style="font-size:14px;font-weight:600;color:var(--peach-text);">Verify it's really your device</div>772 <div style="font-size:11.5px;line-height:1.45;color:var(--peach-text-muted);max-width:240px;">773 Confirm this code matches the one shown on your other device before pairing.774 </div>775 <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;">776 {sasCode()}777 </div>778 <div style="display:flex;flex-direction:column;gap:8px;width:100%;max-width:240px;">779 <button780 class="peach-btn peach-btn--accent peach-btn--md"781 type="button"782 onClick={() => resolveSAS(true)}783 >784 codes match — pair785 </button>786 <button787 class="peach-btn peach-btn--ghost peach-btn--md"788 type="button"789 onClick={() => resolveSAS(false)}790 >791 they don't match792 </button>793 </div>794 </div>795 </Show>796 );797798 799 800 const pairingView = () => (801 <Show when={pairKind() === "desktop"} fallback={chirpPairingView()}>802 {desktopView()}803 </Show>804 );805806 const currentMode = createMemo(() => {807 if (!props.isPro) return "gated";808 if (mode() === "pairing") return "pairing";809 return "rest";810 });811812 return (813 <Show814 when={currentMode() === "gated"}815 fallback={816 <Show when={currentMode() === "pairing"} fallback={restView()}>817 {pairingView()}818 </Show>819 }820 >821 {proGateView()}822 </Show>823 );824};825826const DeviceTypeIcon: Component<{ type: DeviceType }> = (props) => {827 return (828 <div class="peach-chirp-device-icon" data-type={props.type}>829 <Show when={props.type === "android" || props.type === "ios"}>830 <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">831 <rect x="6" y="2" width="8" height="16" rx="1.5" />832 <circle cx="10" cy="15.4" r="0.6" fill="currentColor" />833 </svg>834 </Show>835 <Show when={props.type === "extension" || props.type === "desktop"}>836 <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">837 <rect x="2" y="4" width="16" height="10" rx="1.4" />838 <path d="M7 17 L13 17" />839 <path d="M9 14 L9 17 M11 14 L11 17" />840 </svg>841 </Show>842 </div>843 );844};