feat: Erste Stufe der iOS-App - Anmeldung und Kontenliste
Neuer Ordner ios/ mit dem Geruest: Netzwerkschicht, Modelle, Offline-Ablage, Texte in Deutsch und Englisch, Navigation, der komplette Anmeldeweg (Server waehlen, anmelden, zweiter Faktor, Pflicht-Passwortwechsel) und als erste Fachansicht die Kontenliste. Die Anmeldung laeuft einmal ueber das Sitzungs-Cookie, holt sich dabei eine App-Marke und legt sie in den Schluesselbund. Danach traegt nur noch der Header. Kein Passwort auf dem Geraet, kein Ablauf nach 30 Tagen. Der Code ist nie uebersetzt worden - hier gibt es kein Xcode. Geprueft ist dafuer jedes Modellfeld gegen echte Antworten des laufenden Backends; dabei kam heraus, dass created_at ohne Zeitzone und mit Mikrosekunden kommt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8a7545bf5b
commit
6b86a6bf91
@@ -0,0 +1,106 @@
|
||||
import Foundation
|
||||
|
||||
/// Was beim Reden mit dem Certain-Backend schiefgehen kann.
|
||||
///
|
||||
/// Die Fälle sind nach dem geschnitten, was die App damit *tun* muss – nicht
|
||||
/// nach HTTP-Status. `pflichtOffen` führt auf einen anderen Bildschirm,
|
||||
/// `konflikt` ist eine Rückfrage, die sich mit „trotzdem" wiederholen lässt,
|
||||
/// alles übrige ist eine Meldung.
|
||||
enum APIFehler: Error, LocalizedError, Sendable, Equatable {
|
||||
/// Netz weg, Server nicht erreichbar, Zeitüberschreitung.
|
||||
case keineVerbindung(String)
|
||||
/// Unter der Adresse antwortet etwas, aber kein Certain.
|
||||
case keinCertainServer
|
||||
/// 401 – Marke ungültig oder widerrufen, Sitzung abgelaufen.
|
||||
case nichtAngemeldet
|
||||
/// 403 mit maschinenlesbarem Schlüssel: erst muss etwas erledigt werden.
|
||||
case pflichtOffen(Pflicht)
|
||||
/// 403 aus einem anderen Grund.
|
||||
case verboten(String)
|
||||
/// 404 – gibt es nicht, oder gehört jemand anderem.
|
||||
case nichtGefunden
|
||||
/// 409 – meistens eine Rückfrage, die sich mit `trotzdem = true`
|
||||
/// wiederholen lässt. Der Text ist für den Nutzer geschrieben.
|
||||
case konflikt(String)
|
||||
/// 400 oder 422.
|
||||
case ungueltig(String)
|
||||
/// Alles übrige, was der Server sagt.
|
||||
case server(status: Int, text: String)
|
||||
/// Die Antwort passt nicht zu dem, was wir erwarten.
|
||||
case antwortUnlesbar(String)
|
||||
|
||||
/// Was der Benutzer erledigen muss, bevor die API ihn durchlässt.
|
||||
/// Die Werte sind die Schlüssel aus `backend/app/deps.py`.
|
||||
enum Pflicht: String, Sendable {
|
||||
case passwortWechseln = "password_change_required"
|
||||
case zweiterFaktor = "mfa_setup_required"
|
||||
}
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .keineVerbindung(let grund):
|
||||
return String(format: String(localized: "fehler.keineVerbindung"), grund)
|
||||
case .keinCertainServer:
|
||||
return String(localized: "fehler.keinCertainServer")
|
||||
case .nichtAngemeldet:
|
||||
return String(localized: "fehler.nichtAngemeldet")
|
||||
case .pflichtOffen(.passwortWechseln):
|
||||
return String(localized: "fehler.passwortWechseln")
|
||||
case .pflichtOffen(.zweiterFaktor):
|
||||
return String(localized: "fehler.zweiterFaktor")
|
||||
case .verboten(let text), .konflikt(let text), .ungueltig(let text):
|
||||
return text
|
||||
case .nichtGefunden:
|
||||
return String(localized: "fehler.nichtGefunden")
|
||||
case .server(let status, let text):
|
||||
return text.isEmpty
|
||||
? String(format: String(localized: "fehler.server"), status)
|
||||
: text
|
||||
case .antwortUnlesbar(let grund):
|
||||
return String(format: String(localized: "fehler.antwortUnlesbar"), grund)
|
||||
}
|
||||
}
|
||||
|
||||
/// Ob ein erneuter Versuch überhaupt Sinn ergibt.
|
||||
var lohntWiederholung: Bool {
|
||||
switch self {
|
||||
case .keineVerbindung, .server:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Der Fehlerkörper des Backends.
|
||||
///
|
||||
/// `detail` ist dort mal eine Zeichenkette (`{"detail": "Nicht gefunden"}`),
|
||||
/// mal eine Liste von Pydantic-Meldungen (bei 422). Beides muss hier durch,
|
||||
/// sonst verliert die App bei jedem Eingabefehler die Meldung.
|
||||
struct Fehlerantwort: Decodable, Sendable {
|
||||
let detail: String
|
||||
|
||||
private enum Schluessel: String, CodingKey { case detail }
|
||||
|
||||
private struct Einzelfehler: Decodable {
|
||||
let msg: String?
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let behaelter = try decoder.container(keyedBy: Schluessel.self)
|
||||
if let text = try? behaelter.decode(String.self, forKey: .detail) {
|
||||
detail = text
|
||||
} else if let liste = try? behaelter.decode([Einzelfehler].self, forKey: .detail) {
|
||||
detail = liste.compactMap(\.msg).joined(separator: ", ")
|
||||
} else {
|
||||
detail = ""
|
||||
}
|
||||
}
|
||||
|
||||
/// Versucht, aus Antwortdaten eine Meldung zu ziehen. Schlägt das fehl,
|
||||
/// kommt eine leere Zeichenkette – nie ein Fehler, denn wir sind hier
|
||||
/// bereits im Fehlerfall.
|
||||
static func ausDaten(_ daten: Data) -> String {
|
||||
(try? JSONDecoder().decode(Fehlerantwort.self, from: daten))?.detail ?? ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// Wo die App ihre Einstellungen und ihr Geheimnis hinlegt.
|
||||
///
|
||||
/// Zwei getrennte Orte, mit Absicht: Die Server-Adresse ist keine geheime
|
||||
/// Angabe und liegt in den Voreinstellungen. Die Gerätemarke ist ein
|
||||
/// Vollzugang und gehört in den Schlüsselbund – dort überlebt sie kein
|
||||
/// unverschlüsseltes Backup und keine Gerätesicherung ohne Passcode.
|
||||
enum Ablage {
|
||||
/// Setze das hier auf deine App-Gruppe, sobald es Erweiterungen gibt
|
||||
/// (`group.<deine-bundle-id>`). Solange `nil`, benutzt die App ihren
|
||||
/// eigenen Bereich – das genügt, bis Teilen-Menü und Kurzbefehle dazukommen.
|
||||
static let gruppe: String? = nil
|
||||
|
||||
/// Dieselbe Gruppe für den Schlüsselbund. Erst nötig, wenn eine
|
||||
/// Erweiterung an dieselbe Marke muss.
|
||||
static let schluesselbundGruppe: String? = nil
|
||||
|
||||
private static var voreinstellungen: UserDefaults {
|
||||
if let gruppe, let geteilt = UserDefaults(suiteName: gruppe) { return geteilt }
|
||||
return .standard
|
||||
}
|
||||
|
||||
private enum Schluessel {
|
||||
static let serverAdresse = "certain.serverAdresse"
|
||||
static let marke = "certain.marke"
|
||||
static let benutzername = "certain.benutzername"
|
||||
}
|
||||
|
||||
// MARK: - Server-Adresse
|
||||
|
||||
static var serverAdresse: URL? {
|
||||
get {
|
||||
guard let text = voreinstellungen.string(forKey: Schluessel.serverAdresse) else {
|
||||
return nil
|
||||
}
|
||||
return URL(string: text)
|
||||
}
|
||||
set {
|
||||
if let neue = newValue {
|
||||
voreinstellungen.set(neue.absoluteString, forKey: Schluessel.serverAdresse)
|
||||
} else {
|
||||
voreinstellungen.removeObject(forKey: Schluessel.serverAdresse)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Nur zur Anzeige („angemeldet als …"), nicht zur Prüfung.
|
||||
static var benutzername: String? {
|
||||
get { voreinstellungen.string(forKey: Schluessel.benutzername) }
|
||||
set {
|
||||
if let neuer = newValue {
|
||||
voreinstellungen.set(neuer, forKey: Schluessel.benutzername)
|
||||
} else {
|
||||
voreinstellungen.removeObject(forKey: Schluessel.benutzername)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Gerätemarke im Schlüsselbund
|
||||
|
||||
static var marke: String? {
|
||||
get { schluesselbundLesen(Schluessel.marke) }
|
||||
set {
|
||||
if let neue = newValue {
|
||||
schluesselbundSchreiben(Schluessel.marke, neue)
|
||||
} else {
|
||||
schluesselbundLoeschen(Schluessel.marke)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Alles vergessen – beim Abmelden und wenn der Server die Marke ablehnt.
|
||||
static func raeumeAuf() {
|
||||
marke = nil
|
||||
benutzername = nil
|
||||
serverAdresse = nil
|
||||
Zwischenspeicher.leere()
|
||||
}
|
||||
|
||||
// MARK: - Schlüsselbund, schmucklos
|
||||
|
||||
private static func grundlage(_ konto: String) -> [String: Any] {
|
||||
var abfrage: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: "com.certain.app",
|
||||
kSecAttrAccount as String: konto,
|
||||
]
|
||||
if let schluesselbundGruppe {
|
||||
abfrage[kSecAttrAccessGroup as String] = schluesselbundGruppe
|
||||
}
|
||||
return abfrage
|
||||
}
|
||||
|
||||
private static func schluesselbundLesen(_ konto: String) -> String? {
|
||||
var abfrage = grundlage(konto)
|
||||
abfrage[kSecReturnData as String] = true
|
||||
abfrage[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
|
||||
var ergebnis: CFTypeRef?
|
||||
let status = SecItemCopyMatching(abfrage as CFDictionary, &ergebnis)
|
||||
guard status == errSecSuccess, let daten = ergebnis as? Data else { return nil }
|
||||
return String(data: daten, encoding: .utf8)
|
||||
}
|
||||
|
||||
private static func schluesselbundSchreiben(_ konto: String, _ wert: String) {
|
||||
let abfrage = grundlage(konto)
|
||||
let inhalt: [String: Any] = [
|
||||
kSecValueData as String: Data(wert.utf8),
|
||||
// Nicht in ein Backup und nicht auf ein anderes Gerät: Die Marke
|
||||
// gehoert genau diesem Telefon.
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
||||
]
|
||||
let status = SecItemUpdate(abfrage as CFDictionary, inhalt as CFDictionary)
|
||||
if status == errSecItemNotFound {
|
||||
var neu = abfrage
|
||||
neu.merge(inhalt) { _, zweites in zweites }
|
||||
SecItemAdd(neu as CFDictionary, nil)
|
||||
}
|
||||
}
|
||||
|
||||
private static func schluesselbundLoeschen(_ konto: String) {
|
||||
SecItemDelete(grundlage(konto) as CFDictionary)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import Foundation
|
||||
|
||||
/// Der einzige Weg der App zum Backend.
|
||||
///
|
||||
/// Ein Actor, weil Adresse und Marke sich zur Laufzeit ändern (an- und
|
||||
/// abmelden) und von überall gelesen werden.
|
||||
///
|
||||
/// Zwei Anmeldewege, und zwar nacheinander, nicht nebeneinander:
|
||||
/// Beim Anmelden setzt das Backend ein Sitzungs-Cookie, das `URLSession`
|
||||
/// von selbst mitführt. Noch in dieser Sitzung holt sich die App eine
|
||||
/// Gerätemarke und benutzt danach nur noch den Header `X-Certain-Token`.
|
||||
/// Das Cookie wird dann weggeworfen – siehe `vergissCookies()`.
|
||||
actor CertainClient {
|
||||
private(set) var basis: URL
|
||||
private(set) var marke: String?
|
||||
|
||||
private let sitzung: URLSession
|
||||
private let decoder = JSONDecoder()
|
||||
private let encoder = JSONEncoder()
|
||||
|
||||
init(basis: URL, marke: String? = nil) {
|
||||
self.basis = basis
|
||||
self.marke = marke
|
||||
|
||||
let einstellungen = URLSessionConfiguration.ephemeral
|
||||
einstellungen.httpCookieStorage = HTTPCookieStorage.shared
|
||||
einstellungen.httpShouldSetCookies = true
|
||||
einstellungen.httpCookieAcceptPolicy = .always
|
||||
// Alle /api-Antworten tragen `Cache-Control: no-store`; den lokalen
|
||||
// Zwischenspeicher führen wir selbst (siehe `Zwischenspeicher`), weil
|
||||
// wir ihn auch offline und mit Datumsangabe brauchen.
|
||||
einstellungen.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||
einstellungen.timeoutIntervalForRequest = 30
|
||||
einstellungen.timeoutIntervalForResource = 120
|
||||
einstellungen.waitsForConnectivity = false
|
||||
sitzung = URLSession(configuration: einstellungen)
|
||||
}
|
||||
|
||||
// MARK: - Zustand
|
||||
|
||||
func setzeBasis(_ neue: URL) {
|
||||
basis = neue
|
||||
}
|
||||
|
||||
func setzeMarke(_ neue: String?) {
|
||||
marke = neue
|
||||
}
|
||||
|
||||
/// Nach dem Holen der Marke: Das Cookie wird nicht mehr gebraucht und soll
|
||||
/// nicht länger auf dem Gerät liegen als nötig.
|
||||
func vergissCookies() {
|
||||
guard let speicher = sitzung.configuration.httpCookieStorage else { return }
|
||||
for keks in speicher.cookies(for: basis) ?? [] {
|
||||
speicher.deleteCookie(keks)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Aufrufe
|
||||
|
||||
enum Methode: String, Sendable {
|
||||
case get = "GET"
|
||||
case post = "POST"
|
||||
case patch = "PATCH"
|
||||
case put = "PUT"
|
||||
case delete = "DELETE"
|
||||
}
|
||||
|
||||
/// Lesen.
|
||||
func hole<A: Decodable>(_ pfad: String, abfrage: [URLQueryItem] = []) async throws -> A {
|
||||
let daten = try await fuehreAus(.get, pfad, abfrage: abfrage, koerper: nil)
|
||||
return try entschluessele(daten)
|
||||
}
|
||||
|
||||
/// Schreiben ohne Nutzdaten (etwa `POST /uebertraege/suchen`).
|
||||
@discardableResult
|
||||
func schicke<A: Decodable>(_ methode: Methode, _ pfad: String,
|
||||
abfrage: [URLQueryItem] = []) async throws -> A {
|
||||
let daten = try await fuehreAus(methode, pfad, abfrage: abfrage, koerper: nil)
|
||||
return try entschluessele(daten)
|
||||
}
|
||||
|
||||
/// Schreiben mit JSON-Körper.
|
||||
@discardableResult
|
||||
func schicke<A: Decodable, K: Encodable>(_ methode: Methode, _ pfad: String,
|
||||
koerper: K,
|
||||
abfrage: [URLQueryItem] = []) async throws -> A {
|
||||
let roh = try encoder.encode(koerper)
|
||||
let daten = try await fuehreAus(methode, pfad, abfrage: abfrage, koerper: roh)
|
||||
return try entschluessele(daten)
|
||||
}
|
||||
|
||||
/// Für 204-Antworten: Es kommt nichts zurück, und das ist richtig so.
|
||||
func schickeStumm(_ methode: Methode, _ pfad: String,
|
||||
abfrage: [URLQueryItem] = []) async throws {
|
||||
_ = try await fuehreAus(methode, pfad, abfrage: abfrage, koerper: nil)
|
||||
}
|
||||
|
||||
func schickeStumm<K: Encodable>(_ methode: Methode, _ pfad: String,
|
||||
koerper: K,
|
||||
abfrage: [URLQueryItem] = []) async throws {
|
||||
let roh = try encoder.encode(koerper)
|
||||
_ = try await fuehreAus(methode, pfad, abfrage: abfrage, koerper: roh)
|
||||
}
|
||||
|
||||
/// Die Anmeldung geht als Formular, nicht als JSON – das Backend nutzt
|
||||
/// `OAuth2PasswordRequestForm` (`backend/app/routers/auth.py`).
|
||||
func schickeFormular<A: Decodable>(_ pfad: String,
|
||||
felder: [String: String]) async throws -> A {
|
||||
var teile = URLComponents()
|
||||
teile.queryItems = felder.map { URLQueryItem(name: $0.key, value: $0.value) }
|
||||
// `+` muss als Zeichen durch, sonst wird daraus beim Server ein Leerzeichen.
|
||||
let text = (teile.percentEncodedQuery ?? "").replacingOccurrences(of: "+", with: "%2B")
|
||||
let daten = try await fuehreAus(
|
||||
.post, pfad, abfrage: [], koerper: Data(text.utf8),
|
||||
inhaltstyp: "application/x-www-form-urlencoded")
|
||||
return try entschluessele(daten)
|
||||
}
|
||||
|
||||
/// Datei hochladen (Logos, Kontoauszüge, Belege).
|
||||
func schickeDatei<A: Decodable>(_ pfad: String, feldname: String = "file",
|
||||
dateiname: String, inhalt: Data,
|
||||
typ: String = "application/octet-stream",
|
||||
weitereFelder: [String: String] = [],
|
||||
abfrage: [URLQueryItem] = []) async throws -> A {
|
||||
let grenze = "Grenze-\(UUID().uuidString)"
|
||||
var koerper = Data()
|
||||
func anhaengen(_ text: String) { koerper.append(Data(text.utf8)) }
|
||||
|
||||
for (schluessel, wert) in weitereFelder {
|
||||
anhaengen("--\(grenze)\r\n")
|
||||
anhaengen("Content-Disposition: form-data; name=\"\(schluessel)\"\r\n\r\n")
|
||||
anhaengen("\(wert)\r\n")
|
||||
}
|
||||
anhaengen("--\(grenze)\r\n")
|
||||
anhaengen("Content-Disposition: form-data; name=\"\(feldname)\"; filename=\"\(dateiname)\"\r\n")
|
||||
anhaengen("Content-Type: \(typ)\r\n\r\n")
|
||||
koerper.append(inhalt)
|
||||
anhaengen("\r\n--\(grenze)--\r\n")
|
||||
|
||||
let daten = try await fuehreAus(
|
||||
.post, pfad, abfrage: abfrage, koerper: koerper,
|
||||
inhaltstyp: "multipart/form-data; boundary=\(grenze)")
|
||||
return try entschluessele(daten)
|
||||
}
|
||||
|
||||
/// Rohe Bytes holen – für Bilder (Abo-Logos) und Dateien.
|
||||
func holeBytes(_ pfad: String, abfrage: [URLQueryItem] = []) async throws -> Data {
|
||||
try await fuehreAus(.get, pfad, abfrage: abfrage, koerper: nil)
|
||||
}
|
||||
|
||||
// MARK: - Innenleben
|
||||
|
||||
private func baueAdresse(_ pfad: String, _ abfrage: [URLQueryItem]) throws -> URL {
|
||||
let voll = basis.appendingPathComponent("api").appendingPathComponent(
|
||||
pfad.hasPrefix("/") ? String(pfad.dropFirst()) : pfad)
|
||||
guard var teile = URLComponents(url: voll, resolvingAgainstBaseURL: false) else {
|
||||
throw APIFehler.antwortUnlesbar(pfad)
|
||||
}
|
||||
// Leere Werte gar nicht erst mitschicken – das Backend liest "" sonst
|
||||
// als gesetzten Filter.
|
||||
let gefiltert = abfrage.filter { ($0.value ?? "").isEmpty == false }
|
||||
teile.queryItems = gefiltert.isEmpty ? nil : gefiltert
|
||||
guard let fertig = teile.url else { throw APIFehler.antwortUnlesbar(pfad) }
|
||||
return fertig
|
||||
}
|
||||
|
||||
private func fuehreAus(_ methode: Methode, _ pfad: String,
|
||||
abfrage: [URLQueryItem], koerper: Data?,
|
||||
inhaltstyp: String = "application/json") async throws -> Data {
|
||||
var anfrage = URLRequest(url: try baueAdresse(pfad, abfrage))
|
||||
anfrage.httpMethod = methode.rawValue
|
||||
anfrage.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
if let marke {
|
||||
anfrage.setValue(marke, forHTTPHeaderField: "X-Certain-Token")
|
||||
}
|
||||
if let koerper {
|
||||
anfrage.httpBody = koerper
|
||||
anfrage.setValue(inhaltstyp, forHTTPHeaderField: "Content-Type")
|
||||
}
|
||||
|
||||
let daten: Data
|
||||
let antwort: URLResponse
|
||||
do {
|
||||
(daten, antwort) = try await sitzung.data(for: anfrage)
|
||||
} catch let fehler as URLError {
|
||||
throw APIFehler.keineVerbindung(fehler.localizedDescription)
|
||||
} catch {
|
||||
throw APIFehler.keineVerbindung(error.localizedDescription)
|
||||
}
|
||||
|
||||
guard let http = antwort as? HTTPURLResponse else {
|
||||
throw APIFehler.antwortUnlesbar("keine HTTP-Antwort")
|
||||
}
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
throw deute(status: http.statusCode, daten: daten)
|
||||
}
|
||||
return daten
|
||||
}
|
||||
|
||||
private func deute(status: Int, daten: Data) -> APIFehler {
|
||||
let text = Fehlerantwort.ausDaten(daten)
|
||||
switch status {
|
||||
case 401:
|
||||
return .nichtAngemeldet
|
||||
case 403:
|
||||
if let pflicht = APIFehler.Pflicht(rawValue: text) {
|
||||
return .pflichtOffen(pflicht)
|
||||
}
|
||||
return .verboten(text)
|
||||
case 404:
|
||||
return .nichtGefunden
|
||||
case 409:
|
||||
return .konflikt(text)
|
||||
case 400, 422:
|
||||
return .ungueltig(text)
|
||||
default:
|
||||
return .server(status: status, text: text)
|
||||
}
|
||||
}
|
||||
|
||||
private func entschluessele<A: Decodable>(_ daten: Data) throws -> A {
|
||||
// 204 und andere leere Antworten: Wer hier `Leer` erwartet, bekommt es.
|
||||
if daten.isEmpty, let leer = Leer() as? A { return leer }
|
||||
do {
|
||||
return try decoder.decode(A.self, from: daten)
|
||||
} catch {
|
||||
throw APIFehler.antwortUnlesbar(String(describing: error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Platzhalter für Antworten ohne Inhalt.
|
||||
struct Leer: Codable, Sendable {
|
||||
init() {}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import Foundation
|
||||
|
||||
// Alle Pfade an einer Stelle. An der Aufrufstelle steht nie eine Zeichenkette –
|
||||
// wer einen Endpunkt umbenennt, bricht genau hier und nicht in zwölf Ansichten.
|
||||
//
|
||||
// Stufe 1: Server prüfen, anmelden, Marke holen, Konten lesen.
|
||||
|
||||
extension CertainClient {
|
||||
|
||||
// MARK: - Server
|
||||
|
||||
/// Prüft, ob unter der Adresse überhaupt ein Certain läuft.
|
||||
///
|
||||
/// Wichtig: Das Backend liefert für jeden unbekannten Pfad ausserhalb von
|
||||
/// `/api` die Weboberfläche mit Status 200 aus. `/api/version` kann das
|
||||
/// nicht – nur ein echter Server antwortet hier mit dem erwarteten JSON.
|
||||
func serverkennung() async throws -> Serverkennung {
|
||||
let kennung: Serverkennung
|
||||
do {
|
||||
kennung = try await hole("version")
|
||||
} catch APIFehler.antwortUnlesbar {
|
||||
throw APIFehler.keinCertainServer
|
||||
}
|
||||
guard kennung.istCertain else { throw APIFehler.keinCertainServer }
|
||||
return kennung
|
||||
}
|
||||
|
||||
// MARK: - Anmeldung
|
||||
|
||||
/// Meldet an und bekommt ein Sitzungs-Cookie. Steht der zweite Faktor
|
||||
/// aus, ist `status == "2fa_required"` und das Cookie gilt nur zehn
|
||||
/// Minuten für genau diesen Schritt.
|
||||
func melde(benutzer: String, passwort: String) async throws -> AnmeldeAntwort {
|
||||
try await schickeFormular("auth/login",
|
||||
felder: ["username": benutzer, "password": passwort])
|
||||
}
|
||||
|
||||
func pruefeZweitenFaktor(code: String, wiederherstellung: Bool = false) async throws -> AnmeldeAntwort {
|
||||
try await schicke(.post, "auth/2fa/verify",
|
||||
koerper: ZweiterFaktorEingabe(code: code, recovery: wiederherstellung))
|
||||
}
|
||||
|
||||
func ichSelbst() async throws -> Benutzer {
|
||||
try await hole("auth/me")
|
||||
}
|
||||
|
||||
/// Wechselt das Passwort. Beendet dabei alle anderen Sitzungen des Kontos
|
||||
/// und setzt für den Aufrufer ein frisches Cookie.
|
||||
@discardableResult
|
||||
func wechslePasswort(alt: String, neu: String) async throws -> Benutzer {
|
||||
try await schicke(.post, "auth/change-password",
|
||||
koerper: PasswortWechsel(currentPassword: alt, newPassword: neu))
|
||||
}
|
||||
|
||||
func meldeAb() async throws {
|
||||
try await schickeStumm(.post, "auth/logout")
|
||||
}
|
||||
|
||||
// MARK: - Gerätemarken
|
||||
|
||||
/// Holt die Marke, mit der die App von da an arbeitet. Geht nur aus einer
|
||||
/// echten Anmeldung heraus – eine Marke darf keine weitere ausstellen.
|
||||
func legeAppMarkeAn(name: String) async throws -> Marke {
|
||||
try await schicke(.post, "inbox/tokens", koerper: MarkeAnlegen.fuerApp(name: name))
|
||||
}
|
||||
|
||||
func marken() async throws -> [Marke] {
|
||||
try await hole("inbox/tokens")
|
||||
}
|
||||
|
||||
func widerrufeMarke(id: Int) async throws {
|
||||
try await schickeStumm(.delete, "inbox/tokens/\(id)")
|
||||
}
|
||||
|
||||
// MARK: - Einstellungen
|
||||
|
||||
func einstellungen() async throws -> Einstellungen {
|
||||
let roh: [Einstellung] = try await hole("settings")
|
||||
return Einstellungen(roh)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func setzeEinstellung(_ schluessel: String, _ wert: String) async throws -> Einstellung {
|
||||
// Query statt Körper – so will es das Backend (`routers/settings.py`).
|
||||
try await schicke(.put, "settings/\(schluessel)",
|
||||
abfrage: [URLQueryItem(name: "value", value: wert)])
|
||||
}
|
||||
|
||||
// MARK: - Konten
|
||||
|
||||
func konten() async throws -> [Konto] {
|
||||
try await hole("accounts")
|
||||
}
|
||||
|
||||
func konto(_ id: Int) async throws -> Konto {
|
||||
try await hole("accounts/\(id)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// Holt eine Liste, legt sie in den Zwischenspeicher und weiss, was bei einem
|
||||
/// Fehlschlag noch da ist.
|
||||
///
|
||||
/// Jede Listenansicht braucht dasselbe: laden, Fehler zeigen, zum Aktualisieren
|
||||
/// herunterziehen, offline den letzten Stand behalten. Einmal hier, statt in
|
||||
/// jeder Ansicht neu.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class Lader<A: Codable & Sendable> {
|
||||
private(set) var zustand: Ladezustand<A> = .laedt
|
||||
|
||||
private let schluessel: String
|
||||
private let holen: () async throws -> A
|
||||
|
||||
/// - Parameter schluessel: Name im Zwischenspeicher. Der Endpunktpfad ist
|
||||
/// eine gute Wahl – er ist eindeutig und beim Suchen lesbar.
|
||||
init(schluessel: String, holen: @escaping () async throws -> A) {
|
||||
self.schluessel = schluessel
|
||||
self.holen = holen
|
||||
}
|
||||
|
||||
/// Lädt. Beim ersten Mal wird ein vorhandener Stand sofort angezeigt,
|
||||
/// damit die Liste nicht leer aufblitzt, während der Abruf läuft.
|
||||
func lade(zeigeGemerktesSofort: Bool = true) async {
|
||||
if zeigeGemerktesSofort, zustand.inhalt == nil,
|
||||
let gemerkt = Zwischenspeicher.hole(A.self, unter: schluessel) {
|
||||
zustand = .ausSpeicher(gemerkt.wert, geholtAm: gemerkt.geholtAm,
|
||||
fehler: .keineVerbindung(""))
|
||||
} else if zustand.inhalt == nil {
|
||||
zustand = .laedt
|
||||
}
|
||||
|
||||
do {
|
||||
let frisch = try await holen()
|
||||
Zwischenspeicher.lege(frisch, unter: schluessel)
|
||||
zustand = .da(frisch)
|
||||
} catch let fehler as APIFehler {
|
||||
if let gemerkt = Zwischenspeicher.hole(A.self, unter: schluessel) {
|
||||
zustand = .ausSpeicher(gemerkt.wert, geholtAm: gemerkt.geholtAm,
|
||||
fehler: fehler)
|
||||
} else {
|
||||
zustand = .fehler(fehler)
|
||||
}
|
||||
} catch {
|
||||
zustand = .fehler(.keineVerbindung(error.localizedDescription))
|
||||
}
|
||||
}
|
||||
|
||||
/// Fürs Herunterziehen: kein Zwischenzustand, die alte Liste bleibt
|
||||
/// stehen, bis die neue da ist.
|
||||
func aktualisiere() async {
|
||||
await lade(zeigeGemerktesSofort: false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import Foundation
|
||||
|
||||
/// Die letzte geglückte Antwort je Abruf, auf der Platte.
|
||||
///
|
||||
/// Wozu: In der U-Bahn und im Ausland soll die App nicht mit einer
|
||||
/// Fehlermeldung dastehen, sondern den letzten Stand zeigen – mit Datum, damit
|
||||
/// niemand alte Zahlen für aktuelle hält. Geschrieben wird offline nicht.
|
||||
///
|
||||
/// Bewusst schlicht: eine JSON-Datei je Schlüssel, kein Verfallsdatum, kein
|
||||
/// Aufräumen nach Grösse. Die Antworten sind klein (die grösste, die
|
||||
/// Buchungsliste, liegt im einstelligen Megabyte-Bereich), und ein Abmelden
|
||||
/// räumt ohnehin alles weg.
|
||||
enum Zwischenspeicher {
|
||||
/// Was aus dem Speicher kam, und wann es geholt wurde.
|
||||
struct Eintrag<A>: Sendable where A: Sendable {
|
||||
let wert: A
|
||||
let geholtAm: Date
|
||||
}
|
||||
|
||||
private static let ordner: URL? = {
|
||||
guard let basis = try? FileManager.default.url(
|
||||
for: .cachesDirectory, in: .userDomainMask,
|
||||
appropriateFor: nil, create: true) else { return nil }
|
||||
let eigener = basis.appendingPathComponent("CertainAntworten", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: eigener, withIntermediateDirectories: true)
|
||||
return eigener
|
||||
}()
|
||||
|
||||
private static func datei(_ schluessel: String) -> URL? {
|
||||
// Der Schluessel ist ein Pfad mit Abfrage; daraus muss ein gueltiger
|
||||
// Dateiname werden.
|
||||
let sauber = schluessel.unicodeScalars.map { zeichen -> Character in
|
||||
CharacterSet.alphanumerics.contains(zeichen) ? Character(zeichen) : "_"
|
||||
}
|
||||
return ordner?.appendingPathComponent(String(sauber) + ".json")
|
||||
}
|
||||
|
||||
static func lege<A: Encodable>(_ wert: A, unter schluessel: String) {
|
||||
guard let ziel = datei(schluessel),
|
||||
let daten = try? JSONEncoder().encode(wert) else { return }
|
||||
try? daten.write(to: ziel, options: .atomic)
|
||||
}
|
||||
|
||||
static func hole<A: Decodable & Sendable>(_ typ: A.Type, unter schluessel: String) -> Eintrag<A>? {
|
||||
guard let quelle = datei(schluessel),
|
||||
let daten = try? Data(contentsOf: quelle),
|
||||
let wert = try? JSONDecoder().decode(A.self, from: daten) else { return nil }
|
||||
let wann = (try? quelle.resourceValues(forKeys: [.contentModificationDateKey]))?
|
||||
.contentModificationDate ?? Date()
|
||||
return Eintrag(wert: wert, geholtAm: wann)
|
||||
}
|
||||
|
||||
static func leere() {
|
||||
guard let ordner else { return }
|
||||
try? FileManager.default.removeItem(at: ordner)
|
||||
}
|
||||
}
|
||||
|
||||
/// Was eine Ansicht gerade anzeigen soll.
|
||||
///
|
||||
/// Der vierte Fall ist der Grund für diesen Typ: Daten da, aber vom Band –
|
||||
/// die Ansicht muss das sagen können, ohne dass jede Ansicht ihre eigene
|
||||
/// Zustandsverwaltung erfindet.
|
||||
enum Ladezustand<A: Sendable>: Sendable {
|
||||
case laedt
|
||||
case da(A)
|
||||
/// Der Abruf schlug fehl, aber wir haben noch einen Stand von vorher.
|
||||
case ausSpeicher(A, geholtAm: Date, fehler: APIFehler)
|
||||
case fehler(APIFehler)
|
||||
|
||||
var inhalt: A? {
|
||||
switch self {
|
||||
case .da(let wert): return wert
|
||||
case .ausSpeicher(let wert, _, _): return wert
|
||||
case .laedt, .fehler: return nil
|
||||
}
|
||||
}
|
||||
|
||||
var laeuft: Bool {
|
||||
if case .laedt = self { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user