import Foundation /// Bridges URLSessionDownloadDelegate progress callbacks to Swift concurrency. /// Each invocation of `download()` manages a single download with real-time progress. final class ProgressDownloader: NSObject, URLSessionDownloadDelegate, @unchecked Sendable { /// Result of a completed download. struct Result: Sendable { let fileURL: URL let response: HTTPURLResponse } // MARK: - Private State private let onProgress: @Sendable (Double) -> Void private var continuation: CheckedContinuation? private var session: URLSession? private init(onProgress: @escaping @Sendable (Double) -> Void) { self.onProgress = onProgress super.init() } // MARK: - Public API /// Download a file with progress reporting. /// /// - Parameters: /// - request: The URLRequest to download. /// - sessionConfiguration: URLSession configuration (injectable for testing). /// - onProgress: Called on each progress update (0.0–1.0). Called on arbitrary thread. /// - Returns: The temporary file URL and HTTP response. static func download( request: URLRequest, sessionConfiguration: URLSessionConfiguration = .default, onProgress: @escaping @Sendable (Double) -> Void ) async throws -> Result { let downloader = ProgressDownloader(onProgress: onProgress) return try await withCheckedThrowingContinuation { continuation in downloader.continuation = continuation let session = URLSession( configuration: sessionConfiguration, delegate: downloader, delegateQueue: nil // URLSession creates its own serial queue ) downloader.session = session session.downloadTask(with: request).resume() } } // MARK: - URLSessionDownloadDelegate func urlSession( _ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64 ) { guard totalBytesExpectedToWrite > 0 else { return } let fraction = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite) onProgress(fraction) } func urlSession( _ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL ) { // Move file to a safe location — URLSession deletes the original after this method returns let safeCopy = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString) do { try FileManager.default.moveItem(at: location, to: safeCopy) } catch { continuation?.resume(throwing: error) continuation = nil self.session?.invalidateAndCancel() return } guard let response = downloadTask.response as? HTTPURLResponse else { continuation?.resume(throwing: DownloadService.DownloadError.invalidResponse) continuation = nil self.session?.invalidateAndCancel() return } onProgress(1.0) continuation?.resume(returning: Result(fileURL: safeCopy, response: response)) continuation = nil self.session?.finishTasksAndInvalidate() } func urlSession( _ session: URLSession, task: URLSessionTask, didCompleteWithError error: (any Error)? ) { if let error { continuation?.resume(throwing: error) continuation = nil self.session?.invalidateAndCancel() } } }