Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 2adace3

Browse files
authoredMay 22, 2025··
feat: add coder connect startup progress indicator (#161)
Closes #159. https://github.com/user-attachments/assets/26391aef-31a1-4d5a-8db0-910a9fbe97ea
1 parent 48afa7a commit 2adace3

File tree

13 files changed

+381
-51
lines changed

13 files changed

+381
-51
lines changed
 

‎Coder-Desktop/Coder-Desktop/Preview Content/PreviewVPN.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ final class PreviewVPN: Coder_Desktop.VPNService {
3333
self.shouldFail = shouldFail
3434
}
3535

36+
@Published var progress: VPNProgress = .init(stage: .initial, downloadProgress: nil)
37+
3638
var startTask: Task<Void, Never>?
3739
func start() async {
3840
if await startTask?.value != nil {
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import SwiftUI
2+
import VPNLib
3+
4+
struct VPNProgress {
5+
let stage: ProgressStage
6+
let downloadProgress: DownloadProgress?
7+
}
8+
9+
struct VPNProgressView: View {
10+
let state: VPNServiceState
11+
let progress: VPNProgress
12+
13+
var body: some View {
14+
VStack {
15+
CircularProgressView(value: value)
16+
// We estimate that the last half takes 8 seconds
17+
// so it doesn't appear stuck
18+
.autoComplete(threshold: 0.5, duration: 8)
19+
Text(progressMessage)
20+
.multilineTextAlignment(.center)
21+
}
22+
.padding()
23+
.foregroundStyle(.secondary)
24+
}
25+
26+
var progressMessage: String {
27+
"\(progress.stage.description ?? defaultMessage)\(downloadProgressMessage)"
28+
}
29+
30+
var downloadProgressMessage: String {
31+
progress.downloadProgress.flatMap { "\n\($0.description)" } ?? ""
32+
}
33+
34+
var defaultMessage: String {
35+
state == .connecting ? "Starting Coder Connect..." : "Stopping Coder Connect..."
36+
}
37+
38+
var value: Float? {
39+
guard state == .connecting else {
40+
return nil
41+
}
42+
switch progress.stage {
43+
case .initial:
44+
return 0
45+
case .downloading:
46+
guard let downloadProgress = progress.downloadProgress else {
47+
// We can't make this illegal state unrepresentable because XPC
48+
// doesn't support enums with associated values.
49+
return 0.05
50+
}
51+
// 35MB if the server doesn't give us the expected size
52+
let totalBytes = downloadProgress.totalBytesToWrite ?? 35_000_000
53+
let downloadPercent = min(1.0, Float(downloadProgress.totalBytesWritten) / Float(totalBytes))
54+
return 0.4 * downloadPercent
55+
case .validating:
56+
return 0.43
57+
case .removingQuarantine:
58+
return 0.46
59+
case .startingTunnel:
60+
return 0.50
61+
}
62+
}
63+
}

‎Coder-Desktop/Coder-Desktop/VPN/VPNService.swift

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import VPNLib
77
protocol VPNService: ObservableObject {
88
var state: VPNServiceState { get }
99
var menuState: VPNMenuState { get }
10+
var progress: VPNProgress { get }
1011
func start() async
1112
func stop() async
1213
func configureTunnelProviderProtocol(proto: NETunnelProviderProtocol?)
@@ -55,7 +56,14 @@ final class CoderVPNService: NSObject, VPNService {
5556
var logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "vpn")
5657
lazy var xpc: VPNXPCInterface = .init(vpn: self)
5758

58-
@Published var tunnelState: VPNServiceState = .disabled
59+
@Published var tunnelState: VPNServiceState = .disabled {
60+
didSet {
61+
if tunnelState == .connecting {
62+
progress = .init(stage: .initial, downloadProgress: nil)
63+
}
64+
}
65+
}
66+
5967
@Published var sysExtnState: SystemExtensionState = .uninstalled
6068
@Published var neState: NetworkExtensionState = .unconfigured
6169
var state: VPNServiceState {
@@ -72,6 +80,8 @@ final class CoderVPNService: NSObject, VPNService {
7280
return tunnelState
7381
}
7482

83+
@Published var progress: VPNProgress = .init(stage: .initial, downloadProgress: nil)
84+
7585
@Published var menuState: VPNMenuState = .init()
7686

7787
// Whether the VPN should start as soon as possible
@@ -155,6 +165,10 @@ final class CoderVPNService: NSObject, VPNService {
155165
}
156166
}
157167

168+
func onProgress(stage: ProgressStage, downloadProgress: DownloadProgress?) {
169+
progress = .init(stage: stage, downloadProgress: downloadProgress)
170+
}
171+
158172
func applyPeerUpdate(with update: Vpn_PeerUpdate) {
159173
// Delete agents
160174
update.deletedAgents.forEach { menuState.deleteAgent(withId: $0.id) }
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import SwiftUI
2+
3+
struct CircularProgressView: View {
4+
let value: Float?
5+
6+
var strokeWidth: CGFloat = 4
7+
var diameter: CGFloat = 22
8+
var primaryColor: Color = .secondary
9+
var backgroundColor: Color = .secondary.opacity(0.3)
10+
11+
@State private var rotation = 0.0
12+
@State private var trimAmount: CGFloat = 0.15
13+
14+
var autoCompleteThreshold: Float?
15+
var autoCompleteDuration: TimeInterval?
16+
17+
var body: some View {
18+
ZStack {
19+
// Background circle
20+
Circle()
21+
.stroke(backgroundColor, style: StrokeStyle(lineWidth: strokeWidth, lineCap: .round))
22+
.frame(width: diameter, height: diameter)
23+
Group {
24+
if let value {
25+
// Determinate gauge
26+
Circle()
27+
.trim(from: 0, to: CGFloat(displayValue(for: value)))
28+
.stroke(primaryColor, style: StrokeStyle(lineWidth: strokeWidth, lineCap: .round))
29+
.frame(width: diameter, height: diameter)
30+
.rotationEffect(.degrees(-90))
31+
.animation(autoCompleteAnimation(for: value), value: value)
32+
} else {
33+
// Indeterminate gauge
34+
Circle()
35+
.trim(from: 0, to: trimAmount)
36+
.stroke(primaryColor, style: StrokeStyle(lineWidth: strokeWidth, lineCap: .round))
37+
.frame(width: diameter, height: diameter)
38+
.rotationEffect(.degrees(rotation))
39+
}
40+
}
41+
}
42+
.frame(width: diameter + strokeWidth * 2, height: diameter + strokeWidth * 2)
43+
.onAppear {
44+
if value == nil {
45+
withAnimation(.linear(duration: 0.8).repeatForever(autoreverses: false)) {
46+
rotation = 360
47+
}
48+
}
49+
}
50+
}
51+
52+
private func displayValue(for value: Float) -> Float {
53+
if let threshold = autoCompleteThreshold,
54+
value >= threshold, value < 1.0
55+
{
56+
return 1.0
57+
}
58+
return value
59+
}
60+
61+
private func autoCompleteAnimation(for value: Float) -> Animation? {
62+
guard let threshold = autoCompleteThreshold,
63+
let duration = autoCompleteDuration,
64+
value >= threshold, value < 1.0
65+
else {
66+
return .default
67+
}
68+
69+
return .easeOut(duration: duration)
70+
}
71+
}
72+
73+
extension CircularProgressView {
74+
func autoComplete(threshold: Float, duration: TimeInterval) -> CircularProgressView {
75+
var view = self
76+
view.autoCompleteThreshold = threshold
77+
view.autoCompleteDuration = duration
78+
return view
79+
}
80+
}

‎Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ struct Agents<VPN: VPNService>: View {
3333
if hasToggledExpansion {
3434
return
3535
}
36-
expandedItem = visibleItems.first?.id
36+
withAnimation(.snappy(duration: Theme.Animation.collapsibleDuration)) {
37+
expandedItem = visibleItems.first?.id
38+
}
3739
hasToggledExpansion = true
3840
}
3941
if items.count == 0 {

‎Coder-Desktop/Coder-Desktop/Views/VPN/VPNState.swift

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,7 @@ struct VPNState<VPN: VPNService>: View {
2828
case (.connecting, _), (.disconnecting, _):
2929
HStack {
3030
Spacer()
31-
ProgressView(
32-
vpn.state == .connecting ? "Starting Coder Connect..." : "Stopping Coder Connect..."
33-
).padding()
31+
VPNProgressView(state: vpn.state, progress: vpn.progress)
3432
Spacer()
3533
}
3634
case let (.failed(vpnErr), _):

‎Coder-Desktop/Coder-Desktop/XPCInterface.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,12 @@ import VPNLib
7171
}
7272
}
7373

74+
func onProgress(stage: ProgressStage, downloadProgress: DownloadProgress?) {
75+
Task { @MainActor in
76+
svc.onProgress(stage: stage, downloadProgress: downloadProgress)
77+
}
78+
}
79+
7480
// The NE has verified the dylib and knows better than Gatekeeper
7581
func removeQuarantine(path: String, reply: @escaping (Bool) -> Void) {
7682
let reply = CallbackWrapper(reply)

‎Coder-Desktop/Coder-DesktopTests/Util.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ class MockVPNService: VPNService, ObservableObject {
1010
@Published var state: Coder_Desktop.VPNServiceState = .disabled
1111
@Published var baseAccessURL: URL = .init(string: "https://dev.coder.com")!
1212
@Published var menuState: VPNMenuState = .init()
13+
@Published var progress: VPNProgress = .init(stage: .initial, downloadProgress: nil)
1314
var onStart: (() async -> Void)?
1415
var onStop: (() async -> Void)?
1516

‎Coder-Desktop/Coder-DesktopTests/VPNStateTests.swift

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,7 @@ struct VPNStateTests {
3838

3939
try await ViewHosting.host(view) {
4040
try await sut.inspection.inspect { view in
41-
let progressView = try view.find(ViewType.ProgressView.self)
42-
#expect(try progressView.labelView().text().string() == "Starting Coder Connect...")
41+
_ = try view.find(text: "Starting Coder Connect...")
4342
}
4443
}
4544
}
@@ -50,8 +49,7 @@ struct VPNStateTests {
5049

5150
try await ViewHosting.host(view) {
5251
try await sut.inspection.inspect { view in
53-
let progressView = try view.find(ViewType.ProgressView.self)
54-
#expect(try progressView.labelView().text().string() == "Stopping Coder Connect...")
52+
_ = try view.find(text: "Stopping Coder Connect...")
5553
}
5654
}
5755
}

‎Coder-Desktop/VPN/Manager.swift

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,18 @@ 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+
// TODO: Debounce, somehow
44+
pushProgress(stage: .downloading, downloadProgress: progress)
45+
}
3946
} catch {
4047
throw .download(error)
4148
}
49+
pushProgress(stage: .validating)
4250
let client = Client(url: cfg.serverUrl)
4351
let buildInfo: BuildInfoResponse
4452
do {
@@ -158,6 +166,7 @@ actor Manager {
158166
}
159167

160168
func startVPN() async throws(ManagerError) {
169+
pushProgress(stage: .startingTunnel)
161170
logger.info("sending start rpc")
162171
guard let tunFd = ptp.tunnelFileDescriptor else {
163172
logger.error("no fd")
@@ -234,6 +243,15 @@ actor Manager {
234243
}
235244
}
236245

246+
func pushProgress(stage: ProgressStage, downloadProgress: DownloadProgress? = nil) {
247+
guard let conn = globalXPCListenerDelegate.conn else {
248+
logger.warning("couldn't send progress message to app: no connection")
249+
return
250+
}
251+
logger.debug("sending progress message to app")
252+
conn.onProgress(stage: stage, downloadProgress: downloadProgress)
253+
}
254+
237255
struct ManagerConfig {
238256
let apiToken: String
239257
let serverUrl: URL
@@ -312,6 +330,7 @@ private func removeQuarantine(_ dest: URL) async throws(ManagerError) {
312330
let file = NSURL(fileURLWithPath: dest.path)
313331
try? file.getResourceValue(&flag, forKey: kCFURLQuarantinePropertiesKey as URLResourceKey)
314332
if flag != nil {
333+
pushProgress(stage: .removingQuarantine)
315334
// Try the privileged helper first (it may not even be registered)
316335
if await globalHelperXPCSpeaker.tryRemoveQuarantine(path: dest.path) {
317336
// Success!

‎Coder-Desktop/VPNLib/Download.swift

Lines changed: 135 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -125,47 +125,18 @@ 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: (@Sendable (DownloadProgress) -> Void)? = nil
133+
) async throws(DownloadError) {
134+
try await DownloadManager().download(
135+
src: src,
136+
dest: dest,
137+
urlSession: urlSession,
138+
progressUpdates: progressUpdates.flatMap { throttle(interval: .milliseconds(10), $0) }
139+
)
169140
}
170141

171142
func etag(data: Data) -> String {
@@ -195,3 +166,126 @@ public enum DownloadError: Error {
195166

196167
public var localizedDescription: String { description }
197168
}
169+
170+
// The async `URLSession.download` api ignores the passed-in delegate, so we
171+
// wrap the older delegate methods in an async adapter with a continuation.
172+
private final class DownloadManager: NSObject, @unchecked Sendable {
173+
private var continuation: CheckedContinuation<Void, Error>!
174+
private var progressHandler: ((DownloadProgress) -> Void)?
175+
private var dest: URL!
176+
177+
func download(
178+
src: URL,
179+
dest: URL,
180+
urlSession: URLSession,
181+
progressUpdates: (@Sendable (DownloadProgress) -> Void)?
182+
) async throws(DownloadError) {
183+
var req = URLRequest(url: src)
184+
if FileManager.default.fileExists(atPath: dest.path) {
185+
if let existingFileData = try? Data(contentsOf: dest, options: .mappedIfSafe) {
186+
req.setValue(etag(data: existingFileData), forHTTPHeaderField: "If-None-Match")
187+
}
188+
}
189+
190+
let downloadTask = urlSession.downloadTask(with: req)
191+
progressHandler = progressUpdates
192+
self.dest = dest
193+
downloadTask.delegate = self
194+
do {
195+
try await withCheckedThrowingContinuation { continuation in
196+
self.continuation = continuation
197+
downloadTask.resume()
198+
}
199+
} catch let error as DownloadError {
200+
throw error
201+
} catch {
202+
throw .networkError(error, url: src.absoluteString)
203+
}
204+
}
205+
}
206+
207+
extension DownloadManager: URLSessionDownloadDelegate {
208+
// Progress
209+
func urlSession(
210+
_: URLSession,
211+
downloadTask: URLSessionDownloadTask,
212+
didWriteData _: Int64,
213+
totalBytesWritten: Int64,
214+
totalBytesExpectedToWrite _: Int64
215+
) {
216+
let maybeLength = (downloadTask.response as? HTTPURLResponse)?
217+
.value(forHTTPHeaderField: "X-Original-Content-Length")
218+
.flatMap(Int64.init)
219+
progressHandler?(.init(totalBytesWritten: totalBytesWritten, totalBytesToWrite: maybeLength))
220+
}
221+
222+
// Completion
223+
func urlSession(_: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
224+
guard let httpResponse = downloadTask.response as? HTTPURLResponse else {
225+
continuation.resume(throwing: DownloadError.invalidResponse)
226+
return
227+
}
228+
guard httpResponse.statusCode != 304 else {
229+
// We already have the latest dylib downloaded in dest
230+
continuation.resume()
231+
return
232+
}
233+
234+
guard httpResponse.statusCode == 200 else {
235+
continuation.resume(throwing: DownloadError.unexpectedStatusCode(httpResponse.statusCode))
236+
return
237+
}
238+
239+
do {
240+
if FileManager.default.fileExists(atPath: dest.path) {
241+
try FileManager.default.removeItem(at: dest)
242+
}
243+
try FileManager.default.moveItem(at: location, to: dest)
244+
} catch {
245+
continuation.resume(throwing: DownloadError.fileOpError(error))
246+
return
247+
}
248+
249+
continuation.resume()
250+
}
251+
252+
// Failure
253+
func urlSession(_: URLSession, task _: URLSessionTask, didCompleteWithError error: Error?) {
254+
if let error {
255+
continuation.resume(throwing: error)
256+
}
257+
}
258+
}
259+
260+
@objc public final class DownloadProgress: NSObject, NSSecureCoding, @unchecked Sendable {
261+
public static var supportsSecureCoding: Bool { true }
262+
263+
public let totalBytesWritten: Int64
264+
public let totalBytesToWrite: Int64?
265+
266+
public init(totalBytesWritten: Int64, totalBytesToWrite: Int64?) {
267+
self.totalBytesWritten = totalBytesWritten
268+
self.totalBytesToWrite = totalBytesToWrite
269+
}
270+
271+
public required convenience init?(coder: NSCoder) {
272+
let written = coder.decodeInt64(forKey: "written")
273+
let total = coder.containsValue(forKey: "total") ? coder.decodeInt64(forKey: "total") : nil
274+
self.init(totalBytesWritten: written, totalBytesToWrite: total)
275+
}
276+
277+
public func encode(with coder: NSCoder) {
278+
coder.encode(totalBytesWritten, forKey: "written")
279+
if let total = totalBytesToWrite {
280+
coder.encode(total, forKey: "total")
281+
}
282+
}
283+
284+
override public var description: String {
285+
let fmt = ByteCountFormatter()
286+
let done = fmt.string(fromByteCount: totalBytesWritten)
287+
.padding(toLength: 7, withPad: " ", startingAt: 0)
288+
let total = totalBytesToWrite.map { fmt.string(fromByteCount: $0) } ?? "Unknown"
289+
return "\(done) / \(total)"
290+
}
291+
}

‎Coder-Desktop/VPNLib/Util.swift

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,32 @@ public func makeNSError(suffix: String, code: Int = -1, desc: String) -> NSError
2929
userInfo: [NSLocalizedDescriptionKey: desc]
3030
)
3131
}
32+
33+
private actor Throttler<T: Sendable> {
34+
let interval: Duration
35+
let send: @Sendable (T) -> Void
36+
var lastFire: ContinuousClock.Instant?
37+
38+
init(interval: Duration, send: @escaping @Sendable (T) -> Void) {
39+
self.interval = interval
40+
self.send = send
41+
}
42+
43+
func push(_ value: T) {
44+
let now = ContinuousClock.now
45+
if let lastFire, now - lastFire < interval { return }
46+
lastFire = now
47+
send(value)
48+
}
49+
}
50+
51+
public func throttle<T: Sendable>(
52+
interval: Duration,
53+
_ send: @escaping @Sendable (T) -> Void
54+
) -> @Sendable (T) -> Void {
55+
let box = Throttler(interval: interval, send: send)
56+
57+
return { value in
58+
Task { await box.push(value) }
59+
}
60+
}

‎Coder-Desktop/VPNLib/XPC.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,29 @@ import Foundation
1010
@objc public protocol VPNXPCClientCallbackProtocol {
1111
// data is a serialized `Vpn_PeerUpdate`
1212
func onPeerUpdate(_ data: Data)
13+
func onProgress(stage: ProgressStage, downloadProgress: DownloadProgress?)
1314
func removeQuarantine(path: String, reply: @escaping (Bool) -> Void)
1415
}
16+
17+
@objc public enum ProgressStage: Int, Sendable {
18+
case initial
19+
case downloading
20+
case validating
21+
case removingQuarantine
22+
case startingTunnel
23+
24+
public var description: String? {
25+
switch self {
26+
case .initial:
27+
nil
28+
case .downloading:
29+
"Downloading library..."
30+
case .validating:
31+
"Validating library..."
32+
case .removingQuarantine:
33+
"Removing quarantine..."
34+
case .startingTunnel:
35+
nil
36+
}
37+
}
38+
}

0 commit comments

Comments
 (0)
Please sign in to comment.