SHA256
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:
1 parent
ed8d5cb1c1
commit
74c2c9e6e8
11 files changed
+691
No files matched your search
@@ -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 }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user