1234567891011121314151617import {18 Component,19 Show,20 createEffect,21 createSignal,22 onCleanup,23 onMount,24} from "solid-js";25import { sendMessage } from "../../shared/bridge";26import type {27 PairedDevice,28 PairingProgress,29} from "../../shared/sync-types";30import { ChirpPairingFlow, DesktopBird, MobileBird } from "../components/ChirpPairingFlow";31import {32 ChirpPairingFailure,33 runChirpPairing,34 type ChirpPairingDeps,35 type PairingRole,36} from "../chirp-pair";37import { getLocalSubnet } from "../../shared/local-network";38import { isNativePlatform } from "../../shared/native";39import { openCapacitorPairingServer } from "../chirp-pair-capacitor";4041export interface ChirpSyncViewProps {42 isPro: boolean;43 onBack(): void;44 onLearnPro(): void;45 46474849 initialPair?: "chirp" | "desktop" | null;50}51525354type PairKind = "chirp" | "desktop" | null;5556575859type DesktopPhase = "enter" | "connecting" | "sas" | "paired" | "error";60type DesktopPairingError =61 | "invalid_code"62 | "no_host_found"63 | "handshake_failed"64 | "sas_mismatch"65 | "vault_locked";6667const DESKTOP_PAIRED_DISMISS_MS = 2_000;6869function desktopErrorCopy(error: DesktopPairingError | "unknown"): { headline: string; sub: string } {70 switch (error) {71 case "invalid_code":72 73 return {74 headline: "Code not found or expired",75 sub: "Check the code on your computer and try again. Codes rotate, so grab a fresh one if it's been a while.",76 };77 case "no_host_found":78 return {79 headline: "Couldn't find your computer",80 sub: "Make sure both devices are on the same Wi-Fi, then run “peach-sync pair” in a terminal to start pairing.",81 };82 case "sas_mismatch":83 return {84 headline: "Codes don't match",85 sub: "For your safety, pairing was stopped. Start over and compare the two codes carefully.",86 };87 case "vault_locked":88 return {89 headline: "Unlock Peach first",90 sub: "Your vault needs to be unlocked before you can pair a device.",91 };92 case "handshake_failed":93 default:94 return {95 headline: "Pairing failed",96 sub: "Something went wrong while connecting. Please try again.",97 };98 }99}100101export const ChirpSyncView: Component<ChirpSyncViewProps> = (props) => {102 const [pairing, setPairing] = createSignal<PairingProgress>({ phase: "idle" });103 104 105 106 const [sasCode, setSasCode] = createSignal<string | null>(null);107 const [sasResolve, setSasResolve] = createSignal<((ok: boolean) => void) | null>(null);108109 function resolveSAS(ok: boolean) {110 const r = sasResolve();111 setSasCode(null);112 setSasResolve(null);113 r?.(ok);114 }115116 117 118 119 const [pairKind, setPairKind] = createSignal<PairKind>(props.initialPair ?? null);120 const [desktopPhase, setDesktopPhase] = createSignal<DesktopPhase>("enter");121 const [desktopCode, setDesktopCode] = createSignal("");122 const [desktopSas, setDesktopSas] = createSignal<string | null>(null);123 const [desktopError, setDesktopError] = createSignal<DesktopPairingError | "unknown">("unknown");124 const [desktopDevice, setDesktopDevice] = createSignal<PairedDevice | undefined>(undefined);125 const [desktopBusy, setDesktopBusy] = createSignal(false);126127 const desktopCodeReady = () => /^\d{6}$/.test(desktopCode());128129 function startDesktopFlow(): void {130 setDesktopCode("");131 setDesktopSas(null);132 setDesktopError("unknown");133 setDesktopDevice(undefined);134 setDesktopBusy(false);135 setDesktopPhase("enter");136 setPairKind("desktop");137 }138139 function startChirpFlow(): void {140 setPairKind("chirp");141 void handleStartPairing();142 }143144 function onDesktopCodeInput(value: string): void {145 setDesktopCode(value.replace(/\D/g, "").slice(0, 6));146 }147148 async function handleDesktopConnect(): Promise<void> {149 if (!desktopCodeReady() || desktopBusy()) return;150 setDesktopBusy(true);151 setDesktopPhase("connecting");152 try {153 const res = (await sendMessage({154 type: "PAIR_WITH_DESKTOP",155 payload: { code: desktopCode() },156 })) as { ok: boolean; error?: string };157 158 159 if (!res.ok) {160 setDesktopError((res.error as DesktopPairingError) ?? "unknown");161 setDesktopPhase("error");162 }163 } catch {164 setDesktopError("unknown");165 setDesktopPhase("error");166 } finally {167 setDesktopBusy(false);168 }169 }170171 async function respondDesktopConfirm(confirmed: boolean): Promise<void> {172 if (desktopBusy()) return;173 setDesktopBusy(true);174 175 176 if (confirmed) setDesktopPhase("connecting");177 else { setDesktopError("sas_mismatch"); setDesktopPhase("error"); }178 try {179 await sendMessage({ type: "DESKTOP_PAIR_CONFIRM", payload: { confirmed } });180 } finally {181 setDesktopBusy(false);182 }183 }184185 186 187 createEffect(() => {188 if (desktopPhase() !== "paired") return;189 const t = setTimeout(() => props.onBack(), DESKTOP_PAIRED_DISMISS_MS);190 onCleanup(() => clearTimeout(t));191 });192193 onMount(() => {194 195 196 if (props.initialPair === "chirp") startChirpFlow();197 else if (props.initialPair === "desktop") startDesktopFlow();198 else props.onBack();199200 201 202 203 if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {204 const handler = (msg: { type?: string; payload?: unknown }) => {205 if (pairKind() !== "desktop") return;206 if (msg?.type === "DESKTOP_PAIR_SAS") {207 const p = msg.payload as { sas?: string } | undefined;208 if (typeof p?.sas === "string") { setDesktopSas(p.sas); setDesktopPhase("sas"); }209 } else if (msg?.type === "DESKTOP_PAIR_RESULT") {210 const p = msg.payload as211 | { status: "complete"; device: PairedDevice }212 | { status: "error"; error: DesktopPairingError }213 | undefined;214 if (p?.status === "complete") { setDesktopDevice(p.device); setDesktopPhase("paired"); }215 else if (p?.status === "error") { setDesktopError(p.error); setDesktopPhase("error"); }216 }217 };218 chrome.runtime.onMessage.addListener(handler);219 onCleanup(() => chrome.runtime.onMessage.removeListener(handler));220 }221 });222223 async function handleStartPairing(): Promise<void> {224 225 226 227 228 if (typeof chrome !== "undefined" && chrome.permissions?.request) {229 const granted = await chrome.permissions.request({230 permissions: ["audioCapture" as chrome.runtime.ManifestPermissions],231 });232 if (!granted) {233 setPairing({ phase: "mic_denied" });234 return;235 }236 }237238 setPairing({ phase: "idle" });239240 const deps: ChirpPairingDeps = {241 onProgress: (p) => setPairing(p),242 fetchLocalIdentity: async () => {243 const res = await sendMessage({ type: "CHIRP_GET_LOCAL_IDENTITY" });244 const r = res as { ok: boolean; data?: Parameters<ChirpPairingDeps["onProgress"]>[0] & object; error?: string };245 if (!r.ok || !r.data) throw new Error(r.error ?? "identity_unavailable");246 247 return r.data as unknown as Awaited<ReturnType<ChirpPairingDeps["fetchLocalIdentity"]>>;248 },249 completePairing: async (paired) => {250 const res = await sendMessage({251 type: "CHIRP_COMPLETE_PAIRING",252 payload: { paired },253 });254 const r = res as { ok: boolean; error?: string };255 if (!r.ok) throw new Error(r.error ?? "complete_pairing_failed");256 },257 confirmSAS: (sas) =>258 new Promise<boolean>((resolve) => {259 setSasResolve(() => resolve);260 setSasCode(sas);261 }),262 getLocalSubnet,263 openPairingTransport: async (endpoint) => {264 265 266 267 return new Promise((resolve, reject) => {268 let ws: WebSocket;269 try {270 ws = new WebSocket(`ws://${endpoint.ip}:${endpoint.port}/`);271 } catch (err) {272 reject(err);273 return;274 }275 const inbox: Uint8Array[] = [];276 const waiters: Array<(b: Uint8Array) => void> = [];277 let closed = false;278 ws.binaryType = "arraybuffer";279 const timer = setTimeout(() => {280 try { ws.close(); } catch { }281 reject(new Error("pairing_transport_timeout"));282 }, 2_000);283 ws.onopen = () => {284 clearTimeout(timer);285 resolve({286 async send(data: Uint8Array) {287 ws.send(data as Uint8Array<ArrayBuffer>);288 },289 receive(): Promise<Uint8Array> {290 if (closed) return Promise.reject(new Error("closed"));291 const v = inbox.shift();292 if (v) return Promise.resolve(v);293 return new Promise<Uint8Array>((res) => waiters.push(res));294 },295 async close() {296 try { ws.close(); } catch { }297 },298 });299 };300 ws.onmessage = (ev) => {301 if (!(ev.data instanceof ArrayBuffer)) return;302 const bytes = new Uint8Array(ev.data);303 const w = waiters.shift();304 if (w) w(bytes); else inbox.push(bytes);305 };306 ws.onerror = () => {307 clearTimeout(timer);308 reject(new Error("pairing_transport_error"));309 };310 ws.onclose = () => {311 closed = true;312 while (waiters.length > 0) waiters.shift()!(new Uint8Array(0));313 };314 });315 },316 };317318 const role: PairingRole = isNativePlatform() ? "initiator" : "responder";319 if (role === "initiator") deps.openPairingServer = openCapacitorPairingServer;320321 try {322 await runChirpPairing(deps, role);323 324 325 } catch (err) {326 if (err instanceof ChirpPairingFailure) {327 328 329 return;330 }331 setPairing({ phase: "handshake_failed", errorMessage: (err as Error).message });332 }333 }334335 336337 const proGateView = () => (338 <div class="peach-chirp-settings peach-chirp-settings--gated">339 <header class="peach-chirp-settings-header">340 <button class="peach-icon-btn" aria-label="Back" onClick={() => props.onBack()}>341 <svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">342 <path d="M11 4 L5 9 L11 14" />343 </svg>344 </button>345 <h1 class="peach-chirp-settings-title">Sync</h1>346 </header>347 <div class="peach-chirp-pro-card">348 <div class="peach-chirp-pro-stage" aria-hidden="true">349 <svg class="peach-chirp-pro-chirp" viewBox="0 0 200 120" fill="none" stroke="currentColor"350 stroke-linecap="round" stroke-linejoin="round">351 <path d="M 124 36 Q 150 30 168 44" stroke-width="1.6" opacity="0.9" />352 <path d="M 128 52 Q 162 50 184 64" stroke-width="1.3" opacity="0.55" />353 <path d="M 124 68 Q 158 76 178 92" stroke-width="1.1" opacity="0.32" />354 </svg>355 <div class="peach-chirp-pro-bird-slot">356 <MobileBird pose="idle" />357 </div>358 </div>359 <h2 class="peach-chirp-pro-headline">A little bird that carries your vault</h2>360 <p class="peach-chirp-pro-body">361 Pairs over an inaudible chirp. Syncs locally across your devices.362 No codes, no servers, no cloud.363 </p>364 <button class="peach-btn peach-btn-primary peach-chirp-pro-cta" onClick={() => props.onLearnPro()}>365 Unlock with Peach Pro366 </button>367 <p class="peach-chirp-pro-foot">Peach Pro · one-time purchase</p>368 </div>369 </div>370 );371372 const backArrow = (373 <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">374 <path d="M10 3 L5 8 L10 13" />375 </svg>376 );377378 379 380 const desktopBirdStage = (active: boolean) => (381 <div class={`peach-chirp-stage ${active ? "act-handshake" : "act-listening"}`} aria-hidden="true">382 <div class="peach-chirp-bird-slot peach-chirp-bird-slot-desktop">383 <DesktopBird pose="chirping" />384 </div>385 <div class="peach-chirp-bird-slot peach-chirp-bird-slot-mobile">386 <MobileBird pose="attentive" />387 </div>388 </div>389 );390391 function desktopLiveText(): string {392 switch (desktopPhase()) {393 case "enter": return "Enter the pairing code from your computer";394 case "connecting": return "Searching for the helper";395 case "sas": return "Confirm the code matches on your computer";396 case "paired": return "Paired";397 case "error": return desktopErrorCopy(desktopError()).headline;398 }399 }400401 402 const desktopView = () => (403 <div class="peach-chirp-pair peach-desktop-pair" data-phase={desktopPhase()} role="dialog" aria-label="Pair a desktop helper">404 <header class="peach-chirp-head">405 <button class="peach-chirp-back" aria-label="Back" onClick={() => props.onBack()}>{backArrow}</button>406 <div class="peach-chirp-title">Enter a code</div>407 <div class="peach-chirp-head-spacer" />408 </header>409410 {desktopBirdStage(desktopPhase() !== "enter")}411412 <Show when={desktopPhase() === "enter"}>413 <div class="peach-desktop-enter">414 <div class="peach-chirp-headline">Enter the code from your computer</div>415 <div class="peach-chirp-sub">416 Open the Peach helper on your computer and type the 6-digit code it shows.417 </div>418 <label class="peach-label" for="desktop-pair-code">pairing code</label>419 <input420 id="desktop-pair-code"421 class="peach-underline-input peach-unlock-input peach-code-input"422 type="text"423 inputmode="numeric"424 autocomplete="one-time-code"425 maxlength={6}426 placeholder="000000"427 aria-label="6-digit pairing code"428 value={desktopCode()}429 ref={(el) => setTimeout(() => el.focus(), 80)}430 onInput={(e) => onDesktopCodeInput((e.target as HTMLInputElement).value)}431 onKeyDown={(e) => { if (e.key === "Enter") void handleDesktopConnect(); }}432 />433 <div class="peach-chirp-action">434 <button class="peach-btn peach-btn-ghost" onClick={() => props.onBack()}>Cancel</button>435 <button436 class="peach-btn peach-btn-primary peach-desktop-connect"437 disabled={!desktopCodeReady() || desktopBusy()}438 onClick={() => void handleDesktopConnect()}439 >440 Connect441 </button>442 </div>443 </div>444 </Show>445446 <Show when={desktopPhase() === "connecting"}>447 <div class="peach-desktop-searching">448 <div class="peach-chirp-headline">Searching for helper…</div>449 <div class="peach-chirp-sub">Connecting to the Peach helper on your computer.</div>450 </div>451 </Show>452453 <Show when={desktopPhase() === "sas"}>454 <div class="peach-desktop-confirm" style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:8px 18px 16px;text-align:center;">455 <div class="peach-chirp-headline">Confirm this code matches on your computer:</div>456 <div457 class="peach-desktop-sas"458 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;"459 >460 {desktopSas()}461 </div>462 <div class="peach-chirp-sub" style="max-width:240px;">463 Only continue if this 6-digit code is identical on both screens.464 </div>465 <div style="display:flex;flex-direction:column;gap:8px;width:100%;max-width:240px;">466 <button class="peach-btn peach-btn-primary" disabled={desktopBusy()} onClick={() => void respondDesktopConfirm(true)}>467 Confirm468 </button>469 <button class="peach-btn peach-btn-ghost" disabled={desktopBusy()} onClick={() => void respondDesktopConfirm(false)}>470 Cancel471 </button>472 </div>473 </div>474 </Show>475476 <Show when={desktopPhase() === "paired"}>477 <div class="peach-desktop-paired">478 <div class="peach-chirp-headline">Paired!</div>479 <div class="peach-chirp-sub">480 {desktopDevice()?.deviceName ? `Connected to ${desktopDevice()!.deviceName}` : "Connected to your computer"}481 </div>482 <div class="peach-chirp-action">483 <button class="peach-btn peach-btn-primary" onClick={() => props.onBack()}>Done</button>484 </div>485 </div>486 </Show>487488 <Show when={desktopPhase() === "error"}>489 <div class="peach-desktop-error">490 <div class="peach-chirp-headline">{desktopErrorCopy(desktopError()).headline}</div>491 <div class="peach-chirp-sub">{desktopErrorCopy(desktopError()).sub}</div>492 <div class="peach-chirp-action">493 <button class="peach-btn peach-btn-ghost" onClick={() => props.onBack()}>Cancel</button>494 <button class="peach-btn peach-btn-primary" onClick={() => startDesktopFlow()}>Try again</button>495 </div>496 </div>497 </Show>498499 <div class="peach-sr-only" aria-live="polite">{desktopLiveText()}</div>500 </div>501 );502503 const chirpPairingView = () => (504 <Show505 when={sasCode()}506 fallback={507 <ChirpPairingFlow508 progress={pairing()}509 onCancel={() => props.onBack()}510 onRetry={() => void handleStartPairing()}511 onDismiss={() => props.onBack()}512 />513 }514 >515 {516}517 <div518 class="chirp-sas-verify"519 style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:24px 18px;text-align:center;"520 >521 <div style="font-size:14px;font-weight:600;color:var(--peach-text);">Verify it's really your device</div>522 <div style="font-size:11.5px;line-height:1.45;color:var(--peach-text-muted);max-width:240px;">523 Confirm this code matches the one shown on your other device before pairing.524 </div>525 <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;">526 {sasCode()}527 </div>528 <div style="display:flex;flex-direction:column;gap:8px;width:100%;max-width:240px;">529 <button530 class="peach-btn peach-btn--accent peach-btn--md"531 type="button"532 onClick={() => resolveSAS(true)}533 >534 codes match — pair535 </button>536 <button537 class="peach-btn peach-btn--ghost peach-btn--md"538 type="button"539 onClick={() => resolveSAS(false)}540 >541 they don't match542 </button>543 </div>544 </div>545 </Show>546 );547548 549 550 const pairingView = () => (551 <Show when={pairKind() === "desktop"} fallback={chirpPairingView()}>552 {desktopView()}553 </Show>554 );555556 return (557 <Show when={props.isPro} fallback={proGateView()}>558 {pairingView()}559 </Show>560 );561};