From b66c97b785169b3d40fb8b4d8d8027a72a2a5f4f Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Fri, 13 Mar 2026 22:23:07 -0500 Subject: [PATCH] fix(mobile): use shared auth for background_downloader (#26911) shared client for background_downloader on ios --- .../alextran/immich/core/HttpClientManager.kt | 23 ++++++++ mobile/ios/Runner/AppDelegate.swift | 1 + .../ios/Runner/Core/URLSessionManager.swift | 55 +++++++++++++++++-- 3 files changed, 73 insertions(+), 6 deletions(-) diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt index 5b53b2a49a..e7268396e8 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt @@ -27,7 +27,11 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import java.io.ByteArrayInputStream import java.io.File +import java.net.Authenticator +import java.net.CookieHandler +import java.net.PasswordAuthentication import java.net.Socket +import java.net.URI import java.security.KeyStore import java.security.Principal import java.security.PrivateKey @@ -104,6 +108,25 @@ object HttpClientManager { keyChainAlias = prefs.getString(PREFS_CERT_ALIAS, null) cookieJar.init(prefs) + System.setProperty("http.agent", USER_AGENT) + Authenticator.setDefault(object : Authenticator() { + override fun getPasswordAuthentication(): PasswordAuthentication? { + val url = requestingURL ?: return null + if (url.userInfo.isNullOrEmpty()) return null + val parts = url.userInfo.split(":", limit = 2) + return PasswordAuthentication(parts[0], parts.getOrElse(1) { "" }.toCharArray()) + } + }) + CookieHandler.setDefault(object : CookieHandler() { + override fun get(uri: URI, requestHeaders: Map>): Map> { + val httpUrl = uri.toString().toHttpUrlOrNull() ?: return emptyMap() + val cookies = cookieJar.loadForRequest(httpUrl) + if (cookies.isEmpty()) return emptyMap() + return mapOf("Cookie" to listOf(cookies.joinToString("; ") { "${it.name}=${it.value}" })) + } + + override fun put(uri: URI, responseHeaders: Map>) {} + }) val savedHeaders = prefs.getString(PREFS_HEADERS, null) if (savedHeaders != null) { diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 8487db7b48..81af41ab08 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -20,6 +20,7 @@ import UIKit } SwiftNativeVideoPlayerPlugin.cookieStorage = URLSessionManager.cookieStorage + URLSessionManager.patchBackgroundDownloader() GeneratedPluginRegistrant.register(with: self) let controller: FlutterViewController = window?.rootViewController as! FlutterViewController AppDelegate.registerPlugins(with: controller.engine, controller: controller) diff --git a/mobile/ios/Runner/Core/URLSessionManager.swift b/mobile/ios/Runner/Core/URLSessionManager.swift index 9868d4eb59..0b73ed71a6 100644 --- a/mobile/ios/Runner/Core/URLSessionManager.swift +++ b/mobile/ios/Runner/Core/URLSessionManager.swift @@ -51,7 +51,7 @@ class URLSessionManager: NSObject { diskCapacity: 1024 * 1024 * 1024, directory: cacheDir ) - private static let userAgent: String = { + static let userAgent: String = { let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown" return "Immich_iOS_\(version)" }() @@ -158,6 +158,49 @@ class URLSessionManager: NSObject { return URLSession(configuration: config, delegate: delegate, delegateQueue: nil) } + + /// Patches background_downloader's URLSession to use shared auth configuration. + /// Must be called before background_downloader creates its session (i.e. early in app startup). + static func patchBackgroundDownloader() { + // Swizzle URLSessionConfiguration.background(withIdentifier:) to inject shared config + let originalSel = NSSelectorFromString("backgroundSessionConfigurationWithIdentifier:") + let swizzledSel = #selector(URLSessionConfiguration.immich_background(withIdentifier:)) + if let original = class_getClassMethod(URLSessionConfiguration.self, originalSel), + let swizzled = class_getClassMethod(URLSessionConfiguration.self, swizzledSel) { + method_exchangeImplementations(original, swizzled) + } + + // Add auth challenge handling to background_downloader's UrlSessionDelegate + guard let targetClass = NSClassFromString("background_downloader.UrlSessionDelegate") else { return } + + let sessionBlock: @convention(block) (AnyObject, URLSession, URLAuthenticationChallenge, + @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) -> Void + = { _, session, challenge, completion in + URLSessionManager.shared.delegate.handleChallenge(session, challenge, completion) + } + class_replaceMethod(targetClass, + NSSelectorFromString("URLSession:didReceiveChallenge:completionHandler:"), + imp_implementationWithBlock(sessionBlock), "v@:@@@?") + + let taskBlock: @convention(block) (AnyObject, URLSession, URLSessionTask, URLAuthenticationChallenge, + @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) -> Void + = { _, session, task, challenge, completion in + URLSessionManager.shared.delegate.handleChallenge(session, challenge, completion, task: task) + } + class_replaceMethod(targetClass, + NSSelectorFromString("URLSession:task:didReceiveChallenge:completionHandler:"), + imp_implementationWithBlock(taskBlock), "v@:@@@@?") + } +} + +private extension URLSessionConfiguration { + @objc dynamic class func immich_background(withIdentifier id: String) -> URLSessionConfiguration { + // After swizzle, this calls the original implementation + let config = immich_background(withIdentifier: id) + config.httpCookieStorage = URLSessionManager.cookieStorage + config.httpAdditionalHeaders = ["User-Agent": URLSessionManager.userAgent] + return config + } } class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWebSocketDelegate { @@ -168,7 +211,7 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb ) { handleChallenge(session, challenge, completionHandler) } - + func urlSession( _ session: URLSession, task: URLSessionTask, @@ -177,7 +220,7 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb ) { handleChallenge(session, challenge, completionHandler, task: task) } - + func handleChallenge( _ session: URLSession, _ challenge: URLAuthenticationChallenge, @@ -190,7 +233,7 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb default: completionHandler(.performDefaultHandling, nil) } } - + private func handleClientCertificate( _ session: URLSession, completion: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void @@ -200,7 +243,7 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb 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 { @@ -214,7 +257,7 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb } completion(.performDefaultHandling, nil) } - + private func handleBasicAuth( _ session: URLSession, task: URLSessionTask?,