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 231 232 233 onCleanup(() => {234 if (desktopPhase() === "sas") {235 void sendMessage({ type: "DESKTOP_PAIR_CONFIRM", payload: { confirmed: false } });236 }237 });238 });239240 async function handleStartPairing(): Promise<void> {241 242 243 244 245 if (typeof chrome !== "undefined" && chrome.permissions?.request) {246 const granted = await chrome.permissions.request({247 permissions: ["audioCapture" as chrome.runtime.ManifestPermissions],248 });249 if (!granted) {250 setPairing({ phase: "mic_denied" });251 return;252 }253 }254255 setPairing({ phase: "idle" });256257 const deps: ChirpPairingDeps = {258 onProgress: (p) => setPairing(p),259 fetchLocalIdentity: async () => {260 const res = await sendMessage({ type: "CHIRP_GET_LOCAL_IDENTITY" });261 const r = res as { ok: boolean; data?: Parameters<ChirpPairingDeps["onProgress"]>[0] & object; error?: string };262 if (!r.ok || !r.data) throw new Error(r.error ?? "identity_unavailable");263 264 return r.data as unknown as Awaited<ReturnType<ChirpPairingDeps["fetchLocalIdentity"]>>;265 },266 completePairing: async (paired) => {267 const res = await sendMessage({268 type: "CHIRP_COMPLETE_PAIRING",269 payload: { paired },270 });271 const r = res as { ok: boolean; error?: string };272 if (!r.ok) throw new Error(r.error ?? "complete_pairing_failed");273 },274 confirmSAS: (sas) =>275 new Promise<boolean>((resolve) => {276 setSasResolve(() => resolve);277 setSasCode(sas);278 }),279 280 281 activatePeerLicense: async (license) => {282 if (props.isPro) return;283 const res = (await sendMessage({284 type: "ACTIVATE_LICENSE",285 payload: { key: license },286 })) as { ok: boolean; data?: { valid?: boolean; plan?: string } };287 if (res.ok && res.data?.valid && res.data?.plan === "pro") props.onProChanged?.();288 },289 grantAdoptionConsent: (request, sas) =>290 new Promise<boolean>((resolve) => {291 setAdoptionConsent({ request, sas, resolve });292 }),293 fetchAdoptionMaterial: async () => {294 const res = await sendMessage({ type: "CHIRP_GET_ADOPTION_MATERIAL" });295 const r = res as {296 ok: boolean;297 data?: { rootKeyHex?: string; vaultId?: string; license?: string };298 error?: string;299 };300 if (!r.ok || typeof r.data?.rootKeyHex !== "string" || typeof r.data?.vaultId !== "string") {301 throw new Error(r.error ?? "adoption_material_unavailable");302 }303 return {304 rootKeyHex: r.data.rootKeyHex,305 vaultId: r.data.vaultId,306 license: r.data.license,307 };308 },309 getLocalSubnet,310 openPairingTransport: async (endpoint) => {311 312 313 314 return new Promise((resolve, reject) => {315 let ws: WebSocket;316 try {317 ws = new WebSocket(`ws://${endpoint.ip}:${endpoint.port}/`);318 } catch (err) {319 reject(err);320 return;321 }322 const inbox: Uint8Array[] = [];323 const waiters: Array<(b: Uint8Array) => void> = [];324 let closed = false;325 ws.binaryType = "arraybuffer";326 const timer = setTimeout(() => {327 try { ws.close(); } catch { }328 reject(new Error("pairing_transport_timeout"));329 }, 2_000);330 ws.onopen = () => {331 clearTimeout(timer);332 resolve({333 async send(data: Uint8Array) {334 ws.send(data as Uint8Array<ArrayBuffer>);335 },336 receive(): Promise<Uint8Array> {337 if (closed) return Promise.reject(new Error("closed"));338 const v = inbox.shift();339 if (v) return Promise.resolve(v);340 return new Promise<Uint8Array>((res) => waiters.push(res));341 },342 async close() {343 try { ws.close(); } catch { }344 },345 });346 };347 ws.onmessage = (ev) => {348 if (!(ev.data instanceof ArrayBuffer)) return;349 const bytes = new Uint8Array(ev.data);350 const w = waiters.shift();351 if (w) w(bytes); else inbox.push(bytes);352 };353 ws.onerror = () => {354 clearTimeout(timer);355 reject(new Error("pairing_transport_error"));356 };357 ws.onclose = () => {358 closed = true;359 while (waiters.length > 0) waiters.shift()!(new Uint8Array(0));360 };361 });362 },363 };364365 const role: PairingRole = isNativePlatform() ? "initiator" : "responder";366 if (role === "initiator") deps.openPairingServer = openCapacitorPairingServer;367368 try {369 await runChirpPairing(deps, role);370 371 372 } catch (err) {373 if (err instanceof ChirpPairingFailure) {374 375 376 return;377 }378 setPairing({ phase: "handshake_failed", errorMessage: (err as Error).message });379 }380 }381382 383384 const backArrow = (385 <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">386 <path d="M10 3 L5 8 L10 13" />387 </svg>388 );389390 391 392 const desktopBirdStage = (active: boolean) => (393 <div class={`peach-chirp-stage ${active ? "act-handshake" : "act-listening"}`} aria-hidden="true">394 <div class="peach-chirp-bird-slot peach-chirp-bird-slot-desktop">395 <DesktopBird pose="chirping" />396 </div>397 <div class="peach-chirp-bird-slot peach-chirp-bird-slot-mobile">398 <MobileBird pose="attentive" />399 </div>400 </div>401 );402403 function desktopLiveText(): string {404 switch (desktopPhase()) {405 case "enter": return "Enter the pairing code from your computer";406 case "connecting": return "Searching for the helper";407 case "sas": return "Confirm the code matches on your computer";408 case "paired": return "Paired";409 case "error": return desktopErrorCopy(desktopError()).headline;410 }411 }412413 414 const desktopView = () => (415 <div class="peach-chirp-pair peach-desktop-pair" data-phase={desktopPhase()} role="dialog" aria-label="Pair a desktop helper">416 <header class="peach-chirp-head">417 <button class="peach-chirp-back" aria-label="Back" onClick={() => props.onBack()}>{backArrow}</button>418 <div class="peach-chirp-title">Enter a code</div>419 <div class="peach-chirp-head-spacer" />420 </header>421422 {desktopBirdStage(desktopPhase() !== "enter")}423424 <Show when={desktopPhase() === "enter"}>425 <div class="peach-desktop-enter">426 <div class="peach-chirp-headline">Enter the code from your computer</div>427 <div class="peach-chirp-sub">428 Open the Peach helper on your computer and type the 6-digit code it shows.429 </div>430 <label class="peach-label" for="desktop-pair-code">pairing code</label>431 <input432 id="desktop-pair-code"433 class="peach-underline-input peach-unlock-input peach-code-input"434 type="text"435 inputmode="numeric"436 autocomplete="one-time-code"437 maxlength={6}438 placeholder="000000"439 aria-label="6-digit pairing code"440 value={desktopCode()}441 ref={(el) => setTimeout(() => el.focus(), 80)}442 onInput={(e) => onDesktopCodeInput((e.target as HTMLInputElement).value)}443 onKeyDown={(e) => { if (e.key === "Enter") void handleDesktopConnect(); }}444 />445 <div class="peach-chirp-action">446 <button class="peach-btn peach-btn-ghost" onClick={() => props.onBack()}>Cancel</button>447 <button448 class="peach-btn peach-btn-primary peach-desktop-connect"449 disabled={!desktopCodeReady() || desktopBusy()}450 onClick={() => void handleDesktopConnect()}451 >452 Connect453 </button>454 </div>455 </div>456 </Show>457458 <Show when={desktopPhase() === "connecting"}>459 <div class="peach-desktop-searching">460 <div class="peach-chirp-headline">Searching for helper…</div>461 <div class="peach-chirp-sub">Connecting to the Peach helper on your computer.</div>462 </div>463 </Show>464465 <Show when={desktopPhase() === "sas"}>466 <div class="peach-desktop-confirm" style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:8px 18px 16px;text-align:center;">467 <div class="peach-chirp-headline">Confirm this code matches on your computer:</div>468 <div469 class="peach-desktop-sas"470 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;"471 >472 {desktopSas()}473 </div>474 <div class="peach-chirp-sub" style="max-width:240px;">475 Only continue if this 6-digit code is identical on both screens.476 </div>477 <div style="display:flex;flex-direction:column;gap:8px;width:100%;max-width:240px;">478 <button class="peach-btn peach-btn-primary" disabled={desktopBusy()} onClick={() => void respondDesktopConfirm(true)}>479 Confirm480 </button>481 <button class="peach-btn peach-btn-ghost" disabled={desktopBusy()} onClick={() => void respondDesktopConfirm(false)}>482 Cancel483 </button>484 </div>485 </div>486 </Show>487488 <Show when={desktopPhase() === "paired"}>489 <div class="peach-desktop-paired">490 <div class="peach-chirp-headline">Paired!</div>491 <div class="peach-chirp-sub">492 {desktopDevice()?.deviceName ? `Connected to ${desktopDevice()!.deviceName}` : "Connected to your computer"}493 </div>494 <div class="peach-chirp-action">495 <button class="peach-btn peach-btn-primary" onClick={() => props.onBack()}>Done</button>496 </div>497 </div>498 </Show>499500 <Show when={desktopPhase() === "error"}>501 <div class="peach-desktop-error">502 <div class="peach-chirp-headline">{desktopErrorCopy(desktopError()).headline}</div>503 <div class="peach-chirp-sub">{desktopErrorCopy(desktopError()).sub}</div>504 <div class="peach-chirp-action">505 <button class="peach-btn peach-btn-ghost" onClick={() => props.onBack()}>Cancel</button>506 <button class="peach-btn peach-btn-primary" onClick={() => startDesktopFlow()}>Try again</button>507 </div>508 </div>509 </Show>510511 <div class="peach-sr-only" aria-live="polite">{desktopLiveText()}</div>512 </div>513 );514515 const chirpPairingView = () => (516 <Show517 when={sasCode()}518 fallback={519 <ChirpPairingFlow520 progress={pairing()}521 onCancel={() => props.onBack()}522 onRetry={() => void handleStartPairing()}523 onDismiss={() => props.onBack()}524 />525 }526 >527 {528}529 <div530 class="chirp-sas-verify"531 style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:24px 18px;text-align:center;"532 >533 <div style="font-size:14px;font-weight:600;color:var(--peach-text);">Verify it's really your device</div>534 <div style="font-size:11.5px;line-height:1.45;color:var(--peach-text-muted);max-width:240px;">535 Confirm this code matches the one shown on your other device before pairing.536 </div>537 <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;">538 {sasCode()}539 </div>540 <div style="display:flex;flex-direction:column;gap:8px;width:100%;max-width:240px;">541 <button542 class="peach-btn peach-btn--accent peach-btn--md"543 type="button"544 onClick={() => resolveSAS(true)}545 >546 codes match — pair547 </button>548 <button549 class="peach-btn peach-btn--ghost peach-btn--md"550 type="button"551 onClick={() => resolveSAS(false)}552 >553 they don't match554 </button>555 </div>556 </div>557 </Show>558 );559560 561 562 const pairingView = () => (563 <Show when={pairKind() === "desktop"} fallback={chirpPairingView()}>564 {desktopView()}565 </Show>566 );567568 569 570 return (571 <>572 {pairingView()}573 <Show when={adoptionConsent()}>574 {(pending) => (575 <AdoptionConsentModal576 receiverDeviceName={pending().request.device_name}577 sas={pending().sas}578 verifyMasterPassword={async (password) => {579 const res = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password } });580 return Boolean((res as { ok: boolean; data?: boolean }).ok && (res as { data?: boolean }).data);581 }}582 onCancel={() => {583 pending().resolve(false);584 setAdoptionConsent(null);585 }}586 onApprove={async () => {587 pending().resolve(true);588 setAdoptionConsent(null);589 }}590 />591 )}592 </Show>593 </>594 );595};