From 5ece3a3d962693c5f4c488e7b4604a0e70c2edbf Mon Sep 17 00:00:00 2001 From: Tobias Hagemann Date: Sat, 30 May 2026 15:17:54 +0200 Subject: [PATCH] Split WebDAVSession into foreground data and background transfer sessions --- .../WebDAV/WebDAVClient.swift | 4 +- .../WebDAV/WebDAVSession.swift | 60 +++++++++++++------ .../WebDAV/URLProtocolMock.swift | 39 ++++++++++++ .../WebDAV/WebDAVClientTests.swift | 47 +++++++++++++++ 4 files changed, 131 insertions(+), 19 deletions(-) diff --git a/Sources/CryptomatorCloudAccess/WebDAV/WebDAVClient.swift b/Sources/CryptomatorCloudAccess/WebDAV/WebDAVClient.swift index 068448f..49609f4 100644 --- a/Sources/CryptomatorCloudAccess/WebDAV/WebDAVClient.swift +++ b/Sources/CryptomatorCloudAccess/WebDAV/WebDAVClient.swift @@ -34,7 +34,7 @@ public class WebDAVClient { */ public static func withBackgroundSession(credential: WebDAVCredential, sessionIdentifier: String, sharedContainerIdentifier: String? = nil) -> WebDAVClient { let urlSessionDelegate = WebDAVClientURLSessionDelegate(credential: credential) - let session = WebDAVSession.createBackgroundSession(with: urlSessionDelegate, sessionIdentifier: sessionIdentifier, sharedContainerIdentifier: sharedContainerIdentifier) + let session = WebDAVSession.withBackgroundSession(with: urlSessionDelegate, sessionIdentifier: sessionIdentifier, sharedContainerIdentifier: sharedContainerIdentifier) return WebDAVClient(credential: credential, session: session) } @@ -69,7 +69,7 @@ public class WebDAVClient { request.httpBody = Data(""" \(propfindPropElementsAsXML(with: propertyNames)) """.utf8) - return webDAVSession.performDownloadTask(with: request, to: localURL, onTaskCreation: nil) + return webDAVSession.performDataDownloadTask(with: request, to: localURL) } public func PROPFIND(url: URL, depth: PropfindDepth, propertyNames: [String]? = nil) -> Promise<(HTTPURLResponse, Data?)> { diff --git a/Sources/CryptomatorCloudAccess/WebDAV/WebDAVSession.swift b/Sources/CryptomatorCloudAccess/WebDAV/WebDAVSession.swift index 74d3afa..c71bce6 100644 --- a/Sources/CryptomatorCloudAccess/WebDAV/WebDAVSession.swift +++ b/Sources/CryptomatorCloudAccess/WebDAV/WebDAVSession.swift @@ -170,29 +170,44 @@ class WebDAVClientURLSessionDelegate: NSObject, URLSessionDataDelegate, URLSessi } class WebDAVSession { - private let urlSession: URLSession + /// Session for metadata/control operations (OPTIONS, HEAD, PROPFIND, MKCOL, DELETE, MOVE). Foreground (`.default`) even in the background-session setup, because Apple's background `URLSession`s support only upload/download tasks. + private let dataSession: URLSession + /// Session for file transfers (GET downloads, PUT uploads). + private let transferSession: URLSession private weak var delegate: WebDAVClientURLSessionDelegate? - init(urlSession: URLSession, delegate: WebDAVClientURLSessionDelegate) { - precondition(urlSession.delegate as? WebDAVClientURLSessionDelegate == delegate) - self.urlSession = urlSession + init(dataSession: URLSession, transferSession: URLSession, delegate: WebDAVClientURLSessionDelegate) { + precondition(dataSession.delegate as? WebDAVClientURLSessionDelegate == delegate) + precondition(transferSession.delegate as? WebDAVClientURLSessionDelegate == delegate) + self.dataSession = dataSession + self.transferSession = transferSession self.delegate = delegate } + convenience init(urlSession: URLSession, delegate: WebDAVClientURLSessionDelegate) { + self.init(dataSession: urlSession, transferSession: urlSession, delegate: delegate) + } + /** - Use this method to conveniently create a WebDAV session with a background URL session. + Use this method to conveniently create a WebDAV session with a background URL session for file transfers. If the `WebDAVSession` is used in an app extension, set the `sharedContainerIdentifier` to a valid identifier for a container that will be shared between the app and the extension. - To avoid collisions in the `URLSession` Identifier between multiple targets (e.g. main app and app extension), the `BundleID` is used in addition to the Credential UID. + The caller is responsible for making `sessionIdentifier` unique across targets (e.g. main app and app extension). + + Only file transfers ride the background session; metadata/control operations use a separate foreground session, because Apple's background `URLSession`s support only upload/download tasks. */ - static func createBackgroundSession(with delegate: WebDAVClientURLSessionDelegate, sessionIdentifier: String, sharedContainerIdentifier: String? = nil) -> WebDAVSession { - let configuration = URLSessionConfiguration.background(withIdentifier: sessionIdentifier) - configuration.sharedContainerIdentifier = sharedContainerIdentifier - configuration.httpCookieStorage = HTTPCookieStorage() - configuration.urlCredentialStorage = nil - let session = URLSession(configuration: configuration, delegate: delegate, delegateQueue: nil) - return WebDAVSession(urlSession: session, delegate: delegate) + static func withBackgroundSession(with delegate: WebDAVClientURLSessionDelegate, sessionIdentifier: String, sharedContainerIdentifier: String? = nil) -> WebDAVSession { + let transferConfiguration = URLSessionConfiguration.background(withIdentifier: sessionIdentifier) + transferConfiguration.sharedContainerIdentifier = sharedContainerIdentifier + transferConfiguration.httpCookieStorage = HTTPCookieStorage() + transferConfiguration.urlCredentialStorage = nil + let transferSession = URLSession(configuration: transferConfiguration, delegate: delegate, delegateQueue: nil) + let dataConfiguration = URLSessionConfiguration.default + dataConfiguration.httpCookieStorage = HTTPCookieStorage() + dataConfiguration.urlCredentialStorage = nil + let dataSession = URLSession(configuration: dataConfiguration, delegate: delegate, delegateQueue: nil) + return WebDAVSession(dataSession: dataSession, transferSession: transferSession, delegate: delegate) } convenience init(delegate: WebDAVClientURLSessionDelegate) { @@ -204,12 +219,15 @@ class WebDAVSession { } deinit { - urlSession.invalidateAndCancel() + dataSession.invalidateAndCancel() + if transferSession !== dataSession { + transferSession.invalidateAndCancel() + } } func performDataTask(with request: URLRequest) -> Promise<(HTTPURLResponse, Data?)> { HTTPDebugLogger.logRequest(request) - let task = urlSession.dataTask(with: request) + let task = dataSession.dataTask(with: request) let pendingPromise = Promise<(HTTPURLResponse, Data?)>.pending() let webDAVDataTask = WebDAVDataTask(promise: pendingPromise) delegate?.addRunningDataTask(key: task, value: webDAVDataTask) @@ -218,9 +236,17 @@ class WebDAVSession { } func performDownloadTask(with request: URLRequest, to localURL: URL, onTaskCreation: ((URLSessionDownloadTask?) -> Void)?) -> Promise { + return performDownloadTask(with: request, to: localURL, onTaskCreation: onTaskCreation, on: transferSession) + } + + func performDataDownloadTask(with request: URLRequest, to localURL: URL) -> Promise { + return performDownloadTask(with: request, to: localURL, onTaskCreation: nil, on: dataSession) + } + + private func performDownloadTask(with request: URLRequest, to localURL: URL, onTaskCreation: ((URLSessionDownloadTask?) -> Void)?, on session: URLSession) -> Promise { HTTPDebugLogger.logRequest(request) let progress = Progress(totalUnitCount: 1) - let task = urlSession.downloadTask(with: request) + let task = session.downloadTask(with: request) progress.addChild(task.progress, withPendingUnitCount: 1) let pendingPromise = Promise.pending() let webDAVDownloadTask = WebDAVDownloadTask(promise: pendingPromise, localURL: localURL) @@ -237,7 +263,7 @@ class WebDAVSession { func performUploadTask(with request: URLRequest, fromFile fileURL: URL, onTaskCreation: ((URLSessionUploadTask?) -> Void)?) -> Promise<(HTTPURLResponse, Data?)> { HTTPDebugLogger.logRequest(request) let progress = Progress(totalUnitCount: 1) - let task = urlSession.uploadTask(with: request, fromFile: fileURL) + let task = transferSession.uploadTask(with: request, fromFile: fileURL) progress.addChild(task.progress, withPendingUnitCount: 1) let pendingPromise = Promise<(HTTPURLResponse, Data?)>.pending() let webDAVDataTask = WebDAVDataTask(promise: pendingPromise) diff --git a/Tests/CryptomatorCloudAccessTests/WebDAV/URLProtocolMock.swift b/Tests/CryptomatorCloudAccessTests/WebDAV/URLProtocolMock.swift index 9801bc0..cb033d3 100644 --- a/Tests/CryptomatorCloudAccessTests/WebDAV/URLProtocolMock.swift +++ b/Tests/CryptomatorCloudAccessTests/WebDAV/URLProtocolMock.swift @@ -41,6 +41,45 @@ class URLProtocolMock: URLProtocol { override func stopLoading() {} } +/// Test-only `URLProtocol` that records the HTTP method of every request it handles and answers with an +/// empty `200`. Register a distinct subclass with each session's `protocolClasses` so a test can prove +/// which `URLSession` (data vs. transfer) a WebDAV operation was routed to; the per-subclass storage +/// keeps the two sessions' requests apart. +class RecordingURLProtocolMock: URLProtocol { + private static var recordedMethodsByClass = [ObjectIdentifier: [String]]() + + static func recordedMethods(for protocolClass: URLProtocol.Type) -> [String] { + return recordedMethodsByClass[ObjectIdentifier(protocolClass)] ?? [] + } + + static func reset() { + recordedMethodsByClass.removeAll() + } + + override func startLoading() { + let key = ObjectIdentifier(type(of: self)) + RecordingURLProtocolMock.recordedMethodsByClass[key, default: []].append(request.httpMethod ?? "") + let response = HTTPURLResponse(url: request.url ?? URL(fileURLWithPath: "/"), statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data()) + client?.urlProtocolDidFinishLoading(self) + } + + override class func canInit(with request: URLRequest) -> Bool { + return true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + return request + } + + override func stopLoading() {} +} + +final class DataSessionURLProtocolMock: RecordingURLProtocolMock {} + +final class TransferSessionURLProtocolMock: RecordingURLProtocolMock {} + struct URLAuthenticationChallengeMock { let previousFailureCount: Int let failureResponse: URLResponse diff --git a/Tests/CryptomatorCloudAccessTests/WebDAV/WebDAVClientTests.swift b/Tests/CryptomatorCloudAccessTests/WebDAV/WebDAVClientTests.swift index b895b84..4890ede 100644 --- a/Tests/CryptomatorCloudAccessTests/WebDAV/WebDAVClientTests.swift +++ b/Tests/CryptomatorCloudAccessTests/WebDAV/WebDAVClientTests.swift @@ -32,4 +32,51 @@ class WebDAVClientTests: XCTestCase { let client = WebDAVClient(credential: credential, session: WebDAVSession(urlSession: urlSession, delegate: delegate)) XCTAssertEqual(URL(string: "/cloud/remote.php/webdav/"), client.baseURL) } + + /// Metadata/control operations must ride the foreground data session and file transfers the background + /// transfer session, because Apple's background `URLSession`s support only upload/download tasks. The two + /// sessions are stood up with distinct recording `URLProtocol`s so the routing can be asserted directly. + func testRoutesControlOperationsToDataSessionAndTransfersToTransferSession() async throws { + RecordingURLProtocolMock.reset() + defer { RecordingURLProtocolMock.reset() } + + let tmpDirURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tmpDirURL, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tmpDirURL) } + + let baseURL = try XCTUnwrap(URL(string: "/cloud/remote.php/webdav/")) + let credential = WebDAVCredential(baseURL: baseURL, username: "", password: "", allowedCertificate: nil) + let delegate = WebDAVClientURLSessionDelegate(credential: credential) + + let dataConfiguration = URLSessionConfiguration.default + dataConfiguration.protocolClasses = [DataSessionURLProtocolMock.self] + let dataSession = URLSession(configuration: dataConfiguration, delegate: delegate, delegateQueue: nil) + + let transferConfiguration = URLSessionConfiguration.default + transferConfiguration.protocolClasses = [TransferSessionURLProtocolMock.self] + let transferSession = URLSession(configuration: transferConfiguration, delegate: delegate, delegateQueue: nil) + + let session = WebDAVSession(dataSession: dataSession, transferSession: transferSession, delegate: delegate) + let client = WebDAVClient(credential: credential, session: session) + + let remoteURL = try XCTUnwrap(URL(string: "Documents/About.txt", relativeTo: baseURL)) + let moveDestinationURL = try XCTUnwrap(URL(string: "Documents/About-moved.txt", relativeTo: baseURL)) + let propfindDestinationURL = tmpDirURL.appendingPathComponent(UUID().uuidString, isDirectory: false) + let getDestinationURL = tmpDirURL.appendingPathComponent(UUID().uuidString, isDirectory: false) + let uploadSourceURL = tmpDirURL.appendingPathComponent(UUID().uuidString, isDirectory: false) + try Data().write(to: uploadSourceURL) + + _ = try await client.OPTIONS(url: remoteURL).async() + _ = try await client.HEAD(url: remoteURL).async() + _ = try await client.PROPFIND(url: remoteURL, depth: .one).async() + _ = try await client.PROPFIND(url: remoteURL, depth: .one, to: propfindDestinationURL).async() + _ = try await client.MKCOL(url: remoteURL).async() + _ = try await client.DELETE(url: remoteURL).async() + _ = try await client.MOVE(sourceURL: remoteURL, destinationURL: moveDestinationURL).async() + _ = try await client.GET(from: remoteURL, to: getDestinationURL, onTaskCreation: nil).async() + _ = try await client.PUT(url: remoteURL, fileURL: uploadSourceURL, onTaskCreation: nil).async() + + XCTAssertEqual(["OPTIONS", "HEAD", "PROPFIND", "PROPFIND", "MKCOL", "DELETE", "MOVE"], RecordingURLProtocolMock.recordedMethods(for: DataSessionURLProtocolMock.self)) + XCTAssertEqual(["GET", "PUT"], RecordingURLProtocolMock.recordedMethods(for: TransferSessionURLProtocolMock.self)) + } }