Skip to content

Commit 2adace3

Browse files
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!

0 commit comments

Comments
 (0)