|
@@ -1,11 +1,8 @@
|
|
|
import Foundation
|
|
import Foundation
|
|
|
|
|
|
|
|
/// Bridges URLSessionDownloadDelegate progress callbacks to Swift concurrency.
|
|
/// Bridges URLSessionDownloadDelegate progress callbacks to Swift concurrency.
|
|
|
-/// Each instance manages a single download with real-time progress reporting.
|
|
|
|
|
-///
|
|
|
|
|
-/// This is a stub for test-first development. The implementation will be
|
|
|
|
|
-/// filled in by the fix-download-progress task.
|
|
|
|
|
-final class ProgressDownloader: NSObject, URLSessionDownloadDelegate, Sendable {
|
|
|
|
|
|
|
+/// Each invocation of `download()` manages a single download with real-time progress.
|
|
|
|
|
+final class ProgressDownloader: NSObject, URLSessionDownloadDelegate, @unchecked Sendable {
|
|
|
|
|
|
|
|
/// Result of a completed download.
|
|
/// Result of a completed download.
|
|
|
struct Result: Sendable {
|
|
struct Result: Sendable {
|
|
@@ -13,41 +10,89 @@ final class ProgressDownloader: NSObject, URLSessionDownloadDelegate, Sendable {
|
|
|
let response: HTTPURLResponse
|
|
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.
|
|
/// Download a file with progress reporting.
|
|
|
///
|
|
///
|
|
|
/// - Parameters:
|
|
/// - Parameters:
|
|
|
/// - request: The URLRequest to download.
|
|
/// - request: The URLRequest to download.
|
|
|
/// - sessionConfiguration: URLSession configuration (injectable for testing).
|
|
/// - sessionConfiguration: URLSession configuration (injectable for testing).
|
|
|
- /// - onProgress: Called on each progress update (0.0-1.0). Called on arbitrary thread.
|
|
|
|
|
|
|
+ /// - onProgress: Called on each progress update (0.0–1.0). Called on arbitrary thread.
|
|
|
/// - Returns: The temporary file URL and HTTP response.
|
|
/// - Returns: The temporary file URL and HTTP response.
|
|
|
static func download(
|
|
static func download(
|
|
|
request: URLRequest,
|
|
request: URLRequest,
|
|
|
sessionConfiguration: URLSessionConfiguration = .default,
|
|
sessionConfiguration: URLSessionConfiguration = .default,
|
|
|
onProgress: @escaping @Sendable (Double) -> Void
|
|
onProgress: @escaping @Sendable (Double) -> Void
|
|
|
) async throws -> Result {
|
|
) async throws -> Result {
|
|
|
- // STUB: To be implemented by fix-download-progress task.
|
|
|
|
|
- // This stub exists so that tests compile in the write-tests phase.
|
|
|
|
|
- fatalError("ProgressDownloader.download not yet implemented")
|
|
|
|
|
|
|
+ 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 (stubs)
|
|
|
|
|
|
|
+ // MARK: - URLSessionDownloadDelegate
|
|
|
|
|
|
|
|
func urlSession(
|
|
func urlSession(
|
|
|
_ session: URLSession,
|
|
_ session: URLSession,
|
|
|
downloadTask: URLSessionDownloadTask,
|
|
downloadTask: URLSessionDownloadTask,
|
|
|
- didFinishDownloadingTo location: URL
|
|
|
|
|
|
|
+ didWriteData bytesWritten: Int64,
|
|
|
|
|
+ totalBytesWritten: Int64,
|
|
|
|
|
+ totalBytesExpectedToWrite: Int64
|
|
|
) {
|
|
) {
|
|
|
- fatalError("Not yet implemented")
|
|
|
|
|
|
|
+ guard totalBytesExpectedToWrite > 0 else { return }
|
|
|
|
|
+ let fraction = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
|
|
|
|
|
+ onProgress(fraction)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
func urlSession(
|
|
func urlSession(
|
|
|
_ session: URLSession,
|
|
_ session: URLSession,
|
|
|
downloadTask: URLSessionDownloadTask,
|
|
downloadTask: URLSessionDownloadTask,
|
|
|
- didWriteData bytesWritten: Int64,
|
|
|
|
|
- totalBytesWritten: Int64,
|
|
|
|
|
- totalBytesExpectedToWrite: Int64
|
|
|
|
|
|
|
+ didFinishDownloadingTo location: URL
|
|
|
) {
|
|
) {
|
|
|
- fatalError("Not yet implemented")
|
|
|
|
|
|
|
+ // 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(
|
|
func urlSession(
|
|
@@ -55,6 +100,10 @@ final class ProgressDownloader: NSObject, URLSessionDownloadDelegate, Sendable {
|
|
|
task: URLSessionTask,
|
|
task: URLSessionTask,
|
|
|
didCompleteWithError error: (any Error)?
|
|
didCompleteWithError error: (any Error)?
|
|
|
) {
|
|
) {
|
|
|
- fatalError("Not yet implemented")
|
|
|
|
|
|
|
+ if let error {
|
|
|
|
|
+ continuation?.resume(throwing: error)
|
|
|
|
|
+ continuation = nil
|
|
|
|
|
+ self.session?.invalidateAndCancel()
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|