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 { RuneCanvas } from "../components/RuneCanvas";8import { storageGet, storageRemove, storageSet } from "../../shared/storage";9import { isPeachScript, decodePeachScript, normalisePeachScript } from "../../crypto/peachScript";10import { parsePayload } from "../../crypto/peachPayload";11import { parseImportSource } from "../../shared/import";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 636465 initialRestoreSource?: RestoreSource;66}6768697071export const RESTORE_SOURCE_STORAGE_KEY = "peach_onboarding_restore_source";7273type RestoreSurface = "restore" | null;74type RecoverSurface = "words" | "new-password" | null;7576export function LockView(props: LockProps) {77 const [password, setPassword] = createSignal("");78 const [confirmPassword, setConfirmPassword] = createSignal("");79 const [error, setError] = createSignal("");80 const [loading, setLoading] = createSignal(false);81 const [restoreSurface, setRestoreSurface] = createSignal<RestoreSurface>(82 props.initialRestore ? "restore" : null,83 );84 const [showPassword, setShowPassword] = createSignal(false);85 const [recoverSurface, setRecoverSurface] = createSignal<RecoverSurface>(null);86 87 88 const [showRecoveryWords, setShowRecoveryWords] = createSignal(props.forceRecoveryWords ?? false);8990 const MIN_LENGTH = 12;91 const MIN_ZXCVBN_SCORE = 2;9293 const [strengthScore, setStrengthScore] = createSignal<number | null>(null);94 const [pinAvailable, setPinAvailable] = createSignal(false);95 const [usingPin, setUsingPin] = createSignal(false);96 const [pin, setPin] = createSignal("");9798 const canCreate = createMemo(99 () =>100 password().length >= MIN_LENGTH &&101 confirmPassword().length >= MIN_LENGTH &&102 password() === confirmPassword() &&103 strengthScore() !== null &&104 strengthScore()! >= MIN_ZXCVBN_SCORE105 );106107 const passwordStrength = createMemo(() => {108 const score = strengthScore();109 if (score === null) return null;110 if (score <= 1) return "weak";111 if (score === 2) return "fair";112 if (score === 3) return "good";113 return "strong";114 });115116 const createBlockerMessage = createMemo(() => {117 if (!password()) return null;118 if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;119 if (!confirmPassword()) return "confirm your password";120 if (password() !== confirmPassword()) return "passwords don't match";121 if (strengthScore() === null) return "checking strength…";122 if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak — use a longer passphrase";123 return null;124 });125126 127 createMemo(() => {128 const value = password();129 if (!value) { setStrengthScore(null); return; }130 getZxcvbn().then((zxcvbn) => {131 if (password() === value) setStrengthScore(zxcvbn(value).score);132 }).catch(() => {133 134 if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1);135 });136 });137138 async function handleUnlock() {139 if (!password().trim()) return;140 setLoading(true);141 setError("");142 const response = await sendMessage({143 type: "UNLOCK",144 payload: { password: password() },145 });146 const result = response as { ok: boolean; error?: string };147 if (result.ok) {148 await props.onUnlock();149 setLoading(false);150 return;151 }152 setError(getDisplayError(result.error, "incorrect password"));153 setPassword("");154 const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined;155 passwordField?.classList.add("shake");156 setTimeout(() => passwordField?.classList.remove("shake"), 300);157 setLoading(false);158 }159160 async function handlePinUnlock() {161 if (!/^\d{4,12}$/.test(pin())) return;162 setLoading(true);163 setError("");164 const response = await sendMessage({165 type: "UNLOCK_WITH_PIN",166 payload: { pin: pin() },167 });168 const result = response as { ok: boolean; error?: string };169 if (result.ok) {170 await props.onUnlock();171 setLoading(false);172 return;173 }174 setError(getDisplayError(result.error, "incorrect PIN"));175 setPin("");176 const pinField = document.querySelector(".lock-pin-input") as HTMLInputElement | undefined;177 pinField?.classList.add("shake");178 setTimeout(() => pinField?.classList.remove("shake"), 300);179 setLoading(false);180 }181182 async function checkPinAvailable(): Promise<void> {183 if (!props.hasVault) return;184 try {185 const response = await sendMessage({ type: "GET_SETTINGS" });186 const settings = (response as { ok: boolean; data?: { pinEnabled?: boolean; pinPreferred?: boolean } }).data;187 const enabled = settings?.pinEnabled === true;188 setPinAvailable(enabled);189 if (enabled && settings?.pinPreferred === true) setUsingPin(true);190 } catch { }191 }192193 onMount(() => {194 void checkPinAvailable();195 });196197 async function handleCreate() {198 if (!password().trim()) return;199 if (password().length < MIN_LENGTH) {200 setError(`password must be at least ${MIN_LENGTH} characters`);201 return;202 }203 if (password() !== confirmPassword()) {204 setError("passwords do not match");205 return;206 }207 const score = strengthScore();208 if (score === null || score < MIN_ZXCVBN_SCORE) {209 setError("password is too weak — use a longer passphrase");210 return;211 }212 setLoading(true);213 setError("");214 const response = await sendMessage({215 type: "CREATE_VAULT",216 payload: { password: password() },217 });218 const result = response as { ok: boolean; error?: string };219 if (result.ok) {220 221 222 223 224 225 try {226 await chrome.storage.local?.set({ peach_recovery_pending: "1" });227 } catch { }228 setShowRecoveryWords(true);229 setLoading(false);230 return;231 }232 setError(getDisplayError(result.error, "failed to create vault"));233 setLoading(false);234 }235236 237 return (238 <Switch>239 {}240 <Match when={restoreSurface() === "restore"}>241 <RestoreView242 hasVault={props.hasVault}243 onOpenCodexRestore={props.onOpenCodexRestore}244 initialSource={props.initialRestoreSource}245 onBack={() => {246 247 248 if (props.onBackToOnboarding) props.onBackToOnboarding();249 else setRestoreSurface(null);250 }}251 onUnlock={async () => {252 setRestoreSurface(null);253 await props.onUnlock();254 }}255 />256 </Match>257258 {}259 {260261}262 <Match263 when={264 (showRecoveryWords() || props.forceRecoveryWords) &&265 (!props.hasVault || props.forceRecoveryWords)266 }267 >268 <RecoveryWordDisplayView269 onDone={props.onUnlock}270 />271 </Match>272273 {}274 <Match when={recoverSurface() === "words"}>275 <RecoveryWordEntryView276 onCancel={() => setRecoverSurface(null)}277 onRecoveryComplete={props.onUnlock}278 />279 </Match>280281 {}282 <Match when={props.hasVault}>283 <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">284 <RuneBackground opacity={0.07} />285286 <header class="peach-screen-header lock-screen-header">287 <div class="peach-wordmark-stack">288 <PeachWordmark class="peach-wordmark-wrap" />289 <Show when={props.isPro}>290 <span class="peach-pro-mini-badge">pro</span>291 </Show>292 </div>293 </header>294295 <div class="peach-lock-hero peach-lock-hero--unlock">296 <PeachMark class="peach-lock-mark" />297 <span class="peach-lock-eyebrow">vault locked</span>298 <h1 class="peach-onboarding-headline peach-lock-headline--unlock">299 unlock300 <br />301 your vault.302 </h1>303 </div>304305 <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">306 <Show when={usingPin()} fallback={307 <>308 <label class="peach-label" for="unlock-password">309 master password310 </label>311 <input312 id="unlock-password"313 class="peach-underline-input peach-unlock-input lock-password-input"314 type="password"315 placeholder="••••••••••••"316 autocomplete="current-password"317 value={password()}318 disabled={!props.ready}319 ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}320 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}321 onKeyDown={(event) => {322 if (event.key !== "Enter" || !props.ready) return;323 void handleUnlock();324 }}325 />326 </>327 }>328 <label class="peach-label" for="unlock-pin">329 PIN330 </label>331 <input332 id="unlock-pin"333 class="peach-underline-input peach-unlock-input lock-pin-input"334 type="password"335 inputmode="numeric"336 maxlength="12"337 placeholder="••••••••••••"338 autocomplete="off"339 value={pin()}340 disabled={!props.ready}341 ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}342 onInput={(event) => setPin((event.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}343 onKeyDown={(event) => {344 if (event.key !== "Enter" || !props.ready) return;345 void handlePinUnlock();346 }}347 />348 <button349 class="peach-lock-recover-link"350 type="button"351 onClick={() => { setUsingPin(false); setError(""); }}352 >353 use master password354 </button>355 </Show>356357 <Show when={error()}>358 <p class="peach-hint peach-hint-error">{error()}</p>359 </Show>360361 <button362 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"363 onClick={() => void (usingPin() ? handlePinUnlock() : handleUnlock())}364 disabled={!props.ready || loading()}365 >366 {loading() ? "unlocking..." : usingPin() ? "unlock with PIN" : "unlock"}367 </button>368369 <Show when={!usingPin() && pinAvailable()}>370 <button371 class="peach-lock-recover-link"372 type="button"373 onClick={() => { setUsingPin(true); setError(""); }}374 >375 use PIN376 </button>377 </Show>378379 <button380 class="peach-lock-recover-link"381 type="button"382 onClick={() => setRecoverSurface("words")}383 >384 forgot your password?385 </button>386 </section>387 </section>388 </Match>389390 {}391 <Match when={true}>392 <section393 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"394 data-motion-phase="enter"395 >396 <header class="peach-screen-header peach-newvault-header">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 <Show when={props.onBackToOnboarding}>404 <button405 class="peach-newvault-back"406 type="button"407 onClick={() => props.onBackToOnboarding?.()}408 aria-label="Back to onboarding"409 >410 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">411 <path d="M7 2L3 5.5L7 9" />412 </svg>413 back414 </button>415 </Show>416 </header>417418 <div class="peach-newvault-mark" aria-hidden="true">419 <PeachMark class="peach-newvault-mark-art" />420 </div>421422 <div class="peach-newvault-card">423 <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>424 <hr class="peach-newvault-divider" />425426 <div class="peach-newvault-field">427 <div class="peach-newvault-label-row">428 <label class="peach-label" for="master-password">429 master password430 </label>431 <button432 class="peach-lock-reveal-btn"433 type="button"434 onClick={() => setShowPassword((value) => !value)}435 aria-label={showPassword() ? "Hide password" : "Show password"}436 >437 <Show438 when={showPassword()}439 fallback={440 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">441 <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" />442 <circle cx="7" cy="5" r="1.75" />443 </svg>444 }445 >446 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">447 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />448 <path d="M3.5 5.5L2.5 7.5" />449 <path d="M7 6.5V8.5" />450 <path d="M10.5 5.5L11.5 7.5" />451 </svg>452 </Show>453 {showPassword() ? "hide" : "show"}454 </button>455 </div>456 <input457 id="master-password"458 class="peach-underline-input peach-newvault-input lock-password-input"459 type={showPassword() ? "text" : "password"}460 placeholder="••••••••••••"461 autocomplete="new-password"462 value={password()}463 disabled={!props.ready}464 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}465 onKeyDown={(event) => {466 if (event.key !== "Enter" || !props.ready) return;467 void handleCreate();468 }}469 />470 <Show471 when={password().length > 0 && passwordStrength() !== null}472 fallback={473 <p class="peach-newvault-hint">474 At least 12 characters.475 </p>476 }477 >478 <div class="peach-password-strength">479 <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>480 <div class="peach-strength-fill"></div>481 </div>482 <span class="peach-strength-label">{passwordStrength()}</span>483 </div>484 </Show>485 </div>486487 <div class="peach-newvault-field">488 <label class="peach-label" for="confirm-password">489 confirm password490 </label>491 <input492 id="confirm-password"493 class="peach-underline-input peach-newvault-input"494 type={showPassword() ? "text" : "password"}495 placeholder="••••••••••••"496 autocomplete="new-password"497 value={confirmPassword()}498 disabled={!props.ready}499 onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}500 onKeyDown={(event) => {501 if (event.key === "Enter" && props.ready) void handleCreate();502 }}503 />504 </div>505506 <Show when={error()}>507 <p class="peach-hint peach-hint-error">{error()}</p>508 </Show>509510 <button511 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"512 onClick={() => void handleCreate()}513 disabled={!props.ready || loading() || !canCreate()}514 aria-live="polite"515 >516 <Show517 when={!canCreate() && createBlockerMessage()}518 fallback={loading() ? "creating..." : "create vault"}519 >520 {createBlockerMessage()}521 </Show>522 </button>523524 <p class="peach-newvault-warning">525 <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>526 <span>Save your 12 recovery words after creating — they're the only way back in if you forget your password.</span>527 </p>528 </div>529 </section>530 </Match>531 </Switch>532 );533}534535536537export type RestoreSource = "symbols" | "codex" | "csv";538539const RESTORE_SOURCES: { id: RestoreSource; name: string; detail: string }[] = [540 { id: "symbols", name: "PeachScript", detail: "Paste the rune string, or open a .peach file." },541 { id: "codex", name: "Codex", detail: "Scan printed pages, or import the PDF." },542 { id: "csv", name: "CSV", detail: "A .csv or .json file from another password manager." },543];544545const RESTORE_DEEP_LINKS: Record<"symbols" | "csv", "peachscript" | "csv"> = {546 symbols: "peachscript",547 csv: "csv",548};549550551552553554555556557558async function detachForFilePicker(route: "peachscript" | "csv"): Promise<boolean> {559 try {560 561 if (new URLSearchParams(window.location.search).get("restore")) return false;562 } catch { }563 if (typeof chrome === "undefined" || typeof chrome.runtime?.sendMessage !== "function") {564 return false;565 }566 try {567 const res = (await sendMessage({568 type: "OPEN_DETACHED_POPUP",569 payload: { route },570 })) as { ok: boolean; data?: { detached?: boolean } };571 if (!res?.data?.detached) return false;572 573 574 return true;575 } catch {576 return false;577 }578}579580581582function restoreDeepLinkActive(link: "peachscript" | "csv"): boolean {583 try {584 return new URLSearchParams(window.location.search).get("restore") === link;585 } catch {586 return false;587 }588}589590function RestoreView(props: {591 hasVault: boolean;592 onOpenCodexRestore?: () => void;593 onBack: () => void;594 onUnlock: () => void | Promise<void>;595 initialSource?: RestoreSource;596}) {597 const [source, setSource] = createSignal<RestoreSource | null>(props.initialSource ?? null);598 const [showIntro, setShowIntro] = createSignal(false);599 const [pendingUnlock] = createSignal(false);600601 602 603 604 605 function chooseSource(next: RestoreSource | null) {606 setSource(next);607 if (next) void storageSet(RESTORE_SOURCE_STORAGE_KEY, next).catch(() => {});608 else void storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {});609 }610611 async function handleRestoreSuccess() {612 const seen = await storageGet("peach_shown_restore_intro").catch(() => null);613 if (!seen) {614 setShowIntro(true);615 } else {616 await props.onUnlock();617 }618 }619620 async function dismissIntro() {621 await storageSet("peach_shown_restore_intro", "true").catch(() => {});622 setShowIntro(false);623 await props.onUnlock();624 }625626 return (627 <section628 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault peach-restore"629 data-motion-phase="enter"630 >631 <RuneBackground opacity={0.05} />632633 <header class="peach-screen-header peach-newvault-header">634 <PeachWordmark class="peach-wordmark-wrap" />635 <button636 class="peach-newvault-back peach-restore-back"637 type="button"638 onClick={props.onBack}639 aria-label="Back to onboarding"640 >641 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">642 <path d="M7 2L3 5.5L7 9" />643 </svg>644 back645 </button>646 </header>647648 <div class="peach-newvault-card peach-restore-card">649 <Show650 when={source()}651 keyed652 fallback={653 <>654 <h1 class="peach-newvault-headline">{"welcome\nback."}</h1>655 <hr class="peach-newvault-divider" />656 <div class="peach-restore-sources">657 <For each={RESTORE_SOURCES}>658 {(entry) => (659 <button660 type="button"661 class="peach-onboarding-choice peach-restore-source"662 onClick={() => chooseSource(entry.id)}663 >664 <span class="peach-onboarding-choice-label">{entry.name}</span>665 <span class="peach-onboarding-choice-detail">{entry.detail}</span>666 </button>667 )}668 </For>669 </div>670 </>671 }672 >673 {(active) => (674 <>675 <div class="peach-restore-source-head">676 <h2 class="peach-restore-source-title">677 {RESTORE_SOURCES.find((entry) => entry.id === active)?.name}678 </h2>679 <button680 type="button"681 class="peach-restore-picker-back"682 onClick={() => chooseSource(null)}683 >684 ‹ sources685 </button>686 </div>687 <hr class="peach-newvault-divider" />688 <Show when={active === "symbols"}>689 <RestoreSymbols hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />690 </Show>691 <Show when={active === "codex"}>692 <RestoreCodex onOpen={() => props.onOpenCodexRestore?.()} />693 </Show>694 <Show when={active === "csv"}>695 <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />696 </Show>697 </>698 )}699 </Show>700 </div>701702 {}703 <Show when={showIntro()}>704 <div class="peach-restore-intro-overlay">705 <div class="peach-restore-intro-card">706 <p class="peach-restore-intro-headline">welcome to peach.</p>707 <div class="peach-restore-intro-body">708 <p>Your vault lives only on this device. No server has a copy.</p>709 <p>The inline bubble fills passwords automatically when you visit sites.</p>710 <p>Export your vault from Settings before switching devices.</p>711 </div>712 <button713 class="peach-btn peach-btn--accent peach-btn--md peach-restore-intro-btn"714 onClick={() => void dismissIntro()}715 disabled={pendingUnlock()}716 >717 got it718 </button>719 </div>720 </div>721 </Show>722 </section>723 );724}725726727728function RestoreCodex(props: { onOpen: () => void }) {729 return (730 <div class="peach-restore-form">731 <p class="peach-restore-codex-copy">732 Scan printed Codex pages or import the Codex PDF. Codex restore is733 always available, even before you create a vault.734 </p>735 <button736 type="button"737 class="peach-btn peach-btn--accent peach-btn--md peach-restore-submit"738 onClick={props.onOpen}739 >740 open Codex restore741 </button>742 </div>743 );744}745746747748function RestoreSymbols(props: {749 hasVault: boolean;750 onSuccess: () => void | Promise<void>;751}) {752 const [scriptInput, setScriptInput] = createSignal("");753 const [backupPass, setBackupPass] = createSignal("");754 const [working, setWorking] = createSignal(false);755 const [animating, setAnimating] = createSignal<"chaos" | "snap" | "bloom" | null>(null);756 const [error, setError] = createSignal<string | null>(null);757 let canvasRef: HTMLCanvasElement | undefined;758 let peachFileRef: HTMLInputElement | undefined;759 let animFrameId = 0;760 let runes: { x: number; y: number; vx: number; vy: number; char: string; size: number; angle: number; va: number }[] = [];761762 const scriptValid = createMemo(() => isPeachScript(scriptInput()));763764 function stopAnim() {765 cancelAnimationFrame(animFrameId);766 animFrameId = 0;767 }768769 function startChaos(canvas: HTMLCanvasElement) {770 const ctx = canvas.getContext("2d")!;771 const W = canvas.width;772 const H = canvas.height;773 const RUNE_CHARS = Array.from({ length: 40 }, (_, i) =>774 String.fromCodePoint(0xe000 + i)775 );776 if (runes.length === 0) {777 runes = Array.from({ length: 60 }, () => ({778 x: Math.random() * W,779 y: Math.random() * H,780 vx: (Math.random() - 0.5) * 3,781 vy: (Math.random() - 0.5) * 3,782 char: RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]!,783 size: 14 + Math.random() * 20,784 angle: Math.random() * Math.PI * 2,785 va: (Math.random() - 0.5) * 0.1,786 }));787 }788789 function draw() {790 ctx.clearRect(0, 0, W, H);791 for (const r of runes) {792 r.x = (r.x + r.vx + W) % W;793 r.y = (r.y + r.vy + H) % H;794 r.angle += r.va;795 ctx.save();796 ctx.translate(r.x, r.y);797 ctx.rotate(r.angle);798 ctx.font = `${r.size}px PeachScript, monospace`;799 const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 400 + r.x);800 ctx.fillStyle = `rgba(232, 149, 109, ${alpha})`;801 ctx.shadowColor = "rgba(232, 149, 109, 0.7)";802 ctx.shadowBlur = 8;803 ctx.fillText(r.char, 0, 0);804 ctx.restore();805 }806 animFrameId = requestAnimationFrame(draw);807 }808 draw();809 }810811 function snapToGrid(canvas: HTMLCanvasElement, onDone: () => void) {812 stopAnim();813 const ctx = canvas.getContext("2d")!;814 const W = canvas.width;815 const H = canvas.height;816 const cols = 8;817 const rows = 6;818 const cellW = W / cols;819 const cellH = H / rows;820 const targets = runes.slice(0, cols * rows).map((_, i) => ({821 x: (i % cols) * cellW + cellW / 2,822 y: Math.floor(i / cols) * cellH + cellH / 2,823 }));824 const start = performance.now();825 const DURATION = 400;826827 function drawSnap(now: number) {828 const t = Math.min((now - start) / DURATION, 1);829 const ease = t < 1 ? 1 - (1 - t) ** 3 : 1;830 ctx.clearRect(0, 0, W, H);831 runes.slice(0, targets.length).forEach((r, i) => {832 const tx = targets[i]!.x;833 const ty = targets[i]!.y;834 const cx = r.x + (tx - r.x) * ease;835 const cy = r.y + (ty - r.y) * ease;836 ctx.font = `16px PeachScript, monospace`;837 ctx.fillStyle = `rgba(232, 149, 109, ${0.8 * ease})`;838 ctx.shadowColor = "rgba(232, 149, 109, 0.6)";839 ctx.shadowBlur = 6;840 ctx.fillText(r.char, cx - 8, cy + 8);841 });842 if (t < 1) {843 animFrameId = requestAnimationFrame(drawSnap);844 } else {845 onDone();846 }847 }848 animFrameId = requestAnimationFrame(drawSnap);849 }850851 function bloom(canvas: HTMLCanvasElement, onDone: () => void) {852 stopAnim();853 const ctx = canvas.getContext("2d")!;854 const W = canvas.width;855 const H = canvas.height;856 const start = performance.now();857 const DURATION = 900;858859 function drawBloom(now: number) {860 const t = Math.min((now - start) / DURATION, 1);861 ctx.clearRect(0, 0, W, H);862 863 const runeAlpha = 1 - Math.min(t / 0.4, 1);864 if (runeAlpha > 0) {865 for (const r of runes.slice(0, 48)) {866 ctx.font = `16px PeachScript, monospace`;867 ctx.fillStyle = `rgba(232, 149, 109, ${runeAlpha * 0.8})`;868 ctx.fillText(r.char, r.x - 8, r.y + 8);869 }870 }871 872 const markT = Math.max(0, (t - 0.2) / 0.5);873 const markScale = Math.min(markT * 1.2, 1);874 const markAlpha = markT < 0.5 ? markT * 2 : 2 - markT * 2;875 if (markAlpha > 0) {876 ctx.save();877 ctx.globalAlpha = markAlpha;878 ctx.shadowColor = `rgba(217, 104, 48, ${markAlpha * 0.8})`;879 ctx.shadowBlur = 30;880 ctx.translate(W / 2, H / 2);881 ctx.scale(markScale, markScale);882 ctx.drawImage(peachMarkImg, -32, -32, 64, 64);883 ctx.restore();884 }885 if (t < 1) {886 animFrameId = requestAnimationFrame(drawBloom);887 } else {888 onDone();889 }890 }891 animFrameId = requestAnimationFrame(drawBloom);892 }893894 onCleanup(() => stopAnim());895896 897 898 onMount(() => {899 if (restoreDeepLinkActive(RESTORE_DEEP_LINKS.symbols)) {900 setTimeout(() => peachFileRef?.click(), 50);901 }902 });903904 905 906 const [dragDepth, setDragDepth] = createSignal(0);907908 async function handleDrop(e: DragEvent) {909 e.preventDefault();910 setDragDepth(0);911 if (working() || animating() !== null) return;912 const file = e.dataTransfer?.files?.[0];913 if (!file) return;914 try {915 setScriptInput(await file.text());916 setError(null);917 } catch {918 setError("Couldn't read file.");919 }920 }921922 async function handleRestore() {923 if (!scriptValid() || !backupPass().trim() || working()) return;924 setError(null);925 setWorking(true);926 setAnimating("chaos");927928 const canvas = canvasRef;929 if (canvas) startChaos(canvas);930931 try {932 const bytes = decodePeachScript(normalisePeachScript(scriptInput()));933 const payload = await parsePayload(bytes, backupPass());934935 if (!props.hasVault) {936 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });937 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");938 }939940 const rr = await sendMessage({941 type: "RESTORE_ENTRIES",942 payload: { entries: payload.entries, mode: "merge", settings: payload.settings },943 });944 if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");945946 if (payload.license?.key) {947 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});948 }949950 setAnimating("snap");951 stopAnim();952 if (canvas) {953 snapToGrid(canvas, () => {954 setAnimating("bloom");955 bloom(canvas, async () => {956 setAnimating(null);957 await props.onSuccess();958 });959 });960 } else {961 await props.onSuccess();962 }963 } catch (err) {964 stopAnim();965 setAnimating(null);966 const code = err instanceof Error ? err.message : String(err);967 setError(968 code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"969 ? "That passphrase didn't work."970 : code === "CREATE_VAULT_FAILED"971 ? "Couldn't create a new local vault."972 : "Could not read this backup.",973 );974 } finally {975 setWorking(false);976 }977 }978979 return (980 <div981 class="peach-restore-form"982 classList={{ "is-dragover": dragDepth() > 0 }}983 onDragEnter={(e) => { e.preventDefault(); setDragDepth((d) => d + 1); }}984 onDragOver={(e) => e.preventDefault()}985 onDragLeave={() => setDragDepth((d) => Math.max(0, d - 1))}986 onDrop={(e) => void handleDrop(e)}987 >988 <Show when={animating() !== null}>989 <canvas990 ref={(el) => { canvasRef = el; }}991 class="peach-restore-anim-canvas"992 width={280}993 height={200}994 />995 </Show>996 <Show when={animating() === null}>997 <Show998 when={scriptValid()}999 fallback={1000 <>1001 <textarea1002 class="peach-restore-script"1003 placeholder="paste peachscript…"1004 rows={3}1005 aria-label="Paste your PeachScript backup"1006 value={scriptInput()}1007 onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}1008 />1009 <div class="peach-restore-or" aria-hidden="true">or</div>1010 <label1011 class="peach-btn peach-btn--ghost peach-btn--sm peach-restore-file-btn"1012 onClick={(e) => {1013 if (restoreDeepLinkActive(RESTORE_DEEP_LINKS.symbols)) return;1014 e.preventDefault();1015 void detachForFilePicker(RESTORE_DEEP_LINKS.symbols).then((detached) => {1016 if (!detached) peachFileRef?.click();1017 });1018 }}1019 >1020 open .peach file1021 <input1022 ref={(el) => { peachFileRef = el; }}1023 type="file"1024 accept=".peach,text/plain"1025 style="display:none"1026 onChange={async (e) => {1027 const file = (e.currentTarget as HTMLInputElement).files?.[0];1028 if (!file) return;1029 try {1030 setScriptInput(await file.text());1031 } catch {1032 setError("Couldn't read file.");1033 } finally {1034 (e.currentTarget as HTMLInputElement).value = "";1035 }1036 }}1037 />1038 </label>1039 <p class="peach-restore-drop-hint">or drop the file anywhere here</p>1040 </>1041 }1042 >1043 <div class="peach-restore-rune-preview">1044 <RuneCanvas runeString={scriptInput()} width={256} height={120} />1045 </div>1046 <button1047 type="button"1048 class="peach-restore-picker-back"1049 onClick={() => { setScriptInput(""); setError(null); }}1050 >1051 ‹ paste again1052 </button>1053 </Show>1054 <div class="peach-newvault-field">1055 <label class="peach-label" for="restore-backup-pass">1056 backup password1057 </label>1058 <input1059 id="restore-backup-pass"1060 class="peach-underline-input peach-newvault-input"1061 type="password"1062 placeholder="••••••••••••"1063 autocomplete="current-password"1064 value={backupPass()}1065 onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}1066 onKeyDown={(e) => {1067 if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {1068 e.preventDefault();1069 void handleRestore();1070 }1071 }}1072 />1073 </div>1074 <button1075 class="peach-btn peach-btn--accent peach-btn--md peach-restore-submit"1076 onClick={() => void handleRestore()}1077 disabled={!scriptValid() || !backupPass().trim() || working()}1078 >1079 restore vault1080 </button>1081 <Show when={error()}>1082 <p class="peach-hint peach-hint-error">{error()}</p>1083 </Show>1084 </Show>1085 </div>1086 );1087}1088108910901091function RestoreCSV(props: {1092 hasVault: boolean;1093 onSuccess: () => void | Promise<void>;1094}) {1095 const [importing, setImporting] = createSignal(false);1096 const [error, setError] = createSignal<string | null>(null);1097 1098 1099 1100 1101 const CSV_MIN_PASSWORD_LENGTH = 12;1102 const [newPass, setNewPass] = createSignal("");1103 const [confirmPass, setConfirmPass] = createSignal("");1104 const needsPassword = () => props.hasVault === false;1105 const passwordReady = () =>1106 !needsPassword() ||1107 (newPass().length >= CSV_MIN_PASSWORD_LENGTH && newPass() === confirmPass());1108 1109 1110 const [dragDepth, setDragDepth] = createSignal(0);11111112 1113 1114 1115 async function processFile(file: File) {1116 setImporting(true);1117 setError(null);1118 try {1119 const text = await file.text();1120 const parsed = await parseImportSource(text);1121 if (parsed.entries.length === 0) throw new Error("NO_ENTRIES");11221123 if (props.hasVault === false) {1124 1125 1126 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: newPass() } });1127 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");1128 }11291130 const res = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: parsed.entries } });1131 if (!(res as { ok: boolean }).ok) throw new Error("IMPORT_FAILED");1132 if (parsed.licenseKey) {1133 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: parsed.licenseKey } }).catch(() => {});1134 }1135 await props.onSuccess();1136 } catch (err) {1137 const code = err instanceof Error ? err.message : String(err);1138 setError(1139 code === "NO_ENTRIES" ? "No entries found in this file." :1140 code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." :1141 "Import failed. Check the file format and try again."1142 );1143 } finally {1144 setImporting(false);1145 }1146 }11471148 let csvFileRef: HTMLInputElement | undefined;11491150 onMount(() => {1151 1152 1153 1154 if (restoreDeepLinkActive(RESTORE_DEEP_LINKS.csv) && !needsPassword()) {1155 setTimeout(() => csvFileRef?.click(), 50);1156 }1157 });11581159 function gatePassword(): boolean {1160 if (importing()) return false;1161 if (needsPassword() && newPass().length < CSV_MIN_PASSWORD_LENGTH) {1162 setError(`Set a master password of at least ${CSV_MIN_PASSWORD_LENGTH} characters first.`);1163 return false;1164 }1165 if (needsPassword() && newPass() !== confirmPass()) {1166 setError("Passwords do not match.");1167 return false;1168 }1169 return true;1170 }11711172 async function handleFile(e: Event) {1173 const input = e.currentTarget as HTMLInputElement;1174 const file = input.files?.[0];1175 input.value = "";1176 if (!file) return;1177 await processFile(file);1178 }11791180 async function handleDrop(e: DragEvent) {1181 e.preventDefault();1182 setDragDepth(0);1183 if (!gatePassword()) return;1184 const file = e.dataTransfer?.files?.[0];1185 if (!file) return;1186 await processFile(file);1187 }11881189 return (1190 <div1191 class="peach-restore-form"1192 classList={{ "is-dragover": dragDepth() > 0 }}1193 onDragEnter={(e) => { e.preventDefault(); setDragDepth((d) => d + 1); }}1194 onDragOver={(e) => e.preventDefault()}1195 onDragLeave={() => setDragDepth((d) => Math.max(0, d - 1))}1196 onDrop={(e) => void handleDrop(e)}1197 >1198 <Show when={needsPassword()}>1199 <div class="peach-newvault-field">1200 <label class="peach-label" for="restore-csv-pass">1201 new master password1202 </label>1203 <input1204 id="restore-csv-pass"1205 class="peach-underline-input peach-newvault-input"1206 type="password"1207 placeholder="••••••••••••"1208 autocomplete="new-password"1209 value={newPass()}1210 onInput={(e) => setNewPass((e.target as HTMLInputElement).value)}1211 />1212 </div>1213 <div class="peach-newvault-field">1214 <label class="peach-label" for="restore-csv-confirm">1215 confirm master password1216 </label>1217 <input1218 id="restore-csv-confirm"1219 class="peach-underline-input peach-newvault-input"1220 type="password"1221 placeholder="••••••••••••"1222 autocomplete="new-password"1223 value={confirmPass()}1224 onInput={(e) => setConfirmPass((e.target as HTMLInputElement).value)}1225 />1226 </div>1227 </Show>1228 <label1229 class="peach-btn peach-btn--accent peach-btn--md peach-restore-file-btn"1230 onClick={(e) => {1231 1232 1233 1234 if (restoreDeepLinkActive(RESTORE_DEEP_LINKS.csv)) return;1235 1236 1237 e.preventDefault();1238 if (!gatePassword()) return;1239 void detachForFilePicker(RESTORE_DEEP_LINKS.csv).then((detached) => {1240 if (!detached) csvFileRef?.click();1241 });1242 }}1243 >1244 {importing() ? "importing…" : "open csv file"}1245 <input1246 ref={(el) => { csvFileRef = el; }}1247 type="file"1248 accept=".csv,.json,.peach,application/x-peach"1249 style="display:none"1250 onChange={handleFile}1251 disabled={importing() || !passwordReady()}1252 />1253 </label>1254 <p class="peach-restore-drop-hint">or drop the file anywhere here</p>1255 <p class="peach-hint peach-restore-csv-sources">1256 Imports from Bitwarden, 1Password, Proton Pass,1257 <br />1258 Chrome, Safari, and Edge.1259 </p>1260 <p class="peach-newvault-warning">1261 <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>1262 <span>Plain-text file — delete it after the import.</span>1263 </p>1264 <Show when={error()}>1265 <p class="peach-hint peach-hint-error">{error()}</p>1266 </Show>1267 </div>1268 );1269}1270127112721273function getDisplayError(error: string | undefined, fallback: string): string {1274 if (!error) return fallback;1275 if (1276 error === "SERVICE_WORKER_UNAVAILABLE" ||1277 error.includes("Receiving end does not exist") ||1278 error.includes("message port closed")1279 ) {1280 return "background service unavailable";1281 }1282 return error;1283}