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
@@ -1,77 +0,0 @@
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
|
||||
struct AuthenticationView: View {
|
||||
let destination: URL
|
||||
@ObservedObject var model: BrowserModel
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
IsolatedAuthentication(url: destination, model: model)
|
||||
.navigationTitle(model.authenticationHost)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar { Button("Close") { model.authentication = nil } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct IsolatedAuthentication: UIViewRepresentable {
|
||||
let url: URL
|
||||
let model: BrowserModel
|
||||
func makeCoordinator() -> Coordinator { Coordinator(model) }
|
||||
func makeUIView(context: Context) -> WKWebView {
|
||||
let configuration = WKWebViewConfiguration()
|
||||
// Same cookie store as the initiating Yuvomi handshake; no manual cookie export.
|
||||
configuration.websiteDataStore = model.web.configuration.websiteDataStore
|
||||
configuration.preferences.javaScriptCanOpenWindowsAutomatically = false
|
||||
configuration.mediaTypesRequiringUserActionForPlayback = .all
|
||||
let view = WKWebView(frame: .zero, configuration: configuration)
|
||||
view.navigationDelegate = context.coordinator
|
||||
view.uiDelegate = context.coordinator
|
||||
if #available(iOS 16.4, *) { view.isInspectable = false }
|
||||
view.load(URLRequest(url: url))
|
||||
return view
|
||||
}
|
||||
func updateUIView(_ uiView: WKWebView, context: Context) {}
|
||||
static func dismantleUIView(_ uiView: WKWebView, coordinator: Coordinator) {
|
||||
uiView.stopLoading()
|
||||
uiView.navigationDelegate = nil
|
||||
uiView.uiDelegate = nil
|
||||
}
|
||||
final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate {
|
||||
let model: BrowserModel
|
||||
init(_ model: BrowserModel) { self.model = model }
|
||||
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
|
||||
model.authenticationHost = webView.url?.host ?? "Sign in"
|
||||
}
|
||||
func webView(
|
||||
_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction,
|
||||
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
|
||||
) {
|
||||
guard let url = navigationAction.request.url, url.scheme == "https",
|
||||
url.user == nil, url.password == nil
|
||||
else {
|
||||
decisionHandler(.cancel)
|
||||
return
|
||||
}
|
||||
decisionHandler(.allow)
|
||||
}
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
if let url = webView.url, model.server?.owns(url) == true, !url.path.hasPrefix("/api/") {
|
||||
model.authentication = nil
|
||||
model.web.load(URLRequest(url: url))
|
||||
}
|
||||
}
|
||||
func webView(
|
||||
_ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin,
|
||||
initiatedByFrame frame: WKFrameInfo, type: WKMediaCaptureType,
|
||||
decisionHandler: @escaping (WKPermissionDecision) -> Void
|
||||
) { decisionHandler(.deny) }
|
||||
func webView(
|
||||
_ webView: WKWebView, runOpenPanelWith parameters: WKOpenPanelParameters,
|
||||
initiatedByFrame frame: WKFrameInfo,
|
||||
completionHandler: @escaping @MainActor @Sendable ([URL]?) -> Void
|
||||
) {
|
||||
completionHandler(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,310 +0,0 @@
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
|
||||
struct WebDestination: Identifiable {
|
||||
let id = UUID()
|
||||
let url: URL
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class BrowserModel: NSObject, ObservableObject, WKNavigationDelegate, WKUIDelegate, WKDownloadDelegate {
|
||||
@Published var loading = false
|
||||
@Published var canGoBack = false
|
||||
@Published var error: String?
|
||||
@Published var externalURL: URL?
|
||||
@Published var authentication: WebDestination?
|
||||
@Published var authenticationHost = "Sign in"
|
||||
@Published var download: WebDestination?
|
||||
private(set) var server: ServerAddress?
|
||||
private(set) var web: WKWebView
|
||||
private var observations: [NSKeyValueObservation] = []
|
||||
private var deadline: Timer?
|
||||
private var activeDownload: WKDownload?
|
||||
private var downloadLocation: URL?
|
||||
|
||||
override init() {
|
||||
web = Self.makeWebView()
|
||||
super.init()
|
||||
configureWebView()
|
||||
// Only app-owned temporary exports are removed, never user-saved documents.
|
||||
let folder = Self.exportDirectory
|
||||
try? FileManager.default.removeItem(at: folder)
|
||||
}
|
||||
|
||||
private static func makeWebView() -> WKWebView {
|
||||
let configuration = WKWebViewConfiguration()
|
||||
configuration.websiteDataStore = .default()
|
||||
configuration.preferences.javaScriptCanOpenWindowsAutomatically = false
|
||||
configuration.allowsInlineMediaPlayback = true
|
||||
configuration.mediaTypesRequiringUserActionForPlayback = .all
|
||||
return WKWebView(frame: .zero, configuration: configuration)
|
||||
}
|
||||
|
||||
private func configureWebView() {
|
||||
web.navigationDelegate = self
|
||||
web.uiDelegate = self
|
||||
web.allowsBackForwardNavigationGestures = true
|
||||
web.isOpaque = false
|
||||
if #available(iOS 16.4, *) { web.isInspectable = false }
|
||||
observations.append(
|
||||
web.observe(\.canGoBack, options: [.new]) { [weak self] view, _ in
|
||||
self?.canGoBack = view.canGoBack
|
||||
})
|
||||
}
|
||||
|
||||
static var exportDirectory: URL {
|
||||
FileManager.default.temporaryDirectory.appendingPathComponent("YuvomiExports", isDirectory: true)
|
||||
}
|
||||
|
||||
func open(_ server: ServerAddress) {
|
||||
self.server = server
|
||||
let path = UserDefaults.standard.string(forKey: "path") ?? "/"
|
||||
let restored = URL(string: path, relativeTo: server.url)?.absoluteURL
|
||||
let url = restored.flatMap { server.owns($0) ? $0 : nil } ?? server.url
|
||||
web.load(URLRequest(url: url))
|
||||
}
|
||||
|
||||
func rememberPath() {
|
||||
guard let url = web.url, server?.owns(url) == true else { return }
|
||||
let path = url.path
|
||||
guard !["auth", "reset", "invite", "login"].contains(where: { path.localizedCaseInsensitiveContains($0) })
|
||||
else { return }
|
||||
UserDefaults.standard.set(path, forKey: "path")
|
||||
}
|
||||
|
||||
func erase(completion: @escaping () -> Void) {
|
||||
deadline?.invalidate()
|
||||
web.stopLoading()
|
||||
activeDownload?.cancel { _ in }
|
||||
activeDownload = nil
|
||||
authentication = nil
|
||||
removeDownload()
|
||||
server = nil
|
||||
web.navigationDelegate = nil
|
||||
web.uiDelegate = nil
|
||||
observations.removeAll()
|
||||
WKWebsiteDataStore.default().removeData(
|
||||
ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(),
|
||||
modifiedSince: .distantPast
|
||||
) { [weak self] in
|
||||
guard let self else { return }
|
||||
self.web = Self.makeWebView()
|
||||
self.configureWebView()
|
||||
self.canGoBack = false
|
||||
self.loading = false
|
||||
self.error = nil
|
||||
completion()
|
||||
}
|
||||
}
|
||||
|
||||
func startAuthentication(_ url: URL) {
|
||||
guard url.scheme == "https", url.user == nil, url.password == nil else { return }
|
||||
authenticationHost = url.host ?? "Sign in"
|
||||
authentication = WebDestination(url: url)
|
||||
}
|
||||
|
||||
func removeDownload(expected: URL? = nil) {
|
||||
if let expected, download?.url != expected, downloadLocation != expected { return }
|
||||
download = nil
|
||||
if let location = downloadLocation {
|
||||
try? FileManager.default.removeItem(at: location.deletingLastPathComponent())
|
||||
}
|
||||
downloadLocation = nil
|
||||
}
|
||||
|
||||
func webView(
|
||||
_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction,
|
||||
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
|
||||
) {
|
||||
guard let url = navigationAction.request.url else {
|
||||
decisionHandler(.cancel)
|
||||
return
|
||||
}
|
||||
if server?.owns(url) == true || (navigationAction.shouldPerformDownload && server?.ownsDownload(url) == true) {
|
||||
decisionHandler(navigationAction.shouldPerformDownload ? .download : .allow)
|
||||
} else {
|
||||
decisionHandler(.cancel)
|
||||
if navigationAction.targetFrame?.isMainFrame != false {
|
||||
if url.scheme == "https", url.host != nil, url.user == nil, url.password == nil {
|
||||
externalURL = url
|
||||
} else {
|
||||
error = "This link type is blocked for your security."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func webView(
|
||||
_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse,
|
||||
decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void
|
||||
) {
|
||||
if let response = navigationResponse.response as? HTTPURLResponse,
|
||||
navigationResponse.isForMainFrame, response.statusCode >= 400
|
||||
{
|
||||
error = "The server is unavailable (HTTP \(response.statusCode))."
|
||||
}
|
||||
guard let url = navigationResponse.response.url, server?.ownsDownload(url) == true else {
|
||||
decisionHandler(.cancel)
|
||||
return
|
||||
}
|
||||
if !navigationResponse.canShowMIMEType {
|
||||
guard navigationResponse.response.expectedContentLength <= 100 * 1024 * 1024 else {
|
||||
error = "This file exceeds the 100 MB download limit."
|
||||
decisionHandler(.cancel)
|
||||
return
|
||||
}
|
||||
decisionHandler(.download)
|
||||
} else {
|
||||
decisionHandler(.allow)
|
||||
}
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
|
||||
loading = true
|
||||
deadline?.invalidate()
|
||||
deadline = Timer.scheduledTimer(withTimeInterval: 30, repeats: false) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
self?.loading = false
|
||||
self?.error = "The server is taking too long. Check your connection or try again."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
loading = false
|
||||
deadline?.invalidate()
|
||||
rememberPath()
|
||||
}
|
||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { failed(error) }
|
||||
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
||||
failed(error)
|
||||
}
|
||||
private func failed(_ failure: Error) {
|
||||
loading = false
|
||||
deadline?.invalidate()
|
||||
guard (failure as NSError).code != NSURLErrorCancelled else { return }
|
||||
error =
|
||||
"Unable to connect securely. Check your network, VPN and server certificate. Cached pages may still be available."
|
||||
}
|
||||
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
|
||||
error = "Yuvomi's browser was interrupted. Tap Retry to restore the page."
|
||||
loading = false
|
||||
}
|
||||
func webView(
|
||||
_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration,
|
||||
for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures
|
||||
) -> WKWebView? {
|
||||
if let url = navigationAction.request.url {
|
||||
if server?.owns(url) == true {
|
||||
web.load(navigationAction.request)
|
||||
} else if url.scheme == "https", url.user == nil, url.password == nil {
|
||||
externalURL = url
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func webView(
|
||||
_ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin,
|
||||
initiatedByFrame frame: WKFrameInfo, type: WKMediaCaptureType,
|
||||
decisionHandler: @escaping (WKPermissionDecision) -> Void
|
||||
) {
|
||||
// File input capture uses Apple's picker. Live camera/microphone access is not required by upstream.
|
||||
decisionHandler(.deny)
|
||||
}
|
||||
func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) {
|
||||
attach(download)
|
||||
}
|
||||
func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) {
|
||||
attach(download)
|
||||
}
|
||||
private func attach(_ download: WKDownload) {
|
||||
guard activeDownload == nil, self.download == nil else {
|
||||
download.cancel { _ in }
|
||||
error = "Finish the current download before starting another."
|
||||
return
|
||||
}
|
||||
activeDownload = download
|
||||
download.delegate = self
|
||||
}
|
||||
func download(
|
||||
_ download: WKDownload, decideDestinationUsing response: URLResponse,
|
||||
suggestedFilename: String, completionHandler: @escaping (URL?) -> Void
|
||||
) {
|
||||
guard let url = response.url, server?.ownsDownload(url) == true,
|
||||
response.expectedContentLength <= 100 * 1024 * 1024
|
||||
else {
|
||||
completionHandler(nil)
|
||||
return
|
||||
}
|
||||
do {
|
||||
let folder = Self.exportDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(
|
||||
at: folder, withIntermediateDirectories: true,
|
||||
attributes: [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication])
|
||||
var values = URLResourceValues()
|
||||
values.isExcludedFromBackup = true
|
||||
var directory = folder
|
||||
try directory.setResourceValues(values)
|
||||
let name = String(
|
||||
suggestedFilename.map { $0.isLetter || $0.isNumber || "._ -".contains($0) ? $0 : "_" }.prefix(120))
|
||||
let safeName = name.isEmpty || name == "." || name == ".." ? "download" : name
|
||||
downloadLocation = folder.appendingPathComponent(safeName)
|
||||
completionHandler(downloadLocation)
|
||||
} catch {
|
||||
completionHandler(nil)
|
||||
self.error = "Could not prepare the download."
|
||||
}
|
||||
}
|
||||
func download(
|
||||
_ download: WKDownload, willPerformHTTPRedirection response: HTTPURLResponse,
|
||||
newRequest request: URLRequest, decisionHandler: @escaping (WKDownload.RedirectPolicy) -> Void
|
||||
) {
|
||||
// Do not replay session credentials to a redirect target.
|
||||
decisionHandler(.cancel)
|
||||
}
|
||||
func downloadDidFinish(_ download: WKDownload) {
|
||||
guard activeDownload === download else { return }
|
||||
activeDownload = nil
|
||||
guard let location = downloadLocation,
|
||||
let size = try? location.resourceValues(forKeys: [.fileSizeKey]).fileSize,
|
||||
size <= 100 * 1024 * 1024
|
||||
else {
|
||||
removeDownload()
|
||||
error = "Download exceeded the size limit."
|
||||
return
|
||||
}
|
||||
self.download = WebDestination(url: location)
|
||||
}
|
||||
func download(_ download: WKDownload, didFailWithError error: Error, resumeData: Data?) {
|
||||
guard activeDownload === download else { return }
|
||||
activeDownload = nil
|
||||
removeDownload()
|
||||
self.error = "Download failed. Check your connection and sign-in, then retry."
|
||||
}
|
||||
}
|
||||
|
||||
struct BrowserView: UIViewRepresentable {
|
||||
@ObservedObject var model: BrowserModel
|
||||
func makeUIView(context: Context) -> WKWebView { model.web }
|
||||
func updateUIView(_ uiView: WKWebView, context: Context) {}
|
||||
}
|
||||
|
||||
struct ExportView: UIViewControllerRepresentable {
|
||||
let url: URL
|
||||
let completion: () -> Void
|
||||
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
|
||||
let picker = UIDocumentPickerViewController(forExporting: [url], asCopy: true)
|
||||
picker.delegate = context.coordinator
|
||||
return picker
|
||||
}
|
||||
func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {}
|
||||
func makeCoordinator() -> Coordinator { Coordinator(completion) }
|
||||
final class Coordinator: NSObject, UIDocumentPickerDelegate {
|
||||
let completion: () -> Void
|
||||
init(_ completion: @escaping () -> Void) { self.completion = completion }
|
||||
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { completion() }
|
||||
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
|
||||
completion()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
|
||||
struct ContentView: View {
|
||||
@StateObject private var browser = BrowserModel()
|
||||
@State private var input = UserDefaults.standard.string(forKey: "origin") ?? ""
|
||||
@State private var server: ServerAddress?
|
||||
@State private var connecting = false
|
||||
@State private var message: String?
|
||||
@State private var settings = false
|
||||
@State private var erasing = false
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if server != nil {
|
||||
ZStack {
|
||||
BrowserView(model: browser)
|
||||
if let error = browser.error {
|
||||
VStack(spacing: 18) {
|
||||
Image(systemName: "wifi.exclamationmark").font(.largeTitle)
|
||||
Text("Connection interrupted").font(.title2).bold()
|
||||
Text(error)
|
||||
Button("Retry") {
|
||||
browser.error = nil
|
||||
browser.web.reload()
|
||||
}
|
||||
Button("Keep viewing") { browser.error = nil }
|
||||
Button("Server settings") { settings = true }
|
||||
}
|
||||
.padding(24).frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(uiColor: .systemGroupedBackground))
|
||||
.accessibilityElement(children: .contain)
|
||||
}
|
||||
if browser.loading {
|
||||
ProgressView("Loading Yuvomi").padding().background(.regularMaterial).cornerRadius(12)
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button {
|
||||
browser.web.goBack()
|
||||
} label: {
|
||||
Image(systemName: "chevron.backward")
|
||||
}
|
||||
.disabled(!browser.canGoBack).accessibilityLabel("Back")
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Server settings") { settings = true }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
Image(systemName: "house.fill").font(.system(size: 48)).foregroundStyle(.tint)
|
||||
.accessibilityHidden(true)
|
||||
Text("Your home, connected").font(.largeTitle).bold()
|
||||
Text("Connect to your own Yuvomi server. Your data stays with your household.")
|
||||
TextField("https://family.example.com", text: $input)
|
||||
.keyboardType(.URL).textContentType(.URL)
|
||||
.textInputAutocapitalization(.never).autocorrectionDisabled()
|
||||
.textFieldStyle(.roundedBorder).accessibilityLabel("Yuvomi server URL")
|
||||
Text("HTTPS required. Custom ports supported. Use a dedicated hostname, not a subpath.")
|
||||
.font(.callout).foregroundStyle(.secondary)
|
||||
if let message { Text(message).accessibilityIdentifier("connectionError") }
|
||||
if connecting { ProgressView("Checking your server") }
|
||||
Button("Connect securely") { connect() }
|
||||
.buttonStyle(.borderedProminent).disabled(connecting || erasing)
|
||||
.frame(minHeight: 48)
|
||||
Text("Independent community app. No telemetry or mandatory cloud service.")
|
||||
.font(.footnote).foregroundStyle(.secondary)
|
||||
}.padding(24)
|
||||
}.background(Color(uiColor: .systemGroupedBackground))
|
||||
}
|
||||
}
|
||||
.navigationTitle("Yuvomi Mobile").navigationBarTitleDisplayMode(.inline)
|
||||
.tint(
|
||||
colorScheme == .dark
|
||||
? Color(red: 167 / 255, green: 139 / 255, blue: 250 / 255)
|
||||
: Color(red: 108 / 255, green: 58 / 255, blue: 237 / 255))
|
||||
}
|
||||
.task {
|
||||
if server == nil, let existing = try? ServerAddress(input) {
|
||||
server = existing
|
||||
browser.open(existing)
|
||||
}
|
||||
}
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
if phase == .background { browser.rememberPath() }
|
||||
}
|
||||
.overlay {
|
||||
if scenePhase != .active {
|
||||
Color(uiColor: .systemBackground).ignoresSafeArea()
|
||||
.overlay(Text("Yuvomi Mobile").font(.title).bold())
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $settings) {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Your server") {
|
||||
Text(server?.url.absoluteString ?? "")
|
||||
if let url = server?.url {
|
||||
ShareLink("Share server address", item: url)
|
||||
Link("Open server in system browser", destination: url)
|
||||
}
|
||||
}
|
||||
Section {
|
||||
Text(
|
||||
"Changing servers erases cookies, storage and cached household data. Exported files remain. Sign out in Yuvomi first to revoke the server session."
|
||||
)
|
||||
Button("Erase local data and change server", role: .destructive) {
|
||||
settings = false
|
||||
erasing = true
|
||||
browser.erase {
|
||||
UserDefaults.standard.removeObject(forKey: "origin")
|
||||
UserDefaults.standard.removeObject(forKey: "path")
|
||||
server = nil
|
||||
input = ""
|
||||
erasing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
Section("About") {
|
||||
Text(
|
||||
"Independent community contribution. MIT licensed. Not endorsed by upstream Yuvomi. No mobile telemetry. Your server's own privacy settings still apply."
|
||||
)
|
||||
}
|
||||
}.navigationTitle("Server settings")
|
||||
.toolbar { Button("Done") { settings = false } }
|
||||
}
|
||||
}
|
||||
.alert(
|
||||
"External destination",
|
||||
isPresented: Binding(
|
||||
get: { browser.externalURL != nil }, set: { if !$0 { browser.externalURL = nil } }
|
||||
), presenting: browser.externalURL
|
||||
) { url in
|
||||
Button("Open in browser") {
|
||||
UIApplication.shared.open(url)
|
||||
browser.externalURL = nil
|
||||
}
|
||||
Button("Isolated sign-in window") {
|
||||
browser.startAuthentication(url)
|
||||
browser.externalURL = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) { browser.externalURL = nil }
|
||||
} message: { url in
|
||||
Text(
|
||||
"Destination: \(url.host ?? "")\nFor SSO, use the isolated window only if you trust this provider. Browser sessions do not transfer into the app."
|
||||
)
|
||||
}
|
||||
.sheet(item: $browser.authentication) { destination in
|
||||
AuthenticationView(destination: destination.url, model: browser)
|
||||
}
|
||||
.sheet(item: $browser.download) { file in
|
||||
ExportView(url: file.url) { browser.removeDownload(expected: file.url) }
|
||||
}
|
||||
}
|
||||
|
||||
private func connect() {
|
||||
do {
|
||||
let candidate = try ServerAddress(input)
|
||||
connecting = true
|
||||
message = nil
|
||||
Task { @MainActor in
|
||||
defer { connecting = false }
|
||||
do {
|
||||
try await ServerProbe.check(candidate)
|
||||
UserDefaults.standard.set(candidate.url.absoluteString, forKey: "origin")
|
||||
server = candidate
|
||||
browser.open(candidate)
|
||||
} catch { message = ConnectionError.unavailable.localizedDescription }
|
||||
}
|
||||
} catch { message = ConnectionError.invalidAddress.localizedDescription }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
struct ServerAddress: Equatable {
|
||||
struct ServerAddress: Equatable, Sendable {
|
||||
let url: URL
|
||||
|
||||
init(_ input: String) throws {
|
||||
|
||||
@@ -3,6 +3,6 @@ import SwiftUI
|
||||
@main
|
||||
struct YuvomiApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup { ContentView() }
|
||||
WindowGroup { NativeRootView() }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user