Skip to content

feat: add coder connect startup progress indicator #161

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
May 22, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Coder-Desktop/Coder-Desktop/Preview Content/PreviewVPN.swift
Original file line number Diff line number Diff line change
@@ -33,6 +33,8 @@ final class PreviewVPN: Coder_Desktop.VPNService {
self.shouldFail = shouldFail
}

@Published var progress: VPNProgress = .init(stage: .initial, downloadProgress: nil)

var startTask: Task<Void, Never>?
func start() async {
if await startTask?.value != nil {
63 changes: 63 additions & 0 deletions Coder-Desktop/Coder-Desktop/VPN/VPNProgress.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import SwiftUI
import VPNLib

struct VPNProgress {
let stage: ProgressStage
let downloadProgress: DownloadProgress?
}

struct VPNProgressView: View {
let state: VPNServiceState
let progress: VPNProgress

var body: some View {
VStack {
CircularProgressView(value: value)
// We estimate that the last half takes 8 seconds
// so it doesn't appear stuck
.autoComplete(threshold: 0.5, duration: 8)
Text(progressMessage)
.multilineTextAlignment(.center)
}
.padding()
.foregroundStyle(.secondary)
}

var progressMessage: String {
"\(progress.stage.description ?? defaultMessage)\(downloadProgressMessage)"
}

var downloadProgressMessage: String {
progress.downloadProgress.flatMap { "\n\($0.description)" } ?? ""
}

var defaultMessage: String {
state == .connecting ? "Starting Coder Connect..." : "Stopping Coder Connect..."
}

var value: Float? {
guard state == .connecting else {
return nil
}
switch progress.stage {
case .initial:
return 0
case .downloading:
guard let downloadProgress = progress.downloadProgress else {
// We can't make this illegal state unrepresentable because XPC
// doesn't support enums with associated values.
return 0.05
}
// 35MB if the server doesn't give us the expected size
let totalBytes = downloadProgress.totalBytesToWrite ?? 35_000_000
let downloadPercent = min(1.0, Float(downloadProgress.totalBytesWritten) / Float(totalBytes))
return 0.4 * downloadPercent
case .validating:
return 0.43
case .removingQuarantine:
return 0.46
case .startingTunnel:
return 0.50
}
}
}
16 changes: 15 additions & 1 deletion Coder-Desktop/Coder-Desktop/VPN/VPNService.swift
Original file line number Diff line number Diff line change
@@ -7,6 +7,7 @@ import VPNLib
protocol VPNService: ObservableObject {
var state: VPNServiceState { get }
var menuState: VPNMenuState { get }
var progress: VPNProgress { get }
func start() async
func stop() async
func configureTunnelProviderProtocol(proto: NETunnelProviderProtocol?)
@@ -55,7 +56,14 @@ final class CoderVPNService: NSObject, VPNService {
var logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "vpn")
lazy var xpc: VPNXPCInterface = .init(vpn: self)

@Published var tunnelState: VPNServiceState = .disabled
@Published var tunnelState: VPNServiceState = .disabled {
didSet {
if tunnelState == .connecting {
progress = .init(stage: .initial, downloadProgress: nil)
}
}
}

@Published var sysExtnState: SystemExtensionState = .uninstalled
@Published var neState: NetworkExtensionState = .unconfigured
var state: VPNServiceState {
@@ -72,6 +80,8 @@ final class CoderVPNService: NSObject, VPNService {
return tunnelState
}

@Published var progress: VPNProgress = .init(stage: .initial, downloadProgress: nil)

@Published var menuState: VPNMenuState = .init()

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

func onProgress(stage: ProgressStage, downloadProgress: DownloadProgress?) {
progress = .init(stage: stage, downloadProgress: downloadProgress)
}

func applyPeerUpdate(with update: Vpn_PeerUpdate) {
// Delete agents
update.deletedAgents.forEach { menuState.deleteAgent(withId: $0.id) }
80 changes: 80 additions & 0 deletions Coder-Desktop/Coder-Desktop/Views/CircularProgressView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import SwiftUI

struct CircularProgressView: View {
let value: Float?

var strokeWidth: CGFloat = 4
var diameter: CGFloat = 22
var primaryColor: Color = .secondary
var backgroundColor: Color = .secondary.opacity(0.3)

@State private var rotation = 0.0
@State private var trimAmount: CGFloat = 0.15

var autoCompleteThreshold: Float?
var autoCompleteDuration: TimeInterval?

var body: some View {
ZStack {
// Background circle
Circle()
.stroke(backgroundColor, style: StrokeStyle(lineWidth: strokeWidth, lineCap: .round))
.frame(width: diameter, height: diameter)
Group {
if let value {
// Determinate gauge
Circle()
.trim(from: 0, to: CGFloat(displayValue(for: value)))
.stroke(primaryColor, style: StrokeStyle(lineWidth: strokeWidth, lineCap: .round))
.frame(width: diameter, height: diameter)
.rotationEffect(.degrees(-90))
.animation(autoCompleteAnimation(for: value), value: value)
} else {
// Indeterminate gauge
Circle()
.trim(from: 0, to: trimAmount)
.stroke(primaryColor, style: StrokeStyle(lineWidth: strokeWidth, lineCap: .round))
.frame(width: diameter, height: diameter)
.rotationEffect(.degrees(rotation))
}
}
}
.frame(width: diameter + strokeWidth * 2, height: diameter + strokeWidth * 2)
.onAppear {
if value == nil {
withAnimation(.linear(duration: 0.8).repeatForever(autoreverses: false)) {
rotation = 360
}
}
}
}

private func displayValue(for value: Float) -> Float {
if let threshold = autoCompleteThreshold,
value >= threshold, value < 1.0
{
return 1.0
}
return value
}

private func autoCompleteAnimation(for value: Float) -> Animation? {
guard let threshold = autoCompleteThreshold,
let duration = autoCompleteDuration,
value >= threshold, value < 1.0
else {
return .default
}

return .easeOut(duration: duration)
}
}

extension CircularProgressView {
func autoComplete(threshold: Float, duration: TimeInterval) -> CircularProgressView {
var view = self
view.autoCompleteThreshold = threshold
view.autoCompleteDuration = duration
return view
}
}
4 changes: 3 additions & 1 deletion Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift
Original file line number Diff line number Diff line change
@@ -33,7 +33,9 @@ struct Agents<VPN: VPNService>: View {
if hasToggledExpansion {
return
}
expandedItem = visibleItems.first?.id
withAnimation(.snappy(duration: Theme.Animation.collapsibleDuration)) {
expandedItem = visibleItems.first?.id
}
hasToggledExpansion = true
}
if items.count == 0 {
4 changes: 1 addition & 3 deletions Coder-Desktop/Coder-Desktop/Views/VPN/VPNState.swift
Original file line number Diff line number Diff line change
@@ -28,9 +28,7 @@ struct VPNState<VPN: VPNService>: View {
case (.connecting, _), (.disconnecting, _):
HStack {
Spacer()
ProgressView(
vpn.state == .connecting ? "Starting Coder Connect..." : "Stopping Coder Connect..."
).padding()
VPNProgressView(state: vpn.state, progress: vpn.progress)
Spacer()
}
case let (.failed(vpnErr), _):
6 changes: 6 additions & 0 deletions Coder-Desktop/Coder-Desktop/XPCInterface.swift
Original file line number Diff line number Diff line change
@@ -71,6 +71,12 @@ import VPNLib
}
}

func onProgress(stage: ProgressStage, downloadProgress: DownloadProgress?) {
Task { @MainActor in
svc.onProgress(stage: stage, downloadProgress: downloadProgress)
}
}

// The NE has verified the dylib and knows better than Gatekeeper
func removeQuarantine(path: String, reply: @escaping (Bool) -> Void) {
let reply = CallbackWrapper(reply)
1 change: 1 addition & 0 deletions Coder-Desktop/Coder-DesktopTests/Util.swift
Original file line number Diff line number Diff line change
@@ -10,6 +10,7 @@ class MockVPNService: VPNService, ObservableObject {
@Published var state: Coder_Desktop.VPNServiceState = .disabled
@Published var baseAccessURL: URL = .init(string: "https://dev.coder.com")!
@Published var menuState: VPNMenuState = .init()
@Published var progress: VPNProgress = .init(stage: .initial, downloadProgress: nil)
var onStart: (() async -> Void)?
var onStop: (() async -> Void)?

6 changes: 2 additions & 4 deletions Coder-Desktop/Coder-DesktopTests/VPNStateTests.swift
Original file line number Diff line number Diff line change
@@ -38,8 +38,7 @@ struct VPNStateTests {

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

try await ViewHosting.host(view) {
try await sut.inspection.inspect { view in
let progressView = try view.find(ViewType.ProgressView.self)
#expect(try progressView.labelView().text().string() == "Stopping Coder Connect...")
_ = try view.find(text: "Stopping Coder Connect...")
}
}
}
21 changes: 20 additions & 1 deletion Coder-Desktop/VPN/Manager.swift
Original file line number Diff line number Diff line change
@@ -35,10 +35,18 @@ actor Manager {
// Timeout after 5 minutes, or if there's no data for 60 seconds
sessionConfig.timeoutIntervalForRequest = 60
sessionConfig.timeoutIntervalForResource = 300
try await download(src: dylibPath, dest: dest, urlSession: URLSession(configuration: sessionConfig))
try await download(
src: dylibPath,
dest: dest,
urlSession: URLSession(configuration: sessionConfig)
) { progress in
// TODO: Debounce, somehow
pushProgress(stage: .downloading, downloadProgress: progress)
}
} catch {
throw .download(error)
}
pushProgress(stage: .validating)
let client = Client(url: cfg.serverUrl)
let buildInfo: BuildInfoResponse
do {
@@ -158,6 +166,7 @@ actor Manager {
}

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

func pushProgress(stage: ProgressStage, downloadProgress: DownloadProgress? = nil) {
guard let conn = globalXPCListenerDelegate.conn else {
logger.warning("couldn't send progress message to app: no connection")
return
}
logger.debug("sending progress message to app")
conn.onProgress(stage: stage, downloadProgress: downloadProgress)
}

struct ManagerConfig {
let apiToken: String
let serverUrl: URL
@@ -312,6 +330,7 @@ private func removeQuarantine(_ dest: URL) async throws(ManagerError) {
let file = NSURL(fileURLWithPath: dest.path)
try? file.getResourceValue(&flag, forKey: kCFURLQuarantinePropertiesKey as URLResourceKey)
if flag != nil {
pushProgress(stage: .removingQuarantine)
// Try the privileged helper first (it may not even be registered)
if await globalHelperXPCSpeaker.tryRemoveQuarantine(path: dest.path) {
// Success!
176 changes: 135 additions & 41 deletions Coder-Desktop/VPNLib/Download.swift
Original file line number Diff line number Diff line change
@@ -125,47 +125,18 @@ public class SignatureValidator {
}
}

public func download(src: URL, dest: URL, urlSession: URLSession) async throws(DownloadError) {
var req = URLRequest(url: src)
if FileManager.default.fileExists(atPath: dest.path) {
if let existingFileData = try? Data(contentsOf: dest, options: .mappedIfSafe) {
req.setValue(etag(data: existingFileData), forHTTPHeaderField: "If-None-Match")
}
}
// TODO: Add Content-Length headers to coderd, add download progress delegate
let tempURL: URL
let response: URLResponse
do {
(tempURL, response) = try await urlSession.download(for: req)
} catch {
throw .networkError(error, url: src.absoluteString)
}
defer {
if FileManager.default.fileExists(atPath: tempURL.path) {
try? FileManager.default.removeItem(at: tempURL)
}
}

guard let httpResponse = response as? HTTPURLResponse else {
throw .invalidResponse
}
guard httpResponse.statusCode != 304 else {
// We already have the latest dylib downloaded on disk
return
}

guard httpResponse.statusCode == 200 else {
throw .unexpectedStatusCode(httpResponse.statusCode)
}

do {
if FileManager.default.fileExists(atPath: dest.path) {
try FileManager.default.removeItem(at: dest)
}
try FileManager.default.moveItem(at: tempURL, to: dest)
} catch {
throw .fileOpError(error)
}
public func download(
src: URL,
dest: URL,
urlSession: URLSession,
progressUpdates: (@Sendable (DownloadProgress) -> Void)? = nil
) async throws(DownloadError) {
try await DownloadManager().download(
src: src,
dest: dest,
urlSession: urlSession,
progressUpdates: progressUpdates.flatMap { throttle(interval: .milliseconds(10), $0) }
)
}

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

public var localizedDescription: String { description }
}

// The async `URLSession.download` api ignores the passed-in delegate, so we
// wrap the older delegate methods in an async adapter with a continuation.
private final class DownloadManager: NSObject, @unchecked Sendable {
private var continuation: CheckedContinuation<Void, Error>!
private var progressHandler: ((DownloadProgress) -> Void)?
private var dest: URL!

func download(
src: URL,
dest: URL,
urlSession: URLSession,
progressUpdates: (@Sendable (DownloadProgress) -> Void)?
) async throws(DownloadError) {
var req = URLRequest(url: src)
if FileManager.default.fileExists(atPath: dest.path) {
if let existingFileData = try? Data(contentsOf: dest, options: .mappedIfSafe) {
req.setValue(etag(data: existingFileData), forHTTPHeaderField: "If-None-Match")
}
}

let downloadTask = urlSession.downloadTask(with: req)
progressHandler = progressUpdates
self.dest = dest
downloadTask.delegate = self
do {
try await withCheckedThrowingContinuation { continuation in
self.continuation = continuation
downloadTask.resume()
}
} catch let error as DownloadError {
throw error
} catch {
throw .networkError(error, url: src.absoluteString)
}
}
}

extension DownloadManager: URLSessionDownloadDelegate {
// Progress
func urlSession(
_: URLSession,
downloadTask: URLSessionDownloadTask,
didWriteData _: Int64,
totalBytesWritten: Int64,
totalBytesExpectedToWrite _: Int64
) {
let maybeLength = (downloadTask.response as? HTTPURLResponse)?
.value(forHTTPHeaderField: "X-Original-Content-Length")
.flatMap(Int64.init)
progressHandler?(.init(totalBytesWritten: totalBytesWritten, totalBytesToWrite: maybeLength))
}

// Completion
func urlSession(_: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
guard let httpResponse = downloadTask.response as? HTTPURLResponse else {
continuation.resume(throwing: DownloadError.invalidResponse)
return
}
guard httpResponse.statusCode != 304 else {
// We already have the latest dylib downloaded in dest
continuation.resume()
return
}

guard httpResponse.statusCode == 200 else {
continuation.resume(throwing: DownloadError.unexpectedStatusCode(httpResponse.statusCode))
return
}

do {
if FileManager.default.fileExists(atPath: dest.path) {
try FileManager.default.removeItem(at: dest)
}
try FileManager.default.moveItem(at: location, to: dest)
} catch {
continuation.resume(throwing: DownloadError.fileOpError(error))
return
}

continuation.resume()
}

// Failure
func urlSession(_: URLSession, task _: URLSessionTask, didCompleteWithError error: Error?) {
if let error {
continuation.resume(throwing: error)
}
}
}

@objc public final class DownloadProgress: NSObject, NSSecureCoding, @unchecked Sendable {
public static var supportsSecureCoding: Bool { true }

public let totalBytesWritten: Int64
public let totalBytesToWrite: Int64?

public init(totalBytesWritten: Int64, totalBytesToWrite: Int64?) {
self.totalBytesWritten = totalBytesWritten
self.totalBytesToWrite = totalBytesToWrite
}

public required convenience init?(coder: NSCoder) {
let written = coder.decodeInt64(forKey: "written")
let total = coder.containsValue(forKey: "total") ? coder.decodeInt64(forKey: "total") : nil
self.init(totalBytesWritten: written, totalBytesToWrite: total)
}

public func encode(with coder: NSCoder) {
coder.encode(totalBytesWritten, forKey: "written")
if let total = totalBytesToWrite {
coder.encode(total, forKey: "total")
}
}

override public var description: String {
let fmt = ByteCountFormatter()
let done = fmt.string(fromByteCount: totalBytesWritten)
.padding(toLength: 7, withPad: " ", startingAt: 0)
let total = totalBytesToWrite.map { fmt.string(fromByteCount: $0) } ?? "Unknown"
return "\(done) / \(total)"
}
}
29 changes: 29 additions & 0 deletions Coder-Desktop/VPNLib/Util.swift
Original file line number Diff line number Diff line change
@@ -29,3 +29,32 @@ public func makeNSError(suffix: String, code: Int = -1, desc: String) -> NSError
userInfo: [NSLocalizedDescriptionKey: desc]
)
}

private actor Throttler<T: Sendable> {
let interval: Duration
let send: @Sendable (T) -> Void
var lastFire: ContinuousClock.Instant?

init(interval: Duration, send: @escaping @Sendable (T) -> Void) {
self.interval = interval
self.send = send
}

func push(_ value: T) {
let now = ContinuousClock.now
if let lastFire, now - lastFire < interval { return }
lastFire = now
send(value)
}
}

public func throttle<T: Sendable>(
interval: Duration,
_ send: @escaping @Sendable (T) -> Void
) -> @Sendable (T) -> Void {
let box = Throttler(interval: interval, send: send)

return { value in
Task { await box.push(value) }
}
}
24 changes: 24 additions & 0 deletions Coder-Desktop/VPNLib/XPC.swift
Original file line number Diff line number Diff line change
@@ -10,5 +10,29 @@ import Foundation
@objc public protocol VPNXPCClientCallbackProtocol {
// data is a serialized `Vpn_PeerUpdate`
func onPeerUpdate(_ data: Data)
func onProgress(stage: ProgressStage, downloadProgress: DownloadProgress?)
func removeQuarantine(path: String, reply: @escaping (Bool) -> Void)
}

@objc public enum ProgressStage: Int, Sendable {
case initial
case downloading
case validating
case removingQuarantine
case startingTunnel

public var description: String? {
switch self {
case .initial:
nil
case .downloading:
"Downloading library..."
case .validating:
"Validating library..."
case .removingQuarantine:
"Removing quarantine..."
case .startingTunnel:
nil
}
}
}