From 22b89d241fcdf92d9677375a52f751364ad2bdd7 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Sun, 1 Feb 2026 23:57:38 -0500 Subject: [PATCH] handle mtls on ios --- mobile/ios/Runner/AppDelegate.swift | 2 +- mobile/ios/Runner/Core/Network.g.swift | 240 ++++++++++++++++++ mobile/ios/Runner/Core/NetworkApiImpl.swift | 184 ++++++++++++++ .../ios/Runner/Core/URLSessionManager.swift | 94 +++++++ .../ios/Runner/Images/RemoteImagesImpl.swift | 181 +++++-------- .../services/background_worker.service.dart | 2 +- mobile/lib/main.dart | 2 +- mobile/lib/platform/network_api.g.dart | 181 +++++++++++++ .../infrastructure/platform.provider.dart | 3 + mobile/lib/services/background.service.dart | 2 +- mobile/lib/utils/http_ssl_options.dart | 13 +- mobile/lib/utils/isolate.dart | 2 +- .../settings/ssl_client_cert_settings.dart | 87 ++----- mobile/makefile | 2 + mobile/pigeon/network_api.dart | 32 +++ 15 files changed, 828 insertions(+), 199 deletions(-) create mode 100644 mobile/ios/Runner/Core/Network.g.swift create mode 100644 mobile/ios/Runner/Core/NetworkApiImpl.swift create mode 100644 mobile/ios/Runner/Core/URLSessionManager.swift create mode 100644 mobile/lib/platform/network_api.g.dart create mode 100644 mobile/pigeon/network_api.dart diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 60f97b6645..449e4a63c2 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -15,7 +15,7 @@ import UIKit ) -> Bool { // Required for flutter_local_notification if #available(iOS 10.0, *) { - UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate + UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate } GeneratedPluginRegistrant.register(with: self) diff --git a/mobile/ios/Runner/Core/Network.g.swift b/mobile/ios/Runner/Core/Network.g.swift new file mode 100644 index 0000000000..7e47ece5cb --- /dev/null +++ b/mobile/ios/Runner/Core/Network.g.swift @@ -0,0 +1,240 @@ +// Autogenerated from Pigeon (v26.0.2), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +import Foundation + +#if os(iOS) + import Flutter +#elseif os(macOS) + import FlutterMacOS +#else + #error("Unsupported platform.") +#endif + +private func wrapResult(_ result: Any?) -> [Any?] { + return [result] +} + +private func wrapError(_ error: Any) -> [Any?] { + if let pigeonError = error as? PigeonError { + return [ + pigeonError.code, + pigeonError.message, + pigeonError.details, + ] + } + if let flutterError = error as? FlutterError { + return [ + flutterError.code, + flutterError.message, + flutterError.details, + ] + } + return [ + "\(error)", + "\(type(of: error))", + "Stacktrace: \(Thread.callStackSymbols)", + ] +} + +private func isNullish(_ value: Any?) -> Bool { + return value is NSNull || value == nil +} + +private func nilOrValue(_ value: Any?) -> T? { + if value is NSNull { return nil } + return value as! T? +} + +func deepEqualsNetwork(_ lhs: Any?, _ rhs: Any?) -> Bool { + let cleanLhs = nilOrValue(lhs) as Any? + let cleanRhs = nilOrValue(rhs) as Any? + switch (cleanLhs, cleanRhs) { + case (nil, nil): + return true + + case (nil, _), (_, nil): + return false + + case is (Void, Void): + return true + + case let (cleanLhsHashable, cleanRhsHashable) as (AnyHashable, AnyHashable): + return cleanLhsHashable == cleanRhsHashable + + case let (cleanLhsArray, cleanRhsArray) as ([Any?], [Any?]): + guard cleanLhsArray.count == cleanRhsArray.count else { return false } + for (index, element) in cleanLhsArray.enumerated() { + if !deepEqualsNetwork(element, cleanRhsArray[index]) { + return false + } + } + return true + + case let (cleanLhsDictionary, cleanRhsDictionary) as ([AnyHashable: Any?], [AnyHashable: Any?]): + guard cleanLhsDictionary.count == cleanRhsDictionary.count else { return false } + for (key, cleanLhsValue) in cleanLhsDictionary { + guard cleanRhsDictionary.index(forKey: key) != nil else { return false } + if !deepEqualsNetwork(cleanLhsValue, cleanRhsDictionary[key]!) { + return false + } + } + return true + + default: + // Any other type shouldn't be able to be used with pigeon. File an issue if you find this to be untrue. + return false + } +} + +func deepHashNetwork(value: Any?, hasher: inout Hasher) { + if let valueList = value as? [AnyHashable] { + for item in valueList { deepHashNetwork(value: item, hasher: &hasher) } + return + } + + if let valueDict = value as? [AnyHashable: AnyHashable] { + for key in valueDict.keys { + hasher.combine(key) + deepHashNetwork(value: valueDict[key]!, hasher: &hasher) + } + return + } + + if let hashableValue = value as? AnyHashable { + hasher.combine(hashableValue.hashValue) + } + + return hasher.combine(String(describing: value)) +} + + + +/// Generated class from Pigeon that represents data sent in messages. +struct ClientCertData: Hashable { + var data: FlutterStandardTypedData + var password: String + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> ClientCertData? { + let data = pigeonVar_list[0] as! FlutterStandardTypedData + let password = pigeonVar_list[1] as! String + + return ClientCertData( + data: data, + password: password + ) + } + func toList() -> [Any?] { + return [ + data, + password, + ] + } + static func == (lhs: ClientCertData, rhs: ClientCertData) -> Bool { + return deepEqualsNetwork(lhs.toList(), rhs.toList()) } + func hash(into hasher: inout Hasher) { + deepHashNetwork(value: toList(), hasher: &hasher) + } +} + +private class NetworkPigeonCodecReader: FlutterStandardReader { + override func readValue(ofType type: UInt8) -> Any? { + switch type { + case 129: + return ClientCertData.fromList(self.readValue() as! [Any?]) + default: + return super.readValue(ofType: type) + } + } +} + +private class NetworkPigeonCodecWriter: FlutterStandardWriter { + override func writeValue(_ value: Any) { + if let value = value as? ClientCertData { + super.writeByte(129) + super.writeValue(value.toList()) + } else { + super.writeValue(value) + } + } +} + +private class NetworkPigeonCodecReaderWriter: FlutterStandardReaderWriter { + override func reader(with data: Data) -> FlutterStandardReader { + return NetworkPigeonCodecReader(data: data) + } + + override func writer(with data: NSMutableData) -> FlutterStandardWriter { + return NetworkPigeonCodecWriter(data: data) + } +} + +class NetworkPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { + static let shared = NetworkPigeonCodec(readerWriter: NetworkPigeonCodecReaderWriter()) +} + + +/// Generated protocol from Pigeon that represents a handler of messages from Flutter. +protocol NetworkApi { + func addCertificate(clientData: ClientCertData, completion: @escaping (Result) -> Void) + func selectCertificate(completion: @escaping (Result) -> Void) + func removeCertificate(completion: @escaping (Result) -> Void) +} + +/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. +class NetworkApiSetup { + static var codec: FlutterStandardMessageCodec { NetworkPigeonCodec.shared } + /// Sets up an instance of `NetworkApi` to handle messages through the `binaryMessenger`. + static func setUp(binaryMessenger: FlutterBinaryMessenger, api: NetworkApi?, messageChannelSuffix: String = "") { + let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" + let addCertificateChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NetworkApi.addCertificate\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + addCertificateChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let clientDataArg = args[0] as! ClientCertData + api.addCertificate(clientData: clientDataArg) { result in + switch result { + case .success: + reply(wrapResult(nil)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + addCertificateChannel.setMessageHandler(nil) + } + let selectCertificateChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NetworkApi.selectCertificate\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + selectCertificateChannel.setMessageHandler { _, reply in + api.selectCertificate { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + selectCertificateChannel.setMessageHandler(nil) + } + let removeCertificateChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NetworkApi.removeCertificate\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + removeCertificateChannel.setMessageHandler { _, reply in + api.removeCertificate { result in + switch result { + case .success: + reply(wrapResult(nil)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + removeCertificateChannel.setMessageHandler(nil) + } + } +} diff --git a/mobile/ios/Runner/Core/NetworkApiImpl.swift b/mobile/ios/Runner/Core/NetworkApiImpl.swift new file mode 100644 index 0000000000..f81c77d97a --- /dev/null +++ b/mobile/ios/Runner/Core/NetworkApiImpl.swift @@ -0,0 +1,184 @@ +import Foundation +import UniformTypeIdentifiers + +enum ImportError: Error { + case noFile + case noViewController + case keychainError(OSStatus) + case cancelled +} + +class NetworkApiImpl: NetworkApi { + weak var viewController: UIViewController? + private var activeImporter: CertImporter? + + init(viewController: UIViewController?) { + self.viewController = viewController + } + + func selectCertificate(completion: @escaping (Result) -> Void) { + let importer = CertImporter(completion: { [weak self] result in + self?.activeImporter = nil + completion(result.map { ClientCertData(data: FlutterStandardTypedData(bytes: $0.0), password: $0.1) }) + }, viewController: viewController) + activeImporter = importer + importer.load() + } + + func removeCertificate(completion: @escaping (Result) -> Void) { + let status = clearCerts() + if status == errSecSuccess || status == errSecItemNotFound { + return completion(.success(())) + } + completion(.failure(ImportError.keychainError(status))) + } + + // TODO: remove this method once the app is fully transitioned to native clients + func addCertificate(clientData: ClientCertData, completion: @escaping (Result) -> Void) { + let status = importCert(clientData: clientData.data.data, password: clientData.password) + if status == errSecSuccess { + return completion(.success(())) + } + completion(.failure(ImportError.keychainError(status))) + } +} + +private class CertImporter: NSObject, UIDocumentPickerDelegate { + private var completion: ((Result<(Data, String), Error>) -> Void) + private weak var viewController: UIViewController? + + init(completion: (@escaping (Result<(Data, String), Error>) -> Void), viewController: UIViewController?) { + self.completion = completion + self.viewController = viewController + } + + func load() { + guard let vc = viewController else { return completion(.failure(ImportError.noViewController)) } + let picker = UIDocumentPickerViewController(forOpeningContentTypes: [ + UTType(filenameExtension: "p12")!, + UTType(filenameExtension: "pfx")!, + ]) + picker.delegate = self + picker.allowsMultipleSelection = false + vc.present(picker, animated: true) + } + + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + guard let url = urls.first else { + return completion(.failure(ImportError.noFile)) + } + + Task { @MainActor in + do { + let data = try readSecurityScoped(url: url) + guard let password = await promptForPassword() else { + return completion(.failure(ImportError.cancelled)) + } + let status = importCert(clientData: data, password: password) + if status != errSecSuccess { + return completion(.failure(ImportError.keychainError(status))) + } + + await URLSessionManager.shared.session.flush() + self.completion(.success((data, password))) + } catch { + completion(.failure(error)) + } + } + } + + func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { + completion(.failure(ImportError.cancelled)) + } + + private func promptForPassword() async -> String? { + guard let vc = viewController else { return nil } + + return await withCheckedContinuation { continuation in + let alert = UIAlertController( + title: "Certificate Password", + message: "Enter the password for this certificate", + preferredStyle: .alert + ) + + alert.addTextField { $0.isSecureTextEntry = true } + + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in + continuation.resume(returning: nil) + }) + + alert.addAction(UIAlertAction(title: "Import", style: .default) { _ in + continuation.resume(returning: alert.textFields?.first?.text ?? "") + }) + + vc.present(alert, animated: true) + } + } + + private func readSecurityScoped(url: URL) throws -> Data { + guard url.startAccessingSecurityScopedResource() else { + throw ImportError.noFile + } + defer { url.stopAccessingSecurityScopedResource() } + return try Data(contentsOf: url) + } +} + +private func importCert(clientData: Data, password: String) -> OSStatus { + let options = [kSecImportExportPassphrase: password] as CFDictionary + var items: CFArray? + var status = SecPKCS12Import(clientData as CFData, options, &items) + + guard status == errSecSuccess, + let array = items as? [[String: Any]], + let first = array.first, + let identity = first[kSecImportItemIdentity as String] else { + return status + } + + clearCerts() + + var addQuery = [ + kSecClass as String: kSecClassIdentity, + kSecValueRef as String: identity, + kSecAttrLabel as String: CLIENT_CERT_LABEL, + kSecAttrService as String: CLIENT_CERT_SERVICE, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ] + status = SecItemAdd(addQuery as CFDictionary, nil) + guard status == errSecSuccess else { return status } + + // TODO: remove this section below once the app is fully transitioned to native clients + addQuery = [ + kSecClass as String: kSecClassGenericPassword, + kSecValueData as String: clientData, + kSecAttrAccount as String: CLIENT_CERT_DATA_LABEL, + kSecAttrService as String: CLIENT_CERT_SERVICE, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ] + status = SecItemAdd(addQuery as CFDictionary, nil) + guard status == errSecSuccess else { return status } + + addQuery = [ + kSecClass as String: kSecClassGenericPassword, + kSecValueData as String: password.data(using: .utf8)!, + kSecAttrAccount as String: CLIENT_CERT_PASSWORD_LABEL, + kSecAttrService as String: CLIENT_CERT_SERVICE, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ] + status = SecItemAdd(addQuery as CFDictionary, nil) + return status +} + +@discardableResult private func clearCerts() -> OSStatus { + var status = errSecSuccess + for secClass in [kSecClassIdentity, kSecClassGenericPassword] { + let deleteQuery: [String: Any] = [ + kSecClass as String: secClass, + kSecAttrService as String: CLIENT_CERT_SERVICE, + ] + status = SecItemDelete(deleteQuery as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { return status } + } + return status +} diff --git a/mobile/ios/Runner/Core/URLSessionManager.swift b/mobile/ios/Runner/Core/URLSessionManager.swift new file mode 100644 index 0000000000..7e5392b96c --- /dev/null +++ b/mobile/ios/Runner/Core/URLSessionManager.swift @@ -0,0 +1,94 @@ +import Foundation + +let CLIENT_CERT_SERVICE = "app.alextran.immich.mtls" +let CLIENT_CERT_DATA_LABEL = "client_identity_data" +let CLIENT_CERT_PASSWORD_LABEL = "client_identity_password" + +let CLIENT_CERT_LABEL = "client_identity" + +/// Manages a shared URLSession with SSL configuration support. +class URLSessionManager: NSObject { + static let shared = URLSessionManager() + + let session: URLSession + private var lock = os_unfair_lock() + private let configuration = { + let config = URLSessionConfiguration.default + + let cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask) + .first! + .appendingPathComponent("api", isDirectory: true) + try! FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true) + + config.urlCache = URLCache( + memoryCapacity: 0, + diskCapacity: 1024 * 1024 * 1024, + directory: cacheDir + ) + + config.httpMaximumConnectionsPerHost = 64 + config.timeoutIntervalForRequest = 60 + config.timeoutIntervalForResource = 300 + + let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown" + config.httpAdditionalHeaders = ["User-Agent": "Immich_iOS_\(version)"] + + return config + }() + private var initialized = false + private var sessionChangedListeners: [() -> Void] = [] + + private override init() { + session = URLSession(configuration: configuration, delegate: URLSessionManagerDelegate(), delegateQueue: nil) + super.init() + } +} + +class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate { + func urlSession( + _ session: URLSession, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + handleChallenge(challenge, completionHandler: completionHandler) + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + handleChallenge(challenge, completionHandler: completionHandler) + } + + func handleChallenge( + _ challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + switch challenge.protectionSpace.authenticationMethod { + case NSURLAuthenticationMethodClientCertificate: handleClientCertificate(completion: completionHandler) + default: completionHandler(.performDefaultHandling, nil) + } + } + + private func handleClientCertificate( + completion: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + let query: [String: Any] = [ + kSecClass as String: kSecClassIdentity, + kSecAttrLabel as String: CLIENT_CERT_LABEL, + kSecReturnRef as String: true, + ] + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + if status == errSecSuccess, let identity = item { + let credential = URLCredential(identity: identity as! SecIdentity, + certificates: nil, + persistence: .forSession) + return completion(.useCredential, credential) + } + completion(.performDefaultHandling, nil) + } +} diff --git a/mobile/ios/Runner/Images/RemoteImagesImpl.swift b/mobile/ios/Runner/Images/RemoteImagesImpl.swift index d59204b96e..f1bd90585a 100644 --- a/mobile/ios/Runner/Images/RemoteImagesImpl.swift +++ b/mobile/ios/Runner/Images/RemoteImagesImpl.swift @@ -7,64 +7,19 @@ class RemoteImageRequest { weak var task: URLSessionDataTask? let id: Int64 var isCancelled = false - var data: CFMutableData? let completion: (Result<[String: Int64]?, any Error>) -> Void - + init(id: Int64, task: URLSessionDataTask, completion: @escaping (Result<[String: Int64]?, any Error>) -> Void) { self.id = id self.task = task - self.data = nil self.completion = completion } } class RemoteImageApiImpl: NSObject, RemoteImageApi { - private static let delegate = RemoteImageApiDelegate() - static let session = { - let cacheDir = FileManager.default.temporaryDirectory.appendingPathComponent("thumbnails", isDirectory: true) - let config = URLSessionConfiguration.default - config.requestCachePolicy = .returnCacheDataElseLoad - let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown" - config.httpAdditionalHeaders = ["User-Agent": "Immich_iOS_\(version)"] - try! FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true) - config.urlCache = URLCache( - memoryCapacity: 0, - diskCapacity: 1 << 30, - directory: cacheDir - ) - config.httpMaximumConnectionsPerHost = 64 - return URLSession(configuration: config, delegate: delegate, delegateQueue: nil) - }() - - func requestImage(url: String, headers: [String : String], requestId: Int64, completion: @escaping (Result<[String : Int64]?, any Error>) -> Void) { - var urlRequest = URLRequest(url: URL(string: url)!) - for (key, value) in headers { - urlRequest.setValue(value, forHTTPHeaderField: key) - } - let task = Self.session.dataTask(with: urlRequest) - - let imageRequest = RemoteImageRequest(id: requestId, task: task, completion: completion) - Self.delegate.add(taskId: task.taskIdentifier, request: imageRequest) - - task.resume() - } - - func cancelRequest(requestId: Int64) { - Self.delegate.cancel(requestId: requestId) - } - - func clearCache(completion: @escaping (Result) -> Void) { - Task { - let cache = Self.session.configuration.urlCache! - let cacheSize = Int64(cache.currentDiskUsage) - cache.removeAllCachedResponses() - completion(.success(cacheSize)) - } - } -} - -class RemoteImageApiDelegate: NSObject, URLSessionDataDelegate { - private static let requestQueue = DispatchQueue(label: "thumbnail.requests", qos: .userInitiated, attributes: .concurrent) + private static var lock = os_unfair_lock() + private static var requests = [Int64: RemoteImageRequest]() + private static let cancelledResult = Result<[String: Int64]?, any Error>.success(nil) private static var rgbaFormat = vImage_CGImageFormat( bitsPerComponent: 8, bitsPerPixel: 32, @@ -72,79 +27,73 @@ class RemoteImageApiDelegate: NSObject, URLSessionDataDelegate { bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue), renderingIntent: .perceptual )! - private static var requestByTaskId = [Int: RemoteImageRequest]() - private static var taskIdByRequestId = [Int64: Int]() - private static let cancelledResult = Result<[String: Int64]?, any Error>.success(nil) private static let decodeOptions = [ kCGImageSourceShouldCache: false, kCGImageSourceShouldCacheImmediately: true, kCGImageSourceCreateThumbnailWithTransform: true, kCGImageSourceCreateThumbnailFromImageAlways: true ] as CFDictionary - - func urlSession( - _ session: URLSession, dataTask: URLSessionDataTask, - didReceive response: URLResponse, - completionHandler: @escaping (URLSession.ResponseDisposition) -> Void - ) { - guard let request = get(taskId: dataTask.taskIdentifier) - else { - return completionHandler(.cancel) + + func requestImage(url: String, headers: [String : String], requestId: Int64, completion: @escaping (Result<[String : Int64]?, any Error>) -> Void) { + var urlRequest = URLRequest(url: URL(string: url)!) + urlRequest.cachePolicy = .returnCacheDataElseLoad + for (key, value) in headers { + urlRequest.setValue(value, forHTTPHeaderField: key) } - - let capacity = max(Int(response.expectedContentLength), 0) - request.data = CFDataCreateMutable(nil, capacity) - - completionHandler(.allow) - } - - func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, - didReceive data: Data) { - guard let request = get(taskId: dataTask.taskIdentifier) else { return } - - data.withUnsafeBytes { bytes in - CFDataAppendBytes(request.data, bytes.bindMemory(to: UInt8.self).baseAddress, data.count) + + let task = URLSessionManager.shared.session.dataTask(with: urlRequest) { data, response, error in + Self.handleCompletion(requestId: requestId, data: data, response: response, error: error) } + + let request = RemoteImageRequest(id: requestId, task: task, completion: completion) + + os_unfair_lock_lock(&Self.lock) + Self.requests[requestId] = request + os_unfair_lock_unlock(&Self.lock) + + task.resume() } - - func urlSession(_ session: URLSession, task: URLSessionTask, - didCompleteWithError error: Error?) { - guard let request = get(taskId: task.taskIdentifier) else { return } - - defer { remove(taskId: task.taskIdentifier, requestId: request.id) } - + + private static func handleCompletion(requestId: Int64, data: Data?, response: URLResponse?, error: Error?) { + os_unfair_lock_lock(&Self.lock) + guard let request = requests[requestId] else { + return os_unfair_lock_unlock(&Self.lock) + } + requests[requestId] = nil + os_unfair_lock_unlock(&Self.lock) + if let error = error { if request.isCancelled || (error as NSError).code == NSURLErrorCancelled { - return request.completion(Self.cancelledResult) + return request.completion(cancelledResult) } return request.completion(.failure(error)) } - + if request.isCancelled { - return request.completion(Self.cancelledResult) + return request.completion(cancelledResult) } - - guard let data = request.data else { + + guard let data = data else { return request.completion(.failure(PigeonError(code: "", message: "No data received", details: nil))) } - - guard let imageSource = CGImageSourceCreateWithData(data, nil), - let cgImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, Self.decodeOptions) else { + + guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil), + let cgImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, decodeOptions) else { return request.completion(.failure(PigeonError(code: "", message: "Failed to decode image for request", details: nil))) } - + if request.isCancelled { - return request.completion(Self.cancelledResult) + return request.completion(cancelledResult) } - + do { - let buffer = try vImage_Buffer(cgImage: cgImage, format: Self.rgbaFormat) - + let buffer = try vImage_Buffer(cgImage: cgImage, format: rgbaFormat) + if request.isCancelled { buffer.free() - return request.completion(Self.cancelledResult) + return request.completion(cancelledResult) } - + request.completion( .success([ "pointer": Int64(Int(bitPattern: buffer.data)), @@ -156,31 +105,23 @@ class RemoteImageApiDelegate: NSObject, URLSessionDataDelegate { return request.completion(.failure(PigeonError(code: "", message: "Failed to convert image for request: \(error)", details: nil))) } } - - @inline(__always) func get(taskId: Int) -> RemoteImageRequest? { - Self.requestQueue.sync { Self.requestByTaskId[taskId] } - } - - @inline(__always) func add(taskId: Int, request: RemoteImageRequest) -> Void { - Self.requestQueue.async(flags: .barrier) { - Self.requestByTaskId[taskId] = request - Self.taskIdByRequestId[request.id] = taskId - } - } - - @inline(__always) func remove(taskId: Int, requestId: Int64) -> Void { - Self.requestQueue.async(flags: .barrier) { - Self.taskIdByRequestId[requestId] = nil - Self.requestByTaskId[taskId] = nil - } - } - - @inline(__always) func cancel(requestId: Int64) -> Void { - guard let request: RemoteImageRequest = (Self.requestQueue.sync { - guard let taskId = Self.taskIdByRequestId[requestId] else { return nil } - return Self.requestByTaskId[taskId] - }) else { return } + + func cancelRequest(requestId: Int64) { + os_unfair_lock_lock(&Self.lock) + let request = Self.requests[requestId] + os_unfair_lock_unlock(&Self.lock) + + guard let request = request else { return } request.isCancelled = true request.task?.cancel() } + + func clearCache(completion: @escaping (Result) -> Void) { + Task { + let cache = URLSessionManager.shared.session.configuration.urlCache! + let cacheSize = Int64(cache.currentDiskUsage) + cache.removeAllCachedResponses() + completion(.success(cacheSize)) + } + } } diff --git a/mobile/lib/domain/services/background_worker.service.dart b/mobile/lib/domain/services/background_worker.service.dart index 9019db664d..aa6fe9a729 100644 --- a/mobile/lib/domain/services/background_worker.service.dart +++ b/mobile/lib/domain/services/background_worker.service.dart @@ -88,7 +88,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { Future init() async { try { - HttpSSLOptions.apply(applyNative: false); + await HttpSSLOptions.apply(applyNative: false); await Future.wait( [ diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 60bb1cb9c3..a7bb1fa164 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -57,7 +57,7 @@ void main() async { // Warm-up isolate pool for worker manager await workerManagerPatch.init(dynamicSpawning: true, isolatesCount: max(Platform.numberOfProcessors - 1, 5)); await migrateDatabaseIfNeeded(isar, drift); - HttpSSLOptions.apply(); + await HttpSSLOptions.apply(); runApp( ProviderScope( diff --git a/mobile/lib/platform/network_api.g.dart b/mobile/lib/platform/network_api.g.dart new file mode 100644 index 0000000000..8af9e02086 --- /dev/null +++ b/mobile/lib/platform/network_api.g.dart @@ -0,0 +1,181 @@ +// Autogenerated from Pigeon (v26.0.2), do not edit directly. +// See also: https://pub.dev/packages/pigeon +// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers + +import 'dart:async'; +import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List; + +import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer; +import 'package:flutter/services.dart'; + +PlatformException _createConnectionError(String channelName) { + return PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel: "$channelName".', + ); +} + +bool _deepEquals(Object? a, Object? b) { + if (a is List && b is List) { + return a.length == b.length && a.indexed.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1])); + } + if (a is Map && b is Map) { + return a.length == b.length && + a.entries.every( + (MapEntry entry) => + (b as Map).containsKey(entry.key) && _deepEquals(entry.value, b[entry.key]), + ); + } + return a == b; +} + +class ClientCertData { + ClientCertData({required this.data, required this.password}); + + Uint8List data; + + String password; + + List _toList() { + return [data, password]; + } + + Object encode() { + return _toList(); + } + + static ClientCertData decode(Object result) { + result as List; + return ClientCertData(data: result[0]! as Uint8List, password: result[1]! as String); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! ClientCertData || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()); +} + +class _PigeonCodec extends StandardMessageCodec { + const _PigeonCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is int) { + buffer.putUint8(4); + buffer.putInt64(value); + } else if (value is ClientCertData) { + buffer.putUint8(129); + writeValue(buffer, value.encode()); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + case 129: + return ClientCertData.decode(readValue(buffer)!); + default: + return super.readValueOfType(type, buffer); + } + } +} + +class NetworkApi { + /// Constructor for [NetworkApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + NetworkApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + final BinaryMessenger? pigeonVar_binaryMessenger; + + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + final String pigeonVar_messageChannelSuffix; + + Future addCertificate(ClientCertData clientData) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.NetworkApi.addCertificate$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([clientData]); + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + + Future selectCertificate() async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.NetworkApi.selectCertificate$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as ClientCertData?)!; + } + } + + Future removeCertificate() async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.NetworkApi.removeCertificate$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } +} diff --git a/mobile/lib/providers/infrastructure/platform.provider.dart b/mobile/lib/providers/infrastructure/platform.provider.dart index 60300e74df..01d0f61d1c 100644 --- a/mobile/lib/providers/infrastructure/platform.provider.dart +++ b/mobile/lib/providers/infrastructure/platform.provider.dart @@ -5,6 +5,7 @@ import 'package:immich_mobile/platform/background_worker_lock_api.g.dart'; import 'package:immich_mobile/platform/connectivity_api.g.dart'; import 'package:immich_mobile/platform/native_sync_api.g.dart'; import 'package:immich_mobile/platform/local_image_api.g.dart'; +import 'package:immich_mobile/platform/network_api.g.dart'; import 'package:immich_mobile/platform/remote_image_api.g.dart'; final backgroundWorkerFgServiceProvider = Provider((_) => BackgroundWorkerFgService(BackgroundWorkerFgHostApi())); @@ -20,3 +21,5 @@ final connectivityApiProvider = Provider((_) => ConnectivityApi final localImageApi = LocalImageApi(); final remoteImageApi = RemoteImageApi(); + +final networkApi = NetworkApi(); diff --git a/mobile/lib/services/background.service.dart b/mobile/lib/services/background.service.dart index b69aa53014..e0ed9e84ae 100644 --- a/mobile/lib/services/background.service.dart +++ b/mobile/lib/services/background.service.dart @@ -341,7 +341,7 @@ class BackgroundService { ], ); - HttpSSLOptions.apply(); + await HttpSSLOptions.apply(); await ref.read(apiServiceProvider).setAccessToken(Store.get(StoreKey.accessToken)); await ref.read(authServiceProvider).setOpenApiServiceEndpoint(); dPrint(() => "[BG UPLOAD] Using endpoint: ${ref.read(apiServiceProvider).apiClient.basePath}"); diff --git a/mobile/lib/utils/http_ssl_options.dart b/mobile/lib/utils/http_ssl_options.dart index c4e2ad69f7..901d37f614 100644 --- a/mobile/lib/utils/http_ssl_options.dart +++ b/mobile/lib/utils/http_ssl_options.dart @@ -10,17 +10,15 @@ import 'package:logging/logging.dart'; class HttpSSLOptions { static const MethodChannel _channel = MethodChannel('immich/httpSSLOptions'); - static void apply({bool applyNative = true}) { + static Future apply({bool applyNative = true}) { AppSettingsEnum setting = AppSettingsEnum.allowSelfSignedSSLCert; bool allowSelfSignedSSLCert = Store.get(setting.storeKey as StoreKey, setting.defaultValue); - _apply(allowSelfSignedSSLCert, applyNative: applyNative); + return _apply(allowSelfSignedSSLCert, applyNative: applyNative); } - static void applyFromSettings(bool newValue) { - _apply(newValue); - } + static Future applyFromSettings(bool newValue) => _apply(newValue); - static void _apply(bool allowSelfSignedSSLCert, {bool applyNative = true}) { + static Future _apply(bool allowSelfSignedSSLCert, {bool applyNative = true}) { String? serverHost; if (allowSelfSignedSSLCert && Store.tryGet(StoreKey.currentUser) != null) { serverHost = Uri.parse(Store.tryGet(StoreKey.serverEndpoint) ?? "").host; @@ -31,12 +29,13 @@ class HttpSSLOptions { HttpOverrides.global = HttpSSLCertOverride(allowSelfSignedSSLCert, serverHost, clientCert); if (applyNative && Platform.isAndroid) { - _channel + return _channel .invokeMethod("apply", [allowSelfSignedSSLCert, serverHost, clientCert?.data, clientCert?.password]) .onError((e, _) { final log = Logger("HttpSSLOptions"); log.severe('Failed to set SSL options', e.message); }); } + return Future.value(); } } diff --git a/mobile/lib/utils/isolate.dart b/mobile/lib/utils/isolate.dart index 491e1bf107..182e2e0de1 100644 --- a/mobile/lib/utils/isolate.dart +++ b/mobile/lib/utils/isolate.dart @@ -54,7 +54,7 @@ Cancelable runInIsolateGentle({ Logger log = Logger("IsolateLogger"); try { - HttpSSLOptions.apply(applyNative: false); + await HttpSSLOptions.apply(applyNative: false); result = await computation(ref); } on CanceledError { log.warning("Computation cancelled ${debugLabel == null ? '' : ' for $debugLabel'}"); diff --git a/mobile/lib/widgets/settings/ssl_client_cert_settings.dart b/mobile/lib/widgets/settings/ssl_client_cert_settings.dart index dc31acf0a4..4fa70f86c1 100644 --- a/mobile/lib/widgets/settings/ssl_client_cert_settings.dart +++ b/mobile/lib/widgets/settings/ssl_client_cert_settings.dart @@ -1,13 +1,9 @@ -import 'dart:io'; - import 'package:easy_localization/easy_localization.dart'; -import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; -import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; +import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/utils/http_ssl_options.dart'; class SslClientCertSettings extends StatefulWidget { @@ -41,18 +37,11 @@ class _SslClientCertSettingsState extends State { const SizedBox(height: 6), Row( mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, crossAxisAlignment: CrossAxisAlignment.center, children: [ - ElevatedButton( - onPressed: widget.isLoggedIn ? null : () => importCert(context), - child: Text("client_cert_import".tr()), - ), - const SizedBox(width: 15), - ElevatedButton( - onPressed: widget.isLoggedIn || !isCertExist ? null : () async => await removeCert(context), - child: Text("remove".tr()), - ), + ElevatedButton(onPressed: widget.isLoggedIn ? null : importCert, child: Text("client_cert_import".tr())), + ElevatedButton(onPressed: widget.isLoggedIn ? null : removeCert, child: Text("remove".tr())), ], ), ], @@ -70,61 +59,25 @@ class _SslClientCertSettingsState extends State { ); } - Future storeCert(BuildContext context, Uint8List data, String? password) async { - if (password != null && password.isEmpty) { - password = null; - } - final cert = SSLClientCertStoreVal(data, password); - // Test whether the certificate is valid - final isCertValid = HttpSSLCertOverride.setClientCert(SecurityContext(withTrustedRoots: true), cert); - if (!isCertValid) { + Future importCert() async { + try { + final cert = await networkApi.selectCertificate(); + await SSLClientCertStoreVal(cert.data, cert.password).save(); + await HttpSSLOptions.apply(); + showMessage(context, "client_cert_import_success_msg".tr()); + } catch (e) { showMessage(context, "client_cert_invalid_msg".tr()); - return; - } - await cert.save(); - HttpSSLOptions.apply(); - setState(() => isCertExist = true); - showMessage(context, "client_cert_import_success_msg".tr()); - } - - void setPassword(BuildContext context, Uint8List data) { - final password = TextEditingController(); - showDialog( - context: context, - barrierDismissible: false, - builder: (ctx) => AlertDialog( - content: TextField( - controller: password, - obscureText: true, - obscuringCharacter: "*", - decoration: InputDecoration(hintText: "client_cert_enter_password".tr()), - ), - actions: [ - TextButton( - onPressed: () async => {ctx.pop(), await storeCert(context, data, password.text)}, - child: Text("client_cert_dialog_msg_confirm".tr()), - ), - ], - ), - ); - } - - Future importCert(BuildContext ctx) async { - FilePickerResult? res = await FilePicker.platform.pickFiles( - type: FileType.custom, - allowedExtensions: ['p12', 'pfx'], - ); - if (res != null) { - File file = File(res.files.single.path!); - final bytes = await file.readAsBytes(); - setPassword(ctx, bytes); } } - Future removeCert(BuildContext context) async { - await SSLClientCertStoreVal.delete(); - HttpSSLOptions.apply(); - setState(() => isCertExist = false); - showMessage(context, "client_cert_remove_msg".tr()); + Future removeCert() async { + try { + await networkApi.removeCertificate(); + await SSLClientCertStoreVal.delete(); + await HttpSSLOptions.apply(); + showMessage(context, "client_cert_remove_msg".tr()); + } catch (e) { + showMessage(context, "client_cert_invalid_msg".tr()); + } } } diff --git a/mobile/makefile b/mobile/makefile index 79b263c079..50fa2490f1 100644 --- a/mobile/makefile +++ b/mobile/makefile @@ -12,12 +12,14 @@ pigeon: dart run pigeon --input pigeon/background_worker_api.dart dart run pigeon --input pigeon/background_worker_lock_api.dart dart run pigeon --input pigeon/connectivity_api.dart + dart run pigeon --input pigeon/network_api.dart dart format lib/platform/native_sync_api.g.dart dart format lib/platform/local_image_api.g.dart dart format lib/platform/remote_image_api.g.dart dart format lib/platform/background_worker_api.g.dart dart format lib/platform/background_worker_lock_api.g.dart dart format lib/platform/connectivity_api.g.dart + dart format lib/platform/network_api.g.dart watch: dart run build_runner watch --delete-conflicting-outputs diff --git a/mobile/pigeon/network_api.dart b/mobile/pigeon/network_api.dart new file mode 100644 index 0000000000..17b04a2369 --- /dev/null +++ b/mobile/pigeon/network_api.dart @@ -0,0 +1,32 @@ +import 'package:pigeon/pigeon.dart'; + +class ClientCertData { + Uint8List data; + String password; + + ClientCertData(this.data, this.password); +} + +@ConfigurePigeon( + PigeonOptions( + dartOut: 'lib/platform/network_api.g.dart', + swiftOut: 'ios/Runner/Core/Network.g.swift', + swiftOptions: SwiftOptions(includeErrorClass: false), + kotlinOut: + 'android/app/src/main/kotlin/app/alextran/immich/core/Network.g.kt', + kotlinOptions: KotlinOptions(package: 'app.alextran.immich.core', includeErrorClass: true), + dartOptions: DartOptions(), + dartPackageName: 'immich_mobile', + ), +) +@HostApi() +abstract class NetworkApi { + @async + void addCertificate(ClientCertData clientData); + + @async + ClientCertData selectCertificate(); + + @async + void removeCertificate(); +}