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 } from "../../crypto/peachScript";10import { parsePayload } from "../../crypto/peachPayload";11import { parseImportSource } from "../../shared/import";12import { IMPORT_LIMITS } from "../../shared/import-limits";1314const peachMarkImg = new Image();15peachMarkImg.src = `data:image/svg+xml,${encodeURIComponent(16 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" fill="none">' +17 '<path d="M 124 64 C 134 32 168 16 200 22 C 198 56 168 72 130 66 Z" fill="#92AC65"/>' +18 '<path d="M 130 64 C 152 46 178 30 196 24" stroke="#5E7A42" stroke-width="2.6" fill="none" stroke-linecap="round"/>' +19 '<path d="M 120 60 C 75 45 28 75 22 130 C 18 185 65 222 125 222 C 185 222 222 185 220 130 C 218 78 170 45 120 60 Z" fill="#FFB082"/>' +20 '<path d="M 120 60 C 75 45 28 75 22 130 C 18 185 65 222 125 222 C 105 195 75 150 100 90 C 105 75 112 65 120 60 Z" fill="#E5805C"/>' +21 '<ellipse cx="178" cy="108" rx="20" ry="11" fill="#FFD0A8" opacity="0.7" transform="rotate(-26 178 108)"/>' +22 '</svg>'23)}`;2425262728let _zxcvbn: ((password: string) => { score: number }) | null = null;29async function getZxcvbn() {30 if (_zxcvbn) return _zxcvbn;31 const [{ zxcvbn, zxcvbnOptions }, common, en] = await Promise.all([32 import("@zxcvbn-ts/core"),33 import("@zxcvbn-ts/language-common"),34 import("@zxcvbn-ts/language-en"),35 ]);36 zxcvbnOptions.setOptions({37 translations: en.translations,38 graphs: common.adjacencyGraphs,39 dictionary: { ...common.dictionary, ...en.dictionary },40 });41 _zxcvbn = (password: string) => zxcvbn(password);42 return _zxcvbn;43}4445interface LockProps {46 hasVault: boolean;47 ready: boolean;48 isPro?: boolean;49 505152 forceRecoveryWords?: boolean;53 onUnlock: () => void | Promise<void>;54 555657 onBackToOnboarding?: () => void;58 5960 onOpenCodexRestore?: () => void;61 62 initialRestore?: boolean;63 646566 initialRestoreSource?: RestoreSource;67}6869707172export const RESTORE_SOURCE_STORAGE_KEY = "peach_onboarding_restore_source";7374type RestoreSurface = "restore" | null;75type RecoverSurface = "words" | "new-password" | null;7677export function LockView(props: LockProps) {78 const [password, setPassword] = createSignal("");79 const [confirmPassword, setConfirmPassword] = createSignal("");80 const [error, setError] = createSignal("");81 const [loading, setLoading] = createSignal(false);82 const [restoreSurface, setRestoreSurface] = createSignal<RestoreSurface>(83 props.initialRestore ? "restore" : null,84 );85 const [showPassword, setShowPassword] = createSignal(false);86 const [recoverSurface, setRecoverSurface] = createSignal<RecoverSurface>(null);87 88 89 const [showRecoveryWords, setShowRecoveryWords] = createSignal(props.forceRecoveryWords ?? false);9091 const MIN_LENGTH = 12;92 const MIN_ZXCVBN_SCORE = 2;9394 const [strengthScore, setStrengthScore] = createSignal<number | null>(null);95 const [pinAvailable, setPinAvailable] = createSignal(false);96 const [usingPin, setUsingPin] = createSignal(false);97 const [pin, setPin] = createSignal("");9899 const canCreate = createMemo(100 () =>101 password().length >= MIN_LENGTH &&102 confirmPassword().length >= MIN_LENGTH &&103 password() === confirmPassword() &&104 strengthScore() !== null &&105 strengthScore()! >= MIN_ZXCVBN_SCORE106 );107108 const passwordStrength = createMemo(() => {109 const score = strengthScore();110 if (score === null) return null;111 if (score <= 1) return "weak";112 if (score === 2) return "fair";113 if (score === 3) return "good";114 return "strong";115 });116117 const createBlockerMessage = createMemo(() => {118 if (!password()) return null;119 if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;120 if (!confirmPassword()) return "confirm your password";121 if (password() !== confirmPassword()) return "passwords don't match";122 if (strengthScore() === null) return "checking strength…";123 if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak — use a longer passphrase";124 return null;125 });126127 128 createMemo(() => {129 const value = password();130 if (!value) { setStrengthScore(null); return; }131 getZxcvbn().then((zxcvbn) => {132 if (password() === value) setStrengthScore(zxcvbn(value).score);133 }).catch(() => {134 135 if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1);136 });137 });138139 async function handleUnlock() {140 if (!password().trim()) return;141 setLoading(true);142 setError("");143 const response = await sendMessage({144 type: "UNLOCK",145 payload: { password: password() },146 });147 const result = response as { ok: boolean; error?: string };148 if (result.ok) {149 await props.onUnlock();150 setLoading(false);151 return;152 }153 setError(getDisplayError(result.error, "incorrect password"));154 setPassword("");155 const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined;156 passwordField?.classList.add("shake");157 setTimeout(() => passwordField?.classList.remove("shake"), 300);158 setLoading(false);159 }160161 async function handlePinUnlock() {162 if (!/^\d{4,12}$/.test(pin())) return;163 setLoading(true);164 setError("");165 const response = await sendMessage({166 type: "UNLOCK_WITH_PIN",167 payload: { pin: pin() },168 });169 const result = response as { ok: boolean; error?: string };170 if (result.ok) {171 await props.onUnlock();172 setLoading(false);173 return;174 }175 if (result.error === "pin_attempts_exceeded") {176 setUsingPin(false);177 setError("too many incorrect PIN attempts — use your master password");178 } else if (result.error === "pin_session_expired") {179 setUsingPin(false);180 setError("PIN is unavailable until you unlock with your master password");181 } else {182 setError(getDisplayError(result.error, "incorrect PIN"));183 }184 setPin("");185 const pinField = document.querySelector(".lock-pin-input") as HTMLInputElement | undefined;186 pinField?.classList.add("shake");187 setTimeout(() => pinField?.classList.remove("shake"), 300);188 setLoading(false);189 }190191 async function checkPinAvailable(): Promise<void> {192 if (!props.hasVault) return;193 try {194 const response = await sendMessage({ type: "GET_PIN_STATUS" });195 const status = (response as {196 ok: boolean;197 data?: { configured?: boolean; available?: boolean; attemptsRemaining?: number; pinPreferred?: boolean };198 }).data;199 const enabled = status?.configured === true && status.available === true;200 setPinAvailable(enabled);201 if (enabled) {202 const settingsResponse = await sendMessage({ type: "GET_SETTINGS" });203 const settings = (settingsResponse as { ok: boolean; data?: { pinPreferred?: boolean } }).data;204 if (settings?.pinPreferred === true) setUsingPin(true);205 }206 } catch { }207 }208209 onMount(() => {210 void checkPinAvailable();211 });212213 async function handleCreate() {214 if (!password().trim()) return;215 if (password().length < MIN_LENGTH) {216 setError(`password must be at least ${MIN_LENGTH} characters`);217 return;218 }219 if (password() !== confirmPassword()) {220 setError("passwords do not match");221 return;222 }223 const score = strengthScore();224 if (score === null || score < MIN_ZXCVBN_SCORE) {225 setError("password is too weak — use a longer passphrase");226 return;227 }228 setLoading(true);229 setError("");230 const response = await sendMessage({231 type: "CREATE_VAULT",232 payload: { password: password() },233 });234 const result = response as { ok: boolean; error?: string };235 if (result.ok) {236 237 238 239 await sendMessage({ type: "SET_RECOVERY_CONFIRM_PENDING" });240 setShowRecoveryWords(true);241 setLoading(false);242 return;243 }244 setError(getDisplayError(result.error, "failed to create vault"));245 setLoading(false);246 }247248 249 return (250 <Switch>251 {}252 <Match when={restoreSurface() === "restore"}>253 <RestoreView254 hasVault={props.hasVault}255 onOpenCodexRestore={props.onOpenCodexRestore}256 initialSource={props.initialRestoreSource}257 onBack={() => {258 259 260 if (props.onBackToOnboarding) props.onBackToOnboarding();261 else setRestoreSurface(null);262 }}263 onUnlock={async () => {264 setRestoreSurface(null);265 await props.onUnlock();266 }}267 />268 </Match>269270 {}271 {272273}274 <Match275 when={276 (showRecoveryWords() || props.forceRecoveryWords) &&277 (!props.hasVault || props.forceRecoveryWords)278 }279 >280 <RecoveryWordDisplayView281 onDone={props.onUnlock}282 />283 </Match>284285 {}286 <Match when={recoverSurface() === "words"}>287 <RecoveryWordEntryView288 onCancel={() => setRecoverSurface(null)}289 onRecoveryComplete={props.onUnlock}290 />291 </Match>292293 {}294 <Match when={props.hasVault}>295 <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">296 <RuneBackground opacity={0.07} />297298 <header class="peach-screen-header lock-screen-header">299 <div class="peach-wordmark-stack">300 <PeachWordmark class="peach-wordmark-wrap" />301 <Show when={props.isPro}>302 <span class="peach-pro-mini-badge">pro</span>303 </Show>304 </div>305 </header>306307 <div class="peach-lock-hero peach-lock-hero--unlock">308 <PeachMark class="peach-lock-mark" />309 <span class="peach-lock-eyebrow">vault locked</span>310 <h1 class="peach-onboarding-headline peach-lock-headline--unlock">311 unlock312 <br />313 your vault.314 </h1>315 </div>316317 <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">318 <Show when={usingPin()} fallback={319 <>320 <label class="peach-label" for="unlock-password">321 master password322 </label>323 <input324 id="unlock-password"325 class="peach-underline-input peach-unlock-input lock-password-input"326 type="password"327 placeholder="••••••••••••"328 autocomplete="current-password"329 value={password()}330 disabled={!props.ready}331 ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}332 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}333 onKeyDown={(event) => {334 if (event.key !== "Enter" || !props.ready) return;335 void handleUnlock();336 }}337 />338 </>339 }>340 <label class="peach-label" for="unlock-pin">341 PIN342 </label>343 <input344 id="unlock-pin"345 class="peach-underline-input peach-unlock-input lock-pin-input"346 type="password"347 inputmode="numeric"348 maxlength="12"349 placeholder="••••••••••••"350 autocomplete="off"351 value={pin()}352 disabled={!props.ready}353 ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}354 onInput={(event) => setPin((event.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}355 onKeyDown={(event) => {356 if (event.key !== "Enter" || !props.ready) return;357 void handlePinUnlock();358 }}359 />360 <button361 class="peach-lock-recover-link"362 type="button"363 onClick={() => { setUsingPin(false); setError(""); }}364 >365 use master password366 </button>367 </Show>368369 <Show when={error()}>370 <p class="peach-hint peach-hint-error">{error()}</p>371 </Show>372373 <button374 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"375 onClick={() => void (usingPin() ? handlePinUnlock() : handleUnlock())}376 disabled={!props.ready || loading()}377 >378 {loading() ? "unlocking..." : usingPin() ? "unlock with PIN" : "unlock"}379 </button>380381 <Show when={!usingPin() && pinAvailable()}>382 <button383 class="peach-lock-recover-link"384 type="button"385 onClick={() => { setUsingPin(true); setError(""); }}386 >387 use PIN388 </button>389 </Show>390391 <button392 class="peach-lock-recover-link"393 type="button"394 onClick={() => setRecoverSurface("words")}395 >396 forgot your password?397 </button>398 </section>399 </section>400 </Match>401402 {}403 <Match when={true}>404 <section405 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"406 data-motion-phase="enter"407 >408 <header class="peach-screen-header peach-newvault-header">409 <div class="peach-wordmark-stack">410 <PeachWordmark class="peach-wordmark-wrap" />411 <Show when={props.isPro}>412 <span class="peach-pro-mini-badge">pro</span>413 </Show>414 </div>415 <Show when={props.onBackToOnboarding}>416 <button417 class="peach-newvault-back"418 type="button"419 onClick={() => props.onBackToOnboarding?.()}420 aria-label="Back to onboarding"421 >422 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">423 <path d="M7 2L3 5.5L7 9" />424 </svg>425 back426 </button>427 </Show>428 </header>429430 <div class="peach-newvault-mark" aria-hidden="true">431 <PeachMark class="peach-newvault-mark-art" />432 </div>433434 <div class="peach-newvault-card">435 <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>436 <hr class="peach-newvault-divider" />437438 <div class="peach-newvault-field">439 <div class="peach-newvault-label-row">440 <label class="peach-label" for="master-password">441 master password442 </label>443 <button444 class="peach-lock-reveal-btn"445 type="button"446 onClick={() => setShowPassword((value) => !value)}447 aria-label={showPassword() ? "Hide password" : "Show password"}448 >449 <Show450 when={showPassword()}451 fallback={452 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">453 <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" />454 <circle cx="7" cy="5" r="1.75" />455 </svg>456 }457 >458 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">459 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />460 <path d="M3.5 5.5L2.5 7.5" />461 <path d="M7 6.5V8.5" />462 <path d="M10.5 5.5L11.5 7.5" />463 </svg>464 </Show>465 {showPassword() ? "hide" : "show"}466 </button>467 </div>468 <input469 id="master-password"470 class="peach-underline-input peach-newvault-input lock-password-input"471 type={showPassword() ? "text" : "password"}472 placeholder="••••••••••••"473 autocomplete="new-password"474 value={password()}475 disabled={!props.ready}476 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}477 onKeyDown={(event) => {478 if (event.key !== "Enter" || !props.ready) return;479 void handleCreate();480 }}481 />482 <Show483 when={password().length > 0 && passwordStrength() !== null}484 fallback={485 <p class="peach-newvault-hint">486 At least 12 characters.487 </p>488 }489 >490 <div class="peach-password-strength">491 <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>492 <div class="peach-strength-fill"></div>493 </div>494 <span class="peach-strength-label">{passwordStrength()}</span>495 </div>496 </Show>497 </div>498499 <div class="peach-newvault-field">500 <label class="peach-label" for="confirm-password">501 confirm password502 </label>503 <input504 id="confirm-password"505 class="peach-underline-input peach-newvault-input"506 type={showPassword() ? "text" : "password"}507 placeholder="••••••••••••"508 autocomplete="new-password"509 value={confirmPassword()}510 disabled={!props.ready}511 onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}512 onKeyDown={(event) => {513 if (event.key === "Enter" && props.ready) void handleCreate();514 }}515 />516 </div>517518 <Show when={error()}>519 <p class="peach-hint peach-hint-error">{error()}</p>520 </Show>521522 <button523 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"524 onClick={() => void handleCreate()}525 disabled={!props.ready || loading() || !canCreate()}526 aria-live="polite"527 >528 <Show529 when={!canCreate() && createBlockerMessage()}530 fallback={loading() ? "creating..." : "create vault"}531 >532 {createBlockerMessage()}533 </Show>534 </button>535536 <p class="peach-newvault-warning">537 <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>538 <span>Save your 12 recovery words after creating — they're the only way back in if you forget your password.</span>539 </p>540 </div>541 </section>542 </Match>543 </Switch>544 );545}546547548549export type RestoreSource = "symbols" | "codex" | "csv";550551const RESTORE_SOURCES: { id: RestoreSource; name: string; detail: string }[] = [552 { id: "symbols", name: "PeachScript", detail: "Paste the rune string, or open a .peach file." },553 { id: "codex", name: "Codex", detail: "Scan printed pages, or import the PDF." },554 { id: "csv", name: "CSV", detail: "A .csv or .json file from another password manager." },555];556557const RESTORE_DEEP_LINKS: Record<"symbols" | "csv", "peachscript" | "csv"> = {558 symbols: "peachscript",559 csv: "csv",560};561562563564565566567568569570async function detachForFilePicker(route: "peachscript" | "csv"): Promise<boolean> {571 try {572 573 if (new URLSearchParams(window.location.search).get("restore")) return false;574 } catch { }575 if (typeof chrome === "undefined" || typeof chrome.runtime?.sendMessage !== "function") {576 return false;577 }578 try {579 const res = (await sendMessage({580 type: "OPEN_DETACHED_POPUP",581 payload: { route },582 })) as { ok: boolean; data?: { detached?: boolean } };583 if (!res?.data?.detached) return false;584 585 586 return true;587 } catch {588 return false;589 }590}591592593594function restoreDeepLinkActive(link: "peachscript" | "csv"): boolean {595 try {596 return new URLSearchParams(window.location.search).get("restore") === link;597 } catch {598 return false;599 }600}601602function RestoreView(props: {603 hasVault: boolean;604 onOpenCodexRestore?: () => void;605 onBack: () => void;606 onUnlock: () => void | Promise<void>;607 initialSource?: RestoreSource;608}) {609 const [source, setSource] = createSignal<RestoreSource | null>(props.initialSource ?? null);610 const [showIntro, setShowIntro] = createSignal(false);611 const [pendingUnlock] = createSignal(false);612613 614 615 616 617 function chooseSource(next: RestoreSource | null) {618 setSource(next);619 if (next) void storageSet(RESTORE_SOURCE_STORAGE_KEY, next).catch(() => {});620 else void storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {});621 }622623 async function handleRestoreSuccess() {624 const seen = await storageGet("peach_shown_restore_intro").catch(() => null);625 if (!seen) {626 setShowIntro(true);627 } else {628 await props.onUnlock();629 }630 }631632 async function dismissIntro() {633 await storageSet("peach_shown_restore_intro", "true").catch(() => {});634 setShowIntro(false);635 await props.onUnlock();636 }637638 return (639 <section640 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault peach-restore"641 data-motion-phase="enter"642 >643 <RuneBackground opacity={0.05} />644645 <header class="peach-screen-header peach-newvault-header">646 <PeachWordmark class="peach-wordmark-wrap" />647 <button648 class="peach-newvault-back peach-restore-back"649 type="button"650 onClick={props.onBack}651 aria-label="Back to onboarding"652 >653 <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">654 <path d="M7 2L3 5.5L7 9" />655 </svg>656 back657 </button>658 </header>659660 <div class="peach-newvault-card peach-restore-card">661 <Show662 when={source()}663 keyed664 fallback={665 <>666 <h1 class="peach-newvault-headline">{"welcome\nback."}</h1>667 <hr class="peach-newvault-divider" />668 <div class="peach-restore-sources">669 <For each={RESTORE_SOURCES}>670 {(entry) => (671 <button672 type="button"673 class="peach-onboarding-choice peach-restore-source"674 onClick={() => chooseSource(entry.id)}675 >676 <span class="peach-onboarding-choice-label">{entry.name}</span>677 <span class="peach-onboarding-choice-detail">{entry.detail}</span>678 </button>679 )}680 </For>681 </div>682 </>683 }684 >685 {(active) => (686 <>687 <div class="peach-restore-source-head">688 <h2 class="peach-restore-source-title">689 {RESTORE_SOURCES.find((entry) => entry.id === active)?.name}690 </h2>691 <button692 type="button"693 class="peach-restore-picker-back"694 onClick={() => chooseSource(null)}695 >696 ‹ sources697 </button>698 </div>699 <hr class="peach-newvault-divider" />700 <Show when={active === "symbols"}>701 <RestoreSymbols hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />702 </Show>703 <Show when={active === "codex"}>704 <RestoreCodex onOpen={() => props.onOpenCodexRestore?.()} />705 </Show>706 <Show when={active === "csv"}>707 <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />708 </Show>709 </>710 )}711 </Show>712 </div>713714 {}715 <Show when={showIntro()}>716 <div class="peach-restore-intro-overlay">717 <div class="peach-restore-intro-card">718 <p class="peach-restore-intro-headline">welcome to peach.</p>719 <div class="peach-restore-intro-body">720 <p>Your vault lives only on this device. No server has a copy.</p>721 <p>The inline bubble fills passwords automatically when you visit sites.</p>722 <p>Export your vault from Settings before switching devices.</p>723 </div>724 <button725 class="peach-btn peach-btn--accent peach-btn--md peach-restore-intro-btn"726 onClick={() => void dismissIntro()}727 disabled={pendingUnlock()}728 >729 got it730 </button>731 </div>732 </div>733 </Show>734 </section>735 );736}737738739740function RestoreCodex(props: { onOpen: () => void }) {741 return (742 <div class="peach-restore-form">743 <p class="peach-restore-codex-copy">744 Scan printed Codex pages or import the Codex PDF. Codex restore is745 always available, even before you create a vault.746 </p>747 <button748 type="button"749 class="peach-btn peach-btn--accent peach-btn--md peach-restore-submit"750 onClick={props.onOpen}751 >752 open Codex restore753 </button>754 </div>755 );756}757758759760function RestoreSymbols(props: {761 hasVault: boolean;762 onSuccess: () => void | Promise<void>;763}) {764 const [scriptInput, setScriptInput] = createSignal("");765 const [backupPass, setBackupPass] = createSignal("");766 const [working, setWorking] = createSignal(false);767 const [animating, setAnimating] = createSignal<"chaos" | "snap" | "bloom" | null>(null);768 const [error, setError] = createSignal<string | null>(null);769 let canvasRef: HTMLCanvasElement | undefined;770 let peachFileRef: HTMLInputElement | undefined;771 let animFrameId = 0;772 let runes: { x: number; y: number; vx: number; vy: number; char: string; size: number; angle: number; va: number }[] = [];773774 const scriptValid = createMemo(() => isPeachScript(scriptInput()));775776 function stopAnim() {777 cancelAnimationFrame(animFrameId);778 animFrameId = 0;779 }780781 function startChaos(canvas: HTMLCanvasElement) {782 const ctx = canvas.getContext("2d")!;783 const W = canvas.width;784 const H = canvas.height;785 const RUNE_CHARS = Array.from({ length: 40 }, (_, i) =>786 String.fromCodePoint(0xe000 + i)787 );788 if (runes.length === 0) {789 runes = Array.from({ length: 60 }, () => ({790 x: Math.random() * W,791 y: Math.random() * H,792 vx: (Math.random() - 0.5) * 3,793 vy: (Math.random() - 0.5) * 3,794 char: RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]!,795 size: 14 + Math.random() * 20,796 angle: Math.random() * Math.PI * 2,797 va: (Math.random() - 0.5) * 0.1,798 }));799 }800801 function draw() {802 ctx.clearRect(0, 0, W, H);803 for (const r of runes) {804 r.x = (r.x + r.vx + W) % W;805 r.y = (r.y + r.vy + H) % H;806 r.angle += r.va;807 ctx.save();808 ctx.translate(r.x, r.y);809 ctx.rotate(r.angle);810 ctx.font = `${r.size}px PeachScript, monospace`;811 const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 400 + r.x);812 ctx.fillStyle = `rgba(232, 149, 109, ${alpha})`;813 ctx.shadowColor = "rgba(232, 149, 109, 0.7)";814 ctx.shadowBlur = 8;815 ctx.fillText(r.char, 0, 0);816 ctx.restore();817 }818 animFrameId = requestAnimationFrame(draw);819 }820 draw();821 }822823 function snapToGrid(canvas: HTMLCanvasElement, onDone: () => void) {824 stopAnim();825 const ctx = canvas.getContext("2d")!;826 const W = canvas.width;827 const H = canvas.height;828 const cols = 8;829 const rows = 6;830 const cellW = W / cols;831 const cellH = H / rows;832 const targets = runes.slice(0, cols * rows).map((_, i) => ({833 x: (i % cols) * cellW + cellW / 2,834 y: Math.floor(i / cols) * cellH + cellH / 2,835 }));836 const start = performance.now();837 const DURATION = 400;838839 function drawSnap(now: number) {840 const t = Math.min((now - start) / DURATION, 1);841 const ease = t < 1 ? 1 - (1 - t) ** 3 : 1;842 ctx.clearRect(0, 0, W, H);843 runes.slice(0, targets.length).forEach((r, i) => {844 const tx = targets[i]!.x;845 const ty = targets[i]!.y;846 const cx = r.x + (tx - r.x) * ease;847 const cy = r.y + (ty - r.y) * ease;848 ctx.font = `16px PeachScript, monospace`;849 ctx.fillStyle = `rgba(232, 149, 109, ${0.8 * ease})`;850 ctx.shadowColor = "rgba(232, 149, 109, 0.6)";851 ctx.shadowBlur = 6;852 ctx.fillText(r.char, cx - 8, cy + 8);853 });854 if (t < 1) {855 animFrameId = requestAnimationFrame(drawSnap);856 } else {857 onDone();858 }859 }860 animFrameId = requestAnimationFrame(drawSnap);861 }862863 function bloom(canvas: HTMLCanvasElement, onDone: () => void) {864 stopAnim();865 const ctx = canvas.getContext("2d")!;866 const W = canvas.width;867 const H = canvas.height;868 const start = performance.now();869 const DURATION = 900;870871 function drawBloom(now: number) {872 const t = Math.min((now - start) / DURATION, 1);873 ctx.clearRect(0, 0, W, H);874 875 const runeAlpha = 1 - Math.min(t / 0.4, 1);876 if (runeAlpha > 0) {877 for (const r of runes.slice(0, 48)) {878 ctx.font = `16px PeachScript, monospace`;879 ctx.fillStyle = `rgba(232, 149, 109, ${runeAlpha * 0.8})`;880 ctx.fillText(r.char, r.x - 8, r.y + 8);881 }882 }883 884 const markT = Math.max(0, (t - 0.2) / 0.5);885 const markScale = Math.min(markT * 1.2, 1);886 const markAlpha = markT < 0.5 ? markT * 2 : 2 - markT * 2;887 if (markAlpha > 0) {888 ctx.save();889 ctx.globalAlpha = markAlpha;890 ctx.shadowColor = `rgba(217, 104, 48, ${markAlpha * 0.8})`;891 ctx.shadowBlur = 30;892 ctx.translate(W / 2, H / 2);893 ctx.scale(markScale, markScale);894 ctx.drawImage(peachMarkImg, -32, -32, 64, 64);895 ctx.restore();896 }897 if (t < 1) {898 animFrameId = requestAnimationFrame(drawBloom);899 } else {900 onDone();901 }902 }903 animFrameId = requestAnimationFrame(drawBloom);904 }905906 onCleanup(() => stopAnim());907908 909 910 onMount(() => {911 if (restoreDeepLinkActive(RESTORE_DEEP_LINKS.symbols)) {912 setTimeout(() => peachFileRef?.click(), 50);913 }914 });915916 917 918 const [dragDepth, setDragDepth] = createSignal(0);919920 async function handleDrop(e: DragEvent) {921 e.preventDefault();922 setDragDepth(0);923 if (working() || animating() !== null) return;924 const file = e.dataTransfer?.files?.[0];925 if (!file) return;926 927 928 if (file.size > IMPORT_LIMITS.MAX_JSON_BYTES) {929 setError("That file is too large.");930 return;931 }932 try {933 setScriptInput(await file.text());934 setError(null);935 } catch {936 setError("Couldn't read file.");937 }938 }939940 async function handleRestore() {941 if (!scriptValid() || !backupPass().trim() || working()) return;942 setError(null);943 setWorking(true);944 setAnimating("chaos");945946 const canvas = canvasRef;947 if (canvas) startChaos(canvas);948949 try {950 951 952 const bytes = decodePeachScript(scriptInput());953 const payload = await parsePayload(bytes, backupPass());954955 if (!props.hasVault) {956 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });957 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");958 }959960 const rr = await sendMessage({961 type: "RESTORE_ENTRIES",962 payload: { entries: payload.entries, mode: "merge", settings: payload.settings },963 });964 if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");965966 if (payload.license?.key) {967 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});968 }969970 setAnimating("snap");971 stopAnim();972 if (canvas) {973 snapToGrid(canvas, () => {974 setAnimating("bloom");975 bloom(canvas, async () => {976 setAnimating(null);977 await props.onSuccess();978 });979 });980 } else {981 await props.onSuccess();982 }983 } catch (err) {984 stopAnim();985 setAnimating(null);986 const code = err instanceof Error ? err.message : String(err);987 setError(988 code === "INVALID_PASSPHRASE"989 ? "That passphrase didn't work."990 : code === "CREATE_VAULT_FAILED"991 ? "Couldn't create a new local vault."992 : "Could not read this backup.",993 );994 } finally {995 setWorking(false);996 }997 }998999 return (1000 <div1001 class="peach-restore-form"1002 classList={{ "is-dragover": dragDepth() > 0 }}1003 onDragEnter={(e) => { e.preventDefault(); setDragDepth((d) => d + 1); }}1004 onDragOver={(e) => e.preventDefault()}1005 onDragLeave={() => setDragDepth((d) => Math.max(0, d - 1))}1006 onDrop={(e) => void handleDrop(e)}1007 >1008 <Show when={animating() !== null}>1009 <canvas1010 ref={(el) => { canvasRef = el; }}1011 class="peach-restore-anim-canvas"1012 width={280}1013 height={200}1014 />1015 </Show>1016 <Show when={animating() === null}>1017 <Show1018 when={scriptValid()}1019 fallback={1020 <>1021 <textarea1022 class="peach-restore-script"1023 placeholder="paste peachscript…"1024 rows={3}1025 aria-label="Paste your PeachScript backup"1026 value={scriptInput()}1027 onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}1028 />1029 <div class="peach-restore-or" aria-hidden="true">or</div>1030 <label1031 class="peach-btn peach-btn--ghost peach-btn--sm peach-restore-file-btn"1032 onClick={(e) => {1033 if (restoreDeepLinkActive(RESTORE_DEEP_LINKS.symbols)) return;1034 e.preventDefault();1035 void detachForFilePicker(RESTORE_DEEP_LINKS.symbols).then((detached) => {1036 if (!detached) peachFileRef?.click();1037 });1038 }}1039 >1040 open .peach file1041 <input1042 ref={(el) => { peachFileRef = el; }}1043 type="file"1044 accept=".peach,text/plain"1045 style="display:none"1046 onChange={async (e) => {1047 const file = (e.currentTarget as HTMLInputElement).files?.[0];1048 if (!file) return;1049 1050 if (file.size > IMPORT_LIMITS.MAX_JSON_BYTES) {1051 setError("That file is too large.");1052 (e.currentTarget as HTMLInputElement).value = "";1053 return;1054 }1055 try {1056 setScriptInput(await file.text());1057 } catch {1058 setError("Couldn't read file.");1059 } finally {1060 (e.currentTarget as HTMLInputElement).value = "";1061 }1062 }}1063 />1064 </label>1065 <p class="peach-restore-drop-hint">or drop the file anywhere here</p>1066 </>1067 }1068 >1069 <div class="peach-restore-rune-preview">1070 <RuneCanvas runeString={scriptInput()} width={256} height={120} />1071 </div>1072 <button1073 type="button"1074 class="peach-restore-picker-back"1075 onClick={() => { setScriptInput(""); setError(null); }}1076 >1077 ‹ paste again1078 </button>1079 </Show>1080 <div class="peach-newvault-field">1081 <label class="peach-label" for="restore-backup-pass">1082 backup password1083 </label>1084 <input1085 id="restore-backup-pass"1086 class="peach-underline-input peach-newvault-input"1087 type="password"1088 placeholder="••••••••••••"1089 autocomplete="current-password"1090 value={backupPass()}1091 onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}1092 onKeyDown={(e) => {1093 if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {1094 e.preventDefault();1095 void handleRestore();1096 }1097 }}1098 />1099 </div>1100 <button1101 class="peach-btn peach-btn--accent peach-btn--md peach-restore-submit"1102 onClick={() => void handleRestore()}1103 disabled={!scriptValid() || !backupPass().trim() || working()}1104 >1105 restore vault1106 </button>1107 <Show when={error()}>1108 <p class="peach-hint peach-hint-error">{error()}</p>1109 </Show>1110 </Show>1111 </div>1112 );1113}1114111511161117function RestoreCSV(props: {1118 hasVault: boolean;1119 onSuccess: () => void | Promise<void>;1120}) {1121 const [importing, setImporting] = createSignal(false);1122 const [error, setError] = createSignal<string | null>(null);1123 1124 1125 1126 1127 const CSV_MIN_PASSWORD_LENGTH = 12;1128 const [newPass, setNewPass] = createSignal("");1129 const [confirmPass, setConfirmPass] = createSignal("");1130 const needsPassword = () => props.hasVault === false;1131 const passwordReady = () =>1132 !needsPassword() ||1133 (newPass().length >= CSV_MIN_PASSWORD_LENGTH && newPass() === confirmPass());1134 1135 1136 const [dragDepth, setDragDepth] = createSignal(0);11371138 1139 1140 1141 async function processFile(file: File) {1142 if (file.size > IMPORT_LIMITS.MAX_JSON_BYTES) {1143 setError("That file is too large.");1144 return;1145 }1146 setImporting(true);1147 setError(null);1148 try {1149 const text = await file.text();1150 const parsed = await parseImportSource(text);1151 if (parsed.entries.length === 0) throw new Error("NO_ENTRIES");11521153 if (props.hasVault === false) {1154 1155 1156 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: newPass() } });1157 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");1158 }11591160 const res = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: parsed.entries } });1161 if (!(res as { ok: boolean }).ok) throw new Error("IMPORT_FAILED");1162 await props.onSuccess();1163 } catch (err) {1164 const code = err instanceof Error ? err.message : String(err);1165 setError(1166 code === "NO_ENTRIES" ? "No entries found in this file." :1167 code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." :1168 "Import failed. Check the file format and try again."1169 );1170 } finally {1171 setImporting(false);1172 }1173 }11741175 let csvFileRef: HTMLInputElement | undefined;11761177 onMount(() => {1178 1179 1180 1181 if (restoreDeepLinkActive(RESTORE_DEEP_LINKS.csv) && !needsPassword()) {1182 setTimeout(() => csvFileRef?.click(), 50);1183 }1184 });11851186 function gatePassword(): boolean {1187 if (importing()) return false;1188 if (needsPassword() && newPass().length < CSV_MIN_PASSWORD_LENGTH) {1189 setError(`Set a master password of at least ${CSV_MIN_PASSWORD_LENGTH} characters first.`);1190 return false;1191 }1192 if (needsPassword() && newPass() !== confirmPass()) {1193 setError("Passwords do not match.");1194 return false;1195 }1196 return true;1197 }11981199 async function handleFile(e: Event) {1200 const input = e.currentTarget as HTMLInputElement;1201 const file = input.files?.[0];1202 input.value = "";1203 if (!file) return;1204 await processFile(file);1205 }12061207 async function handleDrop(e: DragEvent) {1208 e.preventDefault();1209 setDragDepth(0);1210 if (!gatePassword()) return;1211 const file = e.dataTransfer?.files?.[0];1212 if (!file) return;1213 await processFile(file);1214 }12151216 return (1217 <div1218 class="peach-restore-form"1219 classList={{ "is-dragover": dragDepth() > 0 }}1220 onDragEnter={(e) => { e.preventDefault(); setDragDepth((d) => d + 1); }}1221 onDragOver={(e) => e.preventDefault()}1222 onDragLeave={() => setDragDepth((d) => Math.max(0, d - 1))}1223 onDrop={(e) => void handleDrop(e)}1224 >1225 <Show when={needsPassword()}>1226 <div class="peach-newvault-field">1227 <label class="peach-label" for="restore-csv-pass">1228 new master password1229 </label>1230 <input1231 id="restore-csv-pass"1232 class="peach-underline-input peach-newvault-input"1233 type="password"1234 placeholder="••••••••••••"1235 autocomplete="new-password"1236 value={newPass()}1237 onInput={(e) => setNewPass((e.target as HTMLInputElement).value)}1238 />1239 </div>1240 <div class="peach-newvault-field">1241 <label class="peach-label" for="restore-csv-confirm">1242 confirm master password1243 </label>1244 <input1245 id="restore-csv-confirm"1246 class="peach-underline-input peach-newvault-input"1247 type="password"1248 placeholder="••••••••••••"1249 autocomplete="new-password"1250 value={confirmPass()}1251 onInput={(e) => setConfirmPass((e.target as HTMLInputElement).value)}1252 />1253 </div>1254 </Show>1255 <label1256 class="peach-btn peach-btn--accent peach-btn--md peach-restore-file-btn"1257 onClick={(e) => {1258 1259 1260 1261 if (restoreDeepLinkActive(RESTORE_DEEP_LINKS.csv)) return;1262 1263 1264 e.preventDefault();1265 if (!gatePassword()) return;1266 void detachForFilePicker(RESTORE_DEEP_LINKS.csv).then((detached) => {1267 if (!detached) csvFileRef?.click();1268 });1269 }}1270 >1271 {importing() ? "importing…" : "open csv file"}1272 <input1273 ref={(el) => { csvFileRef = el; }}1274 type="file"1275 accept=".csv,.json,.peach,application/x-peach"1276 style="display:none"1277 onChange={handleFile}1278 disabled={importing() || !passwordReady()}1279 />1280 </label>1281 <p class="peach-restore-drop-hint">or drop the file anywhere here</p>1282 <p class="peach-hint peach-restore-csv-sources">1283 Imports from Bitwarden, 1Password, Proton Pass,1284 <br />1285 Chrome, Safari, and Edge.1286 </p>1287 <p class="peach-newvault-warning">1288 <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>1289 <span>Plain-text file — delete it after the import.</span>1290 </p>1291 <Show when={error()}>1292 <p class="peach-hint peach-hint-error">{error()}</p>1293 </Show>1294 </div>1295 );1296}1297129812991300function getDisplayError(error: string | undefined, fallback: string): string {1301 if (!error) return fallback;1302 if (1303 error === "SERVICE_WORKER_UNAVAILABLE" ||1304 error.includes("Receiving end does not exist") ||1305 error.includes("message port closed")1306 ) {1307 return "background service unavailable";1308 }1309 1310 return fallback;1311}