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,15 @@
|
|||||||
|
// swift-tools-version: 6.0
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
// Portable tests for the exact production URL policy. This is not an iOS build.
|
||||||
|
let package = Package(
|
||||||
|
name: "YuvomiPolicy",
|
||||||
|
products: [.library(name: "Yuvomi", targets: ["Yuvomi"])],
|
||||||
|
targets: [
|
||||||
|
.target(name: "Yuvomi", path: "Sources", exclude: [
|
||||||
|
"AuthenticationView.swift", "BrowserModel.swift", "ContentView.swift",
|
||||||
|
"ServerProbe.swift", "YuvomiApp.swift", "PrivacyInfo.xcprivacy"
|
||||||
|
], sources: ["ServerAddress.swift"]),
|
||||||
|
.testTarget(name: "YuvomiPolicyTests", dependencies: ["Yuvomi"], path: "Tests")
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import WebKit
|
||||||
|
|
||||||
|
struct AuthenticationView: View {
|
||||||
|
let destination: URL
|
||||||
|
@ObservedObject var model: BrowserModel
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
IsolatedAuthentication(url: destination, model: model)
|
||||||
|
.navigationTitle("Isolated web sign-in")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar { Button("Close") { model.authentication = nil } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct IsolatedAuthentication: UIViewRepresentable {
|
||||||
|
let url: URL
|
||||||
|
let model: BrowserModel
|
||||||
|
func makeCoordinator() -> Coordinator { Coordinator(model) }
|
||||||
|
func makeUIView(context: Context) -> WKWebView {
|
||||||
|
let configuration = WKWebViewConfiguration()
|
||||||
|
// Same cookie store as the initiating Yuvomi handshake; no manual cookie export.
|
||||||
|
configuration.websiteDataStore = model.web.configuration.websiteDataStore
|
||||||
|
configuration.preferences.javaScriptCanOpenWindowsAutomatically = false
|
||||||
|
configuration.mediaTypesRequiringUserActionForPlayback = .all
|
||||||
|
let view = WKWebView(frame: .zero, configuration: configuration)
|
||||||
|
view.navigationDelegate = context.coordinator
|
||||||
|
view.uiDelegate = context.coordinator
|
||||||
|
if #available(iOS 16.4, *) { view.isInspectable = false }
|
||||||
|
view.load(URLRequest(url: url))
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
func updateUIView(_ uiView: WKWebView, context: Context) {}
|
||||||
|
static func dismantleUIView(_ uiView: WKWebView, coordinator: Coordinator) {
|
||||||
|
uiView.stopLoading(); uiView.navigationDelegate = nil; uiView.uiDelegate = nil
|
||||||
|
}
|
||||||
|
final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate {
|
||||||
|
let model: BrowserModel
|
||||||
|
init(_ model: BrowserModel) { self.model = model }
|
||||||
|
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction,
|
||||||
|
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||||
|
guard let url = navigationAction.request.url, url.scheme == "https",
|
||||||
|
url.user == nil, url.password == nil else { decisionHandler(.cancel); return }
|
||||||
|
decisionHandler(.allow)
|
||||||
|
}
|
||||||
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||||
|
if let url = webView.url, model.server?.owns(url) == true, !url.path.hasPrefix("/api/") {
|
||||||
|
model.authentication = nil
|
||||||
|
model.web.load(URLRequest(url: url))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func webView(_ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin,
|
||||||
|
initiatedByFrame frame: WKFrameInfo, type: WKMediaCaptureType,
|
||||||
|
decisionHandler: @escaping (WKPermissionDecision) -> Void) { decisionHandler(.deny) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import WebKit
|
||||||
|
|
||||||
|
struct WebDestination: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let url: URL
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class BrowserModel: NSObject, ObservableObject, WKNavigationDelegate, WKUIDelegate, WKDownloadDelegate {
|
||||||
|
@Published var loading = false
|
||||||
|
@Published var canGoBack = false
|
||||||
|
@Published var error: String?
|
||||||
|
@Published var externalURL: URL?
|
||||||
|
@Published var authentication: WebDestination?
|
||||||
|
@Published var download: WebDestination?
|
||||||
|
private(set) var server: ServerAddress?
|
||||||
|
private(set) var web: WKWebView
|
||||||
|
private var observations: [NSKeyValueObservation] = []
|
||||||
|
private var deadline: Timer?
|
||||||
|
private var activeDownload: WKDownload?
|
||||||
|
private var downloadLocation: URL?
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
web = Self.makeWebView()
|
||||||
|
super.init()
|
||||||
|
configureWebView()
|
||||||
|
// Only app-owned temporary exports are removed, never user-saved documents.
|
||||||
|
let folder = Self.exportDirectory
|
||||||
|
try? FileManager.default.removeItem(at: folder)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func makeWebView() -> WKWebView {
|
||||||
|
let configuration = WKWebViewConfiguration()
|
||||||
|
configuration.websiteDataStore = .default()
|
||||||
|
configuration.preferences.javaScriptCanOpenWindowsAutomatically = false
|
||||||
|
configuration.allowsInlineMediaPlayback = true
|
||||||
|
configuration.mediaTypesRequiringUserActionForPlayback = .all
|
||||||
|
return WKWebView(frame: .zero, configuration: configuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureWebView() {
|
||||||
|
web.navigationDelegate = self
|
||||||
|
web.uiDelegate = self
|
||||||
|
web.allowsBackForwardNavigationGestures = true
|
||||||
|
web.isOpaque = false
|
||||||
|
if #available(iOS 16.4, *) { web.isInspectable = false }
|
||||||
|
observations.append(web.observe(\.canGoBack, options: [.new]) { [weak self] view, _ in
|
||||||
|
self?.canGoBack = view.canGoBack
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
static var exportDirectory: URL {
|
||||||
|
FileManager.default.temporaryDirectory.appendingPathComponent("YuvomiExports", isDirectory: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func open(_ server: ServerAddress) {
|
||||||
|
self.server = server
|
||||||
|
let path = UserDefaults.standard.string(forKey: "path") ?? "/"
|
||||||
|
let restored = URL(string: path, relativeTo: server.url)?.absoluteURL
|
||||||
|
let url = restored.flatMap { server.owns($0) ? $0 : nil } ?? server.url
|
||||||
|
web.load(URLRequest(url: url))
|
||||||
|
}
|
||||||
|
|
||||||
|
func rememberPath() {
|
||||||
|
guard let url = web.url, server?.owns(url) == true else { return }
|
||||||
|
let path = url.path
|
||||||
|
guard !["auth", "reset", "invite", "login"].contains(where: { path.localizedCaseInsensitiveContains($0) }) else { return }
|
||||||
|
UserDefaults.standard.set(path, forKey: "path")
|
||||||
|
}
|
||||||
|
|
||||||
|
func erase(completion: @escaping () -> Void) {
|
||||||
|
deadline?.invalidate()
|
||||||
|
web.stopLoading()
|
||||||
|
activeDownload?.cancel { _ in }
|
||||||
|
activeDownload = nil
|
||||||
|
authentication = nil
|
||||||
|
removeDownload()
|
||||||
|
server = nil
|
||||||
|
web.navigationDelegate = nil
|
||||||
|
web.uiDelegate = nil
|
||||||
|
observations.removeAll()
|
||||||
|
WKWebsiteDataStore.default().removeData(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(),
|
||||||
|
modifiedSince: .distantPast) { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.web = Self.makeWebView()
|
||||||
|
self.configureWebView()
|
||||||
|
self.canGoBack = false; self.loading = false; self.error = nil
|
||||||
|
completion()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startAuthentication(_ url: URL) {
|
||||||
|
guard url.scheme == "https", url.user == nil, url.password == nil else { return }
|
||||||
|
authentication = WebDestination(url: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeDownload() {
|
||||||
|
download = nil
|
||||||
|
if let location = downloadLocation { try? FileManager.default.removeItem(at: location.deletingLastPathComponent()) }
|
||||||
|
downloadLocation = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction,
|
||||||
|
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||||
|
guard let url = navigationAction.request.url else { decisionHandler(.cancel); return }
|
||||||
|
if server?.owns(url) == true {
|
||||||
|
decisionHandler(navigationAction.shouldPerformDownload ? .download : .allow)
|
||||||
|
} else {
|
||||||
|
decisionHandler(.cancel)
|
||||||
|
if navigationAction.targetFrame?.isMainFrame != false {
|
||||||
|
if url.scheme == "https", url.host != nil, url.user == nil, url.password == nil {
|
||||||
|
externalURL = url
|
||||||
|
} else { error = "This link type is blocked for your security." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse,
|
||||||
|
decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {
|
||||||
|
if let response = navigationResponse.response as? HTTPURLResponse,
|
||||||
|
navigationResponse.isForMainFrame, response.statusCode >= 400 {
|
||||||
|
error = "The server is unavailable (HTTP \(response.statusCode))."
|
||||||
|
}
|
||||||
|
guard let url = navigationResponse.response.url, server?.owns(url) == true else {
|
||||||
|
decisionHandler(.cancel); return
|
||||||
|
}
|
||||||
|
if !navigationResponse.canShowMIMEType {
|
||||||
|
guard navigationResponse.response.expectedContentLength <= 100 * 1024 * 1024 else {
|
||||||
|
error = "This file exceeds the 100 MB download limit."
|
||||||
|
decisionHandler(.cancel); return
|
||||||
|
}
|
||||||
|
decisionHandler(.download)
|
||||||
|
} else { decisionHandler(.allow) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
|
||||||
|
loading = true
|
||||||
|
deadline?.invalidate()
|
||||||
|
deadline = Timer.scheduledTimer(withTimeInterval: 30, repeats: false) { [weak self] _ in
|
||||||
|
Task { @MainActor in
|
||||||
|
self?.loading = false
|
||||||
|
self?.error = "The server is taking too long. Check your connection or try again."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||||
|
loading = false; deadline?.invalidate(); rememberPath()
|
||||||
|
}
|
||||||
|
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { failed(error) }
|
||||||
|
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { failed(error) }
|
||||||
|
private func failed(_ failure: Error) {
|
||||||
|
loading = false; deadline?.invalidate()
|
||||||
|
guard (failure as NSError).code != NSURLErrorCancelled else { return }
|
||||||
|
error = "Unable to connect securely. Check your network, VPN and server certificate. Cached pages may still be available."
|
||||||
|
}
|
||||||
|
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
|
||||||
|
error = "Yuvomi's browser was interrupted. Tap Retry to restore the page."
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration,
|
||||||
|
for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
|
||||||
|
if let url = navigationAction.request.url {
|
||||||
|
if server?.owns(url) == true { web.load(navigationAction.request) }
|
||||||
|
else if url.scheme == "https", url.user == nil, url.password == nil { externalURL = url }
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func webView(_ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin,
|
||||||
|
initiatedByFrame frame: WKFrameInfo, type: WKMediaCaptureType,
|
||||||
|
decisionHandler: @escaping (WKPermissionDecision) -> Void) {
|
||||||
|
// File input capture uses Apple's picker. Live camera/microphone access is not required by upstream.
|
||||||
|
decisionHandler(.deny)
|
||||||
|
}
|
||||||
|
func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) {
|
||||||
|
attach(download)
|
||||||
|
}
|
||||||
|
func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) {
|
||||||
|
attach(download)
|
||||||
|
}
|
||||||
|
private func attach(_ download: WKDownload) {
|
||||||
|
guard activeDownload == nil, self.download == nil else {
|
||||||
|
download.cancel { _ in }; error = "Finish the current download before starting another."; return
|
||||||
|
}
|
||||||
|
activeDownload = download; download.delegate = self
|
||||||
|
}
|
||||||
|
func download(_ download: WKDownload, decideDestinationUsing response: URLResponse,
|
||||||
|
suggestedFilename: String, completionHandler: @escaping (URL?) -> Void) {
|
||||||
|
guard let url = response.url, server?.owns(url) == true,
|
||||||
|
response.expectedContentLength <= 100 * 1024 * 1024 else { completionHandler(nil); return }
|
||||||
|
do {
|
||||||
|
let folder = Self.exportDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true,
|
||||||
|
attributes: [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication])
|
||||||
|
var values = URLResourceValues(); values.isExcludedFromBackup = true
|
||||||
|
var directory = folder; try directory.setResourceValues(values)
|
||||||
|
let name = String(suggestedFilename.map { $0.isLetter || $0.isNumber || "._ -".contains($0) ? $0 : "_" }.prefix(120))
|
||||||
|
let safeName = name.isEmpty || name == "." || name == ".." ? "download" : name
|
||||||
|
downloadLocation = folder.appendingPathComponent(safeName)
|
||||||
|
completionHandler(downloadLocation)
|
||||||
|
} catch { completionHandler(nil); self.error = "Could not prepare the download." }
|
||||||
|
}
|
||||||
|
func download(_ download: WKDownload, willPerformHTTPRedirection response: HTTPURLResponse,
|
||||||
|
newRequest request: URLRequest, decisionHandler: @escaping (WKDownload.RedirectPolicy) -> Void) {
|
||||||
|
// Do not replay session credentials to a redirect target.
|
||||||
|
decisionHandler(.cancel)
|
||||||
|
}
|
||||||
|
func downloadDidFinish(_ download: WKDownload) {
|
||||||
|
guard activeDownload === download else { return }
|
||||||
|
activeDownload = nil
|
||||||
|
guard let location = downloadLocation,
|
||||||
|
let size = try? location.resourceValues(forKeys: [.fileSizeKey]).fileSize,
|
||||||
|
size <= 100 * 1024 * 1024 else {
|
||||||
|
removeDownload(); error = "Download exceeded the size limit."; return
|
||||||
|
}
|
||||||
|
self.download = WebDestination(url: location)
|
||||||
|
}
|
||||||
|
func download(_ download: WKDownload, didFailWithError error: Error, resumeData: Data?) {
|
||||||
|
guard activeDownload === download else { return }
|
||||||
|
activeDownload = nil; removeDownload()
|
||||||
|
self.error = "Download failed. Check your connection and sign-in, then retry."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BrowserView: UIViewRepresentable {
|
||||||
|
@ObservedObject var model: BrowserModel
|
||||||
|
func makeUIView(context: Context) -> WKWebView { model.web }
|
||||||
|
func updateUIView(_ uiView: WKWebView, context: Context) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ExportView: UIViewControllerRepresentable {
|
||||||
|
let url: URL
|
||||||
|
let completion: () -> Void
|
||||||
|
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
|
||||||
|
let picker = UIDocumentPickerViewController(forExporting: [url], asCopy: true)
|
||||||
|
picker.delegate = context.coordinator
|
||||||
|
return picker
|
||||||
|
}
|
||||||
|
func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {}
|
||||||
|
func makeCoordinator() -> Coordinator { Coordinator(completion) }
|
||||||
|
final class Coordinator: NSObject, UIDocumentPickerDelegate {
|
||||||
|
let completion: () -> Void
|
||||||
|
init(_ completion: @escaping () -> Void) { self.completion = completion }
|
||||||
|
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { completion() }
|
||||||
|
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { completion() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0"><dict>
|
||||||
|
<key>NSPrivacyTracking</key><false/>
|
||||||
|
<key>NSPrivacyTrackingDomains</key><array/>
|
||||||
|
<key>NSPrivacyCollectedDataTypes</key><array/>
|
||||||
|
<key>NSPrivacyAccessedAPITypes</key><array>
|
||||||
|
<dict><key>NSPrivacyAccessedAPIType</key><string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||||
|
<key>NSPrivacyAccessedAPITypeReasons</key><array><string>CA92.1</string></array></dict>
|
||||||
|
</array>
|
||||||
|
</dict></plist>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct ServerAddress: Equatable {
|
||||||
|
let url: URL
|
||||||
|
|
||||||
|
init(_ input: String) throws {
|
||||||
|
let value = input.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !value.contains("\\"), !value.contains(where: { $0.isWhitespace }),
|
||||||
|
var parts = URLComponents(string: value),
|
||||||
|
parts.scheme?.lowercased() == "https",
|
||||||
|
let host = parts.host, !host.isEmpty,
|
||||||
|
parts.user == nil, parts.password == nil,
|
||||||
|
parts.query == nil, parts.fragment == nil,
|
||||||
|
parts.percentEncodedPath.isEmpty || parts.percentEncodedPath == "/",
|
||||||
|
parts.port == nil || (1...65535).contains(parts.port!) else {
|
||||||
|
throw ConnectionError.invalidAddress
|
||||||
|
}
|
||||||
|
parts.scheme = "https"
|
||||||
|
parts.host = host.lowercased()
|
||||||
|
parts.path = ""
|
||||||
|
if parts.port == 443 { parts.port = nil }
|
||||||
|
guard let url = parts.url else { throw ConnectionError.invalidAddress }
|
||||||
|
self.url = url
|
||||||
|
}
|
||||||
|
|
||||||
|
func owns(_ candidate: URL) -> Bool {
|
||||||
|
guard let parts = URLComponents(url: candidate, resolvingAgainstBaseURL: false),
|
||||||
|
let own = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return false }
|
||||||
|
return parts.scheme?.lowercased() == "https" && parts.user == nil && parts.password == nil
|
||||||
|
&& parts.host?.lowercased() == own.host?.lowercased()
|
||||||
|
&& (parts.port ?? 443) == (own.port ?? 443)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ConnectionError: LocalizedError {
|
||||||
|
case invalidAddress, incompatible, unavailable
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidAddress:
|
||||||
|
return "Use a root HTTPS URL with a valid host and port. Remove credentials, subpaths, query and fragment."
|
||||||
|
case .incompatible:
|
||||||
|
return "This server does not expose compatible Yuvomi metadata."
|
||||||
|
case .unavailable:
|
||||||
|
return "Cannot verify the server. Check its certificate, VPN, network and reverse proxy."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
final class NoRedirect: NSObject, URLSessionTaskDelegate {
|
||||||
|
func urlSession(_ session: URLSession, task: URLSessionTask,
|
||||||
|
willPerformHTTPRedirection response: HTTPURLResponse,
|
||||||
|
newRequest request: URLRequest,
|
||||||
|
completionHandler: @escaping (URLRequest?) -> Void) {
|
||||||
|
completionHandler(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ServerProbe {
|
||||||
|
static func check(_ server: ServerAddress) async throws {
|
||||||
|
let configuration = URLSessionConfiguration.ephemeral
|
||||||
|
configuration.httpCookieStorage = nil
|
||||||
|
configuration.urlCache = nil
|
||||||
|
configuration.timeoutIntervalForRequest = 10
|
||||||
|
configuration.timeoutIntervalForResource = 20
|
||||||
|
let session = URLSession(configuration: configuration, delegate: NoRedirect(), delegateQueue: nil)
|
||||||
|
defer { session.invalidateAndCancel() }
|
||||||
|
let version = try await json(session, server.url.appendingPathComponent("api/v1/version"))
|
||||||
|
guard version["app_name"] is String,
|
||||||
|
version["setup_required"] is Bool,
|
||||||
|
version["password_reset_enabled"] is Bool else { throw ConnectionError.incompatible }
|
||||||
|
let auth = try await json(session, server.url.appendingPathComponent("api/v1/auth/oidc/config"))
|
||||||
|
guard auth["enabled"] is Bool, auth["password_login_enabled"] is Bool else {
|
||||||
|
throw ConnectionError.incompatible
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func json(_ session: URLSession, _ url: URL) async throws -> [String: Any] {
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
let (stream, response) = try await session.bytes(for: request)
|
||||||
|
guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw ConnectionError.unavailable }
|
||||||
|
var data = Data()
|
||||||
|
for try await byte in stream {
|
||||||
|
guard data.count < 65_536 else { throw ConnectionError.incompatible }
|
||||||
|
data.append(byte)
|
||||||
|
}
|
||||||
|
guard let result = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||||
|
throw ConnectionError.incompatible
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct YuvomiApp: App {
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup { ContentView() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import Yuvomi
|
||||||
|
|
||||||
|
final class ServerAddressTests: XCTestCase {
|
||||||
|
func testCanonicalizationAndPorts() throws {
|
||||||
|
XCTAssertEqual(try ServerAddress(" HTTPS://Family.Example:443/ ").url.absoluteString, "https://family.example")
|
||||||
|
XCTAssertEqual(try ServerAddress("https://family.example:8443").url.port, 8443)
|
||||||
|
XCTAssertEqual(try ServerAddress("https://[::1]:8443").url.port, 8443)
|
||||||
|
}
|
||||||
|
func testUnsafeConfigurations() {
|
||||||
|
for value in ["http://family.example", "javascript:alert(1)", "https://u:p@family.example",
|
||||||
|
"https://family.example/subpath", "https://family.example?token=secret",
|
||||||
|
"https://family.example/#token", "https://family.example:0",
|
||||||
|
"https://family.example:65536", "https://family.example/%2f",
|
||||||
|
"https://family.example\\@evil.test", "https:///", ""] {
|
||||||
|
XCTAssertThrowsError(try ServerAddress(value), value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func testOriginIsolation() throws {
|
||||||
|
let server = try ServerAddress("https://family.example")
|
||||||
|
XCTAssertTrue(server.owns(URL(string: "https://FAMILY.example:443/tasks?id=1")!))
|
||||||
|
for value in ["https://family.example.evil.test", "https://family.example@evil.test",
|
||||||
|
"https://user@family.example", "http://family.example", "https://family.example:8443",
|
||||||
|
"file:///etc/passwd", "data:text/html,hi"] {
|
||||||
|
XCTAssertFalse(server.owns(URL(string: value)!), value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class OnboardingTests: XCTestCase {
|
||||||
|
func testRejectsCleartextWithoutNetwork() {
|
||||||
|
let app = XCUIApplication()
|
||||||
|
app.launch()
|
||||||
|
let field = app.textFields["Yuvomi server URL"]
|
||||||
|
XCTAssertTrue(field.waitForExistence(timeout: 5))
|
||||||
|
field.tap()
|
||||||
|
field.typeText("http://family.example")
|
||||||
|
app.buttons["Connect securely"].tap()
|
||||||
|
XCTAssertTrue(app.staticTexts["connectionError"].waitForExistence(timeout: 3))
|
||||||
|
XCTAssertTrue(field.exists)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
name: Yuvomi
|
||||||
|
options:
|
||||||
|
bundleIdPrefix: community.yuvomi
|
||||||
|
deploymentTarget:
|
||||||
|
iOS: "16.0"
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
SWIFT_VERSION: "5.0"
|
||||||
|
MARKETING_VERSION: "0.1.0"
|
||||||
|
CURRENT_PROJECT_VERSION: 1
|
||||||
|
CODE_SIGN_STYLE: Automatic
|
||||||
|
DEVELOPMENT_TEAM: ""
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING: YES
|
||||||
|
SWIFT_TREAT_WARNINGS_AS_ERRORS: YES
|
||||||
|
GCC_TREAT_WARNINGS_AS_ERRORS: YES
|
||||||
|
targets:
|
||||||
|
Yuvomi:
|
||||||
|
type: application
|
||||||
|
platform: iOS
|
||||||
|
sources: [Sources]
|
||||||
|
info:
|
||||||
|
path: Info.plist
|
||||||
|
properties:
|
||||||
|
CFBundleDisplayName: Yuvomi Mobile
|
||||||
|
UILaunchScreen: {}
|
||||||
|
NSCameraUsageDescription: Take a photo to upload to your Yuvomi server.
|
||||||
|
NSPhotoLibraryUsageDescription: Choose photos to upload to your Yuvomi server.
|
||||||
|
NSLocalNetworkUsageDescription: Connect to the private Yuvomi server you configure.
|
||||||
|
ITSAppUsesNonExemptEncryption: NO
|
||||||
|
UISupportedInterfaceOrientations:
|
||||||
|
- UIInterfaceOrientationPortrait
|
||||||
|
- UIInterfaceOrientationLandscapeLeft
|
||||||
|
- UIInterfaceOrientationLandscapeRight
|
||||||
|
UISupportedInterfaceOrientations~ipad:
|
||||||
|
- UIInterfaceOrientationPortrait
|
||||||
|
- UIInterfaceOrientationPortraitUpsideDown
|
||||||
|
- UIInterfaceOrientationLandscapeLeft
|
||||||
|
- UIInterfaceOrientationLandscapeRight
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: community.yuvomi.mobile
|
||||||
|
TARGETED_DEVICE_FAMILY: "1,2"
|
||||||
|
dependencies:
|
||||||
|
- sdk: WebKit.framework
|
||||||
|
YuvomiTests:
|
||||||
|
type: bundle.unit-test
|
||||||
|
platform: iOS
|
||||||
|
sources: [Tests]
|
||||||
|
dependencies:
|
||||||
|
- target: Yuvomi
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
|
YuvomiUITests:
|
||||||
|
type: bundle.ui-testing
|
||||||
|
platform: iOS
|
||||||
|
sources: [UITests]
|
||||||
|
dependencies:
|
||||||
|
- target: Yuvomi
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
|
schemes:
|
||||||
|
Yuvomi:
|
||||||
|
build:
|
||||||
|
targets:
|
||||||
|
Yuvomi: all
|
||||||
|
test:
|
||||||
|
targets: [YuvomiTests, YuvomiUITests]
|
||||||
|
gatherCoverageData: true
|
||||||
|
archive:
|
||||||
|
config: Release
|
||||||
Reference in new issue
Block a user