123456789101112131415161718import {19 Component,20 Show,21 createEffect,22 createSignal,23 onCleanup,24 onMount,25} from "solid-js";26import { sendMessage } from "../../shared/bridge";27import type {28 PairedDevice,29 PairingProgress,30} from "../../shared/sync-types";31import type { AdoptRequest } from "../../shared/chirp-adopt";32import { ChirpPairingFlow, DesktopBird, MobileBird } from "../components/ChirpPairingFlow";33import { AdoptionConsentModal } from "../components/AdoptionConsentModal";34import {35 ChirpPairingFailure,36 runChirpPairing,37 type ChirpPairingDeps,38 type PairingRole,39} from "../chirp-pair";40import { formatPairingCode, parsePairingCode } from "../../shared/pairing-code";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 55 onProChanged?: () => void;56}57585960type PairKind = "chirp" | "desktop" | null;6162636465type DesktopPhase = "enter" | "connecting" | "sas" | "paired" | "error";66type DesktopPairingError =67 | "invalid_code"68 | "no_host_found"69 | "handshake_failed"70 | "sas_mismatch"71 | "vault_locked";7273const DESKTOP_PAIRED_DISMISS_MS = 2_000;7475function desktopErrorCopy(error: DesktopPairingError | "unknown"): { headline: string; sub: string } {76 switch (error) {77 case "invalid_code":78 79 80 return {81 headline: "Code not found or expired",82 sub: "Check the code on your phone and try again. Codes expire, so grab a fresh one if it's been a while.",83 };84 case "no_host_found":85 return {86 headline: "Couldn't reach your phone",87 sub: "Make sure both devices are on the same Wi-Fi and the pairing screen is still open on your phone.",88 };89 case "sas_mismatch":90 return {91 headline: "Codes don't match",92 sub: "For your safety, pairing was stopped. Start over and compare the two codes carefully.",93 };94 case "vault_locked":95 return {96 headline: "Unlock Peach first",97 sub: "Your vault needs to be unlocked before you can pair a device.",98 };99 case "handshake_failed":100 default:101 return {102 headline: "Pairing failed",103 sub: "Something went wrong while connecting. Please try again.",104 };105 }106}107108export const ChirpSyncView: Component<ChirpSyncViewProps> = (props) => {109 const [pairing, setPairing] = createSignal<PairingProgress>({ phase: "idle" });110 111 112 113 const [sasCode, setSasCode] = createSignal<string | null>(null);114 const [sasResolve, setSasResolve] = createSignal<((ok: boolean) => void) | null>(null);115 const [adoptionConsent, setAdoptionConsent] = createSignal<{116 request: AdoptRequest;117 sas: string;118 resolve: (ok: boolean) => void;119 } | null>(null);120121 function resolveSAS(ok: boolean) {122 const r = sasResolve();123 setSasCode(null);124 setSasResolve(null);125 r?.(ok);126 }127128 129 130 131 const [pairKind, setPairKind] = createSignal<PairKind>(props.initialPair ?? null);132 const [desktopPhase, setDesktopPhase] = createSignal<DesktopPhase>("enter");133 const [desktopCode, setDesktopCode] = createSignal("");134 const [desktopSas, setDesktopSas] = createSignal<string | null>(null);135 const [desktopError, setDesktopError] = createSignal<DesktopPairingError | "unknown">("unknown");136 const [desktopDevice, setDesktopDevice] = createSignal<PairedDevice | undefined>(undefined);137 const [desktopBusy, setDesktopBusy] = createSignal(false);138139 140 141 142 const desktopCodeReady = () =>143 parsePairingCode(desktopCode()) !== null || /^\d{6}$/.test(desktopCode());144145 function startDesktopFlow(): void {146 setDesktopCode("");147 setDesktopSas(null);148 setDesktopError("unknown");149 setDesktopDevice(undefined);150 setDesktopBusy(false);151 setDesktopPhase("enter");152 setPairKind("desktop");153 }154155 function startChirpFlow(): void {156 setPairKind("chirp");157 void handleStartPairing();158 }159160 function onDesktopCodeInput(value: string): void {161 const cleaned = value.toUpperCase().replace(/[^0-9A-Z]/g, "").slice(0, 32);162 163 164 165 setDesktopCode(cleaned.length > 6 ? formatPairingCode(cleaned) : cleaned);166 }167168 async function handleDesktopConnect(): Promise<void> {169 if (!desktopCodeReady() || desktopBusy()) return;170 setDesktopBusy(true);171 setDesktopPhase("connecting");172 try {173 const res = (await sendMessage({174 type: "PAIR_WITH_DESKTOP",175 payload: { code: desktopCode() },176 })) as { ok: boolean; error?: string };177 178 179 if (!res.ok) {180 setDesktopError((res.error as DesktopPairingError) ?? "unknown");181 setDesktopPhase("error");182 }183 } catch {184 setDesktopError("unknown");185 setDesktopPhase("error");186 } finally {187 setDesktopBusy(false);188 }189 }190191 async function respondDesktopConfirm(confirmed: boolean): Promise<void> {192 if (desktopBusy()) return;193 setDesktopBusy(true);194 195 196 if (confirmed) setDesktopPhase("connecting");197 else { setDesktopError("sas_mismatch"); setDesktopPhase("error"); }198 try {199 await sendMessage({ type: "DESKTOP_PAIR_CONFIRM", payload: { confirmed } });200 } finally {201 setDesktopBusy(false);202 }203 }204205 206 207 createEffect(() => {208 if (desktopPhase() !== "paired") return;209 const t = setTimeout(() => props.onBack(), DESKTOP_PAIRED_DISMISS_MS);210 onCleanup(() => clearTimeout(t));211 });212213 onMount(() => {214 215 216 if (props.initialPair === "chirp") startChirpFlow();217 else if (props.initialPair === "desktop") startDesktopFlow();218 else props.onBack();219220 221 222 223 if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {224 const handler = (msg: { type?: string; payload?: unknown }) => {225 if (pairKind() !== "desktop") return;226 if (msg?.type === "DESKTOP_PAIR_SAS") {227 const p = msg.payload as { sas?: string } | undefined;228 if (typeof p?.sas === "string") { setDesktopSas(p.sas); setDesktopPhase("sas"); }229 } else if (msg?.type === "DESKTOP_PAIR_RESULT") {230 const p = msg.payload as231 | { status: "complete"; device: PairedDevice }232 | { status: "error"; error: DesktopPairingError }233 | undefined;234 if (p?.status === "complete") { setDesktopDevice(p.device); setDesktopPhase("paired"); }235 else if (p?.status === "error") { setDesktopError(p.error); setDesktopPhase("error"); }236 }237 };238 chrome.runtime.onMessage.addListener(handler);239 onCleanup(() => chrome.runtime.onMessage.removeListener(handler));240 }241 242 243 244 onCleanup(() => {245 if (desktopPhase() === "sas") {246 void sendMessage({ type: "DESKTOP_PAIR_CONFIRM", payload: { confirmed: false } });247 }248 });249 });250251 async function handleStartPairing(): Promise<void> {252 253 254 255 256 if (typeof chrome !== "undefined" && chrome.permissions?.request) {257 const granted = await chrome.permissions.request({258 permissions: ["audioCapture" as chrome.runtime.ManifestPermissions],259 });260 if (!granted) {261 setPairing({ phase: "mic_denied" });262 return;263 }264 }265266 setPairing({ phase: "idle" });267268 const deps: ChirpPairingDeps = {269 onProgress: (p) => setPairing(p),270 fetchLocalIdentity: async () => {271 const res = await sendMessage({ type: "CHIRP_GET_LOCAL_IDENTITY" });272 const r = res as { ok: boolean; data?: Parameters<ChirpPairingDeps["onProgress"]>[0] & object; error?: string };273 if (!r.ok || !r.data) throw new Error(r.error ?? "identity_unavailable");274 275 return r.data as unknown as Awaited<ReturnType<ChirpPairingDeps["fetchLocalIdentity"]>>;276 },277 completePairing: async (paired) => {278 const res = await sendMessage({279 type: "CHIRP_COMPLETE_PAIRING",280 payload: { paired },281 });282 const r = res as { ok: boolean; error?: string };283 if (!r.ok) throw new Error(r.error ?? "complete_pairing_failed");284 },285 confirmSAS: (sas) =>286 new Promise<boolean>((resolve) => {287 setSasResolve(() => resolve);288 setSasCode(sas);289 }),290 291 292 activatePeerLicense: async (license) => {293 if (props.isPro) return;294 const res = (await sendMessage({295 type: "ACTIVATE_LICENSE",296 payload: { key: license },297 })) as { ok: boolean; data?: { valid?: boolean; plan?: string } };298 if (res.ok && res.data?.valid && res.data?.plan === "pro") props.onProChanged?.();299 },300 grantAdoptionConsent: (request, sas) =>301 new Promise<boolean>((resolve) => {302 setAdoptionConsent({ request, sas, resolve });303 }),304 fetchAdoptionMaterial: async () => {305 const res = await sendMessage({ type: "CHIRP_GET_ADOPTION_MATERIAL" });306 const r = res as {307 ok: boolean;308 data?: { rootKeyHex?: string; vaultId?: string; license?: string };309 error?: string;310 };311 if (!r.ok || typeof r.data?.rootKeyHex !== "string" || typeof r.data?.vaultId !== "string") {312 throw new Error(r.error ?? "adoption_material_unavailable");313 }314 return {315 rootKeyHex: r.data.rootKeyHex,316 vaultId: r.data.vaultId,317 license: r.data.license,318 };319 },320 getLocalSubnet,321 openPairingTransport: async (endpoint) => {322 323 324 325 return new Promise((resolve, reject) => {326 let ws: WebSocket;327 try {328 ws = new WebSocket(`ws://${endpoint.ip}:${endpoint.port}/`);329 } catch (err) {330 reject(err);331 return;332 }333 const inbox: Uint8Array[] = [];334 const waiters: Array<(b: Uint8Array) => void> = [];335 let closed = false;336 ws.binaryType = "arraybuffer";337 const timer = setTimeout(() => {338 try { ws.close(); } catch { }339 reject(new Error("pairing_transport_timeout"));340 }, 2_000);341 ws.onopen = () => {342 clearTimeout(timer);343 resolve({344 async send(data: Uint8Array) {345 ws.send(data as Uint8Array<ArrayBuffer>);346 },347 receive(): Promise<Uint8Array> {348 if (closed) return Promise.reject(new Error("closed"));349 const v = inbox.shift();350 if (v) return Promise.resolve(v);351 return new Promise<Uint8Array>((res) => waiters.push(res));352 },353 async close() {354 try { ws.close(); } catch { }355 },356 });357 };358 ws.onmessage = (ev) => {359 if (!(ev.data instanceof ArrayBuffer)) return;360 const bytes = new Uint8Array(ev.data);361 const w = waiters.shift();362 if (w) w(bytes); else inbox.push(bytes);363 };364 ws.onerror = () => {365 clearTimeout(timer);366 reject(new Error("pairing_transport_error"));367 };368 ws.onclose = () => {369 closed = true;370 while (waiters.length > 0) waiters.shift()!(new Uint8Array(0));371 };372 });373 },374 };375376 const role: PairingRole = isNativePlatform() ? "initiator" : "responder";377 if (role === "initiator") deps.openPairingServer = openCapacitorPairingServer;378379 try {380 await runChirpPairing(deps, role);381 382 383 } catch (err) {384 if (err instanceof ChirpPairingFailure) {385 386 387 return;388 }389 setPairing({ phase: "handshake_failed", errorMessage: (err as Error).message });390 }391 }392393 394395 const backArrow = (396 <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">397 <path d="M10 3 L5 8 L10 13" />398 </svg>399 );400401 402 403 const desktopBirdStage = (active: boolean) => (404 <div class={`peach-chirp-stage ${active ? "act-handshake" : "act-listening"}`} aria-hidden="true">405 <div class="peach-chirp-bird-slot peach-chirp-bird-slot-desktop">406 <DesktopBird pose="chirping" />407 </div>408 <div class="peach-chirp-bird-slot peach-chirp-bird-slot-mobile">409 <MobileBird pose="attentive" />410 </div>411 </div>412 );413414 function desktopLiveText(): string {415 switch (desktopPhase()) {416 case "enter": return "Enter the pairing code from your phone";417 case "connecting": return "Connecting to your phone";418 case "sas": return "Confirm the code matches on your phone";419 case "paired": return "Paired";420 case "error": return desktopErrorCopy(desktopError()).headline;421 }422 }423424 425 const desktopView = () => (426 <div class="peach-chirp-pair peach-desktop-pair" data-phase={desktopPhase()} role="dialog" aria-label="Pair a device with a code">427 <header class="peach-chirp-head">428 <button class="peach-chirp-back" aria-label="Back" onClick={() => props.onBack()}>{backArrow}</button>429 <div class="peach-chirp-title">Enter a code</div>430 <div class="peach-chirp-head-spacer" />431 </header>432433 {desktopBirdStage(desktopPhase() !== "enter")}434435 <Show when={desktopPhase() === "enter"}>436 <div class="peach-desktop-enter">437 <div class="peach-chirp-headline">Enter the code from your phone</div>438 <div class="peach-chirp-sub">439 Open Peach on your phone, tap “Pair a device”, and type the code it shows.440 Both devices need to be on the same Wi-Fi.441 </div>442 <label class="peach-label" for="desktop-pair-code">pairing code</label>443 <input444 id="desktop-pair-code"445 class="peach-underline-input peach-unlock-input peach-code-input"446 type="text"447 autocapitalize="characters"448 autocomplete="one-time-code"449 spellcheck={false}450 maxlength={32}451 placeholder="A74H-48J3-KI32-OEP4"452 aria-label="Pairing code"453 value={desktopCode()}454 ref={(el) => setTimeout(() => el.focus(), 80)}455 onInput={(e) => onDesktopCodeInput((e.target as HTMLInputElement).value)}456 onKeyDown={(e) => { if (e.key === "Enter") void handleDesktopConnect(); }}457 />458 <div class="peach-chirp-action">459 <button class="peach-btn peach-btn-ghost" onClick={() => props.onBack()}>Cancel</button>460 <button461 class="peach-btn peach-btn-primary peach-desktop-connect"462 disabled={!desktopCodeReady() || desktopBusy()}463 onClick={() => void handleDesktopConnect()}464 >465 Connect466 </button>467 </div>468 </div>469 </Show>470471 <Show when={desktopPhase() === "connecting"}>472 <div class="peach-desktop-searching">473 <div class="peach-chirp-headline">Connecting…</div>474 <div class="peach-chirp-sub">Reaching your phone over the local network.</div>475 </div>476 </Show>477478 <Show when={desktopPhase() === "sas"}>479 <div class="peach-desktop-confirm" style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:8px 18px 16px;text-align:center;">480 <div class="peach-chirp-headline">Confirm this code matches on your phone:</div>481 <div482 class="peach-desktop-sas"483 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;"484 >485 {desktopSas()}486 </div>487 <div class="peach-chirp-sub" style="max-width:240px;">488 Only continue if this 6-digit code is identical on both screens. This is489 what proves you're talking to your own device.490 </div>491 <div style="display:flex;flex-direction:column;gap:8px;width:100%;max-width:240px;">492 <button class="peach-btn peach-btn-primary" disabled={desktopBusy()} onClick={() => void respondDesktopConfirm(true)}>493 Confirm494 </button>495 <button class="peach-btn peach-btn-ghost" disabled={desktopBusy()} onClick={() => void respondDesktopConfirm(false)}>496 Cancel497 </button>498 </div>499 </div>500 </Show>501502 <Show when={desktopPhase() === "paired"}>503 <div class="peach-desktop-paired">504 <div class="peach-chirp-headline">Paired!</div>505 <div class="peach-chirp-sub">506 {desktopDevice()?.deviceName ? `Connected to ${desktopDevice()!.deviceName}` : "Connected to your phone"}507 </div>508 <div class="peach-chirp-action">509 <button class="peach-btn peach-btn-primary" onClick={() => props.onBack()}>Done</button>510 </div>511 </div>512 </Show>513514 <Show when={desktopPhase() === "error"}>515 <div class="peach-desktop-error">516 <div class="peach-chirp-headline">{desktopErrorCopy(desktopError()).headline}</div>517 <div class="peach-chirp-sub">{desktopErrorCopy(desktopError()).sub}</div>518 <div class="peach-chirp-action">519 <button class="peach-btn peach-btn-ghost" onClick={() => props.onBack()}>Cancel</button>520 <button class="peach-btn peach-btn-primary" onClick={() => startDesktopFlow()}>Try again</button>521 </div>522 </div>523 </Show>524525 <div class="peach-sr-only" aria-live="polite">{desktopLiveText()}</div>526 </div>527 );528529 const chirpPairingView = () => (530 <Show531 when={sasCode()}532 fallback={533 <ChirpPairingFlow534 progress={pairing()}535 onCancel={() => props.onBack()}536 onRetry={() => void handleStartPairing()}537 onDismiss={() => props.onBack()}538 />539 }540 >541 {542}543 <div544 class="chirp-sas-verify"545 style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:24px 18px;text-align:center;"546 >547 <div style="font-size:14px;font-weight:600;color:var(--peach-text);">Verify it's really your device</div>548 <div style="font-size:11.5px;line-height:1.45;color:var(--peach-text-muted);max-width:240px;">549 Confirm this code matches the one shown on your other device before pairing.550 </div>551 <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;">552 {sasCode()}553 </div>554 <div style="display:flex;flex-direction:column;gap:8px;width:100%;max-width:240px;">555 <button556 class="peach-btn peach-btn--accent peach-btn--md"557 type="button"558 onClick={() => resolveSAS(true)}559 >560 codes match — pair561 </button>562 <button563 class="peach-btn peach-btn--ghost peach-btn--md"564 type="button"565 onClick={() => resolveSAS(false)}566 >567 they don't match568 </button>569 </div>570 </div>571 </Show>572 );573574 575 576 const pairingView = () => (577 <Show when={pairKind() === "desktop"} fallback={chirpPairingView()}>578 {desktopView()}579 </Show>580 );581582 583 584 return (585 <>586 {pairingView()}587 <Show when={adoptionConsent()}>588 {(pending) => (589 <AdoptionConsentModal590 receiverDeviceName={pending().request.device_name}591 sas={pending().sas}592 verifyMasterPassword={async (password) => {593 const res = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password } });594 return Boolean((res as { ok: boolean; data?: boolean }).ok && (res as { data?: boolean }).data);595 }}596 onCancel={() => {597 pending().resolve(false);598 setAdoptionConsent(null);599 }}600 onApprove={async () => {601 pending().resolve(true);602 setAdoptionConsent(null);603 }}604 />605 )}606 </Show>607 </>608 );609};