diff --git a/.gitignore b/.gitignore index 977ce02..a73d3a2 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ DerivedData/ *.xcuserstate xcuserdata/ ios/Yuvomi.xcodeproj/ +ios/Info.plist .swiftpm/ .build/ artifacts/ diff --git a/.swift-format b/.swift-format new file mode 100644 index 0000000..33e89f5 --- /dev/null +++ b/.swift-format @@ -0,0 +1,6 @@ +{ + "version": 1, + "indentation": { "spaces": 4 }, + "lineLength": 120, + "respectsExistingLineBreaks": true +} diff --git a/ios/Package.swift b/ios/Package.swift index ad4986c..84d3ad3 100644 --- a/ios/Package.swift +++ b/ios/Package.swift @@ -6,10 +6,12 @@ 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") + .target( + name: "Yuvomi", path: "Sources", + exclude: [ + "AuthenticationView.swift", "BrowserModel.swift", "ContentView.swift", + "ServerProbe.swift", "YuvomiApp.swift", "PrivacyInfo.xcprivacy", "Assets.xcassets", + ], sources: ["ServerAddress.swift", "ServerMetadata.swift"]), + .testTarget(name: "YuvomiPolicyTests", dependencies: ["Yuvomi"], path: "Tests"), ] ) diff --git a/ios/Sources/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/ios/Sources/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 0000000..8ce356c Binary files /dev/null and b/ios/Sources/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/ios/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..6196323 --- /dev/null +++ b/ios/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images": [ + { + "filename": "AppIcon.png", + "idiom": "universal", + "platform": "ios", + "size": "1024x1024" + } + ], + "info": { + "author": "Yuvomi Mobile contributors", + "version": 1 + } +} diff --git a/ios/Sources/AuthenticationView.swift b/ios/Sources/AuthenticationView.swift index 18cc620..ff35752 100644 --- a/ios/Sources/AuthenticationView.swift +++ b/ios/Sources/AuthenticationView.swift @@ -7,7 +7,7 @@ struct AuthenticationView: View { var body: some View { NavigationStack { IsolatedAuthentication(url: destination, model: model) - .navigationTitle("Isolated web sign-in") + .navigationTitle(model.authenticationHost) .navigationBarTitleDisplayMode(.inline) .toolbar { Button("Close") { model.authentication = nil } } } @@ -33,15 +33,26 @@ struct IsolatedAuthentication: UIViewRepresentable { } func updateUIView(_ uiView: WKWebView, context: Context) {} static func dismantleUIView(_ uiView: WKWebView, coordinator: Coordinator) { - uiView.stopLoading(); uiView.navigationDelegate = nil; uiView.uiDelegate = nil + 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) { + func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + model.authenticationHost = webView.url?.host ?? "Sign in" + } + 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 } + url.user == nil, url.password == nil + else { + decisionHandler(.cancel) + return + } decisionHandler(.allow) } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { @@ -50,8 +61,17 @@ struct IsolatedAuthentication: UIViewRepresentable { model.web.load(URLRequest(url: url)) } } - func webView(_ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin, - initiatedByFrame frame: WKFrameInfo, type: WKMediaCaptureType, - decisionHandler: @escaping (WKPermissionDecision) -> Void) { decisionHandler(.deny) } + func webView( + _ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin, + initiatedByFrame frame: WKFrameInfo, type: WKMediaCaptureType, + decisionHandler: @escaping (WKPermissionDecision) -> Void + ) { decisionHandler(.deny) } + func webView( + _ webView: WKWebView, runOpenPanelWith parameters: WKOpenPanelParameters, + initiatedByFrame frame: WKFrameInfo, + completionHandler: @escaping @MainActor @Sendable ([URL]?) -> Void + ) { + completionHandler(nil) + } } } diff --git a/ios/Sources/BrowserModel.swift b/ios/Sources/BrowserModel.swift index 24caa8c..4afb665 100644 --- a/ios/Sources/BrowserModel.swift +++ b/ios/Sources/BrowserModel.swift @@ -13,6 +13,7 @@ final class BrowserModel: NSObject, ObservableObject, WKNavigationDelegate, WKUI @Published var error: String? @Published var externalURL: URL? @Published var authentication: WebDestination? + @Published var authenticationHost = "Sign in" @Published var download: WebDestination? private(set) var server: ServerAddress? private(set) var web: WKWebView @@ -45,9 +46,10 @@ final class BrowserModel: NSObject, ObservableObject, WKNavigationDelegate, WKUI 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 - }) + observations.append( + web.observe(\.canGoBack, options: [.new]) { [weak self] view, _ in + self?.canGoBack = view.canGoBack + }) } static var exportDirectory: URL { @@ -65,7 +67,8 @@ final class BrowserModel: NSObject, ObservableObject, WKNavigationDelegate, WKUI 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 } + guard !["auth", "reset", "invite", "login"].contains(where: { path.localizedCaseInsensitiveContains($0) }) + else { return } UserDefaults.standard.set(path, forKey: "path") } @@ -80,58 +83,79 @@ final class BrowserModel: NSObject, ObservableObject, WKNavigationDelegate, WKUI web.navigationDelegate = nil web.uiDelegate = nil observations.removeAll() - WKWebsiteDataStore.default().removeData(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(), - modifiedSince: .distantPast) { [weak self] in + 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 + 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 } + authenticationHost = url.host ?? "Sign in" authentication = WebDestination(url: url) } func removeDownload() { download = nil - if let location = downloadLocation { try? FileManager.default.removeItem(at: location.deletingLastPathComponent()) } + 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 { + 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 || (navigationAction.shouldPerformDownload && server?.ownsDownload(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." } + } else { + error = "This link type is blocked for your security." + } } } } - func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, - decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) { + func webView( + _ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, + decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void + ) { if let response = navigationResponse.response as? HTTPURLResponse, - navigationResponse.isForMainFrame, response.statusCode >= 400 { + 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 + guard let url = navigationResponse.response.url, server?.ownsDownload(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(.cancel) + return } decisionHandler(.download) - } else { decisionHandler(.allow) } + } else { + decisionHandler(.allow) + } } func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { @@ -146,30 +170,43 @@ final class BrowserModel: NSObject, ObservableObject, WKNavigationDelegate, WKUI } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - loading = false; deadline?.invalidate(); rememberPath() + 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) } + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + failed(error) + } private func failed(_ failure: Error) { - loading = false; deadline?.invalidate() + 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." + 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? { + 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 } + 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) { + 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) } @@ -181,28 +218,46 @@ final class BrowserModel: NSObject, ObservableObject, WKNavigationDelegate, WKUI } private func attach(_ download: WKDownload) { guard activeDownload == nil, self.download == nil else { - download.cancel { _ in }; error = "Finish the current download before starting another."; return + download.cancel { _ in } + error = "Finish the current download before starting another." + return } - activeDownload = download; download.delegate = self + 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 } + func download( + _ download: WKDownload, decideDestinationUsing response: URLResponse, + suggestedFilename: String, completionHandler: @escaping (URL?) -> Void + ) { + guard let url = response.url, server?.ownsDownload(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, + 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)) + 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." } + } 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) { + 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) } @@ -210,15 +265,19 @@ final class BrowserModel: NSObject, ObservableObject, WKNavigationDelegate, WKUI 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 + 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() + activeDownload = nil + removeDownload() self.error = "Download failed. Check your connection and sign-in, then retry." } } @@ -243,6 +302,8 @@ struct ExportView: UIViewControllerRepresentable { let completion: () -> Void init(_ completion: @escaping () -> Void) { self.completion = completion } func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { completion() } - func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { completion() } + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + completion() + } } } diff --git a/ios/Sources/ContentView.swift b/ios/Sources/ContentView.swift index 2f21982..f6768b0 100644 --- a/ios/Sources/ContentView.swift +++ b/ios/Sources/ContentView.swift @@ -22,7 +22,10 @@ struct ContentView: View { Image(systemName: "wifi.exclamationmark").font(.largeTitle) Text("Connection interrupted").font(.title2).bold() Text(error) - Button("Retry") { browser.error = nil; browser.web.reload() } + Button("Retry") { + browser.error = nil + browser.web.reload() + } Button("Keep viewing") { browser.error = nil } Button("Server settings") { settings = true } } @@ -30,12 +33,18 @@ struct ContentView: View { .background(Color(uiColor: .systemGroupedBackground)) .accessibilityElement(children: .contain) } - if browser.loading { ProgressView("Loading Yuvomi").padding().background(.regularMaterial).cornerRadius(12) } + 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") + Button { + browser.web.goBack() + } label: { + Image(systemName: "chevron.backward") + } + .disabled(!browser.canGoBack).accessibilityLabel("Back") } ToolbarItem(placement: .navigationBarTrailing) { Button("Server settings") { settings = true } @@ -70,10 +79,11 @@ struct ContentView: View { } .task { if server == nil, let existing = try? ServerAddress(input) { - server = existing; browser.open(existing) + server = existing + browser.open(existing) } } - .onChange(of: scenePhase) { phase in + .onChange(of: scenePhase) { _, phase in if phase == .background { browser.rememberPath() } } .overlay { @@ -93,31 +103,49 @@ struct ContentView: View { } } Section { - Text("Changing servers erases cookies, storage and cached household data. Exported files remain. Sign out in Yuvomi first to revoke the server session.") + 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 + settings = false + erasing = true browser.erase { UserDefaults.standard.removeObject(forKey: "origin") UserDefaults.standard.removeObject(forKey: "path") - server = nil; input = ""; erasing = false + 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.") + 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 } + .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.") + 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) @@ -130,13 +158,15 @@ struct ContentView: View { private func connect() { do { let candidate = try ServerAddress(input) - connecting = true; message = nil + 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) + server = candidate + browser.open(candidate) } catch { message = ConnectionError.unavailable.localizedDescription } } } catch { message = ConnectionError.invalidAddress.localizedDescription } diff --git a/ios/Sources/ServerAddress.swift b/ios/Sources/ServerAddress.swift index c9d2bc9..6996698 100644 --- a/ios/Sources/ServerAddress.swift +++ b/ios/Sources/ServerAddress.swift @@ -6,13 +6,14 @@ struct ServerAddress: Equatable { 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 { + 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" @@ -25,11 +26,20 @@ struct ServerAddress: Equatable { func owns(_ candidate: URL) -> Bool { guard let parts = URLComponents(url: candidate, resolvingAgainstBaseURL: false), - let own = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return 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) } + + func ownsDownload(_ candidate: URL) -> Bool { + if owns(candidate) { return true } + guard candidate.scheme == "blob", + let inner = URL(string: String(candidate.absoluteString.dropFirst(5))) + else { return false } + return owns(inner) + } } enum ConnectionError: LocalizedError { diff --git a/ios/Sources/ServerMetadata.swift b/ios/Sources/ServerMetadata.swift new file mode 100644 index 0000000..558de60 --- /dev/null +++ b/ios/Sources/ServerMetadata.swift @@ -0,0 +1,19 @@ +import Foundation + +enum ServerMetadata { + private struct Bootstrap: Decodable { + let appName: String + let setupRequired: Bool + let passwordResetEnabled: Bool + } + private struct Authentication: Decodable { + let enabled: Bool + let passwordLoginEnabled: Bool + } + static func validate(bootstrap: Data, authentication: Data) throws { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + _ = try decoder.decode(Bootstrap.self, from: bootstrap) + _ = try decoder.decode(Authentication.self, from: authentication) + } +} diff --git a/ios/Sources/ServerProbe.swift b/ios/Sources/ServerProbe.swift index 85e9be8..ee37ee9 100644 --- a/ios/Sources/ServerProbe.swift +++ b/ios/Sources/ServerProbe.swift @@ -1,10 +1,12 @@ import Foundation final class NoRedirect: NSObject, URLSessionTaskDelegate { - func urlSession(_ session: URLSession, task: URLSessionTask, - willPerformHTTPRedirection response: HTTPURLResponse, - newRequest request: URLRequest, - completionHandler: @escaping (URLRequest?) -> Void) { + func urlSession( + _ session: URLSession, task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void + ) { completionHandler(nil) } } @@ -19,16 +21,11 @@ enum ServerProbe { 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 - } + try ServerMetadata.validate(bootstrap: version, authentication: auth) } - private static func json(_ session: URLSession, _ url: URL) async throws -> [String: Any] { + private static func json(_ session: URLSession, _ url: URL) async throws -> Data { var request = URLRequest(url: url) request.setValue("application/json", forHTTPHeaderField: "Accept") let (stream, response) = try await session.bytes(for: request) @@ -38,9 +35,6 @@ enum ServerProbe { 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 + return data } } diff --git a/ios/Tests/ServerAddressTests.swift b/ios/Tests/ServerAddressTests.swift index 5fedc78..e8f611b 100644 --- a/ios/Tests/ServerAddressTests.swift +++ b/ios/Tests/ServerAddressTests.swift @@ -1,4 +1,5 @@ import XCTest + @testable import Yuvomi final class ServerAddressTests: XCTestCase { @@ -8,21 +9,32 @@ final class ServerAddressTests: XCTestCase { 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:///", ""] { + 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"] { + 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) } } + func testBlobExportsAreOriginBound() throws { + let server = try ServerAddress("https://family.example") + XCTAssertTrue(server.ownsDownload(URL(string: "blob:https://family.example/unique-id")!)) + XCTAssertFalse(server.ownsDownload(URL(string: "blob:https://evil.example/unique-id")!)) + XCTAssertFalse(server.ownsDownload(URL(string: "blob:http://family.example/unique-id")!)) + XCTAssertFalse(server.ownsDownload(URL(string: "file:///tmp/export")!)) + } } diff --git a/ios/Tests/ServerMetadataTests.swift b/ios/Tests/ServerMetadataTests.swift new file mode 100644 index 0000000..6b7dd91 --- /dev/null +++ b/ios/Tests/ServerMetadataTests.swift @@ -0,0 +1,21 @@ +import XCTest + +@testable import Yuvomi + +final class ServerMetadataTests: XCTestCase { + private let bootstrap = Data(#"{"app_name":"Yuvomi","setup_required":false,"password_reset_enabled":true}"#.utf8) + private let authentication = Data(#"{"enabled":false,"password_login_enabled":true}"#.utf8) + func testAcceptsActualAnonymousMetadataWithoutVersion() throws { + try ServerMetadata.validate(bootstrap: bootstrap, authentication: authentication) + } + func testRejectsNumericBooleanAndMissingAuthenticationMetadata() { + let numeric = Data(#"{"enabled":1,"password_login_enabled":true}"#.utf8) + XCTAssertThrowsError(try ServerMetadata.validate(bootstrap: bootstrap, authentication: numeric)) + XCTAssertThrowsError(try ServerMetadata.validate(bootstrap: bootstrap, authentication: Data("{}".utf8))) + } + func testRejectsProxyHtmlAndInvalidJson() { + XCTAssertThrowsError( + try ServerMetadata.validate(bootstrap: Data("SSO proxy".utf8), authentication: authentication)) + XCTAssertThrowsError(try ServerMetadata.validate(bootstrap: Data("null".utf8), authentication: authentication)) + } +} diff --git a/ios/project.yml b/ios/project.yml index 9d1c4ad..7fe4dc1 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -2,7 +2,7 @@ name: Yuvomi options: bundleIdPrefix: community.yuvomi deploymentTarget: - iOS: "16.0" + iOS: "18.4" settings: base: SWIFT_VERSION: "5.0" @@ -40,6 +40,7 @@ targets: base: PRODUCT_BUNDLE_IDENTIFIER: community.yuvomi.mobile TARGETED_DEVICE_FAMILY: "1,2" + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon dependencies: - sdk: WebKit.framework YuvomiTests: diff --git a/scripts/generate-icon.py b/scripts/generate-icon.py new file mode 100644 index 0000000..09bd096 --- /dev/null +++ b/scripts/generate-icon.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +"""Generate the original community Y mark (not the upstream logo), without dependencies.""" +import json +from pathlib import Path +import struct +import zlib + +ROOT = Path(__file__).resolve().parents[1] + + +def png(size): + raw = bytearray() + segments = [(32, 29, 54, 54), (54, 54, 76, 29), (54, 54, 54, 80)] + for y in range(size): + raw.append(0) + for x in range(size): + coverage = 0 + for dx, dy in [(0.25, 0.25), (0.75, 0.25), (0.25, 0.75), (0.75, 0.75)]: + px, py = (x + dx) * 108 / size, (y + dy) * 108 / size + for ax, ay, bx, by in segments: + projection = max(0, min(1, ((px-ax)*(bx-ax)+(py-ay)*(by-ay))/((bx-ax)**2+(by-ay)**2))) + if (px-ax-projection*(bx-ax))**2 + (py-ay-projection*(by-ay))**2 <= 4.5**2: + coverage += 1 + break + raw.extend(round(background + (255-background)*coverage/4) for background in (108, 58, 237)) + + def chunk(name, data): + return struct.pack(">I", len(data)) + name + data + struct.pack(">I", zlib.crc32(name+data)) + return b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", size, size, 8, 2, 0, 0, 0)) + chunk(b"IDAT", zlib.compress(raw, 9)) + chunk(b"IEND", b"") + + +if __name__ == "__main__": + folder = ROOT / "ios/Sources/Assets.xcassets/AppIcon.appiconset" + folder.mkdir(parents=True, exist_ok=True) + (folder / "AppIcon.png").write_bytes(png(1024)) + (folder / "Contents.json").write_text(json.dumps({"images": [ + {"filename": "AppIcon.png", "idiom": "universal", "platform": "ios", "size": "1024x1024"} + ], "info": {"author": "Yuvomi Mobile contributors", "version": 1}}, indent=2) + "\n")