SHA256
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.
145 lines
7.7 KiB
Swift
145 lines
7.7 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 }
|
|
}
|
|
}
|