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)}`;242526let _zxcvbn: ((password: string) => { score: number }) | null = null;27async function getZxcvbn() {28 if (_zxcvbn) return _zxcvbn;29 const mod = await import("zxcvbn");30 _zxcvbn = mod.default;31 return _zxcvbn;32}3334interface LockProps {35 hasVault: boolean;36 ready: boolean;37 isPro?: boolean;38 394041 forceRecoveryWords?: boolean;42 onUnlock: () => void | Promise<void>;43 444546 onBackToOnboarding?: () => void;47 4849 onOpenCodexRestore?: () => void;50}5152type RestoreSurface = "restore" | null;53type RecoverSurface = "words" | "new-password" | null;5455export function LockView(props: LockProps) {56 const [password, setPassword] = createSignal("");57 const [confirmPassword, setConfirmPassword] = createSignal("");58 const [error, setError] = createSignal("");59 const [loading, setLoading] = createSignal(false);60 const [restoreSurface, setRestoreSurface] = createSignal<RestoreSurface>(null);61 const [showPassword, setShowPassword] = createSignal(false);62 const [recoverSurface, setRecoverSurface] = createSignal<RecoverSurface>(null);63 64 65 const [showRecoveryWords, setShowRecoveryWords] = createSignal(props.forceRecoveryWords ?? false);6667 const MIN_LENGTH = 12;68 const MIN_ZXCVBN_SCORE = 2;6970 const [strengthScore, setStrengthScore] = createSignal<number | null>(null);71 const [biometricAvailable, setBiometricAvailable] = createSignal(false);72 const [biometricBusy, setBiometricBusy] = createSignal(false);7374 const canCreate = createMemo(75 () =>76 password().length >= MIN_LENGTH &&77 confirmPassword().length >= MIN_LENGTH &&78 password() === confirmPassword() &&79 strengthScore() !== null &&80 strengthScore()! >= MIN_ZXCVBN_SCORE81 );8283 const passwordStrength = createMemo(() => {84 const score = strengthScore();85 if (score === null) return null;86 if (score <= 1) return "weak";87 if (score === 2) return "fair";88 if (score === 3) return "good";89 return "strong";90 });9192 const createBlockerMessage = createMemo(() => {93 if (!password()) return null;94 if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;95 if (!confirmPassword()) return "confirm your password";96 if (password() !== confirmPassword()) return "passwords don't match";97 if (strengthScore() === null) return "checking strength…";98 if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak — add words or length";99 return null;100 });101102 103 createMemo(() => {104 const value = password();105 if (!value) { setStrengthScore(null); return; }106 getZxcvbn().then((zxcvbn) => {107 if (password() === value) setStrengthScore(zxcvbn(value).score);108 }).catch(() => {109 110 if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1);111 });112 });113114 async function handleUnlock() {115 if (!password().trim()) return;116 setLoading(true);117 setError("");118 const response = await sendMessage({119 type: "UNLOCK",120 payload: { password: password() },121 });122 const result = response as { ok: boolean; error?: string };123 if (result.ok) {124 await props.onUnlock();125 setLoading(false);126 return;127 }128 setError(getDisplayError(result.error, "incorrect password"));129 setPassword("");130 const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined;131 passwordField?.classList.add("shake");132 setTimeout(() => passwordField?.classList.remove("shake"), 300);133 setLoading(false);134 }135136 async function checkBiometricAvailable(): Promise<void> {137 if (!props.hasVault || !isNativePlatform()) return;138 const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");139 const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");140 if (!keystore || !biometric) return;141 try {142 const stored = await keystore.hasMasterKey();143 if (!stored.exists) return;144 const avail = await biometric.isAvailable();145 if (!avail.available) return;146 setBiometricAvailable(true);147 window.setTimeout(() => { void handleBiometricUnlock(); }, 400);148 } catch { }149 }150151 async function handleBiometricUnlock(): Promise<void> {152 if (biometricBusy()) return;153 const biometric = nativePlugin<{ authenticate: (opts: Record<string, string>) => Promise<void> }>("BiometricPlugin");154 const keystore = nativePlugin<{ retrieveMasterKey: () => Promise<{ masterKeyHex: string }> }>("KeystorePlugin");155 if (!biometric || !keystore) return;156 setBiometricBusy(true);157 setError("");158 try {159 await biometric.authenticate({160 title: "Unlock Peach",161 subtitle: "Use biometrics to access your vault",162 negativeButtonText: "Use Password",163 });164 const { masterKeyHex } = await keystore.retrieveMasterKey();165 const res = (await sendMessage({166 type: "UNLOCK_WITH_KEY",167 payload: { masterKeyHex },168 })) as { ok: boolean; error?: string };169 if (res.ok) {170 await props.onUnlock();171 return;172 }173 if (res.error === "biometric_key_invalidated") {174 await sendMessage({175 type: "UPDATE_SETTINGS",176 payload: { biometricsEnabled: false },177 });178 setBiometricAvailable(false);179 setError("biometric key expired — re-enable in settings");180 } else {181 setError("biometric unlock failed");182 }183 } catch (e) {184 const reason = e instanceof Error ? e.message : String(e);185 if (reason.includes("user_cancelled")) return;186 if (reason.includes("biometric_key_invalidated")) {187 await sendMessage({188 type: "UPDATE_SETTINGS",189 payload: { biometricsEnabled: false },190 });191 setBiometricAvailable(false);192 setError("biometric key expired — re-enable in settings");193 return;194 }195 if (reason.includes("biometric_lockout")) {196 setError("biometric locked out — use your password");197 }198 } finally {199 setBiometricBusy(false);200 }201 }202203 onMount(() => { void checkBiometricAvailable(); });204205 async function handleCreate() {206 if (!password().trim()) return;207 if (password().length < MIN_LENGTH) {208 setError(`password must be at least ${MIN_LENGTH} characters`);209 return;210 }211 if (password() !== confirmPassword()) {212 setError("passwords do not match");213 return;214 }215 const score = strengthScore();216 if (score === null || score < MIN_ZXCVBN_SCORE) {217 setError("password is too weak — try a longer or more varied passphrase");218 return;219 }220 setLoading(true);221 setError("");222 const response = await sendMessage({223 type: "CREATE_VAULT",224 payload: { password: password() },225 });226 const result = response as { ok: boolean; error?: string };227 if (result.ok) {228 229 230 231 232 233 try {234 await chrome.storage.local?.set({ peach_recovery_pending: "1" });235 } catch { }236 setShowRecoveryWords(true);237 setLoading(false);238 return;239 }240 setError(getDisplayError(result.error, "failed to create vault"));241 setLoading(false);242 }243244 function openRestore(surface: Exclude<RestoreSurface, null>) {245 if (!props.ready || loading()) return;246 setPassword("");247 setConfirmPassword("");248 setError("");249 setRestoreSurface(surface);250 }251252 253 return (254 <Switch>255 {}256 <Match when={restoreSurface() === "restore"}>257 <RestoreTabView258 hasVault={props.hasVault}259 onOpenCodexRestore={props.onOpenCodexRestore}260 onBack={() => setRestoreSurface(null)}261 onUnlock={async () => {262 setRestoreSurface(null);263 await props.onUnlock();264 }}265 />266 </Match>267268 {}269 <Match when={showRecoveryWords() && (!props.hasVault || props.forceRecoveryWords)}>270 <RecoveryWordDisplayView271 onDone={props.onUnlock}272 />273 </Match>274275 {}276 <Match when={recoverSurface() === "words"}>277 <RecoveryWordEntryView278 onCancel={() => setRecoverSurface(null)}279 onRecoveryComplete={props.onUnlock}280 />281 </Match>282283 {}284 <Match when={props.hasVault}>285 <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">286 <header class="peach-screen-header lock-screen-header">287 <div class="peach-wordmark-stack">288 <PeachWordmark class="peach-wordmark-wrap" />289 <Show when={props.isPro}>290 <span class="peach-pro-mini-badge">pro</span>291 </Show>292 </div>293 </header>294295 <div class="peach-lock-hero peach-lock-hero--unlock">296 <div class="peach-lock-hero-art" aria-hidden="true">297 <PeachMark class="peach-lock-hero-mark" />298 </div>299 <span class="peach-lock-eyebrow">vault locked</span>300 <h1 class="peach-onboarding-headline peach-lock-headline--unlock">301 unlock302 <br />303 your vault.304 </h1>305 </div>306307 <LockRuneCanvas />308309 <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">310 <label class="peach-label" for="unlock-password">311 master password312 </label>313 <input314 id="unlock-password"315 class="peach-underline-input peach-unlock-input lock-password-input"316 type="password"317 placeholder="••••••••••••"318 autocomplete="current-password"319 value={password()}320 disabled={!props.ready}321 ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}322 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}323 onKeyDown={(event) => {324 if (event.key !== "Enter" || !props.ready) return;325 void handleUnlock();326 }}327 />328329 <Show when={error()}>330 <p class="peach-hint peach-hint-error">{error()}</p>331 </Show>332333 <button334 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"335 onClick={() => void handleUnlock()}336 disabled={!props.ready || loading()}337 >338 {loading() ? "unlocking..." : "unlock"}339 </button>340341 <Show when={biometricAvailable()}>342 <button343 class="peach-biometric-btn"344 type="button"345 onClick={() => void handleBiometricUnlock()}346 disabled={biometricBusy() || loading()}347 aria-label="Unlock with biometrics"348 >349 <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">350 <path d="M12 11c-1.7 0-3 1.3-3 3v3"/>351 <path d="M15 17v-3"/>352 <path d="M9 4.5C9.7 4.2 10.8 4 12 4s2.3.2 3 .5"/>353 <path d="M5 9c.7-1.6 2-2.9 3.6-3.6"/>354 <path d="M19 9c-.7-1.6-2-2.9-3.6-3.6"/>355 <path d="M5 15c0-1.7.3-3.3.8-4.7"/>356 <path d="M19 15c0-1.7-.3-3.3-.8-4.7"/>357 </svg>358 {biometricBusy() ? "verifying…" : "use biometrics"}359 </button>360 </Show>361362 <button363 class="peach-lock-recover-link"364 type="button"365 onClick={() => setRecoverSurface("words")}366 >367 forgot your password?368 </button>369 </section>370 </section>371 </Match>372373 {}374 <Match when={true}>375 <section376 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"377 data-motion-phase="enter"378 >379 <header class="peach-screen-header peach-newvault-header">380 <Show when={props.onBackToOnboarding}>381 <button382 class="peach-newvault-back"383 type="button"384 onClick={() => props.onBackToOnboarding?.()}385 aria-label="Back to onboarding"386 >387 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">388 <path d="M7 2L3 5.5L7 9" />389 </svg>390 back391 </button>392 </Show>393 <div class="peach-wordmark-stack">394 <PeachWordmark class="peach-wordmark-wrap" />395 <Show when={props.isPro}>396 <span class="peach-pro-mini-badge">pro</span>397 </Show>398 </div>399 </header>400401 <div class="peach-newvault-mark" aria-hidden="true">402 <PeachMark class="peach-newvault-mark-art" />403 </div>404405 <div class="peach-newvault-card">406 <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>407 <hr class="peach-newvault-divider" />408409 <div class="peach-newvault-field">410 <div class="peach-newvault-label-row">411 <label class="peach-label" for="master-password">412 master password413 </label>414 <button415 class="peach-lock-reveal-btn"416 type="button"417 onClick={() => setShowPassword((value) => !value)}418 aria-label={showPassword() ? "Hide password" : "Show password"}419 >420 <Show421 when={showPassword()}422 fallback={423 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">424 <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" />425 <circle cx="7" cy="5" r="1.75" />426 </svg>427 }428 >429 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">430 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />431 <path d="M3.5 5.5L2.5 7.5" />432 <path d="M7 6.5V8.5" />433 <path d="M10.5 5.5L11.5 7.5" />434 </svg>435 </Show>436 {showPassword() ? "hide" : "show"}437 </button>438 </div>439 <input440 id="master-password"441 class="peach-underline-input peach-newvault-input lock-password-input"442 type={showPassword() ? "text" : "password"}443 placeholder="••••••••••••"444 autocomplete="new-password"445 value={password()}446 disabled={!props.ready}447 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}448 onKeyDown={(event) => {449 if (event.key !== "Enter" || !props.ready) return;450 void handleCreate();451 }}452 />453 <Show454 when={password().length > 0 && passwordStrength() !== null}455 fallback={456 <p class="peach-newvault-hint">at least 12 characters</p>457 }458 >459 <div class="peach-password-strength">460 <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>461 <div class="peach-strength-fill"></div>462 </div>463 <span class="peach-strength-label">{passwordStrength()}</span>464 </div>465 </Show>466 </div>467468 <div class="peach-newvault-field">469 <label class="peach-label" for="confirm-password">470 confirm password471 </label>472 <input473 id="confirm-password"474 class="peach-underline-input peach-newvault-input"475 type={showPassword() ? "text" : "password"}476 placeholder="••••••••••••"477 autocomplete="new-password"478 value={confirmPassword()}479 disabled={!props.ready}480 onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}481 onKeyDown={(event) => {482 if (event.key === "Enter" && props.ready) void handleCreate();483 }}484 />485 </div>486487 <Show when={error()}>488 <p class="peach-hint peach-hint-error">{error()}</p>489 </Show>490491 <button492 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"493 onClick={() => void handleCreate()}494 disabled={!props.ready || loading() || !canCreate()}495 aria-live="polite"496 >497 <Show498 when={!canCreate() && createBlockerMessage()}499 fallback={loading() ? "creating..." : "create vault"}500 >501 {createBlockerMessage()}502 </Show>503 </button>504505 <p class="peach-newvault-warning">506 <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>507 <span>Save your 12 recovery words right after you create the vault — they're the only way in if you forget your password.</span>508 </p>509 </div>510511 <div class="peach-newvault-footer">512 <p class="peach-hint">Already have a backup?</p>513 <button514 class="peach-lock-restore-link"515 onClick={() => void openRestore("restore")}516 disabled={!props.ready}517 >518 › restore from backup519 </button>520 <Show when={props.onOpenCodexRestore}>521 <button522 class="peach-lock-restore-link"523 onClick={() => props.onOpenCodexRestore?.()}524 disabled={!props.ready}525 >526 › restore from peach codex527 </button>528 </Show>529 </div>530 </section>531 </Match>532 </Switch>533 );534}535536537538type RestoreTab = "symbols" | "codex" | "csv";539540function tabLabel(tab: RestoreTab): string {541 return (542 { symbols: "PeachScript", codex: "Codex", csv: "CSV" }[tab]543 );544}545546function RestoreTabView(props: {547 hasVault: boolean;548 onOpenCodexRestore?: () => void;549 onBack: () => void;550 onUnlock: () => void | Promise<void>;551}) {552 const [activeTab, setActiveTab] = createSignal<RestoreTab>("symbols");553 const [showIntro, setShowIntro] = createSignal(false);554 const [pendingUnlock] = createSignal(false);555556 async function handleRestoreSuccess() {557 const seen = await storageGet("peach_shown_restore_intro").catch(() => null);558 if (!seen) {559 setShowIntro(true);560 } else {561 await props.onUnlock();562 }563 }564565 async function dismissIntro() {566 await storageSet("peach_shown_restore_intro", "true").catch(() => {});567 setShowIntro(false);568 await props.onUnlock();569 }570571 return (572 <section573 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-restore-tabview"574 data-motion-phase="enter"575 >576 <RuneBackground opacity={0.05} />577578 <header class="peach-screen-header lock-screen-header">579 <PeachWordmark class="peach-wordmark-wrap" />580 <button class="peach-lock-restore-link" onClick={props.onBack}>581 ‹ back582 </button>583 </header>584585 <div class="peach-newvault-card peach-restore-tabcard">586 <div class="peach-restore-tabs" role="tablist">587 <For each={["symbols", "codex", "csv"] as RestoreTab[]}>588 {(tab) => (589 <button590 class="peach-restore-tab"591 classList={{ "is-active": activeTab() === tab }}592 onClick={() => setActiveTab(tab)}593 role="tab"594 aria-selected={activeTab() === tab}595 >596 {tabLabel(tab)}597 </button>598 )}599 </For>600 </div>601 <div class="peach-restore-content">602 <Show when={activeTab() === "symbols"}>603 <RestoreSymbols hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />604 </Show>605 <Show when={activeTab() === "codex"}>606 <RestoreCodex onOpen={() => props.onOpenCodexRestore?.()} />607 </Show>608 <Show when={activeTab() === "csv"}>609 <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />610 </Show>611 </div>612 </div>613614 {}615 <Show when={showIntro()}>616 <div class="peach-restore-intro-overlay">617 <div class="peach-restore-intro-card">618 <p class="peach-restore-intro-headline">welcome to peach.</p>619 <div class="peach-restore-intro-body">620 <p>Your vault lives only on this device. No server has a copy.</p>621 <p>The inline bubble fills passwords automatically when you visit sites.</p>622 <p>Export your vault from Settings before switching devices.</p>623 </div>624 <button625 class="peach-btn peach-btn--accent peach-btn--md peach-restore-intro-btn"626 onClick={() => void dismissIntro()}627 disabled={pendingUnlock()}628 >629 got it630 </button>631 </div>632 </div>633 </Show>634 </section>635 );636}637638639640function RestoreCodex(props: { onOpen: () => void }) {641 return (642 <div class="peach-restore-codex">643 <p class="peach-restore-codex-title">Restore from Peach Codex</p>644 <p class="peach-restore-codex-copy">645 Scan printed Codex pages or import the Codex PDF. Codex restore is646 always available, even before you create a vault.647 </p>648 <button649 type="button"650 class="peach-btn peach-btn--accent peach-btn--md peach-restore-codex-button"651 onClick={props.onOpen}652 >653 open Codex restore →654 </button>655 </div>656 );657}658659660661function RestoreSymbols(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {662 const [scriptInput, setScriptInput] = createSignal("");663 const [backupPass, setBackupPass] = createSignal("");664 const [working, setWorking] = createSignal(false);665 const [animating, setAnimating] = createSignal<"chaos" | "snap" | "bloom" | null>(null);666 const [error, setError] = createSignal<string | null>(null);667 let canvasRef: HTMLCanvasElement | undefined;668 let animFrameId = 0;669 let runes: { x: number; y: number; vx: number; vy: number; char: string; size: number; angle: number; va: number }[] = [];670671 const scriptValid = createMemo(() => isPeachScript(scriptInput()));672673 function stopAnim() {674 cancelAnimationFrame(animFrameId);675 animFrameId = 0;676 }677678 function startChaos(canvas: HTMLCanvasElement) {679 const ctx = canvas.getContext("2d")!;680 const W = canvas.width;681 const H = canvas.height;682 const RUNE_CHARS = Array.from({ length: 40 }, (_, i) =>683 String.fromCodePoint(0xe000 + i)684 );685 if (runes.length === 0) {686 runes = Array.from({ length: 60 }, () => ({687 x: Math.random() * W,688 y: Math.random() * H,689 vx: (Math.random() - 0.5) * 3,690 vy: (Math.random() - 0.5) * 3,691 char: RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]!,692 size: 14 + Math.random() * 20,693 angle: Math.random() * Math.PI * 2,694 va: (Math.random() - 0.5) * 0.1,695 }));696 }697698 function draw() {699 ctx.clearRect(0, 0, W, H);700 for (const r of runes) {701 r.x = (r.x + r.vx + W) % W;702 r.y = (r.y + r.vy + H) % H;703 r.angle += r.va;704 ctx.save();705 ctx.translate(r.x, r.y);706 ctx.rotate(r.angle);707 ctx.font = `${r.size}px PeachScript, monospace`;708 const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 400 + r.x);709 ctx.fillStyle = `rgba(232, 149, 109, ${alpha})`;710 ctx.shadowColor = "rgba(232, 149, 109, 0.7)";711 ctx.shadowBlur = 8;712 ctx.fillText(r.char, 0, 0);713 ctx.restore();714 }715 animFrameId = requestAnimationFrame(draw);716 }717 draw();718 }719720 function snapToGrid(canvas: HTMLCanvasElement, onDone: () => void) {721 stopAnim();722 const ctx = canvas.getContext("2d")!;723 const W = canvas.width;724 const H = canvas.height;725 const cols = 8;726 const rows = 6;727 const cellW = W / cols;728 const cellH = H / rows;729 const targets = runes.slice(0, cols * rows).map((_, i) => ({730 x: (i % cols) * cellW + cellW / 2,731 y: Math.floor(i / cols) * cellH + cellH / 2,732 }));733 const start = performance.now();734 const DURATION = 400;735736 function drawSnap(now: number) {737 const t = Math.min((now - start) / DURATION, 1);738 const ease = t < 1 ? 1 - Math.pow(1 - t, 3) : 1;739 ctx.clearRect(0, 0, W, H);740 runes.slice(0, targets.length).forEach((r, i) => {741 const tx = targets[i]!.x;742 const ty = targets[i]!.y;743 const cx = r.x + (tx - r.x) * ease;744 const cy = r.y + (ty - r.y) * ease;745 ctx.font = `16px PeachScript, monospace`;746 ctx.fillStyle = `rgba(232, 149, 109, ${0.8 * ease})`;747 ctx.shadowColor = "rgba(232, 149, 109, 0.6)";748 ctx.shadowBlur = 6;749 ctx.fillText(r.char, cx - 8, cy + 8);750 });751 if (t < 1) {752 animFrameId = requestAnimationFrame(drawSnap);753 } else {754 onDone();755 }756 }757 animFrameId = requestAnimationFrame(drawSnap);758 }759760 function bloom(canvas: HTMLCanvasElement, onDone: () => void) {761 stopAnim();762 const ctx = canvas.getContext("2d")!;763 const W = canvas.width;764 const H = canvas.height;765 const start = performance.now();766 const DURATION = 900;767768 function drawBloom(now: number) {769 const t = Math.min((now - start) / DURATION, 1);770 ctx.clearRect(0, 0, W, H);771 772 const runeAlpha = 1 - Math.min(t / 0.4, 1);773 if (runeAlpha > 0) {774 for (const r of runes.slice(0, 48)) {775 ctx.font = `16px PeachScript, monospace`;776 ctx.fillStyle = `rgba(232, 149, 109, ${runeAlpha * 0.8})`;777 ctx.fillText(r.char, r.x - 8, r.y + 8);778 }779 }780 781 const markT = Math.max(0, (t - 0.2) / 0.5);782 const markScale = Math.min(markT * 1.2, 1);783 const markAlpha = markT < 0.5 ? markT * 2 : 2 - markT * 2;784 if (markAlpha > 0) {785 ctx.save();786 ctx.globalAlpha = markAlpha;787 ctx.shadowColor = `rgba(217, 104, 48, ${markAlpha * 0.8})`;788 ctx.shadowBlur = 30;789 ctx.translate(W / 2, H / 2);790 ctx.scale(markScale, markScale);791 ctx.drawImage(peachMarkImg, -32, -32, 64, 64);792 ctx.restore();793 }794 if (t < 1) {795 animFrameId = requestAnimationFrame(drawBloom);796 } else {797 onDone();798 }799 }800 animFrameId = requestAnimationFrame(drawBloom);801 }802803 onCleanup(() => stopAnim());804805 async function handleRestore() {806 if (!scriptValid() || !backupPass().trim() || working()) return;807 setError(null);808 setWorking(true);809 setAnimating("chaos");810811 const canvas = canvasRef;812 if (canvas) startChaos(canvas);813814 try {815 const bytes = decodePeachScript(normalisePeachScript(scriptInput()));816 const payload = await parsePayload(bytes, backupPass());817818 if (!props.hasVault) {819 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });820 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");821 }822823 const rr = await sendMessage({824 type: "RESTORE_ENTRIES",825 payload: { entries: payload.entries, mode: "merge", settings: payload.settings },826 });827 if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");828829 if (payload.license?.key) {830 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});831 }832833 setAnimating("snap");834 stopAnim();835 if (canvas) {836 snapToGrid(canvas, () => {837 setAnimating("bloom");838 bloom(canvas, async () => {839 setAnimating(null);840 await props.onSuccess();841 });842 });843 } else {844 await props.onSuccess();845 }846 } catch (err) {847 stopAnim();848 setAnimating(null);849 const code = err instanceof Error ? err.message : String(err);850 setError(851 code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"852 ? "That passphrase didn't work."853 : code === "CREATE_VAULT_FAILED"854 ? "Couldn't create a new local vault."855 : "Could not read this backup.",856 );857 } finally {858 setWorking(false);859 }860 }861862 return (863 <div class="peach-rt-symbols">864 <Show when={animating() !== null}>865 <canvas866 ref={(el) => { canvasRef = el; }}867 class="peach-rt-anim-canvas"868 width={280}869 height={200}870 />871 </Show>872 <Show when={animating() === null}>873 <textarea874 class="peach-restore-symbols-textarea"875 placeholder="paste peachscript…"876 rows={3}877 value={scriptInput()}878 onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}879 />880 <div class="peach-rt-divider">— or —</div>881 <label class="peach-btn peach-btn--ghost peach-btn--sm peach-rt-file-label">882 open .peach file883 <input884 type="file"885 accept=".peach,text/plain"886 style="display:none"887 onChange={async (e) => {888 const file = (e.currentTarget as HTMLInputElement).files?.[0];889 if (!file) return;890 try {891 setScriptInput(await file.text());892 } catch {893 setError("Couldn't read file.");894 } finally {895 (e.currentTarget as HTMLInputElement).value = "";896 }897 }}898 />899 </label>900 <input901 class="peach-restore-symbols-pass"902 type="password"903 placeholder="backup password"904 value={backupPass()}905 onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}906 onKeyDown={(e) => {907 if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {908 e.preventDefault();909 void handleRestore();910 }911 }}912 />913 <button914 class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"915 onClick={() => void handleRestore()}916 disabled={!scriptValid() || !backupPass().trim() || working()}917 >918 restore vault →919 </button>920 <Show when={error()}>921 <p class="peach-rt-error">{error()}</p>922 </Show>923 </Show>924 </div>925 );926}927928929930function RestoreCSV(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {931 const [importing, setImporting] = createSignal(false);932 const [error, setError] = createSignal<string | null>(null);933 934 935 936 937 const CSV_MIN_PASSWORD_LENGTH = 12;938 const [newPass, setNewPass] = createSignal("");939 const [confirmPass, setConfirmPass] = createSignal("");940 const needsPassword = () => props.hasVault === false;941 const passwordReady = () =>942 !needsPassword() ||943 (newPass().length >= CSV_MIN_PASSWORD_LENGTH && newPass() === confirmPass());944945 async function handleFile(e: Event) {946 const input = e.currentTarget as HTMLInputElement;947 const file = input.files?.[0];948 input.value = "";949 if (!file) return;950 if (needsPassword() && newPass().length < CSV_MIN_PASSWORD_LENGTH) {951 setError(`Set a master password of at least ${CSV_MIN_PASSWORD_LENGTH} characters first.`);952 return;953 }954 if (needsPassword() && newPass() !== confirmPass()) {955 setError("Passwords do not match.");956 return;957 }958 setImporting(true);959 setError(null);960 try {961 const text = await file.text();962 const parsed = await parseImportSource(text);963 if (parsed.entries.length === 0) throw new Error("NO_ENTRIES");964965 if (props.hasVault === false) {966 967 968 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: newPass() } });969 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");970 }971972 const res = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: parsed.entries } });973 if (!(res as { ok: boolean }).ok) throw new Error("IMPORT_FAILED");974 if (parsed.licenseKey) {975 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: parsed.licenseKey } }).catch(() => {});976 }977 await props.onSuccess();978 } catch (err) {979 const code = err instanceof Error ? err.message : String(err);980 setError(981 code === "NO_ENTRIES" ? "No entries found in this file." :982 code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." :983 "Import failed. Check the file format and try again."984 );985 } finally {986 setImporting(false);987 }988 }989990 return (991 <div class="peach-rt-csv">992 <Show when={needsPassword()}>993 <input994 class="peach-restore-symbols-pass"995 type="password"996 placeholder="new master password"997 value={newPass()}998 onInput={(e) => setNewPass((e.target as HTMLInputElement).value)}999 />1000 <input1001 class="peach-restore-symbols-pass"1002 type="password"1003 placeholder="confirm master password"1004 value={confirmPass()}1005 onInput={(e) => setConfirmPass((e.target as HTMLInputElement).value)}1006 />1007 </Show>1008 <label1009 class="peach-btn peach-btn--accent peach-btn--md peach-rt-csv-btn"1010 classList={{ "is-busy": importing(), "is-disabled": !passwordReady() }}1011 >1012 {importing() ? "importing…" : "open csv file"}1013 <input1014 type="file"1015 accept=".csv,.json,.peach,application/x-peach"1016 style="display:none"1017 onChange={handleFile}1018 disabled={importing() || !passwordReady()}1019 />1020 </label>1021 <p class="peach-rt-csv-sources">1022 Imports from Bitwarden, 1Password, Proton Pass,<br />1023 Chrome, Safari, and Edge.1024 </p>1025 <p class="peach-rt-csv-warning">PLAIN-TEXT — DELETE THE FILE AFTER IMPORT</p>1026 <Show when={error()}>1027 <p class="peach-rt-error">{error()}</p>1028 </Show>1029 </div>1030 );1031}1032103310341035function getDisplayError(error: string | undefined, fallback: string): string {1036 if (!error) return fallback;1037 if (1038 error === "SERVICE_WORKER_UNAVAILABLE" ||1039 error.includes("Receiving end does not exist") ||1040 error.includes("message port closed")1041 ) {1042 return "background service unavailable";1043 }1044 return error;1045}