1import { For, Match, Show, Switch, createMemo, createSignal, onCleanup, onMount } from "solid-js";2import { sendMessage } from "../App";3import { LockRuneCanvas } from "../components/LockRuneCanvas";4import { PeachMark, PeachWordmark } from "../components/PeachMark";5import { RecoveryWordEntryView } from "./RecoveryWordEntry";6import { RecoveryWordDisplayView } from "./RecoveryWordDisplay";7import { RuneBackground } from "../components/RuneBackground";8import { storageGet, storageSet } from "../../shared/storage";9import { isPeachScript, decodePeachScript, normalisePeachScript } from "../../crypto/peachScript";10import { parsePayload } from "../../crypto/peachPayload";11import { parseImportSource } from "../../shared/import";12import { isNativePlatform, nativePlugin } from "../../shared/native";1314const peachMarkImg = new Image();15peachMarkImg.src = `data:image/svg+xml,${encodeURIComponent(16 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">' +17 '<path d="M32 54C16 54 8 44 8 31C8 18 17 10 27 10C28 7 30 5.5 32 5.5C34 5.5 36 7 37 10C47 10 56 18 56 31C56 44 48 54 32 54Z" fill="#D96830"/>' +18 '<path d="M37 8.5C40.5 3.5 48 5 46 10.5C42 11 37 8.5 37 8.5Z" fill="#7B9060"/>' +19 '<path d="M32 5.5Q35.5 1 39 2.5" stroke="#7B9060" stroke-width="1.5" stroke-linecap="round"/>' +20 '</svg>'21)}`;222324let _zxcvbn: ((password: string) => { score: number }) | null = null;25async function getZxcvbn() {26 if (_zxcvbn) return _zxcvbn;27 const mod = await import("zxcvbn");28 _zxcvbn = mod.default;29 return _zxcvbn;30}3132interface LockProps {33 hasVault: boolean;34 ready: boolean;35 isPro?: boolean;36 373839 forceRecoveryWords?: boolean;40 onUnlock: () => void | Promise<void>;41 424344 onBackToOnboarding?: () => void;45 4647 onOpenCodexRestore?: () => void;48}4950type RestoreSurface = "restore" | null;51type RecoverSurface = "words" | "new-password" | null;5253export function LockView(props: LockProps) {54 const [password, setPassword] = createSignal("");55 const [confirmPassword, setConfirmPassword] = createSignal("");56 const [error, setError] = createSignal("");57 const [loading, setLoading] = createSignal(false);58 const [restoreSurface, setRestoreSurface] = createSignal<RestoreSurface>(null);59 const [showPassword, setShowPassword] = createSignal(false);60 const [recoverSurface, setRecoverSurface] = createSignal<RecoverSurface>(null);61 62 63 const [showRecoveryWords, setShowRecoveryWords] = createSignal(props.forceRecoveryWords ?? false);6465 const MIN_LENGTH = 12;66 const MIN_ZXCVBN_SCORE = 2;6768 const [strengthScore, setStrengthScore] = createSignal<number | null>(null);69 const [biometricAvailable, setBiometricAvailable] = createSignal(false);70 const [biometricBusy, setBiometricBusy] = createSignal(false);7172 const canCreate = createMemo(73 () =>74 password().length >= MIN_LENGTH &&75 confirmPassword().length >= MIN_LENGTH &&76 password() === confirmPassword() &&77 strengthScore() !== null &&78 strengthScore()! >= MIN_ZXCVBN_SCORE79 );8081 const passwordStrength = createMemo(() => {82 const score = strengthScore();83 if (score === null) return null;84 if (score <= 1) return "weak";85 if (score === 2) return "fair";86 if (score === 3) return "good";87 return "strong";88 });8990 const createBlockerMessage = createMemo(() => {91 if (!password()) return null;92 if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;93 if (!confirmPassword()) return "confirm your password";94 if (password() !== confirmPassword()) return "passwords don't match";95 if (strengthScore() === null) return "checking strength…";96 if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak — add words or length";97 return null;98 });99100 101 createMemo(() => {102 const value = password();103 if (!value) { setStrengthScore(null); return; }104 getZxcvbn().then((zxcvbn) => {105 if (password() === value) setStrengthScore(zxcvbn(value).score);106 }).catch(() => {107 108 if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1);109 });110 });111112 async function handleUnlock() {113 if (!password().trim()) return;114 setLoading(true);115 setError("");116 const response = await sendMessage({117 type: "UNLOCK",118 payload: { password: password() },119 });120 const result = response as { ok: boolean; error?: string };121 if (result.ok) {122 await props.onUnlock();123 setLoading(false);124 return;125 }126 setError(getDisplayError(result.error, "incorrect password"));127 setPassword("");128 const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined;129 passwordField?.classList.add("shake");130 setTimeout(() => passwordField?.classList.remove("shake"), 300);131 setLoading(false);132 }133134 async function checkBiometricAvailable(): Promise<void> {135 if (!props.hasVault || !isNativePlatform()) return;136 const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");137 const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");138 if (!keystore || !biometric) return;139 try {140 const stored = await keystore.hasMasterKey();141 if (!stored.exists) return;142 const avail = await biometric.isAvailable();143 if (!avail.available) return;144 setBiometricAvailable(true);145 window.setTimeout(() => { void handleBiometricUnlock(); }, 400);146 } catch { }147 }148149 async function handleBiometricUnlock(): Promise<void> {150 if (biometricBusy()) return;151 const biometric = nativePlugin<{ authenticate: (opts: Record<string, string>) => Promise<void> }>("BiometricPlugin");152 const keystore = nativePlugin<{ retrieveMasterKey: () => Promise<{ masterKeyHex: string }> }>("KeystorePlugin");153 if (!biometric || !keystore) return;154 setBiometricBusy(true);155 setError("");156 try {157 await biometric.authenticate({158 title: "Unlock Peach",159 subtitle: "Use biometrics to access your vault",160 negativeButtonText: "Use Password",161 });162 const { masterKeyHex } = await keystore.retrieveMasterKey();163 const res = (await sendMessage({164 type: "UNLOCK_WITH_KEY",165 payload: { masterKeyHex },166 })) as { ok: boolean; error?: string };167 if (res.ok) {168 await props.onUnlock();169 return;170 }171 if (res.error === "biometric_key_invalidated") {172 await sendMessage({173 type: "UPDATE_SETTINGS",174 payload: { biometricsEnabled: false },175 });176 setBiometricAvailable(false);177 setError("biometric key expired — re-enable in settings");178 } else {179 setError("biometric unlock failed");180 }181 } catch (e) {182 const reason = e instanceof Error ? e.message : String(e);183 if (reason.includes("user_cancelled")) return;184 if (reason.includes("biometric_key_invalidated")) {185 await sendMessage({186 type: "UPDATE_SETTINGS",187 payload: { biometricsEnabled: false },188 });189 setBiometricAvailable(false);190 setError("biometric key expired — re-enable in settings");191 return;192 }193 if (reason.includes("biometric_lockout")) {194 setError("biometric locked out — use your password");195 }196 } finally {197 setBiometricBusy(false);198 }199 }200201 onMount(() => { void checkBiometricAvailable(); });202203 async function handleCreate() {204 if (!password().trim()) return;205 if (password().length < MIN_LENGTH) {206 setError(`password must be at least ${MIN_LENGTH} characters`);207 return;208 }209 if (password() !== confirmPassword()) {210 setError("passwords do not match");211 return;212 }213 const score = strengthScore();214 if (score === null || score < MIN_ZXCVBN_SCORE) {215 setError("password is too weak — try a longer or more varied passphrase");216 return;217 }218 setLoading(true);219 setError("");220 const response = await sendMessage({221 type: "CREATE_VAULT",222 payload: { password: password() },223 });224 const result = response as { ok: boolean; error?: string };225 if (result.ok) {226 227 228 229 230 231 try {232 await chrome.storage.local?.set({ peach_recovery_pending: "1" });233 } catch { }234 setShowRecoveryWords(true);235 setLoading(false);236 return;237 }238 setError(getDisplayError(result.error, "failed to create vault"));239 setLoading(false);240 }241242 function openRestore(surface: Exclude<RestoreSurface, null>) {243 if (!props.ready || loading()) return;244 setPassword("");245 setConfirmPassword("");246 setError("");247 setRestoreSurface(surface);248 }249250 251 return (252 <Switch>253 {}254 <Match when={restoreSurface() === "restore"}>255 <RestoreTabView256 hasVault={props.hasVault}257 onBack={() => setRestoreSurface(null)}258 onUnlock={async () => {259 setRestoreSurface(null);260 await props.onUnlock();261 }}262 />263 </Match>264265 {}266 <Match when={showRecoveryWords() && (!props.hasVault || props.forceRecoveryWords)}>267 <RecoveryWordDisplayView268 onDone={props.onUnlock}269 />270 </Match>271272 {}273 <Match when={recoverSurface() === "words"}>274 <RecoveryWordEntryView275 onCancel={() => setRecoverSurface(null)}276 onRecoveryComplete={props.onUnlock}277 />278 </Match>279280 {}281 <Match when={props.hasVault}>282 <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">283 <header class="peach-screen-header lock-screen-header">284 <div class="peach-wordmark-stack">285 <PeachWordmark class="peach-wordmark-wrap" />286 <Show when={props.isPro}>287 <span class="peach-pro-mini-badge">pro</span>288 </Show>289 </div>290 </header>291292 <div class="peach-lock-hero peach-lock-hero--unlock">293 <div class="peach-lock-hero-art" aria-hidden="true">294 <PeachMark class="peach-lock-hero-mark" />295 </div>296 <span class="peach-lock-eyebrow">vault locked</span>297 <h1 class="peach-onboarding-headline peach-lock-headline--unlock">298 unlock299 <br />300 your vault.301 </h1>302 </div>303304 <LockRuneCanvas />305306 <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">307 <label class="peach-label" for="unlock-password">308 master password309 </label>310 <input311 id="unlock-password"312 class="peach-underline-input peach-unlock-input lock-password-input"313 type="password"314 placeholder="••••••••••••"315 autocomplete="current-password"316 value={password()}317 disabled={!props.ready}318 ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}319 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}320 onKeyDown={(event) => {321 if (event.key !== "Enter" || !props.ready) return;322 void handleUnlock();323 }}324 />325326 <Show when={error()}>327 <p class="peach-hint peach-hint-error">{error()}</p>328 </Show>329330 <button331 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"332 onClick={() => void handleUnlock()}333 disabled={!props.ready || loading()}334 >335 {loading() ? "unlocking..." : "unlock"}336 </button>337338 <Show when={biometricAvailable()}>339 <button340 class="peach-biometric-btn"341 type="button"342 onClick={() => void handleBiometricUnlock()}343 disabled={biometricBusy() || loading()}344 aria-label="Unlock with biometrics"345 >346 <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">347 <path d="M12 11c-1.7 0-3 1.3-3 3v3"/>348 <path d="M15 17v-3"/>349 <path d="M9 4.5C9.7 4.2 10.8 4 12 4s2.3.2 3 .5"/>350 <path d="M5 9c.7-1.6 2-2.9 3.6-3.6"/>351 <path d="M19 9c-.7-1.6-2-2.9-3.6-3.6"/>352 <path d="M5 15c0-1.7.3-3.3.8-4.7"/>353 <path d="M19 15c0-1.7-.3-3.3-.8-4.7"/>354 </svg>355 {biometricBusy() ? "verifying…" : "use biometrics"}356 </button>357 </Show>358359 <button360 class="peach-lock-recover-link"361 type="button"362 onClick={() => setRecoverSurface("words")}363 >364 forgot your password?365 </button>366 </section>367 </section>368 </Match>369370 {}371 <Match when={true}>372 <section373 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"374 data-motion-phase="enter"375 >376 <header class="peach-screen-header peach-newvault-header">377 <Show when={props.onBackToOnboarding}>378 <button379 class="peach-newvault-back"380 type="button"381 onClick={() => props.onBackToOnboarding?.()}382 aria-label="Back to onboarding"383 >384 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">385 <path d="M7 2L3 5.5L7 9" />386 </svg>387 back388 </button>389 </Show>390 <div class="peach-wordmark-stack">391 <PeachWordmark class="peach-wordmark-wrap" />392 <Show when={props.isPro}>393 <span class="peach-pro-mini-badge">pro</span>394 </Show>395 </div>396 </header>397398 <div class="peach-newvault-mark" aria-hidden="true">399 <PeachMark class="peach-newvault-mark-art" />400 </div>401402 <div class="peach-newvault-card">403 <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>404 <hr class="peach-newvault-divider" />405406 <div class="peach-newvault-field">407 <div class="peach-newvault-label-row">408 <label class="peach-label" for="master-password">409 master password410 </label>411 <button412 class="peach-lock-reveal-btn"413 type="button"414 onClick={() => setShowPassword((value) => !value)}415 aria-label={showPassword() ? "Hide password" : "Show password"}416 >417 <Show418 when={showPassword()}419 fallback={420 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">421 <path d="M1 5C1 5 2.8 1 7 1C11.2 1 13 5 13 5C13 5 11.2 9 7 9C2.8 9 1 5 1 5Z" />422 <circle cx="7" cy="5" r="1.75" />423 </svg>424 }425 >426 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">427 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />428 <path d="M3.5 5.5L2.5 7.5" />429 <path d="M7 6.5V8.5" />430 <path d="M10.5 5.5L11.5 7.5" />431 </svg>432 </Show>433 {showPassword() ? "hide" : "show"}434 </button>435 </div>436 <input437 id="master-password"438 class="peach-underline-input peach-newvault-input lock-password-input"439 type={showPassword() ? "text" : "password"}440 placeholder="••••••••••••"441 autocomplete="new-password"442 value={password()}443 disabled={!props.ready}444 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}445 onKeyDown={(event) => {446 if (event.key !== "Enter" || !props.ready) return;447 void handleCreate();448 }}449 />450 <Show451 when={password().length > 0 && passwordStrength() !== null}452 fallback={453 <p class="peach-newvault-hint">at least 12 characters</p>454 }455 >456 <div class="peach-password-strength">457 <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>458 <div class="peach-strength-fill"></div>459 </div>460 <span class="peach-strength-label">{passwordStrength()}</span>461 </div>462 </Show>463 </div>464465 <div class="peach-newvault-field">466 <label class="peach-label" for="confirm-password">467 confirm password468 </label>469 <input470 id="confirm-password"471 class="peach-underline-input peach-newvault-input"472 type={showPassword() ? "text" : "password"}473 placeholder="••••••••••••"474 autocomplete="new-password"475 value={confirmPassword()}476 disabled={!props.ready}477 onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}478 onKeyDown={(event) => {479 if (event.key === "Enter" && props.ready) void handleCreate();480 }}481 />482 </div>483484 <Show when={error()}>485 <p class="peach-hint peach-hint-error">{error()}</p>486 </Show>487488 <button489 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"490 onClick={() => void handleCreate()}491 disabled={!props.ready || loading() || !canCreate()}492 aria-live="polite"493 >494 <Show495 when={!canCreate() && createBlockerMessage()}496 fallback={loading() ? "creating..." : "create vault"}497 >498 {createBlockerMessage()}499 </Show>500 </button>501502 <p class="peach-newvault-warning">503 <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>504 <span>Save your 12 recovery words right after you create the vault — they're the only way in if you forget your password.</span>505 </p>506 </div>507508 <div class="peach-newvault-footer">509 <p class="peach-hint">Already have a backup?</p>510 <button511 class="peach-lock-restore-link"512 onClick={() => void openRestore("restore")}513 disabled={!props.ready}514 >515 › restore from backup516 </button>517 <Show when={props.onOpenCodexRestore}>518 <button519 class="peach-lock-restore-link"520 onClick={() => props.onOpenCodexRestore?.()}521 disabled={!props.ready}522 >523 › restore from peach codex524 </button>525 </Show>526 </div>527 </section>528 </Match>529 </Switch>530 );531}532533534535type RestoreTab = "symbols" | "csv" | "phone";536537function tabLabel(tab: RestoreTab): string {538 return (539 { symbols: "PeachScript", csv: "CSV", phone: "From Phone" }[tab]540 );541}542543function RestoreTabView(props: {544 hasVault: boolean;545 onBack: () => void;546 onUnlock: () => void | Promise<void>;547}) {548 const [activeTab, setActiveTab] = createSignal<RestoreTab>("symbols");549 const [showIntro, setShowIntro] = createSignal(false);550 const [pendingUnlock] = createSignal(false);551552 async function handleRestoreSuccess() {553 const seen = await storageGet("peach_shown_restore_intro").catch(() => null);554 if (!seen) {555 setShowIntro(true);556 } else {557 await props.onUnlock();558 }559 }560561 async function dismissIntro() {562 await storageSet("peach_shown_restore_intro", "true").catch(() => {});563 setShowIntro(false);564 await props.onUnlock();565 }566567 return (568 <section569 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-restore-tabview"570 data-motion-phase="enter"571 >572 <RuneBackground opacity={0.05} />573574 <header class="peach-screen-header lock-screen-header">575 <PeachWordmark class="peach-wordmark-wrap" />576 <button class="peach-lock-restore-link" onClick={props.onBack}>577 ‹ back578 </button>579 </header>580581 <div class="peach-newvault-card peach-restore-tabcard">582 <div class="peach-restore-tabs" role="tablist">583 <For each={["symbols", "csv", "phone"] as RestoreTab[]}>584 {(tab) => (585 <button586 class="peach-restore-tab"587 classList={{ "is-active": activeTab() === tab }}588 onClick={() => setActiveTab(tab)}589 role="tab"590 aria-selected={activeTab() === tab}591 >592 {tabLabel(tab)}593 </button>594 )}595 </For>596 </div>597 <div class="peach-restore-content">598 <Show when={activeTab() === "symbols"}>599 <RestoreSymbols hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />600 </Show>601 <Show when={activeTab() === "csv"}>602 <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />603 </Show>604 <Show when={activeTab() === "phone"}>605 <RestoreFromPhone onSuccess={handleRestoreSuccess} />606 </Show>607 </div>608 </div>609610 {}611 <Show when={showIntro()}>612 <div class="peach-restore-intro-overlay">613 <div class="peach-restore-intro-card">614 <p class="peach-restore-intro-headline">welcome to peach.</p>615 <div class="peach-restore-intro-body">616 <p>Your vault lives only on this device. No server has a copy.</p>617 <p>The inline bubble fills passwords automatically when you visit sites.</p>618 <p>Export your vault from Settings before switching devices.</p>619 </div>620 <button621 class="peach-btn peach-btn--accent peach-btn--md peach-restore-intro-btn"622 onClick={() => void dismissIntro()}623 disabled={pendingUnlock()}624 >625 got it626 </button>627 </div>628 </div>629 </Show>630 </section>631 );632}633634635636function RestoreSymbols(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {637 const [scriptInput, setScriptInput] = createSignal("");638 const [backupPass, setBackupPass] = createSignal("");639 const [working, setWorking] = createSignal(false);640 const [animating, setAnimating] = createSignal<"chaos" | "snap" | "bloom" | null>(null);641 const [error, setError] = createSignal<string | null>(null);642 let canvasRef: HTMLCanvasElement | undefined;643 let animFrameId = 0;644 let runes: { x: number; y: number; vx: number; vy: number; char: string; size: number; angle: number; va: number }[] = [];645646 const scriptValid = createMemo(() => isPeachScript(scriptInput()));647648 function stopAnim() {649 cancelAnimationFrame(animFrameId);650 animFrameId = 0;651 }652653 function startChaos(canvas: HTMLCanvasElement) {654 const ctx = canvas.getContext("2d")!;655 const W = canvas.width;656 const H = canvas.height;657 const RUNE_CHARS = Array.from({ length: 40 }, (_, i) =>658 String.fromCodePoint(0xe000 + i)659 );660 if (runes.length === 0) {661 runes = Array.from({ length: 60 }, () => ({662 x: Math.random() * W,663 y: Math.random() * H,664 vx: (Math.random() - 0.5) * 3,665 vy: (Math.random() - 0.5) * 3,666 char: RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]!,667 size: 14 + Math.random() * 20,668 angle: Math.random() * Math.PI * 2,669 va: (Math.random() - 0.5) * 0.1,670 }));671 }672673 function draw() {674 ctx.clearRect(0, 0, W, H);675 for (const r of runes) {676 r.x = (r.x + r.vx + W) % W;677 r.y = (r.y + r.vy + H) % H;678 r.angle += r.va;679 ctx.save();680 ctx.translate(r.x, r.y);681 ctx.rotate(r.angle);682 ctx.font = `${r.size}px PeachScript, monospace`;683 const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 400 + r.x);684 ctx.fillStyle = `rgba(232, 149, 109, ${alpha})`;685 ctx.shadowColor = "rgba(232, 149, 109, 0.7)";686 ctx.shadowBlur = 8;687 ctx.fillText(r.char, 0, 0);688 ctx.restore();689 }690 animFrameId = requestAnimationFrame(draw);691 }692 draw();693 }694695 function snapToGrid(canvas: HTMLCanvasElement, onDone: () => void) {696 stopAnim();697 const ctx = canvas.getContext("2d")!;698 const W = canvas.width;699 const H = canvas.height;700 const cols = 8;701 const rows = 6;702 const cellW = W / cols;703 const cellH = H / rows;704 const targets = runes.slice(0, cols * rows).map((_, i) => ({705 x: (i % cols) * cellW + cellW / 2,706 y: Math.floor(i / cols) * cellH + cellH / 2,707 }));708 const start = performance.now();709 const DURATION = 400;710711 function drawSnap(now: number) {712 const t = Math.min((now - start) / DURATION, 1);713 const ease = t < 1 ? 1 - Math.pow(1 - t, 3) : 1;714 ctx.clearRect(0, 0, W, H);715 runes.slice(0, targets.length).forEach((r, i) => {716 const tx = targets[i]!.x;717 const ty = targets[i]!.y;718 const cx = r.x + (tx - r.x) * ease;719 const cy = r.y + (ty - r.y) * ease;720 ctx.font = `16px PeachScript, monospace`;721 ctx.fillStyle = `rgba(232, 149, 109, ${0.8 * ease})`;722 ctx.shadowColor = "rgba(232, 149, 109, 0.6)";723 ctx.shadowBlur = 6;724 ctx.fillText(r.char, cx - 8, cy + 8);725 });726 if (t < 1) {727 animFrameId = requestAnimationFrame(drawSnap);728 } else {729 onDone();730 }731 }732 animFrameId = requestAnimationFrame(drawSnap);733 }734735 function bloom(canvas: HTMLCanvasElement, onDone: () => void) {736 stopAnim();737 const ctx = canvas.getContext("2d")!;738 const W = canvas.width;739 const H = canvas.height;740 const start = performance.now();741 const DURATION = 900;742743 function drawBloom(now: number) {744 const t = Math.min((now - start) / DURATION, 1);745 ctx.clearRect(0, 0, W, H);746 747 const runeAlpha = 1 - Math.min(t / 0.4, 1);748 if (runeAlpha > 0) {749 for (const r of runes.slice(0, 48)) {750 ctx.font = `16px PeachScript, monospace`;751 ctx.fillStyle = `rgba(232, 149, 109, ${runeAlpha * 0.8})`;752 ctx.fillText(r.char, r.x - 8, r.y + 8);753 }754 }755 756 const markT = Math.max(0, (t - 0.2) / 0.5);757 const markScale = Math.min(markT * 1.2, 1);758 const markAlpha = markT < 0.5 ? markT * 2 : 2 - markT * 2;759 if (markAlpha > 0) {760 ctx.save();761 ctx.globalAlpha = markAlpha;762 ctx.shadowColor = `rgba(217, 104, 48, ${markAlpha * 0.8})`;763 ctx.shadowBlur = 30;764 ctx.translate(W / 2, H / 2);765 ctx.scale(markScale, markScale);766 ctx.drawImage(peachMarkImg, -32, -32, 64, 64);767 ctx.restore();768 }769 if (t < 1) {770 animFrameId = requestAnimationFrame(drawBloom);771 } else {772 onDone();773 }774 }775 animFrameId = requestAnimationFrame(drawBloom);776 }777778 onCleanup(() => stopAnim());779780 async function handleRestore() {781 if (!scriptValid() || !backupPass().trim() || working()) return;782 setError(null);783 setWorking(true);784 setAnimating("chaos");785786 const canvas = canvasRef;787 if (canvas) startChaos(canvas);788789 try {790 const bytes = decodePeachScript(normalisePeachScript(scriptInput()));791 const payload = await parsePayload(bytes, backupPass());792793 if (!props.hasVault) {794 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });795 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");796 }797798 const rr = await sendMessage({799 type: "RESTORE_ENTRIES",800 payload: { entries: payload.entries, mode: "merge", settings: payload.settings },801 });802 if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");803804 if (payload.license?.key) {805 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});806 }807808 setAnimating("snap");809 stopAnim();810 if (canvas) {811 snapToGrid(canvas, () => {812 setAnimating("bloom");813 bloom(canvas, async () => {814 setAnimating(null);815 await props.onSuccess();816 });817 });818 } else {819 await props.onSuccess();820 }821 } catch (err) {822 stopAnim();823 setAnimating(null);824 const code = err instanceof Error ? err.message : String(err);825 setError(826 code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"827 ? "That passphrase didn't work."828 : code === "CREATE_VAULT_FAILED"829 ? "Couldn't create a new local vault."830 : "Could not read this backup.",831 );832 } finally {833 setWorking(false);834 }835 }836837 return (838 <div class="peach-rt-symbols">839 <Show when={animating() !== null}>840 <canvas841 ref={(el) => { canvasRef = el; }}842 class="peach-rt-anim-canvas"843 width={280}844 height={200}845 />846 </Show>847 <Show when={animating() === null}>848 <textarea849 class="peach-restore-symbols-textarea"850 placeholder="paste peachscript…"851 rows={3}852 value={scriptInput()}853 onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}854 />855 <div class="peach-rt-divider">— or —</div>856 <label class="peach-btn peach-btn--ghost peach-btn--sm peach-rt-file-label">857 open .peach file858 <input859 type="file"860 accept=".peach,text/plain"861 style="display:none"862 onChange={async (e) => {863 const file = (e.currentTarget as HTMLInputElement).files?.[0];864 if (!file) return;865 try {866 setScriptInput(await file.text());867 } catch {868 setError("Couldn't read file.");869 } finally {870 (e.currentTarget as HTMLInputElement).value = "";871 }872 }}873 />874 </label>875 <input876 class="peach-restore-symbols-pass"877 type="password"878 placeholder="backup password"879 value={backupPass()}880 onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}881 onKeyDown={(e) => {882 if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {883 e.preventDefault();884 void handleRestore();885 }886 }}887 />888 <button889 class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"890 onClick={() => void handleRestore()}891 disabled={!scriptValid() || !backupPass().trim() || working()}892 >893 restore vault →894 </button>895 <Show when={error()}>896 <p class="peach-rt-error">{error()}</p>897 </Show>898 </Show>899 </div>900 );901}902903904905function RestoreCSV(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {906 const [importing, setImporting] = createSignal(false);907 const [error, setError] = createSignal<string | null>(null);908909 async function handleFile(e: Event) {910 const input = e.currentTarget as HTMLInputElement;911 const file = input.files?.[0];912 input.value = "";913 if (!file) return;914 setImporting(true);915 setError(null);916 try {917 const text = await file.text();918 const parsed = await parseImportSource(text);919 if (parsed.entries.length === 0) throw new Error("NO_ENTRIES");920921 if (props.hasVault === false) {922 923 924 925 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: "" } });926 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");927 }928929 const res = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: parsed.entries } });930 if (!(res as { ok: boolean }).ok) throw new Error("IMPORT_FAILED");931 if (parsed.licenseKey) {932 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: parsed.licenseKey } }).catch(() => {});933 }934 await props.onSuccess();935 } catch (err) {936 const code = err instanceof Error ? err.message : String(err);937 setError(938 code === "NO_ENTRIES" ? "No entries found in this file." :939 code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." :940 "Import failed. Check the file format and try again."941 );942 } finally {943 setImporting(false);944 }945 }946947 return (948 <div class="peach-rt-csv">949 <label class="peach-btn peach-btn--accent peach-btn--md peach-rt-csv-btn" classList={{ "is-busy": importing() }}>950 {importing() ? "importing…" : "open csv file"}951 <input952 type="file"953 accept=".csv,.json,.peach,application/x-peach"954 style="display:none"955 onChange={handleFile}956 disabled={importing()}957 />958 </label>959 <p class="peach-rt-csv-sources">960 Imports from Bitwarden, 1Password, Proton Pass,<br />961 Chrome, Safari, and Edge.962 </p>963 <p class="peach-rt-csv-warning">PLAIN-TEXT — DELETE THE FILE AFTER IMPORT</p>964 <Show when={error()}>965 <p class="peach-rt-error">{error()}</p>966 </Show>967 </div>968 );969}970971972973type PhoneState = "waiting" | "connected" | "trust" | "error";974975function RestoreFromPhone(props: { onSuccess: () => void | Promise<void> }) {976 const [state, setState] = createSignal<PhoneState>("waiting");977 const [code, setCode] = createSignal<string | null>(null);978 const [qrUrl, setQrUrl] = createSignal<string | null>(null);979 const [expired, setExpired] = createSignal(false);980 const [deviceName, setDeviceName] = createSignal<string | null>(null);981 const [accepting, setAccepting] = createSignal(false);982 const [serverError, setServerError] = createSignal<string | null>(null);983 let expireTimer: ReturnType<typeof setTimeout> | null = null;984985 function clearExpire() {986 if (expireTimer !== null) { clearTimeout(expireTimer); expireTimer = null; }987 }988989 async function startPairing() {990 setExpired(false);991 setCode(null);992 setQrUrl(null);993 setServerError(null);994 clearExpire();995 const res = await sendMessage({ type: "START_PAIRING_SERVER" });996 const r = res as { ok: boolean; error?: string; data?: { token: string; expiresAt: number } };997 if (!r.ok || !r.data) {998 setServerError(r.error === "pro_required" ? "Pro required." : "Couldn't start pairing server.");999 return;1000 }1001 const token = r.data.token;1002 const formatted = token.length === 6 ? `${token.slice(0, 3)} ${token.slice(3)}` : token;1003 setCode(formatted);1004 const qrData = `peach://handoff?token=${token}`;1005 try {1006 const QRCode = (await import("qrcode")).default;1007 const url = await QRCode.toDataURL(qrData, { errorCorrectionLevel: "M", margin: 2, scale: 4, color: { dark: "#000000", light: "#ffffff" } });1008 setQrUrl(url);1009 } catch {1010 1011 }1012 const remaining = Math.max(0, r.data.expiresAt - Date.now());1013 expireTimer = setTimeout(() => {1014 setCode(null);1015 setQrUrl(null);1016 setExpired(true);1017 }, remaining);1018 }10191020 function handleRuntimeMsg(msg: unknown) {1021 const m = msg as { type?: string; payload?: { deviceName?: string } };1022 if (m?.type === "PAIRING_COMPLETED") {1023 clearExpire();1024 setDeviceName(m.payload?.deviceName ?? "your phone");1025 setState("trust");1026 }1027 }10281029 onMount(() => {1030 void startPairing();1031 1032 if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {1033 chrome.runtime.onMessage.addListener(handleRuntimeMsg);1034 }1035 });10361037 onCleanup(() => {1038 clearExpire();1039 1040 if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {1041 chrome.runtime.onMessage.removeListener(handleRuntimeMsg);1042 }1043 sendMessage({ type: "STOP_PAIRING_SERVER" }).catch(() => {});1044 });10451046 async function handleAccept(trust: boolean) {1047 setAccepting(true);1048 if (trust) {1049 await sendMessage({ type: "ACCEPT_PAIRING" }).catch(() => {});1050 }1051 await props.onSuccess();1052 }10531054 return (1055 <div class="peach-rt-phone">1056 <Show when={state() === "waiting"}>1057 <Show when={!expired()}>1058 <Show when={code() !== null} fallback={<p class="peach-rt-phone-hint">starting…</p>}>1059 <p class="peach-rt-phone-code">{code()}</p>1060 <Show when={qrUrl() !== null}>1061 <img class="peach-rt-phone-qr" src={qrUrl()!} alt="pairing QR code" />1062 </Show>1063 <p class="peach-rt-phone-hint">1064 Open Peach on your phone and tap<br />1065 Settings → Send to desktop.<br />1066 Same WiFi required.1067 </p>1068 </Show>1069 <Show when={serverError()}>1070 <p class="peach-rt-error">{serverError()}</p>1071 </Show>1072 </Show>1073 <Show when={expired()}>1074 <p class="peach-rt-phone-hint">Code expired.</p>1075 <button class="peach-btn peach-btn--ghost peach-btn--sm" onClick={() => void startPairing()}>1076 Generate new code1077 </button>1078 </Show>1079 </Show>10801081 <Show when={state() === "connected"}>1082 <p class="peach-rt-phone-hint">1083 Connected to {deviceName() ?? "your phone"}.<br />1084 Receiving vault…1085 </p>1086 </Show>10871088 <Show when={state() === "trust"}>1089 <p class="peach-rt-phone-hint" style="margin-bottom:10px">1090 Vault received from {deviceName() ?? "your phone"}.<br />1091 Trust this network for sync?1092 </p>1093 <button1094 class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"1095 onClick={() => void handleAccept(true)}1096 disabled={accepting()}1097 >1098 Yes, trust this network1099 </button>1100 <button1101 class="peach-btn peach-btn--ghost peach-btn--sm"1102 style="margin-top:6px"1103 onClick={() => void handleAccept(false)}1104 disabled={accepting()}1105 >1106 Not now1107 </button>1108 </Show>11091110 <Show when={state() === "error"}>1111 <p class="peach-rt-phone-hint">1112 Could not connect. Check that both devices<br />1113 are on the same WiFi network.1114 </p>1115 <button class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit" onClick={() => { setState("waiting"); void startPairing(); }}>1116 Try again1117 </button>1118 </Show>1119 </div>1120 );1121}1122112311241125function getDisplayError(error: string | undefined, fallback: string): string {1126 if (!error) return fallback;1127 if (1128 error === "SERVICE_WORKER_UNAVAILABLE" ||1129 error.includes("Receiving end does not exist") ||1130 error.includes("message port closed")1131 ) {1132 return "background service unavailable";1133 }1134 return error;1135}