Skip to content

Commit 2b9b873

Browse files
committed
Add resumable downloads across app sessions
1 parent b71fb0f commit 2b9b873

File tree

4 files changed

+202
-49
lines changed

4 files changed

+202
-49
lines changed

Sources/Hub/Downloader.swift

+137-38
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import Foundation
1111

1212
class Downloader: NSObject, ObservableObject {
1313
private(set) var destination: URL
14+
private(set) var sourceURL: URL
1415

1516
private let chunkSize = 10 * 1024 * 1024 // 10MB
1617

@@ -24,25 +25,69 @@ class Downloader: NSObject, ObservableObject {
2425
enum DownloadError: Error {
2526
case invalidDownloadLocation
2627
case unexpectedError
28+
case tempFileNotFound
2729
}
2830

2931
private(set) lazy var downloadState: CurrentValueSubject<DownloadState, Never> = CurrentValueSubject(.notStarted)
3032
private var stateSubscriber: Cancellable?
33+
34+
private(set) var tempFilePath: URL?
35+
private(set) var expectedSize: Int?
36+
private(set) var downloadedSize: Int = 0
3137

3238
private var urlSession: URLSession? = nil
39+
40+
/// Creates the incomplete file path for a given destination URL
41+
/// This is similar to the Hugging Face Hub approach of using .incomplete files
42+
static func incompletePath(for destination: URL) -> URL {
43+
destination.appendingPathExtension("incomplete")
44+
}
45+
46+
/// Check if an incomplete file exists for the destination and returns its size
47+
/// - Parameter destination: The destination URL for the download
48+
/// - Returns: Size of the incomplete file if it exists, otherwise 0
49+
static func checkForIncompleteFile(at destination: URL) -> Int {
50+
let incompletePath = Self.incompletePath(for: destination)
51+
52+
if FileManager.default.fileExists(atPath: incompletePath.path) {
53+
if let attributes = try? FileManager.default.attributesOfItem(atPath: incompletePath.path),
54+
let fileSize = attributes[.size] as? Int
55+
{
56+
print("[Downloader] Found existing incomplete file for \(destination.lastPathComponent): \(fileSize) bytes")
57+
return fileSize
58+
}
59+
}
60+
61+
return 0
62+
}
3363

3464
init(
3565
from url: URL,
3666
to destination: URL,
3767
using authToken: String? = nil,
3868
inBackground: Bool = false,
39-
resumeSize: Int = 0,
69+
resumeSize: Int = 0, // Can be specified manually, but will also check for incomplete files
4070
headers: [String: String]? = nil,
4171
expectedSize: Int? = nil,
4272
timeout: TimeInterval = 10,
4373
numRetries: Int = 5
4474
) {
4575
self.destination = destination
76+
sourceURL = url
77+
self.expectedSize = expectedSize
78+
79+
// Create incomplete file path based on destination
80+
tempFilePath = Downloader.incompletePath(for: destination)
81+
82+
// If resume size wasn't specified, check for an existing incomplete file
83+
let actualResumeSize: Int = if resumeSize > 0 {
84+
resumeSize
85+
} else {
86+
Downloader.checkForIncompleteFile(at: destination)
87+
}
88+
89+
downloadedSize = actualResumeSize
90+
4691
super.init()
4792
let sessionIdentifier = "swift-transformers.hub.downloader"
4893

@@ -55,7 +100,7 @@ class Downloader: NSObject, ObservableObject {
55100

56101
urlSession = URLSession(configuration: config, delegate: self, delegateQueue: nil)
57102

58-
setupDownload(from: url, with: authToken, resumeSize: resumeSize, headers: headers, expectedSize: expectedSize, timeout: timeout, numRetries: numRetries)
103+
setUpDownload(from: url, with: authToken, resumeSize: actualResumeSize, headers: headers, expectedSize: expectedSize, timeout: timeout, numRetries: numRetries)
59104
}
60105

61106
/// Sets up and initiates a file download operation
@@ -68,7 +113,7 @@ class Downloader: NSObject, ObservableObject {
68113
/// - expectedSize: Expected file size in bytes for validation
69114
/// - timeout: Time interval before the request times out
70115
/// - numRetries: Number of retry attempts for failed downloads
71-
private func setupDownload(
116+
private func setUpDownload(
72117
from url: URL,
73118
with authToken: String?,
74119
resumeSize: Int,
@@ -77,61 +122,101 @@ class Downloader: NSObject, ObservableObject {
77122
timeout: TimeInterval,
78123
numRetries: Int
79124
) {
80-
downloadState.value = .downloading(0)
125+
print("[Downloader] Setting up download for \(url.lastPathComponent)")
126+
print("[Downloader] Destination: \(destination.path)")
127+
print("[Downloader] Incomplete file: \(tempFilePath?.path ?? "none")")
128+
81129
urlSession?.getAllTasks { tasks in
82130
// If there's an existing pending background task with the same URL, let it proceed.
83131
if let existing = tasks.filter({ $0.originalRequest?.url == url }).first {
84132
switch existing.state {
85133
case .running:
86-
// print("Already downloading \(url)")
134+
print("[Downloader] Task already running for \(url.lastPathComponent)")
87135
return
88136
case .suspended:
89-
// print("Resuming suspended download task for \(url)")
137+
print("[Downloader] Resuming suspended download task for \(url.lastPathComponent)")
90138
existing.resume()
91139
return
92-
case .canceling:
93-
// print("Starting new download task for \(url), previous was canceling")
94-
break
95-
case .completed:
96-
// print("Starting new download task for \(url), previous is complete but the file is no longer present (I think it's cached)")
97-
break
140+
case .canceling, .completed:
141+
existing.cancel()
98142
@unknown default:
99-
// print("Unknown state for running task; cancelling and creating a new one")
100143
existing.cancel()
101144
}
102145
}
103-
var request = URLRequest(url: url)
104-
105-
// Use headers from argument else create an empty header dictionary
106-
var requestHeaders = headers ?? [:]
107-
108-
// Populate header auth and range fields
109-
if let authToken {
110-
requestHeaders["Authorization"] = "Bearer \(authToken)"
111-
}
112-
if resumeSize > 0 {
113-
requestHeaders["Range"] = "bytes=\(resumeSize)-"
114-
}
115146

116-
request.timeoutInterval = timeout
117-
request.allHTTPHeaderFields = requestHeaders
118-
119147
Task {
120148
do {
121-
// Create a temp file to write
122-
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
123-
FileManager.default.createFile(atPath: tempURL.path, contents: nil)
124-
let tempFile = try FileHandle(forWritingTo: tempURL)
149+
// Check if incomplete file exists and get its size
150+
var existingSize = 0
151+
guard let incompleteFilePath = self.tempFilePath else {
152+
throw DownloadError.unexpectedError
153+
}
154+
155+
let fileManager = FileManager.default
156+
if fileManager.fileExists(atPath: incompleteFilePath.path) {
157+
let attributes = try fileManager.attributesOfItem(atPath: incompleteFilePath.path)
158+
existingSize = attributes[.size] as? Int ?? 0
159+
print("[Downloader] Found incomplete file with \(existingSize) bytes")
160+
self.downloadedSize = existingSize
161+
} else {
162+
// Create parent directory if needed
163+
try fileManager.createDirectory(at: incompleteFilePath.deletingLastPathComponent(), withIntermediateDirectories: true)
164+
165+
// Create empty incomplete file
166+
fileManager.createFile(atPath: incompleteFilePath.path, contents: nil)
167+
print("[Downloader] Created new incomplete file at \(incompleteFilePath.path)")
168+
}
169+
170+
// Set up the request with appropriate headers
171+
var request = URLRequest(url: url)
172+
var requestHeaders = headers ?? [:]
173+
174+
if let authToken {
175+
requestHeaders["Authorization"] = "Bearer \(authToken)"
176+
}
177+
178+
// Set Range header if we're resuming
179+
if existingSize > 0 {
180+
requestHeaders["Range"] = "bytes=\(existingSize)-"
181+
182+
// Calculate and show initial progress
183+
if let expectedSize, expectedSize > 0 {
184+
let initialProgress = Double(existingSize) / Double(expectedSize)
185+
self.downloadState.value = .downloading(initialProgress)
186+
print("[Downloader] Resuming from \(existingSize)/\(expectedSize) bytes (\(Int(initialProgress * 100))%)")
187+
} else {
188+
self.downloadState.value = .downloading(0)
189+
print("[Downloader] Resuming download from byte \(existingSize)")
190+
}
191+
} else {
192+
self.downloadState.value = .downloading(0)
193+
print("[Downloader] Starting new download")
194+
}
195+
196+
request.timeoutInterval = timeout
197+
request.allHTTPHeaderFields = requestHeaders
198+
199+
// Open the incomplete file for writing
200+
let tempFile = try FileHandle(forWritingTo: incompleteFilePath)
201+
202+
// If resuming, seek to end of file
203+
if existingSize > 0 {
204+
try tempFile.seekToEnd()
205+
}
125206

126207
defer { tempFile.closeFile() }
127-
try await self.httpGet(request: request, tempFile: tempFile, resumeSize: resumeSize, numRetries: numRetries, expectedSize: expectedSize)
208+
try await self.httpGet(request: request, tempFile: tempFile, resumeSize: self.downloadedSize, numRetries: numRetries, expectedSize: expectedSize)
128209

129210
// Clean up and move the completed download to its final destination
130211
tempFile.closeFile()
131-
try FileManager.default.moveDownloadedFile(from: tempURL, to: self.destination)
212+
print("[Downloader] Download completed with total size \(self.downloadedSize) bytes")
213+
print("[Downloader] Moving incomplete file to destination: \(self.destination.path)")
214+
try fileManager.moveDownloadedFile(from: incompleteFilePath, to: self.destination)
132215

216+
print("[Downloader] Download successfully completed")
133217
self.downloadState.value = .completed(self.destination)
134218
} catch {
219+
print("[Downloader] Error: \(error)")
135220
self.downloadState.value = .failed(error)
136221
}
137222
}
@@ -164,20 +249,31 @@ class Downloader: NSObject, ObservableObject {
164249
var newRequest = request
165250
if resumeSize > 0 {
166251
newRequest.setValue("bytes=\(resumeSize)-", forHTTPHeaderField: "Range")
252+
print("[Downloader] Adding Range header: bytes=\(resumeSize)-")
167253
}
168254

169255
// Start the download and get the byte stream
170256
let (asyncBytes, response) = try await session.bytes(for: newRequest)
171257

172-
guard let response = response as? HTTPURLResponse else {
258+
guard let httpResponse = response as? HTTPURLResponse else {
259+
print("[Downloader] Error: Non-HTTP response received")
173260
throw DownloadError.unexpectedError
174261
}
262+
263+
print("[Downloader] Received HTTP \(httpResponse.statusCode) response")
264+
if let contentRange = httpResponse.value(forHTTPHeaderField: "Content-Range") {
265+
print("[Downloader] Content-Range: \(contentRange)")
266+
}
267+
if let contentLength = httpResponse.value(forHTTPHeaderField: "Content-Length") {
268+
print("[Downloader] Content-Length: \(contentLength)")
269+
}
175270

176-
guard (200..<300).contains(response.statusCode) else {
271+
guard (200..<300).contains(httpResponse.statusCode) else {
272+
print("[Downloader] Error: HTTP status code \(httpResponse.statusCode)")
177273
throw DownloadError.unexpectedError
178274
}
179275

180-
var downloadedSize = resumeSize
276+
downloadedSize = resumeSize
181277

182278
// Create a buffer to collect bytes before writing to disk
183279
var buffer = Data(capacity: chunkSize)
@@ -218,7 +314,7 @@ class Downloader: NSObject, ObservableObject {
218314
try await httpGet(
219315
request: request,
220316
tempFile: tempFile,
221-
resumeSize: downloadedSize,
317+
resumeSize: self.downloadedSize,
222318
numRetries: newNumRetries - 1,
223319
expectedSize: expectedSize
224320
)
@@ -227,7 +323,10 @@ class Downloader: NSObject, ObservableObject {
227323
// Verify the downloaded file size matches the expected size
228324
let actualSize = try tempFile.seekToEnd()
229325
if let expectedSize, expectedSize != actualSize {
326+
print("[Downloader] Error: Size mismatch - expected \(expectedSize) bytes but got \(actualSize) bytes")
230327
throw DownloadError.unexpectedError
328+
} else {
329+
print("[Downloader] Final verification passed, size: \(actualSize) bytes")
231330
}
232331
}
233332

Sources/Hub/Hub.swift

+3-3
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,13 @@ public extension Hub {
5151
}
5252
}
5353

54-
enum RepoType: String {
54+
enum RepoType: String, Codable {
5555
case models
5656
case datasets
5757
case spaces
5858
}
59-
60-
struct Repo {
59+
60+
struct Repo: Codable {
6161
public let id: String
6262
public let type: RepoType
6363

Sources/Hub/HubApi.swift

+38-8
Original file line numberDiff line numberDiff line change
@@ -425,19 +425,49 @@ public extension HubApi {
425425
try prepareDestination()
426426
try prepareMetadataDestination()
427427

428-
let downloader = Downloader(from: source, to: destination, using: hfToken, inBackground: backgroundSession, expectedSize: remoteSize)
428+
// Check for an existing incomplete file
429+
let incompleteFile = Downloader.incompletePath(for: destination)
430+
var resumeSize = 0
431+
432+
if FileManager.default.fileExists(atPath: incompleteFile.path) {
433+
if let fileAttributes = try? FileManager.default.attributesOfItem(atPath: incompleteFile.path) {
434+
resumeSize = (fileAttributes[FileAttributeKey.size] as? Int) ?? 0
435+
print("[HubApi] Found existing incomplete file for \(destination.lastPathComponent): \(resumeSize) bytes at \(incompleteFile.path)")
436+
}
437+
} else {
438+
print("[HubApi] No existing incomplete file found for \(destination.lastPathComponent)")
439+
}
440+
441+
let downloader = Downloader(
442+
from: source,
443+
to: destination,
444+
using: hfToken,
445+
inBackground: backgroundSession,
446+
resumeSize: resumeSize,
447+
expectedSize: remoteSize
448+
)
449+
429450
let downloadSubscriber = downloader.downloadState.sink { state in
430-
if case let .downloading(progress) = state {
451+
switch state {
452+
case let .downloading(progress):
431453
progressHandler(progress)
454+
case .completed, .failed, .notStarted:
455+
break
432456
}
433457
}
434-
_ = try withExtendedLifetime(downloadSubscriber) {
435-
try downloader.waitUntilDone()
458+
do {
459+
_ = try withExtendedLifetime(downloadSubscriber) {
460+
try downloader.waitUntilDone()
461+
}
462+
463+
try HubApi.shared.writeDownloadMetadata(commitHash: remoteCommitHash, etag: remoteEtag, metadataPath: metadataDestination)
464+
465+
return destination
466+
} catch {
467+
// If download fails, leave the incomplete file in place for future resume
468+
print("[HubApi] Download failed but incomplete file is preserved for future resume: \(error.localizedDescription)")
469+
throw error
436470
}
437-
438-
try HubApi.shared.writeDownloadMetadata(commitHash: remoteCommitHash, etag: remoteEtag, metadataPath: metadataDestination)
439-
440-
return destination
441471
}
442472
}
443473

Tests/HubTests/DownloaderTests.swift

+24
Original file line numberDiff line numberDiff line change
@@ -168,4 +168,28 @@ final class DownloaderTests: XCTestCase {
168168
throw error
169169
}
170170
}
171+
172+
func testAutomaticIncompleteFileDetection() throws {
173+
let url = URL(string: "https://huggingface.co/coreml-projects/sam-2-studio/resolve/main/SAM%202%20Studio%201.1.zip")!
174+
let destination = tempDir.appendingPathComponent("SAM%202%20Studio%201.1.zip")
175+
176+
// Create a sample incomplete file with test content
177+
let incompletePath = Downloader.incompletePath(for: destination)
178+
try FileManager.default.createDirectory(at: incompletePath.deletingLastPathComponent(), withIntermediateDirectories: true)
179+
let testContent = Data(repeating: 65, count: 1024) // 1KB of data
180+
FileManager.default.createFile(atPath: incompletePath.path, contents: testContent)
181+
182+
// Create a downloader for the same destination
183+
// It should automatically detect and use the incomplete file
184+
let downloader = Downloader(
185+
from: url,
186+
to: destination
187+
)
188+
189+
// Verify the downloader found and is using the incomplete file
190+
XCTAssertEqual(downloader.downloadedSize, 1024, "Should have detected the incomplete file and set resumeSize")
191+
192+
// Clean up
193+
try? FileManager.default.removeItem(at: incompletePath)
194+
}
171195
}

0 commit comments

Comments
 (0)