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: pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy