Files
certain/ios/Anmeldung/SperrAnsicht.swift
T
GuidoandClaude Opus 5 09ec2dc5fd 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>
2026-10-24 18:09:05 +02:00

211 lines
7.5 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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() }
}
}