@@ -11,6 +11,7 @@ import Combine
11
11
12
12
class Downloader : NSObject , ObservableObject {
13
13
private( set) var destination : URL
14
+ private( set) var sourceURL : URL
14
15
15
16
private let chunkSize = 10 * 1024 * 1024 // 10MB
16
17
@@ -24,10 +25,15 @@ class Downloader: NSObject, ObservableObject {
24
25
enum DownloadError : Error {
25
26
case invalidDownloadLocation
26
27
case unexpectedError
28
+ case tempFileNotFound
27
29
}
28
30
29
31
private( set) lazy var downloadState : CurrentValueSubject < DownloadState , Never > = CurrentValueSubject ( . notStarted)
30
32
private var stateSubscriber : Cancellable ?
33
+
34
+ private( set) var tempFilePath : URL ?
35
+ private( set) var expectedSize : Int ?
36
+ private( set) var downloadedSize : Int = 0
31
37
32
38
private var urlSession : URLSession ? = nil
33
39
@@ -40,9 +46,15 @@ class Downloader: NSObject, ObservableObject {
40
46
headers: [ String : String ] ? = nil ,
41
47
expectedSize: Int ? = nil ,
42
48
timeout: TimeInterval = 10 ,
43
- numRetries: Int = 5
49
+ numRetries: Int = 5 ,
50
+ existingTempFile: URL ? = nil
44
51
) {
45
52
self . destination = destination
53
+ self . sourceURL = url
54
+ self . expectedSize = expectedSize
55
+ self . downloadedSize = resumeSize
56
+ self . tempFilePath = existingTempFile
57
+
46
58
super. init ( )
47
59
let sessionIdentifier = " swift-transformers.hub.downloader "
48
60
@@ -77,7 +89,14 @@ class Downloader: NSObject, ObservableObject {
77
89
timeout: TimeInterval ,
78
90
numRetries: Int
79
91
) {
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
+
81
100
urlSession? . getAllTasks { tasks in
82
101
// If there's an existing pending background task with the same URL, let it proceed.
83
102
if let existing = tasks. filter ( { $0. originalRequest? . url == url } ) . first {
@@ -113,24 +132,54 @@ class Downloader: NSObject, ObservableObject {
113
132
requestHeaders [ " Range " ] = " bytes= \( resumeSize) - "
114
133
}
115
134
116
-
117
135
request. timeoutInterval = timeout
118
136
request. allHTTPHeaderFields = requestHeaders
119
137
120
138
Task {
121
139
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
125
167
let tempFile = try FileHandle ( forWritingTo: tempURL)
126
168
169
+ // If we're resuming, seek to end of file first
170
+ if existingSize > 0 {
171
+ try tempFile. seekToEnd ( )
172
+ }
173
+
127
174
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)
129
176
130
177
// Clean up and move the completed download to its final destination
131
178
tempFile. closeFile ( )
132
179
try FileManager . default. moveDownloadedFile ( from: tempURL, to: self . destination)
133
180
181
+ // Clear temp file reference since it's been moved
182
+ self . tempFilePath = nil
134
183
self . downloadState. value = . completed( self . destination)
135
184
} catch {
136
185
self . downloadState. value = . failed( error)
@@ -178,7 +227,7 @@ class Downloader: NSObject, ObservableObject {
178
227
throw DownloadError . unexpectedError
179
228
}
180
229
181
- var downloadedSize = resumeSize
230
+ self . downloadedSize = resumeSize
182
231
183
232
// Create a buffer to collect bytes before writing to disk
184
233
var buffer = Data ( capacity: chunkSize)
@@ -192,18 +241,18 @@ class Downloader: NSObject, ObservableObject {
192
241
if !buffer. isEmpty { // Filter out keep-alive chunks
193
242
try tempFile. write ( contentsOf: buffer)
194
243
buffer. removeAll ( keepingCapacity: true )
195
- downloadedSize += chunkSize
244
+ self . downloadedSize += chunkSize
196
245
newNumRetries = 5
197
246
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
199
248
downloadState. value = . downloading( progress)
200
249
}
201
250
}
202
251
}
203
252
204
253
if !buffer. isEmpty {
205
254
try tempFile. write ( contentsOf: buffer)
206
- downloadedSize += buffer. count
255
+ self . downloadedSize += buffer. count
207
256
buffer. removeAll ( keepingCapacity: true )
208
257
newNumRetries = 5
209
258
}
@@ -219,7 +268,7 @@ class Downloader: NSObject, ObservableObject {
219
268
try await httpGet (
220
269
request: request,
221
270
tempFile: tempFile,
222
- resumeSize: downloadedSize,
271
+ resumeSize: self . downloadedSize,
223
272
numRetries: newNumRetries - 1 ,
224
273
expectedSize: expectedSize
225
274
)
@@ -291,3 +340,92 @@ extension FileManager {
291
340
try moveItem ( at: srcURL, to: dstURL)
292
341
}
293
342
}
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
+ }
0 commit comments