Skip to content

Commit 1d768af

Browse files
committed
download progress
1 parent 9a93eb7 commit 1d768af

File tree

2 files changed

+115
-42
lines changed

2 files changed

+115
-42
lines changed

Coder-Desktop/VPN/Manager.swift

+7-1
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,13 @@ actor Manager {
3535
// Timeout after 5 minutes, or if there's no data for 60 seconds
3636
sessionConfig.timeoutIntervalForRequest = 60
3737
sessionConfig.timeoutIntervalForResource = 300
38-
try await download(src: dylibPath, dest: dest, urlSession: URLSession(configuration: sessionConfig))
38+
try await download(
39+
src: dylibPath,
40+
dest: dest,
41+
urlSession: URLSession(configuration: sessionConfig)
42+
) { progress in
43+
pushProgress(msg: "Downloading library...\n\(progress.description)")
44+
}
3945
} catch {
4046
throw .download(error)
4147
}

Coder-Desktop/VPNLib/Download.swift

+108-41
Original file line numberDiff line numberDiff line change
@@ -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

171137
func 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

Comments
 (0)