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 { storageGet, storageRemove, storageSet } from "../../shared/storage";8import { isPeachScript, decodePeachScript, normalisePeachScript } from "../../crypto/peachScript";9import { parsePayload } from "../../crypto/peachPayload";10import { parseImportSource } from "../../shared/import";11import { isNativePlatform, nativePlugin } from "../../shared/native";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 [biometricAvailable, setBiometricAvailable] = createSignal(false);95 const [biometricBusy, setBiometricBusy] = createSignal(false);96 const [pinAvailable, setPinAvailable] = createSignal(false);97 const [usingPin, setUsingPin] = createSignal(false);98 const [pin, setPin] = createSignal("");99100 const canCreate = createMemo(101 () =>102 password().length >= MIN_LENGTH &&103 confirmPassword().length >= MIN_LENGTH &&104 password() === confirmPassword() &&105 strengthScore() !== null &&106 strengthScore()! >= MIN_ZXCVBN_SCORE107 );108109 const passwordStrength = createMemo(() => {110 const score = strengthScore();111 if (score === null) return null;112 if (score <= 1) return "weak";113 if (score === 2) return "fair";114 if (score === 3) return "good";115 return "strong";116 });117118 const createBlockerMessage = createMemo(() => {119 if (!password()) return null;120 if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;121 if (!confirmPassword()) return "confirm your password";122 if (password() !== confirmPassword()) return "passwords don't match";123 if (strengthScore() === null) return "checking strength…";124 if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak — use a longer passphrase";125 return null;126 });127128 129 createMemo(() => {130 const value = password();131 if (!value) { setStrengthScore(null); return; }132 getZxcvbn().then((zxcvbn) => {133 if (password() === value) setStrengthScore(zxcvbn(value).score);134 }).catch(() => {135 136 if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1);137 });138 });139140 async function handleUnlock() {141 if (!password().trim()) return;142 setLoading(true);143 setError("");144 const response = await sendMessage({145 type: "UNLOCK",146 payload: { password: password() },147 });148 const result = response as { ok: boolean; error?: string };149 if (result.ok) {150 await props.onUnlock();151 setLoading(false);152 return;153 }154 setError(getDisplayError(result.error, "incorrect password"));155 setPassword("");156 const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined;157 passwordField?.classList.add("shake");158 setTimeout(() => passwordField?.classList.remove("shake"), 300);159 setLoading(false);160 }161162 async function handlePinUnlock() {163 if (!/^\d{4,12}$/.test(pin())) return;164 setLoading(true);165 setError("");166 const response = await sendMessage({167 type: "UNLOCK_WITH_PIN",168 payload: { pin: pin() },169 });170 const result = response as { ok: boolean; error?: string };171 if (result.ok) {172 await props.onUnlock();173 setLoading(false);174 return;175 }176 setError(getDisplayError(result.error, "incorrect PIN"));177 setPin("");178 const pinField = document.querySelector(".lock-pin-input") as HTMLInputElement | undefined;179 pinField?.classList.add("shake");180 setTimeout(() => pinField?.classList.remove("shake"), 300);181 setLoading(false);182 }183184 async function checkPinAvailable(): Promise<void> {185 if (!props.hasVault) return;186 try {187 const response = await sendMessage({ type: "GET_SETTINGS" });188 const settings = (response as { ok: boolean; data?: { pinEnabled?: boolean; pinPreferred?: boolean } }).data;189 const enabled = settings?.pinEnabled === true;190 setPinAvailable(enabled);191 if (enabled && settings?.pinPreferred === true) setUsingPin(true);192 } catch { }193 }194195 async function checkBiometricAvailable(): Promise<void> {196 if (!props.hasVault || !isNativePlatform()) return;197 const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");198 const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");199 if (!keystore || !biometric) return;200 try {201 const stored = await keystore.hasMasterKey();202 if (!stored.exists) return;203 const avail = await biometric.isAvailable();204 if (!avail.available) return;205 setBiometricAvailable(true);206 window.setTimeout(() => { void handleBiometricUnlock(); }, 400);207 } catch { }208 }209210 async function handleBiometricUnlock(): Promise<void> {211 if (biometricBusy()) return;212 const biometric = nativePlugin<{ authenticate: (opts: Record<string, string>) => Promise<void> }>("BiometricPlugin");213 const keystore = nativePlugin<{ retrieveMasterKey: () => Promise<{ masterKeyHex: string }> }>("KeystorePlugin");214 if (!biometric || !keystore) return;215 setBiometricBusy(true);216 setError("");217 try {218 await biometric.authenticate({219 title: "Unlock Peach",220 subtitle: "Use biometrics to access your vault",221 negativeButtonText: "Use Password",222 });223 const { masterKeyHex } = await keystore.retrieveMasterKey();224 const res = (await sendMessage({225 type: "UNLOCK_WITH_KEY",226 payload: { masterKeyHex },227 })) as { ok: boolean; error?: string };228 if (res.ok) {229 await props.onUnlock();230 return;231 }232 if (res.error === "biometric_key_invalidated") {233 await sendMessage({234 type: "UPDATE_SETTINGS",235 payload: { biometricsEnabled: false },236 });237 setBiometricAvailable(false);238 setError("biometric key expired — re-enable in settings");239 } else {240 setError("biometric unlock failed");241 }242 } catch (e) {243 const reason = e instanceof Error ? e.message : String(e);244 if (reason.includes("user_cancelled")) return;245 if (reason.includes("biometric_key_invalidated")) {246 await sendMessage({247 type: "UPDATE_SETTINGS",248 payload: { biometricsEnabled: false },249 });250 setBiometricAvailable(false);251 setError("biometric key expired — re-enable in settings");252 return;253 }254 if (reason.includes("biometric_lockout")) {255 setError("biometric locked out — use your password");256 }257 } finally {258 setBiometricBusy(false);259 }260 }261262 onMount(() => {263 void checkBiometricAvailable();264 void checkPinAvailable();265 });266267 async function handleCreate() {268 if (!password().trim()) return;269 if (password().length < MIN_LENGTH) {270 setError(`password must be at least ${MIN_LENGTH} characters`);271 return;272 }273 if (password() !== confirmPassword()) {274 setError("passwords do not match");275 return;276 }277 const score = strengthScore();278 if (score === null || score < MIN_ZXCVBN_SCORE) {279 setError("password is too weak — use a longer passphrase");280 return;281 }282 setLoading(true);283 setError("");284 const response = await sendMessage({285 type: "CREATE_VAULT",286 payload: { password: password() },287 });288 const result = response as { ok: boolean; error?: string };289 if (result.ok) {290 291 292 293 294 295 try {296 await chrome.storage.local?.set({ peach_recovery_pending: "1" });297 } catch { }298 setShowRecoveryWords(true);299 setLoading(false);300 return;301 }302 setError(getDisplayError(result.error, "failed to create vault"));303 setLoading(false);304 }305306 307 return (308 <Switch>309 {}310 <Match when={restoreSurface() === "restore"}>311 <RestoreView312 hasVault={props.hasVault}313 onOpenCodexRestore={props.onOpenCodexRestore}314 initialSource={props.initialRestoreSource}315 onBack={() => {316 317 318 if (props.onBackToOnboarding) props.onBackToOnboarding();319 else setRestoreSurface(null);320 }}321 onUnlock={async () => {322 setRestoreSurface(null);323 await props.onUnlock();324 }}325 />326 </Match>327328 {}329 {330331}332 <Match333 when={334 (showRecoveryWords() || props.forceRecoveryWords) &&335 (!props.hasVault || props.forceRecoveryWords)336 }337 >338 <RecoveryWordDisplayView339 onDone={props.onUnlock}340 />341 </Match>342343 {}344 <Match when={recoverSurface() === "words"}>345 <RecoveryWordEntryView346 onCancel={() => setRecoverSurface(null)}347 onRecoveryComplete={props.onUnlock}348 />349 </Match>350351 {}352 <Match when={props.hasVault}>353 <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">354 <RuneBackground opacity={0.07} />355356 <header class="peach-screen-header lock-screen-header">357 <div class="peach-wordmark-stack">358 <PeachWordmark class="peach-wordmark-wrap" />359 <Show when={props.isPro}>360 <span class="peach-pro-mini-badge">pro</span>361 </Show>362 </div>363 </header>364365 <div class="peach-lock-hero peach-lock-hero--unlock">366 <PeachMark class="peach-lock-mark" />367 <span class="peach-lock-eyebrow">vault locked</span>368 <h1 class="peach-onboarding-headline peach-lock-headline--unlock">369 unlock370 <br />371 your vault.372 </h1>373 </div>374375 <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">376 <Show when={usingPin()} fallback={377 <>378 <label class="peach-label" for="unlock-password">379 master password380 </label>381 <input382 id="unlock-password"383 class="peach-underline-input peach-unlock-input lock-password-input"384 type="password"385 placeholder="••••••••••••"386 autocomplete="current-password"387 value={password()}388 disabled={!props.ready}389 ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}390 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}391 onKeyDown={(event) => {392 if (event.key !== "Enter" || !props.ready) return;393 void handleUnlock();394 }}395 />396 </>397 }>398 <label class="peach-label" for="unlock-pin">399 PIN400 </label>401 <input402 id="unlock-pin"403 class="peach-underline-input peach-unlock-input lock-pin-input"404 type="password"405 inputmode="numeric"406 maxlength="12"407 placeholder="••••••••••••"408 autocomplete="off"409 value={pin()}410 disabled={!props.ready}411 ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}412 onInput={(event) => setPin((event.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}413 onKeyDown={(event) => {414 if (event.key !== "Enter" || !props.ready) return;415 void handlePinUnlock();416 }}417 />418 <button419 class="peach-lock-recover-link"420 type="button"421 onClick={() => { setUsingPin(false); setError(""); }}422 >423 use master password424 </button>425 </Show>426427 <Show when={error()}>428 <p class="peach-hint peach-hint-error">{error()}</p>429 </Show>430431 <button432 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"433 onClick={() => void (usingPin() ? handlePinUnlock() : handleUnlock())}434 disabled={!props.ready || loading()}435 >436 {loading() ? "unlocking..." : usingPin() ? "unlock with PIN" : "unlock"}437 </button>438439 <Show when={!usingPin() && pinAvailable()}>440 <button441 class="peach-lock-recover-link"442 type="button"443 onClick={() => { setUsingPin(true); setError(""); }}444 >445 use PIN446 </button>447 </Show>448449 <Show when={biometricAvailable()}>450 <button451 class="peach-biometric-btn"452 type="button"453 onClick={() => void handleBiometricUnlock()}454 disabled={biometricBusy() || loading()}455 aria-label="Unlock with biometrics"456 >457 <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">458 <path d="M12 11c-1.7 0-3 1.3-3 3v3"/>459 <path d="M15 17v-3"/>460 <path d="M9 4.5C9.7 4.2 10.8 4 12 4s2.3.2 3 .5"/>461 <path d="M5 9c.7-1.6 2-2.9 3.6-3.6"/>462 <path d="M19 9c-.7-1.6-2-2.9-3.6-3.6"/>463 <path d="M5 15c0-1.7.3-3.3.8-4.7"/>464 <path d="M19 15c0-1.7-.3-3.3-.8-4.7"/>465 </svg>466 {biometricBusy() ? "verifying…" : "use biometrics"}467 </button>468 </Show>469470 <button471 class="peach-lock-recover-link"472 type="button"473 onClick={() => setRecoverSurface("words")}474 >475 forgot your password?476 </button>477 </section>478 </section>479 </Match>480481 {}482 <Match when={true}>483 <section484 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"485 data-motion-phase="enter"486 >487 <header class="peach-screen-header peach-newvault-header">488 <div class="peach-wordmark-stack">489 <PeachWordmark class="peach-wordmark-wrap" />490 <Show when={props.isPro}>491 <span class="peach-pro-mini-badge">pro</span>492 </Show>493 </div>494 <Show when={props.onBackToOnboarding}>495 <button496 class="peach-newvault-back"497 type="button"498 onClick={() => props.onBackToOnboarding?.()}499 aria-label="Back to onboarding"500 >501 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">502 <path d="M7 2L3 5.5L7 9" />503 </svg>504 back505 </button>506 </Show>507 </header>508509 <div class="peach-newvault-mark" aria-hidden="true">510 <PeachMark class="peach-newvault-mark-art" />511 </div>512513 <div class="peach-newvault-card">514 <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>515 <hr class="peach-newvault-divider" />516517 <div class="peach-newvault-field">518 <div class="peach-newvault-label-row">519 <label class="peach-label" for="master-password">520 master password521 </label>522 <button523 class="peach-lock-reveal-btn"524 type="button"525 onClick={() => setShowPassword((value) => !value)}526 aria-label={showPassword() ? "Hide password" : "Show password"}527 >528 <Show529 when={showPassword()}530 fallback={531 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">532 <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" />533 <circle cx="7" cy="5" r="1.75" />534 </svg>535 }536 >537 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">538 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />539 <path d="M3.5 5.5L2.5 7.5" />540 <path d="M7 6.5V8.5" />541 <path d="M10.5 5.5L11.5 7.5" />542 </svg>543 </Show>544 {showPassword() ? "hide" : "show"}545 </button>546 </div>547 <input548 id="master-password"549 class="peach-underline-input peach-newvault-input lock-password-input"550 type={showPassword() ? "text" : "password"}551 placeholder="••••••••••••"552 autocomplete="new-password"553 value={password()}554 disabled={!props.ready}555 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}556 onKeyDown={(event) => {557 if (event.key !== "Enter" || !props.ready) return;558 void handleCreate();559 }}560 />561 <Show562 when={password().length > 0 && passwordStrength() !== null}563 fallback={564 <p class="peach-newvault-hint">565 At least 12 characters.566 </p>567 }568 >569 <div class="peach-password-strength">570 <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>571 <div class="peach-strength-fill"></div>572 </div>573 <span class="peach-strength-label">{passwordStrength()}</span>574 </div>575 </Show>576 </div>577578 <div class="peach-newvault-field">579 <label class="peach-label" for="confirm-password">580 confirm password581 </label>582 <input583 id="confirm-password"584 class="peach-underline-input peach-newvault-input"585 type={showPassword() ? "text" : "password"}586 placeholder="••••••••••••"587 autocomplete="new-password"588 value={confirmPassword()}589 disabled={!props.ready}590 onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}591 onKeyDown={(event) => {592 if (event.key === "Enter" && props.ready) void handleCreate();593 }}594 />595 </div>596597 <Show when={error()}>598 <p class="peach-hint peach-hint-error">{error()}</p>599 </Show>600601 <button602 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"603 onClick={() => void handleCreate()}604 disabled={!props.ready || loading() || !canCreate()}605 aria-live="polite"606 >607 <Show608 when={!canCreate() && createBlockerMessage()}609 fallback={loading() ? "creating..." : "create vault"}610 >611 {createBlockerMessage()}612 </Show>613 </button>614615 <p class="peach-newvault-warning">616 <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>617 <span>Save your 12 recovery words after creating — they're the only way back in if you forget your password.</span>618 </p>619 </div>620 </section>621 </Match>622 </Switch>623 );624}625626627628export type RestoreSource = "symbols" | "codex" | "csv";629630const RESTORE_SOURCES: { id: RestoreSource; name: string; detail: string }[] = [631 { id: "symbols", name: "PeachScript", detail: "Paste the rune string, or open a .peach file." },632 { id: "codex", name: "Codex", detail: "Scan printed pages, or import the PDF." },633 { id: "csv", name: "CSV", detail: "A .csv or .json file from another password manager." },634];635636const RESTORE_DEEP_LINKS: Record<"symbols" | "csv", "peachscript" | "csv"> = {637 symbols: "peachscript",638 csv: "csv",639};640641642643644645646647648649async function detachForFilePicker(route: "peachscript" | "csv"): Promise<boolean> {650 try {651 652 if (new URLSearchParams(window.location.search).get("restore")) return false;653 } catch { }654 if (typeof chrome === "undefined" || typeof chrome.runtime?.sendMessage !== "function") {655 return false;656 }657 try {658 const res = (await sendMessage({659 type: "OPEN_DETACHED_POPUP",660 payload: { route },661 })) as { ok: boolean; data?: { detached?: boolean } };662 if (!res?.data?.detached) return false;663 664 665 return true;666 } catch {667 return false;668 }669}670671672673function restoreDeepLinkActive(link: "peachscript" | "csv"): boolean {674 try {675 return new URLSearchParams(window.location.search).get("restore") === link;676 } catch {677 return false;678 }679}680681function RestoreView(props: {682 hasVault: boolean;683 onOpenCodexRestore?: () => void;684 onBack: () => void;685 onUnlock: () => void | Promise<void>;686 initialSource?: RestoreSource;687}) {688 const [source, setSource] = createSignal<RestoreSource | null>(props.initialSource ?? null);689 const [showIntro, setShowIntro] = createSignal(false);690 const [pendingUnlock] = createSignal(false);691692 693 694 695 696 function chooseSource(next: RestoreSource | null) {697 setSource(next);698 if (next) void storageSet(RESTORE_SOURCE_STORAGE_KEY, next).catch(() => {});699 else void storageRemove(RESTORE_SOURCE_STORAGE_KEY).catch(() => {});700 }701702 async function handleRestoreSuccess() {703 const seen = await storageGet("peach_shown_restore_intro").catch(() => null);704 if (!seen) {705 setShowIntro(true);706 } else {707 await props.onUnlock();708 }709 }710711 async function dismissIntro() {712 await storageSet("peach_shown_restore_intro", "true").catch(() => {});713 setShowIntro(false);714 await props.onUnlock();715 }716717 return (718 <section719 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault peach-restore"720 data-motion-phase="enter"721 >722 <RuneBackground opacity={0.05} />723724 <header class="peach-screen-header peach-newvault-header">725 <PeachWordmark class="peach-wordmark-wrap" />726 <button727 class="peach-newvault-back peach-restore-back"728 type="button"729 onClick={props.onBack}730 aria-label="Back to onboarding"731 >732 <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">733 <path d="M7 2L3 5.5L7 9" />734 </svg>735 back736 </button>737 </header>738739 <div class="peach-newvault-card peach-restore-card">740 <Show741 when={source()}742 keyed743 fallback={744 <>745 <h1 class="peach-newvault-headline">{"welcome\nback."}</h1>746 <hr class="peach-newvault-divider" />747 <div class="peach-restore-sources">748 <For each={RESTORE_SOURCES}>749 {(entry) => (750 <button751 type="button"752 class="peach-onboarding-choice peach-restore-source"753 onClick={() => chooseSource(entry.id)}754 >755 <span class="peach-onboarding-choice-label">{entry.name}</span>756 <span class="peach-onboarding-choice-detail">{entry.detail}</span>757 </button>758 )}759 </For>760 </div>761 </>762 }763 >764 {(active) => (765 <>766 <div class="peach-restore-source-head">767 <h2 class="peach-restore-source-title">768 {RESTORE_SOURCES.find((entry) => entry.id === active)?.name}769 </h2>770 <button771 type="button"772 class="peach-restore-picker-back"773 onClick={() => chooseSource(null)}774 >775 ‹ sources776 </button>777 </div>778 <hr class="peach-newvault-divider" />779 <Show when={active === "symbols"}>780 <RestoreSymbols hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />781 </Show>782 <Show when={active === "codex"}>783 <RestoreCodex onOpen={() => props.onOpenCodexRestore?.()} />784 </Show>785 <Show when={active === "csv"}>786 <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />787 </Show>788 </>789 )}790 </Show>791 </div>792793 {}794 <Show when={showIntro()}>795 <div class="peach-restore-intro-overlay">796 <div class="peach-restore-intro-card">797 <p class="peach-restore-intro-headline">welcome to peach.</p>798 <div class="peach-restore-intro-body">799 <p>Your vault lives only on this device. No server has a copy.</p>800 <p>The inline bubble fills passwords automatically when you visit sites.</p>801 <p>Export your vault from Settings before switching devices.</p>802 </div>803 <button804 class="peach-btn peach-btn--accent peach-btn--md peach-restore-intro-btn"805 onClick={() => void dismissIntro()}806 disabled={pendingUnlock()}807 >808 got it809 </button>810 </div>811 </div>812 </Show>813 </section>814 );815}816817818819function RestoreCodex(props: { onOpen: () => void }) {820 return (821 <div class="peach-restore-form">822 <p class="peach-restore-codex-copy">823 Scan printed Codex pages or import the Codex PDF. Codex restore is824 always available, even before you create a vault.825 </p>826 <button827 type="button"828 class="peach-btn peach-btn--accent peach-btn--md peach-restore-submit"829 onClick={props.onOpen}830 >831 open Codex restore832 </button>833 </div>834 );835}836837838839function RestoreSymbols(props: {840 hasVault: boolean;841 onSuccess: () => void | Promise<void>;842}) {843 const [scriptInput, setScriptInput] = createSignal("");844 const [backupPass, setBackupPass] = createSignal("");845 const [working, setWorking] = createSignal(false);846 const [animating, setAnimating] = createSignal<"chaos" | "snap" | "bloom" | null>(null);847 const [error, setError] = createSignal<string | null>(null);848 let canvasRef: HTMLCanvasElement | undefined;849 let peachFileRef: HTMLInputElement | undefined;850 let animFrameId = 0;851 let runes: { x: number; y: number; vx: number; vy: number; char: string; size: number; angle: number; va: number }[] = [];852853 const scriptValid = createMemo(() => isPeachScript(scriptInput()));854855 function stopAnim() {856 cancelAnimationFrame(animFrameId);857 animFrameId = 0;858 }859860 function startChaos(canvas: HTMLCanvasElement) {861 const ctx = canvas.getContext("2d")!;862 const W = canvas.width;863 const H = canvas.height;864 const RUNE_CHARS = Array.from({ length: 40 }, (_, i) =>865 String.fromCodePoint(0xe000 + i)866 );867 if (runes.length === 0) {868 runes = Array.from({ length: 60 }, () => ({869 x: Math.random() * W,870 y: Math.random() * H,871 vx: (Math.random() - 0.5) * 3,872 vy: (Math.random() - 0.5) * 3,873 char: RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]!,874 size: 14 + Math.random() * 20,875 angle: Math.random() * Math.PI * 2,876 va: (Math.random() - 0.5) * 0.1,877 }));878 }879880 function draw() {881 ctx.clearRect(0, 0, W, H);882 for (const r of runes) {883 r.x = (r.x + r.vx + W) % W;884 r.y = (r.y + r.vy + H) % H;885 r.angle += r.va;886 ctx.save();887 ctx.translate(r.x, r.y);888 ctx.rotate(r.angle);889 ctx.font = `${r.size}px PeachScript, monospace`;890 const alpha = 0.5 + 0.5 * Math.sin(Date.now() / 400 + r.x);891 ctx.fillStyle = `rgba(232, 149, 109, ${alpha})`;892 ctx.shadowColor = "rgba(232, 149, 109, 0.7)";893 ctx.shadowBlur = 8;894 ctx.fillText(r.char, 0, 0);895 ctx.restore();896 }897 animFrameId = requestAnimationFrame(draw);898 }899 draw();900 }901902 function snapToGrid(canvas: HTMLCanvasElement, onDone: () => void) {903 stopAnim();904 const ctx = canvas.getContext("2d")!;905 const W = canvas.width;906 const H = canvas.height;907 const cols = 8;908 const rows = 6;909 const cellW = W / cols;910 const cellH = H / rows;911 const targets = runes.slice(0, cols * rows).map((_, i) => ({912 x: (i % cols) * cellW + cellW / 2,913 y: Math.floor(i / cols) * cellH + cellH / 2,914 }));915 const start = performance.now();916 const DURATION = 400;917918 function drawSnap(now: number) {919 const t = Math.min((now - start) / DURATION, 1);920 const ease = t < 1 ? 1 - (1 - t) ** 3 : 1;921 ctx.clearRect(0, 0, W, H);922 runes.slice(0, targets.length).forEach((r, i) => {923 const tx = targets[i]!.x;924 const ty = targets[i]!.y;925 const cx = r.x + (tx - r.x) * ease;926 const cy = r.y + (ty - r.y) * ease;927 ctx.font = `16px PeachScript, monospace`;928 ctx.fillStyle = `rgba(232, 149, 109, ${0.8 * ease})`;929 ctx.shadowColor = "rgba(232, 149, 109, 0.6)";930 ctx.shadowBlur = 6;931 ctx.fillText(r.char, cx - 8, cy + 8);932 });933 if (t < 1) {934 animFrameId = requestAnimationFrame(drawSnap);935 } else {936 onDone();937 }938 }939 animFrameId = requestAnimationFrame(drawSnap);940 }941942 function bloom(canvas: HTMLCanvasElement, onDone: () => void) {943 stopAnim();944 const ctx = canvas.getContext("2d")!;945 const W = canvas.width;946 const H = canvas.height;947 const start = performance.now();948 const DURATION = 900;949950 function drawBloom(now: number) {951 const t = Math.min((now - start) / DURATION, 1);952 ctx.clearRect(0, 0, W, H);953 954 const runeAlpha = 1 - Math.min(t / 0.4, 1);955 if (runeAlpha > 0) {956 for (const r of runes.slice(0, 48)) {957 ctx.font = `16px PeachScript, monospace`;958 ctx.fillStyle = `rgba(232, 149, 109, ${runeAlpha * 0.8})`;959 ctx.fillText(r.char, r.x - 8, r.y + 8);960 }961 }962 963 const markT = Math.max(0, (t - 0.2) / 0.5);964 const markScale = Math.min(markT * 1.2, 1);965 const markAlpha = markT < 0.5 ? markT * 2 : 2 - markT * 2;966 if (markAlpha > 0) {967 ctx.save();968 ctx.globalAlpha = markAlpha;969 ctx.shadowColor = `rgba(217, 104, 48, ${markAlpha * 0.8})`;970 ctx.shadowBlur = 30;971 ctx.translate(W / 2, H / 2);972 ctx.scale(markScale, markScale);973 ctx.drawImage(peachMarkImg, -32, -32, 64, 64);974 ctx.restore();975 }976 if (t < 1) {977 animFrameId = requestAnimationFrame(drawBloom);978 } else {979 onDone();980 }981 }982 animFrameId = requestAnimationFrame(drawBloom);983 }984985 onCleanup(() => stopAnim());986987 988 989 onMount(() => {990 if (restoreDeepLinkActive(RESTORE_DEEP_LINKS.symbols)) {991 setTimeout(() => peachFileRef?.click(), 50);992 }993 });994995 996 997 const [dragDepth, setDragDepth] = createSignal(0);998999 async function handleDrop(e: DragEvent) {1000 e.preventDefault();1001 setDragDepth(0);1002 if (working() || animating() !== null) return;1003 const file = e.dataTransfer?.files?.[0];1004 if (!file) return;1005 try {1006 setScriptInput(await file.text());1007 setError(null);1008 } catch {1009 setError("Couldn't read file.");1010 }1011 }10121013 async function handleRestore() {1014 if (!scriptValid() || !backupPass().trim() || working()) return;1015 setError(null);1016 setWorking(true);1017 setAnimating("chaos");10181019 const canvas = canvasRef;1020 if (canvas) startChaos(canvas);10211022 try {1023 const bytes = decodePeachScript(normalisePeachScript(scriptInput()));1024 const payload = await parsePayload(bytes, backupPass());10251026 if (!props.hasVault) {1027 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });1028 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");1029 }10301031 const rr = await sendMessage({1032 type: "RESTORE_ENTRIES",1033 payload: { entries: payload.entries, mode: "merge", settings: payload.settings },1034 });1035 if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");10361037 if (payload.license?.key) {1038 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});1039 }10401041 setAnimating("snap");1042 stopAnim();1043 if (canvas) {1044 snapToGrid(canvas, () => {1045 setAnimating("bloom");1046 bloom(canvas, async () => {1047 setAnimating(null);1048 await props.onSuccess();1049 });1050 });1051 } else {1052 await props.onSuccess();1053 }1054 } catch (err) {1055 stopAnim();1056 setAnimating(null);1057 const code = err instanceof Error ? err.message : String(err);1058 setError(1059 code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"1060 ? "That passphrase didn't work."1061 : code === "CREATE_VAULT_FAILED"1062 ? "Couldn't create a new local vault."1063 : "Could not read this backup.",1064 );1065 } finally {1066 setWorking(false);1067 }1068 }10691070 return (1071 <div1072 class="peach-restore-form"1073 classList={{ "is-dragover": dragDepth() > 0 }}1074 onDragEnter={(e) => { e.preventDefault(); setDragDepth((d) => d + 1); }}1075 onDragOver={(e) => e.preventDefault()}1076 onDragLeave={() => setDragDepth((d) => Math.max(0, d - 1))}1077 onDrop={(e) => void handleDrop(e)}1078 >1079 <Show when={animating() !== null}>1080 <canvas1081 ref={(el) => { canvasRef = el; }}1082 class="peach-restore-anim-canvas"1083 width={280}1084 height={200}1085 />1086 </Show>1087 <Show when={animating() === null}>1088 <textarea1089 class="peach-restore-script"1090 placeholder="paste peachscript…"1091 rows={3}1092 aria-label="Paste your PeachScript backup"1093 value={scriptInput()}1094 onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}1095 />1096 <div class="peach-restore-or" aria-hidden="true">or</div>1097 <label1098 class="peach-btn peach-btn--ghost peach-btn--sm peach-restore-file-btn"1099 onClick={(e) => {1100 1101 1102 1103 if (restoreDeepLinkActive(RESTORE_DEEP_LINKS.symbols)) return;1104 e.preventDefault();1105 void detachForFilePicker(RESTORE_DEEP_LINKS.symbols).then((detached) => {1106 if (!detached) peachFileRef?.click();1107 });1108 }}1109 >1110 open .peach file1111 <input1112 ref={(el) => { peachFileRef = el; }}1113 type="file"1114 accept=".peach,text/plain"1115 style="display:none"1116 onChange={async (e) => {1117 const file = (e.currentTarget as HTMLInputElement).files?.[0];1118 if (!file) return;1119 try {1120 setScriptInput(await file.text());1121 } catch {1122 setError("Couldn't read file.");1123 } finally {1124 (e.currentTarget as HTMLInputElement).value = "";1125 }1126 }}1127 />1128 </label>1129 <p class="peach-restore-drop-hint">or drop the file anywhere here</p>1130 <div class="peach-newvault-field">1131 <label class="peach-label" for="restore-backup-pass">1132 backup password1133 </label>1134 <input1135 id="restore-backup-pass"1136 class="peach-underline-input peach-newvault-input"1137 type="password"1138 placeholder="••••••••••••"1139 autocomplete="current-password"1140 value={backupPass()}1141 onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}1142 onKeyDown={(e) => {1143 if (e.key === "Enter" && scriptValid() && backupPass().trim() && !working()) {1144 e.preventDefault();1145 void handleRestore();1146 }1147 }}1148 />1149 </div>1150 <button1151 class="peach-btn peach-btn--accent peach-btn--md peach-restore-submit"1152 onClick={() => void handleRestore()}1153 disabled={!scriptValid() || !backupPass().trim() || working()}1154 >1155 restore vault1156 </button>1157 <Show when={error()}>1158 <p class="peach-hint peach-hint-error">{error()}</p>1159 </Show>1160 </Show>1161 </div>1162 );1163}1164116511661167function RestoreCSV(props: {1168 hasVault: boolean;1169 onSuccess: () => void | Promise<void>;1170}) {1171 const [importing, setImporting] = createSignal(false);1172 const [error, setError] = createSignal<string | null>(null);1173 1174 1175 1176 1177 const CSV_MIN_PASSWORD_LENGTH = 12;1178 const [newPass, setNewPass] = createSignal("");1179 const [confirmPass, setConfirmPass] = createSignal("");1180 const needsPassword = () => props.hasVault === false;1181 const passwordReady = () =>1182 !needsPassword() ||1183 (newPass().length >= CSV_MIN_PASSWORD_LENGTH && newPass() === confirmPass());1184 1185 1186 const [dragDepth, setDragDepth] = createSignal(0);11871188 1189 1190 1191 async function processFile(file: File) {1192 setImporting(true);1193 setError(null);1194 try {1195 const text = await file.text();1196 const parsed = await parseImportSource(text);1197 if (parsed.entries.length === 0) throw new Error("NO_ENTRIES");11981199 if (props.hasVault === false) {1200 1201 1202 const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: newPass() } });1203 if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");1204 }12051206 const res = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: parsed.entries } });1207 if (!(res as { ok: boolean }).ok) throw new Error("IMPORT_FAILED");1208 if (parsed.licenseKey) {1209 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: parsed.licenseKey } }).catch(() => {});1210 }1211 await props.onSuccess();1212 } catch (err) {1213 const code = err instanceof Error ? err.message : String(err);1214 setError(1215 code === "NO_ENTRIES" ? "No entries found in this file." :1216 code === "CREATE_VAULT_FAILED" ? "Couldn't create a new local vault." :1217 "Import failed. Check the file format and try again."1218 );1219 } finally {1220 setImporting(false);1221 }1222 }12231224 let csvFileRef: HTMLInputElement | undefined;12251226 onMount(() => {1227 1228 1229 1230 if (restoreDeepLinkActive(RESTORE_DEEP_LINKS.csv) && !needsPassword()) {1231 setTimeout(() => csvFileRef?.click(), 50);1232 }1233 });12341235 function gatePassword(): boolean {1236 if (importing()) return false;1237 if (needsPassword() && newPass().length < CSV_MIN_PASSWORD_LENGTH) {1238 setError(`Set a master password of at least ${CSV_MIN_PASSWORD_LENGTH} characters first.`);1239 return false;1240 }1241 if (needsPassword() && newPass() !== confirmPass()) {1242 setError("Passwords do not match.");1243 return false;1244 }1245 return true;1246 }12471248 async function handleFile(e: Event) {1249 const input = e.currentTarget as HTMLInputElement;1250 const file = input.files?.[0];1251 input.value = "";1252 if (!file) return;1253 await processFile(file);1254 }12551256 async function handleDrop(e: DragEvent) {1257 e.preventDefault();1258 setDragDepth(0);1259 if (!gatePassword()) return;1260 const file = e.dataTransfer?.files?.[0];1261 if (!file) return;1262 await processFile(file);1263 }12641265 return (1266 <div1267 class="peach-restore-form"1268 classList={{ "is-dragover": dragDepth() > 0 }}1269 onDragEnter={(e) => { e.preventDefault(); setDragDepth((d) => d + 1); }}1270 onDragOver={(e) => e.preventDefault()}1271 onDragLeave={() => setDragDepth((d) => Math.max(0, d - 1))}1272 onDrop={(e) => void handleDrop(e)}1273 >1274 <Show when={needsPassword()}>1275 <div class="peach-newvault-field">1276 <label class="peach-label" for="restore-csv-pass">1277 new master password1278 </label>1279 <input1280 id="restore-csv-pass"1281 class="peach-underline-input peach-newvault-input"1282 type="password"1283 placeholder="••••••••••••"1284 autocomplete="new-password"1285 value={newPass()}1286 onInput={(e) => setNewPass((e.target as HTMLInputElement).value)}1287 />1288 </div>1289 <div class="peach-newvault-field">1290 <label class="peach-label" for="restore-csv-confirm">1291 confirm master password1292 </label>1293 <input1294 id="restore-csv-confirm"1295 class="peach-underline-input peach-newvault-input"1296 type="password"1297 placeholder="••••••••••••"1298 autocomplete="new-password"1299 value={confirmPass()}1300 onInput={(e) => setConfirmPass((e.target as HTMLInputElement).value)}1301 />1302 </div>1303 </Show>1304 <label1305 class="peach-btn peach-btn--accent peach-btn--md peach-restore-file-btn"1306 onClick={(e) => {1307 1308 1309 1310 if (restoreDeepLinkActive(RESTORE_DEEP_LINKS.csv)) return;1311 1312 1313 e.preventDefault();1314 if (!gatePassword()) return;1315 void detachForFilePicker(RESTORE_DEEP_LINKS.csv).then((detached) => {1316 if (!detached) csvFileRef?.click();1317 });1318 }}1319 >1320 {importing() ? "importing…" : "open csv file"}1321 <input1322 ref={(el) => { csvFileRef = el; }}1323 type="file"1324 accept=".csv,.json,.peach,application/x-peach"1325 style="display:none"1326 onChange={handleFile}1327 disabled={importing() || !passwordReady()}1328 />1329 </label>1330 <p class="peach-restore-drop-hint">or drop the file anywhere here</p>1331 <p class="peach-hint peach-restore-csv-sources">1332 Imports from Bitwarden, 1Password, Proton Pass,1333 <br />1334 Chrome, Safari, and Edge.1335 </p>1336 <p class="peach-newvault-warning">1337 <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>1338 <span>Plain-text file — delete it after the import.</span>1339 </p>1340 <Show when={error()}>1341 <p class="peach-hint peach-hint-error">{error()}</p>1342 </Show>1343 </div>1344 );1345}1346134713481349function getDisplayError(error: string | undefined, fallback: string): string {1350 if (!error) return fallback;1351 if (1352 error === "SERVICE_WORKER_UNAVAILABLE" ||1353 error.includes("Receiving end does not exist") ||1354 error.includes("message port closed")1355 ) {1356 return "background service unavailable";1357 }1358 return error;1359}