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