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);87 const [pinAvailable, setPinAvailable] = createSignal(false);88 const [usingPin, setUsingPin] = createSignal(false);89 const [pin, setPin] = createSignal("");9091 const canCreate = createMemo(92 () =>93 password().length >= MIN_LENGTH &&94 confirmPassword().length >= MIN_LENGTH &&95 password() === confirmPassword() &&96 strengthScore() !== null &&97 strengthScore()! >= MIN_ZXCVBN_SCORE98 );99100 const passwordStrength = createMemo(() => {101 const score = strengthScore();102 if (score === null) return null;103 if (score <= 1) return "weak";104 if (score === 2) return "fair";105 if (score === 3) return "good";106 return "strong";107 });108109 const createBlockerMessage = createMemo(() => {110 if (!password()) return null;111 if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;112 if (!confirmPassword()) return "confirm your password";113 if (password() !== confirmPassword()) return "passwords don't match";114 if (strengthScore() === null) return "checking strength…";115 if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak — use a longer passphrase";116 return null;117 });118119 120 createMemo(() => {121 const value = password();122 if (!value) { setStrengthScore(null); return; }123 getZxcvbn().then((zxcvbn) => {124 if (password() === value) setStrengthScore(zxcvbn(value).score);125 }).catch(() => {126 127 if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1);128 });129 });130131 async function handleUnlock() {132 if (!password().trim()) return;133 setLoading(true);134 setError("");135 const response = await sendMessage({136 type: "UNLOCK",137 payload: { password: password() },138 });139 const result = response as { ok: boolean; error?: string };140 if (result.ok) {141 await props.onUnlock();142 setLoading(false);143 return;144 }145 setError(getDisplayError(result.error, "incorrect password"));146 setPassword("");147 const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined;148 passwordField?.classList.add("shake");149 setTimeout(() => passwordField?.classList.remove("shake"), 300);150 setLoading(false);151 }152153 async function handlePinUnlock() {154 if (!/^\d{4,12}$/.test(pin())) return;155 setLoading(true);156 setError("");157 const response = await sendMessage({158 type: "UNLOCK_WITH_PIN",159 payload: { pin: pin() },160 });161 const result = response as { ok: boolean; error?: string };162 if (result.ok) {163 await props.onUnlock();164 setLoading(false);165 return;166 }167 setError(getDisplayError(result.error, "incorrect PIN"));168 setPin("");169 const pinField = document.querySelector(".lock-pin-input") as HTMLInputElement | undefined;170 pinField?.classList.add("shake");171 setTimeout(() => pinField?.classList.remove("shake"), 300);172 setLoading(false);173 }174175 async function checkPinAvailable(): Promise<void> {176 if (!props.hasVault) return;177 try {178 const response = await sendMessage({ type: "GET_SETTINGS" });179 const settings = (response as { ok: boolean; data?: { pinEnabled?: boolean; pinPreferred?: boolean } }).data;180 const enabled = settings?.pinEnabled === true;181 setPinAvailable(enabled);182 if (enabled && settings?.pinPreferred === true) setUsingPin(true);183 } catch { }184 }185186 async function checkBiometricAvailable(): Promise<void> {187 if (!props.hasVault || !isNativePlatform()) return;188 const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");189 const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");190 if (!keystore || !biometric) return;191 try {192 const stored = await keystore.hasMasterKey();193 if (!stored.exists) return;194 const avail = await biometric.isAvailable();195 if (!avail.available) return;196 setBiometricAvailable(true);197 window.setTimeout(() => { void handleBiometricUnlock(); }, 400);198 } catch { }199 }200201 async function handleBiometricUnlock(): Promise<void> {202 if (biometricBusy()) return;203 const biometric = nativePlugin<{ authenticate: (opts: Record<string, string>) => Promise<void> }>("BiometricPlugin");204 const keystore = nativePlugin<{ retrieveMasterKey: () => Promise<{ masterKeyHex: string }> }>("KeystorePlugin");205 if (!biometric || !keystore) return;206 setBiometricBusy(true);207 setError("");208 try {209 await biometric.authenticate({210 title: "Unlock Peach",211 subtitle: "Use biometrics to access your vault",212 negativeButtonText: "Use Password",213 });214 const { masterKeyHex } = await keystore.retrieveMasterKey();215 const res = (await sendMessage({216 type: "UNLOCK_WITH_KEY",217 payload: { masterKeyHex },218 })) as { ok: boolean; error?: string };219 if (res.ok) {220 await props.onUnlock();221 return;222 }223 if (res.error === "biometric_key_invalidated") {224 await sendMessage({225 type: "UPDATE_SETTINGS",226 payload: { biometricsEnabled: false },227 });228 setBiometricAvailable(false);229 setError("biometric key expired — re-enable in settings");230 } else {231 setError("biometric unlock failed");232 }233 } catch (e) {234 const reason = e instanceof Error ? e.message : String(e);235 if (reason.includes("user_cancelled")) return;236 if (reason.includes("biometric_key_invalidated")) {237 await sendMessage({238 type: "UPDATE_SETTINGS",239 payload: { biometricsEnabled: false },240 });241 setBiometricAvailable(false);242 setError("biometric key expired — re-enable in settings");243 return;244 }245 if (reason.includes("biometric_lockout")) {246 setError("biometric locked out — use your password");247 }248 } finally {249 setBiometricBusy(false);250 }251 }252253 onMount(() => {254 void checkBiometricAvailable();255 void checkPinAvailable();256 });257258 async function handleCreate() {259 if (!password().trim()) return;260 if (password().length < MIN_LENGTH) {261 setError(`password must be at least ${MIN_LENGTH} characters`);262 return;263 }264 if (password() !== confirmPassword()) {265 setError("passwords do not match");266 return;267 }268 const score = strengthScore();269 if (score === null || score < MIN_ZXCVBN_SCORE) {270 setError("password is too weak — use a longer passphrase");271 return;272 }273 setLoading(true);274 setError("");275 const response = await sendMessage({276 type: "CREATE_VAULT",277 payload: { password: password() },278 });279 const result = response as { ok: boolean; error?: string };280 if (result.ok) {281 282 283 284 285 286 try {287 await chrome.storage.local?.set({ peach_recovery_pending: "1" });288 } catch { }289 setShowRecoveryWords(true);290 setLoading(false);291 return;292 }293 setError(getDisplayError(result.error, "failed to create vault"));294 setLoading(false);295 }296297 298 return (299 <Switch>300 {}301 <Match when={restoreSurface() === "restore"}>302 <RestoreTabView303 hasVault={props.hasVault}304 onOpenCodexRestore={props.onOpenCodexRestore}305 onBack={() => {306 307 308 if (props.onBackToOnboarding) props.onBackToOnboarding();309 else setRestoreSurface(null);310 }}311 onUnlock={async () => {312 setRestoreSurface(null);313 await props.onUnlock();314 }}315 />316 </Match>317318 {}319 {320321}322 <Match323 when={324 (showRecoveryWords() || props.forceRecoveryWords) &&325 (!props.hasVault || props.forceRecoveryWords)326 }327 >328 <RecoveryWordDisplayView329 onDone={props.onUnlock}330 />331 </Match>332333 {}334 <Match when={recoverSurface() === "words"}>335 <RecoveryWordEntryView336 onCancel={() => setRecoverSurface(null)}337 onRecoveryComplete={props.onUnlock}338 />339 </Match>340341 {}342 <Match when={props.hasVault}>343 <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">344 <RuneBackground opacity={0.07} />345346 <header class="peach-screen-header lock-screen-header">347 <div class="peach-wordmark-stack">348 <PeachWordmark class="peach-wordmark-wrap" />349 <Show when={props.isPro}>350 <span class="peach-pro-mini-badge">pro</span>351 </Show>352 </div>353 </header>354355 <div class="peach-lock-hero peach-lock-hero--unlock">356 <PeachMark class="peach-lock-mark" />357 <span class="peach-lock-eyebrow">vault locked</span>358 <h1 class="peach-onboarding-headline peach-lock-headline--unlock">359 unlock360 <br />361 your vault.362 </h1>363 </div>364365 <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">366 <Show when={usingPin()} fallback={367 <>368 <label class="peach-label" for="unlock-password">369 master password370 </label>371 <input372 id="unlock-password"373 class="peach-underline-input peach-unlock-input lock-password-input"374 type="password"375 placeholder="••••••••••••"376 autocomplete="current-password"377 value={password()}378 disabled={!props.ready}379 ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}380 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}381 onKeyDown={(event) => {382 if (event.key !== "Enter" || !props.ready) return;383 void handleUnlock();384 }}385 />386 </>387 }>388 <label class="peach-label" for="unlock-pin">389 PIN390 </label>391 <input392 id="unlock-pin"393 class="peach-underline-input peach-unlock-input lock-pin-input"394 type="password"395 inputmode="numeric"396 maxlength="12"397 placeholder="••••••••••••"398 autocomplete="off"399 value={pin()}400 disabled={!props.ready}401 ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}402 onInput={(event) => setPin((event.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}403 onKeyDown={(event) => {404 if (event.key !== "Enter" || !props.ready) return;405 void handlePinUnlock();406 }}407 />408 <button409 class="peach-lock-recover-link"410 type="button"411 onClick={() => { setUsingPin(false); setError(""); }}412 >413 use master password414 </button>415 </Show>416417 <Show when={error()}>418 <p class="peach-hint peach-hint-error">{error()}</p>419 </Show>420421 <button422 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"423 onClick={() => void (usingPin() ? handlePinUnlock() : handleUnlock())}424 disabled={!props.ready || loading()}425 >426 {loading() ? "unlocking..." : usingPin() ? "unlock with PIN" : "unlock"}427 </button>428429 <Show when={!usingPin() && pinAvailable()}>430 <button431 class="peach-lock-recover-link"432 type="button"433 onClick={() => { setUsingPin(true); setError(""); }}434 >435 use PIN436 </button>437 </Show>438439 <Show when={biometricAvailable()}>440 <button441 class="peach-biometric-btn"442 type="button"443 onClick={() => void handleBiometricUnlock()}444 disabled={biometricBusy() || loading()}445 aria-label="Unlock with biometrics"446 >447 <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">448 <path d="M12 11c-1.7 0-3 1.3-3 3v3"/>449 <path d="M15 17v-3"/>450 <path d="M9 4.5C9.7 4.2 10.8 4 12 4s2.3.2 3 .5"/>451 <path d="M5 9c.7-1.6 2-2.9 3.6-3.6"/>452 <path d="M19 9c-.7-1.6-2-2.9-3.6-3.6"/>453 <path d="M5 15c0-1.7.3-3.3.8-4.7"/>454 <path d="M19 15c0-1.7-.3-3.3-.8-4.7"/>455 </svg>456 {biometricBusy() ? "verifying…" : "use biometrics"}457 </button>458 </Show>459460 <button461 class="peach-lock-recover-link"462 type="button"463 onClick={() => setRecoverSurface("words")}464 >465 forgot your password?466 </button>467 </section>468 </section>469 </Match>470471 {}472 <Match when={true}>473 <section474 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"475 data-motion-phase="enter"476 >477 <header class="peach-screen-header peach-newvault-header">478 <div class="peach-wordmark-stack">479 <PeachWordmark class="peach-wordmark-wrap" />480 <Show when={props.isPro}>481 <span class="peach-pro-mini-badge">pro</span>482 </Show>483 </div>484 <Show when={props.onBackToOnboarding}>485 <button486 class="peach-newvault-back"487 type="button"488 onClick={() => props.onBackToOnboarding?.()}489 aria-label="Back to onboarding"490 >491 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">492 <path d="M7 2L3 5.5L7 9" />493 </svg>494 back495 </button>496 </Show>497 </header>498499 <div class="peach-newvault-mark" aria-hidden="true">500 <PeachMark class="peach-newvault-mark-art" />501 </div>502503 <div class="peach-newvault-card">504 <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>505 <hr class="peach-newvault-divider" />506507 <div class="peach-newvault-field">508 <div class="peach-newvault-label-row">509 <label class="peach-label" for="master-password">510 master password511 </label>512 <button513 class="peach-lock-reveal-btn"514 type="button"515 onClick={() => setShowPassword((value) => !value)}516 aria-label={showPassword() ? "Hide password" : "Show password"}517 >518 <Show519 when={showPassword()}520 fallback={521 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">522 <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" />523 <circle cx="7" cy="5" r="1.75" />524 </svg>525 }526 >527 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">528 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />529 <path d="M3.5 5.5L2.5 7.5" />530 <path d="M7 6.5V8.5" />531 <path d="M10.5 5.5L11.5 7.5" />532 </svg>533 </Show>534 {showPassword() ? "hide" : "show"}535 </button>536 </div>537 <input538 id="master-password"539 class="peach-underline-input peach-newvault-input lock-password-input"540 type={showPassword() ? "text" : "password"}541 placeholder="••••••••••••"542 autocomplete="new-password"543 value={password()}544 disabled={!props.ready}545 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}546 onKeyDown={(event) => {547 if (event.key !== "Enter" || !props.ready) return;548 void handleCreate();549 }}550 />551 <Show552 when={password().length > 0 && passwordStrength() !== null}553 fallback={554 <p class="peach-newvault-hint">555 At least 12 characters.556 </p>557 }558 >559 <div class="peach-password-strength">560 <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>561 <div class="peach-strength-fill"></div>562 </div>563 <span class="peach-strength-label">{passwordStrength()}</span>564 </div>565 </Show>566 </div>567568 <div class="peach-newvault-field">569 <label class="peach-label" for="confirm-password">570 confirm password571 </label>572 <input573 id="confirm-password"574 class="peach-underline-input peach-newvault-input"575 type={showPassword() ? "text" : "password"}576 placeholder="••••••••••••"577 autocomplete="new-password"578 value={confirmPassword()}579 disabled={!props.ready}580 onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}581 onKeyDown={(event) => {582 if (event.key === "Enter" && props.ready) void handleCreate();583 }}584 />585 </div>586587 <Show when={error()}>588 <p class="peach-hint peach-hint-error">{error()}</p>589 </Show>590591 <button592 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"593 onClick={() => void handleCreate()}594 disabled={!props.ready || loading() || !canCreate()}595 aria-live="polite"596 >597 <Show598 when={!canCreate() && createBlockerMessage()}599 fallback={loading() ? "creating..." : "create vault"}600 >601 {createBlockerMessage()}602 </Show>603 </button>604605 <p class="peach-newvault-warning">606 <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>607 <span>Save your 12 recovery words after creating — they're the only way back in if you forget your password.</span>608 </p>609 </div>610 </section>611 </Match>612 </Switch>613 );614}615616617618type RestoreTab = "symbols" | "codex" | "csv";619620function tabLabel(tab: RestoreTab): string {621 return (622 { symbols: "PeachScript", codex: "Codex", csv: "CSV" }[tab]623 );624}625626function RestoreTabView(props: {627 hasVault: boolean;628 onOpenCodexRestore?: () => void;629 onBack: () => void;630 onUnlock: () => void | Promise<void>;631}) {632 const [activeTab, setActiveTab] = createSignal<RestoreTab>("symbols");633 const [showIntro, setShowIntro] = createSignal(false);634 const [pendingUnlock] = createSignal(false);635636 async function handleRestoreSuccess() {637 const seen = await storageGet("peach_shown_restore_intro").catch(() => null);638 if (!seen) {639 setShowIntro(true);640 } else {641 await props.onUnlock();642 }643 }644645 async function dismissIntro() {646 await storageSet("peach_shown_restore_intro", "true").catch(() => {});647 setShowIntro(false);648 await props.onUnlock();649 }650651 return (652 <section653 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-restore-tabview"654 data-motion-phase="enter"655 >656 <RuneBackground opacity={0.05} />657658 <header class="peach-screen-header lock-screen-header">659 <PeachWordmark class="peach-wordmark-wrap" />660 <button class="peach-lock-restore-link" onClick={props.onBack}>661 ‹ back662 </button>663 </header>664665 <div class="peach-newvault-card peach-restore-tabcard">666 <div class="peach-restore-tabs" role="tablist">667 <For each={["symbols", "codex", "csv"] as RestoreTab[]}>668 {(tab) => (669 <button670 class="peach-restore-tab"671 classList={{ "is-active": activeTab() === tab }}672 onClick={() => setActiveTab(tab)}673 role="tab"674 aria-selected={activeTab() === tab}675 >676 {tabLabel(tab)}677 </button>678 )}679 </For>680 </div>681 <div class="peach-restore-content">682 <Show when={activeTab() === "symbols"}>683 <RestoreSymbols hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />684 </Show>685 <Show when={activeTab() === "codex"}>686 <RestoreCodex onOpen={() => props.onOpenCodexRestore?.()} />687 </Show>688 <Show when={activeTab() === "csv"}>689 <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />690 </Show>691 </div>692 </div>693694 {}695 <Show when={showIntro()}>696 <div class="peach-restore-intro-overlay">697 <div class="peach-restore-intro-card">698 <p class="peach-restore-intro-headline">welcome to peach.</p>699 <div class="peach-restore-intro-body">700 <p>Your vault lives only on this device. No server has a copy.</p>701 <p>The inline bubble fills passwords automatically when you visit sites.</p>702 <p>Export your vault from Settings before switching devices.</p>703 </div>704 <button705 class="peach-btn peach-btn--accent peach-btn--md peach-restore-intro-btn"706 onClick={() => void dismissIntro()}707 disabled={pendingUnlock()}708 >709 got it710 </button>711 </div>712 </div>713 </Show>714 </section>715 );716}717718719720function RestoreCodex(props: { onOpen: () => void }) {721 return (722 <div class="peach-restore-codex">723 <p class="peach-restore-codex-title">Restore from Peach Codex</p>724 <p class="peach-restore-codex-copy">725 Scan printed Codex pages or import the Codex PDF. Codex restore is726 always available, even before you create a vault.727 </p>728 <button729 type="button"730 class="peach-btn peach-btn--accent peach-btn--md peach-restore-codex-button"731 onClick={props.onOpen}732 >733 open Codex restore →734 </button>735 </div>736 );737}738739740741function RestoreSymbols(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {742 const [scriptInput, setScriptInput] = createSignal("");743 const [backupPass, setBackupPass] = createSignal("");744 const [working, setWorking] = createSignal(false);745 const [animating, setAnimating] = createSignal<"chaos" | "snap" | "bloom" | null>(null);746 const [error, setError] = createSignal<string | null>(null);747 let canvasRef: HTMLCanvasElement | undefined;748 let animFrameId = 0;749 let runes: { x: number; y: number; vx: number; vy: number; char: string; size: number; angle: number; va: number }[] = [];750751 const scriptValid = createMemo(() => isPeachScript(scriptInput()));752753 function stopAnim() {754 cancelAnimationFrame(animFrameId);755 animFrameId = 0;756 }757758 function startChaos(canvas: HTMLCanvasElement) {759 const ctx = canvas.getContext("2d")!;760 const W = canvas.width;761 const H = canvas.height;762 const RUNE_CHARS = Array.from({ length: 40 }, (_, i) =>763 String.fromCodePoint(0xe000 + i)764 );765 if (runes.length === 0) {766 runes = Array.from({ length: 60 }, () => ({767 x: Math.random() * W,768 y: Math.random() * H,769 vx: (Math.random() - 0.5) * 3,770 vy: (Math.random() - 0.5) * 3,771 char: RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]!,772 size: 14 + Math.random() * 20,773 angle: Math.random() * Math.PI * 2,774 va: (Math.random() - 0.5) * 0.1,775 }));776 }777778 function draw() {779 ctx.clearRect(0, 0, W, H);780 for (const r of runes) {781 r.x = (r.x + r.vx + W) % W;782 r.y = (r.y + r.vy + H) % H;783 r.angle += r.va;784 ctx.save();785 ctx.translate(r.x, r.y);786 ctx.rotate(r.angle);787 ctx.font = `${r.size}px PeachScript, monospace`;788 const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 400 + r.x);789 ctx.fillStyle = `rgba(232, 149, 109, ${alpha})`;790 ctx.shadowColor = "rgba(232, 149, 109, 0.7)";791 ctx.shadowBlur = 8;792 ctx.fillText(r.char, 0, 0);793 ctx.restore();794 }795 animFrameId = requestAnimationFrame(draw);796 }797 draw();798 }799800 function snapToGrid(canvas: HTMLCanvasElement, onDone: () => void) {801 stopAnim();802 const ctx = canvas.getContext("2d")!;803 const W = canvas.width;804 const H = canvas.height;805 const cols = 8;806 const rows = 6;807 const cellW = W / cols;808 const cellH = H / rows;809 const targets = runes.slice(0, cols * rows).map((_, i) => ({810 x: (i % cols) * cellW + cellW / 2,811 y: Math.floor(i / cols) * cellH + cellH / 2,812 }));813 const start = performance.now();814 const DURATION = 400;815816 function drawSnap(now: number) {817 const t = Math.min((now - start) / DURATION, 1);818 const ease = t < 1 ? 1 - (1 - t) ** 3 : 1;819 ctx.clearRect(0, 0, W, H);820 runes.slice(0, targets.length).forEach((r, i) => {821 const tx = targets[i]!.x;822 const ty = targets[i]!.y;823 const cx = r.x + (tx - r.x) * ease;824 const cy = r.y + (ty - r.y) * ease;825 ctx.font = `16px PeachScript, monospace`;826 ctx.fillStyle = `rgba(232, 149, 109, ${0.8 * ease})`;827 ctx.shadowColor = "rgba(232, 149, 109, 0.6)";828 ctx.shadowBlur = 6;829 ctx.fillText(r.char, cx - 8, cy + 8);830 });831 if (t < 1) {832 animFrameId = requestAnimationFrame(drawSnap);833 } else {834 onDone();835 }836 }837 animFrameId = requestAnimationFrame(drawSnap);838 }839840 function bloom(canvas: HTMLCanvasElement, onDone: () => void) {841 stopAnim();842 const ctx = canvas.getContext("2d")!;843 const W = canvas.width;844 const H = canvas.height;845 const start = performance.now();846 const DURATION = 900;847848 function drawBloom(now: number) {849 const t = Math.min((now - start) / DURATION, 1);850 ctx.clearRect(0, 0, W, H);851 852 const runeAlpha = 1 - Math.min(t / 0.4, 1);853 if (runeAlpha > 0) {854 for (const r of runes.slice(0, 48)) {855 ctx.font = `16px PeachScript, monospace`;856 ctx.fillStyle = `rgba(232, 149, 109, ${runeAlpha * 0.8})`;857 ctx.fillText(r.char, r.x - 8, r.y + 8);858 }859 }860 861 const markT = Math.max(0, (t - 0.2) / 0.5);862 const markScale = Math.min(markT * 1.2, 1);863 const markAlpha = markT < 0.5 ? markT * 2 : 2 - markT * 2;864 if (markAlpha > 0) {865 ctx.save();866 ctx.globalAlpha = markAlpha;867 ctx.shadowColor = `rgba(217, 104, 48, ${markAlpha * 0.8})`;868 ctx.shadowBlur = 30;869 ctx.translate(W / 2, H / 2);870 ctx.scale(markScale, markScale);871 ctx.drawImage(peachMarkImg, -32, -32, 64, 64);872 ctx.restore();873 }874 if (t < 1) {875 animFrameId = requestAnimationFrame(drawBloom);876 } else {877 onDone();878 }879 }880 animFrameId = requestAnimationFrame(drawBloom);881 }882883 onCleanup(() => stopAnim());884885 async function handleRestore() {886 if (!scriptValid() || !backupPass().trim() || working()) return;887 setError(null);888 setWorking(true);889 setAnimating("chaos");890891 const canvas = canvasRef;892 if (canvas) startChaos(canvas);893894 try {895 const bytes = decodePeachScript(normalisePeachScript(scriptInput()));896 const payload = await parsePayload(bytes, backupPass());897898 if (!props.hasVault) {899 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });900 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");901 }902903 const rr = await sendMessage({904 type: "RESTORE_ENTRIES",905 payload: { entries: payload.entries, mode: "merge", settings: payload.settings },906 });907 if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");908909 if (payload.license?.key) {910 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});911 }912913 setAnimating("snap");914 stopAnim();915 if (canvas) {916 snapToGrid(canvas, () => {917 setAnimating("bloom");918 bloom(canvas, async () => {919 setAnimating(null);920 await props.onSuccess();921 });922 });923 } else {924 await props.onSuccess();925 }926 } catch (err) {927 stopAnim();928 setAnimating(null);929 const code = err instanceof Error ? err.message : String(err);930 setError(931 code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"932 ? "That passphrase didn't work."933 : code === "CREATE_VAULT_FAILED"934 ? "Couldn't create a new local vault."935 : "Could not read this backup.",936 );937 } finally {938 setWorking(false);939 }940 }941942 return (943 <div class="peach-rt-symbols">944 <Show when={animating() !== null}>945 <canvas946 ref={(el) => { canvasRef = el; }}947 class="peach-rt-anim-canvas"948 width={280}949 height={200}950 />951 </Show>952 <Show when={animating() === null}>953 <textarea954 class="peach-restore-symbols-textarea"955 placeholder="paste peachscript…"956 rows={3}957 value={scriptInput()}958 onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}959 />960 <div class="peach-rt-divider">— or —</div>961 <label class="peach-btn peach-btn--ghost peach-btn--sm peach-rt-file-label">962 open .peach file963 <input964 type="file"965 accept=".peach,text/plain"966 style="display:none"967 onChange={async (e) => {968 const file = (e.currentTarget as HTMLInputElement).files?.[0];969 if (!file) return;970 try {971 setScriptInput(await file.text());972 } catch {973 setError("Couldn't read file.");974 } finally {975 (e.currentTarget as HTMLInputElement).value = "";976 }977 }}978 />979 </label>980 <input981 class="peach-restore-symbols-pass"982 type="password"983 placeholder="backup password"984 value={backupPass()}985 onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}986 onKeyDown={(e) => {987 if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {988 e.preventDefault();989 void handleRestore();990 }991 }}992 />993 <button994 class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"995 onClick={() => void handleRestore()}996 disabled={!scriptValid() || !backupPass().trim() || working()}997 >998 restore vault →999 </button>1000 <Show when={error()}>1001 <p class="peach-rt-error">{error()}</p>1002 </Show>1003 </Show>1004 </div>1005 );1006}1007100810091010function RestoreCSV(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {1011 const [importing, setImporting] = createSignal(false);1012 const [error, setError] = createSignal<string | null>(null);1013 1014 1015 1016 1017 const CSV_MIN_PASSWORD_LENGTH = 12;1018 const [newPass, setNewPass] = createSignal("");1019 const [confirmPass, setConfirmPass] = createSignal("");1020 const needsPassword = () => props.hasVault === false;1021 const passwordReady = () =>1022 !needsPassword() ||1023 (newPass().length >= CSV_MIN_PASSWORD_LENGTH && newPass() === confirmPass());10241025 async function handleFile(e: Event) {1026 const input = e.currentTarget as HTMLInputElement;1027 const file = input.files?.[0];1028 input.value = "";1029 if (!file) return;1030 if (needsPassword() && newPass().length < CSV_MIN_PASSWORD_LENGTH) {1031 setError(`Set a master password of at least ${CSV_MIN_PASSWORD_LENGTH} characters first.`);1032 return;1033 }1034 if (needsPassword() && newPass() !== confirmPass()) {1035 setError("Passwords do not match.");1036 return;1037 }1038 setImporting(true);1039 setError(null);1040 try {1041 const text = await file.text();1042 const parsed = await parseImportSource(text);1043 if (parsed.entries.length === 0) throw new Error("NO_ENTRIES");10441045 if (props.hasVault === false) {1046 1047 1048 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: newPass() } });1049 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");1050 }10511052 const res = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: parsed.entries } });1053 if (!(res as { ok: boolean }).ok) throw new Error("IMPORT_FAILED");1054 if (parsed.licenseKey) {1055 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: parsed.licenseKey } }).catch(() => {});1056 }1057 await props.onSuccess();1058 } catch (err) {1059 const code = err instanceof Error ? err.message : String(err);1060 setError(1061 code === "NO_ENTRIES" ? "No entries found in this file." :1062 code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." :1063 "Import failed. Check the file format and try again."1064 );1065 } finally {1066 setImporting(false);1067 }1068 }10691070 return (1071 <div class="peach-rt-csv">1072 <Show when={needsPassword()}>1073 <input1074 class="peach-restore-symbols-pass"1075 type="password"1076 placeholder="new master password"1077 value={newPass()}1078 onInput={(e) => setNewPass((e.target as HTMLInputElement).value)}1079 />1080 <input1081 class="peach-restore-symbols-pass"1082 type="password"1083 placeholder="confirm master password"1084 value={confirmPass()}1085 onInput={(e) => setConfirmPass((e.target as HTMLInputElement).value)}1086 />1087 </Show>1088 <label1089 class="peach-btn peach-btn--accent peach-btn--md peach-rt-csv-btn"1090 classList={{ "is-busy": importing(), "is-disabled": !passwordReady() }}1091 >1092 {importing() ? "importing…" : "open csv file"}1093 <input1094 type="file"1095 accept=".csv,.json,.peach,application/x-peach"1096 style="display:none"1097 onChange={handleFile}1098 disabled={importing() || !passwordReady()}1099 />1100 </label>1101 <p class="peach-rt-csv-sources">1102 Imports from Bitwarden, 1Password, Proton Pass,<br />1103 Chrome, Safari, and Edge.1104 </p>1105 <p class="peach-rt-csv-warning">PLAIN-TEXT — DELETE THE FILE AFTER IMPORT</p>1106 <Show when={error()}>1107 <p class="peach-rt-error">{error()}</p>1108 </Show>1109 </div>1110 );1111}1112111311141115function getDisplayError(error: string | undefined, fallback: string): string {1116 if (!error) return fallback;1117 if (1118 error === "SERVICE_WORKER_UNAVAILABLE" ||1119 error.includes("Receiving end does not exist") ||1120 error.includes("message port closed")1121 ) {1122 return "background service unavailable";1123 }1124 return error;1125}