ProgressDownloader.swift 3.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109
  1. import Foundation
  2. /// Bridges URLSessionDownloadDelegate progress callbacks to Swift concurrency.
  3. /// Each invocation of `download()` manages a single download with real-time progress.
  4. final class ProgressDownloader: NSObject, URLSessionDownloadDelegate, @unchecked Sendable {
  5. /// Result of a completed download.
  6. struct Result: Sendable {
  7. let fileURL: URL
  8. let response: HTTPURLResponse
  9. }
  10. // MARK: - Private State
  11. private let onProgress: @Sendable (Double) -> Void
  12. private var continuation: CheckedContinuation<Result, Error>?
  13. private var session: URLSession?
  14. private init(onProgress: @escaping @Sendable (Double) -> Void) {
  15. self.onProgress = onProgress
  16. super.init()
  17. }
  18. // MARK: - Public API
  19. /// Download a file with progress reporting.
  20. ///
  21. /// - Parameters:
  22. /// - request: The URLRequest to download.
  23. /// - sessionConfiguration: URLSession configuration (injectable for testing).
  24. /// - onProgress: Called on each progress update (0.0–1.0). Called on arbitrary thread.
  25. /// - Returns: The temporary file URL and HTTP response.
  26. static func download(
  27. request: URLRequest,
  28. sessionConfiguration: URLSessionConfiguration = .default,
  29. onProgress: @escaping @Sendable (Double) -> Void
  30. ) async throws -> Result {
  31. let downloader = ProgressDownloader(onProgress: onProgress)
  32. return try await withCheckedThrowingContinuation { continuation in
  33. downloader.continuation = continuation
  34. let session = URLSession(
  35. configuration: sessionConfiguration,
  36. delegate: downloader,
  37. delegateQueue: nil // URLSession creates its own serial queue
  38. )
  39. downloader.session = session
  40. session.downloadTask(with: request).resume()
  41. }
  42. }
  43. // MARK: - URLSessionDownloadDelegate
  44. func urlSession(
  45. _ session: URLSession,
  46. downloadTask: URLSessionDownloadTask,
  47. didWriteData bytesWritten: Int64,
  48. totalBytesWritten: Int64,
  49. totalBytesExpectedToWrite: Int64
  50. ) {
  51. guard totalBytesExpectedToWrite > 0 else { return }
  52. let fraction = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
  53. onProgress(fraction)
  54. }
  55. func urlSession(
  56. _ session: URLSession,
  57. downloadTask: URLSessionDownloadTask,
  58. didFinishDownloadingTo location: URL
  59. ) {
  60. // Move file to a safe location — URLSession deletes the original after this method returns
  61. let safeCopy = FileManager.default.temporaryDirectory
  62. .appendingPathComponent(UUID().uuidString)
  63. do {
  64. try FileManager.default.moveItem(at: location, to: safeCopy)
  65. } catch {
  66. continuation?.resume(throwing: error)
  67. continuation = nil
  68. self.session?.invalidateAndCancel()
  69. return
  70. }
  71. guard let response = downloadTask.response as? HTTPURLResponse else {
  72. continuation?.resume(throwing: DownloadService.DownloadError.invalidResponse)
  73. continuation = nil
  74. self.session?.invalidateAndCancel()
  75. return
  76. }
  77. onProgress(1.0)
  78. continuation?.resume(returning: Result(fileURL: safeCopy, response: response))
  79. continuation = nil
  80. self.session?.finishTasksAndInvalidate()
  81. }
  82. func urlSession(
  83. _ session: URLSession,
  84. task: URLSessionTask,
  85. didCompleteWithError error: (any Error)?
  86. ) {
  87. if let error {
  88. continuation?.resume(throwing: error)
  89. continuation = nil
  90. self.session?.invalidateAndCancel()
  91. }
  92. }
  93. }