feat: Bildschirmsperre mit Face ID und PIN
Beim ersten Start fuehrt kein Weg an einer sechsstelligen PIN vorbei. Danach sperrt die App beim Kaltstart und beim Wegschalten; kommt man binnen einer Minute zurueck, geht sie ohne Nachfrage wieder auf. Face ID zuerst, die PIN als Rueckfallweg und als einziger Weg ohne Biometrie. Nach fuenf falschen PINs meldet die App ab. Gesperrt wird schon bei inactive, nicht erst im Hintergrund - sonst stuenden die Kontostaende im App-Umschalter. Von der PIN liegt nur ein gesalzener SHA-256 im Schluesselbund. Die Gerätemarke selbst bleibt wie bisher abgelegt: Die Sperre verdeckt die Oberflaeche, sie verschluesselt nichts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
import LocalAuthentication
|
||||
import SwiftUI
|
||||
|
||||
/// Der Sperrbildschirm. Face ID zuerst, die PIN als Rückfallweg – und als
|
||||
/// einziger Weg, wenn das Gerät keine Biometrie hat oder sie abgelehnt wurde.
|
||||
///
|
||||
/// Die Sperre verdeckt die Oberfläche; die Gerätemarke bleibt im
|
||||
/// Schlüsselbund wie zuvor (siehe `Ablage.hatPIN`). Nach fünf falschen PINs
|
||||
/// meldet die App ab, sonst liesse sich eine sechsstellige PIN in Ruhe
|
||||
/// durchprobieren.
|
||||
struct SperrAnsicht: View {
|
||||
@Environment(AppZustand.self) private var zustand
|
||||
@State private var pin = ""
|
||||
@State private var meldung: String?
|
||||
@State private var biometrieLaeuft = false
|
||||
@FocusState private var imFeld: Bool
|
||||
|
||||
/// Face ID, Touch ID oder gar nichts – entscheidet Beschriftung und Symbol.
|
||||
private var biometrie: LABiometryType {
|
||||
let pruefer = LAContext()
|
||||
guard pruefer.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics,
|
||||
error: nil) else { return .none }
|
||||
return pruefer.biometryType
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 28) {
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.system(size: 44, weight: .medium))
|
||||
.foregroundStyle(Color.marke)
|
||||
|
||||
Text(String(localized: "sperre.titel"))
|
||||
.font(.title2.weight(.semibold))
|
||||
|
||||
VStack(spacing: 12) {
|
||||
SecureField(String(localized: "sperre.pin"), text: $pin)
|
||||
.textContentType(.password)
|
||||
.keyboardType(.numberPad)
|
||||
.multilineTextAlignment(.center)
|
||||
.monospaced()
|
||||
.font(.title3)
|
||||
.focused($imFeld)
|
||||
.padding(.vertical, 12)
|
||||
.background(.quaternary, in: RoundedRectangle(cornerRadius: 12,
|
||||
style: .continuous))
|
||||
.onChange(of: pin) { _, neu in
|
||||
pin = String(neu.filter(\.isNumber).prefix(Ziffern.anzahl))
|
||||
if pin.count == Ziffern.anzahl { pruefe() }
|
||||
}
|
||||
|
||||
if let meldung {
|
||||
Text(meldung)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.red)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 240)
|
||||
|
||||
if biometrie != .none {
|
||||
Button {
|
||||
Task { await versucheBiometrie() }
|
||||
} label: {
|
||||
Label(biometrieName, systemImage: biometrieSymbol)
|
||||
}
|
||||
.disabled(biometrieLaeuft)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(String(localized: "sperre.abmelden"), role: .destructive) {
|
||||
Task { await zustand.meldeAb() }
|
||||
}
|
||||
.font(.footnote)
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(.systemBackground))
|
||||
.task { await versucheBiometrie() }
|
||||
}
|
||||
|
||||
private var biometrieName: String {
|
||||
biometrie == .touchID
|
||||
? String(localized: "sperre.touchid")
|
||||
: String(localized: "sperre.faceid")
|
||||
}
|
||||
|
||||
private var biometrieSymbol: String {
|
||||
biometrie == .touchID ? "touchid" : "faceid"
|
||||
}
|
||||
|
||||
/// Fragt Face ID. Jede Ablehnung – abgebrochen, nicht erkannt, nicht
|
||||
/// eingerichtet – führt schlicht zum PIN-Feld; das ist der Rückfallweg.
|
||||
private func versucheBiometrie() async {
|
||||
guard biometrie != .none, !biometrieLaeuft else {
|
||||
imFeld = true
|
||||
return
|
||||
}
|
||||
biometrieLaeuft = true
|
||||
defer { biometrieLaeuft = false }
|
||||
let pruefer = LAContext()
|
||||
// Der Systemdialog bietet sonst „Passcode eingeben" an – das wäre der
|
||||
// Gerätecode, nicht unsere PIN, und damit verwirrend.
|
||||
pruefer.localizedFallbackTitle = ""
|
||||
let geglueckt = (try? await pruefer.evaluatePolicy(
|
||||
.deviceOwnerAuthenticationWithBiometrics,
|
||||
localizedReason: String(localized: "sperre.grund"))) ?? false
|
||||
if geglueckt {
|
||||
zustand.entsperrt()
|
||||
} else {
|
||||
imFeld = true
|
||||
}
|
||||
}
|
||||
|
||||
private func pruefe() {
|
||||
guard Ablage.stimmtPIN(pin) else {
|
||||
pin = ""
|
||||
Task {
|
||||
let weiter = await zustand.falschePIN()
|
||||
if weiter {
|
||||
meldung = String(format: String(localized: "sperre.falsch"),
|
||||
zustand.verbleibendeVersuche)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
meldung = nil
|
||||
zustand.entsperrt()
|
||||
}
|
||||
}
|
||||
|
||||
enum Ziffern {
|
||||
/// Sechs Stellen, wie der Gerätecode und wie der zweite Faktor.
|
||||
static let anzahl = 6
|
||||
}
|
||||
|
||||
/// PIN festlegen oder ändern. Beim ersten Mal führt kein Weg daran vorbei –
|
||||
/// darum gibt es dann kein „Abbrechen".
|
||||
struct PINEinrichtenAnsicht: View {
|
||||
@Environment(AppZustand.self) private var zustand
|
||||
@Environment(\.dismiss) private var schliesse
|
||||
/// Beim ersten Einrichten Pflicht, beim Ändern aus den Einstellungen nicht.
|
||||
var pflicht = true
|
||||
|
||||
@State private var neue = ""
|
||||
@State private var wiederholung = ""
|
||||
@State private var meldung: String?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
SecureField(String(localized: "pin.neu"), text: $neue)
|
||||
.keyboardType(.numberPad)
|
||||
.monospaced()
|
||||
.onChange(of: neue) { _, wert in
|
||||
neue = String(wert.filter(\.isNumber).prefix(Ziffern.anzahl))
|
||||
}
|
||||
SecureField(String(localized: "pin.wiederholen"), text: $wiederholung)
|
||||
.keyboardType(.numberPad)
|
||||
.monospaced()
|
||||
.onChange(of: wiederholung) { _, wert in
|
||||
wiederholung = String(wert.filter(\.isNumber).prefix(Ziffern.anzahl))
|
||||
}
|
||||
} footer: {
|
||||
// Keine Überschrift: Die steht schon in der Leiste oben.
|
||||
Text(String(localized: "pin.erklaerung"))
|
||||
}
|
||||
|
||||
if let meldung {
|
||||
Section {
|
||||
Label(meldung, systemImage: "exclamationmark.triangle")
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button(String(localized: "pin.sichern"), action: lege)
|
||||
.disabled(neue.count < Ziffern.anzahl
|
||||
|| wiederholung.count < Ziffern.anzahl)
|
||||
}
|
||||
}
|
||||
.navigationTitle(String(localized: "pin.einrichtenTitel"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
if !pflicht {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(String(localized: "allgemein.abbrechen")) { schliesse() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func lege() {
|
||||
guard neue.count == Ziffern.anzahl else {
|
||||
meldung = String(localized: "pin.zuKurz"); return
|
||||
}
|
||||
guard neue == wiederholung else {
|
||||
meldung = String(localized: "pin.ungleich")
|
||||
wiederholung = ""
|
||||
return
|
||||
}
|
||||
zustand.setzePIN(neue)
|
||||
if !pflicht { schliesse() }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user