import SwiftUI @MainActor final class NativeStore: ObservableObject { @Published private(set) var server = "" @Published private(set) var appName = "Yuvomi" @Published private(set) var user: NativeUser? @Published private(set) var permissions: NativePermissions? @Published private(set) var setup = false @Published private(set) var passwordAllowed = true @Published private(set) var oidc = false @Published private(set) var twoFactor = false @Published private(set) var busy = false @Published var error: String? @Published private(set) var page = NativePage.home @Published private(set) var dashboard: NativeDashboard? @Published private(set) var tasks: [NativeTask] = [] @Published private(set) var notes: [NativeNote] = [] @Published private(set) var lists: [NativeShoppingList] = [] @Published private(set) var items: [NativeShoppingItem] = [] @Published private(set) var selectedList: NativeShoppingList? private var api: NativeAPI? private var vault: NativeSessionVault? private var generation = 0 func restore() async { guard server.isEmpty, let saved = UserDefaults.standard.string(forKey: "native-origin") else { return } await connect(saved) } @discardableResult private func run(_ operation: () async throws -> Void) async -> Bool { guard !busy else { return false } let current = generation busy = true error = nil defer { if generation == current { busy = false } } do { try await operation() return true } catch { guard current == generation else { return false } if case NativeAPIError.response(401) = error, user != nil { clearLoadedData() self.error = "Deine Sitzung ist abgelaufen. Bitte erneut anmelden." } else { self.error = (error as? NativeAPIError)?.errorDescription ?? NativeAPIError.unavailable.errorDescription } return false } } func connect(_ input: String) async { await run { let address = try ServerAddress(input) let storage = NativeSessionVault(origin: address.url.absoluteString) let client = try NativeAPI(server: address, saved: storage.load(), persist: storage.save) let metadata = try await client.request("/version", as: NativeBootstrap.self) let authentication = try await client.request("/auth/oidc/config", as: NativeAuthentication.self) api = client vault = storage server = address.url.absoluteString appName = metadata.appName setup = metadata.setupRequired oidc = authentication.enabled passwordAllowed = authentication.passwordLoginEnabled || authentication.guestPasswordLoginEnabled == true UserDefaults.standard.set(server, forKey: "native-origin") do { try await accept(client.request("/auth/me", as: NativeIdentity.self)) } catch NativeAPIError.response( 401) { return } } } func login(username: String, password: String, displayName: String) async { await run { guard let api else { throw NativeAPIError.closed } if setup { let _: NativeEmpty = try await api.request( "/auth/setup", method: "POST", body: [ "username": .string(username.trimmingCharacters(in: .whitespaces)), "password": .string(password), "display_name": .string(displayName.isEmpty ? username : displayName), ], as: NativeEmpty.self) } let response = try await api.request( "/auth/login", method: "POST", body: [ "username": .string(username.trimmingCharacters(in: .whitespaces)), "password": .string(password), ], as: NativeIdentity.self) if response.twoFactorRequired == true { twoFactor = true setup = false } else { try await accept(response) } } } func verify(_ code: String) async { await run { guard let api else { throw NativeAPIError.closed } try await accept( api.request("/auth/2fa/verify", method: "POST", body: ["code": .string(code)], as: NativeIdentity.self)) } } func cancelTwoFactor() { if !busy { twoFactor = false error = nil } } private func accept(_ identity: NativeIdentity) async throws { guard let user = identity.user, let permissions = identity.permissions else { throw NativeAPIError.invalidResponse } self.user = user self.permissions = permissions setup = false twoFactor = false page = .home try await reload() } func navigate(_ selected: NativePage) async { await run { page = selected selectedList = nil try await reload() } } func open(_ list: NativeShoppingList) async { await run { page = .shopping selectedList = list try await reload() } } func refresh() async { await run { try await reload() } } private func reload() async throws { guard let api else { throw NativeAPIError.closed } switch page { case .home: dashboard = try await api.request("/dashboard", as: NativeDashboard.self) case .tasks: tasks = try await api.request("/tasks", as: NativeEnvelope<[NativeTask]>.self).data case .notes: notes = try await api.request("/notes", as: NativeEnvelope<[NativeNote]>.self).data case .shopping: if let selectedList { items = try await api.request( "/shopping/\(selectedList.id)/items", as: NativeEnvelope<[NativeShoppingItem]>.self ).data } else { lists = try await api.request("/shopping", as: NativeEnvelope<[NativeShoppingList]>.self).data } case .more: break } } func mutate(_ path: String, method: String, body: [String: NativeValue] = [:]) async -> Bool { await run { guard let api else { throw NativeAPIError.closed } let _: NativeEmpty = try await api.request(path, method: method, body: body, as: NativeEmpty.self) try await reload() } } func logout() async { await run { let revoked: Bool do { if let api { let _: NativeEmpty = try await api.request("/auth/logout", method: "POST", as: NativeEmpty.self) } revoked = true } catch { revoked = false } await api?.close() clearLoadedData() try vault?.clear() if let address = try? ServerAddress(server), let storage = vault { api = try NativeAPI(server: address, saved: nil, persist: storage.save) } if !revoked { error = "Lokal abgemeldet. Die Abmeldung auf dem Server konnte nicht bestÃĪtigt werden." } } } func forget() async { guard !busy else { return } busy = true defer { busy = false } generation += 1 await api?.close() clearLoadedData() do { try vault?.clear() } catch { self.error = NativeAPIError.unavailable.errorDescription return } api = nil vault = nil server = "" appName = "Yuvomi" setup = false passwordAllowed = true oidc = false error = nil UserDefaults.standard.removeObject(forKey: "native-origin") } private func clearLoadedData() { user = nil permissions = nil dashboard = nil tasks = [] notes = [] lists = [] items = [] selectedList = nil page = .home twoFactor = false } }