diff --git a/apps/rebreak-native/components/LockScreen.tsx b/apps/rebreak-native/components/LockScreen.tsx index 795c796..5ccfa09 100644 --- a/apps/rebreak-native/components/LockScreen.tsx +++ b/apps/rebreak-native/components/LockScreen.tsx @@ -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 - * inactive→active-Transition direkt nach einem abgebrochenen Prompt löst NICHT - * neu aus, nur background→active). + * User ab, bleibt der „Entsperren"-Button stehen. + * + * iOS-Freeze-Falle (vormals: Screen blieb hängen, nur Hard-Quit half): + * 1. Foreground läuft als background→inactive→active. 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 active→inactive→active. 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 active↔inactive-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') { - tryUnlock(); - } - }, [tryUnlock]); + 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(); - // 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; + 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(); + } }); return () => sub.remove(); }, [tryUnlock]); diff --git a/apps/rebreak-native/stores/appLock.ts b/apps/rebreak-native/stores/appLock.ts index 7dce9ef..42089f4 100644 --- a/apps/rebreak-native/stores/appLock.ts +++ b/apps/rebreak-native/stores/appLock.ts @@ -102,17 +102,24 @@ export const useAppLockStore = create((set, get) => ({ authenticate: async (promptMessage) => { if (!LocalAuthentication) return false; - const result = await LocalAuthentication.authenticateAsync({ - promptMessage: promptMessage ?? DEFAULT_PROMPT, - // Geräte-Passcode als Fallback erlauben (Face ID schlägt 3x fehl → Passcode). - // Wichtig, damit man sich nicht aus der App aussperrt. - disableDeviceFallback: false, - cancelLabel: undefined, - }); - if (result.success) { - set({ locked: false }); - return true; + try { + const result = await LocalAuthentication.authenticateAsync({ + promptMessage: promptMessage ?? DEFAULT_PROMPT, + // Geräte-Passcode als Fallback erlauben (Face ID schlägt 3x fehl → Passcode). + // Wichtig, damit man sich nicht aus der App aussperrt. + disableDeviceFallback: false, + cancelLabel: undefined, + }); + if (result.success) { + set({ locked: false }); + 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; } - return false; }, }));