import Foundation import Observation #if canImport(UIKit) import UIKit #endif /// Wo die App gerade steht – und damit, welcher Bildschirm zu sehen ist. @MainActor @Observable final class AppZustand { enum Lage: Equatable { /// Beim Start: Gibt es eine Adresse, gibt es eine Marke, gilt sie noch? case startet /// Keine Server-Adresse hinterlegt. case brauchtServer /// Adresse steht, aber keine gültige Marke. case brauchtAnmeldung /// Passwort stimmt, der Code fehlt noch. case brauchtZweitenFaktor /// Das Konto muss erst sein Passwort wechseln. case brauchtPasswortwechsel /// Die Richtlinie verlangt einen zweiten Faktor, das Konto hat keinen. /// Einrichten geht in Stufe 1 noch nicht in der App. case brauchtZweitenFaktorEinrichtung /// Alles gut. case bereit(Benutzer) } private(set) var lage: Lage = .startet private(set) var einstellungen = Einstellungen() private(set) var client: CertainClient? /// Kategorien, Schlagwörter und Konten – einmal geladen, von allen /// Listen benutzt. Jede Zeile will zu einer ID den Namen, das Symbol und /// die Farbe; das je Ansicht neu zu holen wäre genau das N+1, das das /// Backend sich selbst verbietet. private(set) var nachschlag = Nachschlag.leer /// Die letzte Meldung für den Anmeldebildschirm. Keine Fehleransicht, /// sondern eine Zeile unter dem Formular. var meldung: String? /// Läuft gerade ein Anmeldeschritt? Sperrt die Knöpfe. private(set) var beschaeftigt = false var serverAdresse: URL? { Ablage.serverAdresse } // MARK: - Start func starte() async { guard let adresse = Ablage.serverAdresse else { lage = .brauchtServer return } let neuer = CertainClient(basis: adresse, marke: Ablage.marke) client = neuer guard Ablage.marke != nil else { lage = .brauchtAnmeldung return } await ladeEigenenZustand() } /// Fragt den Server, wer wir sind. Das ist zugleich die Prüfung, ob die /// Marke noch gilt. private func ladeEigenenZustand() async { guard let client else { return } do { let ich = try await client.ichSelbst() // Muss gemerkt werden, sonst greift der Offline-Zweig unten nie: // Niemand sonst schreibt unter diesem Schlüssel. Zwischenspeicher.lege(ich, unter: "auth/me") einstellungen = (try? await client.einstellungen()) ?? Einstellungen() lage = .bereit(ich) await ladeNachschlag() } catch APIFehler.nichtAngemeldet { // Marke widerrufen oder gelöscht – neu anmelden, Adresse behalten. Ablage.marke = nil await client.setzeMarke(nil) lage = .brauchtAnmeldung } catch APIFehler.pflichtOffen(.passwortWechseln) { lage = .brauchtPasswortwechsel } catch APIFehler.pflichtOffen(.zweiterFaktor) { lage = .brauchtZweitenFaktorEinrichtung } catch { // Offline beim Start: Wir lassen die App herein und zeigen in den // Ansichten den letzten Stand. Die Marke ist ja da. if let gemerkt = Zwischenspeicher.hole(Benutzer.self, unter: "auth/me") { lage = .bereit(gemerkt.wert) await ladeNachschlag() } else { meldung = error.localizedDescription lage = .brauchtAnmeldung } } } // MARK: - Server festlegen /// Nimmt, was der Nutzer tippt, und macht daraus eine brauchbare Adresse. /// `budget.example.com` wird zu `https://budget.example.com`. /// /// `nonisolated`, weil es reine Textarbeit ist – so lässt es sich ohne /// Hauptthread testen. nonisolated static func deuteAdresse(_ eingabe: String) -> URL? { var text = eingabe.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { return nil } if !text.contains("://") { text = "https://" + text } // Ein angehängter Pfad hilft nicht – die API hängt immer unter /api. guard var teile = URLComponents(string: text), teile.host?.isEmpty == false else { return nil } teile.path = "" teile.query = nil teile.fragment = nil return teile.url } func pruefeUndMerkeServer(_ eingabe: String) async -> Bool { guard let adresse = Self.deuteAdresse(eingabe) else { meldung = String(localized: "server.keineAdresse") return false } beschaeftigt = true defer { beschaeftigt = false } meldung = nil let versuch = CertainClient(basis: adresse) do { _ = try await versuch.serverkennung() Ablage.serverAdresse = adresse client = versuch lage = .brauchtAnmeldung return true } catch { meldung = (error as? APIFehler)?.errorDescription ?? error.localizedDescription return false } } // MARK: - Anmelden func melde(benutzer: String, passwort: String) async { guard let client else { return } beschaeftigt = true defer { beschaeftigt = false } meldung = nil do { let antwort = try await client.melde(benutzer: benutzer, passwort: passwort) Ablage.benutzername = benutzer if antwort.brauchtZweitenFaktor { lage = .brauchtZweitenFaktor return } await nachDerAnmeldung(antwort.user) } catch { meldung = (error as? APIFehler)?.errorDescription ?? error.localizedDescription } } func pruefeZweitenFaktor(code: String, wiederherstellung: Bool) async { guard let client else { return } beschaeftigt = true defer { beschaeftigt = false } meldung = nil do { let antwort = try await client.pruefeZweitenFaktor( code: code, wiederherstellung: wiederherstellung) await nachDerAnmeldung(antwort.user) } catch APIFehler.nichtAngemeldet { // Zu viele Fehlversuche: Das Zwischen-Cookie ist weg. meldung = String(localized: "zweifaktor.verbraucht") lage = .brauchtAnmeldung } catch { meldung = (error as? APIFehler)?.errorDescription ?? error.localizedDescription } } /// Nach geglückter Anmeldung: Marke holen und das Cookie wegwerfen. private func nachDerAnmeldung(_ benutzer: Benutzer?) async { guard let client else { return } // Offene Pflichten zuerst – mit ihnen lässt sich keine Marke anlegen, // das Backend sperrt den Endpunkt (`enforce_account_state`). if let benutzer, benutzer.mustChangePassword { lage = .brauchtPasswortwechsel return } if let benutzer, benutzer.mfaSetupRequired { lage = .brauchtZweitenFaktorEinrichtung return } do { let marke = try await client.legeAppMarkeAn(name: Self.geraetename) guard let klartext = marke.token else { meldung = String(localized: "anmeldung.markeOhneSchluessel") return } Ablage.marke = klartext await client.setzeMarke(klartext) // Ab hier trägt die Marke. Das Cookie hat seinen Zweck erfüllt. await client.vergissCookies() await ladeEigenenZustand() } catch { meldung = (error as? APIFehler)?.errorDescription ?? error.localizedDescription } } func wechslePasswort(alt: String, neu: String) async { guard let client else { return } beschaeftigt = true defer { beschaeftigt = false } meldung = nil do { let ich = try await client.wechslePasswort(alt: alt, neu: neu) await nachDerAnmeldung(ich) } catch { meldung = (error as? APIFehler)?.errorDescription ?? error.localizedDescription } } // MARK: - Abmelden /// Meldet ab und räumt auf. Die Marke wird dabei am Server widerrufen – /// sonst bliebe sie dort stehen, bis jemand sie von Hand entfernt. func meldeAb(adresseBehalten: Bool = true) async { if let client, Ablage.marke != nil { // Welche der Marken unsere ist, lässt sich nicht am Klartext // ablesen – am Server liegt nur der Hash. Also über den Namen, // unter dem wir sie angelegt haben. let meine = (try? await client.marken())?.first { $0.scope == "app" && $0.name == Self.geraetename } if let meine { try? await client.widerrufeMarke(id: meine.id) } } let adresse = adresseBehalten ? Ablage.serverAdresse : nil Ablage.raeumeAuf() Ablage.serverAdresse = adresse if let adresse { client = CertainClient(basis: adresse) lage = .brauchtAnmeldung } else { client = nil lage = .brauchtServer } } func aktualisiereEinstellungen() async { guard let client else { return } if let frisch = try? await client.einstellungen() { einstellungen = frisch } } /// Holt das Nachschlagewerk. Geht das schief, bleibt der letzte Stand – /// eine Buchungsliste ohne Kategorienamen wäre schlechter als eine mit /// alten. func ladeNachschlag() async { guard let client else { return } if let frisch = try? await client.nachschlag() { Zwischenspeicher.lege(frisch, unter: "nachschlag") nachschlag = frisch } else if nachschlag.gruppen.isEmpty, let gemerkt = Zwischenspeicher.hole(Nachschlag.self, unter: "nachschlag") { nachschlag = gemerkt.wert } } // MARK: - Kleinkram /// Der Name, unter dem die Marke am Rechner auftaucht. Soll erkennbar /// machen, welches Gerät man widerruft. static var geraetename: String { #if canImport(UIKit) let roh = UIDevice.current.name return roh.isEmpty ? "iPhone" : roh #else return "iOS-Gerät" #endif } }