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 <PeachMark class="peach-lock-mark" />305 <span class="peach-lock-eyebrow">vault locked</span>306 <h1 class="peach-onboarding-headline peach-lock-headline--unlock">307 unlock308 <br />309 your vault.310 </h1>311 </div>312313 <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">314 <label class="peach-label" for="unlock-password">315 master password316 </label>317 <input318 id="unlock-password"319 class="peach-underline-input peach-unlock-input lock-password-input"320 type="password"321 placeholder="••••••••••••"322 autocomplete="current-password"323 value={password()}324 disabled={!props.ready}325 ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}326 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}327 onKeyDown={(event) => {328 if (event.key !== "Enter" || !props.ready) return;329 void handleUnlock();330 }}331 />332333 <Show when={error()}>334 <p class="peach-hint peach-hint-error">{error()}</p>335 </Show>336337 <button338 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"339 onClick={() => void handleUnlock()}340 disabled={!props.ready || loading()}341 >342 {loading() ? "unlocking..." : "unlock"}343 </button>344345 <Show when={biometricAvailable()}>346 <button347 class="peach-biometric-btn"348 type="button"349 onClick={() => void handleBiometricUnlock()}350 disabled={biometricBusy() || loading()}351 aria-label="Unlock with biometrics"352 >353 <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">354 <path d="M12 11c-1.7 0-3 1.3-3 3v3"/>355 <path d="M15 17v-3"/>356 <path d="M9 4.5C9.7 4.2 10.8 4 12 4s2.3.2 3 .5"/>357 <path d="M5 9c.7-1.6 2-2.9 3.6-3.6"/>358 <path d="M19 9c-.7-1.6-2-2.9-3.6-3.6"/>359 <path d="M5 15c0-1.7.3-3.3.8-4.7"/>360 <path d="M19 15c0-1.7-.3-3.3-.8-4.7"/>361 </svg>362 {biometricBusy() ? "verifying…" : "use biometrics"}363 </button>364 </Show>365366 <button367 class="peach-lock-recover-link"368 type="button"369 onClick={() => setRecoverSurface("words")}370 >371 forgot your password?372 </button>373 </section>374 </section>375 </Match>376377 {}378 <Match when={true}>379 <section380 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"381 data-motion-phase="enter"382 >383 <header class="peach-screen-header peach-newvault-header">384 <Show when={props.onBackToOnboarding}>385 <button386 class="peach-newvault-back"387 type="button"388 onClick={() => props.onBackToOnboarding?.()}389 aria-label="Back to onboarding"390 >391 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">392 <path d="M7 2L3 5.5L7 9" />393 </svg>394 back395 </button>396 </Show>397 <div class="peach-wordmark-stack">398 <PeachWordmark class="peach-wordmark-wrap" />399 <Show when={props.isPro}>400 <span class="peach-pro-mini-badge">pro</span>401 </Show>402 </div>403 </header>404405 <div class="peach-newvault-mark" aria-hidden="true">406 <PeachMark class="peach-newvault-mark-art" />407 </div>408409 <div class="peach-newvault-card">410 <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>411 <hr class="peach-newvault-divider" />412413 <div class="peach-newvault-field">414 <div class="peach-newvault-label-row">415 <label class="peach-label" for="master-password">416 master password417 </label>418 <button419 class="peach-lock-reveal-btn"420 type="button"421 onClick={() => setShowPassword((value) => !value)}422 aria-label={showPassword() ? "Hide password" : "Show password"}423 >424 <Show425 when={showPassword()}426 fallback={427 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">428 <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" />429 <circle cx="7" cy="5" r="1.75" />430 </svg>431 }432 >433 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">434 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />435 <path d="M3.5 5.5L2.5 7.5" />436 <path d="M7 6.5V8.5" />437 <path d="M10.5 5.5L11.5 7.5" />438 </svg>439 </Show>440 {showPassword() ? "hide" : "show"}441 </button>442 </div>443 <input444 id="master-password"445 class="peach-underline-input peach-newvault-input lock-password-input"446 type={showPassword() ? "text" : "password"}447 placeholder="••••••••••••"448 autocomplete="new-password"449 value={password()}450 disabled={!props.ready}451 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}452 onKeyDown={(event) => {453 if (event.key !== "Enter" || !props.ready) return;454 void handleCreate();455 }}456 />457 <Show458 when={password().length > 0 && passwordStrength() !== null}459 fallback={460 <p class="peach-newvault-hint">461 At least 12 characters. The vault is created once both fields match and pass the strength check.462 </p>463 }464 >465 <div class="peach-password-strength">466 <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>467 <div class="peach-strength-fill"></div>468 </div>469 <span class="peach-strength-label">{passwordStrength()}</span>470 </div>471 </Show>472 </div>473474 <div class="peach-newvault-field">475 <label class="peach-label" for="confirm-password">476 confirm password477 </label>478 <input479 id="confirm-password"480 class="peach-underline-input peach-newvault-input"481 type={showPassword() ? "text" : "password"}482 placeholder="••••••••••••"483 autocomplete="new-password"484 value={confirmPassword()}485 disabled={!props.ready}486 onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}487 onKeyDown={(event) => {488 if (event.key === "Enter" && props.ready) void handleCreate();489 }}490 />491 </div>492493 <Show when={error()}>494 <p class="peach-hint peach-hint-error">{error()}</p>495 </Show>496497 <button498 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"499 onClick={() => void handleCreate()}500 disabled={!props.ready || loading() || !canCreate()}501 aria-live="polite"502 >503 <Show504 when={!canCreate() && createBlockerMessage()}505 fallback={loading() ? "creating..." : "create vault"}506 >507 {createBlockerMessage()}508 </Show>509 </button>510511 <p class="peach-newvault-warning">512 <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>513 <span>Save your 12 recovery words after creating — they're the only way back in if you forget your password.</span>514 </p>515 </div>516 </section>517 </Match>518 </Switch>519 );520}521522523524type RestoreTab = "symbols" | "codex" | "csv";525526function tabLabel(tab: RestoreTab): string {527 return (528 { symbols: "PeachScript", codex: "Codex", csv: "CSV" }[tab]529 );530}531532function RestoreTabView(props: {533 hasVault: boolean;534 onOpenCodexRestore?: () => void;535 onBack: () => void;536 onUnlock: () => void | Promise<void>;537}) {538 const [activeTab, setActiveTab] = createSignal<RestoreTab>("symbols");539 const [showIntro, setShowIntro] = createSignal(false);540 const [pendingUnlock] = createSignal(false);541542 async function handleRestoreSuccess() {543 const seen = await storageGet("peach_shown_restore_intro").catch(() => null);544 if (!seen) {545 setShowIntro(true);546 } else {547 await props.onUnlock();548 }549 }550551 async function dismissIntro() {552 await storageSet("peach_shown_restore_intro", "true").catch(() => {});553 setShowIntro(false);554 await props.onUnlock();555 }556557 return (558 <section559 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-restore-tabview"560 data-motion-phase="enter"561 >562 <RuneBackground opacity={0.05} />563564 <header class="peach-screen-header lock-screen-header">565 <PeachWordmark class="peach-wordmark-wrap" />566 <button class="peach-lock-restore-link" onClick={props.onBack}>567 ‹ back568 </button>569 </header>570571 <div class="peach-newvault-card peach-restore-tabcard">572 <div class="peach-restore-tabs" role="tablist">573 <For each={["symbols", "codex", "csv"] as RestoreTab[]}>574 {(tab) => (575 <button576 class="peach-restore-tab"577 classList={{ "is-active": activeTab() === tab }}578 onClick={() => setActiveTab(tab)}579 role="tab"580 aria-selected={activeTab() === tab}581 >582 {tabLabel(tab)}583 </button>584 )}585 </For>586 </div>587 <div class="peach-restore-content">588 <Show when={activeTab() === "symbols"}>589 <RestoreSymbols hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />590 </Show>591 <Show when={activeTab() === "codex"}>592 <RestoreCodex onOpen={() => props.onOpenCodexRestore?.()} />593 </Show>594 <Show when={activeTab() === "csv"}>595 <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />596 </Show>597 </div>598 </div>599600 {}601 <Show when={showIntro()}>602 <div class="peach-restore-intro-overlay">603 <div class="peach-restore-intro-card">604 <p class="peach-restore-intro-headline">welcome to peach.</p>605 <div class="peach-restore-intro-body">606 <p>Your vault lives only on this device. No server has a copy.</p>607 <p>The inline bubble fills passwords automatically when you visit sites.</p>608 <p>Export your vault from Settings before switching devices.</p>609 </div>610 <button611 class="peach-btn peach-btn--accent peach-btn--md peach-restore-intro-btn"612 onClick={() => void dismissIntro()}613 disabled={pendingUnlock()}614 >615 got it616 </button>617 </div>618 </div>619 </Show>620 </section>621 );622}623624625626function RestoreCodex(props: { onOpen: () => void }) {627 return (628 <div class="peach-restore-codex">629 <p class="peach-restore-codex-title">Restore from Peach Codex</p>630 <p class="peach-restore-codex-copy">631 Scan printed Codex pages or import the Codex PDF. Codex restore is632 always available, even before you create a vault.633 </p>634 <button635 type="button"636 class="peach-btn peach-btn--accent peach-btn--md peach-restore-codex-button"637 onClick={props.onOpen}638 >639 open Codex restore →640 </button>641 </div>642 );643}644645646647function RestoreSymbols(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {648 const [scriptInput, setScriptInput] = createSignal("");649 const [backupPass, setBackupPass] = createSignal("");650 const [working, setWorking] = createSignal(false);651 const [animating, setAnimating] = createSignal<"chaos" | "snap" | "bloom" | null>(null);652 const [error, setError] = createSignal<string | null>(null);653 let canvasRef: HTMLCanvasElement | undefined;654 let animFrameId = 0;655 let runes: { x: number; y: number; vx: number; vy: number; char: string; size: number; angle: number; va: number }[] = [];656657 const scriptValid = createMemo(() => isPeachScript(scriptInput()));658659 function stopAnim() {660 cancelAnimationFrame(animFrameId);661 animFrameId = 0;662 }663664 function startChaos(canvas: HTMLCanvasElement) {665 const ctx = canvas.getContext("2d")!;666 const W = canvas.width;667 const H = canvas.height;668 const RUNE_CHARS = Array.from({ length: 40 }, (_, i) =>669 String.fromCodePoint(0xe000 + i)670 );671 if (runes.length === 0) {672 runes = Array.from({ length: 60 }, () => ({673 x: Math.random() * W,674 y: Math.random() * H,675 vx: (Math.random() - 0.5) * 3,676 vy: (Math.random() - 0.5) * 3,677 char: RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]!,678 size: 14 + Math.random() * 20,679 angle: Math.random() * Math.PI * 2,680 va: (Math.random() - 0.5) * 0.1,681 }));682 }683684 function draw() {685 ctx.clearRect(0, 0, W, H);686 for (const r of runes) {687 r.x = (r.x + r.vx + W) % W;688 r.y = (r.y + r.vy + H) % H;689 r.angle += r.va;690 ctx.save();691 ctx.translate(r.x, r.y);692 ctx.rotate(r.angle);693 ctx.font = `${r.size}px PeachScript, monospace`;694 const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 400 + r.x);695 ctx.fillStyle = `rgba(232, 149, 109, ${alpha})`;696 ctx.shadowColor = "rgba(232, 149, 109, 0.7)";697 ctx.shadowBlur = 8;698 ctx.fillText(r.char, 0, 0);699 ctx.restore();700 }701 animFrameId = requestAnimationFrame(draw);702 }703 draw();704 }705706 function snapToGrid(canvas: HTMLCanvasElement, onDone: () => void) {707 stopAnim();708 const ctx = canvas.getContext("2d")!;709 const W = canvas.width;710 const H = canvas.height;711 const cols = 8;712 const rows = 6;713 const cellW = W / cols;714 const cellH = H / rows;715 const targets = runes.slice(0, cols * rows).map((_, i) => ({716 x: (i % cols) * cellW + cellW / 2,717 y: Math.floor(i / cols) * cellH + cellH / 2,718 }));719 const start = performance.now();720 const DURATION = 400;721722 function drawSnap(now: number) {723 const t = Math.min((now - start) / DURATION, 1);724 const ease = t < 1 ? 1 - (1 - t) ** 3 : 1;725 ctx.clearRect(0, 0, W, H);726 runes.slice(0, targets.length).forEach((r, i) => {727 const tx = targets[i]!.x;728 const ty = targets[i]!.y;729 const cx = r.x + (tx - r.x) * ease;730 const cy = r.y + (ty - r.y) * ease;731 ctx.font = `16px PeachScript, monospace`;732 ctx.fillStyle = `rgba(232, 149, 109, ${0.8 * ease})`;733 ctx.shadowColor = "rgba(232, 149, 109, 0.6)";734 ctx.shadowBlur = 6;735 ctx.fillText(r.char, cx - 8, cy + 8);736 });737 if (t < 1) {738 animFrameId = requestAnimationFrame(drawSnap);739 } else {740 onDone();741 }742 }743 animFrameId = requestAnimationFrame(drawSnap);744 }745746 function bloom(canvas: HTMLCanvasElement, onDone: () => void) {747 stopAnim();748 const ctx = canvas.getContext("2d")!;749 const W = canvas.width;750 const H = canvas.height;751 const start = performance.now();752 const DURATION = 900;753754 function drawBloom(now: number) {755 const t = Math.min((now - start) / DURATION, 1);756 ctx.clearRect(0, 0, W, H);757 758 const runeAlpha = 1 - Math.min(t / 0.4, 1);759 if (runeAlpha > 0) {760 for (const r of runes.slice(0, 48)) {761 ctx.font = `16px PeachScript, monospace`;762 ctx.fillStyle = `rgba(232, 149, 109, ${runeAlpha * 0.8})`;763 ctx.fillText(r.char, r.x - 8, r.y + 8);764 }765 }766 767 const markT = Math.max(0, (t - 0.2) / 0.5);768 const markScale = Math.min(markT * 1.2, 1);769 const markAlpha = markT < 0.5 ? markT * 2 : 2 - markT * 2;770 if (markAlpha > 0) {771 ctx.save();772 ctx.globalAlpha = markAlpha;773 ctx.shadowColor = `rgba(217, 104, 48, ${markAlpha * 0.8})`;774 ctx.shadowBlur = 30;775 ctx.translate(W / 2, H / 2);776 ctx.scale(markScale, markScale);777 ctx.drawImage(peachMarkImg, -32, -32, 64, 64);778 ctx.restore();779 }780 if (t < 1) {781 animFrameId = requestAnimationFrame(drawBloom);782 } else {783 onDone();784 }785 }786 animFrameId = requestAnimationFrame(drawBloom);787 }788789 onCleanup(() => stopAnim());790791 async function handleRestore() {792 if (!scriptValid() || !backupPass().trim() || working()) return;793 setError(null);794 setWorking(true);795 setAnimating("chaos");796797 const canvas = canvasRef;798 if (canvas) startChaos(canvas);799800 try {801 const bytes = decodePeachScript(normalisePeachScript(scriptInput()));802 const payload = await parsePayload(bytes, backupPass());803804 if (!props.hasVault) {805 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });806 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");807 }808809 const rr = await sendMessage({810 type: "RESTORE_ENTRIES",811 payload: { entries: payload.entries, mode: "merge", settings: payload.settings },812 });813 if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");814815 if (payload.license?.key) {816 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});817 }818819 setAnimating("snap");820 stopAnim();821 if (canvas) {822 snapToGrid(canvas, () => {823 setAnimating("bloom");824 bloom(canvas, async () => {825 setAnimating(null);826 await props.onSuccess();827 });828 });829 } else {830 await props.onSuccess();831 }832 } catch (err) {833 stopAnim();834 setAnimating(null);835 const code = err instanceof Error ? err.message : String(err);836 setError(837 code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"838 ? "That passphrase didn't work."839 : code === "CREATE_VAULT_FAILED"840 ? "Couldn't create a new local vault."841 : "Could not read this backup.",842 );843 } finally {844 setWorking(false);845 }846 }847848 return (849 <div class="peach-rt-symbols">850 <Show when={animating() !== null}>851 <canvas852 ref={(el) => { canvasRef = el; }}853 class="peach-rt-anim-canvas"854 width={280}855 height={200}856 />857 </Show>858 <Show when={animating() === null}>859 <textarea860 class="peach-restore-symbols-textarea"861 placeholder="paste peachscript…"862 rows={3}863 value={scriptInput()}864 onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}865 />866 <div class="peach-rt-divider">— or —</div>867 <label class="peach-btn peach-btn--ghost peach-btn--sm peach-rt-file-label">868 open .peach file869 <input870 type="file"871 accept=".peach,text/plain"872 style="display:none"873 onChange={async (e) => {874 const file = (e.currentTarget as HTMLInputElement).files?.[0];875 if (!file) return;876 try {877 setScriptInput(await file.text());878 } catch {879 setError("Couldn't read file.");880 } finally {881 (e.currentTarget as HTMLInputElement).value = "";882 }883 }}884 />885 </label>886 <input887 class="peach-restore-symbols-pass"888 type="password"889 placeholder="backup password"890 value={backupPass()}891 onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}892 onKeyDown={(e) => {893 if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {894 e.preventDefault();895 void handleRestore();896 }897 }}898 />899 <button900 class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"901 onClick={() => void handleRestore()}902 disabled={!scriptValid() || !backupPass().trim() || working()}903 >904 restore vault →905 </button>906 <Show when={error()}>907 <p class="peach-rt-error">{error()}</p>908 </Show>909 </Show>910 </div>911 );912}913914915916function RestoreCSV(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {917 const [importing, setImporting] = createSignal(false);918 const [error, setError] = createSignal<string | null>(null);919 920 921 922 923 const CSV_MIN_PASSWORD_LENGTH = 12;924 const [newPass, setNewPass] = createSignal("");925 const [confirmPass, setConfirmPass] = createSignal("");926 const needsPassword = () => props.hasVault === false;927 const passwordReady = () =>928 !needsPassword() ||929 (newPass().length >= CSV_MIN_PASSWORD_LENGTH && newPass() === confirmPass());930931 async function handleFile(e: Event) {932 const input = e.currentTarget as HTMLInputElement;933 const file = input.files?.[0];934 input.value = "";935 if (!file) return;936 if (needsPassword() && newPass().length < CSV_MIN_PASSWORD_LENGTH) {937 setError(`Set a master password of at least ${CSV_MIN_PASSWORD_LENGTH} characters first.`);938 return;939 }940 if (needsPassword() && newPass() !== confirmPass()) {941 setError("Passwords do not match.");942 return;943 }944 setImporting(true);945 setError(null);946 try {947 const text = await file.text();948 const parsed = await parseImportSource(text);949 if (parsed.entries.length === 0) throw new Error("NO_ENTRIES");950951 if (props.hasVault === false) {952 953 954 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: newPass() } });955 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");956 }957958 const res = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: parsed.entries } });959 if (!(res as { ok: boolean }).ok) throw new Error("IMPORT_FAILED");960 if (parsed.licenseKey) {961 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: parsed.licenseKey } }).catch(() => {});962 }963 await props.onSuccess();964 } catch (err) {965 const code = err instanceof Error ? err.message : String(err);966 setError(967 code === "NO_ENTRIES" ? "No entries found in this file." :968 code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." :969 "Import failed. Check the file format and try again."970 );971 } finally {972 setImporting(false);973 }974 }975976 return (977 <div class="peach-rt-csv">978 <Show when={needsPassword()}>979 <input980 class="peach-restore-symbols-pass"981 type="password"982 placeholder="new master password"983 value={newPass()}984 onInput={(e) => setNewPass((e.target as HTMLInputElement).value)}985 />986 <input987 class="peach-restore-symbols-pass"988 type="password"989 placeholder="confirm master password"990 value={confirmPass()}991 onInput={(e) => setConfirmPass((e.target as HTMLInputElement).value)}992 />993 </Show>994 <label995 class="peach-btn peach-btn--accent peach-btn--md peach-rt-csv-btn"996 classList={{ "is-busy": importing(), "is-disabled": !passwordReady() }}997 >998 {importing() ? "importing…" : "open csv file"}999 <input1000 type="file"1001 accept=".csv,.json,.peach,application/x-peach"1002 style="display:none"1003 onChange={handleFile}1004 disabled={importing() || !passwordReady()}1005 />1006 </label>1007 <p class="peach-rt-csv-sources">1008 Imports from Bitwarden, 1Password, Proton Pass,<br />1009 Chrome, Safari, and Edge.1010 </p>1011 <p class="peach-rt-csv-warning">PLAIN-TEXT — DELETE THE FILE AFTER IMPORT</p>1012 <Show when={error()}>1013 <p class="peach-rt-error">{error()}</p>1014 </Show>1015 </div>1016 );1017}1018101910201021function getDisplayError(error: string | undefined, fallback: string): string {1022 if (!error) return fallback;1023 if (1024 error === "SERVICE_WORKER_UNAVAILABLE" ||1025 error.includes("Receiving end does not exist") ||1026 error.includes("message port closed")1027 ) {1028 return "background service unavailable";1029 }1030 return error;1031}