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:
parent
adb686d9a3
commit
c4fe7d356f
@ -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') {
|
||||
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]);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user