1import { Match, Show, Switch, createMemo, createSignal, onMount } from "solid-js";2import { Capacitor } from "@capacitor/core";34import { sendMessage } from "../App";5import { LockRuneCanvas } from "../components/LockRuneCanvas";6import { PeachMark, PeachWordmark } from "../components/PeachMark";7import { ImportView } from "./Import";8import { biometricUnlock, biometricAvailable } from "../../shared/biometric";91011let _zxcvbn: ((password: string) => { score: number }) | null = null;12async function getZxcvbn() {13 if (_zxcvbn) return _zxcvbn;14 const mod = await import("zxcvbn");15 _zxcvbn = mod.default;16 return _zxcvbn;17}1819interface LockProps {20 hasVault: boolean;21 ready: boolean;22 isPro?: boolean;23 onUnlock: () => void | Promise<void>;24 252627 onBackToOnboarding?: () => void;28}29303132type RestoreSurface = "restore" | "peachscript" | "file" | "mayan" | null;3334export function LockView(props: LockProps) {35 const [password, setPassword] = createSignal("");36 const [confirmPassword, setConfirmPassword] = createSignal("");37 const [error, setError] = createSignal("");38 const [loading, setLoading] = createSignal(false);39 const [restoreSurface, setRestoreSurface] = createSignal<RestoreSurface>(null);40 const [showPassword, setShowPassword] = createSignal(false);4142 const MIN_LENGTH = 12;43 const MIN_ZXCVBN_SCORE = 2;4445 const [strengthScore, setStrengthScore] = createSignal<number | null>(null);4647 const canCreate = createMemo(48 () =>49 password().length >= MIN_LENGTH &&50 confirmPassword().length >= MIN_LENGTH &&51 password() === confirmPassword() &&52 strengthScore() !== null &&53 strengthScore()! >= MIN_ZXCVBN_SCORE54 );5556 const passwordStrength = createMemo(() => {57 const score = strengthScore();58 if (score === null) return null;59 if (score <= 1) return "weak";60 if (score === 2) return "fair";61 if (score === 3) return "good";62 return "strong";63 });6465 const createBlockerMessage = createMemo(() => {66 if (!password()) return null;67 if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;68 if (!confirmPassword()) return "confirm your password";69 if (password() !== confirmPassword()) return "passwords don't match";70 if (strengthScore() === null) return "checking strength…";71 if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak — add words or length";72 return null;73 });7475 76 createMemo(() => {77 const value = password();78 if (!value) { setStrengthScore(null); return; }79 getZxcvbn().then((zxcvbn) => {80 if (password() === value) setStrengthScore(zxcvbn(value).score);81 }).catch(() => {82 83 if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1);84 });85 });8687 async function handleUnlock() {88 if (!password().trim()) return;89 setLoading(true);90 setError("");91 const response = await sendMessage({92 type: "UNLOCK",93 payload: { password: password() },94 });95 const result = response as { ok: boolean; error?: string };96 if (result.ok) {97 await props.onUnlock();98 setLoading(false);99 return;100 }101 setError(getDisplayError(result.error, "incorrect password"));102 setPassword("");103 const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined;104 passwordField?.classList.add("shake");105 setTimeout(() => passwordField?.classList.remove("shake"), 300);106 setLoading(false);107 }108109 async function handleCreate() {110 if (!password().trim()) return;111 if (password().length < MIN_LENGTH) {112 setError(`password must be at least ${MIN_LENGTH} characters`);113 return;114 }115 if (password() !== confirmPassword()) {116 setError("passwords do not match");117 return;118 }119 const score = strengthScore();120 if (score === null || score < MIN_ZXCVBN_SCORE) {121 setError("password is too weak — try a longer or more varied passphrase");122 return;123 }124 setLoading(true);125 setError("");126 const response = await sendMessage({127 type: "CREATE_VAULT",128 payload: { password: password() },129 });130 const result = response as { ok: boolean; error?: string };131 if (result.ok) {132 await props.onUnlock();133 setLoading(false);134 return;135 }136 setError(getDisplayError(result.error, "failed to create vault"));137 setLoading(false);138 }139140 function openRestore(surface: Exclude<RestoreSurface, null>) {141 if (!props.ready || loading()) return;142 setPassword("");143 setConfirmPassword("");144 setError("");145 setRestoreSurface(surface);146 }147148 149 return (150 <Switch>151 {}152 <Match when={restoreSurface() === "restore"}>153 <RestorePickerView154 onBack={() => setRestoreSurface(null)}155 onPickPeachScript={() => setRestoreSurface("peachscript")}156 onPickMayan={() => setRestoreSurface("mayan")}157 onPickCSV={() => setRestoreSurface("file")}158 />159 </Match>160161 {}162 <Match when={restoreSurface() === "mayan"}>163 <MayanRestoreView164 hasVault={props.hasVault}165 onBack={() => setRestoreSurface("restore")}166 onUnlock={async () => {167 setRestoreSurface(null);168 await props.onUnlock();169 }}170 />171 </Match>172173 {}174 <Match when={restoreSurface() === "peachscript"}>175 <ImportView176 mode="onboarding"177 replacesExistingVault={props.hasVault}178 initialView="peachscript"179 onBack={() => setRestoreSurface("restore")}180 onImport={async () => {181 setRestoreSurface(null);182 await props.onUnlock();183 }}184 />185 </Match>186187 {}188 <Match when={restoreSurface() === "file"}>189 <ImportView190 mode="onboarding"191 replacesExistingVault={props.hasVault}192 initialView="file"193 onBack={() => setRestoreSurface("restore")}194 onImport={async () => {195 setRestoreSurface(null);196 await props.onUnlock();197 }}198 />199 </Match>200201 {}202 <Match when={props.hasVault}>203 <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">204 <header class="peach-screen-header lock-screen-header">205 <div class="peach-wordmark-stack">206 <PeachWordmark class="peach-wordmark-wrap" />207 <Show when={props.isPro}>208 <span class="peach-pro-mini-badge">pro</span>209 </Show>210 </div>211 </header>212213 <div class="peach-lock-hero peach-lock-hero--unlock">214 <div class="peach-lock-hero-art" aria-hidden="true">215 <PeachMark class="peach-lock-hero-mark" />216 </div>217 <span class="peach-lock-eyebrow">vault locked</span>218 <h1 class="peach-onboarding-headline peach-lock-headline--unlock">219 unlock220 <br />221 your vault.222 </h1>223 </div>224225 <LockRuneCanvas />226227 <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">228 <label class="peach-label" for="unlock-password">229 master password230 </label>231 <input232 id="unlock-password"233 class="peach-underline-input peach-unlock-input lock-password-input"234 type="password"235 placeholder="••••••••••••"236 autocomplete="current-password"237 value={password()}238 disabled={!props.ready}239 ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}240 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}241 onKeyDown={(event) => {242 if (event.key !== "Enter" || !props.ready) return;243 void handleUnlock();244 }}245 />246247 <Show when={error()}>248 <p class="peach-hint peach-hint-error">{error()}</p>249 </Show>250251 <button252 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"253 onClick={() => void handleUnlock()}254 disabled={!props.ready || loading()}255 >256 {loading() ? "unlocking..." : "unlock"}257 </button>258259 <Show when={Capacitor.isNativePlatform()}>260 <BiometricUnlockButton ready={props.ready} loading={loading} onUnlock={props.onUnlock} />261 </Show>262 </section>263 </section>264 </Match>265266 {}267 <Match when={true}>268 <section269 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"270 data-motion-phase="enter"271 >272 <header class="peach-screen-header peach-newvault-header">273 <Show when={props.onBackToOnboarding}>274 <button275 class="peach-newvault-back"276 type="button"277 onClick={() => props.onBackToOnboarding?.()}278 aria-label="Back to onboarding"279 >280 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">281 <path d="M7 2L3 5.5L7 9" />282 </svg>283 back284 </button>285 </Show>286 <div class="peach-wordmark-stack">287 <PeachWordmark class="peach-wordmark-wrap" />288 <Show when={props.isPro}>289 <span class="peach-pro-mini-badge">pro</span>290 </Show>291 </div>292 </header>293294 <div class="peach-newvault-mark" aria-hidden="true">295 <PeachMark class="peach-newvault-mark-art" />296 </div>297298 <div class="peach-newvault-card">299 <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>300 <hr class="peach-newvault-divider" />301302 <div class="peach-newvault-field">303 <div class="peach-newvault-label-row">304 <label class="peach-label" for="master-password">305 master password306 </label>307 <button308 class="peach-lock-reveal-btn"309 type="button"310 onClick={() => setShowPassword((value) => !value)}311 aria-label={showPassword() ? "Hide password" : "Show password"}312 >313 <Show314 when={showPassword()}315 fallback={316 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">317 <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" />318 <circle cx="7" cy="5" r="1.75" />319 </svg>320 }321 >322 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">323 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />324 <path d="M3.5 5.5L2.5 7.5" />325 <path d="M7 6.5V8.5" />326 <path d="M10.5 5.5L11.5 7.5" />327 </svg>328 </Show>329 {showPassword() ? "hide" : "show"}330 </button>331 </div>332 <input333 id="master-password"334 class="peach-underline-input peach-newvault-input lock-password-input"335 type={showPassword() ? "text" : "password"}336 placeholder="••••••••••••"337 autocomplete="new-password"338 value={password()}339 disabled={!props.ready}340 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}341 onKeyDown={(event) => {342 if (event.key !== "Enter" || !props.ready) return;343 void handleCreate();344 }}345 />346 <Show347 when={password().length > 0 && passwordStrength() !== null}348 fallback={349 <p class="peach-newvault-hint">at least 12 characters</p>350 }351 >352 <div class="peach-password-strength">353 <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>354 <div class="peach-strength-fill"></div>355 </div>356 <span class="peach-strength-label">{passwordStrength()}</span>357 </div>358 </Show>359 </div>360361 <div class="peach-newvault-field">362 <label class="peach-label" for="confirm-password">363 confirm password364 </label>365 <input366 id="confirm-password"367 class="peach-underline-input peach-newvault-input"368 type={showPassword() ? "text" : "password"}369 placeholder="••••••••••••"370 autocomplete="new-password"371 value={confirmPassword()}372 disabled={!props.ready}373 onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}374 onKeyDown={(event) => {375 if (event.key === "Enter" && props.ready) void handleCreate();376 }}377 />378 </div>379380 <Show when={error()}>381 <p class="peach-hint peach-hint-error">{error()}</p>382 </Show>383384 <button385 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"386 onClick={() => void handleCreate()}387 disabled={!props.ready || loading() || !canCreate()}388 aria-live="polite"389 >390 <Show391 when={!canCreate() && createBlockerMessage()}392 fallback={loading() ? "creating..." : "create vault"}393 >394 {createBlockerMessage()}395 </Show>396 </button>397398 <p class="peach-newvault-warning">399 <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>400 <span>If you forget it, your vault cannot be opened. There is no reset.</span>401 </p>402 </div>403404 <div class="peach-newvault-footer">405 <p class="peach-hint">Already have a backup?</p>406 <button407 class="peach-lock-restore-link"408 onClick={() => openRestore("restore")}409 disabled={!props.ready}410 >411 › restore from backup412 </button>413 </div>414 </section>415 </Match>416 </Switch>417 );418}419420421422function RestorePickerView(props: {423 onBack: () => void;424 onPickPeachScript: () => void;425 onPickMayan: () => void;426 onPickCSV: () => void;427}) {428 return (429 <section430 class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding peach-restore-screen"431 data-motion-phase="enter"432 >433 <header class="peach-screen-header lock-screen-header">434 <PeachWordmark class="peach-wordmark-wrap" />435 <button class="peach-lock-restore-link" onClick={props.onBack}>436 ‹ back437 </button>438 </header>439440 <div class="peach-lock-hero">441 <span class="peach-lock-eyebrow">restore</span>442 <h1 class="peach-onboarding-headline">443 bring back444 <br />445 your vault.446 </h1>447 </div>448449 <div class="peach-restore-stack" role="list">450 <button451 class="peach-restore-card"452 role="listitem"453 onClick={props.onPickPeachScript}454 >455 <h2 class="peach-restore-card-headline">PeachScript</h2>456 <hr class="peach-restore-card-divider" />457 <p class="peach-restore-card-body">458 Paste a string of Peach symbols, or open a <code>.peach</code> file459 you saved from another device.460 </p>461 <p class="peach-restore-card-meta">backup password required</p>462 </button>463464 <button465 class="peach-restore-card"466 role="listitem"467 onClick={props.onPickMayan}468 >469 <h2 class="peach-restore-card-headline">Mayan code</h2>470 <hr class="peach-restore-card-divider" />471 <p class="peach-restore-card-body">472 Restore from a printed Mayan code — scan or upload a photo.473 </p>474 <p class="peach-restore-card-meta">decoder shipping soon</p>475 </button>476477 <button478 class="peach-restore-card"479 role="listitem"480 onClick={props.onPickCSV}481 >482 <h2 class="peach-restore-card-headline">CSV</h2>483 <hr class="peach-restore-card-divider" />484 <p class="peach-restore-card-body">485 Move in from Bitwarden, 1Password, Proton Pass, or a browser486 (Chrome, Safari, Edge) CSV export.487 </p>488 <p class="peach-restore-card-meta">plain-text — open the file once and delete it</p>489 </button>490 </div>491492 </section>493 );494}495496497498499500501502503async function handleMayanDecode(base64: string): Promise<504 { ok: true; payload: Uint8Array } | { ok: false; reason: string }505> {506 const { decodeMayan } = await import("../../crypto/mayan");507 const img = new Image();508 const dataUrl = base64.startsWith("data:") ? base64 : `data:image/*;base64,${base64}`;509 await new Promise<void>((resolve, reject) => {510 img.onload = () => resolve();511 img.onerror = () => reject(new Error("Couldn't read the image."));512 img.src = dataUrl;513 });514 const canvas = document.createElement("canvas");515 516 517 const MAX_DIM = 1500;518 const scale = Math.min(1, MAX_DIM / Math.max(img.width, img.height));519 canvas.width = Math.round(img.width * scale);520 canvas.height = Math.round(img.height * scale);521 const ctx = canvas.getContext("2d");522 if (!ctx) return { ok: false, reason: "Couldn't open a 2D canvas." };523 ctx.drawImage(img, 0, 0, canvas.width, canvas.height);524 const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);525 const payload = decodeMayan(imageData);526 if (!payload) {527 return {528 ok: false,529 reason:530 "Couldn't read this Mayan code. Try a closer, sharper photo with even lighting.",531 };532 }533 return { ok: true, payload };534}535536function MayanRestoreView(props: {537 hasVault: boolean;538 onBack: () => void;539 onUnlock: () => void | Promise<void>;540}) {541 const [decodeError, setDecodeError] = createSignal<string | null>(null);542 const [decoding, setDecoding] = createSignal(false);543 const [decodedBytes, setDecodedBytes] = createSignal<Uint8Array | null>(null);544 const [backupPass, setBackupPass] = createSignal("");545 const [masterPass, setMasterPass] = createSignal("");546 const [masterConfirm, setMasterConfirm] = createSignal("");547 const [restoring, setRestoring] = createSignal(false);548549 const isAndroid = createMemo(() =>550 Capacitor.isNativePlatform() && Capacitor.getPlatform() === "android",551 );552 const masterReady = createMemo(553 () => masterPass().length >= 8 && masterPass() === masterConfirm(),554 );555556 async function processBase64(base64: string) {557 setDecoding(true);558 setDecodeError(null);559 try {560 const result = await handleMayanDecode(base64);561 if (!result.ok) {562 setDecodeError(result.reason);563 return;564 }565 setDecodedBytes(result.payload);566 } finally {567 setDecoding(false);568 }569 }570571 async function handleCameraScan() {572 setDecodeError(null);573 try {574 const { Camera, CameraResultType, CameraSource } = await import("@capacitor/camera");575 const image = await Camera.getPhoto({576 quality: 90,577 resultType: CameraResultType.Base64,578 source: CameraSource.Camera,579 allowEditing: false,580 });581 if (image.base64String) await processBase64(image.base64String);582 } catch (err) {583 const msg = err instanceof Error ? err.message : "";584 if (msg && !/cancel/i.test(msg)) setDecodeError(msg);585 }586 }587588 function handleFileUpload(e: Event) {589 const input = e.currentTarget as HTMLInputElement;590 const file = input.files?.[0];591 if (!file) return;592 const reader = new FileReader();593 reader.onload = () => {594 const result = reader.result;595 if (typeof result !== "string") return;596 void processBase64(result);597 };598 reader.readAsDataURL(file);599 }600601 async function handleRestore() {602 const bytes = decodedBytes();603 if (!bytes) return;604 if (!backupPass().trim()) {605 setDecodeError("Enter the backup password.");606 return;607 }608 if (!props.hasVault && !masterReady()) {609 setDecodeError("Choose a master password (≥8 chars, must match).");610 return;611 }612 setRestoring(true);613 setDecodeError(null);614 try {615 const { parsePayload } = await import("../../crypto/peachPayload");616 const payload = await parsePayload(bytes, backupPass());617 if (!props.hasVault) {618 const createRes = await sendMessage({619 type: "CREATE_VAULT",620 payload: { password: masterPass() },621 });622 if (!(createRes as { ok: boolean }).ok) {623 throw new Error("CREATE_VAULT_FAILED");624 }625 }626 const restoreRes = await sendMessage({627 type: "RESTORE_ENTRIES",628 payload: {629 entries: payload.entries,630 mode: "merge",631 settings: payload.settings,632 },633 });634 const env = restoreRes as { ok: boolean; error?: string };635 if (!env.ok) throw new Error(env.error ?? "RESTORE_FAILED");636 if (payload.license?.key) {637 await sendMessage({638 type: "ACTIVATE_LICENSE",639 payload: { key: payload.license.key },640 }).catch(() => {});641 }642 await props.onUnlock();643 } catch (err) {644 const code = err instanceof Error ? err.message : String(err);645 setDecodeError(646 code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"647 ? "Wrong backup password — this is the password you set when you created the Mayan code."648 : code === "CREATE_VAULT_FAILED"649 ? "Couldn't create a new local vault."650 : "Restore failed. The backup may be corrupted.",651 );652 } finally {653 setRestoring(false);654 }655 }656657 return (658 <section659 class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding peach-restore-screen"660 data-motion-phase="enter"661 >662 <header class="peach-screen-header lock-screen-header">663 <PeachWordmark class="peach-wordmark-wrap" />664 <button class="peach-lock-restore-link" onClick={props.onBack}>665 ‹ back666 </button>667 </header>668669 <div class="peach-lock-hero">670 <span class="peach-lock-eyebrow">mayan code</span>671 <h1 class="peach-onboarding-headline">672 {decodedBytes() ? "enter password." : "scan or upload."}673 </h1>674 </div>675676 <Show677 when={decodedBytes() === null}678 fallback={679 <div class="peach-restore-stack">680 <div class="peach-settings-group peach-export-card peach-export-card--primary">681 <p class="peach-settings-label">backup password</p>682 <input683 ref={(el) => queueMicrotask(() => el.focus())}684 class="peach-settings-input"685 type="password"686 placeholder="backup password"687 value={backupPass()}688 onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}689 onKeyDown={(e) => {690 if (e.key === "Enter" && !restoring()) {691 e.preventDefault();692 void handleRestore();693 }694 }}695 />696 <Show when={!props.hasVault}>697 <p class="peach-settings-label" style="margin-top:12px">698 master password699 </p>700 <input701 class="peach-settings-input"702 type="password"703 placeholder="master password (min 8 chars)"704 value={masterPass()}705 onInput={(e) => setMasterPass((e.target as HTMLInputElement).value)}706 />707 <input708 class="peach-settings-input"709 type="password"710 placeholder="confirm master password"711 value={masterConfirm()}712 onInput={(e) => setMasterConfirm((e.target as HTMLInputElement).value)}713 />714 </Show>715 <button716 class="peach-btn peach-btn--subtle peach-btn--sm"717 style="margin-top:12px"718 onClick={() => void handleRestore()}719 disabled={restoring()}720 >721 {restoring() ? "restoring…" : "Restore"}722 </button>723 </div>724 <Show when={decodeError()}>725 <div class="peach-restore-card peach-restore-card--static">726 <p class="peach-restore-card-body">{decodeError()}</p>727 </div>728 </Show>729 </div>730 }731 >732 <div class="peach-restore-stack">733 <Show when={isAndroid()}>734 <button735 class="peach-restore-card"736 disabled={decoding()}737 onClick={() => void handleCameraScan()}738 >739 <h2 class="peach-restore-card-headline">Scan with camera</h2>740 <hr class="peach-restore-card-divider" />741 <p class="peach-restore-card-body">742 Point your camera at the printed Mayan code.743 </p>744 </button>745 </Show>746747 <label748 class="peach-restore-card"749 classList={{ "peach-restore-card--disabled": decoding() }}750 >751 <h2 class="peach-restore-card-headline">Choose from gallery</h2>752 <hr class="peach-restore-card-divider" />753 <p class="peach-restore-card-body">754 {decoding() ? "decoding…" : "Pick a saved photo of your Mayan code."}755 </p>756 <input757 type="file"758 accept="image/*"759 style="display:none"760 onChange={handleFileUpload}761 disabled={decoding()}762 />763 </label>764765 <Show when={decodeError()}>766 <div class="peach-restore-card peach-restore-card--static">767 <p class="peach-restore-card-body">{decodeError()}</p>768 </div>769 </Show>770 </div>771 </Show>772773 <div class="peach-restore-footer">774 <button class="peach-restore-footer-link" onClick={props.onBack}>775 ‹ back to restore776 </button>777 </div>778 </section>779 );780}781782783784async function handleBiometricUnlock(props: { onUnlock: () => void | Promise<void> }) {785 const unlocked = await biometricUnlock();786 if (unlocked) {787 await props.onUnlock();788 }789}790791function BiometricUnlockButton(props: { ready: boolean; loading: () => boolean; onUnlock: () => void | Promise<void> }) {792 const [bioAvailable, setBioAvailable] = createSignal(false);793 onMount(async () => {794 setBioAvailable(await biometricAvailable());795 });796 return (797 <Show when={bioAvailable()}>798 <button799 class="peach-btn peach-btn--subtle peach-btn--md peach-biometric-btn"800 onClick={() => void handleBiometricUnlock(props)}801 disabled={!props.ready || props.loading()}802 type="button"803 >804 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">805 <path d="M8 1a3 3 0 0 0-3 3v4a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />806 <path d="M5 8v1a3 3 0 0 0 6 0V8" />807 <line x1="8" y1="12" x2="8" y2="15" />808 <line x1="5" y1="15" x2="11" y2="15" />809 </svg>810 use biometrics811 </button>812 </Show>813 );814}815816817818function getDisplayError(error: string | undefined, fallback: string): string {819 if (!error) return fallback;820 if (821 error === "SERVICE_WORKER_UNAVAILABLE" ||822 error.includes("Receiving end does not exist") ||823 error.includes("message port closed")824 ) {825 return "background service unavailable";826 }827 return error;828}