Files
Yuvomi-App/ios/Sources/ContentView.swift
T
Yuvomi Mobile Contributors 69c48169e2 fix(ios): restrict authentication capabilities and validate bootstrap types
Require iOS 18.4 for explicit upload denial in the isolated authentication window. Add origin-bound generated download policy, app-switcher privacy, typed metadata regressions and original community icon assets. Seven portable tests and Swift formatting checks pass; Apple SDK validation remains external.
2026-10-20 23:45:01 +02:00

175 lines
8.3 KiB
Swift

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 }
}
}