From 97570b7fdf02acdd9d1baecb887a31b6cfcb1c7c6ab9314d410901572858a48b Mon Sep 17 00:00:00 2001 From: Yuvomi Mobile Contributors Date: Wed, 21 Oct 2026 08:57:31 +0200 Subject: [PATCH] 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. --- ios/Package.swift | 6 +- ios/Sources/NativeAPI.swift | 151 +++++ ios/Sources/NativeContracts.swift | 147 +++++ ios/Sources/NativeRootView.swift | 525 ++++++++++++++++++ ios/Sources/NativeSessionVault.swift | 40 ++ ios/Sources/NativeStore.swift | 219 ++++++++ ios/Sources/ServerAddress.swift | 2 +- ios/Sources/YuvomiApp.swift | 2 +- ios/Tests/NativeContractsTests.swift | 88 +++ ios/UITests/OnboardingTests.swift | 7 +- ios/project.yml | 6 +- .../ios}/AuthenticationView.swift | 0 .../Sources => legacy/ios}/BrowserModel.swift | 0 .../ios}/BrowserModelTests.swift | 0 {ios/Sources => legacy/ios}/ContentView.swift | 0 15 files changed, 1182 insertions(+), 11 deletions(-) create mode 100644 ios/Sources/NativeAPI.swift create mode 100644 ios/Sources/NativeContracts.swift create mode 100644 ios/Sources/NativeRootView.swift create mode 100644 ios/Sources/NativeSessionVault.swift create mode 100644 ios/Sources/NativeStore.swift create mode 100644 ios/Tests/NativeContractsTests.swift rename {ios/Sources => legacy/ios}/AuthenticationView.swift (100%) rename {ios/Sources => legacy/ios}/BrowserModel.swift (100%) rename {ios/Tests => legacy/ios}/BrowserModelTests.swift (100%) rename {ios/Sources => legacy/ios}/ContentView.swift (100%) diff --git a/ios/Package.swift b/ios/Package.swift index 9272601..836d0f6 100644 --- a/ios/Package.swift +++ b/ios/Package.swift @@ -9,10 +9,10 @@ let package = Package( .target( name: "Yuvomi", path: "Sources", exclude: [ - "AuthenticationView.swift", "BrowserModel.swift", "ContentView.swift", + "NativeStore.swift", "NativeRootView.swift", "NativeSessionVault.swift", "ServerProbe.swift", "YuvomiApp.swift", "PrivacyInfo.xcprivacy", "Assets.xcassets", - ], sources: ["ServerAddress.swift", "ServerMetadata.swift"]), + ], sources: ["ServerAddress.swift", "ServerMetadata.swift", "NativeContracts.swift", "NativeAPI.swift"]), .testTarget( - name: "YuvomiPolicyTests", dependencies: ["Yuvomi"], path: "Tests", exclude: ["BrowserModelTests.swift"]), + name: "YuvomiPolicyTests", dependencies: ["Yuvomi"], path: "Tests"), ] ) diff --git a/ios/Sources/NativeAPI.swift b/ios/Sources/NativeAPI.swift new file mode 100644 index 0000000..a2821cc --- /dev/null +++ b/ios/Sources/NativeAPI.swift @@ -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( + _ 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 + } + } +} diff --git a/ios/Sources/NativeContracts.swift b/ios/Sources/NativeContracts.swift new file mode 100644 index 0000000..20dd97f --- /dev/null +++ b/ios/Sources/NativeContracts.swift @@ -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: 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." + } + } +} diff --git a/ios/Sources/NativeRootView.swift b/ios/Sources/NativeRootView.swift new file mode 100644 index 0000000..c2b269c --- /dev/null +++ b/ios/Sources/NativeRootView.swift @@ -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: 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))) + } + } + } +} diff --git a/ios/Sources/NativeSessionVault.swift b/ios/Sources/NativeSessionVault.swift new file mode 100644 index 0000000..4b50109 --- /dev/null +++ b/ios/Sources/NativeSessionVault.swift @@ -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 } + } +} diff --git a/ios/Sources/NativeStore.swift b/ios/Sources/NativeStore.swift new file mode 100644 index 0000000..8f85a6e --- /dev/null +++ b/ios/Sources/NativeStore.swift @@ -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 + } +} diff --git a/ios/Sources/ServerAddress.swift b/ios/Sources/ServerAddress.swift index 6996698..73d1b15 100644 --- a/ios/Sources/ServerAddress.swift +++ b/ios/Sources/ServerAddress.swift @@ -1,6 +1,6 @@ import Foundation -struct ServerAddress: Equatable { +struct ServerAddress: Equatable, Sendable { let url: URL init(_ input: String) throws { diff --git a/ios/Sources/YuvomiApp.swift b/ios/Sources/YuvomiApp.swift index 38e646c..2f8d64e 100644 --- a/ios/Sources/YuvomiApp.swift +++ b/ios/Sources/YuvomiApp.swift @@ -3,6 +3,6 @@ import SwiftUI @main struct YuvomiApp: App { var body: some Scene { - WindowGroup { ContentView() } + WindowGroup { NativeRootView() } } } diff --git a/ios/Tests/NativeContractsTests.swift b/ios/Tests/NativeContractsTests.swift new file mode 100644 index 0000000..389420a --- /dev/null +++ b/ios/Tests/NativeContractsTests.swift @@ -0,0 +1,88 @@ +import XCTest + +@testable import Yuvomi + +final class NativeContractsTests: XCTestCase { + private func decode(_ 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 {} + } +} diff --git a/ios/UITests/OnboardingTests.swift b/ios/UITests/OnboardingTests.swift index 60601da..c906a86 100644 --- a/ios/UITests/OnboardingTests.swift +++ b/ios/UITests/OnboardingTests.swift @@ -4,12 +4,13 @@ final class OnboardingTests: XCTestCase { func testRejectsCleartextWithoutNetwork() { let app = XCUIApplication() app.launch() - let field = app.textFields["Yuvomi server URL"] + let field = app.textFields["server-url"] XCTAssertTrue(field.waitForExistence(timeout: 5)) field.tap() field.typeText("http://family.example") - app.buttons["Connect securely"].tap() - XCTAssertTrue(app.staticTexts["connectionError"].waitForExistence(timeout: 3)) + app.buttons["connect"].tap() + XCTAssertTrue(app.staticTexts["invalid-server"].waitForExistence(timeout: 3)) XCTAssertTrue(field.exists) + XCTAssertEqual(app.webViews.count, 0) } } diff --git a/ios/project.yml b/ios/project.yml index 5be5548..f4eb434 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -6,8 +6,8 @@ options: settings: base: SWIFT_VERSION: "5.0" - MARKETING_VERSION: "0.1.0" - CURRENT_PROJECT_VERSION: 1 + MARKETING_VERSION: "0.2.0" + CURRENT_PROJECT_VERSION: 2 CODE_SIGN_STYLE: Automatic DEVELOPMENT_TEAM: "" ENABLE_USER_SCRIPT_SANDBOXING: YES @@ -42,7 +42,7 @@ targets: TARGETED_DEVICE_FAMILY: "1,2" ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon dependencies: - - sdk: WebKit.framework + - sdk: Security.framework YuvomiTests: type: bundle.unit-test platform: iOS diff --git a/ios/Sources/AuthenticationView.swift b/legacy/ios/AuthenticationView.swift similarity index 100% rename from ios/Sources/AuthenticationView.swift rename to legacy/ios/AuthenticationView.swift diff --git a/ios/Sources/BrowserModel.swift b/legacy/ios/BrowserModel.swift similarity index 100% rename from ios/Sources/BrowserModel.swift rename to legacy/ios/BrowserModel.swift diff --git a/ios/Tests/BrowserModelTests.swift b/legacy/ios/BrowserModelTests.swift similarity index 100% rename from ios/Tests/BrowserModelTests.swift rename to legacy/ios/BrowserModelTests.swift diff --git a/ios/Sources/ContentView.swift b/legacy/ios/ContentView.swift similarity index 100% rename from ios/Sources/ContentView.swift rename to legacy/ios/ContentView.swift