SHA256
Ignore late picker callbacks that no longer match the active download. Add an Apple-platform regression; Swift formatting, parsing and seven portable policy tests pass locally, but the new WKWebView regression requires Xcode and has not run here.
179 lines
8.5 KiB
Swift
179 lines
8.5 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
|
|
@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 }
|
|
}
|
|
}
|