diff --git a/ios/Package.swift b/ios/Package.swift new file mode 100644 index 0000000..ad4986c --- /dev/null +++ b/ios/Package.swift @@ -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") + ] +) diff --git a/ios/Sources/AuthenticationView.swift b/ios/Sources/AuthenticationView.swift new file mode 100644 index 0000000..18cc620 --- /dev/null +++ b/ios/Sources/AuthenticationView.swift @@ -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) } + } +} diff --git a/ios/Sources/BrowserModel.swift b/ios/Sources/BrowserModel.swift new file mode 100644 index 0000000..24caa8c --- /dev/null +++ b/ios/Sources/BrowserModel.swift @@ -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() } + } +} diff --git a/ios/Sources/ContentView.swift b/ios/Sources/ContentView.swift new file mode 100644 index 0000000..2f21982 --- /dev/null +++ b/ios/Sources/ContentView.swift @@ -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 } + } +} diff --git a/ios/Sources/PrivacyInfo.xcprivacy b/ios/Sources/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..dbe53b6 --- /dev/null +++ b/ios/Sources/PrivacyInfo.xcprivacy @@ -0,0 +1,11 @@ + + + + NSPrivacyTracking + NSPrivacyTrackingDomains + NSPrivacyCollectedDataTypes + NSPrivacyAccessedAPITypes + NSPrivacyAccessedAPITypeNSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasonsCA92.1 + + diff --git a/ios/Sources/ServerAddress.swift b/ios/Sources/ServerAddress.swift new file mode 100644 index 0000000..c9d2bc9 --- /dev/null +++ b/ios/Sources/ServerAddress.swift @@ -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." + } + } +} diff --git a/ios/Sources/ServerProbe.swift b/ios/Sources/ServerProbe.swift new file mode 100644 index 0000000..85e9be8 --- /dev/null +++ b/ios/Sources/ServerProbe.swift @@ -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 + } +} diff --git a/ios/Sources/YuvomiApp.swift b/ios/Sources/YuvomiApp.swift new file mode 100644 index 0000000..38e646c --- /dev/null +++ b/ios/Sources/YuvomiApp.swift @@ -0,0 +1,8 @@ +import SwiftUI + +@main +struct YuvomiApp: App { + var body: some Scene { + WindowGroup { ContentView() } + } +} diff --git a/ios/Tests/ServerAddressTests.swift b/ios/Tests/ServerAddressTests.swift new file mode 100644 index 0000000..5fedc78 --- /dev/null +++ b/ios/Tests/ServerAddressTests.swift @@ -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) + } + } +} diff --git a/ios/UITests/OnboardingTests.swift b/ios/UITests/OnboardingTests.swift new file mode 100644 index 0000000..60601da --- /dev/null +++ b/ios/UITests/OnboardingTests.swift @@ -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) + } +} diff --git a/ios/project.yml b/ios/project.yml new file mode 100644 index 0000000..9d1c4ad --- /dev/null +++ b/ios/project.yml @@ -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