@@ -24,25 +24,39 @@ class Downloader: NSObject, ObservableObject {
24
24
enum DownloadError : Error {
25
25
case invalidDownloadLocation
26
26
case unexpectedError
27
+ case tempFileNotFound
27
28
}
28
29
29
30
private( set) lazy var downloadState : CurrentValueSubject < DownloadState , Never > = CurrentValueSubject ( . notStarted)
30
31
private var stateSubscriber : Cancellable ?
32
+
33
+ private( set) var tempFilePath : URL
34
+ private( set) var expectedSize : Int ?
35
+ private( set) var downloadedSize : Int = 0
31
36
32
- private var urlSession : URLSession ? = nil
33
-
37
+ var session : URLSession ? = nil
38
+ var downloadTask : Task < Void , Error > ? = nil
39
+
34
40
init (
35
41
from url: URL ,
36
42
to destination: URL ,
43
+ incompleteDestination: URL ,
37
44
using authToken: String ? = nil ,
38
45
inBackground: Bool = false ,
39
- resumeSize: Int = 0 ,
40
46
headers: [ String : String ] ? = nil ,
41
47
expectedSize: Int ? = nil ,
42
48
timeout: TimeInterval = 10 ,
43
49
numRetries: Int = 5
44
50
) {
45
51
self . destination = destination
52
+ self . expectedSize = expectedSize
53
+
54
+ // Create incomplete file path based on destination
55
+ tempFilePath = incompleteDestination
56
+
57
+ // If resume size wasn't specified, check for an existing incomplete file
58
+ let resumeSize = Self . incompleteFileSize ( at: incompleteDestination)
59
+
46
60
super. init ( )
47
61
let sessionIdentifier = " swift-transformers.hub.downloader "
48
62
@@ -53,9 +67,22 @@ class Downloader: NSObject, ObservableObject {
53
67
config. sessionSendsLaunchEvents = true
54
68
}
55
69
56
- urlSession = URLSession ( configuration: config, delegate: self , delegateQueue: nil )
70
+ session = URLSession ( configuration: config, delegate: self , delegateQueue: nil )
57
71
58
- setupDownload ( from: url, with: authToken, resumeSize: resumeSize, headers: headers, expectedSize: expectedSize, timeout: timeout, numRetries: numRetries)
72
+ setUpDownload ( from: url, with: authToken, resumeSize: resumeSize, headers: headers, expectedSize: expectedSize, timeout: timeout, numRetries: numRetries)
73
+ }
74
+
75
+ /// Check if an incomplete file exists for the destination and returns its size
76
+ /// - Parameter destination: The destination URL for the download
77
+ /// - Returns: Size of the incomplete file if it exists, otherwise 0
78
+ static func incompleteFileSize( at incompletePath: URL ) -> Int {
79
+ if FileManager . default. fileExists ( atPath: incompletePath. path) {
80
+ if let attributes = try ? FileManager . default. attributesOfItem ( atPath: incompletePath. path) , let fileSize = attributes [ . size] as? Int {
81
+ return fileSize
82
+ }
83
+ }
84
+
85
+ return 0
59
86
}
60
87
61
88
/// Sets up and initiates a file download operation
@@ -68,7 +95,7 @@ class Downloader: NSObject, ObservableObject {
68
95
/// - expectedSize: Expected file size in bytes for validation
69
96
/// - timeout: Time interval before the request times out
70
97
/// - numRetries: Number of retry attempts for failed downloads
71
- private func setupDownload (
98
+ private func setUpDownload (
72
99
from url: URL ,
73
100
with authToken: String ? ,
74
101
resumeSize: Int ,
@@ -77,59 +104,67 @@ class Downloader: NSObject, ObservableObject {
77
104
timeout: TimeInterval ,
78
105
numRetries: Int
79
106
) {
80
- downloadState. value = . downloading( 0 )
81
- urlSession? . getAllTasks { tasks in
107
+ session? . getAllTasks { tasks in
82
108
// If there's an existing pending background task with the same URL, let it proceed.
83
109
if let existing = tasks. filter ( { $0. originalRequest? . url == url } ) . first {
84
110
switch existing. state {
85
111
case . running:
86
- // print("Already downloading \(url)")
87
112
return
88
113
case . suspended:
89
- // print("Resuming suspended download task for \(url)")
90
114
existing. resume ( )
91
115
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
116
+ case . canceling, . completed:
117
+ existing. cancel ( )
98
118
@unknown default :
99
- // print("Unknown state for running task; cancelling and creating a new one")
100
119
existing. cancel ( )
101
120
}
102
121
}
103
- var request = URLRequest ( url: url)
104
122
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
- }
115
-
116
- request. timeoutInterval = timeout
117
- request. allHTTPHeaderFields = requestHeaders
118
-
119
- Task {
123
+ self . downloadTask = Task {
120
124
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)
125
+ // Set up the request with appropriate headers
126
+ var request = URLRequest ( url: url)
127
+ var requestHeaders = headers ?? [ : ]
128
+
129
+ if let authToken {
130
+ requestHeaders [ " Authorization " ] = " Bearer \( authToken) "
131
+ }
132
+
133
+ self . downloadedSize = resumeSize
134
+
135
+ // Set Range header if we're resuming
136
+ if resumeSize > 0 {
137
+ requestHeaders [ " Range " ] = " bytes= \( resumeSize) - "
138
+
139
+ // Calculate and show initial progress
140
+ if let expectedSize, expectedSize > 0 {
141
+ let initialProgress = Double ( resumeSize) / Double( expectedSize)
142
+ self . downloadState. value = . downloading( initialProgress)
143
+ } else {
144
+ self . downloadState. value = . downloading( 0 )
145
+ }
146
+ } else {
147
+ self . downloadState. value = . downloading( 0 )
148
+ }
125
149
126
- defer { tempFile. closeFile ( ) }
127
- try await self . httpGet ( request: request, tempFile: tempFile, resumeSize: resumeSize, numRetries: numRetries, expectedSize: expectedSize)
150
+ request. timeoutInterval = timeout
151
+ request. allHTTPHeaderFields = requestHeaders
152
+
153
+ // Open the incomplete file for writing
154
+ let tempFile = try FileHandle ( forWritingTo: self . tempFilePath)
155
+
156
+ // If resuming, seek to end of file
157
+ if resumeSize > 0 {
158
+ try tempFile. seekToEnd ( )
159
+ }
160
+
161
+ try await self . httpGet ( request: request, tempFile: tempFile, resumeSize: self . downloadedSize, numRetries: numRetries, expectedSize: expectedSize)
128
162
129
163
// Clean up and move the completed download to its final destination
130
164
tempFile. closeFile ( )
131
- try FileManager . default. moveDownloadedFile ( from: tempURL, to: self . destination)
132
165
166
+ try Task . checkCancellation ( )
167
+ try FileManager . default. moveDownloadedFile ( from: self . tempFilePath, to: self . destination)
133
168
self . downloadState. value = . completed( self . destination)
134
169
} catch {
135
170
self . downloadState. value = . failed( error)
@@ -156,7 +191,7 @@ class Downloader: NSObject, ObservableObject {
156
191
numRetries: Int ,
157
192
expectedSize: Int ?
158
193
) async throws {
159
- guard let session = urlSession else {
194
+ guard let session else {
160
195
throw DownloadError . unexpectedError
161
196
}
162
197
@@ -169,16 +204,13 @@ class Downloader: NSObject, ObservableObject {
169
204
// Start the download and get the byte stream
170
205
let ( asyncBytes, response) = try await session. bytes ( for: newRequest)
171
206
172
- guard let response = response as? HTTPURLResponse else {
207
+ guard let httpResponse = response as? HTTPURLResponse else {
173
208
throw DownloadError . unexpectedError
174
209
}
175
-
176
- guard ( 200 ..< 300 ) . contains ( response. statusCode) else {
210
+ guard ( 200 ..< 300 ) . contains ( httpResponse. statusCode) else {
177
211
throw DownloadError . unexpectedError
178
212
}
179
213
180
- var downloadedSize = resumeSize
181
-
182
214
// Create a buffer to collect bytes before writing to disk
183
215
var buffer = Data ( capacity: chunkSize)
184
216
@@ -213,12 +245,12 @@ class Downloader: NSObject, ObservableObject {
213
245
try await Task . sleep ( nanoseconds: 1_000_000_000 )
214
246
215
247
let config = URLSessionConfiguration . default
216
- self . urlSession = URLSession ( configuration: config, delegate: self , delegateQueue: nil )
248
+ self . session = URLSession ( configuration: config, delegate: self , delegateQueue: nil )
217
249
218
250
try await httpGet (
219
251
request: request,
220
252
tempFile: tempFile,
221
- resumeSize: downloadedSize,
253
+ resumeSize: self . downloadedSize,
222
254
numRetries: newNumRetries - 1 ,
223
255
expectedSize: expectedSize
224
256
)
@@ -252,7 +284,9 @@ class Downloader: NSObject, ObservableObject {
252
284
}
253
285
254
286
func cancel( ) {
255
- urlSession? . invalidateAndCancel ( )
287
+ session? . invalidateAndCancel ( )
288
+ downloadTask? . cancel ( )
289
+ downloadState. value = . failed( URLError ( . cancelled) )
256
290
}
257
291
}
258
292
@@ -284,9 +318,13 @@ extension Downloader: URLSessionDownloadDelegate {
284
318
285
319
extension FileManager {
286
320
func moveDownloadedFile( from srcURL: URL , to dstURL: URL ) throws {
287
- if fileExists ( atPath: dstURL. path) {
321
+ if fileExists ( atPath: dstURL. path ( ) ) {
288
322
try removeItem ( at: dstURL)
289
323
}
324
+
325
+ let directoryURL = dstURL. deletingLastPathComponent ( )
326
+ try createDirectory ( at: directoryURL, withIntermediateDirectories: true , attributes: nil )
327
+
290
328
try moveItem ( at: srcURL, to: dstURL)
291
329
}
292
330
}
0 commit comments