feat(ios): implement SwiftUI companion with isolated WKWebView sessions

Add HTTPS onboarding, origin policy, native recovery/settings, isolated web sign-in and file export. Portable production URL policy tests pass on Linux; all Swift sources parse. Xcode build and simulator validation remain required on macOS.
This commit is contained in:
Yuvomi Mobile Contributors committed 2026-10-20 23:28:07 +02:00
1 parent ed8d5cb1c1
commit 74c2c9e6e8
11 files changed
+691

No files matched your search

+144
View File
@@ -0,0 +1,144 @@
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
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(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() }
}
}
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 }
}
}