Selaa lähdekoodia

feat: implement ProgressDownloader with real download progress

- ProgressDownloader: URLSessionDownloadDelegate bridge to async/await
  with intermediate progress reporting via onProgress callback
- Injectable sessionConfiguration parameter for test isolation
- DownloadService.downloadPersistent now uses ProgressDownloader
  instead of URLSession.shared.download (one-shot with no progress)
- Batch download (download()) unchanged — serves DAW export only

Data flow: URLSession delegate didWriteData → onProgress(fraction)
  → DownloadManager.trackProgress[id] → DownloadIndicator UI

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
aldiss 2 kuukautta sitten
vanhempi
commit
b85ecf3d9e
2 muutettua tiedostoa jossa 72 lisäystä ja 21 poistoa
  1. 6 4
      Sources/Services/DownloadService.swift
  2. 66 17
      Sources/Services/ProgressDownloader.swift

+ 6 - 4
Sources/Services/DownloadService.swift

@@ -252,11 +252,13 @@ enum DownloadService {
                 request.setValue(value, forHTTPHeaderField: key)
             }
 
-            let (tempURL, response) = try await URLSession.shared.download(for: request)
+            let result = try await ProgressDownloader.download(
+                request: request,
+                onProgress: onProgress
+            )
+            let tempURL = result.fileURL
+            let http = result.response
 
-            guard let http = response as? HTTPURLResponse else {
-                throw DownloadError.invalidResponse
-            }
             guard (200..<300).contains(http.statusCode) else {
                 throw DownloadError.httpError(http.statusCode)
             }

+ 66 - 17
Sources/Services/ProgressDownloader.swift

@@ -1,11 +1,8 @@
 import Foundation
 
 /// 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.
     struct Result: Sendable {
@@ -13,41 +10,89 @@ final class ProgressDownloader: NSObject, URLSessionDownloadDelegate, Sendable {
         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.
+    ///   - onProgress: Called on each progress update (0.01.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 {
-        // 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(
         _ session: URLSession,
         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(
         _ session: URLSession,
         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(
@@ -55,6 +100,10 @@ final class ProgressDownloader: NSObject, URLSessionDownloadDelegate, Sendable {
         task: URLSessionTask,
         didCompleteWithError error: (any Error)?
     ) {
-        fatalError("Not yet implemented")
+        if let error {
+            continuation?.resume(throwing: error)
+            continuation = nil
+            self.session?.invalidateAndCancel()
+        }
     }
 }