1import { For, Match, Show, Switch, createMemo, createSignal, onCleanup, onMount } from "solid-js";2import { sendMessage } from "../App";3import { PeachMark, PeachWordmark } from "../components/PeachMark";4import { RecoveryWordEntryView } from "./RecoveryWordEntry";5import { RecoveryWordDisplayView } from "./RecoveryWordDisplay";6import { RuneBackground } from "../components/RuneBackground";7import { storageGet, storageSet } from "../../shared/storage";8import { isPeachScript, decodePeachScript, normalisePeachScript } from "../../crypto/peachScript";9import { parsePayload } from "../../crypto/peachPayload";10import { parseImportSource } from "../../shared/import";11import { isNativePlatform, nativePlugin } from "../../shared/native";1213const peachMarkImg = new Image();14peachMarkImg.src = `data:image/svg+xml,${encodeURIComponent(15 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" fill="none">' +16 '<path d="M 124 64 C 134 32 168 16 200 22 C 198 56 168 72 130 66 Z" fill="#92AC65"/>' +17 '<path d="M 130 64 C 152 46 178 30 196 24" stroke="#5E7A42" stroke-width="2.6" fill="none" stroke-linecap="round"/>' +18 '<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"/>' +19 '<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"/>' +20 '<ellipse cx="178" cy="108" rx="20" ry="11" fill="#FFD0A8" opacity="0.7" transform="rotate(-26 178 108)"/>' +21 '</svg>'22)}`;2324252627let _zxcvbn: ((password: string) => { score: number }) | null = null;28async function getZxcvbn() {29 if (_zxcvbn) return _zxcvbn;30 const [{ zxcvbn, zxcvbnOptions }, common, en] = await Promise.all([31 import("@zxcvbn-ts/core"),32 import("@zxcvbn-ts/language-common"),33 import("@zxcvbn-ts/language-en"),34 ]);35 zxcvbnOptions.setOptions({36 translations: en.translations,37 graphs: common.adjacencyGraphs,38 dictionary: { ...common.dictionary, ...en.dictionary },39 });40 _zxcvbn = (password: string) => zxcvbn(password);41 return _zxcvbn;42}4344interface LockProps {45 hasVault: boolean;46 ready: boolean;47 isPro?: boolean;48 495051 forceRecoveryWords?: boolean;52 onUnlock: () => void | Promise<void>;53 545556 onBackToOnboarding?: () => void;57 5859 onOpenCodexRestore?: () => void;60 61 initialRestore?: boolean;62}6364type RestoreSurface = "restore" | null;65type RecoverSurface = "words" | "new-password" | null;6667export function LockView(props: LockProps) {68 const [password, setPassword] = createSignal("");69 const [confirmPassword, setConfirmPassword] = createSignal("");70 const [error, setError] = createSignal("");71 const [loading, setLoading] = createSignal(false);72 const [restoreSurface, setRestoreSurface] = createSignal<RestoreSurface>(73 props.initialRestore ? "restore" : null,74 );75 const [showPassword, setShowPassword] = createSignal(false);76 const [recoverSurface, setRecoverSurface] = createSignal<RecoverSurface>(null);77 78 79 const [showRecoveryWords, setShowRecoveryWords] = createSignal(props.forceRecoveryWords ?? false);8081 const MIN_LENGTH = 12;82 const MIN_ZXCVBN_SCORE = 2;8384 const [strengthScore, setStrengthScore] = createSignal<number | null>(null);85 const [biometricAvailable, setBiometricAvailable] = createSignal(false);86 const [biometricBusy, setBiometricBusy] = createSignal(false);8788 const canCreate = createMemo(89 () =>90 password().length >= MIN_LENGTH &&91 confirmPassword().length >= MIN_LENGTH &&92 password() === confirmPassword() &&93 strengthScore() !== null &&94 strengthScore()! >= MIN_ZXCVBN_SCORE95 );9697 const passwordStrength = createMemo(() => {98 const score = strengthScore();99 if (score === null) return null;100 if (score <= 1) return "weak";101 if (score === 2) return "fair";102 if (score === 3) return "good";103 return "strong";104 });105106 const createBlockerMessage = createMemo(() => {107 if (!password()) return null;108 if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;109 if (!confirmPassword()) return "confirm your password";110 if (password() !== confirmPassword()) return "passwords don't match";111 if (strengthScore() === null) return "checking strength…";112 if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak — use a longer passphrase";113 return null;114 });115116 117 createMemo(() => {118 const value = password();119 if (!value) { setStrengthScore(null); return; }120 getZxcvbn().then((zxcvbn) => {121 if (password() === value) setStrengthScore(zxcvbn(value).score);122 }).catch(() => {123 124 if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1);125 });126 });127128 async function handleUnlock() {129 if (!password().trim()) return;130 setLoading(true);131 setError("");132 const response = await sendMessage({133 type: "UNLOCK",134 payload: { password: password() },135 });136 const result = response as { ok: boolean; error?: string };137 if (result.ok) {138 await props.onUnlock();139 setLoading(false);140 return;141 }142 setError(getDisplayError(result.error, "incorrect password"));143 setPassword("");144 const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined;145 passwordField?.classList.add("shake");146 setTimeout(() => passwordField?.classList.remove("shake"), 300);147 setLoading(false);148 }149150 async function checkBiometricAvailable(): Promise<void> {151 if (!props.hasVault || !isNativePlatform()) return;152 const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");153 const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");154 if (!keystore || !biometric) return;155 try {156 const stored = await keystore.hasMasterKey();157 if (!stored.exists) return;158 const avail = await biometric.isAvailable();159 if (!avail.available) return;160 setBiometricAvailable(true);161 window.setTimeout(() => { void handleBiometricUnlock(); }, 400);162 } catch { }163 }164165 async function handleBiometricUnlock(): Promise<void> {166 if (biometricBusy()) return;167 const biometric = nativePlugin<{ authenticate: (opts: Record<string, string>) => Promise<void> }>("BiometricPlugin");168 const keystore = nativePlugin<{ retrieveMasterKey: () => Promise<{ masterKeyHex: string }> }>("KeystorePlugin");169 if (!biometric || !keystore) return;170 setBiometricBusy(true);171 setError("");172 try {173 await biometric.authenticate({174 title: "Unlock Peach",175 subtitle: "Use biometrics to access your vault",176 negativeButtonText: "Use Password",177 });178 const { masterKeyHex } = await keystore.retrieveMasterKey();179 const res = (await sendMessage({180 type: "UNLOCK_WITH_KEY",181 payload: { masterKeyHex },182 })) as { ok: boolean; error?: string };183 if (res.ok) {184 await props.onUnlock();185 return;186 }187 if (res.error === "biometric_key_invalidated") {188 await sendMessage({189 type: "UPDATE_SETTINGS",190 payload: { biometricsEnabled: false },191 });192 setBiometricAvailable(false);193 setError("biometric key expired — re-enable in settings");194 } else {195 setError("biometric unlock failed");196 }197 } catch (e) {198 const reason = e instanceof Error ? e.message : String(e);199 if (reason.includes("user_cancelled")) return;200 if (reason.includes("biometric_key_invalidated")) {201 await sendMessage({202 type: "UPDATE_SETTINGS",203 payload: { biometricsEnabled: false },204 });205 setBiometricAvailable(false);206 setError("biometric key expired — re-enable in settings");207 return;208 }209 if (reason.includes("biometric_lockout")) {210 setError("biometric locked out — use your password");211 }212 } finally {213 setBiometricBusy(false);214 }215 }216217 onMount(() => { void checkBiometricAvailable(); });218219 async function handleCreate() {220 if (!password().trim()) return;221 if (password().length < MIN_LENGTH) {222 setError(`password must be at least ${MIN_LENGTH} characters`);223 return;224 }225 if (password() !== confirmPassword()) {226 setError("passwords do not match");227 return;228 }229 const score = strengthScore();230 if (score === null || score < MIN_ZXCVBN_SCORE) {231 setError("password is too weak — use a longer passphrase");232 return;233 }234 setLoading(true);235 setError("");236 const response = await sendMessage({237 type: "CREATE_VAULT",238 payload: { password: password() },239 });240 const result = response as { ok: boolean; error?: string };241 if (result.ok) {242 243 244 245 246 247 try {248 await chrome.storage.local?.set({ peach_recovery_pending: "1" });249 } catch { }250 setShowRecoveryWords(true);251 setLoading(false);252 return;253 }254 setError(getDisplayError(result.error, "failed to create vault"));255 setLoading(false);256 }257258 259 return (260 <Switch>261 {}262 <Match when={restoreSurface() === "restore"}>263 <RestoreTabView264 hasVault={props.hasVault}265 onOpenCodexRestore={props.onOpenCodexRestore}266 onBack={() => setRestoreSurface(null)}267 onUnlock={async () => {268 setRestoreSurface(null);269 await props.onUnlock();270 }}271 />272 </Match>273274 {}275 <Match when={showRecoveryWords() && (!props.hasVault || props.forceRecoveryWords)}>276 <RecoveryWordDisplayView277 onDone={props.onUnlock}278 />279 </Match>280281 {}282 <Match when={recoverSurface() === "words"}>283 <RecoveryWordEntryView284 onCancel={() => setRecoverSurface(null)}285 onRecoveryComplete={props.onUnlock}286 />287 </Match>288289 {}290 <Match when={props.hasVault}>291 <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">292 <RuneBackground opacity={0.07} accentGlow />293294 <header class="peach-screen-header lock-screen-header">295 <div class="peach-wordmark-stack">296 <PeachWordmark class="peach-wordmark-wrap" />297 <Show when={props.isPro}>298 <span class="peach-pro-mini-badge">pro</span>299 </Show>300 </div>301 </header>302303 <div class="peach-lock-hero peach-lock-hero--unlock">304 <span class="peach-lock-eyebrow">vault locked</span>305 <h1 class="peach-onboarding-headline peach-lock-headline--unlock">306 unlock307 <br />308 your vault.309 </h1>310 </div>311312 <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">313 <label class="peach-label" for="unlock-password">314 master password315 </label>316 <input317 id="unlock-password"318 class="peach-underline-input peach-unlock-input lock-password-input"319 type="password"320 placeholder="••••••••••••"321 autocomplete="current-password"322 value={password()}323 disabled={!props.ready}324 ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}325 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}326 onKeyDown={(event) => {327 if (event.key !== "Enter" || !props.ready) return;328 void handleUnlock();329 }}330 />331332 <Show when={error()}>333 <p class="peach-hint peach-hint-error">{error()}</p>334 </Show>335336 <button337 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"338 onClick={() => void handleUnlock()}339 disabled={!props.ready || loading()}340 >341 {loading() ? "unlocking..." : "unlock"}342 </button>343344 <Show when={biometricAvailable()}>345 <button346 class="peach-biometric-btn"347 type="button"348 onClick={() => void handleBiometricUnlock()}349 disabled={biometricBusy() || loading()}350 aria-label="Unlock with biometrics"351 >352 <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">353 <path d="M12 11c-1.7 0-3 1.3-3 3v3"/>354 <path d="M15 17v-3"/>355 <path d="M9 4.5C9.7 4.2 10.8 4 12 4s2.3.2 3 .5"/>356 <path d="M5 9c.7-1.6 2-2.9 3.6-3.6"/>357 <path d="M19 9c-.7-1.6-2-2.9-3.6-3.6"/>358 <path d="M5 15c0-1.7.3-3.3.8-4.7"/>359 <path d="M19 15c0-1.7-.3-3.3-.8-4.7"/>360 </svg>361 {biometricBusy() ? "verifying…" : "use biometrics"}362 </button>363 </Show>364365 <button366 class="peach-lock-recover-link"367 type="button"368 onClick={() => setRecoverSurface("words")}369 >370 forgot your password?371 </button>372 </section>373 </section>374 </Match>375376 {}377 <Match when={true}>378 <section379 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"380 data-motion-phase="enter"381 >382 <header class="peach-screen-header peach-newvault-header">383 <Show when={props.onBackToOnboarding}>384 <button385 class="peach-newvault-back"386 type="button"387 onClick={() => props.onBackToOnboarding?.()}388 aria-label="Back to onboarding"389 >390 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">391 <path d="M7 2L3 5.5L7 9" />392 </svg>393 back394 </button>395 </Show>396 <div class="peach-wordmark-stack">397 <PeachWordmark class="peach-wordmark-wrap" />398 <Show when={props.isPro}>399 <span class="peach-pro-mini-badge">pro</span>400 </Show>401 </div>402 </header>403404 <div class="peach-newvault-mark" aria-hidden="true">405 <PeachMark class="peach-newvault-mark-art" />406 </div>407408 <div class="peach-newvault-card">409 <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>410 <hr class="peach-newvault-divider" />411412 <div class="peach-newvault-field">413 <div class="peach-newvault-label-row">414 <label class="peach-label" for="master-password">415 master password416 </label>417 <button418 class="peach-lock-reveal-btn"419 type="button"420 onClick={() => setShowPassword((value) => !value)}421 aria-label={showPassword() ? "Hide password" : "Show password"}422 >423 <Show424 when={showPassword()}425 fallback={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 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" />428 <circle cx="7" cy="5" r="1.75" />429 </svg>430 }431 >432 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">433 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />434 <path d="M3.5 5.5L2.5 7.5" />435 <path d="M7 6.5V8.5" />436 <path d="M10.5 5.5L11.5 7.5" />437 </svg>438 </Show>439 {showPassword() ? "hide" : "show"}440 </button>441 </div>442 <input443 id="master-password"444 class="peach-underline-input peach-newvault-input lock-password-input"445 type={showPassword() ? "text" : "password"}446 placeholder="••••••••••••"447 autocomplete="new-password"448 value={password()}449 disabled={!props.ready}450 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}451 onKeyDown={(event) => {452 if (event.key !== "Enter" || !props.ready) return;453 void handleCreate();454 }}455 />456 <Show457 when={password().length > 0 && passwordStrength() !== null}458 fallback={459 <p class="peach-newvault-hint">460 At least 12 characters. The vault is created once both fields match and pass the strength check.461 </p>462 }463 >464 <div class="peach-password-strength">465 <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>466 <div class="peach-strength-fill"></div>467 </div>468 <span class="peach-strength-label">{passwordStrength()}</span>469 </div>470 </Show>471 </div>472473 <div class="peach-newvault-field">474 <label class="peach-label" for="confirm-password">475 confirm password476 </label>477 <input478 id="confirm-password"479 class="peach-underline-input peach-newvault-input"480 type={showPassword() ? "text" : "password"}481 placeholder="••••••••••••"482 autocomplete="new-password"483 value={confirmPassword()}484 disabled={!props.ready}485 onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}486 onKeyDown={(event) => {487 if (event.key === "Enter" && props.ready) void handleCreate();488 }}489 />490 </div>491492 <Show when={error()}>493 <p class="peach-hint peach-hint-error">{error()}</p>494 </Show>495496 <button497 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"498 onClick={() => void handleCreate()}499 disabled={!props.ready || loading() || !canCreate()}500 aria-live="polite"501 >502 <Show503 when={!canCreate() && createBlockerMessage()}504 fallback={loading() ? "creating..." : "create vault"}505 >506 {createBlockerMessage()}507 </Show>508 </button>509510 <p class="peach-newvault-warning">511 <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>512 <span>Save your 12 recovery words after creating — they're the only way back in if you forget your password.</span>513 </p>514 </div>515 </section>516 </Match>517 </Switch>518 );519}520521522523type RestoreTab = "symbols" | "codex" | "csv";524525function tabLabel(tab: RestoreTab): string {526 return (527 { symbols: "PeachScript", codex: "Codex", csv: "CSV" }[tab]528 );529}530531function RestoreTabView(props: {532 hasVault: boolean;533 onOpenCodexRestore?: () => void;534 onBack: () => void;535 onUnlock: () => void | Promise<void>;536}) {537 const [activeTab, setActiveTab] = createSignal<RestoreTab>("symbols");538 const [showIntro, setShowIntro] = createSignal(false);539 const [pendingUnlock] = createSignal(false);540541 async function handleRestoreSuccess() {542 const seen = await storageGet("peach_shown_restore_intro").catch(() => null);543 if (!seen) {544 setShowIntro(true);545 } else {546 await props.onUnlock();547 }548 }549550 async function dismissIntro() {551 await storageSet("peach_shown_restore_intro", "true").catch(() => {});552 setShowIntro(false);553 await props.onUnlock();554 }555556 return (557 <section558 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-restore-tabview"559 data-motion-phase="enter"560 >561 <RuneBackground opacity={0.05} />562563 <header class="peach-screen-header lock-screen-header">564 <PeachWordmark class="peach-wordmark-wrap" />565 <button class="peach-lock-restore-link" onClick={props.onBack}>566 ‹ back567 </button>568 </header>569570 <div class="peach-newvault-card peach-restore-tabcard">571 <div class="peach-restore-tabs" role="tablist">572 <For each={["symbols", "codex", "csv"] as RestoreTab[]}>573 {(tab) => (574 <button575 class="peach-restore-tab"576 classList={{ "is-active": activeTab() === tab }}577 onClick={() => setActiveTab(tab)}578 role="tab"579 aria-selected={activeTab() === tab}580 >581 {tabLabel(tab)}582 </button>583 )}584 </For>585 </div>586 <div class="peach-restore-content">587 <Show when={activeTab() === "symbols"}>588 <RestoreSymbols hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />589 </Show>590 <Show when={activeTab() === "codex"}>591 <RestoreCodex onOpen={() => props.onOpenCodexRestore?.()} />592 </Show>593 <Show when={activeTab() === "csv"}>594 <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />595 </Show>596 </div>597 </div>598599 {}600 <Show when={showIntro()}>601 <div class="peach-restore-intro-overlay">602 <div class="peach-restore-intro-card">603 <p class="peach-restore-intro-headline">welcome to peach.</p>604 <div class="peach-restore-intro-body">605 <p>Your vault lives only on this device. No server has a copy.</p>606 <p>The inline bubble fills passwords automatically when you visit sites.</p>607 <p>Export your vault from Settings before switching devices.</p>608 </div>609 <button610 class="peach-btn peach-btn--accent peach-btn--md peach-restore-intro-btn"611 onClick={() => void dismissIntro()}612 disabled={pendingUnlock()}613 >614 got it615 </button>616 </div>617 </div>618 </Show>619 </section>620 );621}622623624625function RestoreCodex(props: { onOpen: () => void }) {626 return (627 <div class="peach-restore-codex">628 <p class="peach-restore-codex-title">Restore from Peach Codex</p>629 <p class="peach-restore-codex-copy">630 Scan printed Codex pages or import the Codex PDF. Codex restore is631 always available, even before you create a vault.632 </p>633 <button634 type="button"635 class="peach-btn peach-btn--accent peach-btn--md peach-restore-codex-button"636 onClick={props.onOpen}637 >638 open Codex restore →639 </button>640 </div>641 );642}643644645646function RestoreSymbols(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {647 const [scriptInput, setScriptInput] = createSignal("");648 const [backupPass, setBackupPass] = createSignal("");649 const [working, setWorking] = createSignal(false);650 const [animating, setAnimating] = createSignal<"chaos" | "snap" | "bloom" | null>(null);651 const [error, setError] = createSignal<string | null>(null);652 let canvasRef: HTMLCanvasElement | undefined;653 let animFrameId = 0;654 let runes: { x: number; y: number; vx: number; vy: number; char: string; size: number; angle: number; va: number }[] = [];655656 const scriptValid = createMemo(() => isPeachScript(scriptInput()));657658 function stopAnim() {659 cancelAnimationFrame(animFrameId);660 animFrameId = 0;661 }662663 function startChaos(canvas: HTMLCanvasElement) {664 const ctx = canvas.getContext("2d")!;665 const W = canvas.width;666 const H = canvas.height;667 const RUNE_CHARS = Array.from({ length: 40 }, (_, i) =>668 String.fromCodePoint(0xe000 + i)669 );670 if (runes.length === 0) {671 runes = Array.from({ length: 60 }, () => ({672 x: Math.random() * W,673 y: Math.random() * H,674 vx: (Math.random() - 0.5) * 3,675 vy: (Math.random() - 0.5) * 3,676 char: RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]!,677 size: 14 + Math.random() * 20,678 angle: Math.random() * Math.PI * 2,679 va: (Math.random() - 0.5) * 0.1,680 }));681 }682683 function draw() {684 ctx.clearRect(0, 0, W, H);685 for (const r of runes) {686 r.x = (r.x + r.vx + W) % W;687 r.y = (r.y + r.vy + H) % H;688 r.angle += r.va;689 ctx.save();690 ctx.translate(r.x, r.y);691 ctx.rotate(r.angle);692 ctx.font = `${r.size}px PeachScript, monospace`;693 const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 400 + r.x);694 ctx.fillStyle = `rgba(232, 149, 109, ${alpha})`;695 ctx.shadowColor = "rgba(232, 149, 109, 0.7)";696 ctx.shadowBlur = 8;697 ctx.fillText(r.char, 0, 0);698 ctx.restore();699 }700 animFrameId = requestAnimationFrame(draw);701 }702 draw();703 }704705 function snapToGrid(canvas: HTMLCanvasElement, onDone: () => void) {706 stopAnim();707 const ctx = canvas.getContext("2d")!;708 const W = canvas.width;709 const H = canvas.height;710 const cols = 8;711 const rows = 6;712 const cellW = W / cols;713 const cellH = H / rows;714 const targets = runes.slice(0, cols * rows).map((_, i) => ({715 x: (i % cols) * cellW + cellW / 2,716 y: Math.floor(i / cols) * cellH + cellH / 2,717 }));718 const start = performance.now();719 const DURATION = 400;720721 function drawSnap(now: number) {722 const t = Math.min((now - start) / DURATION, 1);723 const ease = t < 1 ? 1 - (1 - t) ** 3 : 1;724 ctx.clearRect(0, 0, W, H);725 runes.slice(0, targets.length).forEach((r, i) => {726 const tx = targets[i]!.x;727 const ty = targets[i]!.y;728 const cx = r.x + (tx - r.x) * ease;729 const cy = r.y + (ty - r.y) * ease;730 ctx.font = `16px PeachScript, monospace`;731 ctx.fillStyle = `rgba(232, 149, 109, ${0.8 * ease})`;732 ctx.shadowColor = "rgba(232, 149, 109, 0.6)";733 ctx.shadowBlur = 6;734 ctx.fillText(r.char, cx - 8, cy + 8);735 });736 if (t < 1) {737 animFrameId = requestAnimationFrame(drawSnap);738 } else {739 onDone();740 }741 }742 animFrameId = requestAnimationFrame(drawSnap);743 }744745 function bloom(canvas: HTMLCanvasElement, onDone: () => void) {746 stopAnim();747 const ctx = canvas.getContext("2d")!;748 const W = canvas.width;749 const H = canvas.height;750 const start = performance.now();751 const DURATION = 900;752753 function drawBloom(now: number) {754 const t = Math.min((now - start) / DURATION, 1);755 ctx.clearRect(0, 0, W, H);756 757 const runeAlpha = 1 - Math.min(t / 0.4, 1);758 if (runeAlpha > 0) {759 for (const r of runes.slice(0, 48)) {760 ctx.font = `16px PeachScript, monospace`;761 ctx.fillStyle = `rgba(232, 149, 109, ${runeAlpha * 0.8})`;762 ctx.fillText(r.char, r.x - 8, r.y + 8);763 }764 }765 766 const markT = Math.max(0, (t - 0.2) / 0.5);767 const markScale = Math.min(markT * 1.2, 1);768 const markAlpha = markT < 0.5 ? markT * 2 : 2 - markT * 2;769 if (markAlpha > 0) {770 ctx.save();771 ctx.globalAlpha = markAlpha;772 ctx.shadowColor = `rgba(217, 104, 48, ${markAlpha * 0.8})`;773 ctx.shadowBlur = 30;774 ctx.translate(W / 2, H / 2);775 ctx.scale(markScale, markScale);776 ctx.drawImage(peachMarkImg, -32, -32, 64, 64);777 ctx.restore();778 }779 if (t < 1) {780 animFrameId = requestAnimationFrame(drawBloom);781 } else {782 onDone();783 }784 }785 animFrameId = requestAnimationFrame(drawBloom);786 }787788 onCleanup(() => stopAnim());789790 async function handleRestore() {791 if (!scriptValid() || !backupPass().trim() || working()) return;792 setError(null);793 setWorking(true);794 setAnimating("chaos");795796 const canvas = canvasRef;797 if (canvas) startChaos(canvas);798799 try {800 const bytes = decodePeachScript(normalisePeachScript(scriptInput()));801 const payload = await parsePayload(bytes, backupPass());802803 if (!props.hasVault) {804 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });805 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");806 }807808 const rr = await sendMessage({809 type: "RESTORE_ENTRIES",810 payload: { entries: payload.entries, mode: "merge", settings: payload.settings },811 });812 if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");813814 if (payload.license?.key) {815 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});816 }817818 setAnimating("snap");819 stopAnim();820 if (canvas) {821 snapToGrid(canvas, () => {822 setAnimating("bloom");823 bloom(canvas, async () => {824 setAnimating(null);825 await props.onSuccess();826 });827 });828 } else {829 await props.onSuccess();830 }831 } catch (err) {832 stopAnim();833 setAnimating(null);834 const code = err instanceof Error ? err.message : String(err);835 setError(836 code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"837 ? "That passphrase didn't work."838 : code === "CREATE_VAULT_FAILED"839 ? "Couldn't create a new local vault."840 : "Could not read this backup.",841 );842 } finally {843 setWorking(false);844 }845 }846847 return (848 <div class="peach-rt-symbols">849 <Show when={animating() !== null}>850 <canvas851 ref={(el) => { canvasRef = el; }}852 class="peach-rt-anim-canvas"853 width={280}854 height={200}855 />856 </Show>857 <Show when={animating() === null}>858 <textarea859 class="peach-restore-symbols-textarea"860 placeholder="paste peachscript…"861 rows={3}862 value={scriptInput()}863 onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}864 />865 <div class="peach-rt-divider">— or —</div>866 <label class="peach-btn peach-btn--ghost peach-btn--sm peach-rt-file-label">867 open .peach file868 <input869 type="file"870 accept=".peach,text/plain"871 style="display:none"872 onChange={async (e) => {873 const file = (e.currentTarget as HTMLInputElement).files?.[0];874 if (!file) return;875 try {876 setScriptInput(await file.text());877 } catch {878 setError("Couldn't read file.");879 } finally {880 (e.currentTarget as HTMLInputElement).value = "";881 }882 }}883 />884 </label>885 <input886 class="peach-restore-symbols-pass"887 type="password"888 placeholder="backup password"889 value={backupPass()}890 onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}891 onKeyDown={(e) => {892 if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {893 e.preventDefault();894 void handleRestore();895 }896 }}897 />898 <button899 class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"900 onClick={() => void handleRestore()}901 disabled={!scriptValid() || !backupPass().trim() || working()}902 >903 restore vault →904 </button>905 <Show when={error()}>906 <p class="peach-rt-error">{error()}</p>907 </Show>908 </Show>909 </div>910 );911}912913914915function RestoreCSV(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {916 const [importing, setImporting] = createSignal(false);917 const [error, setError] = createSignal<string | null>(null);918 919 920 921 922 const CSV_MIN_PASSWORD_LENGTH = 12;923 const [newPass, setNewPass] = createSignal("");924 const [confirmPass, setConfirmPass] = createSignal("");925 const needsPassword = () => props.hasVault === false;926 const passwordReady = () =>927 !needsPassword() ||928 (newPass().length >= CSV_MIN_PASSWORD_LENGTH && newPass() === confirmPass());929930 async function handleFile(e: Event) {931 const input = e.currentTarget as HTMLInputElement;932 const file = input.files?.[0];933 input.value = "";934 if (!file) return;935 if (needsPassword() && newPass().length < CSV_MIN_PASSWORD_LENGTH) {936 setError(`Set a master password of at least ${CSV_MIN_PASSWORD_LENGTH} characters first.`);937 return;938 }939 if (needsPassword() && newPass() !== confirmPass()) {940 setError("Passwords do not match.");941 return;942 }943 setImporting(true);944 setError(null);945 try {946 const text = await file.text();947 const parsed = await parseImportSource(text);948 if (parsed.entries.length === 0) throw new Error("NO_ENTRIES");949950 if (props.hasVault === false) {951 952 953 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: newPass() } });954 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");955 }956957 const res = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: parsed.entries } });958 if (!(res as { ok: boolean }).ok) throw new Error("IMPORT_FAILED");959 if (parsed.licenseKey) {960 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: parsed.licenseKey } }).catch(() => {});961 }962 await props.onSuccess();963 } catch (err) {964 const code = err instanceof Error ? err.message : String(err);965 setError(966 code === "NO_ENTRIES" ? "No entries found in this file." :967 code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." :968 "Import failed. Check the file format and try again."969 );970 } finally {971 setImporting(false);972 }973 }974975 return (976 <div class="peach-rt-csv">977 <Show when={needsPassword()}>978 <input979 class="peach-restore-symbols-pass"980 type="password"981 placeholder="new master password"982 value={newPass()}983 onInput={(e) => setNewPass((e.target as HTMLInputElement).value)}984 />985 <input986 class="peach-restore-symbols-pass"987 type="password"988 placeholder="confirm master password"989 value={confirmPass()}990 onInput={(e) => setConfirmPass((e.target as HTMLInputElement).value)}991 />992 </Show>993 <label994 class="peach-btn peach-btn--accent peach-btn--md peach-rt-csv-btn"995 classList={{ "is-busy": importing(), "is-disabled": !passwordReady() }}996 >997 {importing() ? "importing…" : "open csv file"}998 <input999 type="file"1000 accept=".csv,.json,.peach,application/x-peach"1001 style="display:none"1002 onChange={handleFile}1003 disabled={importing() || !passwordReady()}1004 />1005 </label>1006 <p class="peach-rt-csv-sources">1007 Imports from Bitwarden, 1Password, Proton Pass,<br />1008 Chrome, Safari, and Edge.1009 </p>1010 <p class="peach-rt-csv-warning">PLAIN-TEXT — DELETE THE FILE AFTER IMPORT</p>1011 <Show when={error()}>1012 <p class="peach-rt-error">{error()}</p>1013 </Show>1014 </div>1015 );1016}1017101810191020function getDisplayError(error: string | undefined, fallback: string): string {1021 if (!error) return fallback;1022 if (1023 error === "SERVICE_WORKER_UNAVAILABLE" ||1024 error.includes("Receiving end does not exist") ||1025 error.includes("message port closed")1026 ) {1027 return "background service unavailable";1028 }1029 return error;1030}