SHA256
feat(ios): replace embedded website with initial native SwiftUI API client
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.
This commit is contained in:
1 parent
11bf4ca620
commit
97570b7fdf
15 files changed
+1182
-11
No files matched your search
@@ -0,0 +1,219 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user