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";12import { isNativePlatform, nativePlugin } from "../../shared/native";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 [biometricAvailable, setBiometricAvailable] = createSignal(false);96 const [biometricBusy, setBiometricBusy] = createSignal(false);97 const [pinAvailable, setPinAvailable] = createSignal(false);98 const [usingPin, setUsingPin] = createSignal(false);99 const [pin, setPin] = createSignal("");100101 const canCreate = createMemo(102 () =>103 password().length >= MIN_LENGTH &&104 confirmPassword().length >= MIN_LENGTH &&105 password() === confirmPassword() &&106 strengthScore() !== null &&107 strengthScore()! >= MIN_ZXCVBN_SCORE108 );109110 const passwordStrength = createMemo(() => {111 const score = strengthScore();112 if (score === null) return null;113 if (score <= 1) return "weak";114 if (score === 2) return "fair";115 if (score === 3) return "good";116 return "strong";117 });118119 const createBlockerMessage = createMemo(() => {120 if (!password()) return null;121 if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;122 if (!confirmPassword()) return "confirm your password";123 if (password() !== confirmPassword()) return "passwords don't match";124 if (strengthScore() === null) return "checking strength…";125 if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak — use a longer passphrase";126 return null;127 });128129 130 createMemo(() => {131 const value = password();132 if (!value) { setStrengthScore(null); return; }133 getZxcvbn().then((zxcvbn) => {134 if (password() === value) setStrengthScore(zxcvbn(value).score);135 }).catch(() => {136 137 if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1);138 });139 });140141 async function handleUnlock() {142 if (!password().trim()) return;143 setLoading(true);144 setError("");145 const response = await sendMessage({146 type: "UNLOCK",147 payload: { password: password() },148 });149 const result = response as { ok: boolean; error?: string };150 if (result.ok) {151 await props.onUnlock();152 setLoading(false);153 return;154 }155 setError(getDisplayError(result.error, "incorrect password"));156 setPassword("");157 const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined;158 passwordField?.classList.add("shake");159 setTimeout(() => passwordField?.classList.remove("shake"), 300);160 setLoading(false);161 }162163 async function handlePinUnlock() {164 if (!/^\d{4,12}$/.test(pin())) return;165 setLoading(true);166 setError("");167 const response = await sendMessage({168 type: "UNLOCK_WITH_PIN",169 payload: { pin: pin() },170 });171 const result = response as { ok: boolean; error?: string };172 if (result.ok) {173 await props.onUnlock();174 setLoading(false);175 return;176 }177 setError(getDisplayError(result.error, "incorrect PIN"));178 setPin("");179 const pinField = document.querySelector(".lock-pin-input") as HTMLInputElement | undefined;180 pinField?.classList.add("shake");181 setTimeout(() => pinField?.classList.remove("shake"), 300);182 setLoading(false);183 }184185 async function checkPinAvailable(): Promise<void> {186 if (!props.hasVault) return;187 try {188 const response = await sendMessage({ type: "GET_SETTINGS" });189 const settings = (response as { ok: boolean; data?: { pinEnabled?: boolean; pinPreferred?: boolean } }).data;190 const enabled = settings?.pinEnabled === true;191 setPinAvailable(enabled);192 if (enabled && settings?.pinPreferred === true) setUsingPin(true);193 } catch { }194 }195196 async function checkBiometricAvailable(): Promise<void> {197 if (!props.hasVault || !isNativePlatform()) return;198 const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");199 const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");200 if (!keystore || !biometric) return;201 try {202 const stored = await keystore.hasMasterKey();203 if (!stored.exists) return;204 const avail = await biometric.isAvailable();205 if (!avail.available) return;206 setBiometricAvailable(true);207 window.setTimeout(() => { void handleBiometricUnlock(); }, 400);208 } catch { }209 }210211 async function handleBiometricUnlock(): Promise<void> {212 if (biometricBusy()) return;213 const biometric = nativePlugin<{ authenticate: (opts: Record<string, string>) => Promise<void> }>("BiometricPlugin");214 const keystore = nativePlugin<{ retrieveMasterKey: () => Promise<{ masterKeyHex: string }> }>("KeystorePlugin");215 if (!biometric || !keystore) return;216 setBiometricBusy(true);217 setError("");218 try {219 await biometric.authenticate({220 title: "Unlock Peach",221 subtitle: "Use biometrics to access your vault",222 negativeButtonText: "Use Password",223 });224 const { masterKeyHex } = await keystore.retrieveMasterKey();225 const res = (await sendMessage({226 type: "UNLOCK_WITH_KEY",227 payload: { masterKeyHex },228 })) as { ok: boolean; error?: string };229 if (res.ok) {230 await props.onUnlock();231 return;232 }233 if (res.error === "biometric_key_invalidated") {234 await sendMessage({235 type: "UPDATE_SETTINGS",236 payload: { biometricsEnabled: false },237 });238 setBiometricAvailable(false);239 setError("biometric key expired — re-enable in settings");240 } else {241 setError("biometric unlock failed");242 }243 } catch (e) {244 const reason = e instanceof Error ? e.message : String(e);245 if (reason.includes("user_cancelled")) return;246 if (reason.includes("biometric_key_invalidated")) {247 await sendMessage({248 type: "UPDATE_SETTINGS",249 payload: { biometricsEnabled: false },250 });251 setBiometricAvailable(false);252 setError("biometric key expired — re-enable in settings");253 return;254 }255 if (reason.includes("biometric_lockout")) {256 setError("biometric locked out — use your password");257 }258 } finally {259 setBiometricBusy(false);260 }261 }262263 onMount(() => {264 void checkBiometricAvailable();265 void checkPinAvailable();266 });267268 async function handleCreate() {269 if (!password().trim()) return;270 if (password().length < MIN_LENGTH) {271 setError(`password must be at least ${MIN_LENGTH} characters`);272 return;273 }274 if (password() !== confirmPassword()) {275 setError("passwords do not match");276 return;277 }278 const score = strengthScore();279 if (score === null || score < MIN_ZXCVBN_SCORE) {280 setError("password is too weak — use a longer passphrase");281 return;282 }283 setLoading(true);284 setError("");285 const response = await sendMessage({286 type: "CREATE_VAULT",287 payload: { password: password() },288 });289 const result = response as { ok: boolean; error?: string };290 if (result.ok) {291 292 293 294 295 296 try {297 await chrome.storage.local?.set({ peach_recovery_pending: "1" });298 } catch { }299 setShowRecoveryWords(true);300 setLoading(false);301 return;302 }303 setError(getDisplayError(result.error, "failed to create vault"));304 setLoading(false);305 }306307 308 return (309 <Switch>310 {}311 <Match when={restoreSurface() === "restore"}>312 <RestoreView313 hasVault={props.hasVault}314 onOpenCodexRestore={props.onOpenCodexRestore}315 initialSource={props.initialRestoreSource}316 onBack={() => {317 318 319 if (props.onBackToOnboarding) props.onBackToOnboarding();320 else setRestoreSurface(null);321 }}322 onUnlock={async () => {323 setRestoreSurface(null);324 await props.onUnlock();325 }}326 />327 </Match>328329 {}330 {331332}333 <Match334 when={335 (showRecoveryWords() || props.forceRecoveryWords) &&336 (!props.hasVault || props.forceRecoveryWords)337 }338 >339 <RecoveryWordDisplayView340 onDone={props.onUnlock}341 />342 </Match>343344 {}345 <Match when={recoverSurface() === "words"}>346 <RecoveryWordEntryView347 onCancel={() => setRecoverSurface(null)}348 onRecoveryComplete={props.onUnlock}349 />350 </Match>351352 {}353 <Match when={props.hasVault}>354 <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">355 <RuneBackground opacity={0.07} />356357 <header class="peach-screen-header lock-screen-header">358 <div class="peach-wordmark-stack">359 <PeachWordmark class="peach-wordmark-wrap" />360 <Show when={props.isPro}>361 <span class="peach-pro-mini-badge">pro</span>362 </Show>363 </div>364 </header>365366 <div class="peach-lock-hero peach-lock-hero--unlock">367 <PeachMark class="peach-lock-mark" />368 <span class="peach-lock-eyebrow">vault locked</span>369 <h1 class="peach-onboarding-headline peach-lock-headline--unlock">370 unlock371 <br />372 your vault.373 </h1>374 </div>375376 <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">377 <Show when={usingPin()} fallback={378 <>379 <label class="peach-label" for="unlock-password">380 master password381 </label>382 <input383 id="unlock-password"384 class="peach-underline-input peach-unlock-input lock-password-input"385 type="password"386 placeholder="••••••••••••"387 autocomplete="current-password"388 value={password()}389 disabled={!props.ready}390 ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}391 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}392 onKeyDown={(event) => {393 if (event.key !== "Enter" || !props.ready) return;394 void handleUnlock();395 }}396 />397 </>398 }>399 <label class="peach-label" for="unlock-pin">400 PIN401 </label>402 <input403 id="unlock-pin"404 class="peach-underline-input peach-unlock-input lock-pin-input"405 type="password"406 inputmode="numeric"407 maxlength="12"408 placeholder="••••••••••••"409 autocomplete="off"410 value={pin()}411 disabled={!props.ready}412 ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}413 onInput={(event) => setPin((event.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}414 onKeyDown={(event) => {415 if (event.key !== "Enter" || !props.ready) return;416 void handlePinUnlock();417 }}418 />419 <button420 class="peach-lock-recover-link"421 type="button"422 onClick={() => { setUsingPin(false); setError(""); }}423 >424 use master password425 </button>426 </Show>427428 <Show when={error()}>429 <p class="peach-hint peach-hint-error">{error()}</p>430 </Show>431432 <button433 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"434 onClick={() => void (usingPin() ? handlePinUnlock() : handleUnlock())}435 disabled={!props.ready || loading()}436 >437 {loading() ? "unlocking..." : usingPin() ? "unlock with PIN" : "unlock"}438 </button>439440 <Show when={!usingPin() && pinAvailable()}>441 <button442 class="peach-lock-recover-link"443 type="button"444 onClick={() => { setUsingPin(true); setError(""); }}445 >446 use PIN447 </button>448 </Show>449450 <Show when={biometricAvailable()}>451 <button452 class="peach-biometric-btn"453 type="button"454 onClick={() => void handleBiometricUnlock()}455 disabled={biometricBusy() || loading()}456 aria-label="Unlock with biometrics"457 >458 <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">459 <path d="M12 11c-1.7 0-3 1.3-3 3v3"/>460 <path d="M15 17v-3"/>461 <path d="M9 4.5C9.7 4.2 10.8 4 12 4s2.3.2 3 .5"/>462 <path d="M5 9c.7-1.6 2-2.9 3.6-3.6"/>463 <path d="M19 9c-.7-1.6-2-2.9-3.6-3.6"/>464 <path d="M5 15c0-1.7.3-3.3.8-4.7"/>465 <path d="M19 15c0-1.7-.3-3.3-.8-4.7"/>466 </svg>467 {biometricBusy() ? "verifying…" : "use biometrics"}468 </button>469 </Show>470471 <button472 class="peach-lock-recover-link"473 type="button"474 onClick={() => setRecoverSurface("words")}475 >476 forgot your password?477 </button>478 </section>479 </section>480 </Match>481482 {}483 <Match when={true}>484 <section485 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"486 data-motion-phase="enter"487 >488 <header class="peach-screen-header peach-newvault-header">489 <div class="peach-wordmark-stack">490 <PeachWordmark class="peach-wordmark-wrap" />491 <Show when={props.isPro}>492 <span class="peach-pro-mini-badge">pro</span>493 </Show>494 </div>495 <Show when={props.onBackToOnboarding}>496 <button497 class="peach-newvault-back"498 type="button"499 onClick={() => props.onBackToOnboarding?.()}500 aria-label="Back to onboarding"501 >502 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">503 <path d="M7 2L3 5.5L7 9" />504 </svg>505 back506 </button>507 </Show>508 </header>509510 <div class="peach-newvault-mark" aria-hidden="true">511 <PeachMark class="peach-newvault-mark-art" />512 </div>513514 <div class="peach-newvault-card">515 <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>516 <hr class="peach-newvault-divider" />517518 <div class="peach-newvault-field">519 <div class="peach-newvault-label-row">520 <label class="peach-label" for="master-password">521 master password522 </label>523 <button524 class="peach-lock-reveal-btn"525 type="button"526 onClick={() => setShowPassword((value) => !value)}527 aria-label={showPassword() ? "Hide password" : "Show password"}528 >529 <Show530 when={showPassword()}531 fallback={532 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">533 <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" />534 <circle cx="7" cy="5" r="1.75" />535 </svg>536 }537 >538 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">539 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />540 <path d="M3.5 5.5L2.5 7.5" />541 <path d="M7 6.5V8.5" />542 <path d="M10.5 5.5L11.5 7.5" />543 </svg>544 </Show>545 {showPassword() ? "hide" : "show"}546 </button>547 </div>548 <input549 id="master-password"550 class="peach-underline-input peach-newvault-input lock-password-input"551 type={showPassword() ? "text" : "password"}552 placeholder="••••••••••••"553 autocomplete="new-password"554 value={password()}555 disabled={!props.ready}556 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}557 onKeyDown={(event) => {558 if (event.key !== "Enter" || !props.ready) return;559 void handleCreate();560 }}561 />562 <Show563 when={password().length > 0 && passwordStrength() !== null}564 fallback={565 <p class="peach-newvault-hint">566 At least 12 characters.567 </p>568 }569 >570 <div class="peach-password-strength">571 <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>572 <div class="peach-strength-fill"></div>573 </div>574 <span class="peach-strength-label">{passwordStrength()}</span>575 </div>576 </Show>577 </div>578579 <div class="peach-newvault-field">580 <label class="peach-label" for="confirm-password">581 confirm password582 </label>583 <input584 id="confirm-password"585 class="peach-underline-input peach-newvault-input"586 type={showPassword() ? "text" : "password"}587 placeholder="••••••••••••"588 autocomplete="new-password"589 value={confirmPassword()}590 disabled={!props.ready}591 onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}592 onKeyDown={(event) => {593 if (event.key === "Enter" && props.ready) void handleCreate();594 }}595 />596 </div>597598 <Show when={error()}>599 <p class="peach-hint peach-hint-error">{error()}</p>600 </Show>601602 <button603 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"604 onClick={() => void handleCreate()}605 disabled={!props.ready || loading() || !canCreate()}606 aria-live="polite"607 >608 <Show609 when={!canCreate() && createBlockerMessage()}610 fallback={loading() ? "creating..." : "create vault"}611 >612 {createBlockerMessage()}613 </Show>614 </button>615616 <p class="peach-newvault-warning">617 <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>618 <span>Save your 12 recovery words after creating — they're the only way back in if you forget your password.</span>619 </p>620 </div>621 </section>622 </Match>623 </Switch>624 );625}626627628629export type RestoreSource = "symbols" | "codex" | "csv";630631const RESTORE_SOURCES: { id: RestoreSource; name: string; detail: string }[] = [632 { id: "symbols", name: "PeachScript", detail: "Paste the rune string, or open a .peach file." },633 { id: "codex", name: "Codex", detail: "Scan printed pages, or import the PDF." },634 { id: "csv", name: "CSV", detail: "A .csv or .json file from another password manager." },635];636637const RESTORE_DEEP_LINKS: Record<"symbols" | "csv", "peachscript" | "csv"> = {638 symbols: "peachscript",639 csv: "csv",640};641642643644645646647648649650async function detachForFilePicker(route: "peachscript" | "csv"): Promise<boolean> {651 try {652 653 if (new URLSearchParams(window.location.search).get("restore")) return false;654 } catch { }655 if (typeof chrome === "undefined" || typeof chrome.runtime?.sendMessage !== "function") {656 return false;657 }658 try {659 const res = (await sendMessage({660 type: "OPEN_DETACHED_POPUP",661 payload: { route },662 })) as { ok: boolean; data?: { detached?: boolean } };663 if (!res?.data?.detached) return false;664 665 666 return true;667 } catch {668 return false;669 }670}671672673674function restoreDeepLinkActive(link: "peachscript" | "csv"): boolean {675 try {676 return new URLSearchParams(window.location.search).get("restore") === link;677 } catch {678 return false;679 }680}681682function RestoreView(props: {683 hasVault: boolean;684 onOpenCodexRestore?: () => void;685 onBack: () => void;686 onUnlock: () => void | Promise<void>;687 initialSource?: RestoreSource;688}) {689 const [source, setSource] = createSignal<RestoreSource | null>(props.initialSource ?? null);690 const [showIntro, setShowIntro] = createSignal(false);691 const [pendingUnlock] = createSignal(false);692693 694 695 696 697 function chooseSource(next: RestoreSource | null) {698 setSource(next);699 if (next) void storageSet(RESTORE_SOURCE_STORAGE_KEY, next).catch(() => {});700 else void storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {});701 }702703 async function handleRestoreSuccess() {704 const seen = await storageGet("peach_shown_restore_intro").catch(() => null);705 if (!seen) {706 setShowIntro(true);707 } else {708 await props.onUnlock();709 }710 }711712 async function dismissIntro() {713 await storageSet("peach_shown_restore_intro", "true").catch(() => {});714 setShowIntro(false);715 await props.onUnlock();716 }717718 return (719 <section720 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault peach-restore"721 data-motion-phase="enter"722 >723 <RuneBackground opacity={0.05} />724725 <header class="peach-screen-header peach-newvault-header">726 <PeachWordmark class="peach-wordmark-wrap" />727 <button728 class="peach-newvault-back peach-restore-back"729 type="button"730 onClick={props.onBack}731 aria-label="Back to onboarding"732 >733 <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">734 <path d="M7 2L3 5.5L7 9" />735 </svg>736 back737 </button>738 </header>739740 <div class="peach-newvault-card peach-restore-card">741 <Show742 when={source()}743 keyed744 fallback={745 <>746 <h1 class="peach-newvault-headline">{"welcome\nback."}</h1>747 <hr class="peach-newvault-divider" />748 <div class="peach-restore-sources">749 <For each={RESTORE_SOURCES}>750 {(entry) => (751 <button752 type="button"753 class="peach-onboarding-choice peach-restore-source"754 onClick={() => chooseSource(entry.id)}755 >756 <span class="peach-onboarding-choice-label">{entry.name}</span>757 <span class="peach-onboarding-choice-detail">{entry.detail}</span>758 </button>759 )}760 </For>761 </div>762 </>763 }764 >765 {(active) => (766 <>767 <div class="peach-restore-source-head">768 <h2 class="peach-restore-source-title">769 {RESTORE_SOURCES.find((entry) => entry.id === active)?.name}770 </h2>771 <button772 type="button"773 class="peach-restore-picker-back"774 onClick={() => chooseSource(null)}775 >776 ‹ sources777 </button>778 </div>779 <hr class="peach-newvault-divider" />780 <Show when={active === "symbols"}>781 <RestoreSymbols hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />782 </Show>783 <Show when={active === "codex"}>784 <RestoreCodex onOpen={() => props.onOpenCodexRestore?.()} />785 </Show>786 <Show when={active === "csv"}>787 <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />788 </Show>789 </>790 )}791 </Show>792 </div>793794 {}795 <Show when={showIntro()}>796 <div class="peach-restore-intro-overlay">797 <div class="peach-restore-intro-card">798 <p class="peach-restore-intro-headline">welcome to peach.</p>799 <div class="peach-restore-intro-body">800 <p>Your vault lives only on this device. No server has a copy.</p>801 <p>The inline bubble fills passwords automatically when you visit sites.</p>802 <p>Export your vault from Settings before switching devices.</p>803 </div>804 <button805 class="peach-btn peach-btn--accent peach-btn--md peach-restore-intro-btn"806 onClick={() => void dismissIntro()}807 disabled={pendingUnlock()}808 >809 got it810 </button>811 </div>812 </div>813 </Show>814 </section>815 );816}817818819820function RestoreCodex(props: { onOpen: () => void }) {821 return (822 <div class="peach-restore-form">823 <p class="peach-restore-codex-copy">824 Scan printed Codex pages or import the Codex PDF. Codex restore is825 always available, even before you create a vault.826 </p>827 <button828 type="button"829 class="peach-btn peach-btn--accent peach-btn--md peach-restore-submit"830 onClick={props.onOpen}831 >832 open Codex restore833 </button>834 </div>835 );836}837838839840function RestoreSymbols(props: {841 hasVault: boolean;842 onSuccess: () => void | Promise<void>;843}) {844 const [scriptInput, setScriptInput] = createSignal("");845 const [backupPass, setBackupPass] = createSignal("");846 const [working, setWorking] = createSignal(false);847 const [animating, setAnimating] = createSignal<"chaos" | "snap" | "bloom" | null>(null);848 const [error, setError] = createSignal<string | null>(null);849 let canvasRef: HTMLCanvasElement | undefined;850 let peachFileRef: HTMLInputElement | undefined;851 let animFrameId = 0;852 let runes: { x: number; y: number; vx: number; vy: number; char: string; size: number; angle: number; va: number }[] = [];853854 const scriptValid = createMemo(() => isPeachScript(scriptInput()));855856 function stopAnim() {857 cancelAnimationFrame(animFrameId);858 animFrameId = 0;859 }860861 function startChaos(canvas: HTMLCanvasElement) {862 const ctx = canvas.getContext("2d")!;863 const W = canvas.width;864 const H = canvas.height;865 const RUNE_CHARS = Array.from({ length: 40 }, (_, i) =>866 String.fromCodePoint(0xe000 + i)867 );868 if (runes.length === 0) {869 runes = Array.from({ length: 60 }, () => ({870 x: Math.random() * W,871 y: Math.random() * H,872 vx: (Math.random() - 0.5) * 3,873 vy: (Math.random() - 0.5) * 3,874 char: RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]!,875 size: 14 + Math.random() * 20,876 angle: Math.random() * Math.PI * 2,877 va: (Math.random() - 0.5) * 0.1,878 }));879 }880881 function draw() {882 ctx.clearRect(0, 0, W, H);883 for (const r of runes) {884 r.x = (r.x + r.vx + W) % W;885 r.y = (r.y + r.vy + H) % H;886 r.angle += r.va;887 ctx.save();888 ctx.translate(r.x, r.y);889 ctx.rotate(r.angle);890 ctx.font = `${r.size}px PeachScript, monospace`;891 const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 400 + r.x);892 ctx.fillStyle = `rgba(232, 149, 109, ${alpha})`;893 ctx.shadowColor = "rgba(232, 149, 109, 0.7)";894 ctx.shadowBlur = 8;895 ctx.fillText(r.char, 0, 0);896 ctx.restore();897 }898 animFrameId = requestAnimationFrame(draw);899 }900 draw();901 }902903 function snapToGrid(canvas: HTMLCanvasElement, onDone: () => void) {904 stopAnim();905 const ctx = canvas.getContext("2d")!;906 const W = canvas.width;907 const H = canvas.height;908 const cols = 8;909 const rows = 6;910 const cellW = W / cols;911 const cellH = H / rows;912 const targets = runes.slice(0, cols * rows).map((_, i) => ({913 x: (i % cols) * cellW + cellW / 2,914 y: Math.floor(i / cols) * cellH + cellH / 2,915 }));916 const start = performance.now();917 const DURATION = 400;918919 function drawSnap(now: number) {920 const t = Math.min((now - start) / DURATION, 1);921 const ease = t < 1 ? 1 - (1 - t) ** 3 : 1;922 ctx.clearRect(0, 0, W, H);923 runes.slice(0, targets.length).forEach((r, i) => {924 const tx = targets[i]!.x;925 const ty = targets[i]!.y;926 const cx = r.x + (tx - r.x) * ease;927 const cy = r.y + (ty - r.y) * ease;928 ctx.font = `16px PeachScript, monospace`;929 ctx.fillStyle = `rgba(232, 149, 109, ${0.8 * ease})`;930 ctx.shadowColor = "rgba(232, 149, 109, 0.6)";931 ctx.shadowBlur = 6;932 ctx.fillText(r.char, cx - 8, cy + 8);933 });934 if (t < 1) {935 animFrameId = requestAnimationFrame(drawSnap);936 } else {937 onDone();938 }939 }940 animFrameId = requestAnimationFrame(drawSnap);941 }942943 function bloom(canvas: HTMLCanvasElement, onDone: () => void) {944 stopAnim();945 const ctx = canvas.getContext("2d")!;946 const W = canvas.width;947 const H = canvas.height;948 const start = performance.now();949 const DURATION = 900;950951 function drawBloom(now: number) {952 const t = Math.min((now - start) / DURATION, 1);953 ctx.clearRect(0, 0, W, H);954 955 const runeAlpha = 1 - Math.min(t / 0.4, 1);956 if (runeAlpha > 0) {957 for (const r of runes.slice(0, 48)) {958 ctx.font = `16px PeachScript, monospace`;959 ctx.fillStyle = `rgba(232, 149, 109, ${runeAlpha * 0.8})`;960 ctx.fillText(r.char, r.x - 8, r.y + 8);961 }962 }963 964 const markT = Math.max(0, (t - 0.2) / 0.5);965 const markScale = Math.min(markT * 1.2, 1);966 const markAlpha = markT < 0.5 ? markT * 2 : 2 - markT * 2;967 if (markAlpha > 0) {968 ctx.save();969 ctx.globalAlpha = markAlpha;970 ctx.shadowColor = `rgba(217, 104, 48, ${markAlpha * 0.8})`;971 ctx.shadowBlur = 30;972 ctx.translate(W / 2, H / 2);973 ctx.scale(markScale, markScale);974 ctx.drawImage(peachMarkImg, -32, -32, 64, 64);975 ctx.restore();976 }977 if (t < 1) {978 animFrameId = requestAnimationFrame(drawBloom);979 } else {980 onDone();981 }982 }983 animFrameId = requestAnimationFrame(drawBloom);984 }985986 onCleanup(() => stopAnim());987988 989 990 onMount(() => {991 if (restoreDeepLinkActive(RESTORE_DEEP_LINKS.symbols)) {992 setTimeout(() => peachFileRef?.click(), 50);993 }994 });995996 997 998 const [dragDepth, setDragDepth] = createSignal(0);9991000 async function handleDrop(e: DragEvent) {1001 e.preventDefault();1002 setDragDepth(0);1003 if (working() || animating() !== null) return;1004 const file = e.dataTransfer?.files?.[0];1005 if (!file) return;1006 try {1007 setScriptInput(await file.text());1008 setError(null);1009 } catch {1010 setError("Couldn't read file.");1011 }1012 }10131014 async function handleRestore() {1015 if (!scriptValid() || !backupPass().trim() || working()) return;1016 setError(null);1017 setWorking(true);1018 setAnimating("chaos");10191020 const canvas = canvasRef;1021 if (canvas) startChaos(canvas);10221023 try {1024 const bytes = decodePeachScript(normalisePeachScript(scriptInput()));1025 const payload = await parsePayload(bytes, backupPass());10261027 if (!props.hasVault) {1028 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });1029 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");1030 }10311032 const rr = await sendMessage({1033 type: "RESTORE_ENTRIES",1034 payload: { entries: payload.entries, mode: "merge", settings: payload.settings },1035 });1036 if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");10371038 if (payload.license?.key) {1039 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});1040 }10411042 setAnimating("snap");1043 stopAnim();1044 if (canvas) {1045 snapToGrid(canvas, () => {1046 setAnimating("bloom");1047 bloom(canvas, async () => {1048 setAnimating(null);1049 await props.onSuccess();1050 });1051 });1052 } else {1053 await props.onSuccess();1054 }1055 } catch (err) {1056 stopAnim();1057 setAnimating(null);1058 const code = err instanceof Error ? err.message : String(err);1059 setError(1060 code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"1061 ? "That passphrase didn't work."1062 : code === "CREATE_VAULT_FAILED"1063 ? "Couldn't create a new local vault."1064 : "Could not read this backup.",1065 );1066 } finally {1067 setWorking(false);1068 }1069 }10701071 return (1072 <div1073 class="peach-restore-form"1074 classList={{ "is-dragover": dragDepth() > 0 }}1075 onDragEnter={(e) => { e.preventDefault(); setDragDepth((d) => d + 1); }}1076 onDragOver={(e) => e.preventDefault()}1077 onDragLeave={() => setDragDepth((d) => Math.max(0, d - 1))}1078 onDrop={(e) => void handleDrop(e)}1079 >1080 <Show when={animating() !== null}>1081 <canvas1082 ref={(el) => { canvasRef = el; }}1083 class="peach-restore-anim-canvas"1084 width={280}1085 height={200}1086 />1087 </Show>1088 <Show when={animating() === null}>1089 <textarea1090 class="peach-restore-script"1091 placeholder="paste peachscript…"1092 rows={3}1093 aria-label="Paste your PeachScript backup"1094 value={scriptInput()}1095 onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}1096 />1097 <div class="peach-restore-or" aria-hidden="true">or</div>1098 <label1099 class="peach-btn peach-btn--ghost peach-btn--sm peach-restore-file-btn"1100 onClick={(e) => {1101 1102 1103 1104 if (restoreDeepLinkActive(RESTORE_DEEP_LINKS.symbols)) return;1105 e.preventDefault();1106 void detachForFilePicker(RESTORE_DEEP_LINKS.symbols).then((detached) => {1107 if (!detached) peachFileRef?.click();1108 });1109 }}1110 >1111 open .peach file1112 <input1113 ref={(el) => { peachFileRef = el; }}1114 type="file"1115 accept=".peach,text/plain"1116 style="display:none"1117 onChange={async (e) => {1118 const file = (e.currentTarget as HTMLInputElement).files?.[0];1119 if (!file) return;1120 try {1121 setScriptInput(await file.text());1122 } catch {1123 setError("Couldn't read file.");1124 } finally {1125 (e.currentTarget as HTMLInputElement).value = "";1126 }1127 }}1128 />1129 </label>1130 <p class="peach-restore-drop-hint">or drop the file anywhere here</p>1131 <div class="peach-newvault-field">1132 <label class="peach-label" for="restore-backup-pass">1133 backup password1134 </label>1135 <input1136 id="restore-backup-pass"1137 class="peach-underline-input peach-newvault-input"1138 type="password"1139 placeholder="••••••••••••"1140 autocomplete="current-password"1141 value={backupPass()}1142 onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}1143 onKeyDown={(e) => {1144 if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {1145 e.preventDefault();1146 void handleRestore();1147 }1148 }}1149 />1150 </div>1151 <button1152 class="peach-btn peach-btn--accent peach-btn--md peach-restore-submit"1153 onClick={() => void handleRestore()}1154 disabled={!scriptValid() || !backupPass().trim() || working()}1155 >1156 restore vault1157 </button>1158 <Show when={error()}>1159 <p class="peach-hint peach-hint-error">{error()}</p>1160 </Show>1161 </Show>1162 </div>1163 );1164}1165116611671168function RestoreCSV(props: {1169 hasVault: boolean;1170 onSuccess: () => void | Promise<void>;1171}) {1172 const [importing, setImporting] = createSignal(false);1173 const [error, setError] = createSignal<string | null>(null);1174 1175 1176 1177 1178 const CSV_MIN_PASSWORD_LENGTH = 12;1179 const [newPass, setNewPass] = createSignal("");1180 const [confirmPass, setConfirmPass] = createSignal("");1181 const needsPassword = () => props.hasVault === false;1182 const passwordReady = () =>1183 !needsPassword() ||1184 (newPass().length >= CSV_MIN_PASSWORD_LENGTH && newPass() === confirmPass());1185 1186 1187 const [dragDepth, setDragDepth] = createSignal(0);11881189 1190 1191 1192 async function processFile(file: File) {1193 setImporting(true);1194 setError(null);1195 try {1196 const text = await file.text();1197 const parsed = await parseImportSource(text);1198 if (parsed.entries.length === 0) throw new Error("NO_ENTRIES");11991200 if (props.hasVault === false) {1201 1202 1203 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: newPass() } });1204 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");1205 }12061207 const res = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: parsed.entries } });1208 if (!(res as { ok: boolean }).ok) throw new Error("IMPORT_FAILED");1209 if (parsed.licenseKey) {1210 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: parsed.licenseKey } }).catch(() => {});1211 }1212 await props.onSuccess();1213 } catch (err) {1214 const code = err instanceof Error ? err.message : String(err);1215 setError(1216 code === "NO_ENTRIES" ? "No entries found in this file." :1217 code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." :1218 "Import failed. Check the file format and try again."1219 );1220 } finally {1221 setImporting(false);1222 }1223 }12241225 let csvFileRef: HTMLInputElement | undefined;12261227 onMount(() => {1228 1229 1230 1231 if (restoreDeepLinkActive(RESTORE_DEEP_LINKS.csv) && !needsPassword()) {1232 setTimeout(() => csvFileRef?.click(), 50);1233 }1234 });12351236 function gatePassword(): boolean {1237 if (importing()) return false;1238 if (needsPassword() && newPass().length < CSV_MIN_PASSWORD_LENGTH) {1239 setError(`Set a master password of at least ${CSV_MIN_PASSWORD_LENGTH} characters first.`);1240 return false;1241 }1242 if (needsPassword() && newPass() !== confirmPass()) {1243 setError("Passwords do not match.");1244 return false;1245 }1246 return true;1247 }12481249 async function handleFile(e: Event) {1250 const input = e.currentTarget as HTMLInputElement;1251 const file = input.files?.[0];1252 input.value = "";1253 if (!file) return;1254 await processFile(file);1255 }12561257 async function handleDrop(e: DragEvent) {1258 e.preventDefault();1259 setDragDepth(0);1260 if (!gatePassword()) return;1261 const file = e.dataTransfer?.files?.[0];1262 if (!file) return;1263 await processFile(file);1264 }12651266 return (1267 <div1268 class="peach-restore-form"1269 classList={{ "is-dragover": dragDepth() > 0 }}1270 onDragEnter={(e) => { e.preventDefault(); setDragDepth((d) => d + 1); }}1271 onDragOver={(e) => e.preventDefault()}1272 onDragLeave={() => setDragDepth((d) => Math.max(0, d - 1))}1273 onDrop={(e) => void handleDrop(e)}1274 >1275 <Show when={needsPassword()}>1276 <div class="peach-newvault-field">1277 <label class="peach-label" for="restore-csv-pass">1278 new master password1279 </label>1280 <input1281 id="restore-csv-pass"1282 class="peach-underline-input peach-newvault-input"1283 type="password"1284 placeholder="••••••••••••"1285 autocomplete="new-password"1286 value={newPass()}1287 onInput={(e) => setNewPass((e.target as HTMLInputElement).value)}1288 />1289 </div>1290 <div class="peach-newvault-field">1291 <label class="peach-label" for="restore-csv-confirm">1292 confirm master password1293 </label>1294 <input1295 id="restore-csv-confirm"1296 class="peach-underline-input peach-newvault-input"1297 type="password"1298 placeholder="••••••••••••"1299 autocomplete="new-password"1300 value={confirmPass()}1301 onInput={(e) => setConfirmPass((e.target as HTMLInputElement).value)}1302 />1303 </div>1304 </Show>1305 <label1306 class="peach-btn peach-btn--accent peach-btn--md peach-restore-file-btn"1307 onClick={(e) => {1308 1309 1310 1311 if (restoreDeepLinkActive(RESTORE_DEEP_LINKS.csv)) return;1312 1313 1314 e.preventDefault();1315 if (!gatePassword()) return;1316 void detachForFilePicker(RESTORE_DEEP_LINKS.csv).then((detached) => {1317 if (!detached) csvFileRef?.click();1318 });1319 }}1320 >1321 {importing() ? "importing…" : "open csv file"}1322 <input1323 ref={(el) => { csvFileRef = el; }}1324 type="file"1325 accept=".csv,.json,.peach,application/x-peach"1326 style="display:none"1327 onChange={handleFile}1328 disabled={importing() || !passwordReady()}1329 />1330 </label>1331 <p class="peach-restore-drop-hint">or drop the file anywhere here</p>1332 <p class="peach-hint peach-restore-csv-sources">1333 Imports from Bitwarden, 1Password, Proton Pass,1334 <br />1335 Chrome, Safari, and Edge.1336 </p>1337 <p class="peach-newvault-warning">1338 <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>1339 <span>Plain-text file — delete it after the import.</span>1340 </p>1341 <Show when={error()}>1342 <p class="peach-hint peach-hint-error">{error()}</p>1343 </Show>1344 </div>1345 );1346}1347134813491350function getDisplayError(error: string | undefined, fallback: string): string {1351 if (!error) return fallback;1352 if (1353 error === "SERVICE_WORKER_UNAVAILABLE" ||1354 error.includes("Receiving end does not exist") ||1355 error.includes("message port closed")1356 ) {1357 return "background service unavailable";1358 }1359 return error;1360}