fix(rebreak-native): iOS app-lock freeze (Face ID stuck, needs hard-quit)

LockScreen could latch on the locked screen with no working Face ID
prompt until a hard-quit. Two coupled causes:
- inFlight/busy could stay latched if authenticateAsync hung when called
  mid active↔inactive transition → gate tryUnlock on AppState 'active'
  and release the latch on background (iOS tears down the sheet anyway).
- foreground recovery missed background→inactive→active (prev was
  'inactive' at the active event) → track "was backgrounded since last
  active" instead, so re-prompt fires reliably (this is why only
  hard-quit recovered it). The Face ID sheet's own active→inactive→active
  no longer re-triggers (only real 'background' arms the flag).
- appLock.authenticate wraps authenticateAsync in try/catch so a native
  reject always settles (the LockScreen finally relies on it).

cancelAuthenticate() is Android-only (no iOS native impl) — fix works by
prevention + self-heal, not cancellation. Frontend-only; needs a native
rebuild, no deploy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-06-10 15:11:12 +02:00
parent adb686d9a3
commit c4fe7d356f
2 changed files with 56 additions and 30 deletions

View File

@ -12,9 +12,22 @@ import { useAuthStore } from '../stores/auth';
* `locked` ist (siehe AppLockGate). Beim Mount und jedes Mal wenn man aus dem
* Hintergrund zur noch-gesperrten App zurückkommt wird automatisch der
* Face-ID/Touch-ID/Passcode-Prompt ausgelöst; schlägt er fehl oder bricht der
* User ab, bleibt der Entsperren"-Button stehen (kein Auto-Retry-Loop die
* inactiveactive-Transition direkt nach einem abgebrochenen Prompt löst NICHT
* neu aus, nur backgroundactive).
* User ab, bleibt der Entsperren"-Button stehen.
*
* iOS-Freeze-Falle (vormals: Screen blieb hängen, nur Hard-Quit half):
* 1. Foreground läuft als backgroundinactiveactive. Würde man nur auf
* prev === 'background'" prüfen, verschluckt das intermediäre 'inactive' den
* Trigger kein Re-Prompt bei Rückkehr der Screen bleibt tot". Deshalb
* merken wir, ob wir seit dem letzten 'active' ÜBERHAUPT im Hintergrund waren.
* 2. Der Face-ID-Sheet selbst schickt die App activeinactiveactive. Dieses
* 'inactive' darf NICHT als war im Hintergrund" zählen (sonst Re-Prompt-Loop
* direkt nach Abbruch) nur echtes 'background' setzt das Flag.
* 3. evaluatePolicy() während einer activeinactive-Transition kann nativ hängen
* bleiben und den Prompt nie auflösen inFlight/busy würden für immer
* latchen. Gegenmittel: nur prompten wenn die App hart 'active' ist, und beim
* Backgrounden den Latch zwangsweise freigeben (iOS reißt den Sheet dabei eh
* ab), damit der Re-Prompt bei Rückkehr garantiert sauber startet.
* `cancelAuthenticate()` hilft hier NICHT es ist Android-only (kein iOS-Nativ).
*
* Abmelden" unten ist die Notausfahrt: clear't die Session beim nächsten Start
* gibt es keine Session keine Sperre frischer Login. Verhindert ein echtes
@ -40,6 +53,10 @@ export function LockScreen() {
}, [pulse]);
const tryUnlock = useCallback(async () => {
// Nur prompten wenn die App wirklich vorne ist. Ein evaluatePolicy()-Call
// während einer active↔inactive-Transition kann nativ hängen bleiben und den
// Prompt nie auflösen → Screen friert ein (Falle 3 oben).
if (AppState.currentState !== 'active') return;
if (inFlight.current) return;
inFlight.current = true;
setBusy(true);
@ -51,25 +68,27 @@ export function LockScreen() {
}
}, [authenticate, t]);
// Auto-Prompt beim ersten Erscheinen — aber NUR wenn die App schon im
// Foreground ist. Wird LockScreen während eines `background`/`inactive`-State
// gemountet (typisch wenn der Lock durch das background-Event selbst getriggert
// wurde), zeigt FaceID keinen sichtbaren Prompt und failed silent — der User
// sieht dann nur den Fallback-Button.
// Wenn beim Mount nicht active, fängt der background→active-Listener unten
// den Foreground-Wechsel und prompted dann.
// Auto-Prompt: beim ersten aktiven Erscheinen + jeder Rückkehr aus dem
// Hintergrund. Eine einzige Quelle der Wahrheit für den AppState (siehe die drei
// iOS-Fallen im Doc-Block oben).
useEffect(() => {
if (AppState.currentState === 'active') {
let wasBackground = AppState.currentState !== 'active';
// Kaltstart/aktiv gemountet → einmal prompten. Lag die App beim Mount im
// Hintergrund/inactive, übernimmt der Listener beim nächsten 'active'.
if (AppState.currentState === 'active') tryUnlock();
const sub = AppState.addEventListener('change', (next) => {
if (next === 'background') {
wasBackground = true;
// Sicherheitsnetz gegen einen hängenden nativen Prompt: Latch hart
// freigeben (iOS hat den Face-ID-Sheet beim Backgrounden ohnehin abgerissen).
inFlight.current = false;
setBusy(false);
} else if (next === 'active' && wasBackground) {
wasBackground = false;
tryUnlock();
}
}, [tryUnlock]);
// Rückkehr aus dem Hintergrund zur noch gesperrten App → erneut prompten
useEffect(() => {
let prev = AppState.currentState;
const sub = AppState.addEventListener('change', (next) => {
if (prev === 'background' && next === 'active') tryUnlock();
prev = next;
});
return () => sub.remove();
}, [tryUnlock]);

View File

@ -102,6 +102,7 @@ export const useAppLockStore = create<AppLockState>((set, get) => ({
authenticate: async (promptMessage) => {
if (!LocalAuthentication) return false;
try {
const result = await LocalAuthentication.authenticateAsync({
promptMessage: promptMessage ?? DEFAULT_PROMPT,
// Geräte-Passcode als Fallback erlauben (Face ID schlägt 3x fehl → Passcode).
@ -114,5 +115,11 @@ export const useAppLockStore = create<AppLockState>((set, get) => ({
return true;
}
return false;
} catch {
// Nativer Reject (z.B. „authentication already in progress") darf nicht als
// unhandled rejection durchschlagen — der LockScreen-Latch (finally) muss
// sich verlassen können, dass dieser Call immer settled.
return false;
}
},
}));