Files
readest/apps/readest-app/src/components/AppLockScreen.tsx
T
Huang Xin 6c7c86f346 feat(applock): biometric unlock (fingerprint / Face ID) at startup on mobile (#4650)
* feat(applock): wire up biometric plugin + biometricUnlockEnabled setting

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(applock): add guarded biometric service wrapper

* feat(applock): auto-prompt biometrics on the lock screen with PIN fallback

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(applock): guard concurrent biometric prompts + tighten lock-screen tests

- Add biometricInFlightRef to prevent concurrent authenticateWithBiometrics calls
- Assert PIN input still rendered after biometric failure (test 2)
- Replace flaky waitFor negative assertion with a 50ms flush in test 3

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(applock): mobile biometric toggle + default-on at PIN setup

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(i18n): add biometric app-lock strings

* fix(applock): seed biometricUnlockEnabled via app-lock store init; close test gaps

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* build(applock): pin tauri-plugin-biometric in Cargo.lock

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 18:39:03 +02:00

174 lines
5.3 KiB
TypeScript

'use client';
import clsx from 'clsx';
import { useEffect, useRef, useState } from 'react';
import PinInput from '@/components/PinInput';
import { useEnv } from '@/context/EnvContext';
import { PIN_LENGTH, verifyPin } from '@/libs/crypto/applock';
import { useAppLockStore } from '@/store/appLockStore';
import { useTranslation } from '@/hooks/useTranslation';
import {
authenticateWithBiometrics,
getBiometricStatus,
getBiometryLabelKey,
isBiometricSupported,
shouldAttemptBiometricUnlock,
} from '@/services/biometric';
export default function AppLockScreen() {
const _ = useTranslation();
const { appService } = useEnv();
const { pinHash, pinSalt, unlock, biometricUnlockEnabled } = useAppLockStore();
const [biometryLabel, setBiometryLabel] = useState('');
const [pin, setPin] = useState('');
const [error, setError] = useState('');
const [shaking, setShaking] = useState(false);
const [kbInset, setKbInset] = useState(0);
const biometricAttemptedRef = useRef(false);
const biometricInFlightRef = useRef(false);
const autoFocusEnabled = !appService?.isMobile;
const runBiometric = async () => {
if (biometricInFlightRef.current) return;
biometricInFlightRef.current = true;
try {
const ok = await authenticateWithBiometrics(_('Unlock Readest'));
if (ok) unlock();
} finally {
biometricInFlightRef.current = false;
}
};
useEffect(() => {
if (!isBiometricSupported(appService)) return;
let cancelled = false;
void (async () => {
const { available, biometryType } = await getBiometricStatus();
if (cancelled) return;
if (
!shouldAttemptBiometricUnlock({
isMobileApp: !!appService?.isMobileApp,
biometricUnlockEnabled,
available,
})
) {
return;
}
setBiometryLabel(_(getBiometryLabelKey(biometryType)));
if (biometricAttemptedRef.current) return;
biometricAttemptedRef.current = true;
await runBiometric();
})();
return () => {
cancelled = true;
};
}, [appService]);
useEffect(() => {
const vv = window.visualViewport;
if (!vv) return;
const update = () => {
const layoutH = document.documentElement.clientHeight;
const offset = layoutH - vv.height - vv.offsetTop;
setKbInset(offset > 1 ? offset : 0);
};
vv.addEventListener('resize', update);
vv.addEventListener('scroll', update);
update();
return () => {
vv.removeEventListener('resize', update);
vv.removeEventListener('scroll', update);
};
}, []);
// Avoid React state for the in-flight guard — `setVerifying(true)`
// would re-trigger the effect, the cleanup would set `cancelled=true`,
// and the resolve handler would short-circuit before clearing the
// failed PIN. A ref keeps the guard outside the render cycle.
const verifyingRef = useRef(false);
const handleChange = async (next: string) => {
setPin(next);
if (error) setError('');
if (next.length !== PIN_LENGTH || verifyingRef.current) return;
if (!pinHash || !pinSalt) {
// Settings flag is enabled but the hash/salt are missing — treat
// as a corrupted-config bypass so the user isn't locked out
// forever. This should be unreachable through normal flows.
unlock();
return;
}
verifyingRef.current = true;
try {
const ok = await verifyPin(next, pinSalt, pinHash);
if (ok) {
unlock();
} else {
setError(_('Incorrect PIN'));
setPin('');
setShaking(true);
window.setTimeout(() => setShaking(false), 400);
}
} finally {
verifyingRef.current = false;
}
};
return (
<div
className='bg-base-100 full-height inset-0 z-[200] flex flex-col items-center justify-center px-6'
style={{ paddingBottom: kbInset || undefined }}
role='dialog'
aria-modal='true'
aria-label={_('App locked')}
>
<div className='flex max-w-sm flex-col items-center text-center'>
<h1 className='text-base-content mb-2 text-xl font-semibold tracking-tight'>
{_('Enter your PIN')}
</h1>
<p className='text-base-content/60 mb-8 text-sm leading-relaxed'>
{_('Readest is locked. Enter your 4-digit PIN to continue.')}
</p>
<PinInput
value={pin}
onChange={handleChange}
ariaLabel={_('PIN code')}
stickyFocus={autoFocusEnabled}
shake={shaking}
/>
<p
className={clsx(
'text-error mt-4 h-5 text-sm transition-opacity',
error ? 'opacity-100' : 'opacity-0',
)}
aria-live='polite'
>
{error || ' '}
</p>
{biometryLabel && (
<button
type='button'
onClick={runBiometric}
className={clsx(
'eink-bordered',
'mt-2 h-10 rounded-lg px-4 text-sm font-medium',
'text-base-content hover:bg-base-200 transition-colors duration-150',
)}
>
{_('Use {{biometry}}', { biometry: biometryLabel })}
</button>
)}
<p className='text-base-content/40 mt-10 text-xs leading-relaxed'>
{_(
"Forgetting your PIN locks you out of this device. You'll need to clear the app's data to reset it.",
)}
</p>
</div>
</div>
);
}