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 type { AdoptRequest } from "../../shared/chirp-adopt";31import { ChirpPairingFlow, DesktopBird, MobileBird } from "../components/ChirpPairingFlow";32import { AdoptionConsentModal } from "../components/AdoptionConsentModal";33import {34 ChirpPairingFailure,35 runChirpPairing,36 type ChirpPairingDeps,37 type PairingRole,38} from "../chirp-pair";39import { getLocalSubnet } from "../../shared/local-network";40import { isNativePlatform } from "../../shared/native";41import { openCapacitorPairingServer } from "../chirp-pair-capacitor";4243export interface ChirpSyncViewProps {44 isPro: boolean;45 onBack(): void;46 onLearnPro(): void;47 48495051 initialPair?: "chirp" | "desktop" | null;52 53 onProChanged?: () => void;54}55565758type PairKind = "chirp" | "desktop" | null;5960616263type DesktopPhase = "enter" | "connecting" | "sas" | "paired" | "error";64type DesktopPairingError =65 | "invalid_code"66 | "no_host_found"67 | "handshake_failed"68 | "sas_mismatch"69 | "vault_locked";7071const DESKTOP_PAIRED_DISMISS_MS = 2_000;7273function desktopErrorCopy(error: DesktopPairingError | "unknown"): { headline: string; sub: string } {74 switch (error) {75 case "invalid_code":76 77 return {78 headline: "Code not found or expired",79 sub: "Check the code on your computer and try again. Codes rotate, so grab a fresh one if it's been a while.",80 };81 case "no_host_found":82 return {83 headline: "Couldn't find your computer",84 sub: "Make sure both devices are on the same Wi-Fi, then run “peach-sync pair” in a terminal to start pairing.",85 };86 case "sas_mismatch":87 return {88 headline: "Codes don't match",89 sub: "For your safety, pairing was stopped. Start over and compare the two codes carefully.",90 };91 case "vault_locked":92 return {93 headline: "Unlock Peach first",94 sub: "Your vault needs to be unlocked before you can pair a device.",95 };96 case "handshake_failed":97 default:98 return {99 headline: "Pairing failed",100 sub: "Something went wrong while connecting. Please try again.",101 };102 }103}104105export const ChirpSyncView: Component<ChirpSyncViewProps> = (props) => {106 const [pairing, setPairing] = createSignal<PairingProgress>({ phase: "idle" });107 108 109 110 const [sasCode, setSasCode] = createSignal<string | null>(null);111 const [sasResolve, setSasResolve] = createSignal<((ok: boolean) => void) | null>(null);112 const [adoptionConsent, setAdoptionConsent] = createSignal<{113 request: AdoptRequest;114 sas: string;115 resolve: (ok: boolean) => void;116 } | 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>(props.initialPair ?? 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 startDesktopFlow(): void {139 setDesktopCode("");140 setDesktopSas(null);141 setDesktopError("unknown");142 setDesktopDevice(undefined);143 setDesktopBusy(false);144 setDesktopPhase("enter");145 setPairKind("desktop");146 }147148 function startChirpFlow(): void {149 setPairKind("chirp");150 void handleStartPairing();151 }152153 function onDesktopCodeInput(value: string): void {154 setDesktopCode(value.replace(/\D/g, "").slice(0, 6));155 }156157 async function handleDesktopConnect(): Promise<void> {158 if (!desktopCodeReady() || desktopBusy()) return;159 setDesktopBusy(true);160 setDesktopPhase("connecting");161 try {162 const res = (await sendMessage({163 type: "PAIR_WITH_DESKTOP",164 payload: { code: desktopCode() },165 })) as { ok: boolean; error?: string };166 167 168 if (!res.ok) {169 setDesktopError((res.error as DesktopPairingError) ?? "unknown");170 setDesktopPhase("error");171 }172 } catch {173 setDesktopError("unknown");174 setDesktopPhase("error");175 } finally {176 setDesktopBusy(false);177 }178 }179180 async function respondDesktopConfirm(confirmed: boolean): Promise<void> {181 if (desktopBusy()) return;182 setDesktopBusy(true);183 184 185 if (confirmed) setDesktopPhase("connecting");186 else { setDesktopError("sas_mismatch"); setDesktopPhase("error"); }187 try {188 await sendMessage({ type: "DESKTOP_PAIR_CONFIRM", payload: { confirmed } });189 } finally {190 setDesktopBusy(false);191 }192 }193194 195 196 createEffect(() => {197 if (desktopPhase() !== "paired") return;198 const t = setTimeout(() => props.onBack(), DESKTOP_PAIRED_DISMISS_MS);199 onCleanup(() => clearTimeout(t));200 });201202 onMount(() => {203 204 205 if (props.initialPair === "chirp") startChirpFlow();206 else if (props.initialPair === "desktop") startDesktopFlow();207 else props.onBack();208209 210 211 212 if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {213 const handler = (msg: { type?: string; payload?: unknown }) => {214 if (pairKind() !== "desktop") return;215 if (msg?.type === "DESKTOP_PAIR_SAS") {216 const p = msg.payload as { sas?: string } | undefined;217 if (typeof p?.sas === "string") { setDesktopSas(p.sas); setDesktopPhase("sas"); }218 } else if (msg?.type === "DESKTOP_PAIR_RESULT") {219 const p = msg.payload as220 | { status: "complete"; device: PairedDevice }221 | { status: "error"; error: DesktopPairingError }222 | undefined;223 if (p?.status === "complete") { setDesktopDevice(p.device); setDesktopPhase("paired"); }224 else if (p?.status === "error") { setDesktopError(p.error); setDesktopPhase("error"); }225 }226 };227 chrome.runtime.onMessage.addListener(handler);228 onCleanup(() => chrome.runtime.onMessage.removeListener(handler));229 }230 });231232 async function handleStartPairing(): Promise<void> {233 234 235 236 237 if (typeof chrome !== "undefined" && chrome.permissions?.request) {238 const granted = await chrome.permissions.request({239 permissions: ["audioCapture" as chrome.runtime.ManifestPermissions],240 });241 if (!granted) {242 setPairing({ phase: "mic_denied" });243 return;244 }245 }246247 setPairing({ phase: "idle" });248249 const deps: ChirpPairingDeps = {250 onProgress: (p) => setPairing(p),251 fetchLocalIdentity: async () => {252 const res = await sendMessage({ type: "CHIRP_GET_LOCAL_IDENTITY" });253 const r = res as { ok: boolean; data?: Parameters<ChirpPairingDeps["onProgress"]>[0] & object; error?: string };254 if (!r.ok || !r.data) throw new Error(r.error ?? "identity_unavailable");255 256 return r.data as unknown as Awaited<ReturnType<ChirpPairingDeps["fetchLocalIdentity"]>>;257 },258 completePairing: async (paired) => {259 const res = await sendMessage({260 type: "CHIRP_COMPLETE_PAIRING",261 payload: { paired },262 });263 const r = res as { ok: boolean; error?: string };264 if (!r.ok) throw new Error(r.error ?? "complete_pairing_failed");265 },266 confirmSAS: (sas) =>267 new Promise<boolean>((resolve) => {268 setSasResolve(() => resolve);269 setSasCode(sas);270 }),271 272 273 activatePeerLicense: async (license) => {274 if (props.isPro) return;275 const res = (await sendMessage({276 type: "ACTIVATE_LICENSE",277 payload: { key: license },278 })) as { ok: boolean; data?: { valid?: boolean; plan?: string } };279 if (res.ok && res.data?.valid && res.data?.plan === "pro") props.onProChanged?.();280 },281 grantAdoptionConsent: (request, sas) =>282 new Promise<boolean>((resolve) => {283 setAdoptionConsent({ request, sas, resolve });284 }),285 fetchAdoptionMaterial: async () => {286 const res = await sendMessage({ type: "CHIRP_GET_ADOPTION_MATERIAL" });287 const r = res as {288 ok: boolean;289 data?: { rootKeyHex?: string; vaultId?: string; license?: string };290 error?: string;291 };292 if (!r.ok || typeof r.data?.rootKeyHex !== "string" || typeof r.data?.vaultId !== "string") {293 throw new Error(r.error ?? "adoption_material_unavailable");294 }295 return {296 rootKeyHex: r.data.rootKeyHex,297 vaultId: r.data.vaultId,298 license: r.data.license,299 };300 },301 getLocalSubnet,302 openPairingTransport: async (endpoint) => {303 304 305 306 return new Promise((resolve, reject) => {307 let ws: WebSocket;308 try {309 ws = new WebSocket(`ws://${endpoint.ip}:${endpoint.port}/`);310 } catch (err) {311 reject(err);312 return;313 }314 const inbox: Uint8Array[] = [];315 const waiters: Array<(b: Uint8Array) => void> = [];316 let closed = false;317 ws.binaryType = "arraybuffer";318 const timer = setTimeout(() => {319 try { ws.close(); } catch { }320 reject(new Error("pairing_transport_timeout"));321 }, 2_000);322 ws.onopen = () => {323 clearTimeout(timer);324 resolve({325 async send(data: Uint8Array) {326 ws.send(data as Uint8Array<ArrayBuffer>);327 },328 receive(): Promise<Uint8Array> {329 if (closed) return Promise.reject(new Error("closed"));330 const v = inbox.shift();331 if (v) return Promise.resolve(v);332 return new Promise<Uint8Array>((res) => waiters.push(res));333 },334 async close() {335 try { ws.close(); } catch { }336 },337 });338 };339 ws.onmessage = (ev) => {340 if (!(ev.data instanceof ArrayBuffer)) return;341 const bytes = new Uint8Array(ev.data);342 const w = waiters.shift();343 if (w) w(bytes); else inbox.push(bytes);344 };345 ws.onerror = () => {346 clearTimeout(timer);347 reject(new Error("pairing_transport_error"));348 };349 ws.onclose = () => {350 closed = true;351 while (waiters.length > 0) waiters.shift()!(new Uint8Array(0));352 };353 });354 },355 };356357 const role: PairingRole = isNativePlatform() ? "initiator" : "responder";358 if (role === "initiator") deps.openPairingServer = openCapacitorPairingServer;359360 try {361 await runChirpPairing(deps, role);362 363 364 } catch (err) {365 if (err instanceof ChirpPairingFailure) {366 367 368 return;369 }370 setPairing({ phase: "handshake_failed", errorMessage: (err as Error).message });371 }372 }373374 375376 const backArrow = (377 <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">378 <path d="M10 3 L5 8 L10 13" />379 </svg>380 );381382 383 384 const desktopBirdStage = (active: boolean) => (385 <div class={`peach-chirp-stage ${active ? "act-handshake" : "act-listening"}`} aria-hidden="true">386 <div class="peach-chirp-bird-slot peach-chirp-bird-slot-desktop">387 <DesktopBird pose="chirping" />388 </div>389 <div class="peach-chirp-bird-slot peach-chirp-bird-slot-mobile">390 <MobileBird pose="attentive" />391 </div>392 </div>393 );394395 function desktopLiveText(): string {396 switch (desktopPhase()) {397 case "enter": return "Enter the pairing code from your computer";398 case "connecting": return "Searching for the helper";399 case "sas": return "Confirm the code matches on your computer";400 case "paired": return "Paired";401 case "error": return desktopErrorCopy(desktopError()).headline;402 }403 }404405 406 const desktopView = () => (407 <div class="peach-chirp-pair peach-desktop-pair" data-phase={desktopPhase()} role="dialog" aria-label="Pair a desktop helper">408 <header class="peach-chirp-head">409 <button class="peach-chirp-back" aria-label="Back" onClick={() => props.onBack()}>{backArrow}</button>410 <div class="peach-chirp-title">Enter a code</div>411 <div class="peach-chirp-head-spacer" />412 </header>413414 {desktopBirdStage(desktopPhase() !== "enter")}415416 <Show when={desktopPhase() === "enter"}>417 <div class="peach-desktop-enter">418 <div class="peach-chirp-headline">Enter the code from your computer</div>419 <div class="peach-chirp-sub">420 Open the Peach helper on your computer and type the 6-digit code it shows.421 </div>422 <label class="peach-label" for="desktop-pair-code">pairing code</label>423 <input424 id="desktop-pair-code"425 class="peach-underline-input peach-unlock-input peach-code-input"426 type="text"427 inputmode="numeric"428 autocomplete="one-time-code"429 maxlength={6}430 placeholder="000000"431 aria-label="6-digit pairing code"432 value={desktopCode()}433 ref={(el) => setTimeout(() => el.focus(), 80)}434 onInput={(e) => onDesktopCodeInput((e.target as HTMLInputElement).value)}435 onKeyDown={(e) => { if (e.key === "Enter") void handleDesktopConnect(); }}436 />437 <div class="peach-chirp-action">438 <button class="peach-btn peach-btn-ghost" onClick={() => props.onBack()}>Cancel</button>439 <button440 class="peach-btn peach-btn-primary peach-desktop-connect"441 disabled={!desktopCodeReady() || desktopBusy()}442 onClick={() => void handleDesktopConnect()}443 >444 Connect445 </button>446 </div>447 </div>448 </Show>449450 <Show when={desktopPhase() === "connecting"}>451 <div class="peach-desktop-searching">452 <div class="peach-chirp-headline">Searching for helper…</div>453 <div class="peach-chirp-sub">Connecting to the Peach helper on your computer.</div>454 </div>455 </Show>456457 <Show when={desktopPhase() === "sas"}>458 <div class="peach-desktop-confirm" style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:8px 18px 16px;text-align:center;">459 <div class="peach-chirp-headline">Confirm this code matches on your computer:</div>460 <div461 class="peach-desktop-sas"462 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;"463 >464 {desktopSas()}465 </div>466 <div class="peach-chirp-sub" style="max-width:240px;">467 Only continue if this 6-digit code is identical on both screens.468 </div>469 <div style="display:flex;flex-direction:column;gap:8px;width:100%;max-width:240px;">470 <button class="peach-btn peach-btn-primary" disabled={desktopBusy()} onClick={() => void respondDesktopConfirm(true)}>471 Confirm472 </button>473 <button class="peach-btn peach-btn-ghost" disabled={desktopBusy()} onClick={() => void respondDesktopConfirm(false)}>474 Cancel475 </button>476 </div>477 </div>478 </Show>479480 <Show when={desktopPhase() === "paired"}>481 <div class="peach-desktop-paired">482 <div class="peach-chirp-headline">Paired!</div>483 <div class="peach-chirp-sub">484 {desktopDevice()?.deviceName ? `Connected to ${desktopDevice()!.deviceName}` : "Connected to your computer"}485 </div>486 <div class="peach-chirp-action">487 <button class="peach-btn peach-btn-primary" onClick={() => props.onBack()}>Done</button>488 </div>489 </div>490 </Show>491492 <Show when={desktopPhase() === "error"}>493 <div class="peach-desktop-error">494 <div class="peach-chirp-headline">{desktopErrorCopy(desktopError()).headline}</div>495 <div class="peach-chirp-sub">{desktopErrorCopy(desktopError()).sub}</div>496 <div class="peach-chirp-action">497 <button class="peach-btn peach-btn-ghost" onClick={() => props.onBack()}>Cancel</button>498 <button class="peach-btn peach-btn-primary" onClick={() => startDesktopFlow()}>Try again</button>499 </div>500 </div>501 </Show>502503 <div class="peach-sr-only" aria-live="polite">{desktopLiveText()}</div>504 </div>505 );506507 const chirpPairingView = () => (508 <Show509 when={sasCode()}510 fallback={511 <ChirpPairingFlow512 progress={pairing()}513 onCancel={() => props.onBack()}514 onRetry={() => void handleStartPairing()}515 onDismiss={() => props.onBack()}516 />517 }518 >519 {520}521 <div522 class="chirp-sas-verify"523 style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:24px 18px;text-align:center;"524 >525 <div style="font-size:14px;font-weight:600;color:var(--peach-text);">Verify it's really your device</div>526 <div style="font-size:11.5px;line-height:1.45;color:var(--peach-text-muted);max-width:240px;">527 Confirm this code matches the one shown on your other device before pairing.528 </div>529 <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;">530 {sasCode()}531 </div>532 <div style="display:flex;flex-direction:column;gap:8px;width:100%;max-width:240px;">533 <button534 class="peach-btn peach-btn--accent peach-btn--md"535 type="button"536 onClick={() => resolveSAS(true)}537 >538 codes match — pair539 </button>540 <button541 class="peach-btn peach-btn--ghost peach-btn--md"542 type="button"543 onClick={() => resolveSAS(false)}544 >545 they don't match546 </button>547 </div>548 </div>549 </Show>550 );551552 553 554 const pairingView = () => (555 <Show when={pairKind() === "desktop"} fallback={chirpPairingView()}>556 {desktopView()}557 </Show>558 );559560 561 562 return (563 <>564 {pairingView()}565 <Show when={adoptionConsent()}>566 {(pending) => (567 <AdoptionConsentModal568 receiverDeviceName={pending().request.device_name}569 sas={pending().sas}570 verifyMasterPassword={async (password) => {571 const res = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password } });572 return Boolean((res as { ok: boolean; data?: boolean }).ok && (res as { data?: boolean }).data);573 }}574 onCancel={() => {575 pending().resolve(false);576 setAdoptionConsent(null);577 }}578 onApprove={async () => {579 pending().resolve(true);580 setAdoptionConsent(null);581 }}582 />583 )}584 </Show>585 </>586 );587};