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:
Yuvomi Mobile Contributors committed 2026-10-21 08:57:31 +02:00
1 parent 11bf4ca620
commit 97570b7fdf
15 files changed
+1182 -11

No files matched your search

+3 -3
View File
@@ -9,10 +9,10 @@ let package = Package(
.target( .target(
name: "Yuvomi", path: "Sources", name: "Yuvomi", path: "Sources",
exclude: [ exclude: [
"AuthenticationView.swift", "BrowserModel.swift", "ContentView.swift", "NativeStore.swift", "NativeRootView.swift", "NativeSessionVault.swift",
"ServerProbe.swift", "YuvomiApp.swift", "PrivacyInfo.xcprivacy", "Assets.xcassets", "ServerProbe.swift", "YuvomiApp.swift", "PrivacyInfo.xcprivacy", "Assets.xcassets",
], sources: ["ServerAddress.swift", "ServerMetadata.swift"]), ], sources: ["ServerAddress.swift", "ServerMetadata.swift", "NativeContracts.swift", "NativeAPI.swift"]),
.testTarget( .testTarget(
name: "YuvomiPolicyTests", dependencies: ["Yuvomi"], path: "Tests", exclude: ["BrowserModelTests.swift"]), name: "YuvomiPolicyTests", dependencies: ["Yuvomi"], path: "Tests"),
] ]
) )
+151
View File
@@ -0,0 +1,151 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
private struct NativeSavedCookie: Codable {
let name: String
let value: String
let expires: Date
}
/// Bounded streaming requests. Redirects never replay native session cookies.
private final class NativeTransport: NSObject, URLSessionDataDelegate, @unchecked Sendable {
private struct Pending {
let continuation: CheckedContinuation<(Data, HTTPURLResponse), Error>
var data = Data()
var response: HTTPURLResponse?
}
private let lock = NSLock()
private var requests: [Int: Pending] = [:]
func register(_ task: URLSessionDataTask, continuation: CheckedContinuation<(Data, HTTPURLResponse), Error>) {
lock.lock()
requests[task.taskIdentifier] = Pending(continuation: continuation)
lock.unlock()
}
func urlSession(
_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse,
newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void
) {
completionHandler(nil)
}
func urlSession(
_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse,
completionHandler: @escaping (URLSession.ResponseDisposition) -> Void
) {
guard let http = response as? HTTPURLResponse, response.expectedContentLength <= 4 * 1024 * 1024 else {
completionHandler(.cancel)
return
}
lock.lock()
requests[dataTask.taskIdentifier]?.response = http
lock.unlock()
completionHandler(.allow)
}
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
lock.lock()
let allowed = (requests[dataTask.taskIdentifier]?.data.count ?? 0) + data.count <= 4 * 1024 * 1024
if allowed { requests[dataTask.taskIdentifier]?.data.append(data) }
lock.unlock()
if !allowed { dataTask.cancel() }
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
lock.lock()
let pending = requests.removeValue(forKey: task.taskIdentifier)
lock.unlock()
guard let pending else { return }
if error != nil {
pending.continuation.resume(throwing: NativeAPIError.unavailable)
} else if let response = pending.response {
pending.continuation.resume(returning: (pending.data, response))
} else {
pending.continuation.resume(throwing: NativeAPIError.invalidResponse)
}
}
}
actor NativeAPI {
let server: ServerAddress
private let transport: NativeTransport
private let storage: HTTPCookieStorage
private let persist: @Sendable (Data) throws -> Void
private let session: URLSession
private var csrf = ""
private var closed = false
init(server: ServerAddress, saved: Data?, persist: @escaping @Sendable (Data) throws -> Void) throws {
self.server = server
self.persist = persist
let transport = NativeTransport()
self.transport = transport
let configuration = URLSessionConfiguration.ephemeral
guard let storage = configuration.httpCookieStorage else { throw NativeAPIError.unavailable }
self.storage = storage
configuration.timeoutIntervalForRequest = 15
configuration.timeoutIntervalForResource = 30
configuration.urlCache = nil
if let saved, let cookies = try? JSONDecoder().decode([NativeSavedCookie].self, from: saved) {
for cookie in cookies where cookie.expires > Date() && ["yuvomi.sid", "csrf-token"].contains(cookie.name) {
if let restored = HTTPCookie(properties: [
.domain: server.url.host ?? "", .path: "/", .secure: "TRUE", .name: cookie.name,
.value: cookie.value, .expires: cookie.expires,
]) {
storage.setCookie(restored)
}
}
}
session = URLSession(configuration: configuration, delegate: transport, delegateQueue: nil)
}
func close() {
closed = true
csrf = ""
session.invalidateAndCancel()
for cookie in storage.cookies ?? [] { storage.deleteCookie(cookie) }
}
func request<Value: Decodable & Sendable>(
_ path: String, method: String = "GET", body: [String: NativeValue]? = nil,
as type: Value.Type, retry: Bool = true
) async throws -> Value {
guard !closed else { throw NativeAPIError.closed }
guard path.hasPrefix("/"), !path.hasPrefix("//"), !path.contains("#"), !path.contains("\\"),
let url = URL(string: server.url.absoluteString + "/api/v1" + path), server.owns(url)
else { throw NativeAPIError.invalidPath }
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData)
request.httpMethod = method
request.setValue("application/json", forHTTPHeaderField: "Accept")
let mutation = ["POST", "PUT", "PATCH", "DELETE"].contains(method)
if mutation {
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(csrf, forHTTPHeaderField: "X-CSRF-Token")
request.httpBody = try JSONEncoder().encode(body ?? [:])
}
let (data, response) = try await withCheckedThrowingContinuation { continuation in
let task = session.dataTask(with: request)
transport.register(task, continuation: continuation)
task.resume()
}
guard !closed else { throw NativeAPIError.closed }
guard !(300...399).contains(response.statusCode) else { throw NativeAPIError.response(response.statusCode) }
let root = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
if let token = response.value(forHTTPHeaderField: "X-CSRF-Token") ?? root?["csrfToken"] as? String {
csrf = token
}
let cookies = (storage.cookies(for: server.url) ?? []).filter {
$0.isSecure && ["yuvomi.sid", "csrf-token"].contains($0.name) && $0.path == "/" && $0.expiresDate != nil
}.map { NativeSavedCookie(name: $0.name, value: $0.value, expires: $0.expiresDate!) }
try persist(JSONEncoder().encode(cookies))
if response.statusCode == 403 && mutation && retry && root?["reason"] as? String == "csrf_invalid" {
let _: NativeIdentity = try await self.request("/auth/me", as: NativeIdentity.self, retry: false)
return try await self.request(path, method: method, body: body, as: type, retry: false)
}
guard (200...299).contains(response.statusCode) else { throw NativeAPIError.response(response.statusCode) }
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
do { return try decoder.decode(type, from: data.isEmpty ? Data("{}".utf8) : data) } catch {
throw NativeAPIError.invalidResponse
}
}
}
+147
View File
@@ -0,0 +1,147 @@
import Foundation
enum NativePage: String, CaseIterable, Identifiable, Sendable {
case home, tasks, shopping, notes, more
var id: String { rawValue }
var title: String {
switch self {
case .home: "Übersicht"
case .tasks: "Aufgaben"
case .shopping: "Einkauf"
case .notes: "Notizen"
case .more: "Mehr"
}
}
var symbol: String {
switch self {
case .home: "house"
case .tasks: "checkmark.square"
case .shopping: "cart"
case .notes: "note.text"
case .more: "ellipsis"
}
}
}
struct NativeBootstrap: Decodable, Sendable {
let appName: String
let setupRequired: Bool
let passwordResetEnabled: Bool
}
struct NativeAuthentication: Decodable, Sendable {
let enabled: Bool
let passwordLoginEnabled: Bool
let guestPasswordLoginEnabled: Bool?
}
struct NativeUser: Decodable, Sendable {
let id: Int
let username: String
let displayName: String
}
struct NativePermissions: Decodable, Sendable {
let admin: Bool
let modules: [String: String]
let widgets: [String: String]
func access(_ module: String) -> String { admin ? "write" : modules[module] ?? "write" }
func showsWidget(_ widget: String) -> Bool { admin || widgets[widget] != "none" }
}
struct NativeIdentity: Decodable, Sendable {
let user: NativeUser?
let permissions: NativePermissions?
let csrfToken: String?
let twoFactorRequired: Bool?
}
struct NativeEnvelope<Value: Decodable & Sendable>: Decodable, Sendable { let data: Value }
struct NativeEmpty: Decodable, Sendable {}
struct NativeTask: Decodable, Identifiable, Sendable {
let id: Int
let title: String
let description: String?
let status: String
let priority: String
let dueDate: String?
let category: String?
}
struct NativeNote: Decodable, Identifiable, Sendable {
let id: Int
let title: String?
let content: String
let pinned: Int
}
struct NativeShoppingList: Decodable, Identifiable, Sendable {
let id: Int
let name: String
let itemTotal: Int?
let itemChecked: Int?
let openCount: Int?
}
struct NativeShoppingItem: Decodable, Identifiable, Sendable {
let id: Int
let name: String
let quantity: String?
let category: String?
let isChecked: Int
}
struct NativeEventPreview: Decodable, Identifiable, Sendable {
let id: Int
let title: String
let startDatetime: String
}
struct NativeTaskPreview: Decodable, Identifiable, Sendable {
let id: Int
let title: String
let dueDate: String?
}
struct NativeDashboard: Decodable, Sendable {
let openTaskCount: Int?
let shoppingOpenCount: Int?
let urgentTasks: [NativeTaskPreview]
let upcomingEvents: [NativeEventPreview]
let shoppingLists: [NativeShoppingList]
let pinnedNotes: [NativeNote]
}
enum NativeValue: Encodable, Sendable {
case string(String)
case number(Int)
case bool(Bool)
case null
func encode(to encoder: Encoder) throws {
var value = encoder.singleValueContainer()
switch self {
case .string(let text): try value.encode(text)
case .number(let number): try value.encode(number)
case .bool(let flag): try value.encode(flag)
case .null: try value.encodeNil()
}
}
}
enum NativeAPIError: LocalizedError {
case response(Int)
case invalidResponse, invalidPath, unavailable, closed
var errorDescription: String? {
switch self {
case .response(401): "Anmeldung oder Sitzung ist nicht mehr gültig."
case .response(403): "Diese Aktion ist nicht erlaubt. Bitte prüfe deine Berechtigungen."
case .response(409): "Der Eintrag wurde inzwischen geändert. Bitte neu laden."
case .response(429): "Zu viele Versuche. Bitte warte einen Moment."
case .response(let status): "Die Anfrage konnte nicht abgeschlossen werden (HTTP \(status))."
case .invalidPath, .invalidResponse: "Der Server hat keine gültige API-Antwort geliefert."
case .unavailable: "Verbindung fehlgeschlagen. Bitte Server, Zertifikat und Netzwerk prüfen."
case .closed: "Diese Sitzung wurde geschlossen."
}
}
}
+525
View File
@@ -0,0 +1,525 @@
import SwiftUI
private enum NativeDesign {
static let accent = Color(red: 108 / 255, green: 58 / 255, blue: 237 / 255)
static func background(_ dark: Bool) -> Color {
dark
? Color(red: 25 / 255, green: 24 / 255, blue: 22 / 255)
: Color(red: 245 / 255, green: 243 / 255, blue: 237 / 255)
}
static func surface(_ dark: Bool) -> Color {
dark ? Color(red: 43 / 255, green: 40 / 255, blue: 37 / 255) : .white
}
}
private struct NativeDraft: Identifiable {
let id = UUID()
let recordID: Int?
let page: NativePage
var title: String
var detail: String
var priority = "none"
var date = ""
}
struct NativeRootView: View {
@StateObject private var store = NativeStore()
@Environment(\.colorScheme) private var scheme
@Environment(\.scenePhase) private var phase
@State private var draft: NativeDraft?
@State private var forget = false
@State private var deletePath: String?
var body: some View {
ZStack {
NativeDesign.background(scheme == .dark).ignoresSafeArea()
if store.user == nil { authentication } else { application }
if phase != .active {
NativeDesign.background(scheme == .dark).ignoresSafeArea()
NativeBrandMark().frame(width: 64, height: 64)
}
}
.tint(NativeDesign.accent)
.task { await store.restore() }
.sheet(item: $draft) { entry in
NativeEditor(draft: entry, busy: store.busy, error: store.error) { body in
let path: String
let method: String
switch entry.page {
case .tasks:
path = entry.recordID.map { "/tasks/\($0)" } ?? "/tasks"
method = entry.recordID == nil ? "POST" : "PUT"
case .notes:
path = entry.recordID.map { "/notes/\($0)" } ?? "/notes"
method = entry.recordID == nil ? "POST" : "PUT"
case .shopping:
path =
entry.recordID.map { "/shopping/items/\($0)" } ?? store.selectedList.map {
"/shopping/\($0.id)/items"
} ?? "/shopping"
method = entry.recordID == nil ? "POST" : "PATCH"
default: return false
}
return await store.mutate(path, method: method, body: body)
}
}
.alert("Server entfernen?", isPresented: $forget) {
Button("Lokal entfernen", role: .destructive) { Task { await store.forget() } }
Button("Abbrechen", role: .cancel) {}
} message: {
Text(
"Die native Sitzung und geladene Inhalte werden lokal gelöscht. Melde dich möglichst zuerst ab, um die Sitzung auch auf dem Server zu widerrufen."
)
}
.alert(
"Eintrag löschen?", isPresented: Binding(get: { deletePath != nil }, set: { if !$0 { deletePath = nil } })
) {
Button("Löschen", role: .destructive) {
if let path = deletePath { Task { _ = await store.mutate(path, method: "DELETE") } }
deletePath = nil
}
Button("Abbrechen", role: .cancel) { deletePath = nil }
} message: {
Text("Der Eintrag wird auf deinem Server gelöscht.")
}
}
private var authentication: some View {
NativeAuthenticationScreen(store: store, forget: { forget = true })
}
private var application: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
if store.busy { ProgressView().frame(maxWidth: .infinity) }
if let error = store.error {
Text(error).foregroundStyle(.red).accessibilityIdentifier("native-error")
}
switch store.page {
case .home: dashboard
case .tasks: taskList
case .notes: noteList
case .shopping: shopping
case .more: more
}
}
.padding(16)
.frame(maxWidth: 760)
.frame(maxWidth: .infinity)
}
.background(NativeDesign.background(scheme == .dark))
.navigationTitle(store.selectedList?.name ?? store.page.title)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Aktualisieren", systemImage: "arrow.clockwise") { Task { await store.refresh() } }.disabled(
store.busy)
}
if [.tasks, .notes, .shopping].contains(store.page),
store.permissions?.access(store.page.rawValue) == "write"
{
ToolbarItem(placement: .topBarTrailing) {
Button("Hinzufügen", systemImage: "plus") {
draft = NativeDraft(recordID: nil, page: store.page, title: "", detail: "")
}.disabled(store.busy)
}
}
}
.safeAreaInset(edge: .bottom) {
HStack(spacing: 0) {
ForEach(
NativePage.allCases.filter {
[.home, .more].contains($0) || store.permissions?.access($0.rawValue) != "none"
}
) { page in
Button {
Task { await store.navigate(page) }
} label: {
VStack(spacing: 4) {
Image(systemName: page.symbol).font(.system(size: 21))
Text(page.title).font(.system(size: 11))
}
.foregroundStyle(store.page == page ? NativeDesign.accent : .secondary)
.frame(maxWidth: .infinity).padding(.vertical, 10)
}
.disabled(store.busy).accessibilityIdentifier("nav-\(page.rawValue)")
}
}
.padding(6).background(.regularMaterial, in: Capsule()).shadow(
color: .black.opacity(0.08), radius: 12, y: 4
).padding(.horizontal, 12).padding(.bottom, 8)
}
}
}
@ViewBuilder private var dashboard: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Hallo, \(store.user?.displayName ?? "")").font(.title2.bold())
Text(Date.now, format: .dateTime.weekday(.wide).day().month(.wide)).foregroundStyle(.secondary)
}
if let data = store.dashboard {
if store.permissions?.access("tasks") != "none", store.permissions?.showsWidget("tasks") == true {
NativeCard("Aufgaben", subtitle: "\(data.openTaskCount.map(String.init) ?? "—") offen") {
if data.urgentTasks.isEmpty { empty("Keine dringenden Aufgaben") }
ForEach(data.urgentTasks) { item in row(item.title, detail: item.dueDate ?? "") }
Button("Alle Aufgaben") { Task { await store.navigate(.tasks) } }
}
}
if store.permissions?.access("calendar") != "none", store.permissions?.showsWidget("calendar") == true {
NativeCard("Kalender", subtitle: "Nächste Termine") {
if data.upcomingEvents.isEmpty { empty("Keine anstehenden Termine") }
ForEach(data.upcomingEvents) { item in
row(
item.title,
detail: String(item.startDatetime.replacingOccurrences(of: "T", with: " ").prefix(16)))
}
}
}
if store.permissions?.access("shopping") != "none", store.permissions?.showsWidget("shopping") == true {
NativeCard("Einkauf", subtitle: "\(data.shoppingOpenCount.map(String.init) ?? "—") offene Artikel") {
if data.shoppingLists.isEmpty { empty("Alles eingekauft") }
ForEach(data.shoppingLists) { item in row(item.name, detail: "\(item.openCount ?? 0) offen") }
Button("Alle Listen") { Task { await store.navigate(.shopping) } }
}
}
if store.permissions?.access("notes") != "none", store.permissions?.showsWidget("notes") == true {
NativeCard("Notizen", subtitle: "Angeheftet") {
if data.pinnedNotes.isEmpty { empty("Noch keine Notizen") }
ForEach(data.pinnedNotes) { item in
row(item.title ?? "Notiz", detail: String(item.content.prefix(140)))
}
Button("Alle Notizen") { Task { await store.navigate(.notes) } }
}
}
}
}
@State private var search = ""
@State private var taskStatus = "open"
private var canWrite: Bool { !store.busy && store.permissions?.access(store.page.rawValue) == "write" }
@ViewBuilder private var taskList: some View {
TextField("Suchen", text: $search).textFieldStyle(.roundedBorder)
Picker("Status", selection: $taskStatus) {
Text("Offen").tag("open")
Text("Erledigt").tag("done")
Text("Alle").tag("all")
}.pickerStyle(.segmented)
let filtered = store.tasks.filter {
(search.isEmpty || ($0.title + ($0.description ?? "")).localizedCaseInsensitiveContains(search))
&& (taskStatus == "all" || (taskStatus == "done" ? $0.status == "done" : $0.status != "done"))
}
if filtered.isEmpty && !store.busy { empty("Hier gibt es noch keine passenden Aufgaben.") }
ForEach(filtered) { item in
NativeCard {
HStack {
Button {
Task {
_ = await store.mutate(
"/tasks/\(item.id)/status", method: "PATCH",
body: ["status": .string(item.status == "done" ? "open" : "done")])
}
} label: {
Image(systemName: item.status == "done" ? "checkmark.circle.fill" : "circle").font(.title2)
}.disabled(!canWrite).accessibilityLabel(item.status == "done" ? "Wieder öffnen" : "Erledigen")
Button {
draft = NativeDraft(
recordID: item.id, page: .tasks, title: item.title, detail: item.description ?? "",
priority: item.priority, date: item.dueDate ?? "")
} label: {
row(
item.title,
detail: [item.category, item.dueDate].compactMap { $0 }.joined(separator: " · "))
}.buttonStyle(.plain).disabled(!canWrite)
Spacer()
deleteButton("/tasks/\(item.id)")
}
}
}
}
@ViewBuilder private var noteList: some View {
TextField("Suchen", text: $search).textFieldStyle(.roundedBorder)
let filtered = store.notes.filter {
search.isEmpty || (($0.title ?? "") + $0.content).localizedCaseInsensitiveContains(search)
}
if filtered.isEmpty && !store.busy { empty("Hier gibt es noch keine passenden Notizen.") }
ForEach(filtered) { item in
NativeCard {
Button {
draft = NativeDraft(recordID: item.id, page: .notes, title: item.title ?? "", detail: item.content)
} label: {
row(item.title ?? "Notiz", detail: item.content)
}.buttonStyle(.plain).disabled(!canWrite)
HStack {
Button(item.pinned == 1 ? "Lösen" : "Anheften") {
Task { _ = await store.mutate("/notes/\(item.id)/pin", method: "PATCH") }
}.disabled(!canWrite)
Spacer()
deleteButton("/notes/\(item.id)")
}
}
}
}
@ViewBuilder private var shopping: some View {
if store.selectedList != nil {
Button("Alle Listen") { Task { await store.navigate(.shopping) } }.disabled(store.busy)
if store.items.isEmpty && !store.busy { empty("Diese Liste ist noch leer.") }
ForEach(store.items) { item in
NativeCard {
HStack {
Button {
Task {
_ = await store.mutate(
"/shopping/items/\(item.id)", method: "PATCH",
body: ["is_checked": .number(item.isChecked == 1 ? 0 : 1)])
}
} label: {
Image(systemName: item.isChecked == 1 ? "checkmark.circle.fill" : "circle").font(.title2)
}.disabled(!canWrite).accessibilityLabel(item.isChecked == 1 ? "Wieder öffnen" : "Abhaken")
Button {
draft = NativeDraft(
recordID: item.id, page: .shopping, title: item.name, detail: item.quantity ?? "")
} label: {
row(item.name, detail: item.quantity ?? "")
}.buttonStyle(.plain).disabled(!canWrite)
Spacer()
deleteButton("/shopping/items/\(item.id)")
}
}
}
} else {
if store.lists.isEmpty && !store.busy { empty("Hier gibt es noch keine Einkaufslisten.") }
ForEach(store.lists) { list in
NativeCard {
HStack {
Button {
Task { await store.open(list) }
} label: {
row(list.name, detail: "\(list.itemTotal ?? 0) Artikel")
}.buttonStyle(.plain).disabled(store.busy)
Spacer()
deleteButton("/shopping/\(list.id)")
}
}
}
}
}
private var more: some View {
VStack(spacing: 16) {
NativeCard("Dein Haushalt", subtitle: store.appName) {
Text(store.user?.displayName ?? "")
Text(store.server).foregroundStyle(.secondary)
}
NativeCard("Konto") {
Button("Abmelden") { Task { await store.logout() } }.disabled(store.busy)
Button("Server und lokale Sitzung entfernen", role: .destructive) { forget = true }.disabled(store.busy)
}
NativeCard("Nativer Neubau", subtitle: "Noch keine vollständige PWA-Parität") {
Text(
"Die weiteren Module und Einstellungen werden separat nativ nachgebaut. Es gibt keinen Browser-Fallback."
)
}
}
}
private func deleteButton(_ path: String) -> some View {
Button("Löschen", systemImage: "trash", role: .destructive) { deletePath = path }.labelStyle(.iconOnly)
.disabled(!canWrite)
}
private func row(_ title: String, detail: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(title).fontWeight(.semibold)
if !detail.isEmpty { Text(detail).font(.subheadline).foregroundStyle(.secondary) }
}.frame(maxWidth: .infinity, alignment: .leading)
}
private func empty(_ text: String) -> some View {
Text(text).foregroundStyle(.secondary).padding(.vertical, 16).frame(maxWidth: .infinity)
}
}
private struct NativeAuthenticationScreen: View {
@ObservedObject var store: NativeStore
let forget: () -> Void
@State private var address = ""
@State private var username = ""
@State private var password = ""
@State private var name = ""
@State private var code = ""
@State private var invalidAddress = false
@State private var sso = false
var body: some View {
ScrollView {
VStack(spacing: 16) {
NativeCard {
VStack(spacing: 16) {
NativeBrandMark().frame(width: 64, height: 64).accessibilityHidden(true)
Text(store.appName).font(.system(size: 34, weight: .bold))
Text(
store.server.isEmpty ? "Dein Zuhause. Dein Server." : "Alles für euren Alltag an einem Ort."
).foregroundStyle(.secondary).multilineTextAlignment(.center)
if store.server.isEmpty {
TextField("Server-Adresse", text: $address).keyboardType(.URL).textContentType(.URL)
.textInputAutocapitalization(.never).autocorrectionDisabled().textFieldStyle(
.roundedBorder
).accessibilityIdentifier("server-url")
if invalidAddress {
Text("Bitte eine gültige HTTPS-Adresse ohne Unterpfad eingeben.").foregroundStyle(.red)
.accessibilityIdentifier("invalid-server")
}
Text("HTTPS · eigener Server · keine zentrale Cloud").font(.caption).foregroundStyle(
.secondary)
Button("Verbinden") {
guard (try? ServerAddress(address)) != nil else {
invalidAddress = true
return
}
invalidAddress = false
Task { await store.connect(address) }
}.buttonStyle(.borderedProminent).disabled(store.busy || address.isEmpty)
.accessibilityIdentifier("connect")
} else if store.twoFactor {
TextField("Code oder Wiederherstellungscode", text: $code).textContentType(.oneTimeCode)
.textFieldStyle(.roundedBorder)
Button("Bestätigen") {
let value = code
code = ""
Task { await store.verify(value) }
}.buttonStyle(.borderedProminent).disabled(store.busy || code.isEmpty)
Button("Zurück zur Anmeldung") { store.cancelTwoFactor() }.disabled(store.busy)
} else {
if store.setup { TextField("Anzeigename", text: $name).textFieldStyle(.roundedBorder) }
if store.passwordAllowed || store.setup {
TextField("Benutzername", text: $username).textContentType(.username)
.textInputAutocapitalization(.never).autocorrectionDisabled().textFieldStyle(
.roundedBorder
).accessibilityIdentifier("username")
SecureField("Passwort", text: $password).textContentType(.password).textFieldStyle(
.roundedBorder
).accessibilityIdentifier("password")
Button(store.setup ? "Haushalt einrichten" : "Anmelden") {
let submitted = password
password = ""
Task {
await store.login(username: username, password: submitted, displayName: name)
}
}.buttonStyle(.borderedProminent).disabled(
store.busy || username.isEmpty || password.isEmpty
).accessibilityIdentifier("login")
}
if store.oidc { Button("SSO – noch in Umsetzung") { sso = true } }
Button("Anderen Server verwenden", action: forget).disabled(store.busy)
}
if store.busy { ProgressView() }
if let error = store.error {
Text(error).foregroundStyle(.red).accessibilityIdentifier("auth-error")
}
}
.padding(8)
}
.frame(maxWidth: 412)
}
.padding(16).frame(maxWidth: .infinity)
.containerRelativeFrame(.vertical) { height, _ in max(height, 500) }
}
.alert("Native Anmeldung", isPresented: $sso) {
Button("Verstanden", role: .cancel) {}
} message: {
Text(
"SSO wird für den nativen API-Client noch nicht unterstützt. Es wird keine Browser-Sitzung als native Anmeldung vorgetäuscht."
)
}
}
}
private struct NativeEditor: View {
@Environment(\.dismiss) private var dismiss
@State var draft: NativeDraft
let busy: Bool
let error: String?
let save: ([String: NativeValue]) async -> Bool
var body: some View {
NavigationStack {
Form {
TextField(draft.page == .shopping ? "Name" : "Titel", text: $draft.title)
TextField(draft.page == .shopping ? "Menge" : "Inhalt", text: $draft.detail, axis: .vertical).lineLimit(
3...10)
if draft.page == .tasks {
Picker("Priorität", selection: $draft.priority) {
Text("Keine").tag("none")
Text("Niedrig").tag("low")
Text("Mittel").tag("medium")
Text("Hoch").tag("high")
Text("Dringend").tag("urgent")
}
TextField("Fällig am (JJJJ-MM-TT)", text: $draft.date)
}
if let error { Text(error).foregroundStyle(.red) }
}
.navigationTitle(draft.recordID == nil ? "Hinzufügen" : "Bearbeiten").navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("Abbrechen") { dismiss() }.disabled(busy) }
ToolbarItem(placement: .confirmationAction) {
Button("Speichern") {
var body: [String: NativeValue] = [
draft.page == .shopping ? "name" : "title": .string(draft.title)
]
if draft.page == .notes { body["content"] = .string(draft.detail) }
if draft.page == .tasks {
body["description"] = .string(draft.detail)
body["priority"] = .string(draft.priority)
body["due_date"] = draft.date.isEmpty ? .null : .string(draft.date)
}
if draft.page == .shopping { body["quantity"] = .string(draft.detail) }
Task { if await save(body) { dismiss() } }
}.disabled(
busy || (draft.title.isEmpty && draft.page != .notes)
|| (draft.page == .notes && draft.detail.isEmpty))
}
}
}.interactiveDismissDisabled(busy)
}
}
private struct NativeCard<Content: View>: View {
@Environment(\.colorScheme) private var scheme
let title: String?
let subtitle: String?
let content: Content
init(_ title: String? = nil, subtitle: String? = nil, @ViewBuilder content: () -> Content) {
self.title = title
self.subtitle = subtitle
self.content = content()
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
if let title { Text(title).font(.title3.weight(.semibold)) }
if let subtitle { Text(subtitle).font(.subheadline).foregroundStyle(.secondary) }
content
}.frame(maxWidth: .infinity, alignment: .leading).padding(16).background(
NativeDesign.surface(scheme == .dark), in: RoundedRectangle(cornerRadius: 16))
}
}
private struct NativeBrandMark: View {
var body: some View {
Canvas { context, size in
let shape = Path(roundedRect: CGRect(origin: .zero, size: size), cornerRadius: size.width * 0.225)
context.fill(
shape,
with: .linearGradient(
Gradient(colors: [Color(red: 139 / 255, green: 92 / 255, blue: 246 / 255), NativeDesign.accent]),
startPoint: .zero, endPoint: CGPoint(x: size.width, y: size.height)))
for (x, y, radius) in [(64.0, 72.0, 27.0), (100.0, 78.0, 25.0), (80.0, 106.0, 24.0)] {
let rect = CGRect(
x: (x - radius) * size.width / 160, y: (y - radius) * size.height / 160,
width: radius * 2 * size.width / 160, height: radius * 2 * size.height / 160)
context.fill(Path(ellipseIn: rect), with: .color(.white.opacity(0.82)))
}
}
}
}
+40
View File
@@ -0,0 +1,40 @@
import Foundation
import Security
/// Native cookies, separate from WebKit and scoped to one configured origin.
struct NativeSessionVault: Sendable {
let origin: String
private var query: [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "community.yuvomi.mobile.native-session",
kSecAttrAccount as String: origin,
]
}
func load() -> Data? {
var request = query
request[kSecReturnData as String] = true
request[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
guard SecItemCopyMatching(request as CFDictionary, &result) == errSecSuccess else { return nil }
return result as? Data
}
func save(_ data: Data) throws {
let attributes: [String: Any] = [
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
]
let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
if status == errSecItemNotFound {
var request = query
for (key, value) in attributes { request[key] = value }
guard SecItemAdd(request as CFDictionary, nil) == errSecSuccess else { throw NativeAPIError.unavailable }
} else if status != errSecSuccess {
throw NativeAPIError.unavailable
}
}
func clear() throws {
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else { throw NativeAPIError.unavailable }
}
}
+219
View File
@@ -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
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
import Foundation import Foundation
struct ServerAddress: Equatable { struct ServerAddress: Equatable, Sendable {
let url: URL let url: URL
init(_ input: String) throws { init(_ input: String) throws {
+1 -1
View File
@@ -3,6 +3,6 @@ import SwiftUI
@main @main
struct YuvomiApp: App { struct YuvomiApp: App {
var body: some Scene { var body: some Scene {
WindowGroup { ContentView() } WindowGroup { NativeRootView() }
} }
} }
+88
View File
@@ -0,0 +1,88 @@
import XCTest
@testable import Yuvomi
final class NativeContractsTests: XCTestCase {
private func decode<T: Decodable>(_ type: T.Type, _ json: String) throws -> T {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
return try decoder.decode(type, from: Data(json.utf8))
}
func testSparsePermissionsPreserveReadAndHiddenStates() throws {
let permissions = try decode(
NativePermissions.self,
#"{"admin":false,"modules":{"tasks":"read","notes":"none"},"widgets":{"shopping":"none"}}"#)
XCTAssertEqual(permissions.access("tasks"), "read")
XCTAssertEqual(permissions.access("notes"), "none")
XCTAssertEqual(permissions.access("shopping"), "write")
XCTAssertFalse(permissions.showsWidget("shopping"))
}
func testTwoFactorResponseDoesNotInventSignedInUser() throws {
let identity = try decode(NativeIdentity.self, #"{"twoFactorRequired":true,"recoveryAvailable":true}"#)
XCTAssertEqual(identity.twoFactorRequired, true)
XCTAssertNil(identity.user)
XCTAssertNil(identity.permissions)
}
func testTaskFieldsMatchActualServerContract() throws {
let task = try decode(
NativeTask.self,
#"{"id":17,"title":"Native task","description":null,"status":"open","priority":"high","due_date":"2026-10-21","category":"general","recurrence_rule":"FREQ=DAILY"}"#
)
XCTAssertEqual(task.id, 17)
XCTAssertEqual(task.dueDate, "2026-10-21")
XCTAssertNil(task.description)
}
func testShoppingUsesServerCounterNames() throws {
let list = try decode(NativeShoppingList.self, #"{"id":3,"name":"Einkauf","item_total":8,"item_checked":2}"#)
XCTAssertEqual(list.itemTotal, 8)
XCTAssertEqual(list.itemChecked, 2)
XCTAssertNil(list.openCount)
}
func testDashboardUnavailableCountsStayUnknown() throws {
let dashboard = try decode(
NativeDashboard.self,
#"{"openTaskCount":null,"shoppingOpenCount":0,"urgentTasks":[],"upcomingEvents":[],"shoppingLists":[],"pinnedNotes":[]}"#
)
XCTAssertNil(dashboard.openTaskCount)
XCTAssertEqual(dashboard.shoppingOpenCount, 0)
}
func testMutationEncodingPreservesNullAndNumericFlags() throws {
let body: [String: NativeValue] = ["due_date": .null, "is_checked": .number(1), "title": .string("Native")]
let json = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(body)) as? [String: Any])
XCTAssertTrue(json["due_date"] is NSNull)
XCTAssertEqual(json["is_checked"] as? Int, 1)
XCTAssertEqual(json["title"] as? String, "Native")
}
func testRejectsBooleanWhereServerUsesIntegerState() {
XCTAssertThrowsError(try decode(NativeShoppingItem.self, #"{"id":1,"name":"Apfel","is_checked":true}"#))
}
@MainActor
func testNativeTransportRejectsNonAPIPathsBeforeNetwork() async throws {
let api = try NativeAPI(server: ServerAddress("https://example.invalid"), saved: nil, persist: { _ in })
for path in ["https://evil.invalid", "//evil.invalid", "/path#fragment", "/path\\escape"] {
do {
let _: NativeEmpty = try await api.request(path, as: NativeEmpty.self)
XCTFail("Invalid path accepted")
} catch NativeAPIError.invalidPath {}
}
await api.close()
}
@MainActor
func testClosedTransportCannotReuseAnOldSession() async throws {
let api = try NativeAPI(server: ServerAddress("https://example.invalid"), saved: nil, persist: { _ in })
await api.close()
do {
let _: NativeEmpty = try await api.request("/auth/me", as: NativeEmpty.self)
XCTFail("Closed session accepted a request")
} catch NativeAPIError.closed {}
}
}
+4 -3
View File
@@ -4,12 +4,13 @@ final class OnboardingTests: XCTestCase {
func testRejectsCleartextWithoutNetwork() { func testRejectsCleartextWithoutNetwork() {
let app = XCUIApplication() let app = XCUIApplication()
app.launch() app.launch()
let field = app.textFields["Yuvomi server URL"] let field = app.textFields["server-url"]
XCTAssertTrue(field.waitForExistence(timeout: 5)) XCTAssertTrue(field.waitForExistence(timeout: 5))
field.tap() field.tap()
field.typeText("http://family.example") field.typeText("http://family.example")
app.buttons["Connect securely"].tap() app.buttons["connect"].tap()
XCTAssertTrue(app.staticTexts["connectionError"].waitForExistence(timeout: 3)) XCTAssertTrue(app.staticTexts["invalid-server"].waitForExistence(timeout: 3))
XCTAssertTrue(field.exists) XCTAssertTrue(field.exists)
XCTAssertEqual(app.webViews.count, 0)
} }
} }
+3 -3
View File
@@ -6,8 +6,8 @@ options:
settings: settings:
base: base:
SWIFT_VERSION: "5.0" SWIFT_VERSION: "5.0"
MARKETING_VERSION: "0.1.0" MARKETING_VERSION: "0.2.0"
CURRENT_PROJECT_VERSION: 1 CURRENT_PROJECT_VERSION: 2
CODE_SIGN_STYLE: Automatic CODE_SIGN_STYLE: Automatic
DEVELOPMENT_TEAM: "" DEVELOPMENT_TEAM: ""
ENABLE_USER_SCRIPT_SANDBOXING: YES ENABLE_USER_SCRIPT_SANDBOXING: YES
@@ -42,7 +42,7 @@ targets:
TARGETED_DEVICE_FAMILY: "1,2" TARGETED_DEVICE_FAMILY: "1,2"
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
dependencies: dependencies:
- sdk: WebKit.framework - sdk: Security.framework
YuvomiTests: YuvomiTests:
type: bundle.unit-test type: bundle.unit-test
platform: iOS platform: iOS
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.