| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109 |
- 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<Result, Error>?
- 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()
- }
- }
- }
|