From ccf5f10b0caad2c9329fb304e66750a7da0f12c6 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 15 May 2025 17:32:17 +1000 Subject: [PATCH 01/12] feat: add coder connect startup progress messages --- .../Preview Content/PreviewVPN.swift | 2 ++ Coder-Desktop/Coder-Desktop/VPN/VPNService.swift | 7 +++++++ .../Coder-Desktop/Views/VPN/VPNState.swift | 16 +++++++++++++--- Coder-Desktop/Coder-Desktop/XPCInterface.swift | 6 ++++++ Coder-Desktop/Coder-DesktopTests/Util.swift | 1 + Coder-Desktop/VPN/Manager.swift | 15 +++++++++++++++ Coder-Desktop/VPN/PacketTunnelProvider.swift | 2 ++ Coder-Desktop/VPNLib/XPC.swift | 1 + 8 files changed, 47 insertions(+), 3 deletions(-) diff --git a/Coder-Desktop/Coder-Desktop/Preview Content/PreviewVPN.swift b/Coder-Desktop/Coder-Desktop/Preview Content/PreviewVPN.swift index 2c6e8d02..6f611bfb 100644 --- a/Coder-Desktop/Coder-Desktop/Preview Content/PreviewVPN.swift +++ b/Coder-Desktop/Coder-Desktop/Preview Content/PreviewVPN.swift @@ -33,6 +33,8 @@ final class PreviewVPN: Coder_Desktop.VPNService { self.shouldFail = shouldFail } + @Published var progressMessage: String? + var startTask: Task? func start() async { if await startTask?.value != nil { diff --git a/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift b/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift index c3c17738..fe68c769 100644 --- a/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift +++ b/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift @@ -7,6 +7,7 @@ import VPNLib protocol VPNService: ObservableObject { var state: VPNServiceState { get } var menuState: VPNMenuState { get } + var progressMessage: String? { get } func start() async func stop() async func configureTunnelProviderProtocol(proto: NETunnelProviderProtocol?) @@ -72,6 +73,8 @@ final class CoderVPNService: NSObject, VPNService { return tunnelState } + @Published var progressMessage: String? + @Published var menuState: VPNMenuState = .init() // Whether the VPN should start as soon as possible @@ -155,6 +158,10 @@ final class CoderVPNService: NSObject, VPNService { } } + func onProgress(_ msg: String?) { + progressMessage = msg + } + func applyPeerUpdate(with update: Vpn_PeerUpdate) { // Delete agents update.deletedAgents.forEach { menuState.deleteAgent(withId: $0.id) } diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNState.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNState.swift index 23319020..dfa65466 100644 --- a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNState.swift +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNState.swift @@ -6,6 +6,14 @@ struct VPNState: View { let inspection = Inspection() + var progressMessage: String { + if let msg = vpn.progressMessage { + msg + } else { + vpn.state == .connecting ? "Starting Coder Connect..." : "Stopping Coder Connect..." + } + } + var body: some View { Group { switch (vpn.state, state.hasSession) { @@ -28,9 +36,11 @@ struct VPNState: View { case (.connecting, _), (.disconnecting, _): HStack { Spacer() - ProgressView( - vpn.state == .connecting ? "Starting Coder Connect..." : "Stopping Coder Connect..." - ).padding() + ProgressView { + Text(progressMessage) + .multilineTextAlignment(.center) + } + .padding() Spacer() } case let (.failed(vpnErr), _): diff --git a/Coder-Desktop/Coder-Desktop/XPCInterface.swift b/Coder-Desktop/Coder-Desktop/XPCInterface.swift index e21be86f..f056961b 100644 --- a/Coder-Desktop/Coder-Desktop/XPCInterface.swift +++ b/Coder-Desktop/Coder-Desktop/XPCInterface.swift @@ -71,6 +71,12 @@ import VPNLib } } + func onProgress(msg: String?) { + Task { @MainActor in + svc.onProgress(msg) + } + } + // The NE has verified the dylib and knows better than Gatekeeper func removeQuarantine(path: String, reply: @escaping (Bool) -> Void) { let reply = CallbackWrapper(reply) diff --git a/Coder-Desktop/Coder-DesktopTests/Util.swift b/Coder-Desktop/Coder-DesktopTests/Util.swift index 6c7bc206..ddc21f4a 100644 --- a/Coder-Desktop/Coder-DesktopTests/Util.swift +++ b/Coder-Desktop/Coder-DesktopTests/Util.swift @@ -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 progressMessage: String? var onStart: (() async -> Void)? var onStop: (() async -> Void)? diff --git a/Coder-Desktop/VPN/Manager.swift b/Coder-Desktop/VPN/Manager.swift index bc441acd..1b9812ea 100644 --- a/Coder-Desktop/VPN/Manager.swift +++ b/Coder-Desktop/VPN/Manager.swift @@ -39,6 +39,7 @@ actor Manager { } catch { throw .download(error) } + pushProgress(msg: "Fetching server version...") let client = Client(url: cfg.serverUrl) let buildInfo: BuildInfoResponse do { @@ -49,6 +50,7 @@ actor Manager { guard let semver = buildInfo.semver else { throw .serverInfo("invalid version: \(buildInfo.version)") } + pushProgress(msg: "Validating library...") do { try SignatureValidator.validate(path: dest, expectedVersion: semver) } catch { @@ -59,11 +61,13 @@ actor Manager { // so it's safe to execute. However, the SE must be sandboxed, so we defer to the app. try await removeQuarantine(dest) + pushProgress(msg: "Opening library...") do { try tunnelHandle = TunnelHandle(dylibPath: dest) } catch { throw .tunnelSetup(error) } + pushProgress(msg: "Setting up tunnel...") speaker = await Speaker( writeFD: tunnelHandle.writeHandle, readFD: tunnelHandle.readHandle @@ -158,6 +162,7 @@ actor Manager { } func startVPN() async throws(ManagerError) { + pushProgress(msg: nil) logger.info("sending start rpc") guard let tunFd = ptp.tunnelFileDescriptor else { logger.error("no fd") @@ -234,6 +239,15 @@ actor Manager { } } +func pushProgress(msg: String?) { + guard let conn = globalXPCListenerDelegate.conn else { + logger.error("couldn't send progress message to app: no connection") + return + } + logger.info("sending progress message to app: \(msg ?? "nil")") + conn.onProgress(msg: msg) +} + struct ManagerConfig { let apiToken: String let serverUrl: URL @@ -312,6 +326,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(msg: "Unquarantining download...") // Try the privileged helper first (it may not even be registered) if await globalHelperXPCSpeaker.tryRemoveQuarantine(path: dest.path) { // Success! diff --git a/Coder-Desktop/VPN/PacketTunnelProvider.swift b/Coder-Desktop/VPN/PacketTunnelProvider.swift index 140cb5cc..04c9dbcf 100644 --- a/Coder-Desktop/VPN/PacketTunnelProvider.swift +++ b/Coder-Desktop/VPN/PacketTunnelProvider.swift @@ -92,6 +92,8 @@ class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { logger.info("vpn started") self.manager = manager completionHandler(nil) + // Clear progress message + pushProgress(msg: nil) } catch { logger.error("error starting manager: \(error.description, privacy: .public)") completionHandler( diff --git a/Coder-Desktop/VPNLib/XPC.swift b/Coder-Desktop/VPNLib/XPC.swift index dc79651e..28d171cd 100644 --- a/Coder-Desktop/VPNLib/XPC.swift +++ b/Coder-Desktop/VPNLib/XPC.swift @@ -10,5 +10,6 @@ import Foundation @objc public protocol VPNXPCClientCallbackProtocol { // data is a serialized `Vpn_PeerUpdate` func onPeerUpdate(_ data: Data) + func onProgress(msg: String?) func removeQuarantine(path: String, reply: @escaping (Bool) -> Void) } From d5d249ffbc5005b13b71c5346af9d48e667d8866 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 15 May 2025 17:35:06 +1000 Subject: [PATCH 02/12] log level --- Coder-Desktop/VPN/Manager.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Coder-Desktop/VPN/Manager.swift b/Coder-Desktop/VPN/Manager.swift index 1b9812ea..9b957a37 100644 --- a/Coder-Desktop/VPN/Manager.swift +++ b/Coder-Desktop/VPN/Manager.swift @@ -162,6 +162,7 @@ actor Manager { } func startVPN() async throws(ManagerError) { + // Clear progress message pushProgress(msg: nil) logger.info("sending start rpc") guard let tunFd = ptp.tunnelFileDescriptor else { @@ -241,10 +242,10 @@ actor Manager { func pushProgress(msg: String?) { guard let conn = globalXPCListenerDelegate.conn else { - logger.error("couldn't send progress message to app: no connection") + logger.warning("couldn't send progress message to app: no connection") return } - logger.info("sending progress message to app: \(msg ?? "nil")") + logger.debug("sending progress message to app: \(msg ?? "nil")") conn.onProgress(msg: msg) } From a2f3c472897b03f7513fd45eeb9ae20fcae17fc1 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 15 May 2025 17:37:47 +1000 Subject: [PATCH 03/12] download progress --- Coder-Desktop/VPN/Manager.swift | 8 +- Coder-Desktop/VPNLib/Download.swift | 149 ++++++++++++++++++++-------- 2 files changed, 115 insertions(+), 42 deletions(-) diff --git a/Coder-Desktop/VPN/Manager.swift b/Coder-Desktop/VPN/Manager.swift index 9b957a37..8e77f0b7 100644 --- a/Coder-Desktop/VPN/Manager.swift +++ b/Coder-Desktop/VPN/Manager.swift @@ -35,7 +35,13 @@ 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 + pushProgress(msg: "Downloading library...\n\(progress.description)") + } } catch { throw .download(error) } diff --git a/Coder-Desktop/VPNLib/Download.swift b/Coder-Desktop/VPNLib/Download.swift index 559be37f..e2461dae 100644 --- a/Coder-Desktop/VPNLib/Download.swift +++ b/Coder-Desktop/VPNLib/Download.swift @@ -125,47 +125,13 @@ 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: ((DownloadProgress) -> Void)? = nil +) async throws(DownloadError) { + try await DownloadManager().download(src: src, dest: dest, urlSession: urlSession, progressUpdates: progressUpdates) } func etag(data: Data) -> String { @@ -195,3 +161,104 @@ 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! + private var progressHandler: ((DownloadProgress) -> Void)? + private var dest: URL! + + func download( + src: URL, + dest: URL, + urlSession: URLSession, + progressUpdates: ((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)) + } + + continuation.resume() + } + + // Failure + func urlSession(_: URLSession, task _: URLSessionTask, didCompleteWithError error: Error?) { + if let error { + continuation.resume(throwing: error) + } + } +} + +public struct DownloadProgress: Sendable, CustomStringConvertible { + let totalBytesWritten: Int64 + let totalBytesToWrite: Int64? + + public var description: String { + let fmt = ByteCountFormatter() + let done = fmt.string(fromByteCount: totalBytesWritten) + let total = totalBytesToWrite.map { fmt.string(fromByteCount: $0) } ?? "Unknown" + return "\(done) / \(total)" + } +} From 065502bb37a5a12828e6163c748abdcbaaffdf8b Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 16 May 2025 19:51:08 +1000 Subject: [PATCH 04/12] progress gauge --- .../Preview Content/PreviewVPN.swift | 2 +- .../Coder-Desktop/VPN/VPNProgress.swift | 68 ++++++++++++++++ .../Coder-Desktop/VPN/VPNService.swift | 8 +- .../Views/CircularProgressView.swift | 80 +++++++++++++++++++ .../Coder-Desktop/Views/VPN/VPNState.swift | 14 +--- .../Coder-Desktop/XPCInterface.swift | 4 +- Coder-Desktop/VPN/Manager.swift | 21 +++-- Coder-Desktop/VPN/PacketTunnelProvider.swift | 2 +- Coder-Desktop/VPNLib/Download.swift | 32 ++++++-- Coder-Desktop/VPNLib/XPC.swift | 31 ++++++- 10 files changed, 223 insertions(+), 39 deletions(-) create mode 100644 Coder-Desktop/Coder-Desktop/VPN/VPNProgress.swift create mode 100644 Coder-Desktop/Coder-Desktop/Views/CircularProgressView.swift diff --git a/Coder-Desktop/Coder-Desktop/Preview Content/PreviewVPN.swift b/Coder-Desktop/Coder-Desktop/Preview Content/PreviewVPN.swift index 6f611bfb..28bc7188 100644 --- a/Coder-Desktop/Coder-Desktop/Preview Content/PreviewVPN.swift +++ b/Coder-Desktop/Coder-Desktop/Preview Content/PreviewVPN.swift @@ -33,7 +33,7 @@ final class PreviewVPN: Coder_Desktop.VPNService { self.shouldFail = shouldFail } - @Published var progressMessage: String? + @Published var progress: VPNProgress = .init(stage: .none, downloadProgress: nil) var startTask: Task? func start() async { diff --git a/Coder-Desktop/Coder-Desktop/VPN/VPNProgress.swift b/Coder-Desktop/Coder-Desktop/VPN/VPNProgress.swift new file mode 100644 index 00000000..40f339f7 --- /dev/null +++ b/Coder-Desktop/Coder-Desktop/VPN/VPNProgress.swift @@ -0,0 +1,68 @@ +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'll estimate that the last 25% takes 9 seconds + // so it doesn't appear stuck + .autoComplete(threshold: 0.75, duration: 9) + Text(progressMessage) + .multilineTextAlignment(.center) + } + .padding() + .progressViewStyle(.circular) + .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 .none: + return 0.10 + 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 + } + // 40MB if the server doesn't give us the expected size + let totalBytes = downloadProgress.totalBytesToWrite ?? 40_000_000 + let downloadPercent = min(1.0, Float(downloadProgress.totalBytesWritten) / Float(totalBytes)) + return 0.10 + 0.6 * downloadPercent + case .validating: + return 0.71 + case .removingQuarantine: + return 0.72 + case .opening: + return 0.73 + case .settingUpTunnel: + return 0.74 + case .startingTunnel: + return 0.75 + } + } +} diff --git a/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift b/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift index fe68c769..1e131cf8 100644 --- a/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift +++ b/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift @@ -7,7 +7,7 @@ import VPNLib protocol VPNService: ObservableObject { var state: VPNServiceState { get } var menuState: VPNMenuState { get } - var progressMessage: String? { get } + var progress: VPNProgress { get } func start() async func stop() async func configureTunnelProviderProtocol(proto: NETunnelProviderProtocol?) @@ -73,7 +73,7 @@ final class CoderVPNService: NSObject, VPNService { return tunnelState } - @Published var progressMessage: String? + @Published var progress: VPNProgress = .init(stage: .none, downloadProgress: nil) @Published var menuState: VPNMenuState = .init() @@ -158,8 +158,8 @@ final class CoderVPNService: NSObject, VPNService { } } - func onProgress(_ msg: String?) { - progressMessage = msg + func onProgress(stage: ProgressStage, downloadProgress: DownloadProgress?) { + progress = .init(stage: stage, downloadProgress: downloadProgress) } func applyPeerUpdate(with update: Vpn_PeerUpdate) { diff --git a/Coder-Desktop/Coder-Desktop/Views/CircularProgressView.swift b/Coder-Desktop/Coder-Desktop/Views/CircularProgressView.swift new file mode 100644 index 00000000..fc359e83 --- /dev/null +++ b/Coder-Desktop/Coder-Desktop/Views/CircularProgressView.swift @@ -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 + } +} diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNState.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNState.swift index dfa65466..e2aa1d8d 100644 --- a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNState.swift +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNState.swift @@ -6,14 +6,6 @@ struct VPNState: View { let inspection = Inspection() - var progressMessage: String { - if let msg = vpn.progressMessage { - msg - } else { - vpn.state == .connecting ? "Starting Coder Connect..." : "Stopping Coder Connect..." - } - } - var body: some View { Group { switch (vpn.state, state.hasSession) { @@ -36,11 +28,7 @@ struct VPNState: View { case (.connecting, _), (.disconnecting, _): HStack { Spacer() - ProgressView { - Text(progressMessage) - .multilineTextAlignment(.center) - } - .padding() + VPNProgressView(state: vpn.state, progress: vpn.progress) Spacer() } case let (.failed(vpnErr), _): diff --git a/Coder-Desktop/Coder-Desktop/XPCInterface.swift b/Coder-Desktop/Coder-Desktop/XPCInterface.swift index f056961b..e6c78d6d 100644 --- a/Coder-Desktop/Coder-Desktop/XPCInterface.swift +++ b/Coder-Desktop/Coder-Desktop/XPCInterface.swift @@ -71,9 +71,9 @@ import VPNLib } } - func onProgress(msg: String?) { + func onProgress(stage: ProgressStage, downloadProgress: DownloadProgress?) { Task { @MainActor in - svc.onProgress(msg) + svc.onProgress(stage: stage, downloadProgress: downloadProgress) } } diff --git a/Coder-Desktop/VPN/Manager.swift b/Coder-Desktop/VPN/Manager.swift index 8e77f0b7..4559e4d5 100644 --- a/Coder-Desktop/VPN/Manager.swift +++ b/Coder-Desktop/VPN/Manager.swift @@ -40,12 +40,13 @@ actor Manager { dest: dest, urlSession: URLSession(configuration: sessionConfig) ) { progress in - pushProgress(msg: "Downloading library...\n\(progress.description)") + // TODO: Debounce, somehow + pushProgress(stage: .downloading, downloadProgress: progress) } } catch { throw .download(error) } - pushProgress(msg: "Fetching server version...") + pushProgress(stage: .validating) let client = Client(url: cfg.serverUrl) let buildInfo: BuildInfoResponse do { @@ -56,7 +57,6 @@ actor Manager { guard let semver = buildInfo.semver else { throw .serverInfo("invalid version: \(buildInfo.version)") } - pushProgress(msg: "Validating library...") do { try SignatureValidator.validate(path: dest, expectedVersion: semver) } catch { @@ -67,13 +67,13 @@ actor Manager { // so it's safe to execute. However, the SE must be sandboxed, so we defer to the app. try await removeQuarantine(dest) - pushProgress(msg: "Opening library...") + pushProgress(stage: .opening) do { try tunnelHandle = TunnelHandle(dylibPath: dest) } catch { throw .tunnelSetup(error) } - pushProgress(msg: "Setting up tunnel...") + pushProgress(stage: .settingUpTunnel) speaker = await Speaker( writeFD: tunnelHandle.writeHandle, readFD: tunnelHandle.readHandle @@ -168,8 +168,7 @@ actor Manager { } func startVPN() async throws(ManagerError) { - // Clear progress message - pushProgress(msg: nil) + pushProgress(stage: .startingTunnel) logger.info("sending start rpc") guard let tunFd = ptp.tunnelFileDescriptor else { logger.error("no fd") @@ -246,13 +245,13 @@ actor Manager { } } -func pushProgress(msg: String?) { +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: \(msg ?? "nil")") - conn.onProgress(msg: msg) + logger.debug("sending progress message to app") + conn.onProgress(stage: stage, downloadProgress: downloadProgress) } struct ManagerConfig { @@ -333,7 +332,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(msg: "Unquarantining download...") + pushProgress(stage: .removingQuarantine) // Try the privileged helper first (it may not even be registered) if await globalHelperXPCSpeaker.tryRemoveQuarantine(path: dest.path) { // Success! diff --git a/Coder-Desktop/VPN/PacketTunnelProvider.swift b/Coder-Desktop/VPN/PacketTunnelProvider.swift index 04c9dbcf..748710b6 100644 --- a/Coder-Desktop/VPN/PacketTunnelProvider.swift +++ b/Coder-Desktop/VPN/PacketTunnelProvider.swift @@ -93,7 +93,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { self.manager = manager completionHandler(nil) // Clear progress message - pushProgress(msg: nil) + pushProgress(stage: .none, downloadProgress: nil) } catch { logger.error("error starting manager: \(error.description, privacy: .public)") completionHandler( diff --git a/Coder-Desktop/VPNLib/Download.swift b/Coder-Desktop/VPNLib/Download.swift index e2461dae..d1022098 100644 --- a/Coder-Desktop/VPNLib/Download.swift +++ b/Coder-Desktop/VPNLib/Download.swift @@ -251,14 +251,34 @@ extension DownloadManager: URLSessionDownloadDelegate { } } -public struct DownloadProgress: Sendable, CustomStringConvertible { - let totalBytesWritten: Int64 - let totalBytesToWrite: Int64? +@objc public final class DownloadProgress: NSObject, NSSecureCoding, @unchecked Sendable { + public static var supportsSecureCoding: Bool { true } - public var description: String { + 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) - let total = totalBytesToWrite.map { fmt.string(fromByteCount: $0) } ?? "Unknown" - return "\(done) / \(total)" + let tot = totalBytesToWrite.map { fmt.string(fromByteCount: $0) } ?? "Unknown" + return "\(done) / \(tot)" } } diff --git a/Coder-Desktop/VPNLib/XPC.swift b/Coder-Desktop/VPNLib/XPC.swift index 28d171cd..96e561f9 100644 --- a/Coder-Desktop/VPNLib/XPC.swift +++ b/Coder-Desktop/VPNLib/XPC.swift @@ -10,6 +10,35 @@ import Foundation @objc public protocol VPNXPCClientCallbackProtocol { // data is a serialized `Vpn_PeerUpdate` func onPeerUpdate(_ data: Data) - func onProgress(msg: String?) + func onProgress(stage: ProgressStage, downloadProgress: DownloadProgress?) func removeQuarantine(path: String, reply: @escaping (Bool) -> Void) } + +@objc public enum ProgressStage: Int, Sendable { + case none + case downloading + case validating + case removingQuarantine + case opening + case settingUpTunnel + case startingTunnel + + public var description: String? { + switch self { + case .none: + nil + case .downloading: + "Downloading library..." + case .validating: + "Validating library..." + case .removingQuarantine: + "Removing quarantine..." + case .opening: + "Opening library..." + case .settingUpTunnel: + "Setting up tunnel..." + case .startingTunnel: + nil + } + } +} From 64ffe1747d959bcad0f778a953d12e3c081d6db4 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 16 May 2025 20:10:06 +1000 Subject: [PATCH 05/12] fix tests --- Coder-Desktop/Coder-DesktopTests/Util.swift | 2 +- Coder-Desktop/Coder-DesktopTests/VPNStateTests.swift | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/Coder-Desktop/Coder-DesktopTests/Util.swift b/Coder-Desktop/Coder-DesktopTests/Util.swift index ddc21f4a..72f04df2 100644 --- a/Coder-Desktop/Coder-DesktopTests/Util.swift +++ b/Coder-Desktop/Coder-DesktopTests/Util.swift @@ -10,7 +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 progressMessage: String? + @Published var progress: VPNProgress = .init(stage: .none, downloadProgress: nil) var onStart: (() async -> Void)? var onStop: (() async -> Void)? diff --git a/Coder-Desktop/Coder-DesktopTests/VPNStateTests.swift b/Coder-Desktop/Coder-DesktopTests/VPNStateTests.swift index 92827cf8..abad6abd 100644 --- a/Coder-Desktop/Coder-DesktopTests/VPNStateTests.swift +++ b/Coder-Desktop/Coder-DesktopTests/VPNStateTests.swift @@ -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...") } } } From 5177bd065a70fd99f690a7e210fa7271e1cde5bf Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 16 May 2025 20:59:56 +1000 Subject: [PATCH 06/12] fixup --- .../Coder-Desktop/Preview Content/PreviewVPN.swift | 2 +- Coder-Desktop/Coder-Desktop/VPN/VPNProgress.swift | 2 +- Coder-Desktop/Coder-Desktop/VPN/VPNService.swift | 11 +++++++++-- Coder-Desktop/Coder-DesktopTests/Util.swift | 2 +- Coder-Desktop/VPN/PacketTunnelProvider.swift | 2 -- Coder-Desktop/VPNLib/XPC.swift | 4 ++-- 6 files changed, 14 insertions(+), 9 deletions(-) diff --git a/Coder-Desktop/Coder-Desktop/Preview Content/PreviewVPN.swift b/Coder-Desktop/Coder-Desktop/Preview Content/PreviewVPN.swift index 28bc7188..4d4e9f90 100644 --- a/Coder-Desktop/Coder-Desktop/Preview Content/PreviewVPN.swift +++ b/Coder-Desktop/Coder-Desktop/Preview Content/PreviewVPN.swift @@ -33,7 +33,7 @@ final class PreviewVPN: Coder_Desktop.VPNService { self.shouldFail = shouldFail } - @Published var progress: VPNProgress = .init(stage: .none, downloadProgress: nil) + @Published var progress: VPNProgress = .init(stage: .initial, downloadProgress: nil) var startTask: Task? func start() async { diff --git a/Coder-Desktop/Coder-Desktop/VPN/VPNProgress.swift b/Coder-Desktop/Coder-Desktop/VPN/VPNProgress.swift index 40f339f7..1d2aa6b9 100644 --- a/Coder-Desktop/Coder-Desktop/VPN/VPNProgress.swift +++ b/Coder-Desktop/Coder-Desktop/VPN/VPNProgress.swift @@ -41,7 +41,7 @@ struct VPNProgressView: View { return nil } switch progress.stage { - case .none: + case .initial: return 0.10 case .downloading: guard let downloadProgress = progress.downloadProgress else { diff --git a/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift b/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift index 1e131cf8..224174ae 100644 --- a/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift +++ b/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift @@ -56,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 { @@ -73,7 +80,7 @@ final class CoderVPNService: NSObject, VPNService { return tunnelState } - @Published var progress: VPNProgress = .init(stage: .none, downloadProgress: nil) + @Published var progress: VPNProgress = .init(stage: .initial, downloadProgress: nil) @Published var menuState: VPNMenuState = .init() diff --git a/Coder-Desktop/Coder-DesktopTests/Util.swift b/Coder-Desktop/Coder-DesktopTests/Util.swift index 72f04df2..60751274 100644 --- a/Coder-Desktop/Coder-DesktopTests/Util.swift +++ b/Coder-Desktop/Coder-DesktopTests/Util.swift @@ -10,7 +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: .none, downloadProgress: nil) + @Published var progress: VPNProgress = .init(stage: .initial, downloadProgress: nil) var onStart: (() async -> Void)? var onStop: (() async -> Void)? diff --git a/Coder-Desktop/VPN/PacketTunnelProvider.swift b/Coder-Desktop/VPN/PacketTunnelProvider.swift index 748710b6..140cb5cc 100644 --- a/Coder-Desktop/VPN/PacketTunnelProvider.swift +++ b/Coder-Desktop/VPN/PacketTunnelProvider.swift @@ -92,8 +92,6 @@ class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { logger.info("vpn started") self.manager = manager completionHandler(nil) - // Clear progress message - pushProgress(stage: .none, downloadProgress: nil) } catch { logger.error("error starting manager: \(error.description, privacy: .public)") completionHandler( diff --git a/Coder-Desktop/VPNLib/XPC.swift b/Coder-Desktop/VPNLib/XPC.swift index 96e561f9..9464ea29 100644 --- a/Coder-Desktop/VPNLib/XPC.swift +++ b/Coder-Desktop/VPNLib/XPC.swift @@ -15,7 +15,7 @@ import Foundation } @objc public enum ProgressStage: Int, Sendable { - case none + case initial case downloading case validating case removingQuarantine @@ -25,7 +25,7 @@ import Foundation public var description: String? { switch self { - case .none: + case .initial: nil case .downloading: "Downloading library..." From 8d3b4a6e5e80722ac576ba0ba901d3c27b64ab87 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Mon, 19 May 2025 14:11:55 +1000 Subject: [PATCH 07/12] scaling --- .../Coder-Desktop/VPN/VPNProgress.swift | 18 +++++++++--------- .../Coder-Desktop/Views/VPN/Agents.swift | 4 +++- Coder-Desktop/VPNLib/Download.swift | 6 ++++-- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/Coder-Desktop/Coder-Desktop/VPN/VPNProgress.swift b/Coder-Desktop/Coder-Desktop/VPN/VPNProgress.swift index 1d2aa6b9..6d2cd6d6 100644 --- a/Coder-Desktop/Coder-Desktop/VPN/VPNProgress.swift +++ b/Coder-Desktop/Coder-Desktop/VPN/VPNProgress.swift @@ -13,9 +13,9 @@ struct VPNProgressView: View { var body: some View { VStack { CircularProgressView(value: value) - // We'll estimate that the last 25% takes 9 seconds + // We estimate that the last half takes 8 seconds // so it doesn't appear stuck - .autoComplete(threshold: 0.75, duration: 9) + .autoComplete(threshold: 0.5, duration: 8) Text(progressMessage) .multilineTextAlignment(.center) } @@ -42,7 +42,7 @@ struct VPNProgressView: View { } switch progress.stage { case .initial: - return 0.10 + return 0.05 case .downloading: guard let downloadProgress = progress.downloadProgress else { // We can't make this illegal state unrepresentable because XPC @@ -52,17 +52,17 @@ struct VPNProgressView: View { // 40MB if the server doesn't give us the expected size let totalBytes = downloadProgress.totalBytesToWrite ?? 40_000_000 let downloadPercent = min(1.0, Float(downloadProgress.totalBytesWritten) / Float(totalBytes)) - return 0.10 + 0.6 * downloadPercent + return 0.05 + 0.4 * downloadPercent case .validating: - return 0.71 + return 0.42 case .removingQuarantine: - return 0.72 + return 0.44 case .opening: - return 0.73 + return 0.46 case .settingUpTunnel: - return 0.74 + return 0.48 case .startingTunnel: - return 0.75 + return 0.50 } } } diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift index fb3928f6..33fa71c5 100644 --- a/Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift @@ -33,7 +33,9 @@ struct Agents: View { if hasToggledExpansion { return } - expandedItem = visibleItems.first?.id + withAnimation(.snappy(duration: Theme.Animation.collapsibleDuration)) { + expandedItem = visibleItems.first?.id + } hasToggledExpansion = true } if items.count == 0 { diff --git a/Coder-Desktop/VPNLib/Download.swift b/Coder-Desktop/VPNLib/Download.swift index d1022098..2cc32b97 100644 --- a/Coder-Desktop/VPNLib/Download.swift +++ b/Coder-Desktop/VPNLib/Download.swift @@ -238,6 +238,7 @@ extension DownloadManager: URLSessionDownloadDelegate { try FileManager.default.moveItem(at: location, to: dest) } catch { continuation.resume(throwing: DownloadError.fileOpError(error)) + return } continuation.resume() @@ -278,7 +279,8 @@ extension DownloadManager: URLSessionDownloadDelegate { override public var description: String { let fmt = ByteCountFormatter() let done = fmt.string(fromByteCount: totalBytesWritten) - let tot = totalBytesToWrite.map { fmt.string(fromByteCount: $0) } ?? "Unknown" - return "\(done) / \(tot)" + .padding(toLength: 7, withPad: " ", startingAt: 0) + let total = totalBytesToWrite.map { fmt.string(fromByteCount: $0) } ?? "Unknown" + return "\(done) / \(total)" } } From 26eaa889063120232553289e5ba1ceb18e99422b Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Mon, 19 May 2025 16:03:02 +1000 Subject: [PATCH 08/12] throttle --- Coder-Desktop/VPNLib/Download.swift | 11 ++++++++--- Coder-Desktop/VPNLib/Util.swift | 29 +++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/Coder-Desktop/VPNLib/Download.swift b/Coder-Desktop/VPNLib/Download.swift index 2cc32b97..38909ff6 100644 --- a/Coder-Desktop/VPNLib/Download.swift +++ b/Coder-Desktop/VPNLib/Download.swift @@ -129,9 +129,14 @@ public func download( src: URL, dest: URL, urlSession: URLSession, - progressUpdates: ((DownloadProgress) -> Void)? = nil + progressUpdates: (@Sendable (DownloadProgress) -> Void)? = nil ) async throws(DownloadError) { - try await DownloadManager().download(src: src, dest: dest, urlSession: urlSession, progressUpdates: progressUpdates) + try await DownloadManager().download( + src: src, + dest: dest, + urlSession: urlSession, + progressUpdates: progressUpdates.flatMap { throttle(interval: .milliseconds(10), $0) }, + ) } func etag(data: Data) -> String { @@ -173,7 +178,7 @@ private final class DownloadManager: NSObject, @unchecked Sendable { src: URL, dest: URL, urlSession: URLSession, - progressUpdates: ((DownloadProgress) -> Void)? + progressUpdates: (@Sendable (DownloadProgress) -> Void)? ) async throws(DownloadError) { var req = URLRequest(url: src) if FileManager.default.fileExists(atPath: dest.path) { diff --git a/Coder-Desktop/VPNLib/Util.swift b/Coder-Desktop/VPNLib/Util.swift index fd9bbc3f..9ce03766 100644 --- a/Coder-Desktop/VPNLib/Util.swift +++ b/Coder-Desktop/VPNLib/Util.swift @@ -29,3 +29,32 @@ public func makeNSError(suffix: String, code: Int = -1, desc: String) -> NSError userInfo: [NSLocalizedDescriptionKey: desc] ) } + +private actor Throttler { + 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( + 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) } + } +} From 59360214da16b9b0875c60fc20c26eef31b4d113 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Mon, 19 May 2025 16:19:33 +1000 Subject: [PATCH 09/12] not being able to sync swift versions strikes again! --- Coder-Desktop/VPNLib/Download.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Coder-Desktop/VPNLib/Download.swift b/Coder-Desktop/VPNLib/Download.swift index 38909ff6..99febc29 100644 --- a/Coder-Desktop/VPNLib/Download.swift +++ b/Coder-Desktop/VPNLib/Download.swift @@ -135,7 +135,7 @@ public func download( src: src, dest: dest, urlSession: urlSession, - progressUpdates: progressUpdates.flatMap { throttle(interval: .milliseconds(10), $0) }, + progressUpdates: progressUpdates.flatMap { throttle(interval: .milliseconds(10), $0) } ) } From 56d7e1f1438d36deac7b3b453ce377add2cde8c1 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 20 May 2025 13:40:52 +1000 Subject: [PATCH 10/12] remove two stages --- Coder-Desktop/Coder-Desktop/VPN/VPNProgress.swift | 10 +++------- Coder-Desktop/VPN/Manager.swift | 2 -- Coder-Desktop/VPNLib/XPC.swift | 6 ------ 3 files changed, 3 insertions(+), 15 deletions(-) diff --git a/Coder-Desktop/Coder-Desktop/VPN/VPNProgress.swift b/Coder-Desktop/Coder-Desktop/VPN/VPNProgress.swift index 6d2cd6d6..67535290 100644 --- a/Coder-Desktop/Coder-Desktop/VPN/VPNProgress.swift +++ b/Coder-Desktop/Coder-Desktop/VPN/VPNProgress.swift @@ -42,7 +42,7 @@ struct VPNProgressView: View { } switch progress.stage { case .initial: - return 0.05 + return 0 case .downloading: guard let downloadProgress = progress.downloadProgress else { // We can't make this illegal state unrepresentable because XPC @@ -52,15 +52,11 @@ struct VPNProgressView: View { // 40MB if the server doesn't give us the expected size let totalBytes = downloadProgress.totalBytesToWrite ?? 40_000_000 let downloadPercent = min(1.0, Float(downloadProgress.totalBytesWritten) / Float(totalBytes)) - return 0.05 + 0.4 * downloadPercent + return 0.4 * downloadPercent case .validating: - return 0.42 + return 0.43 case .removingQuarantine: - return 0.44 - case .opening: return 0.46 - case .settingUpTunnel: - return 0.48 case .startingTunnel: return 0.50 } diff --git a/Coder-Desktop/VPN/Manager.swift b/Coder-Desktop/VPN/Manager.swift index 4559e4d5..649a1612 100644 --- a/Coder-Desktop/VPN/Manager.swift +++ b/Coder-Desktop/VPN/Manager.swift @@ -67,13 +67,11 @@ actor Manager { // so it's safe to execute. However, the SE must be sandboxed, so we defer to the app. try await removeQuarantine(dest) - pushProgress(stage: .opening) do { try tunnelHandle = TunnelHandle(dylibPath: dest) } catch { throw .tunnelSetup(error) } - pushProgress(stage: .settingUpTunnel) speaker = await Speaker( writeFD: tunnelHandle.writeHandle, readFD: tunnelHandle.readHandle diff --git a/Coder-Desktop/VPNLib/XPC.swift b/Coder-Desktop/VPNLib/XPC.swift index 9464ea29..baea7fe9 100644 --- a/Coder-Desktop/VPNLib/XPC.swift +++ b/Coder-Desktop/VPNLib/XPC.swift @@ -19,8 +19,6 @@ import Foundation case downloading case validating case removingQuarantine - case opening - case settingUpTunnel case startingTunnel public var description: String? { @@ -33,10 +31,6 @@ import Foundation "Validating library..." case .removingQuarantine: "Removing quarantine..." - case .opening: - "Opening library..." - case .settingUpTunnel: - "Setting up tunnel..." case .startingTunnel: nil } From c2728dd6da87b672df91289bb05b963d081a0095 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 20 May 2025 14:00:39 +1000 Subject: [PATCH 11/12] fixup --- Coder-Desktop/Coder-Desktop/VPN/VPNProgress.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Coder-Desktop/Coder-Desktop/VPN/VPNProgress.swift b/Coder-Desktop/Coder-Desktop/VPN/VPNProgress.swift index 67535290..5b660be6 100644 --- a/Coder-Desktop/Coder-Desktop/VPN/VPNProgress.swift +++ b/Coder-Desktop/Coder-Desktop/VPN/VPNProgress.swift @@ -20,7 +20,6 @@ struct VPNProgressView: View { .multilineTextAlignment(.center) } .padding() - .progressViewStyle(.circular) .foregroundStyle(.secondary) } From 59d3affe2e868a69ad23bc008df610df86e78147 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 22 May 2025 12:13:38 +1000 Subject: [PATCH 12/12] size --- Coder-Desktop/Coder-Desktop/VPN/VPNProgress.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Coder-Desktop/Coder-Desktop/VPN/VPNProgress.swift b/Coder-Desktop/Coder-Desktop/VPN/VPNProgress.swift index 5b660be6..56593b20 100644 --- a/Coder-Desktop/Coder-Desktop/VPN/VPNProgress.swift +++ b/Coder-Desktop/Coder-Desktop/VPN/VPNProgress.swift @@ -48,8 +48,8 @@ struct VPNProgressView: View { // doesn't support enums with associated values. return 0.05 } - // 40MB if the server doesn't give us the expected size - let totalBytes = downloadProgress.totalBytesToWrite ?? 40_000_000 + // 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: