Skip to content

Commit 855f594

Browse files
committed
Add resumable downloads across app sessions
1 parent eec56ed commit 855f594

File tree

4 files changed

+275
-18
lines changed

4 files changed

+275
-18
lines changed

Sources/Hub/Downloader.swift

Lines changed: 151 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,93 @@ 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 self.tempFilePath != nil else {
366+
print("[Downloader] Cannot persist state: No temp file path")
367+
return // Nothing to persist if no temp file
368+
}
369+
370+
let state = PersistableDownloadState(downloader: self)
371+
372+
do {
373+
let encoder = JSONEncoder()
374+
let data = try encoder.encode(state)
375+
376+
// Store in UserDefaults
377+
var states = Downloader.getPersistedStates()
378+
states[sourceURL.absoluteString] = data
379+
UserDefaults.standard.set(states, forKey: "SwiftTransformers.ActiveDownloads")
380+
} catch {
381+
print("Error persisting download state: \(error)")
382+
}
383+
}
384+
385+
/// Removes this download from persisted states
386+
func removePersistedState() {
387+
var states = Downloader.getPersistedStates()
388+
states.removeValue(forKey: sourceURL.absoluteString)
389+
UserDefaults.standard.set(states, forKey: "SwiftTransformers.ActiveDownloads")
390+
}
391+
392+
/// Get all persisted download states
393+
static func getPersistedStates() -> [String: Data] {
394+
return UserDefaults.standard.dictionary(forKey: "SwiftTransformers.ActiveDownloads") as? [String: Data] ?? [:]
395+
}
396+
397+
/// Resume all persisted downloads
398+
static func resumeAllPersistedDownloads(authToken: String? = nil) -> [Downloader] {
399+
let states = getPersistedStates()
400+
let decoder = JSONDecoder()
401+
402+
var resumedDownloaders: [Downloader] = []
403+
404+
for (_, stateData) in states {
405+
do {
406+
let state = try decoder.decode(PersistableDownloadState.self, from: stateData)
407+
408+
// Check if temp file still exists
409+
if FileManager.default.fileExists(atPath: state.tempFilePath.path) {
410+
let attributes = try FileManager.default.attributesOfItem(atPath: state.tempFilePath.path)
411+
let fileSize = attributes[.size] as? Int ?? 0
412+
413+
// Create a new downloader that resumes from the temp file
414+
let downloader = Downloader(
415+
from: state.sourceURL,
416+
to: state.destinationURL,
417+
using: authToken,
418+
resumeSize: fileSize,
419+
expectedSize: state.expectedSize,
420+
existingTempFile: state.tempFilePath
421+
)
422+
423+
resumedDownloaders.append(downloader)
424+
}
425+
} catch {
426+
print("Error restoring download: \(error)")
427+
}
428+
}
429+
430+
return resumedDownloaders
431+
}
432+
}

Sources/Hub/Hub.swift

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,19 @@
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: LocalizedError {
@@ -51,13 +63,13 @@ public extension Hub {
5163
}
5264
}
5365

54-
enum RepoType: String {
66+
enum RepoType: String, Codable {
5567
case models
5668
case datasets
5769
case spaces
5870
}
59-
60-
struct Repo {
71+
72+
struct Repo: Codable {
6173
public let id: String
6274
public let type: RepoType
6375

@@ -68,6 +80,23 @@ public extension Hub {
6880
}
6981
}
7082

83+
/// Network monitoring utility to track connectivity status
84+
internal class NetworkMonitor {
85+
static let shared = NetworkMonitor()
86+
87+
private(set) var isConnected: Bool = true
88+
89+
static var isOffline: Bool {
90+
return !NetworkMonitor.shared.isConnected
91+
}
92+
93+
func startMonitoring() {
94+
// Simplified implementation - in a real app, use NWPathMonitor
95+
// to actually track network status
96+
self.isConnected = true
97+
}
98+
}
99+
71100
// MARK: - Configuration files with dynamic lookup
72101

73102
@dynamicMemberLookup

Sources/Hub/HubApi.swift

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -425,10 +425,54 @@ 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 partial download for this file
429+
let filename = source.lastPathComponent
430+
// Create a stable hash by extracting just the path component
431+
let urlPath = source.absoluteString
432+
// Use a deterministic hash that doesn't change between app launches
433+
let stableHash = abs(urlPath.data(using: .utf8)!.reduce(5381) {
434+
($0 << 5) &+ $0 &+ Int32($1)
435+
})
436+
let hashedName = "\(filename)-\(stableHash)"
437+
let possibleTempFile = FileManager.default.temporaryDirectory.appendingPathComponent(hashedName)
438+
var resumeSize = 0
439+
440+
if FileManager.default.fileExists(atPath: possibleTempFile.path) {
441+
if let fileAttributes = try? FileManager.default.attributesOfItem(atPath: possibleTempFile.path) {
442+
resumeSize = (fileAttributes[FileAttributeKey.size] as? Int) ?? 0
443+
print("[HubApi] Found existing partial download for \(filename): \(resumeSize) bytes at \(possibleTempFile.path)")
444+
}
445+
} else {
446+
print("[HubApi] No existing partial download found for \(filename)")
447+
}
448+
449+
let downloader = Downloader(
450+
from: source,
451+
to: destination,
452+
using: hfToken,
453+
inBackground: backgroundSession,
454+
resumeSize: resumeSize,
455+
expectedSize: remoteSize,
456+
existingTempFile: FileManager.default.fileExists(atPath: possibleTempFile.path) ? possibleTempFile : nil
457+
)
458+
429459
let downloadSubscriber = downloader.downloadState.sink { state in
430-
if case .downloading(let progress) = state {
460+
switch state {
461+
case .downloading(let progress):
462+
// After we have a few bytes downloaded, we know the temp file exists
463+
// This is a good time to persist state
464+
if progress > 0.01 { // After we have 1% downloaded
465+
downloader.persistState()
466+
}
431467
progressHandler(progress)
468+
case .completed:
469+
// Remove from persisted downloads when complete
470+
downloader.removePersistedState()
471+
case .failed:
472+
// Keep in persisted downloads when failed so it can be resumed
473+
downloader.persistState()
474+
case .notStarted:
475+
break
432476
}
433477
}
434478
_ = try withExtendedLifetime(downloadSubscriber) {

0 commit comments

Comments
 (0)