Files
certain/ios/App/AppZustand.swift
T
GuidoandClaude Opus 5 58bd504333 fix: Der Offline-Start zeigt wieder den letzten Stand
AppZustand faellt beim Start ohne Verbindung auf den gemerkten Benutzer
zurueck - nur schrieb diesen Schluessel niemand je in den Zwischenspeicher.
Der Zweig konnte also nie greifen, und die App verlangte im Flugmodus eine
neue Anmeldung, obwohl die Geraetemarke laengst da war.

Gefunden beim Durchsehen fuer Stufe 2: Nur Lader und das Nachschlagewerk
schreiben in den Speicher, und auth/me geht an beiden vorbei.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-21 20:23:49 +02:00

281 lines
10 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 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
}
}