handle mtls on ios

This commit is contained in:
mertalev
2026-02-01 23:57:38 -05:00
parent 1436e2a75f
commit 22b89d241f
15 changed files with 828 additions and 199 deletions

View File

@@ -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)

View File

@@ -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<T>(_ 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, Error>) -> Void)
func selectCertificate(completion: @escaping (Result<ClientCertData, Error>) -> Void)
func removeCertificate(completion: @escaping (Result<Void, Error>) -> 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)
}
}
}

View File

@@ -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<ClientCertData, any Error>) -> 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, any Error>) -> 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, any Error>) -> 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
}

View File

@@ -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)
}
}

View File

@@ -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<Int64, any Error>) -> 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<Int64, any Error>) -> Void) {
Task {
let cache = URLSessionManager.shared.session.configuration.urlCache!
let cacheSize = Int64(cache.currentDiskUsage)
cache.removeAllCachedResponses()
completion(.success(cacheSize))
}
}
}

View File

@@ -88,7 +88,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
Future<void> init() async {
try {
HttpSSLOptions.apply(applyNative: false);
await HttpSSLOptions.apply(applyNative: false);
await Future.wait(
[

View File

@@ -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(

181
mobile/lib/platform/network_api.g.dart generated Normal file
View File

@@ -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<Object?, Object?> entry) =>
(b as Map<Object?, Object?>).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<Object?> _toList() {
return <Object?>[data, password];
}
Object encode() {
return _toList();
}
static ClientCertData decode(Object result) {
result as List<Object?>;
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<Object?> pigeonChannelCodec = _PigeonCodec();
final String pigeonVar_messageChannelSuffix;
Future<void> addCertificate(ClientCertData clientData) async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NetworkApi.addCertificate$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[clientData]);
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
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<ClientCertData> selectCertificate() async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NetworkApi.selectCertificate$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
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<void> removeCertificate() async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NetworkApi.removeCertificate$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
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;
}
}
}

View File

@@ -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>((_) => ConnectivityApi
final localImageApi = LocalImageApi();
final remoteImageApi = RemoteImageApi();
final networkApi = NetworkApi();

View File

@@ -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}");

View File

@@ -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<void> apply({bool applyNative = true}) {
AppSettingsEnum setting = AppSettingsEnum.allowSelfSignedSSLCert;
bool allowSelfSignedSSLCert = Store.get(setting.storeKey as StoreKey<bool>, setting.defaultValue);
_apply(allowSelfSignedSSLCert, applyNative: applyNative);
return _apply(allowSelfSignedSSLCert, applyNative: applyNative);
}
static void applyFromSettings(bool newValue) {
_apply(newValue);
}
static Future<void> applyFromSettings(bool newValue) => _apply(newValue);
static void _apply(bool allowSelfSignedSSLCert, {bool applyNative = true}) {
static Future<void> _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<PlatformException>((e, _) {
final log = Logger("HttpSSLOptions");
log.severe('Failed to set SSL options', e.message);
});
}
return Future.value();
}
}

View File

@@ -54,7 +54,7 @@ Cancelable<T?> runInIsolateGentle<T>({
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'}");

View File

@@ -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<SslClientCertSettings> {
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<SslClientCertSettings> {
);
}
Future<void> 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<void> 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<void> 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<void> removeCert(BuildContext context) async {
await SSLClientCertStoreVal.delete();
HttpSSLOptions.apply();
setState(() => isCertExist = false);
showMessage(context, "client_cert_remove_msg".tr());
Future<void> 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());
}
}
}

View File

@@ -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

View File

@@ -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();
}