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 51 onProChanged?: () => void;52}53545556type PairKind = "chirp" | "desktop" | null;5758596061type DesktopPhase = "enter" | "connecting" | "sas" | "paired" | "error";62type DesktopPairingError =63 | "invalid_code"64 | "no_host_found"65 | "handshake_failed"66 | "sas_mismatch"67 | "vault_locked";6869const DESKTOP_PAIRED_DISMISS_MS = 2_000;7071function desktopErrorCopy(error: DesktopPairingError | "unknown"): { headline: string; sub: string } {72 switch (error) {73 case "invalid_code":74 75 return {76 headline: "Code not found or expired",77 sub: "Check the code on your computer and try again. Codes rotate, so grab a fresh one if it's been a while.",78 };79 case "no_host_found":80 return {81 headline: "Couldn't find your computer",82 sub: "Make sure both devices are on the same Wi-Fi, then run “peach-sync pair” in a terminal to start pairing.",83 };84 case "sas_mismatch":85 return {86 headline: "Codes don't match",87 sub: "For your safety, pairing was stopped. Start over and compare the two codes carefully.",88 };89 case "vault_locked":90 return {91 headline: "Unlock Peach first",92 sub: "Your vault needs to be unlocked before you can pair a device.",93 };94 case "handshake_failed":95 default:96 return {97 headline: "Pairing failed",98 sub: "Something went wrong while connecting. Please try again.",99 };100 }101}102103export const ChirpSyncView: Component<ChirpSyncViewProps> = (props) => {104 const [pairing, setPairing] = createSignal<PairingProgress>({ phase: "idle" });105 106 107 108 const [sasCode, setSasCode] = createSignal<string | null>(null);109 const [sasResolve, setSasResolve] = createSignal<((ok: boolean) => void) | null>(null);110111 function resolveSAS(ok: boolean) {112 const r = sasResolve();113 setSasCode(null);114 setSasResolve(null);115 r?.(ok);116 }117118 119 120 121 const [pairKind, setPairKind] = createSignal<PairKind>(props.initialPair ?? null);122 const [desktopPhase, setDesktopPhase] = createSignal<DesktopPhase>("enter");123 const [desktopCode, setDesktopCode] = createSignal("");124 const [desktopSas, setDesktopSas] = createSignal<string | null>(null);125 const [desktopError, setDesktopError] = createSignal<DesktopPairingError | "unknown">("unknown");126 const [desktopDevice, setDesktopDevice] = createSignal<PairedDevice | undefined>(undefined);127 const [desktopBusy, setDesktopBusy] = createSignal(false);128129 const desktopCodeReady = () => /^\d{6}$/.test(desktopCode());130131 function startDesktopFlow(): void {132 setDesktopCode("");133 setDesktopSas(null);134 setDesktopError("unknown");135 setDesktopDevice(undefined);136 setDesktopBusy(false);137 setDesktopPhase("enter");138 setPairKind("desktop");139 }140141 function startChirpFlow(): void {142 setPairKind("chirp");143 void handleStartPairing();144 }145146 function onDesktopCodeInput(value: string): void {147 setDesktopCode(value.replace(/\D/g, "").slice(0, 6));148 }149150 async function handleDesktopConnect(): Promise<void> {151 if (!desktopCodeReady() || desktopBusy()) return;152 setDesktopBusy(true);153 setDesktopPhase("connecting");154 try {155 const res = (await sendMessage({156 type: "PAIR_WITH_DESKTOP",157 payload: { code: desktopCode() },158 })) as { ok: boolean; error?: string };159 160 161 if (!res.ok) {162 setDesktopError((res.error as DesktopPairingError) ?? "unknown");163 setDesktopPhase("error");164 }165 } catch {166 setDesktopError("unknown");167 setDesktopPhase("error");168 } finally {169 setDesktopBusy(false);170 }171 }172173 async function respondDesktopConfirm(confirmed: boolean): Promise<void> {174 if (desktopBusy()) return;175 setDesktopBusy(true);176 177 178 if (confirmed) setDesktopPhase("connecting");179 else { setDesktopError("sas_mismatch"); setDesktopPhase("error"); }180 try {181 await sendMessage({ type: "DESKTOP_PAIR_CONFIRM", payload: { confirmed } });182 } finally {183 setDesktopBusy(false);184 }185 }186187 188 189 createEffect(() => {190 if (desktopPhase() !== "paired") return;191 const t = setTimeout(() => props.onBack(), DESKTOP_PAIRED_DISMISS_MS);192 onCleanup(() => clearTimeout(t));193 });194195 onMount(() => {196 197 198 if (props.initialPair === "chirp") startChirpFlow();199 else if (props.initialPair === "desktop") startDesktopFlow();200 else props.onBack();201202 203 204 205 if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {206 const handler = (msg: { type?: string; payload?: unknown }) => {207 if (pairKind() !== "desktop") return;208 if (msg?.type === "DESKTOP_PAIR_SAS") {209 const p = msg.payload as { sas?: string } | undefined;210 if (typeof p?.sas === "string") { setDesktopSas(p.sas); setDesktopPhase("sas"); }211 } else if (msg?.type === "DESKTOP_PAIR_RESULT") {212 const p = msg.payload as213 | { status: "complete"; device: PairedDevice }214 | { status: "error"; error: DesktopPairingError }215 | undefined;216 if (p?.status === "complete") { setDesktopDevice(p.device); setDesktopPhase("paired"); }217 else if (p?.status === "error") { setDesktopError(p.error); setDesktopPhase("error"); }218 }219 };220 chrome.runtime.onMessage.addListener(handler);221 onCleanup(() => chrome.runtime.onMessage.removeListener(handler));222 }223 });224225 async function handleStartPairing(): Promise<void> {226 227 228 229 230 if (typeof chrome !== "undefined" && chrome.permissions?.request) {231 const granted = await chrome.permissions.request({232 permissions: ["audioCapture" as chrome.runtime.ManifestPermissions],233 });234 if (!granted) {235 setPairing({ phase: "mic_denied" });236 return;237 }238 }239240 setPairing({ phase: "idle" });241242 const deps: ChirpPairingDeps = {243 onProgress: (p) => setPairing(p),244 fetchLocalIdentity: async () => {245 const res = await sendMessage({ type: "CHIRP_GET_LOCAL_IDENTITY" });246 const r = res as { ok: boolean; data?: Parameters<ChirpPairingDeps["onProgress"]>[0] & object; error?: string };247 if (!r.ok || !r.data) throw new Error(r.error ?? "identity_unavailable");248 249 return r.data as unknown as Awaited<ReturnType<ChirpPairingDeps["fetchLocalIdentity"]>>;250 },251 completePairing: async (paired) => {252 const res = await sendMessage({253 type: "CHIRP_COMPLETE_PAIRING",254 payload: { paired },255 });256 const r = res as { ok: boolean; error?: string };257 if (!r.ok) throw new Error(r.error ?? "complete_pairing_failed");258 },259 confirmSAS: (sas) =>260 new Promise<boolean>((resolve) => {261 setSasResolve(() => resolve);262 setSasCode(sas);263 }),264 265 266 activatePeerLicense: async (license) => {267 if (props.isPro) return;268 const res = (await sendMessage({269 type: "ACTIVATE_LICENSE",270 payload: { key: license },271 })) as { ok: boolean; data?: { valid?: boolean; plan?: string } };272 if (res.ok && res.data?.valid && res.data?.plan === "pro") props.onProChanged?.();273 },274 getLocalSubnet,275 openPairingTransport: async (endpoint) => {276 277 278 279 return new Promise((resolve, reject) => {280 let ws: WebSocket;281 try {282 ws = new WebSocket(`ws://${endpoint.ip}:${endpoint.port}/`);283 } catch (err) {284 reject(err);285 return;286 }287 const inbox: Uint8Array[] = [];288 const waiters: Array<(b: Uint8Array) => void> = [];289 let closed = false;290 ws.binaryType = "arraybuffer";291 const timer = setTimeout(() => {292 try { ws.close(); } catch { }293 reject(new Error("pairing_transport_timeout"));294 }, 2_000);295 ws.onopen = () => {296 clearTimeout(timer);297 resolve({298 async send(data: Uint8Array) {299 ws.send(data as Uint8Array<ArrayBuffer>);300 },301 receive(): Promise<Uint8Array> {302 if (closed) return Promise.reject(new Error("closed"));303 const v = inbox.shift();304 if (v) return Promise.resolve(v);305 return new Promise<Uint8Array>((res) => waiters.push(res));306 },307 async close() {308 try { ws.close(); } catch { }309 },310 });311 };312 ws.onmessage = (ev) => {313 if (!(ev.data instanceof ArrayBuffer)) return;314 const bytes = new Uint8Array(ev.data);315 const w = waiters.shift();316 if (w) w(bytes); else inbox.push(bytes);317 };318 ws.onerror = () => {319 clearTimeout(timer);320 reject(new Error("pairing_transport_error"));321 };322 ws.onclose = () => {323 closed = true;324 while (waiters.length > 0) waiters.shift()!(new Uint8Array(0));325 };326 });327 },328 };329330 const role: PairingRole = isNativePlatform() ? "initiator" : "responder";331 if (role === "initiator") deps.openPairingServer = openCapacitorPairingServer;332333 try {334 await runChirpPairing(deps, role);335 336 337 } catch (err) {338 if (err instanceof ChirpPairingFailure) {339 340 341 return;342 }343 setPairing({ phase: "handshake_failed", errorMessage: (err as Error).message });344 }345 }346347 348349 const backArrow = (350 <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">351 <path d="M10 3 L5 8 L10 13" />352 </svg>353 );354355 356 357 const desktopBirdStage = (active: boolean) => (358 <div class={`peach-chirp-stage ${active ? "act-handshake" : "act-listening"}`} aria-hidden="true">359 <div class="peach-chirp-bird-slot peach-chirp-bird-slot-desktop">360 <DesktopBird pose="chirping" />361 </div>362 <div class="peach-chirp-bird-slot peach-chirp-bird-slot-mobile">363 <MobileBird pose="attentive" />364 </div>365 </div>366 );367368 function desktopLiveText(): string {369 switch (desktopPhase()) {370 case "enter": return "Enter the pairing code from your computer";371 case "connecting": return "Searching for the helper";372 case "sas": return "Confirm the code matches on your computer";373 case "paired": return "Paired";374 case "error": return desktopErrorCopy(desktopError()).headline;375 }376 }377378 379 const desktopView = () => (380 <div class="peach-chirp-pair peach-desktop-pair" data-phase={desktopPhase()} role="dialog" aria-label="Pair a desktop helper">381 <header class="peach-chirp-head">382 <button class="peach-chirp-back" aria-label="Back" onClick={() => props.onBack()}>{backArrow}</button>383 <div class="peach-chirp-title">Enter a code</div>384 <div class="peach-chirp-head-spacer" />385 </header>386387 {desktopBirdStage(desktopPhase() !== "enter")}388389 <Show when={desktopPhase() === "enter"}>390 <div class="peach-desktop-enter">391 <div class="peach-chirp-headline">Enter the code from your computer</div>392 <div class="peach-chirp-sub">393 Open the Peach helper on your computer and type the 6-digit code it shows.394 </div>395 <label class="peach-label" for="desktop-pair-code">pairing code</label>396 <input397 id="desktop-pair-code"398 class="peach-underline-input peach-unlock-input peach-code-input"399 type="text"400 inputmode="numeric"401 autocomplete="one-time-code"402 maxlength={6}403 placeholder="000000"404 aria-label="6-digit pairing code"405 value={desktopCode()}406 ref={(el) => setTimeout(() => el.focus(), 80)}407 onInput={(e) => onDesktopCodeInput((e.target as HTMLInputElement).value)}408 onKeyDown={(e) => { if (e.key === "Enter") void handleDesktopConnect(); }}409 />410 <div class="peach-chirp-action">411 <button class="peach-btn peach-btn-ghost" onClick={() => props.onBack()}>Cancel</button>412 <button413 class="peach-btn peach-btn-primary peach-desktop-connect"414 disabled={!desktopCodeReady() || desktopBusy()}415 onClick={() => void handleDesktopConnect()}416 >417 Connect418 </button>419 </div>420 </div>421 </Show>422423 <Show when={desktopPhase() === "connecting"}>424 <div class="peach-desktop-searching">425 <div class="peach-chirp-headline">Searching for helper…</div>426 <div class="peach-chirp-sub">Connecting to the Peach helper on your computer.</div>427 </div>428 </Show>429430 <Show when={desktopPhase() === "sas"}>431 <div class="peach-desktop-confirm" style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:8px 18px 16px;text-align:center;">432 <div class="peach-chirp-headline">Confirm this code matches on your computer:</div>433 <div434 class="peach-desktop-sas"435 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;"436 >437 {desktopSas()}438 </div>439 <div class="peach-chirp-sub" style="max-width:240px;">440 Only continue if this 6-digit code is identical on both screens.441 </div>442 <div style="display:flex;flex-direction:column;gap:8px;width:100%;max-width:240px;">443 <button class="peach-btn peach-btn-primary" disabled={desktopBusy()} onClick={() => void respondDesktopConfirm(true)}>444 Confirm445 </button>446 <button class="peach-btn peach-btn-ghost" disabled={desktopBusy()} onClick={() => void respondDesktopConfirm(false)}>447 Cancel448 </button>449 </div>450 </div>451 </Show>452453 <Show when={desktopPhase() === "paired"}>454 <div class="peach-desktop-paired">455 <div class="peach-chirp-headline">Paired!</div>456 <div class="peach-chirp-sub">457 {desktopDevice()?.deviceName ? `Connected to ${desktopDevice()!.deviceName}` : "Connected to your computer"}458 </div>459 <div class="peach-chirp-action">460 <button class="peach-btn peach-btn-primary" onClick={() => props.onBack()}>Done</button>461 </div>462 </div>463 </Show>464465 <Show when={desktopPhase() === "error"}>466 <div class="peach-desktop-error">467 <div class="peach-chirp-headline">{desktopErrorCopy(desktopError()).headline}</div>468 <div class="peach-chirp-sub">{desktopErrorCopy(desktopError()).sub}</div>469 <div class="peach-chirp-action">470 <button class="peach-btn peach-btn-ghost" onClick={() => props.onBack()}>Cancel</button>471 <button class="peach-btn peach-btn-primary" onClick={() => startDesktopFlow()}>Try again</button>472 </div>473 </div>474 </Show>475476 <div class="peach-sr-only" aria-live="polite">{desktopLiveText()}</div>477 </div>478 );479480 const chirpPairingView = () => (481 <Show482 when={sasCode()}483 fallback={484 <ChirpPairingFlow485 progress={pairing()}486 onCancel={() => props.onBack()}487 onRetry={() => void handleStartPairing()}488 onDismiss={() => props.onBack()}489 />490 }491 >492 {493}494 <div495 class="chirp-sas-verify"496 style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:24px 18px;text-align:center;"497 >498 <div style="font-size:14px;font-weight:600;color:var(--peach-text);">Verify it's really your device</div>499 <div style="font-size:11.5px;line-height:1.45;color:var(--peach-text-muted);max-width:240px;">500 Confirm this code matches the one shown on your other device before pairing.501 </div>502 <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;">503 {sasCode()}504 </div>505 <div style="display:flex;flex-direction:column;gap:8px;width:100%;max-width:240px;">506 <button507 class="peach-btn peach-btn--accent peach-btn--md"508 type="button"509 onClick={() => resolveSAS(true)}510 >511 codes match — pair512 </button>513 <button514 class="peach-btn peach-btn--ghost peach-btn--md"515 type="button"516 onClick={() => resolveSAS(false)}517 >518 they don't match519 </button>520 </div>521 </div>522 </Show>523 );524525 526 527 const pairingView = () => (528 <Show when={pairKind() === "desktop"} fallback={chirpPairingView()}>529 {desktopView()}530 </Show>531 );532533 534 535 return pairingView();536};