feat(ios): replace embedded website with initial native SwiftUI API client

Introduce real SwiftUI auth/dashboard/task/note/shopping screens, a bounded origin-bound URLSession transport and Keychain-backed native sessions. Archive the rejected WebKit renderer rather than embedding it for unfinished modules. Sixteen portable URL/contract/transport tests and Swift format/parsing pass on Linux; Apple SDK application and UI validation remains mandatory. This is an incomplete native slice, not full PWA parity or a release.
This commit is contained in:
Yuvomi Mobile Contributors committed 2026-10-21 08:57:31 +02:00
1 parent 11bf4ca620
commit 97570b7fdf
15 files changed
+1182 -11

No files matched your search

+77
View File
@@ -0,0 +1,77 @@
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)
}
}
}
+310
View File
@@ -0,0 +1,310 @@
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()
}
}
}
+40
View File
@@ -0,0 +1,40 @@
import WebKit
import XCTest
@testable import Yuvomi
final class BrowserModelTests: XCTestCase {
@MainActor
func testMainViewHasNoNativeBridgeOrAutomaticPopupAccess() {
let model = BrowserModel()
XCTAssertFalse(model.web.configuration.preferences.javaScriptCanOpenWindowsAutomatically)
XCTAssertTrue(model.web.configuration.userContentController.userScripts.isEmpty)
XCTAssertTrue(model.web.configuration.websiteDataStore.isPersistent)
XCTAssertFalse(model.web.isInspectable)
}
@MainActor
func testLateExportCancellationDoesNotEraseAnotherServersDownload() {
let model = BrowserModel()
let previous = URL(fileURLWithPath: "/old-selection.txt")
let current = URL(fileURLWithPath: "/new-selection.txt")
model.download = WebDestination(url: current)
model.removeDownload(expected: previous)
XCTAssertEqual(model.download?.url, current)
model.removeDownload(expected: current)
XCTAssertNil(model.download)
}
@MainActor
func testServerErasureReplacesHistoryContext() async {
let model = BrowserModel()
let previousView = model.web
await withCheckedContinuation { continuation in
model.erase { continuation.resume() }
}
XCTAssertFalse(previousView === model.web)
XCTAssertNil(model.server)
XCTAssertFalse(model.canGoBack)
XCTAssertNil(model.error)
}
}
+178
View File
@@ -0,0 +1,178 @@
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 }
}
}