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 240 240" fill="none">' +17 '<path d="M 124 64 C 134 32 168 16 200 22 C 198 56 168 72 130 66 Z" fill="#92AC65"/>' +18 '<path d="M 130 64 C 152 46 178 30 196 24" stroke="#5E7A42" stroke-width="2.6" fill="none" stroke-linecap="round"/>' +19 '<path d="M 120 60 C 75 45 28 75 22 130 C 18 185 65 222 125 222 C 185 222 222 185 220 130 C 218 78 170 45 120 60 Z" fill="#FFB082"/>' +20 '<path d="M 120 60 C 75 45 28 75 22 130 C 18 185 65 222 125 222 C 105 195 75 150 100 90 C 105 75 112 65 120 60 Z" fill="#E5805C"/>' +21 '<ellipse cx="178" cy="108" rx="20" ry="11" fill="#FFD0A8" opacity="0.7" transform="rotate(-26 178 108)"/>' +22 '</svg>'23)}`;2425262728let _zxcvbn: ((password: string) => { score: number }) | null = null;29async function getZxcvbn() {30 if (_zxcvbn) return _zxcvbn;31 const [{ zxcvbn, zxcvbnOptions }, common, en] = await Promise.all([32 import("@zxcvbn-ts/core"),33 import("@zxcvbn-ts/language-common"),34 import("@zxcvbn-ts/language-en"),35 ]);36 zxcvbnOptions.setOptions({37 translations: en.translations,38 graphs: common.adjacencyGraphs,39 dictionary: { ...common.dictionary, ...en.dictionary },40 });41 _zxcvbn = (password: string) => zxcvbn(password);42 return _zxcvbn;43}4445interface LockProps {46 hasVault: boolean;47 ready: boolean;48 isPro?: boolean;49 505152 forceRecoveryWords?: boolean;53 onUnlock: () => void | Promise<void>;54 555657 onBackToOnboarding?: () => void;58 5960 onOpenCodexRestore?: () => void;61}6263type RestoreSurface = "restore" | null;64type RecoverSurface = "words" | "new-password" | null;6566export function LockView(props: LockProps) {67 const [password, setPassword] = createSignal("");68 const [confirmPassword, setConfirmPassword] = createSignal("");69 const [error, setError] = createSignal("");70 const [loading, setLoading] = createSignal(false);71 const [restoreSurface, setRestoreSurface] = createSignal<RestoreSurface>(null);72 const [showPassword, setShowPassword] = createSignal(false);73 const [recoverSurface, setRecoverSurface] = createSignal<RecoverSurface>(null);74 75 76 const [showRecoveryWords, setShowRecoveryWords] = createSignal(props.forceRecoveryWords ?? false);7778 const MIN_LENGTH = 12;79 const MIN_ZXCVBN_SCORE = 2;8081 const [strengthScore, setStrengthScore] = createSignal<number | null>(null);82 const [biometricAvailable, setBiometricAvailable] = createSignal(false);83 const [biometricBusy, setBiometricBusy] = createSignal(false);8485 const canCreate = createMemo(86 () =>87 password().length >= MIN_LENGTH &&88 confirmPassword().length >= MIN_LENGTH &&89 password() === confirmPassword() &&90 strengthScore() !== null &&91 strengthScore()! >= MIN_ZXCVBN_SCORE92 );9394 const passwordStrength = createMemo(() => {95 const score = strengthScore();96 if (score === null) return null;97 if (score <= 1) return "weak";98 if (score === 2) return "fair";99 if (score === 3) return "good";100 return "strong";101 });102103 const createBlockerMessage = createMemo(() => {104 if (!password()) return null;105 if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;106 if (!confirmPassword()) return "confirm your password";107 if (password() !== confirmPassword()) return "passwords don't match";108 if (strengthScore() === null) return "checking strength…";109 if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak — use a longer passphrase";110 return null;111 });112113 114 createMemo(() => {115 const value = password();116 if (!value) { setStrengthScore(null); return; }117 getZxcvbn().then((zxcvbn) => {118 if (password() === value) setStrengthScore(zxcvbn(value).score);119 }).catch(() => {120 121 if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1);122 });123 });124125 async function handleUnlock() {126 if (!password().trim()) return;127 setLoading(true);128 setError("");129 const response = await sendMessage({130 type: "UNLOCK",131 payload: { password: password() },132 });133 const result = response as { ok: boolean; error?: string };134 if (result.ok) {135 await props.onUnlock();136 setLoading(false);137 return;138 }139 setError(getDisplayError(result.error, "incorrect password"));140 setPassword("");141 const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined;142 passwordField?.classList.add("shake");143 setTimeout(() => passwordField?.classList.remove("shake"), 300);144 setLoading(false);145 }146147 async function checkBiometricAvailable(): Promise<void> {148 if (!props.hasVault || !isNativePlatform()) return;149 const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");150 const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");151 if (!keystore || !biometric) return;152 try {153 const stored = await keystore.hasMasterKey();154 if (!stored.exists) return;155 const avail = await biometric.isAvailable();156 if (!avail.available) return;157 setBiometricAvailable(true);158 window.setTimeout(() => { void handleBiometricUnlock(); }, 400);159 } catch { }160 }161162 async function handleBiometricUnlock(): Promise<void> {163 if (biometricBusy()) return;164 const biometric = nativePlugin<{ authenticate: (opts: Record<string, string>) => Promise<void> }>("BiometricPlugin");165 const keystore = nativePlugin<{ retrieveMasterKey: () => Promise<{ masterKeyHex: string }> }>("KeystorePlugin");166 if (!biometric || !keystore) return;167 setBiometricBusy(true);168 setError("");169 try {170 await biometric.authenticate({171 title: "Unlock Peach",172 subtitle: "Use biometrics to access your vault",173 negativeButtonText: "Use Password",174 });175 const { masterKeyHex } = await keystore.retrieveMasterKey();176 const res = (await sendMessage({177 type: "UNLOCK_WITH_KEY",178 payload: { masterKeyHex },179 })) as { ok: boolean; error?: string };180 if (res.ok) {181 await props.onUnlock();182 return;183 }184 if (res.error === "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 } else {192 setError("biometric unlock failed");193 }194 } catch (e) {195 const reason = e instanceof Error ? e.message : String(e);196 if (reason.includes("user_cancelled")) return;197 if (reason.includes("biometric_key_invalidated")) {198 await sendMessage({199 type: "UPDATE_SETTINGS",200 payload: { biometricsEnabled: false },201 });202 setBiometricAvailable(false);203 setError("biometric key expired — re-enable in settings");204 return;205 }206 if (reason.includes("biometric_lockout")) {207 setError("biometric locked out — use your password");208 }209 } finally {210 setBiometricBusy(false);211 }212 }213214 onMount(() => { void checkBiometricAvailable(); });215216 async function handleCreate() {217 if (!password().trim()) return;218 if (password().length < MIN_LENGTH) {219 setError(`password must be at least ${MIN_LENGTH} characters`);220 return;221 }222 if (password() !== confirmPassword()) {223 setError("passwords do not match");224 return;225 }226 const score = strengthScore();227 if (score === null || score < MIN_ZXCVBN_SCORE) {228 setError("password is too weak — use a longer passphrase");229 return;230 }231 setLoading(true);232 setError("");233 const response = await sendMessage({234 type: "CREATE_VAULT",235 payload: { password: password() },236 });237 const result = response as { ok: boolean; error?: string };238 if (result.ok) {239 240 241 242 243 244 try {245 await chrome.storage.local?.set({ peach_recovery_pending: "1" });246 } catch { }247 setShowRecoveryWords(true);248 setLoading(false);249 return;250 }251 setError(getDisplayError(result.error, "failed to create vault"));252 setLoading(false);253 }254255 function openRestore(surface: Exclude<RestoreSurface, null>) {256 if (!props.ready || loading()) return;257 setPassword("");258 setConfirmPassword("");259 setError("");260 setRestoreSurface(surface);261 }262263 264 return (265 <Switch>266 {}267 <Match when={restoreSurface() === "restore"}>268 <RestoreTabView269 hasVault={props.hasVault}270 onOpenCodexRestore={props.onOpenCodexRestore}271 onBack={() => setRestoreSurface(null)}272 onUnlock={async () => {273 setRestoreSurface(null);274 await props.onUnlock();275 }}276 />277 </Match>278279 {}280 <Match when={showRecoveryWords() && (!props.hasVault || props.forceRecoveryWords)}>281 <RecoveryWordDisplayView282 onDone={props.onUnlock}283 />284 </Match>285286 {}287 <Match when={recoverSurface() === "words"}>288 <RecoveryWordEntryView289 onCancel={() => setRecoverSurface(null)}290 onRecoveryComplete={props.onUnlock}291 />292 </Match>293294 {}295 <Match when={props.hasVault}>296 <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">297 <header class="peach-screen-header lock-screen-header">298 <div class="peach-wordmark-stack">299 <PeachWordmark class="peach-wordmark-wrap" />300 <Show when={props.isPro}>301 <span class="peach-pro-mini-badge">pro</span>302 </Show>303 </div>304 </header>305306 <div class="peach-lock-hero peach-lock-hero--unlock">307 <div class="peach-lock-hero-art" aria-hidden="true">308 <PeachMark class="peach-lock-hero-mark" />309 </div>310 <span class="peach-lock-eyebrow">vault locked</span>311 <h1 class="peach-onboarding-headline peach-lock-headline--unlock">312 unlock313 <br />314 your vault.315 </h1>316 </div>317318 <LockRuneCanvas />319320 <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">321 <label class="peach-label" for="unlock-password">322 master password323 </label>324 <input325 id="unlock-password"326 class="peach-underline-input peach-unlock-input lock-password-input"327 type="password"328 placeholder="••••••••••••"329 autocomplete="current-password"330 value={password()}331 disabled={!props.ready}332 ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}333 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}334 onKeyDown={(event) => {335 if (event.key !== "Enter" || !props.ready) return;336 void handleUnlock();337 }}338 />339340 <Show when={error()}>341 <p class="peach-hint peach-hint-error">{error()}</p>342 </Show>343344 <button345 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"346 onClick={() => void handleUnlock()}347 disabled={!props.ready || loading()}348 >349 {loading() ? "unlocking..." : "unlock"}350 </button>351352 <Show when={biometricAvailable()}>353 <button354 class="peach-biometric-btn"355 type="button"356 onClick={() => void handleBiometricUnlock()}357 disabled={biometricBusy() || loading()}358 aria-label="Unlock with biometrics"359 >360 <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">361 <path d="M12 11c-1.7 0-3 1.3-3 3v3"/>362 <path d="M15 17v-3"/>363 <path d="M9 4.5C9.7 4.2 10.8 4 12 4s2.3.2 3 .5"/>364 <path d="M5 9c.7-1.6 2-2.9 3.6-3.6"/>365 <path d="M19 9c-.7-1.6-2-2.9-3.6-3.6"/>366 <path d="M5 15c0-1.7.3-3.3.8-4.7"/>367 <path d="M19 15c0-1.7-.3-3.3-.8-4.7"/>368 </svg>369 {biometricBusy() ? "verifying…" : "use biometrics"}370 </button>371 </Show>372373 <button374 class="peach-lock-recover-link"375 type="button"376 onClick={() => setRecoverSurface("words")}377 >378 forgot your password?379 </button>380 </section>381 </section>382 </Match>383384 {}385 <Match when={true}>386 <section387 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"388 data-motion-phase="enter"389 >390 <header class="peach-screen-header peach-newvault-header">391 <Show when={props.onBackToOnboarding}>392 <button393 class="peach-newvault-back"394 type="button"395 onClick={() => props.onBackToOnboarding?.()}396 aria-label="Back to onboarding"397 >398 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">399 <path d="M7 2L3 5.5L7 9" />400 </svg>401 back402 </button>403 </Show>404 <div class="peach-wordmark-stack">405 <PeachWordmark class="peach-wordmark-wrap" />406 <Show when={props.isPro}>407 <span class="peach-pro-mini-badge">pro</span>408 </Show>409 </div>410 </header>411412 <div class="peach-newvault-mark" aria-hidden="true">413 <PeachMark class="peach-newvault-mark-art" />414 </div>415416 <div class="peach-newvault-card">417 <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>418 <hr class="peach-newvault-divider" />419420 <div class="peach-newvault-field">421 <div class="peach-newvault-label-row">422 <label class="peach-label" for="master-password">423 master password424 </label>425 <button426 class="peach-lock-reveal-btn"427 type="button"428 onClick={() => setShowPassword((value) => !value)}429 aria-label={showPassword() ? "Hide password" : "Show password"}430 >431 <Show432 when={showPassword()}433 fallback={434 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">435 <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" />436 <circle cx="7" cy="5" r="1.75" />437 </svg>438 }439 >440 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">441 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />442 <path d="M3.5 5.5L2.5 7.5" />443 <path d="M7 6.5V8.5" />444 <path d="M10.5 5.5L11.5 7.5" />445 </svg>446 </Show>447 {showPassword() ? "hide" : "show"}448 </button>449 </div>450 <input451 id="master-password"452 class="peach-underline-input peach-newvault-input lock-password-input"453 type={showPassword() ? "text" : "password"}454 placeholder="••••••••••••"455 autocomplete="new-password"456 value={password()}457 disabled={!props.ready}458 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}459 onKeyDown={(event) => {460 if (event.key !== "Enter" || !props.ready) return;461 void handleCreate();462 }}463 />464 <Show465 when={password().length > 0 && passwordStrength() !== null}466 fallback={467 <p class="peach-newvault-hint">468 At least 12 characters. The vault is created once both fields match and pass the strength check.469 </p>470 }471 >472 <div class="peach-password-strength">473 <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>474 <div class="peach-strength-fill"></div>475 </div>476 <span class="peach-strength-label">{passwordStrength()}</span>477 </div>478 </Show>479 </div>480481 <div class="peach-newvault-field">482 <label class="peach-label" for="confirm-password">483 confirm password484 </label>485 <input486 id="confirm-password"487 class="peach-underline-input peach-newvault-input"488 type={showPassword() ? "text" : "password"}489 placeholder="••••••••••••"490 autocomplete="new-password"491 value={confirmPassword()}492 disabled={!props.ready}493 onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}494 onKeyDown={(event) => {495 if (event.key === "Enter" && props.ready) void handleCreate();496 }}497 />498 </div>499500 <Show when={error()}>501 <p class="peach-hint peach-hint-error">{error()}</p>502 </Show>503504 <button505 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"506 onClick={() => void handleCreate()}507 disabled={!props.ready || loading() || !canCreate()}508 aria-live="polite"509 >510 <Show511 when={!canCreate() && createBlockerMessage()}512 fallback={loading() ? "creating..." : "create vault"}513 >514 {createBlockerMessage()}515 </Show>516 </button>517518 {519520521}522 <div class="peach-newvault-restore">523 <p class="peach-hint">Already have a backup?</p>524 <button525 class="peach-lock-restore-link"526 onClick={() => void openRestore("restore")}527 disabled={!props.ready}528 >529 › restore from backup530 </button>531 <Show when={props.onOpenCodexRestore}>532 <button533 class="peach-lock-restore-link"534 onClick={() => props.onOpenCodexRestore?.()}535 disabled={!props.ready}536 >537 › restore from peach codex538 </button>539 </Show>540 </div>541542 <p class="peach-newvault-warning">543 <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>544 <span>Save your 12 recovery words after creating — they're the only way back in if you forget your password.</span>545 </p>546 </div>547 </section>548 </Match>549 </Switch>550 );551}552553554555type RestoreTab = "symbols" | "codex" | "csv";556557function tabLabel(tab: RestoreTab): string {558 return (559 { symbols: "PeachScript", codex: "Codex", csv: "CSV" }[tab]560 );561}562563function RestoreTabView(props: {564 hasVault: boolean;565 onOpenCodexRestore?: () => void;566 onBack: () => void;567 onUnlock: () => void | Promise<void>;568}) {569 const [activeTab, setActiveTab] = createSignal<RestoreTab>("symbols");570 const [showIntro, setShowIntro] = createSignal(false);571 const [pendingUnlock] = createSignal(false);572573 async function handleRestoreSuccess() {574 const seen = await storageGet("peach_shown_restore_intro").catch(() => null);575 if (!seen) {576 setShowIntro(true);577 } else {578 await props.onUnlock();579 }580 }581582 async function dismissIntro() {583 await storageSet("peach_shown_restore_intro", "true").catch(() => {});584 setShowIntro(false);585 await props.onUnlock();586 }587588 return (589 <section590 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-restore-tabview"591 data-motion-phase="enter"592 >593 <RuneBackground opacity={0.05} />594595 <header class="peach-screen-header lock-screen-header">596 <PeachWordmark class="peach-wordmark-wrap" />597 <button class="peach-lock-restore-link" onClick={props.onBack}>598 ‹ back599 </button>600 </header>601602 <div class="peach-newvault-card peach-restore-tabcard">603 <div class="peach-restore-tabs" role="tablist">604 <For each={["symbols", "codex", "csv"] as RestoreTab[]}>605 {(tab) => (606 <button607 class="peach-restore-tab"608 classList={{ "is-active": activeTab() === tab }}609 onClick={() => setActiveTab(tab)}610 role="tab"611 aria-selected={activeTab() === tab}612 >613 {tabLabel(tab)}614 </button>615 )}616 </For>617 </div>618 <div class="peach-restore-content">619 <Show when={activeTab() === "symbols"}>620 <RestoreSymbols hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />621 </Show>622 <Show when={activeTab() === "codex"}>623 <RestoreCodex onOpen={() => props.onOpenCodexRestore?.()} />624 </Show>625 <Show when={activeTab() === "csv"}>626 <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />627 </Show>628 </div>629 </div>630631 {}632 <Show when={showIntro()}>633 <div class="peach-restore-intro-overlay">634 <div class="peach-restore-intro-card">635 <p class="peach-restore-intro-headline">welcome to peach.</p>636 <div class="peach-restore-intro-body">637 <p>Your vault lives only on this device. No server has a copy.</p>638 <p>The inline bubble fills passwords automatically when you visit sites.</p>639 <p>Export your vault from Settings before switching devices.</p>640 </div>641 <button642 class="peach-btn peach-btn--accent peach-btn--md peach-restore-intro-btn"643 onClick={() => void dismissIntro()}644 disabled={pendingUnlock()}645 >646 got it647 </button>648 </div>649 </div>650 </Show>651 </section>652 );653}654655656657function RestoreCodex(props: { onOpen: () => void }) {658 return (659 <div class="peach-restore-codex">660 <p class="peach-restore-codex-title">Restore from Peach Codex</p>661 <p class="peach-restore-codex-copy">662 Scan printed Codex pages or import the Codex PDF. Codex restore is663 always available, even before you create a vault.664 </p>665 <button666 type="button"667 class="peach-btn peach-btn--accent peach-btn--md peach-restore-codex-button"668 onClick={props.onOpen}669 >670 open Codex restore →671 </button>672 </div>673 );674}675676677678function RestoreSymbols(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {679 const [scriptInput, setScriptInput] = createSignal("");680 const [backupPass, setBackupPass] = createSignal("");681 const [working, setWorking] = createSignal(false);682 const [animating, setAnimating] = createSignal<"chaos" | "snap" | "bloom" | null>(null);683 const [error, setError] = createSignal<string | null>(null);684 let canvasRef: HTMLCanvasElement | undefined;685 let animFrameId = 0;686 let runes: { x: number; y: number; vx: number; vy: number; char: string; size: number; angle: number; va: number }[] = [];687688 const scriptValid = createMemo(() => isPeachScript(scriptInput()));689690 function stopAnim() {691 cancelAnimationFrame(animFrameId);692 animFrameId = 0;693 }694695 function startChaos(canvas: HTMLCanvasElement) {696 const ctx = canvas.getContext("2d")!;697 const W = canvas.width;698 const H = canvas.height;699 const RUNE_CHARS = Array.from({ length: 40 }, (_, i) =>700 String.fromCodePoint(0xe000 + i)701 );702 if (runes.length === 0) {703 runes = Array.from({ length: 60 }, () => ({704 x: Math.random() * W,705 y: Math.random() * H,706 vx: (Math.random() - 0.5) * 3,707 vy: (Math.random() - 0.5) * 3,708 char: RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]!,709 size: 14 + Math.random() * 20,710 angle: Math.random() * Math.PI * 2,711 va: (Math.random() - 0.5) * 0.1,712 }));713 }714715 function draw() {716 ctx.clearRect(0, 0, W, H);717 for (const r of runes) {718 r.x = (r.x + r.vx + W) % W;719 r.y = (r.y + r.vy + H) % H;720 r.angle += r.va;721 ctx.save();722 ctx.translate(r.x, r.y);723 ctx.rotate(r.angle);724 ctx.font = `${r.size}px PeachScript, monospace`;725 const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 400 + r.x);726 ctx.fillStyle = `rgba(232, 149, 109, ${alpha})`;727 ctx.shadowColor = "rgba(232, 149, 109, 0.7)";728 ctx.shadowBlur = 8;729 ctx.fillText(r.char, 0, 0);730 ctx.restore();731 }732 animFrameId = requestAnimationFrame(draw);733 }734 draw();735 }736737 function snapToGrid(canvas: HTMLCanvasElement, onDone: () => void) {738 stopAnim();739 const ctx = canvas.getContext("2d")!;740 const W = canvas.width;741 const H = canvas.height;742 const cols = 8;743 const rows = 6;744 const cellW = W / cols;745 const cellH = H / rows;746 const targets = runes.slice(0, cols * rows).map((_, i) => ({747 x: (i % cols) * cellW + cellW / 2,748 y: Math.floor(i / cols) * cellH + cellH / 2,749 }));750 const start = performance.now();751 const DURATION = 400;752753 function drawSnap(now: number) {754 const t = Math.min((now - start) / DURATION, 1);755 const ease = t < 1 ? 1 - Math.pow(1 - t, 3) : 1;756 ctx.clearRect(0, 0, W, H);757 runes.slice(0, targets.length).forEach((r, i) => {758 const tx = targets[i]!.x;759 const ty = targets[i]!.y;760 const cx = r.x + (tx - r.x) * ease;761 const cy = r.y + (ty - r.y) * ease;762 ctx.font = `16px PeachScript, monospace`;763 ctx.fillStyle = `rgba(232, 149, 109, ${0.8 * ease})`;764 ctx.shadowColor = "rgba(232, 149, 109, 0.6)";765 ctx.shadowBlur = 6;766 ctx.fillText(r.char, cx - 8, cy + 8);767 });768 if (t < 1) {769 animFrameId = requestAnimationFrame(drawSnap);770 } else {771 onDone();772 }773 }774 animFrameId = requestAnimationFrame(drawSnap);775 }776777 function bloom(canvas: HTMLCanvasElement, onDone: () => void) {778 stopAnim();779 const ctx = canvas.getContext("2d")!;780 const W = canvas.width;781 const H = canvas.height;782 const start = performance.now();783 const DURATION = 900;784785 function drawBloom(now: number) {786 const t = Math.min((now - start) / DURATION, 1);787 ctx.clearRect(0, 0, W, H);788 789 const runeAlpha = 1 - Math.min(t / 0.4, 1);790 if (runeAlpha > 0) {791 for (const r of runes.slice(0, 48)) {792 ctx.font = `16px PeachScript, monospace`;793 ctx.fillStyle = `rgba(232, 149, 109, ${runeAlpha * 0.8})`;794 ctx.fillText(r.char, r.x - 8, r.y + 8);795 }796 }797 798 const markT = Math.max(0, (t - 0.2) / 0.5);799 const markScale = Math.min(markT * 1.2, 1);800 const markAlpha = markT < 0.5 ? markT * 2 : 2 - markT * 2;801 if (markAlpha > 0) {802 ctx.save();803 ctx.globalAlpha = markAlpha;804 ctx.shadowColor = `rgba(217, 104, 48, ${markAlpha * 0.8})`;805 ctx.shadowBlur = 30;806 ctx.translate(W / 2, H / 2);807 ctx.scale(markScale, markScale);808 ctx.drawImage(peachMarkImg, -32, -32, 64, 64);809 ctx.restore();810 }811 if (t < 1) {812 animFrameId = requestAnimationFrame(drawBloom);813 } else {814 onDone();815 }816 }817 animFrameId = requestAnimationFrame(drawBloom);818 }819820 onCleanup(() => stopAnim());821822 async function handleRestore() {823 if (!scriptValid() || !backupPass().trim() || working()) return;824 setError(null);825 setWorking(true);826 setAnimating("chaos");827828 const canvas = canvasRef;829 if (canvas) startChaos(canvas);830831 try {832 const bytes = decodePeachScript(normalisePeachScript(scriptInput()));833 const payload = await parsePayload(bytes, backupPass());834835 if (!props.hasVault) {836 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });837 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");838 }839840 const rr = await sendMessage({841 type: "RESTORE_ENTRIES",842 payload: { entries: payload.entries, mode: "merge", settings: payload.settings },843 });844 if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");845846 if (payload.license?.key) {847 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});848 }849850 setAnimating("snap");851 stopAnim();852 if (canvas) {853 snapToGrid(canvas, () => {854 setAnimating("bloom");855 bloom(canvas, async () => {856 setAnimating(null);857 await props.onSuccess();858 });859 });860 } else {861 await props.onSuccess();862 }863 } catch (err) {864 stopAnim();865 setAnimating(null);866 const code = err instanceof Error ? err.message : String(err);867 setError(868 code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"869 ? "That passphrase didn't work."870 : code === "CREATE_VAULT_FAILED"871 ? "Couldn't create a new local vault."872 : "Could not read this backup.",873 );874 } finally {875 setWorking(false);876 }877 }878879 return (880 <div class="peach-rt-symbols">881 <Show when={animating() !== null}>882 <canvas883 ref={(el) => { canvasRef = el; }}884 class="peach-rt-anim-canvas"885 width={280}886 height={200}887 />888 </Show>889 <Show when={animating() === null}>890 <textarea891 class="peach-restore-symbols-textarea"892 placeholder="paste peachscript…"893 rows={3}894 value={scriptInput()}895 onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}896 />897 <div class="peach-rt-divider">— or —</div>898 <label class="peach-btn peach-btn--ghost peach-btn--sm peach-rt-file-label">899 open .peach file900 <input901 type="file"902 accept=".peach,text/plain"903 style="display:none"904 onChange={async (e) => {905 const file = (e.currentTarget as HTMLInputElement).files?.[0];906 if (!file) return;907 try {908 setScriptInput(await file.text());909 } catch {910 setError("Couldn't read file.");911 } finally {912 (e.currentTarget as HTMLInputElement).value = "";913 }914 }}915 />916 </label>917 <input918 class="peach-restore-symbols-pass"919 type="password"920 placeholder="backup password"921 value={backupPass()}922 onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}923 onKeyDown={(e) => {924 if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {925 e.preventDefault();926 void handleRestore();927 }928 }}929 />930 <button931 class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"932 onClick={() => void handleRestore()}933 disabled={!scriptValid() || !backupPass().trim() || working()}934 >935 restore vault →936 </button>937 <Show when={error()}>938 <p class="peach-rt-error">{error()}</p>939 </Show>940 </Show>941 </div>942 );943}944945946947function RestoreCSV(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {948 const [importing, setImporting] = createSignal(false);949 const [error, setError] = createSignal<string | null>(null);950 951 952 953 954 const CSV_MIN_PASSWORD_LENGTH = 12;955 const [newPass, setNewPass] = createSignal("");956 const [confirmPass, setConfirmPass] = createSignal("");957 const needsPassword = () => props.hasVault === false;958 const passwordReady = () =>959 !needsPassword() ||960 (newPass().length >= CSV_MIN_PASSWORD_LENGTH && newPass() === confirmPass());961962 async function handleFile(e: Event) {963 const input = e.currentTarget as HTMLInputElement;964 const file = input.files?.[0];965 input.value = "";966 if (!file) return;967 if (needsPassword() && newPass().length < CSV_MIN_PASSWORD_LENGTH) {968 setError(`Set a master password of at least ${CSV_MIN_PASSWORD_LENGTH} characters first.`);969 return;970 }971 if (needsPassword() && newPass() !== confirmPass()) {972 setError("Passwords do not match.");973 return;974 }975 setImporting(true);976 setError(null);977 try {978 const text = await file.text();979 const parsed = await parseImportSource(text);980 if (parsed.entries.length === 0) throw new Error("NO_ENTRIES");981982 if (props.hasVault === false) {983 984 985 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: newPass() } });986 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");987 }988989 const res = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: parsed.entries } });990 if (!(res as { ok: boolean }).ok) throw new Error("IMPORT_FAILED");991 if (parsed.licenseKey) {992 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: parsed.licenseKey } }).catch(() => {});993 }994 await props.onSuccess();995 } catch (err) {996 const code = err instanceof Error ? err.message : String(err);997 setError(998 code === "NO_ENTRIES" ? "No entries found in this file." :999 code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." :1000 "Import failed. Check the file format and try again."1001 );1002 } finally {1003 setImporting(false);1004 }1005 }10061007 return (1008 <div class="peach-rt-csv">1009 <Show when={needsPassword()}>1010 <input1011 class="peach-restore-symbols-pass"1012 type="password"1013 placeholder="new master password"1014 value={newPass()}1015 onInput={(e) => setNewPass((e.target as HTMLInputElement).value)}1016 />1017 <input1018 class="peach-restore-symbols-pass"1019 type="password"1020 placeholder="confirm master password"1021 value={confirmPass()}1022 onInput={(e) => setConfirmPass((e.target as HTMLInputElement).value)}1023 />1024 </Show>1025 <label1026 class="peach-btn peach-btn--accent peach-btn--md peach-rt-csv-btn"1027 classList={{ "is-busy": importing(), "is-disabled": !passwordReady() }}1028 >1029 {importing() ? "importing…" : "open csv file"}1030 <input1031 type="file"1032 accept=".csv,.json,.peach,application/x-peach"1033 style="display:none"1034 onChange={handleFile}1035 disabled={importing() || !passwordReady()}1036 />1037 </label>1038 <p class="peach-rt-csv-sources">1039 Imports from Bitwarden, 1Password, Proton Pass,<br />1040 Chrome, Safari, and Edge.1041 </p>1042 <p class="peach-rt-csv-warning">PLAIN-TEXT — DELETE THE FILE AFTER IMPORT</p>1043 <Show when={error()}>1044 <p class="peach-rt-error">{error()}</p>1045 </Show>1046 </div>1047 );1048}1049105010511052function getDisplayError(error: string | undefined, fallback: string): string {1053 if (!error) return fallback;1054 if (1055 error === "SERVICE_WORKER_UNAVAILABLE" ||1056 error.includes("Receiving end does not exist") ||1057 error.includes("message port closed")1058 ) {1059 return "background service unavailable";1060 }1061 return error;1062}