Skip to content

feat: add coder connect startup progress indicator #161

New issue

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

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

Already on GitHub? Sign in to your account

Draft
wants to merge 6 commits into
base: ethan/add-privileged-helper
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

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

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

var startTask: Task<Void, Never>?
func start() async {
if await startTask?.value != nil {
Expand Down
68 changes: 68 additions & 0 deletions Coder-Desktop/Coder-Desktop/VPN/VPNProgress.swift
Original file line number Diff line number Diff line change
@@ -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 .initial:
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
}
}
}
16 changes: 15 additions & 1 deletion Coder-Desktop/Coder-Desktop/VPN/VPNService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import VPNLib
protocol VPNService: ObservableObject {
var state: VPNServiceState { get }
var menuState: VPNMenuState { get }
var progress: VPNProgress { get }
func start() async
func stop() async
func configureTunnelProviderProtocol(proto: NETunnelProviderProtocol?)
Expand Down Expand Up @@ -55,7 +56,14 @@ final class CoderVPNService: NSObject, VPNService {
var logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "vpn")
lazy var xpc: VPNXPCInterface = .init(vpn: self)

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

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

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

@Published var menuState: VPNMenuState = .init()

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

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

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

struct CircularProgressView: View {
let value: Float?

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

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

var autoCompleteThreshold: Float?
var autoCompleteDuration: TimeInterval?

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

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

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

return .easeOut(duration: duration)
}
}

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

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

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

Expand Down
6 changes: 2 additions & 4 deletions Coder-Desktop/Coder-DesktopTests/VPNStateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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...")
}
}
}
Expand All @@ -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...")
}
}
}
Expand Down
23 changes: 22 additions & 1 deletion Coder-Desktop/VPN/Manager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,18 @@ actor Manager {
// Timeout after 5 minutes, or if there's no data for 60 seconds
sessionConfig.timeoutIntervalForRequest = 60
sessionConfig.timeoutIntervalForResource = 300
try await download(src: dylibPath, dest: dest, urlSession: URLSession(configuration: sessionConfig))
try await download(
src: dylibPath,
dest: dest,
urlSession: URLSession(configuration: sessionConfig)
) { progress in
// TODO: Debounce, somehow
pushProgress(stage: .downloading, downloadProgress: progress)
}
} catch {
throw .download(error)
}
pushProgress(stage: .validating)
let client = Client(url: cfg.serverUrl)
let buildInfo: BuildInfoResponse
do {
Expand All @@ -59,11 +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(stage: .opening)
do {
try tunnelHandle = TunnelHandle(dylibPath: dest)
} catch {
throw .tunnelSetup(error)
}
pushProgress(stage: .settingUpTunnel)
speaker = await Speaker<Vpn_ManagerMessage, Vpn_TunnelMessage>(
writeFD: tunnelHandle.writeHandle,
readFD: tunnelHandle.readHandle
Expand Down Expand Up @@ -158,6 +168,7 @@ actor Manager {
}

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

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

struct ManagerConfig {
let apiToken: String
let serverUrl: URL
Expand Down Expand Up @@ -312,6 +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(stage: .removingQuarantine)
// Try the privileged helper first (it may not even be registered)
if await globalHelperXPCSpeaker.tryRemoveQuarantine(path: dest.path) {
// Success!
Expand Down
Loading
Loading