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 await sendMessage({ type: "SET_RECOVERY_CONFIRM_PENDING" });224 setShowRecoveryWords(true);225 setLoading(false);226 return;227 }228 setError(getDisplayError(result.error, "failed to create vault"));229 setLoading(false);230 }231232 233 return (234 <Switch>235 {}236 <Match when={restoreSurface() === "restore"}>237 <RestoreView238 hasVault={props.hasVault}239 onOpenCodexRestore={props.onOpenCodexRestore}240 initialSource={props.initialRestoreSource}241 onBack={() => {242 243 244 if (props.onBackToOnboarding) props.onBackToOnboarding();245 else setRestoreSurface(null);246 }}247 onUnlock={async () => {248 setRestoreSurface(null);249 await props.onUnlock();250 }}251 />252 </Match>253254 {}255 {256257}258 <Match259 when={260 (showRecoveryWords() || props.forceRecoveryWords) &&261 (!props.hasVault || props.forceRecoveryWords)262 }263 >264 <RecoveryWordDisplayView265 onDone={props.onUnlock}266 />267 </Match>268269 {}270 <Match when={recoverSurface() === "words"}>271 <RecoveryWordEntryView272 onCancel={() => setRecoverSurface(null)}273 onRecoveryComplete={props.onUnlock}274 />275 </Match>276277 {}278 <Match when={props.hasVault}>279 <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">280 <RuneBackground opacity={0.07} />281282 <header class="peach-screen-header lock-screen-header">283 <div class="peach-wordmark-stack">284 <PeachWordmark class="peach-wordmark-wrap" />285 <Show when={props.isPro}>286 <span class="peach-pro-mini-badge">pro</span>287 </Show>288 </div>289 </header>290291 <div class="peach-lock-hero peach-lock-hero--unlock">292 <PeachMark class="peach-lock-mark" />293 <span class="peach-lock-eyebrow">vault locked</span>294 <h1 class="peach-onboarding-headline peach-lock-headline--unlock">295 unlock296 <br />297 your vault.298 </h1>299 </div>300301 <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">302 <Show when={usingPin()} fallback={303 <>304 <label class="peach-label" for="unlock-password">305 master password306 </label>307 <input308 id="unlock-password"309 class="peach-underline-input peach-unlock-input lock-password-input"310 type="password"311 placeholder="••••••••••••"312 autocomplete="current-password"313 value={password()}314 disabled={!props.ready}315 ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}316 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}317 onKeyDown={(event) => {318 if (event.key !== "Enter" || !props.ready) return;319 void handleUnlock();320 }}321 />322 </>323 }>324 <label class="peach-label" for="unlock-pin">325 PIN326 </label>327 <input328 id="unlock-pin"329 class="peach-underline-input peach-unlock-input lock-pin-input"330 type="password"331 inputmode="numeric"332 maxlength="12"333 placeholder="••••••••••••"334 autocomplete="off"335 value={pin()}336 disabled={!props.ready}337 ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}338 onInput={(event) => setPin((event.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}339 onKeyDown={(event) => {340 if (event.key !== "Enter" || !props.ready) return;341 void handlePinUnlock();342 }}343 />344 <button345 class="peach-lock-recover-link"346 type="button"347 onClick={() => { setUsingPin(false); setError(""); }}348 >349 use master password350 </button>351 </Show>352353 <Show when={error()}>354 <p class="peach-hint peach-hint-error">{error()}</p>355 </Show>356357 <button358 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"359 onClick={() => void (usingPin() ? handlePinUnlock() : handleUnlock())}360 disabled={!props.ready || loading()}361 >362 {loading() ? "unlocking..." : usingPin() ? "unlock with PIN" : "unlock"}363 </button>364365 <Show when={!usingPin() && pinAvailable()}>366 <button367 class="peach-lock-recover-link"368 type="button"369 onClick={() => { setUsingPin(true); setError(""); }}370 >371 use PIN372 </button>373 </Show>374375 <button376 class="peach-lock-recover-link"377 type="button"378 onClick={() => setRecoverSurface("words")}379 >380 forgot your password?381 </button>382 </section>383 </section>384 </Match>385386 {}387 <Match when={true}>388 <section389 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"390 data-motion-phase="enter"391 >392 <header class="peach-screen-header peach-newvault-header">393 <div class="peach-wordmark-stack">394 <PeachWordmark class="peach-wordmark-wrap" />395 <Show when={props.isPro}>396 <span class="peach-pro-mini-badge">pro</span>397 </Show>398 </div>399 <Show when={props.onBackToOnboarding}>400 <button401 class="peach-newvault-back"402 type="button"403 onClick={() => props.onBackToOnboarding?.()}404 aria-label="Back to onboarding"405 >406 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">407 <path d="M7 2L3 5.5L7 9" />408 </svg>409 back410 </button>411 </Show>412 </header>413414 <div class="peach-newvault-mark" aria-hidden="true">415 <PeachMark class="peach-newvault-mark-art" />416 </div>417418 <div class="peach-newvault-card">419 <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>420 <hr class="peach-newvault-divider" />421422 <div class="peach-newvault-field">423 <div class="peach-newvault-label-row">424 <label class="peach-label" for="master-password">425 master password426 </label>427 <button428 class="peach-lock-reveal-btn"429 type="button"430 onClick={() => setShowPassword((value) => !value)}431 aria-label={showPassword() ? "Hide password" : "Show password"}432 >433 <Show434 when={showPassword()}435 fallback={436 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">437 <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" />438 <circle cx="7" cy="5" r="1.75" />439 </svg>440 }441 >442 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">443 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />444 <path d="M3.5 5.5L2.5 7.5" />445 <path d="M7 6.5V8.5" />446 <path d="M10.5 5.5L11.5 7.5" />447 </svg>448 </Show>449 {showPassword() ? "hide" : "show"}450 </button>451 </div>452 <input453 id="master-password"454 class="peach-underline-input peach-newvault-input lock-password-input"455 type={showPassword() ? "text" : "password"}456 placeholder="••••••••••••"457 autocomplete="new-password"458 value={password()}459 disabled={!props.ready}460 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}461 onKeyDown={(event) => {462 if (event.key !== "Enter" || !props.ready) return;463 void handleCreate();464 }}465 />466 <Show467 when={password().length > 0 && passwordStrength() !== null}468 fallback={469 <p class="peach-newvault-hint">470 At least 12 characters.471 </p>472 }473 >474 <div class="peach-password-strength">475 <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>476 <div class="peach-strength-fill"></div>477 </div>478 <span class="peach-strength-label">{passwordStrength()}</span>479 </div>480 </Show>481 </div>482483 <div class="peach-newvault-field">484 <label class="peach-label" for="confirm-password">485 confirm password486 </label>487 <input488 id="confirm-password"489 class="peach-underline-input peach-newvault-input"490 type={showPassword() ? "text" : "password"}491 placeholder="••••••••••••"492 autocomplete="new-password"493 value={confirmPassword()}494 disabled={!props.ready}495 onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}496 onKeyDown={(event) => {497 if (event.key === "Enter" && props.ready) void handleCreate();498 }}499 />500 </div>501502 <Show when={error()}>503 <p class="peach-hint peach-hint-error">{error()}</p>504 </Show>505506 <button507 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"508 onClick={() => void handleCreate()}509 disabled={!props.ready || loading() || !canCreate()}510 aria-live="polite"511 >512 <Show513 when={!canCreate() && createBlockerMessage()}514 fallback={loading() ? "creating..." : "create vault"}515 >516 {createBlockerMessage()}517 </Show>518 </button>519520 <p class="peach-newvault-warning">521 <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>522 <span>Save your 12 recovery words after creating — they're the only way back in if you forget your password.</span>523 </p>524 </div>525 </section>526 </Match>527 </Switch>528 );529}530531532533export type RestoreSource = "symbols" | "codex" | "csv";534535const RESTORE_SOURCES: { id: RestoreSource; name: string; detail: string }[] = [536 { id: "symbols", name: "PeachScript", detail: "Paste the rune string, or open a .peach file." },537 { id: "codex", name: "Codex", detail: "Scan printed pages, or import the PDF." },538 { id: "csv", name: "CSV", detail: "A .csv or .json file from another password manager." },539];540541const RESTORE_DEEP_LINKS: Record<"symbols" | "csv", "peachscript" | "csv"> = {542 symbols: "peachscript",543 csv: "csv",544};545546547548549550551552553554async function detachForFilePicker(route: "peachscript" | "csv"): Promise<boolean> {555 try {556 557 if (new URLSearchParams(window.location.search).get("restore")) return false;558 } catch { }559 if (typeof chrome === "undefined" || typeof chrome.runtime?.sendMessage !== "function") {560 return false;561 }562 try {563 const res = (await sendMessage({564 type: "OPEN_DETACHED_POPUP",565 payload: { route },566 })) as { ok: boolean; data?: { detached?: boolean } };567 if (!res?.data?.detached) return false;568 569 570 return true;571 } catch {572 return false;573 }574}575576577578function restoreDeepLinkActive(link: "peachscript" | "csv"): boolean {579 try {580 return new URLSearchParams(window.location.search).get("restore") === link;581 } catch {582 return false;583 }584}585586function RestoreView(props: {587 hasVault: boolean;588 onOpenCodexRestore?: () => void;589 onBack: () => void;590 onUnlock: () => void | Promise<void>;591 initialSource?: RestoreSource;592}) {593 const [source, setSource] = createSignal<RestoreSource | null>(props.initialSource ?? null);594 const [showIntro, setShowIntro] = createSignal(false);595 const [pendingUnlock] = createSignal(false);596597 598 599 600 601 function chooseSource(next: RestoreSource | null) {602 setSource(next);603 if (next) void storageSet(RESTORE_SOURCE_STORAGE_KEY, next).catch(() => {});604 else void storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {});605 }606607 async function handleRestoreSuccess() {608 const seen = await storageGet("peach_shown_restore_intro").catch(() => null);609 if (!seen) {610 setShowIntro(true);611 } else {612 await props.onUnlock();613 }614 }615616 async function dismissIntro() {617 await storageSet("peach_shown_restore_intro", "true").catch(() => {});618 setShowIntro(false);619 await props.onUnlock();620 }621622 return (623 <section624 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault peach-restore"625 data-motion-phase="enter"626 >627 <RuneBackground opacity={0.05} />628629 <header class="peach-screen-header peach-newvault-header">630 <PeachWordmark class="peach-wordmark-wrap" />631 <button632 class="peach-newvault-back peach-restore-back"633 type="button"634 onClick={props.onBack}635 aria-label="Back to onboarding"636 >637 <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">638 <path d="M7 2L3 5.5L7 9" />639 </svg>640 back641 </button>642 </header>643644 <div class="peach-newvault-card peach-restore-card">645 <Show646 when={source()}647 keyed648 fallback={649 <>650 <h1 class="peach-newvault-headline">{"welcome\nback."}</h1>651 <hr class="peach-newvault-divider" />652 <div class="peach-restore-sources">653 <For each={RESTORE_SOURCES}>654 {(entry) => (655 <button656 type="button"657 class="peach-onboarding-choice peach-restore-source"658 onClick={() => chooseSource(entry.id)}659 >660 <span class="peach-onboarding-choice-label">{entry.name}</span>661 <span class="peach-onboarding-choice-detail">{entry.detail}</span>662 </button>663 )}664 </For>665 </div>666 </>667 }668 >669 {(active) => (670 <>671 <div class="peach-restore-source-head">672 <h2 class="peach-restore-source-title">673 {RESTORE_SOURCES.find((entry) => entry.id === active)?.name}674 </h2>675 <button676 type="button"677 class="peach-restore-picker-back"678 onClick={() => chooseSource(null)}679 >680 ‹ sources681 </button>682 </div>683 <hr class="peach-newvault-divider" />684 <Show when={active === "symbols"}>685 <RestoreSymbols hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />686 </Show>687 <Show when={active === "codex"}>688 <RestoreCodex onOpen={() => props.onOpenCodexRestore?.()} />689 </Show>690 <Show when={active === "csv"}>691 <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />692 </Show>693 </>694 )}695 </Show>696 </div>697698 {}699 <Show when={showIntro()}>700 <div class="peach-restore-intro-overlay">701 <div class="peach-restore-intro-card">702 <p class="peach-restore-intro-headline">welcome to peach.</p>703 <div class="peach-restore-intro-body">704 <p>Your vault lives only on this device. No server has a copy.</p>705 <p>The inline bubble fills passwords automatically when you visit sites.</p>706 <p>Export your vault from Settings before switching devices.</p>707 </div>708 <button709 class="peach-btn peach-btn--accent peach-btn--md peach-restore-intro-btn"710 onClick={() => void dismissIntro()}711 disabled={pendingUnlock()}712 >713 got it714 </button>715 </div>716 </div>717 </Show>718 </section>719 );720}721722723724function RestoreCodex(props: { onOpen: () => void }) {725 return (726 <div class="peach-restore-form">727 <p class="peach-restore-codex-copy">728 Scan printed Codex pages or import the Codex PDF. Codex restore is729 always available, even before you create a vault.730 </p>731 <button732 type="button"733 class="peach-btn peach-btn--accent peach-btn--md peach-restore-submit"734 onClick={props.onOpen}735 >736 open Codex restore737 </button>738 </div>739 );740}741742743744function RestoreSymbols(props: {745 hasVault: boolean;746 onSuccess: () => void | Promise<void>;747}) {748 const [scriptInput, setScriptInput] = createSignal("");749 const [backupPass, setBackupPass] = createSignal("");750 const [working, setWorking] = createSignal(false);751 const [animating, setAnimating] = createSignal<"chaos" | "snap" | "bloom" | null>(null);752 const [error, setError] = createSignal<string | null>(null);753 let canvasRef: HTMLCanvasElement | undefined;754 let peachFileRef: HTMLInputElement | undefined;755 let animFrameId = 0;756 let runes: { x: number; y: number; vx: number; vy: number; char: string; size: number; angle: number; va: number }[] = [];757758 const scriptValid = createMemo(() => isPeachScript(scriptInput()));759760 function stopAnim() {761 cancelAnimationFrame(animFrameId);762 animFrameId = 0;763 }764765 function startChaos(canvas: HTMLCanvasElement) {766 const ctx = canvas.getContext("2d")!;767 const W = canvas.width;768 const H = canvas.height;769 const RUNE_CHARS = Array.from({ length: 40 }, (_, i) =>770 String.fromCodePoint(0xe000 + i)771 );772 if (runes.length === 0) {773 runes = Array.from({ length: 60 }, () => ({774 x: Math.random() * W,775 y: Math.random() * H,776 vx: (Math.random() - 0.5) * 3,777 vy: (Math.random() - 0.5) * 3,778 char: RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]!,779 size: 14 + Math.random() * 20,780 angle: Math.random() * Math.PI * 2,781 va: (Math.random() - 0.5) * 0.1,782 }));783 }784785 function draw() {786 ctx.clearRect(0, 0, W, H);787 for (const r of runes) {788 r.x = (r.x + r.vx + W) % W;789 r.y = (r.y + r.vy + H) % H;790 r.angle += r.va;791 ctx.save();792 ctx.translate(r.x, r.y);793 ctx.rotate(r.angle);794 ctx.font = `${r.size}px PeachScript, monospace`;795 const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 400 + r.x);796 ctx.fillStyle = `rgba(232, 149, 109, ${alpha})`;797 ctx.shadowColor = "rgba(232, 149, 109, 0.7)";798 ctx.shadowBlur = 8;799 ctx.fillText(r.char, 0, 0);800 ctx.restore();801 }802 animFrameId = requestAnimationFrame(draw);803 }804 draw();805 }806807 function snapToGrid(canvas: HTMLCanvasElement, onDone: () => void) {808 stopAnim();809 const ctx = canvas.getContext("2d")!;810 const W = canvas.width;811 const H = canvas.height;812 const cols = 8;813 const rows = 6;814 const cellW = W / cols;815 const cellH = H / rows;816 const targets = runes.slice(0, cols * rows).map((_, i) => ({817 x: (i % cols) * cellW + cellW / 2,818 y: Math.floor(i / cols) * cellH + cellH / 2,819 }));820 const start = performance.now();821 const DURATION = 400;822823 function drawSnap(now: number) {824 const t = Math.min((now - start) / DURATION, 1);825 const ease = t < 1 ? 1 - (1 - t) ** 3 : 1;826 ctx.clearRect(0, 0, W, H);827 runes.slice(0, targets.length).forEach((r, i) => {828 const tx = targets[i]!.x;829 const ty = targets[i]!.y;830 const cx = r.x + (tx - r.x) * ease;831 const cy = r.y + (ty - r.y) * ease;832 ctx.font = `16px PeachScript, monospace`;833 ctx.fillStyle = `rgba(232, 149, 109, ${0.8 * ease})`;834 ctx.shadowColor = "rgba(232, 149, 109, 0.6)";835 ctx.shadowBlur = 6;836 ctx.fillText(r.char, cx - 8, cy + 8);837 });838 if (t < 1) {839 animFrameId = requestAnimationFrame(drawSnap);840 } else {841 onDone();842 }843 }844 animFrameId = requestAnimationFrame(drawSnap);845 }846847 function bloom(canvas: HTMLCanvasElement, onDone: () => void) {848 stopAnim();849 const ctx = canvas.getContext("2d")!;850 const W = canvas.width;851 const H = canvas.height;852 const start = performance.now();853 const DURATION = 900;854855 function drawBloom(now: number) {856 const t = Math.min((now - start) / DURATION, 1);857 ctx.clearRect(0, 0, W, H);858 859 const runeAlpha = 1 - Math.min(t / 0.4, 1);860 if (runeAlpha > 0) {861 for (const r of runes.slice(0, 48)) {862 ctx.font = `16px PeachScript, monospace`;863 ctx.fillStyle = `rgba(232, 149, 109, ${runeAlpha * 0.8})`;864 ctx.fillText(r.char, r.x - 8, r.y + 8);865 }866 }867 868 const markT = Math.max(0, (t - 0.2) / 0.5);869 const markScale = Math.min(markT * 1.2, 1);870 const markAlpha = markT < 0.5 ? markT * 2 : 2 - markT * 2;871 if (markAlpha > 0) {872 ctx.save();873 ctx.globalAlpha = markAlpha;874 ctx.shadowColor = `rgba(217, 104, 48, ${markAlpha * 0.8})`;875 ctx.shadowBlur = 30;876 ctx.translate(W / 2, H / 2);877 ctx.scale(markScale, markScale);878 ctx.drawImage(peachMarkImg, -32, -32, 64, 64);879 ctx.restore();880 }881 if (t < 1) {882 animFrameId = requestAnimationFrame(drawBloom);883 } else {884 onDone();885 }886 }887 animFrameId = requestAnimationFrame(drawBloom);888 }889890 onCleanup(() => stopAnim());891892 893 894 onMount(() => {895 if (restoreDeepLinkActive(RESTORE_DEEP_LINKS.symbols)) {896 setTimeout(() => peachFileRef?.click(), 50);897 }898 });899900 901 902 const [dragDepth, setDragDepth] = createSignal(0);903904 async function handleDrop(e: DragEvent) {905 e.preventDefault();906 setDragDepth(0);907 if (working() || animating() !== null) return;908 const file = e.dataTransfer?.files?.[0];909 if (!file) return;910 try {911 setScriptInput(await file.text());912 setError(null);913 } catch {914 setError("Couldn't read file.");915 }916 }917918 async function handleRestore() {919 if (!scriptValid() || !backupPass().trim() || working()) return;920 setError(null);921 setWorking(true);922 setAnimating("chaos");923924 const canvas = canvasRef;925 if (canvas) startChaos(canvas);926927 try {928 const bytes = decodePeachScript(normalisePeachScript(scriptInput()));929 const payload = await parsePayload(bytes, backupPass());930931 if (!props.hasVault) {932 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });933 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");934 }935936 const rr = await sendMessage({937 type: "RESTORE_ENTRIES",938 payload: { entries: payload.entries, mode: "merge", settings: payload.settings },939 });940 if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");941942 if (payload.license?.key) {943 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});944 }945946 setAnimating("snap");947 stopAnim();948 if (canvas) {949 snapToGrid(canvas, () => {950 setAnimating("bloom");951 bloom(canvas, async () => {952 setAnimating(null);953 await props.onSuccess();954 });955 });956 } else {957 await props.onSuccess();958 }959 } catch (err) {960 stopAnim();961 setAnimating(null);962 const code = err instanceof Error ? err.message : String(err);963 setError(964 code === "INVALID_PASSPHRASE"965 ? "That passphrase didn't work."966 : code === "CREATE_VAULT_FAILED"967 ? "Couldn't create a new local vault."968 : "Could not read this backup.",969 );970 } finally {971 setWorking(false);972 }973 }974975 return (976 <div977 class="peach-restore-form"978 classList={{ "is-dragover": dragDepth() > 0 }}979 onDragEnter={(e) => { e.preventDefault(); setDragDepth((d) => d + 1); }}980 onDragOver={(e) => e.preventDefault()}981 onDragLeave={() => setDragDepth((d) => Math.max(0, d - 1))}982 onDrop={(e) => void handleDrop(e)}983 >984 <Show when={animating() !== null}>985 <canvas986 ref={(el) => { canvasRef = el; }}987 class="peach-restore-anim-canvas"988 width={280}989 height={200}990 />991 </Show>992 <Show when={animating() === null}>993 <Show994 when={scriptValid()}995 fallback={996 <>997 <textarea998 class="peach-restore-script"999 placeholder="paste peachscript…"1000 rows={3}1001 aria-label="Paste your PeachScript backup"1002 value={scriptInput()}1003 onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}1004 />1005 <div class="peach-restore-or" aria-hidden="true">or</div>1006 <label1007 class="peach-btn peach-btn--ghost peach-btn--sm peach-restore-file-btn"1008 onClick={(e) => {1009 if (restoreDeepLinkActive(RESTORE_DEEP_LINKS.symbols)) return;1010 e.preventDefault();1011 void detachForFilePicker(RESTORE_DEEP_LINKS.symbols).then((detached) => {1012 if (!detached) peachFileRef?.click();1013 });1014 }}1015 >1016 open .peach file1017 <input1018 ref={(el) => { peachFileRef = el; }}1019 type="file"1020 accept=".peach,text/plain"1021 style="display:none"1022 onChange={async (e) => {1023 const file = (e.currentTarget as HTMLInputElement).files?.[0];1024 if (!file) return;1025 try {1026 setScriptInput(await file.text());1027 } catch {1028 setError("Couldn't read file.");1029 } finally {1030 (e.currentTarget as HTMLInputElement).value = "";1031 }1032 }}1033 />1034 </label>1035 <p class="peach-restore-drop-hint">or drop the file anywhere here</p>1036 </>1037 }1038 >1039 <div class="peach-restore-rune-preview">1040 <RuneCanvas runeString={scriptInput()} width={256} height={120} />1041 </div>1042 <button1043 type="button"1044 class="peach-restore-picker-back"1045 onClick={() => { setScriptInput(""); setError(null); }}1046 >1047 ‹ paste again1048 </button>1049 </Show>1050 <div class="peach-newvault-field">1051 <label class="peach-label" for="restore-backup-pass">1052 backup password1053 </label>1054 <input1055 id="restore-backup-pass"1056 class="peach-underline-input peach-newvault-input"1057 type="password"1058 placeholder="••••••••••••"1059 autocomplete="current-password"1060 value={backupPass()}1061 onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}1062 onKeyDown={(e) => {1063 if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {1064 e.preventDefault();1065 void handleRestore();1066 }1067 }}1068 />1069 </div>1070 <button1071 class="peach-btn peach-btn--accent peach-btn--md peach-restore-submit"1072 onClick={() => void handleRestore()}1073 disabled={!scriptValid() || !backupPass().trim() || working()}1074 >1075 restore vault1076 </button>1077 <Show when={error()}>1078 <p class="peach-hint peach-hint-error">{error()}</p>1079 </Show>1080 </Show>1081 </div>1082 );1083}1084108510861087function RestoreCSV(props: {1088 hasVault: boolean;1089 onSuccess: () => void | Promise<void>;1090}) {1091 const [importing, setImporting] = createSignal(false);1092 const [error, setError] = createSignal<string | null>(null);1093 1094 1095 1096 1097 const CSV_MIN_PASSWORD_LENGTH = 12;1098 const [newPass, setNewPass] = createSignal("");1099 const [confirmPass, setConfirmPass] = createSignal("");1100 const needsPassword = () => props.hasVault === false;1101 const passwordReady = () =>1102 !needsPassword() ||1103 (newPass().length >= CSV_MIN_PASSWORD_LENGTH && newPass() === confirmPass());1104 1105 1106 const [dragDepth, setDragDepth] = createSignal(0);11071108 1109 1110 1111 async function processFile(file: File) {1112 setImporting(true);1113 setError(null);1114 try {1115 const text = await file.text();1116 const parsed = await parseImportSource(text);1117 if (parsed.entries.length === 0) throw new Error("NO_ENTRIES");11181119 if (props.hasVault === false) {1120 1121 1122 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: newPass() } });1123 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");1124 }11251126 const res = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: parsed.entries } });1127 if (!(res as { ok: boolean }).ok) throw new Error("IMPORT_FAILED");1128 await props.onSuccess();1129 } catch (err) {1130 const code = err instanceof Error ? err.message : String(err);1131 setError(1132 code === "NO_ENTRIES" ? "No entries found in this file." :1133 code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." :1134 "Import failed. Check the file format and try again."1135 );1136 } finally {1137 setImporting(false);1138 }1139 }11401141 let csvFileRef: HTMLInputElement | undefined;11421143 onMount(() => {1144 1145 1146 1147 if (restoreDeepLinkActive(RESTORE_DEEP_LINKS.csv) && !needsPassword()) {1148 setTimeout(() => csvFileRef?.click(), 50);1149 }1150 });11511152 function gatePassword(): boolean {1153 if (importing()) return false;1154 if (needsPassword() && newPass().length < CSV_MIN_PASSWORD_LENGTH) {1155 setError(`Set a master password of at least ${CSV_MIN_PASSWORD_LENGTH} characters first.`);1156 return false;1157 }1158 if (needsPassword() && newPass() !== confirmPass()) {1159 setError("Passwords do not match.");1160 return false;1161 }1162 return true;1163 }11641165 async function handleFile(e: Event) {1166 const input = e.currentTarget as HTMLInputElement;1167 const file = input.files?.[0];1168 input.value = "";1169 if (!file) return;1170 await processFile(file);1171 }11721173 async function handleDrop(e: DragEvent) {1174 e.preventDefault();1175 setDragDepth(0);1176 if (!gatePassword()) return;1177 const file = e.dataTransfer?.files?.[0];1178 if (!file) return;1179 await processFile(file);1180 }11811182 return (1183 <div1184 class="peach-restore-form"1185 classList={{ "is-dragover": dragDepth() > 0 }}1186 onDragEnter={(e) => { e.preventDefault(); setDragDepth((d) => d + 1); }}1187 onDragOver={(e) => e.preventDefault()}1188 onDragLeave={() => setDragDepth((d) => Math.max(0, d - 1))}1189 onDrop={(e) => void handleDrop(e)}1190 >1191 <Show when={needsPassword()}>1192 <div class="peach-newvault-field">1193 <label class="peach-label" for="restore-csv-pass">1194 new master password1195 </label>1196 <input1197 id="restore-csv-pass"1198 class="peach-underline-input peach-newvault-input"1199 type="password"1200 placeholder="••••••••••••"1201 autocomplete="new-password"1202 value={newPass()}1203 onInput={(e) => setNewPass((e.target as HTMLInputElement).value)}1204 />1205 </div>1206 <div class="peach-newvault-field">1207 <label class="peach-label" for="restore-csv-confirm">1208 confirm master password1209 </label>1210 <input1211 id="restore-csv-confirm"1212 class="peach-underline-input peach-newvault-input"1213 type="password"1214 placeholder="••••••••••••"1215 autocomplete="new-password"1216 value={confirmPass()}1217 onInput={(e) => setConfirmPass((e.target as HTMLInputElement).value)}1218 />1219 </div>1220 </Show>1221 <label1222 class="peach-btn peach-btn--accent peach-btn--md peach-restore-file-btn"1223 onClick={(e) => {1224 1225 1226 1227 if (restoreDeepLinkActive(RESTORE_DEEP_LINKS.csv)) return;1228 1229 1230 e.preventDefault();1231 if (!gatePassword()) return;1232 void detachForFilePicker(RESTORE_DEEP_LINKS.csv).then((detached) => {1233 if (!detached) csvFileRef?.click();1234 });1235 }}1236 >1237 {importing() ? "importing…" : "open csv file"}1238 <input1239 ref={(el) => { csvFileRef = el; }}1240 type="file"1241 accept=".csv,.json,.peach,application/x-peach"1242 style="display:none"1243 onChange={handleFile}1244 disabled={importing() || !passwordReady()}1245 />1246 </label>1247 <p class="peach-restore-drop-hint">or drop the file anywhere here</p>1248 <p class="peach-hint peach-restore-csv-sources">1249 Imports from Bitwarden, 1Password, Proton Pass,1250 <br />1251 Chrome, Safari, and Edge.1252 </p>1253 <p class="peach-newvault-warning">1254 <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>1255 <span>Plain-text file — delete it after the import.</span>1256 </p>1257 <Show when={error()}>1258 <p class="peach-hint peach-hint-error">{error()}</p>1259 </Show>1260 </div>1261 );1262}1263126412651266function getDisplayError(error: string | undefined, fallback: string): string {1267 if (!error) return fallback;1268 if (1269 error === "SERVICE_WORKER_UNAVAILABLE" ||1270 error.includes("Receiving end does not exist") ||1271 error.includes("message port closed")1272 ) {1273 return "background service unavailable";1274 }1275 1276 return fallback;1277}