Skip to content

Commit 4257c37

Browse files
committed
Update Downloader.swift
1 parent 27e76bf commit 4257c37

File tree

1 file changed

+116
-25
lines changed

1 file changed

+116
-25
lines changed

Sources/Hub/Downloader.swift

Lines changed: 116 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -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

361412
extension 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

Comments
 (0)