@@ -125,47 +125,13 @@ public class SignatureValidator {
125125 }
126126}
127127
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)
169135}
170136
171137func etag( data: Data ) -> String {
@@ -195,3 +161,104 @@ public enum DownloadError: Error {
195161
196162 public var localizedDescription : String { description }
197163}
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+ }
0 commit comments