Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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 estimate that the last half takes 8 seconds
// so it doesn't appear stuck
.autoComplete(threshold: 0.5, duration: 8)
Text(progressMessage)
.multilineTextAlignment(.center)
}
.padding()
.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.05
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.05 + 0.4 * downloadPercent
case .validating:
return 0.42
case .removingQuarantine:
return 0.44
case .opening:
return 0.46
case .settingUpTunnel:
return 0.48
case .startingTunnel:
return 0.50
}
}
}
16 changes: 15 additions & 1 deletion Coder-Desktop/Coder-Desktop/VPN/VPNService.swift
Original file line number Diff line number Diff line change
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: 3 additions & 1 deletion Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ struct Agents<VPN: VPNService>: View {
if hasToggledExpansion {
return
}
expandedItem = visibleItems.first?.id
withAnimation(.snappy(duration: Theme.Animation.collapsibleDuration)) {
expandedItem = visibleItems.first?.id
}
hasToggledExpansion = true
}
if items.count == 0 {
Expand Down
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