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 if (result.error === "pin_attempts_exceeded") {175 setUsingPin(false);176 setError("too many incorrect PIN attempts — use your master password");177 } else if (result.error === "pin_session_expired") {178 setUsingPin(false);179 setError("PIN is unavailable until you unlock with your master password");180 } else {181 setError(getDisplayError(result.error, "incorrect PIN"));182 }183 setPin("");184 const pinField = document.querySelector(".lock-pin-input") as HTMLInputElement | undefined;185 pinField?.classList.add("shake");186 setTimeout(() => pinField?.classList.remove("shake"), 300);187 setLoading(false);188 }189190 async function checkPinAvailable(): Promise<void> {191 if (!props.hasVault) return;192 try {193 const response = await sendMessage({ type: "GET_PIN_STATUS" });194 const status = (response as {195 ok: boolean;196 data?: { configured?: boolean; available?: boolean; attemptsRemaining?: number; pinPreferred?: boolean };197 }).data;198 const enabled = status?.configured === true && status.available === true;199 setPinAvailable(enabled);200 if (enabled) {201 const settingsResponse = await sendMessage({ type: "GET_SETTINGS" });202 const settings = (settingsResponse as { ok: boolean; data?: { pinPreferred?: boolean } }).data;203 if (settings?.pinPreferred === true) setUsingPin(true);204 }205 } catch { }206 }207208 onMount(() => {209 void checkPinAvailable();210 });211212 async function handleCreate() {213 if (!password().trim()) return;214 if (password().length < MIN_LENGTH) {215 setError(`password must be at least ${MIN_LENGTH} characters`);216 return;217 }218 if (password() !== confirmPassword()) {219 setError("passwords do not match");220 return;221 }222 const score = strengthScore();223 if (score === null || score < MIN_ZXCVBN_SCORE) {224 setError("password is too weak — use a longer passphrase");225 return;226 }227 setLoading(true);228 setError("");229 const response = await sendMessage({230 type: "CREATE_VAULT",231 payload: { password: password() },232 });233 const result = response as { ok: boolean; error?: string };234 if (result.ok) {235 236 237 238 await sendMessage({ type: "SET_RECOVERY_CONFIRM_PENDING" });239 setShowRecoveryWords(true);240 setLoading(false);241 return;242 }243 setError(getDisplayError(result.error, "failed to create vault"));244 setLoading(false);245 }246247 248 return (249 <Switch>250 {}251 <Match when={restoreSurface() === "restore"}>252 <RestoreView253 hasVault={props.hasVault}254 onOpenCodexRestore={props.onOpenCodexRestore}255 initialSource={props.initialRestoreSource}256 onBack={() => {257 258 259 if (props.onBackToOnboarding) props.onBackToOnboarding();260 else setRestoreSurface(null);261 }}262 onUnlock={async () => {263 setRestoreSurface(null);264 await props.onUnlock();265 }}266 />267 </Match>268269 {}270 {271272}273 <Match274 when={275 (showRecoveryWords() || props.forceRecoveryWords) &&276 (!props.hasVault || props.forceRecoveryWords)277 }278 >279 <RecoveryWordDisplayView280 onDone={props.onUnlock}281 />282 </Match>283284 {}285 <Match when={recoverSurface() === "words"}>286 <RecoveryWordEntryView287 onCancel={() => setRecoverSurface(null)}288 onRecoveryComplete={props.onUnlock}289 />290 </Match>291292 {}293 <Match when={props.hasVault}>294 <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">295 <RuneBackground opacity={0.07} />296297 <header class="peach-screen-header lock-screen-header">298 <div class="peach-wordmark-stack">299 <PeachWordmark class="peach-wordmark-wrap" />300 <Show when={props.isPro}>301 <span class="peach-pro-mini-badge">pro</span>302 </Show>303 </div>304 </header>305306 <div class="peach-lock-hero peach-lock-hero--unlock">307 <PeachMark class="peach-lock-mark" />308 <span class="peach-lock-eyebrow">vault locked</span>309 <h1 class="peach-onboarding-headline peach-lock-headline--unlock">310 unlock311 <br />312 your vault.313 </h1>314 </div>315316 <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">317 <Show when={usingPin()} fallback={318 <>319 <label class="peach-label" for="unlock-password">320 master password321 </label>322 <input323 id="unlock-password"324 class="peach-underline-input peach-unlock-input lock-password-input"325 type="password"326 placeholder="••••••••••••"327 autocomplete="current-password"328 value={password()}329 disabled={!props.ready}330 ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}331 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}332 onKeyDown={(event) => {333 if (event.key !== "Enter" || !props.ready) return;334 void handleUnlock();335 }}336 />337 </>338 }>339 <label class="peach-label" for="unlock-pin">340 PIN341 </label>342 <input343 id="unlock-pin"344 class="peach-underline-input peach-unlock-input lock-pin-input"345 type="password"346 inputmode="numeric"347 maxlength="12"348 placeholder="••••••••••••"349 autocomplete="off"350 value={pin()}351 disabled={!props.ready}352 ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}353 onInput={(event) => setPin((event.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}354 onKeyDown={(event) => {355 if (event.key !== "Enter" || !props.ready) return;356 void handlePinUnlock();357 }}358 />359 <button360 class="peach-lock-recover-link"361 type="button"362 onClick={() => { setUsingPin(false); setError(""); }}363 >364 use master password365 </button>366 </Show>367368 <Show when={error()}>369 <p class="peach-hint peach-hint-error">{error()}</p>370 </Show>371372 <button373 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"374 onClick={() => void (usingPin() ? handlePinUnlock() : handleUnlock())}375 disabled={!props.ready || loading()}376 >377 {loading() ? "unlocking..." : usingPin() ? "unlock with PIN" : "unlock"}378 </button>379380 <Show when={!usingPin() && pinAvailable()}>381 <button382 class="peach-lock-recover-link"383 type="button"384 onClick={() => { setUsingPin(true); setError(""); }}385 >386 use PIN387 </button>388 </Show>389390 <button391 class="peach-lock-recover-link"392 type="button"393 onClick={() => setRecoverSurface("words")}394 >395 forgot your password?396 </button>397 </section>398 </section>399 </Match>400401 {}402 <Match when={true}>403 <section404 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"405 data-motion-phase="enter"406 >407 <header class="peach-screen-header peach-newvault-header">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 <Show when={props.onBackToOnboarding}>415 <button416 class="peach-newvault-back"417 type="button"418 onClick={() => props.onBackToOnboarding?.()}419 aria-label="Back to onboarding"420 >421 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">422 <path d="M7 2L3 5.5L7 9" />423 </svg>424 back425 </button>426 </Show>427 </header>428429 <div class="peach-newvault-mark" aria-hidden="true">430 <PeachMark class="peach-newvault-mark-art" />431 </div>432433 <div class="peach-newvault-card">434 <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>435 <hr class="peach-newvault-divider" />436437 <div class="peach-newvault-field">438 <div class="peach-newvault-label-row">439 <label class="peach-label" for="master-password">440 master password441 </label>442 <button443 class="peach-lock-reveal-btn"444 type="button"445 onClick={() => setShowPassword((value) => !value)}446 aria-label={showPassword() ? "Hide password" : "Show password"}447 >448 <Show449 when={showPassword()}450 fallback={451 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">452 <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" />453 <circle cx="7" cy="5" r="1.75" />454 </svg>455 }456 >457 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">458 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />459 <path d="M3.5 5.5L2.5 7.5" />460 <path d="M7 6.5V8.5" />461 <path d="M10.5 5.5L11.5 7.5" />462 </svg>463 </Show>464 {showPassword() ? "hide" : "show"}465 </button>466 </div>467 <input468 id="master-password"469 class="peach-underline-input peach-newvault-input lock-password-input"470 type={showPassword() ? "text" : "password"}471 placeholder="••••••••••••"472 autocomplete="new-password"473 value={password()}474 disabled={!props.ready}475 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}476 onKeyDown={(event) => {477 if (event.key !== "Enter" || !props.ready) return;478 void handleCreate();479 }}480 />481 <Show482 when={password().length > 0 && passwordStrength() !== null}483 fallback={484 <p class="peach-newvault-hint">485 At least 12 characters.486 </p>487 }488 >489 <div class="peach-password-strength">490 <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>491 <div class="peach-strength-fill"></div>492 </div>493 <span class="peach-strength-label">{passwordStrength()}</span>494 </div>495 </Show>496 </div>497498 <div class="peach-newvault-field">499 <label class="peach-label" for="confirm-password">500 confirm password501 </label>502 <input503 id="confirm-password"504 class="peach-underline-input peach-newvault-input"505 type={showPassword() ? "text" : "password"}506 placeholder="••••••••••••"507 autocomplete="new-password"508 value={confirmPassword()}509 disabled={!props.ready}510 onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}511 onKeyDown={(event) => {512 if (event.key === "Enter" && props.ready) void handleCreate();513 }}514 />515 </div>516517 <Show when={error()}>518 <p class="peach-hint peach-hint-error">{error()}</p>519 </Show>520521 <button522 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"523 onClick={() => void handleCreate()}524 disabled={!props.ready || loading() || !canCreate()}525 aria-live="polite"526 >527 <Show528 when={!canCreate() && createBlockerMessage()}529 fallback={loading() ? "creating..." : "create vault"}530 >531 {createBlockerMessage()}532 </Show>533 </button>534535 <p class="peach-newvault-warning">536 <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>537 <span>Save your 12 recovery words after creating — they're the only way back in if you forget your password.</span>538 </p>539 </div>540 </section>541 </Match>542 </Switch>543 );544}545546547548export type RestoreSource = "symbols" | "codex" | "csv";549550const RESTORE_SOURCES: { id: RestoreSource; name: string; detail: string }[] = [551 { id: "symbols", name: "PeachScript", detail: "Paste the rune string, or open a .peach file." },552 { id: "codex", name: "Codex", detail: "Scan printed pages, or import the PDF." },553 { id: "csv", name: "CSV", detail: "A .csv or .json file from another password manager." },554];555556const RESTORE_DEEP_LINKS: Record<"symbols" | "csv", "peachscript" | "csv"> = {557 symbols: "peachscript",558 csv: "csv",559};560561562563564565566567568569async function detachForFilePicker(route: "peachscript" | "csv"): Promise<boolean> {570 try {571 572 if (new URLSearchParams(window.location.search).get("restore")) return false;573 } catch { }574 if (typeof chrome === "undefined" || typeof chrome.runtime?.sendMessage !== "function") {575 return false;576 }577 try {578 const res = (await sendMessage({579 type: "OPEN_DETACHED_POPUP",580 payload: { route },581 })) as { ok: boolean; data?: { detached?: boolean } };582 if (!res?.data?.detached) return false;583 584 585 return true;586 } catch {587 return false;588 }589}590591592593function restoreDeepLinkActive(link: "peachscript" | "csv"): boolean {594 try {595 return new URLSearchParams(window.location.search).get("restore") === link;596 } catch {597 return false;598 }599}600601function RestoreView(props: {602 hasVault: boolean;603 onOpenCodexRestore?: () => void;604 onBack: () => void;605 onUnlock: () => void | Promise<void>;606 initialSource?: RestoreSource;607}) {608 const [source, setSource] = createSignal<RestoreSource | null>(props.initialSource ?? null);609 const [showIntro, setShowIntro] = createSignal(false);610 const [pendingUnlock] = createSignal(false);611612 613 614 615 616 function chooseSource(next: RestoreSource | null) {617 setSource(next);618 if (next) void storageSet(RESTORE_SOURCE_STORAGE_KEY, next).catch(() => {});619 else void storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {});620 }621622 async function handleRestoreSuccess() {623 const seen = await storageGet("peach_shown_restore_intro").catch(() => null);624 if (!seen) {625 setShowIntro(true);626 } else {627 await props.onUnlock();628 }629 }630631 async function dismissIntro() {632 await storageSet("peach_shown_restore_intro", "true").catch(() => {});633 setShowIntro(false);634 await props.onUnlock();635 }636637 return (638 <section639 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault peach-restore"640 data-motion-phase="enter"641 >642 <RuneBackground opacity={0.05} />643644 <header class="peach-screen-header peach-newvault-header">645 <PeachWordmark class="peach-wordmark-wrap" />646 <button647 class="peach-newvault-back peach-restore-back"648 type="button"649 onClick={props.onBack}650 aria-label="Back to onboarding"651 >652 <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">653 <path d="M7 2L3 5.5L7 9" />654 </svg>655 back656 </button>657 </header>658659 <div class="peach-newvault-card peach-restore-card">660 <Show661 when={source()}662 keyed663 fallback={664 <>665 <h1 class="peach-newvault-headline">{"welcome\nback."}</h1>666 <hr class="peach-newvault-divider" />667 <div class="peach-restore-sources">668 <For each={RESTORE_SOURCES}>669 {(entry) => (670 <button671 type="button"672 class="peach-onboarding-choice peach-restore-source"673 onClick={() => chooseSource(entry.id)}674 >675 <span class="peach-onboarding-choice-label">{entry.name}</span>676 <span class="peach-onboarding-choice-detail">{entry.detail}</span>677 </button>678 )}679 </For>680 </div>681 </>682 }683 >684 {(active) => (685 <>686 <div class="peach-restore-source-head">687 <h2 class="peach-restore-source-title">688 {RESTORE_SOURCES.find((entry) => entry.id === active)?.name}689 </h2>690 <button691 type="button"692 class="peach-restore-picker-back"693 onClick={() => chooseSource(null)}694 >695 ‹ sources696 </button>697 </div>698 <hr class="peach-newvault-divider" />699 <Show when={active === "symbols"}>700 <RestoreSymbols hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />701 </Show>702 <Show when={active === "codex"}>703 <RestoreCodex onOpen={() => props.onOpenCodexRestore?.()} />704 </Show>705 <Show when={active === "csv"}>706 <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />707 </Show>708 </>709 )}710 </Show>711 </div>712713 {}714 <Show when={showIntro()}>715 <div class="peach-restore-intro-overlay">716 <div class="peach-restore-intro-card">717 <p class="peach-restore-intro-headline">welcome to peach.</p>718 <div class="peach-restore-intro-body">719 <p>Your vault lives only on this device. No server has a copy.</p>720 <p>The inline bubble fills passwords automatically when you visit sites.</p>721 <p>Export your vault from Settings before switching devices.</p>722 </div>723 <button724 class="peach-btn peach-btn--accent peach-btn--md peach-restore-intro-btn"725 onClick={() => void dismissIntro()}726 disabled={pendingUnlock()}727 >728 got it729 </button>730 </div>731 </div>732 </Show>733 </section>734 );735}736737738739function RestoreCodex(props: { onOpen: () => void }) {740 return (741 <div class="peach-restore-form">742 <p class="peach-restore-codex-copy">743 Scan printed Codex pages or import the Codex PDF. Codex restore is744 always available, even before you create a vault.745 </p>746 <button747 type="button"748 class="peach-btn peach-btn--accent peach-btn--md peach-restore-submit"749 onClick={props.onOpen}750 >751 open Codex restore752 </button>753 </div>754 );755}756757758759function RestoreSymbols(props: {760 hasVault: boolean;761 onSuccess: () => void | Promise<void>;762}) {763 const [scriptInput, setScriptInput] = createSignal("");764 const [backupPass, setBackupPass] = createSignal("");765 const [working, setWorking] = createSignal(false);766 const [animating, setAnimating] = createSignal<"chaos" | "snap" | "bloom" | null>(null);767 const [error, setError] = createSignal<string | null>(null);768 let canvasRef: HTMLCanvasElement | undefined;769 let peachFileRef: HTMLInputElement | undefined;770 let animFrameId = 0;771 let runes: { x: number; y: number; vx: number; vy: number; char: string; size: number; angle: number; va: number }[] = [];772773 const scriptValid = createMemo(() => isPeachScript(scriptInput()));774775 function stopAnim() {776 cancelAnimationFrame(animFrameId);777 animFrameId = 0;778 }779780 function startChaos(canvas: HTMLCanvasElement) {781 const ctx = canvas.getContext("2d")!;782 const W = canvas.width;783 const H = canvas.height;784 const RUNE_CHARS = Array.from({ length: 40 }, (_, i) =>785 String.fromCodePoint(0xe000 + i)786 );787 if (runes.length === 0) {788 runes = Array.from({ length: 60 }, () => ({789 x: Math.random() * W,790 y: Math.random() * H,791 vx: (Math.random() - 0.5) * 3,792 vy: (Math.random() - 0.5) * 3,793 char: RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]!,794 size: 14 + Math.random() * 20,795 angle: Math.random() * Math.PI * 2,796 va: (Math.random() - 0.5) * 0.1,797 }));798 }799800 function draw() {801 ctx.clearRect(0, 0, W, H);802 for (const r of runes) {803 r.x = (r.x + r.vx + W) % W;804 r.y = (r.y + r.vy + H) % H;805 r.angle += r.va;806 ctx.save();807 ctx.translate(r.x, r.y);808 ctx.rotate(r.angle);809 ctx.font = `${r.size}px PeachScript, monospace`;810 const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 400 + r.x);811 ctx.fillStyle = `rgba(232, 149, 109, ${alpha})`;812 ctx.shadowColor = "rgba(232, 149, 109, 0.7)";813 ctx.shadowBlur = 8;814 ctx.fillText(r.char, 0, 0);815 ctx.restore();816 }817 animFrameId = requestAnimationFrame(draw);818 }819 draw();820 }821822 function snapToGrid(canvas: HTMLCanvasElement, onDone: () => void) {823 stopAnim();824 const ctx = canvas.getContext("2d")!;825 const W = canvas.width;826 const H = canvas.height;827 const cols = 8;828 const rows = 6;829 const cellW = W / cols;830 const cellH = H / rows;831 const targets = runes.slice(0, cols * rows).map((_, i) => ({832 x: (i % cols) * cellW + cellW / 2,833 y: Math.floor(i / cols) * cellH + cellH / 2,834 }));835 const start = performance.now();836 const DURATION = 400;837838 function drawSnap(now: number) {839 const t = Math.min((now - start) / DURATION, 1);840 const ease = t < 1 ? 1 - (1 - t) ** 3 : 1;841 ctx.clearRect(0, 0, W, H);842 runes.slice(0, targets.length).forEach((r, i) => {843 const tx = targets[i]!.x;844 const ty = targets[i]!.y;845 const cx = r.x + (tx - r.x) * ease;846 const cy = r.y + (ty - r.y) * ease;847 ctx.font = `16px PeachScript, monospace`;848 ctx.fillStyle = `rgba(232, 149, 109, ${0.8 * ease})`;849 ctx.shadowColor = "rgba(232, 149, 109, 0.6)";850 ctx.shadowBlur = 6;851 ctx.fillText(r.char, cx - 8, cy + 8);852 });853 if (t < 1) {854 animFrameId = requestAnimationFrame(drawSnap);855 } else {856 onDone();857 }858 }859 animFrameId = requestAnimationFrame(drawSnap);860 }861862 function bloom(canvas: HTMLCanvasElement, onDone: () => void) {863 stopAnim();864 const ctx = canvas.getContext("2d")!;865 const W = canvas.width;866 const H = canvas.height;867 const start = performance.now();868 const DURATION = 900;869870 function drawBloom(now: number) {871 const t = Math.min((now - start) / DURATION, 1);872 ctx.clearRect(0, 0, W, H);873 874 const runeAlpha = 1 - Math.min(t / 0.4, 1);875 if (runeAlpha > 0) {876 for (const r of runes.slice(0, 48)) {877 ctx.font = `16px PeachScript, monospace`;878 ctx.fillStyle = `rgba(232, 149, 109, ${runeAlpha * 0.8})`;879 ctx.fillText(r.char, r.x - 8, r.y + 8);880 }881 }882 883 const markT = Math.max(0, (t - 0.2) / 0.5);884 const markScale = Math.min(markT * 1.2, 1);885 const markAlpha = markT < 0.5 ? markT * 2 : 2 - markT * 2;886 if (markAlpha > 0) {887 ctx.save();888 ctx.globalAlpha = markAlpha;889 ctx.shadowColor = `rgba(217, 104, 48, ${markAlpha * 0.8})`;890 ctx.shadowBlur = 30;891 ctx.translate(W / 2, H / 2);892 ctx.scale(markScale, markScale);893 ctx.drawImage(peachMarkImg, -32, -32, 64, 64);894 ctx.restore();895 }896 if (t < 1) {897 animFrameId = requestAnimationFrame(drawBloom);898 } else {899 onDone();900 }901 }902 animFrameId = requestAnimationFrame(drawBloom);903 }904905 onCleanup(() => stopAnim());906907 908 909 onMount(() => {910 if (restoreDeepLinkActive(RESTORE_DEEP_LINKS.symbols)) {911 setTimeout(() => peachFileRef?.click(), 50);912 }913 });914915 916 917 const [dragDepth, setDragDepth] = createSignal(0);918919 async function handleDrop(e: DragEvent) {920 e.preventDefault();921 setDragDepth(0);922 if (working() || animating() !== null) return;923 const file = e.dataTransfer?.files?.[0];924 if (!file) return;925 try {926 setScriptInput(await file.text());927 setError(null);928 } catch {929 setError("Couldn't read file.");930 }931 }932933 async function handleRestore() {934 if (!scriptValid() || !backupPass().trim() || working()) return;935 setError(null);936 setWorking(true);937 setAnimating("chaos");938939 const canvas = canvasRef;940 if (canvas) startChaos(canvas);941942 try {943 const bytes = decodePeachScript(normalisePeachScript(scriptInput()));944 const payload = await parsePayload(bytes, backupPass());945946 if (!props.hasVault) {947 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });948 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");949 }950951 const rr = await sendMessage({952 type: "RESTORE_ENTRIES",953 payload: { entries: payload.entries, mode: "merge", settings: payload.settings },954 });955 if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");956957 if (payload.license?.key) {958 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});959 }960961 setAnimating("snap");962 stopAnim();963 if (canvas) {964 snapToGrid(canvas, () => {965 setAnimating("bloom");966 bloom(canvas, async () => {967 setAnimating(null);968 await props.onSuccess();969 });970 });971 } else {972 await props.onSuccess();973 }974 } catch (err) {975 stopAnim();976 setAnimating(null);977 const code = err instanceof Error ? err.message : String(err);978 setError(979 code === "INVALID_PASSPHRASE"980 ? "That passphrase didn't work."981 : code === "CREATE_VAULT_FAILED"982 ? "Couldn't create a new local vault."983 : "Could not read this backup.",984 );985 } finally {986 setWorking(false);987 }988 }989990 return (991 <div992 class="peach-restore-form"993 classList={{ "is-dragover": dragDepth() > 0 }}994 onDragEnter={(e) => { e.preventDefault(); setDragDepth((d) => d + 1); }}995 onDragOver={(e) => e.preventDefault()}996 onDragLeave={() => setDragDepth((d) => Math.max(0, d - 1))}997 onDrop={(e) => void handleDrop(e)}998 >999 <Show when={animating() !== null}>1000 <canvas1001 ref={(el) => { canvasRef = el; }}1002 class="peach-restore-anim-canvas"1003 width={280}1004 height={200}1005 />1006 </Show>1007 <Show when={animating() === null}>1008 <Show1009 when={scriptValid()}1010 fallback={1011 <>1012 <textarea1013 class="peach-restore-script"1014 placeholder="paste peachscript…"1015 rows={3}1016 aria-label="Paste your PeachScript backup"1017 value={scriptInput()}1018 onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}1019 />1020 <div class="peach-restore-or" aria-hidden="true">or</div>1021 <label1022 class="peach-btn peach-btn--ghost peach-btn--sm peach-restore-file-btn"1023 onClick={(e) => {1024 if (restoreDeepLinkActive(RESTORE_DEEP_LINKS.symbols)) return;1025 e.preventDefault();1026 void detachForFilePicker(RESTORE_DEEP_LINKS.symbols).then((detached) => {1027 if (!detached) peachFileRef?.click();1028 });1029 }}1030 >1031 open .peach file1032 <input1033 ref={(el) => { peachFileRef = el; }}1034 type="file"1035 accept=".peach,text/plain"1036 style="display:none"1037 onChange={async (e) => {1038 const file = (e.currentTarget as HTMLInputElement).files?.[0];1039 if (!file) return;1040 try {1041 setScriptInput(await file.text());1042 } catch {1043 setError("Couldn't read file.");1044 } finally {1045 (e.currentTarget as HTMLInputElement).value = "";1046 }1047 }}1048 />1049 </label>1050 <p class="peach-restore-drop-hint">or drop the file anywhere here</p>1051 </>1052 }1053 >1054 <div class="peach-restore-rune-preview">1055 <RuneCanvas runeString={scriptInput()} width={256} height={120} />1056 </div>1057 <button1058 type="button"1059 class="peach-restore-picker-back"1060 onClick={() => { setScriptInput(""); setError(null); }}1061 >1062 ‹ paste again1063 </button>1064 </Show>1065 <div class="peach-newvault-field">1066 <label class="peach-label" for="restore-backup-pass">1067 backup password1068 </label>1069 <input1070 id="restore-backup-pass"1071 class="peach-underline-input peach-newvault-input"1072 type="password"1073 placeholder="••••••••••••"1074 autocomplete="current-password"1075 value={backupPass()}1076 onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}1077 onKeyDown={(e) => {1078 if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {1079 e.preventDefault();1080 void handleRestore();1081 }1082 }}1083 />1084 </div>1085 <button1086 class="peach-btn peach-btn--accent peach-btn--md peach-restore-submit"1087 onClick={() => void handleRestore()}1088 disabled={!scriptValid() || !backupPass().trim() || working()}1089 >1090 restore vault1091 </button>1092 <Show when={error()}>1093 <p class="peach-hint peach-hint-error">{error()}</p>1094 </Show>1095 </Show>1096 </div>1097 );1098}1099110011011102function RestoreCSV(props: {1103 hasVault: boolean;1104 onSuccess: () => void | Promise<void>;1105}) {1106 const [importing, setImporting] = createSignal(false);1107 const [error, setError] = createSignal<string | null>(null);1108 1109 1110 1111 1112 const CSV_MIN_PASSWORD_LENGTH = 12;1113 const [newPass, setNewPass] = createSignal("");1114 const [confirmPass, setConfirmPass] = createSignal("");1115 const needsPassword = () => props.hasVault === false;1116 const passwordReady = () =>1117 !needsPassword() ||1118 (newPass().length >= CSV_MIN_PASSWORD_LENGTH && newPass() === confirmPass());1119 1120 1121 const [dragDepth, setDragDepth] = createSignal(0);11221123 1124 1125 1126 async function processFile(file: File) {1127 setImporting(true);1128 setError(null);1129 try {1130 const text = await file.text();1131 const parsed = await parseImportSource(text);1132 if (parsed.entries.length === 0) throw new Error("NO_ENTRIES");11331134 if (props.hasVault === false) {1135 1136 1137 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: newPass() } });1138 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");1139 }11401141 const res = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: parsed.entries } });1142 if (!(res as { ok: boolean }).ok) throw new Error("IMPORT_FAILED");1143 await props.onSuccess();1144 } catch (err) {1145 const code = err instanceof Error ? err.message : String(err);1146 setError(1147 code === "NO_ENTRIES" ? "No entries found in this file." :1148 code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." :1149 "Import failed. Check the file format and try again."1150 );1151 } finally {1152 setImporting(false);1153 }1154 }11551156 let csvFileRef: HTMLInputElement | undefined;11571158 onMount(() => {1159 1160 1161 1162 if (restoreDeepLinkActive(RESTORE_DEEP_LINKS.csv) && !needsPassword()) {1163 setTimeout(() => csvFileRef?.click(), 50);1164 }1165 });11661167 function gatePassword(): boolean {1168 if (importing()) return false;1169 if (needsPassword() && newPass().length < CSV_MIN_PASSWORD_LENGTH) {1170 setError(`Set a master password of at least ${CSV_MIN_PASSWORD_LENGTH} characters first.`);1171 return false;1172 }1173 if (needsPassword() && newPass() !== confirmPass()) {1174 setError("Passwords do not match.");1175 return false;1176 }1177 return true;1178 }11791180 async function handleFile(e: Event) {1181 const input = e.currentTarget as HTMLInputElement;1182 const file = input.files?.[0];1183 input.value = "";1184 if (!file) return;1185 await processFile(file);1186 }11871188 async function handleDrop(e: DragEvent) {1189 e.preventDefault();1190 setDragDepth(0);1191 if (!gatePassword()) return;1192 const file = e.dataTransfer?.files?.[0];1193 if (!file) return;1194 await processFile(file);1195 }11961197 return (1198 <div1199 class="peach-restore-form"1200 classList={{ "is-dragover": dragDepth() > 0 }}1201 onDragEnter={(e) => { e.preventDefault(); setDragDepth((d) => d + 1); }}1202 onDragOver={(e) => e.preventDefault()}1203 onDragLeave={() => setDragDepth((d) => Math.max(0, d - 1))}1204 onDrop={(e) => void handleDrop(e)}1205 >1206 <Show when={needsPassword()}>1207 <div class="peach-newvault-field">1208 <label class="peach-label" for="restore-csv-pass">1209 new master password1210 </label>1211 <input1212 id="restore-csv-pass"1213 class="peach-underline-input peach-newvault-input"1214 type="password"1215 placeholder="••••••••••••"1216 autocomplete="new-password"1217 value={newPass()}1218 onInput={(e) => setNewPass((e.target as HTMLInputElement).value)}1219 />1220 </div>1221 <div class="peach-newvault-field">1222 <label class="peach-label" for="restore-csv-confirm">1223 confirm master password1224 </label>1225 <input1226 id="restore-csv-confirm"1227 class="peach-underline-input peach-newvault-input"1228 type="password"1229 placeholder="••••••••••••"1230 autocomplete="new-password"1231 value={confirmPass()}1232 onInput={(e) => setConfirmPass((e.target as HTMLInputElement).value)}1233 />1234 </div>1235 </Show>1236 <label1237 class="peach-btn peach-btn--accent peach-btn--md peach-restore-file-btn"1238 onClick={(e) => {1239 1240 1241 1242 if (restoreDeepLinkActive(RESTORE_DEEP_LINKS.csv)) return;1243 1244 1245 e.preventDefault();1246 if (!gatePassword()) return;1247 void detachForFilePicker(RESTORE_DEEP_LINKS.csv).then((detached) => {1248 if (!detached) csvFileRef?.click();1249 });1250 }}1251 >1252 {importing() ? "importing…" : "open csv file"}1253 <input1254 ref={(el) => { csvFileRef = el; }}1255 type="file"1256 accept=".csv,.json,.peach,application/x-peach"1257 style="display:none"1258 onChange={handleFile}1259 disabled={importing() || !passwordReady()}1260 />1261 </label>1262 <p class="peach-restore-drop-hint">or drop the file anywhere here</p>1263 <p class="peach-hint peach-restore-csv-sources">1264 Imports from Bitwarden, 1Password, Proton Pass,1265 <br />1266 Chrome, Safari, and Edge.1267 </p>1268 <p class="peach-newvault-warning">1269 <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>1270 <span>Plain-text file — delete it after the import.</span>1271 </p>1272 <Show when={error()}>1273 <p class="peach-hint peach-hint-error">{error()}</p>1274 </Show>1275 </div>1276 );1277}1278127912801281function getDisplayError(error: string | undefined, fallback: string): string {1282 if (!error) return fallback;1283 if (1284 error === "SERVICE_WORKER_UNAVAILABLE" ||1285 error.includes("Receiving end does not exist") ||1286 error.includes("message port closed")1287 ) {1288 return "background service unavailable";1289 }1290 1291 return fallback;1292}