Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit c52a62e

Browse files
committedMar 6, 2025
Add resumable downloads across app sessions
1 parent 1a926a2 commit c52a62e

File tree

4 files changed

+275
-17
lines changed

4 files changed

+275
-17
lines changed
 

‎Sources/Hub/Downloader.swift

Lines changed: 150 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import Combine
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,10 +25,15 @@ 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
3339

@@ -40,9 +46,15 @@ class Downloader: NSObject, ObservableObject {
4046
headers: [String: String]? = nil,
4147
expectedSize: Int? = nil,
4248
timeout: TimeInterval = 10,
43-
numRetries: Int = 5
49+
numRetries: Int = 5,
50+
existingTempFile: URL? = nil
4451
) {
4552
self.destination = destination
53+
self.sourceURL = url
54+
self.expectedSize = expectedSize
55+
self.downloadedSize = resumeSize
56+
self.tempFilePath = existingTempFile
57+
4658
super.init()
4759
let sessionIdentifier = "swift-transformers.hub.downloader"
4860

@@ -77,7 +89,14 @@ class Downloader: NSObject, ObservableObject {
7789
timeout: TimeInterval,
7890
numRetries: Int
7991
) {
80-
downloadState.value = .downloading(0)
92+
// If we have an expected size and resumeSize, calculate initial progress
93+
if let expectedSize = expectedSize, expectedSize > 0 && resumeSize > 0 {
94+
let initialProgress = Double(resumeSize) / Double(expectedSize)
95+
downloadState.value = .downloading(initialProgress)
96+
} else {
97+
downloadState.value = .downloading(0)
98+
}
99+
81100
urlSession?.getAllTasks { tasks in
82101
// If there's an existing pending background task with the same URL, let it proceed.
83102
if let existing = tasks.filter({ $0.originalRequest?.url == url }).first {
@@ -113,24 +132,54 @@ class Downloader: NSObject, ObservableObject {
113132
requestHeaders["Range"] = "bytes=\(resumeSize)-"
114133
}
115134

116-
117135
request.timeoutInterval = timeout
118136
request.allHTTPHeaderFields = requestHeaders
119137

120138
Task {
121139
do {
122-
// Create a temp file to write
123-
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
124-
FileManager.default.createFile(atPath: tempURL.path, contents: nil)
140+
// Create or use existing temp file
141+
let tempURL: URL
142+
var existingSize = 0
143+
144+
if let existingTempFile = self.tempFilePath, FileManager.default.fileExists(atPath: existingTempFile.path) {
145+
tempURL = existingTempFile
146+
let attributes = try FileManager.default.attributesOfItem(atPath: tempURL.path)
147+
existingSize = attributes[.size] as? Int ?? 0
148+
// If the reported resumeSize doesn't match the file size, trust the file size
149+
if existingSize != resumeSize {
150+
self.downloadedSize = existingSize
151+
}
152+
} else {
153+
// Create new temp file with predictable path for future resume
154+
let filename = url.lastPathComponent
155+
// Create a stable hash by extracting just the path component
156+
let urlPath = url.absoluteString
157+
// Use a deterministic hash that doesn't change between app launches
158+
let stableHash = abs(urlPath.data(using: .utf8)!.reduce(5381) {
159+
($0 << 5) &+ $0 &+ Int32($1)
160+
})
161+
let hashedName = "\(filename)-\(stableHash)"
162+
tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(hashedName)
163+
FileManager.default.createFile(atPath: tempURL.path, contents: nil)
164+
}
165+
166+
self.tempFilePath = tempURL
125167
let tempFile = try FileHandle(forWritingTo: tempURL)
126168

169+
// If we're resuming, seek to end of file first
170+
if existingSize > 0 {
171+
try tempFile.seekToEnd()
172+
}
173+
127174
defer { tempFile.closeFile() }
128-
try await self.httpGet(request: request, tempFile: tempFile, resumeSize: resumeSize, numRetries: numRetries, expectedSize: expectedSize)
175+
try await self.httpGet(request: request, tempFile: tempFile, resumeSize: self.downloadedSize, numRetries: numRetries, expectedSize: expectedSize)
129176

130177
// Clean up and move the completed download to its final destination
131178
tempFile.closeFile()
132179
try FileManager.default.moveDownloadedFile(from: tempURL, to: self.destination)
133180

181+
// Clear temp file reference since it's been moved
182+
self.tempFilePath = nil
134183
self.downloadState.value = .completed(self.destination)
135184
} catch {
136185
self.downloadState.value = .failed(error)
@@ -178,7 +227,7 @@ class Downloader: NSObject, ObservableObject {
178227
throw DownloadError.unexpectedError
179228
}
180229

181-
var downloadedSize = resumeSize
230+
self.downloadedSize = resumeSize
182231

183232
// Create a buffer to collect bytes before writing to disk
184233
var buffer = Data(capacity: chunkSize)
@@ -192,18 +241,18 @@ class Downloader: NSObject, ObservableObject {
192241
if !buffer.isEmpty { // Filter out keep-alive chunks
193242
try tempFile.write(contentsOf: buffer)
194243
buffer.removeAll(keepingCapacity: true)
195-
downloadedSize += chunkSize
244+
self.downloadedSize += chunkSize
196245
newNumRetries = 5
197246
guard let expectedSize = expectedSize else { continue }
198-
let progress = expectedSize != 0 ? Double(downloadedSize) / Double(expectedSize) : 0
247+
let progress = expectedSize != 0 ? Double(self.downloadedSize) / Double(expectedSize) : 0
199248
downloadState.value = .downloading(progress)
200249
}
201250
}
202251
}
203252

204253
if !buffer.isEmpty {
205254
try tempFile.write(contentsOf: buffer)
206-
downloadedSize += buffer.count
255+
self.downloadedSize += buffer.count
207256
buffer.removeAll(keepingCapacity: true)
208257
newNumRetries = 5
209258
}
@@ -219,7 +268,7 @@ class Downloader: NSObject, ObservableObject {
219268
try await httpGet(
220269
request: request,
221270
tempFile: tempFile,
222-
resumeSize: downloadedSize,
271+
resumeSize: self.downloadedSize,
223272
numRetries: newNumRetries - 1,
224273
expectedSize: expectedSize
225274
)
@@ -291,3 +340,92 @@ extension FileManager {
291340
try moveItem(at: srcURL, to: dstURL)
292341
}
293342
}
343+
344+
/// Structs for persisting download state
345+
public struct PersistableDownloadState: Codable {
346+
let sourceURL: URL
347+
let destinationURL: URL
348+
let tempFilePath: URL
349+
let downloadedSize: Int
350+
let expectedSize: Int?
351+
352+
init(downloader: Downloader) {
353+
self.sourceURL = downloader.sourceURL
354+
self.destinationURL = downloader.destination
355+
self.tempFilePath = downloader.tempFilePath ?? FileManager.default.temporaryDirectory.appendingPathComponent("unknown")
356+
self.downloadedSize = downloader.downloadedSize
357+
self.expectedSize = downloader.expectedSize
358+
}
359+
}
360+
361+
/// Extension for managing persisted download states
362+
extension Downloader {
363+
/// Persists the current download state to UserDefaults
364+
func persistState() {
365+
guard let tempFilePath = self.tempFilePath else {
366+
return // Nothing to persist if no temp file
367+
}
368+
369+
let state = PersistableDownloadState(downloader: self)
370+
371+
do {
372+
let encoder = JSONEncoder()
373+
let data = try encoder.encode(state)
374+
375+
// Store in UserDefaults
376+
var states = Downloader.getPersistedStates()
377+
states[sourceURL.absoluteString] = data
378+
UserDefaults.standard.set(states, forKey: "SwiftTransformers.ActiveDownloads")
379+
} catch {
380+
print("Error persisting download state: \(error)")
381+
}
382+
}
383+
384+
/// Removes this download from persisted states
385+
func removePersistedState() {
386+
var states = Downloader.getPersistedStates()
387+
states.removeValue(forKey: sourceURL.absoluteString)
388+
UserDefaults.standard.set(states, forKey: "SwiftTransformers.ActiveDownloads")
389+
}
390+
391+
/// Get all persisted download states
392+
static func getPersistedStates() -> [String: Data] {
393+
return UserDefaults.standard.dictionary(forKey: "SwiftTransformers.ActiveDownloads") as? [String: Data] ?? [:]
394+
}
395+
396+
/// Resume all persisted downloads
397+
static func resumeAllPersistedDownloads(authToken: String? = nil) -> [Downloader] {
398+
let states = getPersistedStates()
399+
let decoder = JSONDecoder()
400+
401+
var resumedDownloaders: [Downloader] = []
402+
403+
for (_, stateData) in states {
404+
do {
405+
let state = try decoder.decode(PersistableDownloadState.self, from: stateData)
406+
407+
// Check if temp file still exists
408+
if FileManager.default.fileExists(atPath: state.tempFilePath.path) {
409+
let attributes = try FileManager.default.attributesOfItem(atPath: state.tempFilePath.path)
410+
let fileSize = attributes[.size] as? Int ?? 0
411+
412+
// Create a new downloader that resumes from the temp file
413+
let downloader = Downloader(
414+
from: state.sourceURL,
415+
to: state.destinationURL,
416+
using: authToken,
417+
resumeSize: fileSize,
418+
expectedSize: state.expectedSize,
419+
existingTempFile: state.tempFilePath
420+
)
421+
422+
resumedDownloaders.append(downloader)
423+
}
424+
} catch {
425+
print("Error restoring download: \(error)")
426+
}
427+
}
428+
429+
return resumedDownloaders
430+
}
431+
}

‎Sources/Hub/Hub.swift

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,23 +7,37 @@
77

88
import Foundation
99

10-
public struct Hub {}
10+
public struct Hub {
11+
/// Resume all downloads that were in progress when the app was previously closed
12+
///
13+
/// - Parameters:
14+
/// - authToken: Optional authentication token for accessing private repositories
15+
/// - Returns: Array of Downloader objects for the resumed downloads
16+
static func resumeAllDownloads(authToken: String? = nil) -> [Downloader] {
17+
print("[Hub] Resuming all persisted downloads...")
18+
let downloaders = Downloader.resumeAllPersistedDownloads(authToken: authToken)
19+
print("[Hub] Resumed \(downloaders.count) downloads from previous session")
20+
return downloaders
21+
}
22+
}
1123

1224
public extension Hub {
1325
enum HubClientError: Error {
1426
case parse
1527
case authorizationRequired
1628
case unexpectedError
1729
case httpStatusCode(Int)
30+
case missingFile
31+
case noNetworkConnection
1832
}
1933

20-
enum RepoType: String {
34+
enum RepoType: String, Codable {
2135
case models
2236
case datasets
2337
case spaces
2438
}
2539

26-
struct Repo {
40+
struct Repo: Codable {
2741
public let id: String
2842
public let type: RepoType
2943

@@ -34,6 +48,23 @@ public extension Hub {
3448
}
3549
}
3650

51+
/// Network monitoring utility to track connectivity status
52+
internal class NetworkMonitor {
53+
static let shared = NetworkMonitor()
54+
55+
private(set) var isConnected: Bool = true
56+
57+
static var isOffline: Bool {
58+
return !NetworkMonitor.shared.isConnected
59+
}
60+
61+
func startMonitoring() {
62+
// Simplified implementation - in a real app, use NWPathMonitor
63+
// to actually track network status
64+
self.isConnected = true
65+
}
66+
}
67+
3768
// MARK: - Configuration files with dynamic lookup
3869

3970
@dynamicMemberLookup

‎Sources/Hub/HubApi.swift

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -401,10 +401,54 @@ public extension HubApi {
401401
try prepareDestination()
402402
try prepareMetadataDestination()
403403

404-
let downloader = Downloader(from: source, to: destination, using: hfToken, inBackground: backgroundSession, expectedSize: remoteSize)
404+
// Check for an existing partial download for this file
405+
let filename = source.lastPathComponent
406+
// Create a stable hash by extracting just the path component
407+
let urlPath = source.absoluteString
408+
// Use a deterministic hash that doesn't change between app launches
409+
let stableHash = abs(urlPath.data(using: .utf8)!.reduce(5381) {
410+
($0 << 5) &+ $0 &+ Int32($1)
411+
})
412+
let hashedName = "\(filename)-\(stableHash)"
413+
let possibleTempFile = FileManager.default.temporaryDirectory.appendingPathComponent(hashedName)
414+
var resumeSize = 0
415+
416+
if FileManager.default.fileExists(atPath: possibleTempFile.path) {
417+
if let fileAttributes = try? FileManager.default.attributesOfItem(atPath: possibleTempFile.path) {
418+
resumeSize = (fileAttributes[FileAttributeKey.size] as? Int) ?? 0
419+
print("[HubApi] Found existing partial download for \(filename): \(resumeSize) bytes at \(possibleTempFile.path)")
420+
}
421+
} else {
422+
print("[HubApi] No existing partial download found for \(filename)")
423+
}
424+
425+
let downloader = Downloader(
426+
from: source,
427+
to: destination,
428+
using: hfToken,
429+
inBackground: backgroundSession,
430+
resumeSize: resumeSize,
431+
expectedSize: remoteSize,
432+
existingTempFile: FileManager.default.fileExists(atPath: possibleTempFile.path) ? possibleTempFile : nil
433+
)
434+
405435
let downloadSubscriber = downloader.downloadState.sink { state in
406-
if case .downloading(let progress) = state {
436+
switch state {
437+
case .downloading(let progress):
438+
// After we have a few bytes downloaded, we know the temp file exists
439+
// This is a good time to persist state
440+
if progress > 0.01 { // After we have 1% downloaded
441+
downloader.persistState()
442+
}
407443
progressHandler(progress)
444+
case .completed:
445+
// Remove from persisted downloads when complete
446+
downloader.removePersistedState()
447+
case .failed:
448+
// Keep in persisted downloads when failed so it can be resumed
449+
downloader.persistState()
450+
case .notStarted:
451+
break
408452
}
409453
}
410454
_ = try withExtendedLifetime(downloadSubscriber) {

‎Tests/HubTests/DownloaderTests.swift

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,14 @@ final class DownloaderTests: XCTestCase {
2222
super.setUp()
2323
tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
2424
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
25+
26+
// Clear any persisted downloads from previous tests
27+
UserDefaults.standard.removeObject(forKey: "SwiftTransformers.ActiveDownloads")
2528
}
2629

2730
override func tearDown() {
2831
try? FileManager.default.removeItem(at: tempDir)
32+
UserDefaults.standard.removeObject(forKey: "SwiftTransformers.ActiveDownloads")
2933
super.tearDown()
3034
}
3135

@@ -159,4 +163,45 @@ final class DownloaderTests: XCTestCase {
159163
throw error
160164
}
161165
}
166+
167+
func testPersistedDownloads() throws {
168+
// Create a test downloader
169+
let url = URL(string: "https://huggingface.co/coreml-projects/sam-2-studio/resolve/main/SAM%202%20Studio%201.1.zip")!
170+
let destination = tempDir.appendingPathComponent("SAM%202%20Studio%201.1.zip")
171+
172+
// First create a temp file so we can persist the state
173+
let urlPath = url.absoluteString
174+
let stableHash = abs(urlPath.data(using: .utf8)!.reduce(5381) {
175+
($0 << 5) &+ $0 &+ Int32($1)
176+
})
177+
let hashedName = "\(url.lastPathComponent)-\(stableHash)"
178+
let tempFilePath = FileManager.default.temporaryDirectory.appendingPathComponent(hashedName)
179+
180+
// Create a sample file
181+
FileManager.default.createFile(atPath: tempFilePath.path, contents: "test content".data(using: .utf8))
182+
183+
let downloader = Downloader(
184+
from: url,
185+
to: destination,
186+
expectedSize: 73194001,
187+
existingTempFile: tempFilePath
188+
)
189+
190+
// Persist the download state
191+
downloader.persistState()
192+
193+
// Check if we get a non-empty array when resuming
194+
let resumedDownloaders = Downloader.resumeAllPersistedDownloads()
195+
XCTAssertFalse(resumedDownloaders.isEmpty, "Should have resumed downloads")
196+
197+
// Clean up persisted state
198+
downloader.removePersistedState()
199+
200+
// Check that the list is now empty
201+
let emptyDownloaders = Downloader.resumeAllPersistedDownloads()
202+
XCTAssertTrue(emptyDownloaders.isEmpty, "Should have no downloads after removal")
203+
204+
// Clean up temp file
205+
try? FileManager.default.removeItem(at: tempFilePath)
206+
}
162207
}

0 commit comments

Comments
 (0)
Please sign in to comment.