@@ -18,6 +18,7 @@ final class Downloader: NSObject, Sendable {
1818 private let incompleteDestination : URL
1919 private let downloadResumeState : DownloadResumeState = . init( )
2020 private let chunkSize : Int
21+ private let useBackgroundSession : Bool
2122
2223 /// Represents the current state of a download operation.
2324 enum DownloadState {
@@ -39,6 +40,8 @@ final class Downloader: NSObject, Sendable {
3940 case unexpectedError
4041 /// The temporary file could not be found during resume.
4142 case tempFileNotFound
43+ /// HTTP error with status code.
44+ case httpError( Int )
4245 }
4346
4447 private let broadcaster : Broadcaster < DownloadState > = Broadcaster< DownloadState> {
@@ -48,6 +51,9 @@ final class Downloader: NSObject, Sendable {
4851 private let sessionConfig : URLSessionConfiguration
4952 let session : SessionActor = . init( )
5053 private let task : TaskActor = . init( )
54+
55+ /// Actor to manage background download task completion
56+ private let backgroundDownloadState : BackgroundDownloadState = . init( )
5157
5258 /// Initializes a new downloader instance.
5359 ///
@@ -66,8 +72,9 @@ final class Downloader: NSObject, Sendable {
6672 // Create incomplete file path based on destination
6773 self . incompleteDestination = incompleteDestination
6874 self . chunkSize = chunkSize
75+ self . useBackgroundSession = inBackground
6976
70- let sessionIdentifier = " swift-transformers.hub.downloader "
77+ let sessionIdentifier = " swift-transformers.hub.downloader. \( destination . lastPathComponent . hashValue ) "
7178
7279 var config = URLSessionConfiguration . default
7380 if inBackground {
@@ -189,24 +196,27 @@ final class Downloader: NSObject, Sendable {
189196 request. timeoutInterval = timeout
190197 request. allHTTPHeaderFields = requestHeaders
191198
192- // Open the incomplete file for writing
193- let tempFile = try FileHandle ( forWritingTo: self . incompleteDestination)
194-
195- // If resuming, seek to end of file
196- if resumeSize > 0 {
197- try tempFile. seekToEnd ( )
198- }
199+ // Use different download strategy based on session type
200+ if self . useBackgroundSession {
201+ // Background session: use downloadTask which works when app is suspended
202+ try await self . backgroundDownload ( request: request, numRetries: numRetries)
203+ } else {
204+ // Foreground session: use streaming bytes API for better progress tracking
205+ // Open the incomplete file for writing
206+ let tempFile = try FileHandle ( forWritingTo: self . incompleteDestination)
199207
200- defer { tempFile. closeFile ( ) }
208+ // If resuming, seek to end of file
209+ if resumeSize > 0 {
210+ try tempFile. seekToEnd ( )
211+ }
201212
202- try await self . httpGet ( request : request , tempFile : tempFile , numRetries : numRetries )
213+ defer { tempFile . closeFile ( ) }
203214
204- try Task . checkCancellation ( )
205- try FileManager . default. moveDownloadedFile ( from: self . incompleteDestination, to: self . destination)
215+ try await self . httpGet ( request: request, tempFile: tempFile, numRetries: numRetries)
206216
207- // // Clean up and move the completed download to its final destination
208- // tempFile.closeFile( )
209- // try FileManager.default.moveDownloadedFile(from: tempURL, to: self.destination)
217+ try Task . checkCancellation ( )
218+ try FileManager . default . moveDownloadedFile ( from : self . incompleteDestination , to : self . destination )
219+ }
210220
211221 await self . broadcaster. broadcast ( state: . completed( self . destination) )
212222 } catch {
@@ -215,6 +225,47 @@ final class Downloader: NSObject, Sendable {
215225 }
216226 )
217227 }
228+
229+ /// Performs download using URLSession downloadTask (works in background)
230+ ///
231+ /// - Parameters:
232+ /// - request: The URLRequest for the file to download
233+ /// - numRetries: The number of retry attempts remaining for failed downloads
234+ private func backgroundDownload(
235+ request: URLRequest ,
236+ numRetries: Int
237+ ) async throws {
238+ guard let session = await session. get ( ) else {
239+ throw DownloadError . unexpectedError
240+ }
241+
242+ // Reset background download state
243+ await backgroundDownloadState. reset ( )
244+
245+ // Create and start download task
246+ let downloadTask = session. downloadTask ( with: request)
247+ downloadTask. resume ( )
248+
249+ // Wait for download to complete via delegate callbacks
250+ let result = await backgroundDownloadState. waitForCompletion ( )
251+
252+ switch result {
253+ case . success( let tempURL) :
254+ // Move file from temp location to destination
255+ try Task . checkCancellation ( )
256+ try FileManager . default. moveDownloadedFile ( from: tempURL, to: destination)
257+
258+ case . failure( let error) :
259+ // Retry logic
260+ if numRetries > 0 {
261+ try await Task . sleep ( nanoseconds: 1_000_000_000 )
262+ await self . session. set ( URLSession ( configuration: self . sessionConfig, delegate: self , delegateQueue: nil ) )
263+ try await backgroundDownload ( request: request, numRetries: numRetries - 1 )
264+ } else {
265+ throw error
266+ }
267+ }
268+ }
218269
219270 /// Downloads a file from given URL using chunked transfer and handles retries.
220271 ///
@@ -360,35 +411,39 @@ final class Downloader: NSObject, Sendable {
360411
361412extension Downloader: URLSessionDownloadDelegate {
362413 func urlSession( _: URLSession , downloadTask: URLSessionDownloadTask , didWriteData _: Int64 , totalBytesWritten: Int64 , totalBytesExpectedToWrite: Int64 ) {
414+ let progress = totalBytesExpectedToWrite > 0
415+ ? Double ( totalBytesWritten) / Double( totalBytesExpectedToWrite)
416+ : 0
363417 Task {
364- await self . broadcaster. broadcast ( state: . downloading( Double ( totalBytesWritten ) / Double ( totalBytesExpectedToWrite ) , nil ) )
418+ await self . broadcaster. broadcast ( state: . downloading( progress , nil ) )
365419 }
366420 }
367421
368- func urlSession( _: URLSession , downloadTask _: URLSessionDownloadTask , didFinishDownloadingTo location: URL ) {
422+ func urlSession( _: URLSession , downloadTask: URLSessionDownloadTask , didFinishDownloadingTo location: URL ) {
423+ // Copy file to a safe location before the system deletes it
424+ let tempDir = FileManager . default. temporaryDirectory
425+ let safeTempURL = tempDir. appendingPathComponent ( UUID ( ) . uuidString + " _ " + location. lastPathComponent)
426+
369427 do {
370- // If the downloaded file already exists on the filesystem, overwrite it
371- try FileManager . default. moveDownloadedFile ( from: location, to: destination)
428+ try FileManager . default. copyItem ( at: location, to: safeTempURL)
372429 Task {
373- await self . broadcaster . broadcast ( state : . completed ( destination ) )
430+ await self . backgroundDownloadState . complete ( with : . success ( safeTempURL ) )
374431 }
375432 } catch {
376433 Task {
377- await self . broadcaster . broadcast ( state : . failed ( error) )
434+ await self . backgroundDownloadState . complete ( with : . failure ( error) )
378435 }
379436 }
380437 }
381438
382439 func urlSession( _ session: URLSession , task: URLSessionTask , didCompleteWithError error: Error ? ) {
383440 if let error {
384441 Task {
442+ await self . backgroundDownloadState. complete ( with: . failure( error) )
385443 await self . broadcaster. broadcast ( state: . failed( error) )
386444 }
387- // } else if let response = task.response as? HTTPURLResponse {
388- // print("HTTP response status code: \(response.statusCode)")
389- // let headers = response.allHeaderFields
390- // print("HTTP response headers: \(headers)")
391445 }
446+ // Note: Success case is handled in didFinishDownloadingTo
392447 }
393448}
394449
@@ -493,3 +548,39 @@ actor TaskActor {
493548 task
494549 }
495550}
551+
552+ /// Actor to manage background download task completion signaling
553+ private actor BackgroundDownloadState {
554+ private var continuation: CheckedContinuation < Result < URL , Error > , Never > ?
555+ private var result : Result < URL , Error > ?
556+
557+ /// Resets the state for a new download
558+ func reset( ) {
559+ continuation = nil
560+ result = nil
561+ }
562+
563+ /// Waits for the download to complete and returns the result
564+ func waitForCompletion( ) async -> Result < URL , Error > {
565+ // If result is already available, return it immediately
566+ if let result {
567+ return result
568+ }
569+
570+ // Otherwise, wait for completion signal
571+ return await withCheckedContinuation { cont in
572+ self . continuation = cont
573+ }
574+ }
575+
576+ /// Signals that the download has completed
577+ func complete( with result: Result < URL , Error > ) {
578+ self . result = result
579+
580+ // If someone is waiting, resume them
581+ if let continuation {
582+ continuation. resume ( returning: result)
583+ self . continuation = nil
584+ }
585+ }
586+ }
0 commit comments