diff --git a/ios/Anmeldung/SperrAnsicht.swift b/ios/Anmeldung/SperrAnsicht.swift new file mode 100644 index 0000000..42ed39f --- /dev/null +++ b/ios/Anmeldung/SperrAnsicht.swift @@ -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() } + } +} diff --git a/ios/App/AppZustand.swift b/ios/App/AppZustand.swift index cc38fab..bc3e763 100644 --- a/ios/App/AppZustand.swift +++ b/ios/App/AppZustand.swift @@ -43,6 +43,22 @@ final class AppZustand { /// Läuft gerade ein Anmeldeschritt? Sperrt die Knöpfe. private(set) var beschaeftigt = false + // MARK: - Bildschirmsperre + + /// Liegt der Sperrbildschirm über der App? + private(set) var gesperrt = false + /// Ob eine PIN eingerichtet ist. Als Eigenschaft und nicht direkt aus der + /// Ablage gelesen, damit die Ansicht nach dem Einrichten neu zeichnet. + private(set) var hatPIN = Ablage.hatPIN + private var seitWannWeg: Date? + private var fehlversuche = 0 + + /// So lange darf man weg sein, ohne erneut zu entsperren. + private static let gnadenfrist: TimeInterval = 60 + /// Danach fliegt die Marke raus und es geht zur Anmeldung. Ohne Grenze + /// wäre eine sechsstellige PIN in Ruhe durchprobierbar. + private static let maxFehlversuche = 5 + var serverAdresse: URL? { Ablage.serverAdresse } // MARK: - Start @@ -54,6 +70,9 @@ final class AppZustand { } let neuer = CertainClient(basis: adresse, marke: Ablage.marke) client = neuer + // Kaltstart: wer eine PIN hat, sieht zuerst den Sperrbildschirm. + hatPIN = Ablage.hatPIN + gesperrt = hatPIN guard Ablage.marke != nil else { lage = .brauchtAnmeldung return @@ -237,6 +256,9 @@ final class AppZustand { let adresse = adresseBehalten ? Ablage.serverAdresse : nil Ablage.raeumeAuf() Ablage.serverAdresse = adresse + hatPIN = false + gesperrt = false + fehlversuche = 0 if let adresse { client = CertainClient(basis: adresse) lage = .brauchtAnmeldung @@ -246,6 +268,48 @@ final class AppZustand { } } + // MARK: - Sperren und entsperren + + /// iOS fotografiert die App beim Wegschalten für den App-Umschalter. + /// Darum wird sofort gesperrt und nicht erst beim Zurückkommen – sonst + /// stünden die Kontostände im Umschalter. + func gehtWeg() { + guard hatPIN, !gesperrt else { return } + seitWannWeg = Date() + gesperrt = true + } + + /// Kurz weggewischt – etwa für eine eintreffende Nachricht – geht ohne + /// Nachfrage wieder auf. Erst nach der Gnadenfrist wird gefragt. + func kommtZurueck() { + guard let weg = seitWannWeg else { return } + seitWannWeg = nil + if Date().timeIntervalSince(weg) <= Self.gnadenfrist { gesperrt = false } + } + + func entsperrt() { + gesperrt = false + fehlversuche = 0 + } + + /// Meldet einen Fehlversuch. Gibt zurück, ob noch Versuche übrig sind; + /// beim letzten meldet die App ab. + func falschePIN() async -> Bool { + fehlversuche += 1 + guard fehlversuche >= Self.maxFehlversuche else { return true } + await meldeAb() + return false + } + + var verbleibendeVersuche: Int { max(0, Self.maxFehlversuche - fehlversuche) } + + func setzePIN(_ pin: String) { + Ablage.setzePIN(pin) + hatPIN = true + gesperrt = false + fehlversuche = 0 + } + func aktualisiereEinstellungen() async { guard let client else { return } if let frisch = try? await client.einstellungen() { einstellungen = frisch } diff --git a/ios/App/CertainApp.swift b/ios/App/CertainApp.swift index de38b34..b22aa77 100644 --- a/ios/App/CertainApp.swift +++ b/ios/App/CertainApp.swift @@ -3,6 +3,7 @@ import SwiftUI @main struct CertainApp: App { @State private var zustand = AppZustand() + @Environment(\.scenePhase) private var phase var body: some Scene { // Das Erscheinungsbild kommt aus den Server-Einstellungen, damit App @@ -22,6 +23,16 @@ struct CertainApp: App { // Einmal hier, damit jeder Knopf, Schalter und Link // die Markenfarbe traegt statt des System-Blaus. .tint(.marke) + .onChange(of: phase) { _, neue in + // `inactive` kommt, bevor iOS das Bild für den + // App-Umschalter macht. Dort soll der Sperrbildschirm + // stehen, nicht der Kontostand. + switch neue { + case .inactive, .background: zustand.gehtWeg() + case .active: zustand.kommtZurueck() + @unknown default: break + } + } } } } diff --git a/ios/App/RootAnsicht.swift b/ios/App/RootAnsicht.swift index ebc1c00..45478cc 100644 --- a/ios/App/RootAnsicht.swift +++ b/ios/App/RootAnsicht.swift @@ -28,7 +28,15 @@ struct RootAnsicht: View { ZweitenFaktorEinrichtenHinweis() case .bereit: - Hauptnavigation() + // Reihenfolge mit Absicht: Ohne PIN kommt niemand an die Zahlen, + // und der Sperrbildschirm liegt vor allem anderen. + if !zustand.hatPIN { + PINEinrichtenAnsicht() + } else if zustand.gesperrt { + SperrAnsicht() + } else { + Hauptnavigation() + } } } } diff --git a/ios/Certain.xcodeproj/project.pbxproj b/ios/Certain.xcodeproj/project.pbxproj index 8977dcd..57328df 100644 --- a/ios/Certain.xcodeproj/project.pbxproj +++ b/ios/Certain.xcodeproj/project.pbxproj @@ -56,6 +56,7 @@ EE4F2382CFF3F60FF5AC8241 /* CertainClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77E5A58246E6A9D72E99C2F2 /* CertainClient.swift */; }; F2C67D902D1AC96A60691687 /* AboFormular.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFABF1A481445B1CD960C993 /* AboFormular.swift */; }; F414F982412C79242CADB473 /* Ablage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13C14F1D9CEF39FF3E095239 /* Ablage.swift */; }; + F8BC5F736C54FFFAD25670D5 /* SperrAnsicht.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C618BE3CBCEBBF65F79A63D /* SperrAnsicht.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -82,9 +83,10 @@ 3114763A11EC90E1D38C9DEF /* Nebenraeume.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nebenraeume.swift; sourceTree = ""; }; 3137E525D1ADE3DB2F8E45A8 /* antworten.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = antworten.json; sourceTree = ""; }; 327787E4869ED7D0C7E9EE63 /* Buchung.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Buchung.swift; sourceTree = ""; }; + 3C618BE3CBCEBBF65F79A63D /* SperrAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SperrAnsicht.swift; sourceTree = ""; }; 3F7647AF036D2CD6339FA287 /* AuswahlBlaetter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuswahlBlaetter.swift; sourceTree = ""; }; 4119B85EFF3FB38E84FAA913 /* Konto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Konto.swift; sourceTree = ""; }; - 416FD5B020E78140037306C8 /* Certain.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Certain.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 416FD5B020E78140037306C8 /* Certain.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Certain.app; sourceTree = BUILT_PRODUCTS_DIR; }; 451BA41D9B53FB6B511AF734 /* Darstellung.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Darstellung.swift; sourceTree = ""; }; 4AFC43300F101BE4F4541BA2 /* KontoFormular.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KontoFormular.swift; sourceTree = ""; }; 4E5EA4F794EFAD4CB4EE63B4 /* ArbeitslisteAnsicht.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArbeitslisteAnsicht.swift; sourceTree = ""; }; @@ -209,6 +211,7 @@ DDF0ED12E1BBB42E39295725 /* AnmeldeAnsicht.swift */, 52A55D83C342BDB64428D8F9 /* PasswortWechselAnsicht.swift */, A987A2CB878E52BAF6A9808D /* ServerAnsicht.swift */, + 3C618BE3CBCEBBF65F79A63D /* SperrAnsicht.swift */, A39A3E42F80E88125AB372BD /* ZweiFaktorAnsicht.swift */, ); path = Anmeldung; @@ -465,6 +468,7 @@ B2470AAE64D657CF7CDEA099 /* PasswortWechselAnsicht.swift in Sources */, B90D679A31C1627CC7B788DA /* RootAnsicht.swift in Sources */, 3E574E1D2C0CDB24C652190C /* ServerAnsicht.swift in Sources */, + F8BC5F736C54FFFAD25670D5 /* SperrAnsicht.swift in Sources */, D5800913A3A01494BEFE85C5 /* TagsAnsicht.swift in Sources */, 7AFCD4ADF85329F373C89E06 /* Zeitangaben.swift in Sources */, 25EC32ED2FF88F4286B752A7 /* Zustandsansichten.swift in Sources */, @@ -508,6 +512,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = Certain; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance"; + INFOPLIST_KEY_NSFaceIDUsageDescription = "Damit entsperrst du Certain, ohne deine PIN zu tippen."; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; @@ -694,6 +699,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = Certain; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance"; + INFOPLIST_KEY_NSFaceIDUsageDescription = "Damit entsperrst du Certain, ohne deine PIN zu tippen."; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; diff --git a/ios/Einstellungen/EinstellungenAnsicht.swift b/ios/Einstellungen/EinstellungenAnsicht.swift index 2e1ad5e..b082c28 100644 --- a/ios/Einstellungen/EinstellungenAnsicht.swift +++ b/ios/Einstellungen/EinstellungenAnsicht.swift @@ -10,6 +10,7 @@ struct EinstellungenAnsicht: View { @State private var vorgang = Speichervorgang() @State private var zeigeAbmeldefrage = false @State private var markierte: [MarkiertesWort] = [] + @State private var aenderePIN = false var body: some View { Form { @@ -27,6 +28,11 @@ struct EinstellungenAnsicht: View { } label: { Label(String(localized: "zweifaktor.titel"), systemImage: "lock.shield") } + Button { + aenderePIN = true + } label: { + Label(String(localized: "pin.aendern"), systemImage: "lock.rotation") + } } header: { Text(String(localized: "einstellungen.zugang")) } footer: { @@ -104,6 +110,9 @@ struct EinstellungenAnsicht: View { Text(String(localized: "einstellungen.abmeldenErklaerung")) } } + .sheet(isPresented: $aenderePIN) { + PINEinrichtenAnsicht(pflicht: false) + } .navigationTitle(String(localized: "reiter.einstellungen")) .refreshable { await lade() } .task { await lade() } diff --git a/ios/Netzwerk/Ablage.swift b/ios/Netzwerk/Ablage.swift index 89e1a26..31d52d6 100644 --- a/ios/Netzwerk/Ablage.swift +++ b/ios/Netzwerk/Ablage.swift @@ -1,3 +1,4 @@ +import CryptoKit import Foundation import Security @@ -26,6 +27,7 @@ enum Ablage { static let serverAdresse = "certain.serverAdresse" static let marke = "certain.marke" static let benutzername = "certain.benutzername" + static let pin = "certain.pin" } // MARK: - Server-Adresse @@ -71,9 +73,54 @@ enum Ablage { } } + // MARK: - Bildschirmsperre + + /// Die PIN selbst wird nie abgelegt, nur ein gesalzener SHA-256 davon. + /// Das Salz liegt daneben, beides im Schlüsselbund als `salz:hash` in Hex. + /// + /// Die PIN verdeckt die Oberfläche, sie verschlüsselt nicht die + /// Gerätemarke – wer das Telefon entsperrt hat und es auslesen kann, + /// käme an die Marke. Gegen Mitlesen über die Schulter und gegen ein + /// kurz liegengelassenes Telefon hilft sie, gegen Forensik nicht. + static var hatPIN: Bool { schluesselbundLesen(Schluessel.pin) != nil } + + static func setzePIN(_ pin: String) { + var salz = Data(count: 16) + _ = salz.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) } + schluesselbundSchreiben(Schluessel.pin, "\(hex(salz)):\(hex(verhashe(pin, salz: salz)))") + } + + static func stimmtPIN(_ pin: String) -> Bool { + guard let abgelegt = schluesselbundLesen(Schluessel.pin) else { return false } + let teile = abgelegt.split(separator: ":", maxSplits: 1) + guard teile.count == 2, let salz = daten(hex: String(teile[0])) else { return false } + return hex(verhashe(pin, salz: salz)) == String(teile[1]) + } + + static func loeschePIN() { schluesselbundLoeschen(Schluessel.pin) } + + private static func verhashe(_ pin: String, salz: Data) -> Data { + Data(SHA256.hash(data: salz + Data(pin.utf8))) + } + + private static func hex(_ daten: Data) -> String { + daten.map { String(format: "%02x", $0) }.joined() + } + + private static func daten(hex text: String) -> Data? { + guard text.count % 2 == 0 else { return nil } + var roh = Data(); var rest = Substring(text) + while !rest.isEmpty { + guard let wert = UInt8(rest.prefix(2), radix: 16) else { return nil } + roh.append(wert); rest = rest.dropFirst(2) + } + return roh + } + /// Alles vergessen – beim Abmelden und wenn der Server die Marke ablehnt. static func raeumeAuf() { marke = nil + loeschePIN() benutzername = nil serverAdresse = nil Zwischenspeicher.leere() diff --git a/ios/Texte/de.lproj/Localizable.strings b/ios/Texte/de.lproj/Localizable.strings index cbe4e4a..df98574 100644 --- a/ios/Texte/de.lproj/Localizable.strings +++ b/ios/Texte/de.lproj/Localizable.strings @@ -358,3 +358,20 @@ "bank.abrufFertig" = "Abruf fertig"; "bank.abrufErgebnis" = "%1$d Umsätze geholt, davon %2$d neu."; "bank.ohneZuordnung" = "Dieser Anbindung ist noch kein Konto zugeordnet. Das geht am Rechner."; + +/* Bildschirmsperre */ +"sperre.titel" = "Certain ist gesperrt"; +"sperre.pin" = "PIN"; +"sperre.faceid" = "Mit Face ID entsperren"; +"sperre.touchid" = "Mit Touch ID entsperren"; +"sperre.grund" = "Entsperrt Certain, ohne die PIN zu tippen."; +"sperre.falsch" = "Falsche PIN. Noch %d Versuche, dann meldet sich die App ab."; +"sperre.abmelden" = "PIN vergessen? Abmelden"; +"pin.einrichtenTitel" = "PIN festlegen"; +"pin.erklaerung" = "Sechs Ziffern. Du brauchst sie, wenn Face ID nicht greift – und bei jedem Start."; +"pin.neu" = "Neue PIN"; +"pin.wiederholen" = "Noch einmal"; +"pin.zuKurz" = "Die PIN muss sechs Ziffern haben."; +"pin.ungleich" = "Die beiden Eingaben sind verschieden."; +"pin.sichern" = "Festlegen"; +"pin.aendern" = "PIN ändern"; diff --git a/ios/Texte/en.lproj/Localizable.strings b/ios/Texte/en.lproj/Localizable.strings index 33f7f07..4071133 100644 --- a/ios/Texte/en.lproj/Localizable.strings +++ b/ios/Texte/en.lproj/Localizable.strings @@ -358,3 +358,20 @@ "bank.abrufFertig" = "Fetch complete"; "bank.abrufErgebnis" = "Fetched %1$d transactions, %2$d of them new."; "bank.ohneZuordnung" = "This connection has no account assigned yet. That is done on the desktop."; + +/* Screen lock */ +"sperre.titel" = "Certain is locked"; +"sperre.pin" = "PIN"; +"sperre.faceid" = "Unlock with Face ID"; +"sperre.touchid" = "Unlock with Touch ID"; +"sperre.grund" = "Unlocks Certain without typing your PIN."; +"sperre.falsch" = "Wrong PIN. %d attempts left, then the app signs out."; +"sperre.abmelden" = "Forgot your PIN? Sign out"; +"pin.einrichtenTitel" = "Set a PIN"; +"pin.erklaerung" = "Six digits. You need them when Face ID does not work – and on every start."; +"pin.neu" = "New PIN"; +"pin.wiederholen" = "Again"; +"pin.zuKurz" = "The PIN must be six digits."; +"pin.ungleich" = "The two entries differ."; +"pin.sichern" = "Set PIN"; +"pin.aendern" = "Change PIN"; diff --git a/ios/project.yml b/ios/project.yml index 7b1f3da..a09d6d4 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -47,8 +47,13 @@ targets: DEVELOPMENT_TEAM: PP34X97WS3 MARKETING_VERSION: "0.1" CURRENT_PROJECT_VERSION: "1" - # iPhone und iPad, zweispaltig gedacht. - TARGETED_DEVICE_FAMILY: "1,2" + # Nur iPhone, nur Hochformat - am Rechner gibt es die Weboberflaeche. + TARGETED_DEVICE_FAMILY: "1" + SUPPORTED_PLATFORMS: "iphoneos iphonesimulator" + SUPPORTS_MACCATALYST: NO + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD: NO + INFOPLIST_KEY_LSApplicationCategoryType: "public.app-category.finance" # Sprachfassung 5: der Code ist nicht auf die strikte # Nebenlaeufigkeitspruefung von Swift 6 hin geschrieben. Umstellen, # wenn die Uebergaenge AppZustand <-> CertainClient geprueft sind. @@ -58,7 +63,10 @@ targets: INFOPLIST_KEY_CFBundleDisplayName: Certain INFOPLIST_KEY_UILaunchScreen_Generation: YES INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES - INFOPLIST_KEY_UISupportedInterfaceOrientations: "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight" + INFOPLIST_KEY_UISupportedInterfaceOrientations: "UIInterfaceOrientationPortrait" + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait" + # Ohne diesen Text stuerzt die App beim ersten Face-ID-Versuch ab. + INFOPLIST_KEY_NSFaceIDUsageDescription: "Damit entsperrst du Certain, ohne deine PIN zu tippen." CertainTests: type: bundle.unit-test