1import { For, Match, Show, Switch, createMemo, createSignal, onCleanup, onMount } from "solid-js";2import { sendMessage } from "../App";3import { LockRuneCanvas } from "../components/LockRuneCanvas";4import { PeachMark, PeachWordmark } from "../components/PeachMark";5import { RecoveryWordEntryView } from "./RecoveryWordEntry";6import { RecoveryWordDisplayView } from "./RecoveryWordDisplay";7import { RuneBackground } from "../components/RuneBackground";8import { storageGet, storageSet } from "../../shared/storage";9import { isPeachScript, decodePeachScript, normalisePeachScript } from "../../crypto/peachScript";10import { parsePayload } from "../../crypto/peachPayload";11import { parseImportSource } from "../../shared/import";12import { isNativePlatform, nativePlugin } from "../../shared/native";1314const peachMarkImg = new Image();15peachMarkImg.src = `data:image/svg+xml,${encodeURIComponent(16 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" fill="none">' +17 '<path d="M 124 64 C 134 32 168 16 200 22 C 198 56 168 72 130 66 Z" fill="#92AC65"/>' +18 '<path d="M 130 64 C 152 46 178 30 196 24" stroke="#5E7A42" stroke-width="2.6" fill="none" stroke-linecap="round"/>' +19 '<path d="M 120 60 C 75 45 28 75 22 130 C 18 185 65 222 125 222 C 185 222 222 185 220 130 C 218 78 170 45 120 60 Z" fill="#FFB082"/>' +20 '<path d="M 120 60 C 75 45 28 75 22 130 C 18 185 65 222 125 222 C 105 195 75 150 100 90 C 105 75 112 65 120 60 Z" fill="#E5805C"/>' +21 '<ellipse cx="178" cy="108" rx="20" ry="11" fill="#FFD0A8" opacity="0.7" transform="rotate(-26 178 108)"/>' +22 '</svg>'23)}`;2425262728let _zxcvbn: ((password: string) => { score: number }) | null = null;29async function getZxcvbn() {30 if (_zxcvbn) return _zxcvbn;31 const [{ zxcvbn, zxcvbnOptions }, common, en] = await Promise.all([32 import("@zxcvbn-ts/core"),33 import("@zxcvbn-ts/language-common"),34 import("@zxcvbn-ts/language-en"),35 ]);36 zxcvbnOptions.setOptions({37 translations: en.translations,38 graphs: common.adjacencyGraphs,39 dictionary: { ...common.dictionary, ...en.dictionary },40 });41 _zxcvbn = (password: string) => zxcvbn(password);42 return _zxcvbn;43}4445interface LockProps {46 hasVault: boolean;47 ready: boolean;48 isPro?: boolean;49 505152 forceRecoveryWords?: boolean;53 onUnlock: () => void | Promise<void>;54 555657 onBackToOnboarding?: () => void;58 5960 onOpenCodexRestore?: () => void;61 62 initialRestore?: boolean;63}6465type RestoreSurface = "restore" | null;66type RecoverSurface = "words" | "new-password" | null;6768export function LockView(props: LockProps) {69 const [password, setPassword] = createSignal("");70 const [confirmPassword, setConfirmPassword] = createSignal("");71 const [error, setError] = createSignal("");72 const [loading, setLoading] = createSignal(false);73 const [restoreSurface, setRestoreSurface] = createSignal<RestoreSurface>(74 props.initialRestore ? "restore" : null,75 );76 const [showPassword, setShowPassword] = createSignal(false);77 const [recoverSurface, setRecoverSurface] = createSignal<RecoverSurface>(null);78 79 80 const [showRecoveryWords, setShowRecoveryWords] = createSignal(props.forceRecoveryWords ?? false);8182 const MIN_LENGTH = 12;83 const MIN_ZXCVBN_SCORE = 2;8485 const [strengthScore, setStrengthScore] = createSignal<number | null>(null);86 const [biometricAvailable, setBiometricAvailable] = createSignal(false);87 const [biometricBusy, setBiometricBusy] = createSignal(false);8889 const canCreate = createMemo(90 () =>91 password().length >= MIN_LENGTH &&92 confirmPassword().length >= MIN_LENGTH &&93 password() === confirmPassword() &&94 strengthScore() !== null &&95 strengthScore()! >= MIN_ZXCVBN_SCORE96 );9798 const passwordStrength = createMemo(() => {99 const score = strengthScore();100 if (score === null) return null;101 if (score <= 1) return "weak";102 if (score === 2) return "fair";103 if (score === 3) return "good";104 return "strong";105 });106107 const createBlockerMessage = createMemo(() => {108 if (!password()) return null;109 if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;110 if (!confirmPassword()) return "confirm your password";111 if (password() !== confirmPassword()) return "passwords don't match";112 if (strengthScore() === null) return "checking strength…";113 if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak — use a longer passphrase";114 return null;115 });116117 118 createMemo(() => {119 const value = password();120 if (!value) { setStrengthScore(null); return; }121 getZxcvbn().then((zxcvbn) => {122 if (password() === value) setStrengthScore(zxcvbn(value).score);123 }).catch(() => {124 125 if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1);126 });127 });128129 async function handleUnlock() {130 if (!password().trim()) return;131 setLoading(true);132 setError("");133 const response = await sendMessage({134 type: "UNLOCK",135 payload: { password: password() },136 });137 const result = response as { ok: boolean; error?: string };138 if (result.ok) {139 await props.onUnlock();140 setLoading(false);141 return;142 }143 setError(getDisplayError(result.error, "incorrect password"));144 setPassword("");145 const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined;146 passwordField?.classList.add("shake");147 setTimeout(() => passwordField?.classList.remove("shake"), 300);148 setLoading(false);149 }150151 async function checkBiometricAvailable(): Promise<void> {152 if (!props.hasVault || !isNativePlatform()) return;153 const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");154 const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");155 if (!keystore || !biometric) return;156 try {157 const stored = await keystore.hasMasterKey();158 if (!stored.exists) return;159 const avail = await biometric.isAvailable();160 if (!avail.available) return;161 setBiometricAvailable(true);162 window.setTimeout(() => { void handleBiometricUnlock(); }, 400);163 } catch { }164 }165166 async function handleBiometricUnlock(): Promise<void> {167 if (biometricBusy()) return;168 const biometric = nativePlugin<{ authenticate: (opts: Record<string, string>) => Promise<void> }>("BiometricPlugin");169 const keystore = nativePlugin<{ retrieveMasterKey: () => Promise<{ masterKeyHex: string }> }>("KeystorePlugin");170 if (!biometric || !keystore) return;171 setBiometricBusy(true);172 setError("");173 try {174 await biometric.authenticate({175 title: "Unlock Peach",176 subtitle: "Use biometrics to access your vault",177 negativeButtonText: "Use Password",178 });179 const { masterKeyHex } = await keystore.retrieveMasterKey();180 const res = (await sendMessage({181 type: "UNLOCK_WITH_KEY",182 payload: { masterKeyHex },183 })) as { ok: boolean; error?: string };184 if (res.ok) {185 await props.onUnlock();186 return;187 }188 if (res.error === "biometric_key_invalidated") {189 await sendMessage({190 type: "UPDATE_SETTINGS",191 payload: { biometricsEnabled: false },192 });193 setBiometricAvailable(false);194 setError("biometric key expired — re-enable in settings");195 } else {196 setError("biometric unlock failed");197 }198 } catch (e) {199 const reason = e instanceof Error ? e.message : String(e);200 if (reason.includes("user_cancelled")) return;201 if (reason.includes("biometric_key_invalidated")) {202 await sendMessage({203 type: "UPDATE_SETTINGS",204 payload: { biometricsEnabled: false },205 });206 setBiometricAvailable(false);207 setError("biometric key expired — re-enable in settings");208 return;209 }210 if (reason.includes("biometric_lockout")) {211 setError("biometric locked out — use your password");212 }213 } finally {214 setBiometricBusy(false);215 }216 }217218 onMount(() => { void checkBiometricAvailable(); });219220 async function handleCreate() {221 if (!password().trim()) return;222 if (password().length < MIN_LENGTH) {223 setError(`password must be at least ${MIN_LENGTH} characters`);224 return;225 }226 if (password() !== confirmPassword()) {227 setError("passwords do not match");228 return;229 }230 const score = strengthScore();231 if (score === null || score < MIN_ZXCVBN_SCORE) {232 setError("password is too weak — use a longer passphrase");233 return;234 }235 setLoading(true);236 setError("");237 const response = await sendMessage({238 type: "CREATE_VAULT",239 payload: { password: password() },240 });241 const result = response as { ok: boolean; error?: string };242 if (result.ok) {243 244 245 246 247 248 try {249 await chrome.storage.local?.set({ peach_recovery_pending: "1" });250 } catch { }251 setShowRecoveryWords(true);252 setLoading(false);253 return;254 }255 setError(getDisplayError(result.error, "failed to create vault"));256 setLoading(false);257 }258259 function openRestore(surface: Exclude<RestoreSurface, null>) {260 if (!props.ready || loading()) return;261 setPassword("");262 setConfirmPassword("");263 setError("");264 setRestoreSurface(surface);265 }266267 268 return (269 <Switch>270 {}271 <Match when={restoreSurface() === "restore"}>272 <RestoreTabView273 hasVault={props.hasVault}274 onOpenCodexRestore={props.onOpenCodexRestore}275 onBack={() => setRestoreSurface(null)}276 onUnlock={async () => {277 setRestoreSurface(null);278 await props.onUnlock();279 }}280 />281 </Match>282283 {}284 <Match when={showRecoveryWords() && (!props.hasVault || props.forceRecoveryWords)}>285 <RecoveryWordDisplayView286 onDone={props.onUnlock}287 />288 </Match>289290 {}291 <Match when={recoverSurface() === "words"}>292 <RecoveryWordEntryView293 onCancel={() => setRecoverSurface(null)}294 onRecoveryComplete={props.onUnlock}295 />296 </Match>297298 {}299 <Match when={props.hasVault}>300 <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">301 <header class="peach-screen-header lock-screen-header">302 <div class="peach-wordmark-stack">303 <PeachWordmark class="peach-wordmark-wrap" />304 <Show when={props.isPro}>305 <span class="peach-pro-mini-badge">pro</span>306 </Show>307 </div>308 </header>309310 <div class="peach-lock-hero peach-lock-hero--unlock">311 <div class="peach-lock-hero-art" aria-hidden="true">312 <PeachMark class="peach-lock-hero-mark" />313 </div>314 <span class="peach-lock-eyebrow">vault locked</span>315 <h1 class="peach-onboarding-headline peach-lock-headline--unlock">316 unlock317 <br />318 your vault.319 </h1>320 </div>321322 <LockRuneCanvas />323324 <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">325 <label class="peach-label" for="unlock-password">326 master password327 </label>328 <input329 id="unlock-password"330 class="peach-underline-input peach-unlock-input lock-password-input"331 type="password"332 placeholder="••••••••••••"333 autocomplete="current-password"334 value={password()}335 disabled={!props.ready}336 ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}337 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}338 onKeyDown={(event) => {339 if (event.key !== "Enter" || !props.ready) return;340 void handleUnlock();341 }}342 />343344 <Show when={error()}>345 <p class="peach-hint peach-hint-error">{error()}</p>346 </Show>347348 <button349 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"350 onClick={() => void handleUnlock()}351 disabled={!props.ready || loading()}352 >353 {loading() ? "unlocking..." : "unlock"}354 </button>355356 <Show when={biometricAvailable()}>357 <button358 class="peach-biometric-btn"359 type="button"360 onClick={() => void handleBiometricUnlock()}361 disabled={biometricBusy() || loading()}362 aria-label="Unlock with biometrics"363 >364 <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">365 <path d="M12 11c-1.7 0-3 1.3-3 3v3"/>366 <path d="M15 17v-3"/>367 <path d="M9 4.5C9.7 4.2 10.8 4 12 4s2.3.2 3 .5"/>368 <path d="M5 9c.7-1.6 2-2.9 3.6-3.6"/>369 <path d="M19 9c-.7-1.6-2-2.9-3.6-3.6"/>370 <path d="M5 15c0-1.7.3-3.3.8-4.7"/>371 <path d="M19 15c0-1.7-.3-3.3-.8-4.7"/>372 </svg>373 {biometricBusy() ? "verifying…" : "use biometrics"}374 </button>375 </Show>376377 <button378 class="peach-lock-recover-link"379 type="button"380 onClick={() => setRecoverSurface("words")}381 >382 forgot your password?383 </button>384 </section>385 </section>386 </Match>387388 {}389 <Match when={true}>390 <section391 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"392 data-motion-phase="enter"393 >394 <header class="peach-screen-header peach-newvault-header">395 <Show when={props.onBackToOnboarding}>396 <button397 class="peach-newvault-back"398 type="button"399 onClick={() => props.onBackToOnboarding?.()}400 aria-label="Back to onboarding"401 >402 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">403 <path d="M7 2L3 5.5L7 9" />404 </svg>405 back406 </button>407 </Show>408 <div class="peach-wordmark-stack">409 <PeachWordmark class="peach-wordmark-wrap" />410 <Show when={props.isPro}>411 <span class="peach-pro-mini-badge">pro</span>412 </Show>413 </div>414 </header>415416 <div class="peach-newvault-mark" aria-hidden="true">417 <PeachMark class="peach-newvault-mark-art" />418 </div>419420 <div class="peach-newvault-card">421 <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>422 <hr class="peach-newvault-divider" />423424 <div class="peach-newvault-field">425 <div class="peach-newvault-label-row">426 <label class="peach-label" for="master-password">427 master password428 </label>429 <button430 class="peach-lock-reveal-btn"431 type="button"432 onClick={() => setShowPassword((value) => !value)}433 aria-label={showPassword() ? "Hide password" : "Show password"}434 >435 <Show436 when={showPassword()}437 fallback={438 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">439 <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" />440 <circle cx="7" cy="5" r="1.75" />441 </svg>442 }443 >444 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">445 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />446 <path d="M3.5 5.5L2.5 7.5" />447 <path d="M7 6.5V8.5" />448 <path d="M10.5 5.5L11.5 7.5" />449 </svg>450 </Show>451 {showPassword() ? "hide" : "show"}452 </button>453 </div>454 <input455 id="master-password"456 class="peach-underline-input peach-newvault-input lock-password-input"457 type={showPassword() ? "text" : "password"}458 placeholder="••••••••••••"459 autocomplete="new-password"460 value={password()}461 disabled={!props.ready}462 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}463 onKeyDown={(event) => {464 if (event.key !== "Enter" || !props.ready) return;465 void handleCreate();466 }}467 />468 <Show469 when={password().length > 0 && passwordStrength() !== null}470 fallback={471 <p class="peach-newvault-hint">472 At least 12 characters. The vault is created once both fields match and pass the strength check.473 </p>474 }475 >476 <div class="peach-password-strength">477 <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>478 <div class="peach-strength-fill"></div>479 </div>480 <span class="peach-strength-label">{passwordStrength()}</span>481 </div>482 </Show>483 </div>484485 <div class="peach-newvault-field">486 <label class="peach-label" for="confirm-password">487 confirm password488 </label>489 <input490 id="confirm-password"491 class="peach-underline-input peach-newvault-input"492 type={showPassword() ? "text" : "password"}493 placeholder="••••••••••••"494 autocomplete="new-password"495 value={confirmPassword()}496 disabled={!props.ready}497 onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}498 onKeyDown={(event) => {499 if (event.key === "Enter" && props.ready) void handleCreate();500 }}501 />502 </div>503504 <Show when={error()}>505 <p class="peach-hint peach-hint-error">{error()}</p>506 </Show>507508 <button509 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"510 onClick={() => void handleCreate()}511 disabled={!props.ready || loading() || !canCreate()}512 aria-live="polite"513 >514 <Show515 when={!canCreate() && createBlockerMessage()}516 fallback={loading() ? "creating..." : "create vault"}517 >518 {createBlockerMessage()}519 </Show>520 </button>521522 {523524525}526 <div class="peach-newvault-restore">527 <p class="peach-hint">Already have a backup?</p>528 <button529 class="peach-lock-restore-link"530 onClick={() => void openRestore("restore")}531 disabled={!props.ready}532 >533 › restore from backup534 </button>535 <Show when={props.onOpenCodexRestore}>536 <button537 class="peach-lock-restore-link"538 onClick={() => props.onOpenCodexRestore?.()}539 disabled={!props.ready}540 >541 › restore from peach codex542 </button>543 </Show>544 </div>545546 <p class="peach-newvault-warning">547 <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>548 <span>Save your 12 recovery words after creating — they're the only way back in if you forget your password.</span>549 </p>550 </div>551 </section>552 </Match>553 </Switch>554 );555}556557558559type RestoreTab = "symbols" | "codex" | "csv";560561function tabLabel(tab: RestoreTab): string {562 return (563 { symbols: "PeachScript", codex: "Codex", csv: "CSV" }[tab]564 );565}566567function RestoreTabView(props: {568 hasVault: boolean;569 onOpenCodexRestore?: () => void;570 onBack: () => void;571 onUnlock: () => void | Promise<void>;572}) {573 const [activeTab, setActiveTab] = createSignal<RestoreTab>("symbols");574 const [showIntro, setShowIntro] = createSignal(false);575 const [pendingUnlock] = createSignal(false);576577 async function handleRestoreSuccess() {578 const seen = await storageGet("peach_shown_restore_intro").catch(() => null);579 if (!seen) {580 setShowIntro(true);581 } else {582 await props.onUnlock();583 }584 }585586 async function dismissIntro() {587 await storageSet("peach_shown_restore_intro", "true").catch(() => {});588 setShowIntro(false);589 await props.onUnlock();590 }591592 return (593 <section594 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-restore-tabview"595 data-motion-phase="enter"596 >597 <RuneBackground opacity={0.05} />598599 <header class="peach-screen-header lock-screen-header">600 <PeachWordmark class="peach-wordmark-wrap" />601 <button class="peach-lock-restore-link" onClick={props.onBack}>602 ‹ back603 </button>604 </header>605606 <div class="peach-newvault-card peach-restore-tabcard">607 <div class="peach-restore-tabs" role="tablist">608 <For each={["symbols", "codex", "csv"] as RestoreTab[]}>609 {(tab) => (610 <button611 class="peach-restore-tab"612 classList={{ "is-active": activeTab() === tab }}613 onClick={() => setActiveTab(tab)}614 role="tab"615 aria-selected={activeTab() === tab}616 >617 {tabLabel(tab)}618 </button>619 )}620 </For>621 </div>622 <div class="peach-restore-content">623 <Show when={activeTab() === "symbols"}>624 <RestoreSymbols hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />625 </Show>626 <Show when={activeTab() === "codex"}>627 <RestoreCodex onOpen={() => props.onOpenCodexRestore?.()} />628 </Show>629 <Show when={activeTab() === "csv"}>630 <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />631 </Show>632 </div>633 </div>634635 {}636 <Show when={showIntro()}>637 <div class="peach-restore-intro-overlay">638 <div class="peach-restore-intro-card">639 <p class="peach-restore-intro-headline">welcome to peach.</p>640 <div class="peach-restore-intro-body">641 <p>Your vault lives only on this device. No server has a copy.</p>642 <p>The inline bubble fills passwords automatically when you visit sites.</p>643 <p>Export your vault from Settings before switching devices.</p>644 </div>645 <button646 class="peach-btn peach-btn--accent peach-btn--md peach-restore-intro-btn"647 onClick={() => void dismissIntro()}648 disabled={pendingUnlock()}649 >650 got it651 </button>652 </div>653 </div>654 </Show>655 </section>656 );657}658659660661function RestoreCodex(props: { onOpen: () => void }) {662 return (663 <div class="peach-restore-codex">664 <p class="peach-restore-codex-title">Restore from Peach Codex</p>665 <p class="peach-restore-codex-copy">666 Scan printed Codex pages or import the Codex PDF. Codex restore is667 always available, even before you create a vault.668 </p>669 <button670 type="button"671 class="peach-btn peach-btn--accent peach-btn--md peach-restore-codex-button"672 onClick={props.onOpen}673 >674 open Codex restore →675 </button>676 </div>677 );678}679680681682function RestoreSymbols(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {683 const [scriptInput, setScriptInput] = createSignal("");684 const [backupPass, setBackupPass] = createSignal("");685 const [working, setWorking] = createSignal(false);686 const [animating, setAnimating] = createSignal<"chaos" | "snap" | "bloom" | null>(null);687 const [error, setError] = createSignal<string | null>(null);688 let canvasRef: HTMLCanvasElement | undefined;689 let animFrameId = 0;690 let runes: { x: number; y: number; vx: number; vy: number; char: string; size: number; angle: number; va: number }[] = [];691692 const scriptValid = createMemo(() => isPeachScript(scriptInput()));693694 function stopAnim() {695 cancelAnimationFrame(animFrameId);696 animFrameId = 0;697 }698699 function startChaos(canvas: HTMLCanvasElement) {700 const ctx = canvas.getContext("2d")!;701 const W = canvas.width;702 const H = canvas.height;703 const RUNE_CHARS = Array.from({ length: 40 }, (_, i) =>704 String.fromCodePoint(0xe000 + i)705 );706 if (runes.length === 0) {707 runes = Array.from({ length: 60 }, () => ({708 x: Math.random() * W,709 y: Math.random() * H,710 vx: (Math.random() - 0.5) * 3,711 vy: (Math.random() - 0.5) * 3,712 char: RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]!,713 size: 14 + Math.random() * 20,714 angle: Math.random() * Math.PI * 2,715 va: (Math.random() - 0.5) * 0.1,716 }));717 }718719 function draw() {720 ctx.clearRect(0, 0, W, H);721 for (const r of runes) {722 r.x = (r.x + r.vx + W) % W;723 r.y = (r.y + r.vy + H) % H;724 r.angle += r.va;725 ctx.save();726 ctx.translate(r.x, r.y);727 ctx.rotate(r.angle);728 ctx.font = `${r.size}px PeachScript, monospace`;729 const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 400 + r.x);730 ctx.fillStyle = `rgba(232, 149, 109, ${alpha})`;731 ctx.shadowColor = "rgba(232, 149, 109, 0.7)";732 ctx.shadowBlur = 8;733 ctx.fillText(r.char, 0, 0);734 ctx.restore();735 }736 animFrameId = requestAnimationFrame(draw);737 }738 draw();739 }740741 function snapToGrid(canvas: HTMLCanvasElement, onDone: () => void) {742 stopAnim();743 const ctx = canvas.getContext("2d")!;744 const W = canvas.width;745 const H = canvas.height;746 const cols = 8;747 const rows = 6;748 const cellW = W / cols;749 const cellH = H / rows;750 const targets = runes.slice(0, cols * rows).map((_, i) => ({751 x: (i % cols) * cellW + cellW / 2,752 y: Math.floor(i / cols) * cellH + cellH / 2,753 }));754 const start = performance.now();755 const DURATION = 400;756757 function drawSnap(now: number) {758 const t = Math.min((now - start) / DURATION, 1);759 const ease = t < 1 ? 1 - Math.pow(1 - t, 3) : 1;760 ctx.clearRect(0, 0, W, H);761 runes.slice(0, targets.length).forEach((r, i) => {762 const tx = targets[i]!.x;763 const ty = targets[i]!.y;764 const cx = r.x + (tx - r.x) * ease;765 const cy = r.y + (ty - r.y) * ease;766 ctx.font = `16px PeachScript, monospace`;767 ctx.fillStyle = `rgba(232, 149, 109, ${0.8 * ease})`;768 ctx.shadowColor = "rgba(232, 149, 109, 0.6)";769 ctx.shadowBlur = 6;770 ctx.fillText(r.char, cx - 8, cy + 8);771 });772 if (t < 1) {773 animFrameId = requestAnimationFrame(drawSnap);774 } else {775 onDone();776 }777 }778 animFrameId = requestAnimationFrame(drawSnap);779 }780781 function bloom(canvas: HTMLCanvasElement, onDone: () => void) {782 stopAnim();783 const ctx = canvas.getContext("2d")!;784 const W = canvas.width;785 const H = canvas.height;786 const start = performance.now();787 const DURATION = 900;788789 function drawBloom(now: number) {790 const t = Math.min((now - start) / DURATION, 1);791 ctx.clearRect(0, 0, W, H);792 793 const runeAlpha = 1 - Math.min(t / 0.4, 1);794 if (runeAlpha > 0) {795 for (const r of runes.slice(0, 48)) {796 ctx.font = `16px PeachScript, monospace`;797 ctx.fillStyle = `rgba(232, 149, 109, ${runeAlpha * 0.8})`;798 ctx.fillText(r.char, r.x - 8, r.y + 8);799 }800 }801 802 const markT = Math.max(0, (t - 0.2) / 0.5);803 const markScale = Math.min(markT * 1.2, 1);804 const markAlpha = markT < 0.5 ? markT * 2 : 2 - markT * 2;805 if (markAlpha > 0) {806 ctx.save();807 ctx.globalAlpha = markAlpha;808 ctx.shadowColor = `rgba(217, 104, 48, ${markAlpha * 0.8})`;809 ctx.shadowBlur = 30;810 ctx.translate(W / 2, H / 2);811 ctx.scale(markScale, markScale);812 ctx.drawImage(peachMarkImg, -32, -32, 64, 64);813 ctx.restore();814 }815 if (t < 1) {816 animFrameId = requestAnimationFrame(drawBloom);817 } else {818 onDone();819 }820 }821 animFrameId = requestAnimationFrame(drawBloom);822 }823824 onCleanup(() => stopAnim());825826 async function handleRestore() {827 if (!scriptValid() || !backupPass().trim() || working()) return;828 setError(null);829 setWorking(true);830 setAnimating("chaos");831832 const canvas = canvasRef;833 if (canvas) startChaos(canvas);834835 try {836 const bytes = decodePeachScript(normalisePeachScript(scriptInput()));837 const payload = await parsePayload(bytes, backupPass());838839 if (!props.hasVault) {840 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });841 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");842 }843844 const rr = await sendMessage({845 type: "RESTORE_ENTRIES",846 payload: { entries: payload.entries, mode: "merge", settings: payload.settings },847 });848 if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");849850 if (payload.license?.key) {851 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});852 }853854 setAnimating("snap");855 stopAnim();856 if (canvas) {857 snapToGrid(canvas, () => {858 setAnimating("bloom");859 bloom(canvas, async () => {860 setAnimating(null);861 await props.onSuccess();862 });863 });864 } else {865 await props.onSuccess();866 }867 } catch (err) {868 stopAnim();869 setAnimating(null);870 const code = err instanceof Error ? err.message : String(err);871 setError(872 code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"873 ? "That passphrase didn't work."874 : code === "CREATE_VAULT_FAILED"875 ? "Couldn't create a new local vault."876 : "Could not read this backup.",877 );878 } finally {879 setWorking(false);880 }881 }882883 return (884 <div class="peach-rt-symbols">885 <Show when={animating() !== null}>886 <canvas887 ref={(el) => { canvasRef = el; }}888 class="peach-rt-anim-canvas"889 width={280}890 height={200}891 />892 </Show>893 <Show when={animating() === null}>894 <textarea895 class="peach-restore-symbols-textarea"896 placeholder="paste peachscript…"897 rows={3}898 value={scriptInput()}899 onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}900 />901 <div class="peach-rt-divider">— or —</div>902 <label class="peach-btn peach-btn--ghost peach-btn--sm peach-rt-file-label">903 open .peach file904 <input905 type="file"906 accept=".peach,text/plain"907 style="display:none"908 onChange={async (e) => {909 const file = (e.currentTarget as HTMLInputElement).files?.[0];910 if (!file) return;911 try {912 setScriptInput(await file.text());913 } catch {914 setError("Couldn't read file.");915 } finally {916 (e.currentTarget as HTMLInputElement).value = "";917 }918 }}919 />920 </label>921 <input922 class="peach-restore-symbols-pass"923 type="password"924 placeholder="backup password"925 value={backupPass()}926 onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}927 onKeyDown={(e) => {928 if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {929 e.preventDefault();930 void handleRestore();931 }932 }}933 />934 <button935 class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"936 onClick={() => void handleRestore()}937 disabled={!scriptValid() || !backupPass().trim() || working()}938 >939 restore vault →940 </button>941 <Show when={error()}>942 <p class="peach-rt-error">{error()}</p>943 </Show>944 </Show>945 </div>946 );947}948949950951function RestoreCSV(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {952 const [importing, setImporting] = createSignal(false);953 const [error, setError] = createSignal<string | null>(null);954 955 956 957 958 const CSV_MIN_PASSWORD_LENGTH = 12;959 const [newPass, setNewPass] = createSignal("");960 const [confirmPass, setConfirmPass] = createSignal("");961 const needsPassword = () => props.hasVault === false;962 const passwordReady = () =>963 !needsPassword() ||964 (newPass().length >= CSV_MIN_PASSWORD_LENGTH && newPass() === confirmPass());965966 async function handleFile(e: Event) {967 const input = e.currentTarget as HTMLInputElement;968 const file = input.files?.[0];969 input.value = "";970 if (!file) return;971 if (needsPassword() && newPass().length < CSV_MIN_PASSWORD_LENGTH) {972 setError(`Set a master password of at least ${CSV_MIN_PASSWORD_LENGTH} characters first.`);973 return;974 }975 if (needsPassword() && newPass() !== confirmPass()) {976 setError("Passwords do not match.");977 return;978 }979 setImporting(true);980 setError(null);981 try {982 const text = await file.text();983 const parsed = await parseImportSource(text);984 if (parsed.entries.length === 0) throw new Error("NO_ENTRIES");985986 if (props.hasVault === false) {987 988 989 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: newPass() } });990 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");991 }992993 const res = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: parsed.entries } });994 if (!(res as { ok: boolean }).ok) throw new Error("IMPORT_FAILED");995 if (parsed.licenseKey) {996 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: parsed.licenseKey } }).catch(() => {});997 }998 await props.onSuccess();999 } catch (err) {1000 const code = err instanceof Error ? err.message : String(err);1001 setError(1002 code === "NO_ENTRIES" ? "No entries found in this file." :1003 code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." :1004 "Import failed. Check the file format and try again."1005 );1006 } finally {1007 setImporting(false);1008 }1009 }10101011 return (1012 <div class="peach-rt-csv">1013 <Show when={needsPassword()}>1014 <input1015 class="peach-restore-symbols-pass"1016 type="password"1017 placeholder="new master password"1018 value={newPass()}1019 onInput={(e) => setNewPass((e.target as HTMLInputElement).value)}1020 />1021 <input1022 class="peach-restore-symbols-pass"1023 type="password"1024 placeholder="confirm master password"1025 value={confirmPass()}1026 onInput={(e) => setConfirmPass((e.target as HTMLInputElement).value)}1027 />1028 </Show>1029 <label1030 class="peach-btn peach-btn--accent peach-btn--md peach-rt-csv-btn"1031 classList={{ "is-busy": importing(), "is-disabled": !passwordReady() }}1032 >1033 {importing() ? "importing…" : "open csv file"}1034 <input1035 type="file"1036 accept=".csv,.json,.peach,application/x-peach"1037 style="display:none"1038 onChange={handleFile}1039 disabled={importing() || !passwordReady()}1040 />1041 </label>1042 <p class="peach-rt-csv-sources">1043 Imports from Bitwarden, 1Password, Proton Pass,<br />1044 Chrome, Safari, and Edge.1045 </p>1046 <p class="peach-rt-csv-warning">PLAIN-TEXT — DELETE THE FILE AFTER IMPORT</p>1047 <Show when={error()}>1048 <p class="peach-rt-error">{error()}</p>1049 </Show>1050 </div>1051 );1052}1053105410551056function getDisplayError(error: string | undefined, fallback: string): string {1057 if (!error) return fallback;1058 if (1059 error === "SERVICE_WORKER_UNAVAILABLE" ||1060 error.includes("Receiving end does not exist") ||1061 error.includes("message port closed")1062 ) {1063 return "background service unavailable";1064 }1065 return error;1066}