SHA256
Introduce real SwiftUI auth/dashboard/task/note/shopping screens, a bounded origin-bound URLSession transport and Keychain-backed native sessions. Archive the rejected WebKit renderer rather than embedding it for unfinished modules. Sixteen portable URL/contract/transport tests and Swift format/parsing pass on Linux; Apple SDK application and UI validation remains mandatory. This is an incomplete native slice, not full PWA parity or a release.
220 lines
8.0 KiB
Swift
220 lines
8.0 KiB
Swift
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()
|
|
try vault?.clear()
|
|
if let address = try? ServerAddress(server), let storage = vault {
|
|
api = try NativeAPI(server: address, saved: nil, persist: storage.save)
|
|
}
|
|
clearLoadedData()
|
|
if !revoked { error = "Lokal abgemeldet. Die Abmeldung auf dem Server konnte nicht bestätigt werden." }
|
|
}
|
|
}
|
|
|
|
func forget() async {
|
|
guard !busy else { return }
|
|
generation += 1
|
|
await api?.close()
|
|
do { try vault?.clear() } catch {
|
|
self.error = NativeAPIError.unavailable.errorDescription
|
|
return
|
|
}
|
|
api = nil
|
|
vault = nil
|
|
clearLoadedData()
|
|
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
|
|
}
|
|
}
|