From c4fe7d356fbc9bb33565e9e4ecad6c2c1a223f4d Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Wed, 10 Jun 2026 15:11:12 +0200 Subject: [PATCH] fix(rebreak-native): iOS app-lock freeze (Face ID stuck, needs hard-quit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/rebreak-native/components/LockScreen.tsx | 57 ++++++++++++------- apps/rebreak-native/stores/appLock.ts | 29 ++++++---- 2 files changed, 56 insertions(+), 30 deletions(-) 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; }, }));