@@ -125,47 +125,13 @@ public class SignatureValidator {
125
125
}
126
126
}
127
127
128
- public func download(src: URL, dest: URL, urlSession: URLSession) async throws(DownloadError) {
129
- var req = URLRequest(url: src)
130
- if FileManager.default.fileExists(atPath: dest.path) {
131
- if let existingFileData = try? Data(contentsOf: dest, options: .mappedIfSafe) {
132
- req.setValue(etag(data: existingFileData), forHTTPHeaderField: "If-None-Match")
133
- }
134
- }
135
- // TODO: Add Content-Length headers to coderd, add download progress delegate
136
- let tempURL: URL
137
- let response: URLResponse
138
- do {
139
- (tempURL, response) = try await urlSession.download(for: req)
140
- } catch {
141
- throw .networkError(error, url: src.absoluteString)
142
- }
143
- defer {
144
- if FileManager.default.fileExists(atPath: tempURL.path) {
145
- try? FileManager.default.removeItem(at: tempURL)
146
- }
147
- }
148
-
149
- guard let httpResponse = response as? HTTPURLResponse else {
150
- throw .invalidResponse
151
- }
152
- guard httpResponse.statusCode != 304 else {
153
- // We already have the latest dylib downloaded on disk
154
- return
155
- }
156
-
157
- guard httpResponse.statusCode == 200 else {
158
- throw .unexpectedStatusCode(httpResponse.statusCode)
159
- }
160
-
161
- do {
162
- if FileManager.default.fileExists(atPath: dest.path) {
163
- try FileManager.default.removeItem(at: dest)
164
- }
165
- try FileManager.default.moveItem(at: tempURL, to: dest)
166
- } catch {
167
- throw .fileOpError(error)
168
- }
128
+ public func download(
129
+ src: URL,
130
+ dest: URL,
131
+ urlSession: URLSession,
132
+ progressUpdates: ((DownloadProgress) -> Void)? = nil
133
+ ) async throws(DownloadError) {
134
+ try await DownloadManager().download(src: src, dest: dest, urlSession: urlSession, progressUpdates: progressUpdates)
169
135
}
170
136
171
137
func etag(data: Data) -> String {
@@ -195,3 +161,104 @@ public enum DownloadError: Error {
195
161
196
162
public var localizedDescription: String { description }
197
163
}
164
+
165
+ // The async `URLSession.download` api ignores the passed-in delegate, so we
166
+ // wrap the older delegate methods in an async adapter with a continuation.
167
+ private final class DownloadManager: NSObject, @unchecked Sendable {
168
+ private var continuation: CheckedContinuation<Void, Error>!
169
+ private var progressHandler: ((DownloadProgress) -> Void)?
170
+ private var dest: URL!
171
+
172
+ func download(
173
+ src: URL,
174
+ dest: URL,
175
+ urlSession: URLSession,
176
+ progressUpdates: ((DownloadProgress) -> Void)?
177
+ ) async throws(DownloadError) {
178
+ var req = URLRequest(url: src)
179
+ if FileManager.default.fileExists(atPath: dest.path) {
180
+ if let existingFileData = try? Data(contentsOf: dest, options: .mappedIfSafe) {
181
+ req.setValue(etag(data: existingFileData), forHTTPHeaderField: "If-None-Match")
182
+ }
183
+ }
184
+
185
+ let downloadTask = urlSession.downloadTask(with: req)
186
+ progressHandler = progressUpdates
187
+ self.dest = dest
188
+ downloadTask.delegate = self
189
+ do {
190
+ try await withCheckedThrowingContinuation { continuation in
191
+ self.continuation = continuation
192
+ downloadTask.resume()
193
+ }
194
+ } catch let error as DownloadError {
195
+ throw error
196
+ } catch {
197
+ throw .networkError(error, url: src.absoluteString)
198
+ }
199
+ }
200
+ }
201
+
202
+ extension DownloadManager: URLSessionDownloadDelegate {
203
+ // Progress
204
+ func urlSession(
205
+ _: URLSession,
206
+ downloadTask: URLSessionDownloadTask,
207
+ didWriteData _: Int64,
208
+ totalBytesWritten: Int64,
209
+ totalBytesExpectedToWrite _: Int64
210
+ ) {
211
+ let maybeLength = (downloadTask.response as? HTTPURLResponse)?
212
+ .value(forHTTPHeaderField: "X-Original-Content-Length")
213
+ .flatMap(Int64.init)
214
+ progressHandler?(.init(totalBytesWritten: totalBytesWritten, totalBytesToWrite: maybeLength))
215
+ }
216
+
217
+ // Completion
218
+ func urlSession(_: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
219
+ guard let httpResponse = downloadTask.response as? HTTPURLResponse else {
220
+ continuation.resume(throwing: DownloadError.invalidResponse)
221
+ return
222
+ }
223
+ guard httpResponse.statusCode != 304 else {
224
+ // We already have the latest dylib downloaded in dest
225
+ continuation.resume()
226
+ return
227
+ }
228
+
229
+ guard httpResponse.statusCode == 200 else {
230
+ continuation.resume(throwing: DownloadError.unexpectedStatusCode(httpResponse.statusCode))
231
+ return
232
+ }
233
+
234
+ do {
235
+ if FileManager.default.fileExists(atPath: dest.path) {
236
+ try FileManager.default.removeItem(at: dest)
237
+ }
238
+ try FileManager.default.moveItem(at: location, to: dest)
239
+ } catch {
240
+ continuation.resume(throwing: DownloadError.fileOpError(error))
241
+ }
242
+
243
+ continuation.resume()
244
+ }
245
+
246
+ // Failure
247
+ func urlSession(_: URLSession, task _: URLSessionTask, didCompleteWithError error: Error?) {
248
+ if let error {
249
+ continuation.resume(throwing: error)
250
+ }
251
+ }
252
+ }
253
+
254
+ public struct DownloadProgress: Sendable, CustomStringConvertible {
255
+ let totalBytesWritten: Int64
256
+ let totalBytesToWrite: Int64?
257
+
258
+ public var description: String {
259
+ let fmt = ByteCountFormatter()
260
+ let done = fmt.string(fromByteCount: totalBytesWritten)
261
+ let total = totalBytesToWrite.map { fmt.string(fromByteCount: $0) } ?? "Unknown"
262
+ return "\(done) / \(total)"
263
+ }
264
+ }