Skip to content

Commit ae6eb95

Browse files
committed
progress gauge
1 parent 4d87c0c commit ae6eb95

File tree

10 files changed

+223
-39
lines changed

10 files changed

+223
-39
lines changed

Coder-Desktop/Coder-Desktop/Preview Content/PreviewVPN.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ final class PreviewVPN: Coder_Desktop.VPNService {
3333
self.shouldFail = shouldFail
3434
}
3535

36-
@Published var progressMessage: String?
36+
@Published var progress: VPNProgress = .init(stage: .none, downloadProgress: nil)
3737

3838
var startTask: Task<Void, Never>?
3939
func start() async {
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import SwiftUI
2+
import VPNLib
3+
4+
struct VPNProgress {
5+
let stage: ProgressStage
6+
let downloadProgress: DownloadProgress?
7+
}
8+
9+
struct VPNProgressView: View {
10+
let state: VPNServiceState
11+
let progress: VPNProgress
12+
13+
var body: some View {
14+
VStack {
15+
CircularProgressView(value: value)
16+
// We'll estimate that the last 25% takes 9 seconds
17+
// so it doesn't appear stuck
18+
.autoComplete(threshold: 0.75, duration: 9)
19+
Text(progressMessage)
20+
.multilineTextAlignment(.center)
21+
}
22+
.padding()
23+
.progressViewStyle(.circular)
24+
.foregroundStyle(.secondary)
25+
}
26+
27+
var progressMessage: String {
28+
"\(progress.stage.description ?? defaultMessage)\(downloadProgressMessage)"
29+
}
30+
31+
var downloadProgressMessage: String {
32+
progress.downloadProgress.flatMap { "\n\($0.description)" } ?? ""
33+
}
34+
35+
var defaultMessage: String {
36+
state == .connecting ? "Starting Coder Connect..." : "Stopping Coder Connect..."
37+
}
38+
39+
var value: Float? {
40+
guard state == .connecting else {
41+
return nil
42+
}
43+
switch progress.stage {
44+
case .none:
45+
return 0.10
46+
case .downloading:
47+
guard let downloadProgress = progress.downloadProgress else {
48+
// We can't make this illegal state unrepresentable because XPC
49+
// doesn't support enums with associated values.
50+
return 0.05
51+
}
52+
// 40MB if the server doesn't give us the expected size
53+
let totalBytes = downloadProgress.totalBytesToWrite ?? 40_000_000
54+
let downloadPercent = min(1.0, Float(downloadProgress.totalBytesWritten) / Float(totalBytes))
55+
return 0.10 + 0.6 * downloadPercent
56+
case .validating:
57+
return 0.71
58+
case .removingQuarantine:
59+
return 0.72
60+
case .opening:
61+
return 0.73
62+
case .settingUpTunnel:
63+
return 0.74
64+
case .startingTunnel:
65+
return 0.75
66+
}
67+
}
68+
}

Coder-Desktop/Coder-Desktop/VPN/VPNService.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import VPNLib
77
protocol VPNService: ObservableObject {
88
var state: VPNServiceState { get }
99
var menuState: VPNMenuState { get }
10-
var progressMessage: String? { get }
10+
var progress: VPNProgress { get }
1111
func start() async
1212
func stop() async
1313
func configureTunnelProviderProtocol(proto: NETunnelProviderProtocol?)
@@ -73,7 +73,7 @@ final class CoderVPNService: NSObject, VPNService {
7373
return tunnelState
7474
}
7575

76-
@Published var progressMessage: String?
76+
@Published var progress: VPNProgress = .init(stage: .none, downloadProgress: nil)
7777

7878
@Published var menuState: VPNMenuState = .init()
7979

@@ -158,8 +158,8 @@ final class CoderVPNService: NSObject, VPNService {
158158
}
159159
}
160160

161-
func onProgress(_ msg: String?) {
162-
progressMessage = msg
161+
func onProgress(stage: ProgressStage, downloadProgress: DownloadProgress?) {
162+
progress = .init(stage: stage, downloadProgress: downloadProgress)
163163
}
164164

165165
func applyPeerUpdate(with update: Vpn_PeerUpdate) {
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import SwiftUI
2+
3+
struct CircularProgressView: View {
4+
let value: Float?
5+
6+
var strokeWidth: CGFloat = 4
7+
var diameter: CGFloat = 22
8+
var primaryColor: Color = .secondary
9+
var backgroundColor: Color = .secondary.opacity(0.3)
10+
11+
@State private var rotation = 0.0
12+
@State private var trimAmount: CGFloat = 0.15
13+
14+
var autoCompleteThreshold: Float?
15+
var autoCompleteDuration: TimeInterval?
16+
17+
var body: some View {
18+
ZStack {
19+
// Background circle
20+
Circle()
21+
.stroke(backgroundColor, style: StrokeStyle(lineWidth: strokeWidth, lineCap: .round))
22+
.frame(width: diameter, height: diameter)
23+
Group {
24+
if let value {
25+
// Determinate gauge
26+
Circle()
27+
.trim(from: 0, to: CGFloat(displayValue(for: value)))
28+
.stroke(primaryColor, style: StrokeStyle(lineWidth: strokeWidth, lineCap: .round))
29+
.frame(width: diameter, height: diameter)
30+
.rotationEffect(.degrees(-90))
31+
.animation(autoCompleteAnimation(for: value), value: value)
32+
} else {
33+
// Indeterminate gauge
34+
Circle()
35+
.trim(from: 0, to: trimAmount)
36+
.stroke(primaryColor, style: StrokeStyle(lineWidth: strokeWidth, lineCap: .round))
37+
.frame(width: diameter, height: diameter)
38+
.rotationEffect(.degrees(rotation))
39+
}
40+
}
41+
}
42+
.frame(width: diameter + strokeWidth * 2, height: diameter + strokeWidth * 2)
43+
.onAppear {
44+
if value == nil {
45+
withAnimation(.linear(duration: 0.8).repeatForever(autoreverses: false)) {
46+
rotation = 360
47+
}
48+
}
49+
}
50+
}
51+
52+
private func displayValue(for value: Float) -> Float {
53+
if let threshold = autoCompleteThreshold,
54+
value >= threshold, value < 1.0
55+
{
56+
return 1.0
57+
}
58+
return value
59+
}
60+
61+
private func autoCompleteAnimation(for value: Float) -> Animation? {
62+
guard let threshold = autoCompleteThreshold,
63+
let duration = autoCompleteDuration,
64+
value >= threshold, value < 1.0
65+
else {
66+
return .default
67+
}
68+
69+
return .easeOut(duration: duration)
70+
}
71+
}
72+
73+
extension CircularProgressView {
74+
func autoComplete(threshold: Float, duration: TimeInterval) -> CircularProgressView {
75+
var view = self
76+
view.autoCompleteThreshold = threshold
77+
view.autoCompleteDuration = duration
78+
return view
79+
}
80+
}

Coder-Desktop/Coder-Desktop/Views/VPN/VPNState.swift

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,6 @@ struct VPNState<VPN: VPNService>: View {
66

77
let inspection = Inspection<Self>()
88

9-
var progressMessage: String {
10-
if let msg = vpn.progressMessage {
11-
msg
12-
} else {
13-
vpn.state == .connecting ? "Starting Coder Connect..." : "Stopping Coder Connect..."
14-
}
15-
}
16-
179
var body: some View {
1810
Group {
1911
switch (vpn.state, state.hasSession) {
@@ -36,11 +28,7 @@ struct VPNState<VPN: VPNService>: View {
3628
case (.connecting, _), (.disconnecting, _):
3729
HStack {
3830
Spacer()
39-
ProgressView {
40-
Text(progressMessage)
41-
.multilineTextAlignment(.center)
42-
}
43-
.padding()
31+
VPNProgressView(state: vpn.state, progress: vpn.progress)
4432
Spacer()
4533
}
4634
case let (.failed(vpnErr), _):

Coder-Desktop/Coder-Desktop/XPCInterface.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,9 @@ import VPNLib
7171
}
7272
}
7373

74-
func onProgress(msg: String?) {
74+
func onProgress(stage: ProgressStage, downloadProgress: DownloadProgress?) {
7575
Task { @MainActor in
76-
svc.onProgress(msg)
76+
svc.onProgress(stage: stage, downloadProgress: downloadProgress)
7777
}
7878
}
7979

Coder-Desktop/VPN/Manager.swift

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,13 @@ actor Manager {
4040
dest: dest,
4141
urlSession: URLSession(configuration: sessionConfig)
4242
) { progress in
43-
pushProgress(msg: "Downloading library...\n\(progress.description)")
43+
// TODO: Debounce, somehow
44+
pushProgress(stage: .downloading, downloadProgress: progress)
4445
}
4546
} catch {
4647
throw .download(error)
4748
}
48-
pushProgress(msg: "Fetching server version...")
49+
pushProgress(stage: .validating)
4950
let client = Client(url: cfg.serverUrl)
5051
let buildInfo: BuildInfoResponse
5152
do {
@@ -56,7 +57,6 @@ actor Manager {
5657
guard let semver = buildInfo.semver else {
5758
throw .serverInfo("invalid version: \(buildInfo.version)")
5859
}
59-
pushProgress(msg: "Validating library...")
6060
do {
6161
try SignatureValidator.validate(path: dest, expectedVersion: semver)
6262
} catch {
@@ -67,13 +67,13 @@ actor Manager {
6767
// so it's safe to execute. However, the SE must be sandboxed, so we defer to the app.
6868
try await removeQuarantine(dest)
6969

70-
pushProgress(msg: "Opening library...")
70+
pushProgress(stage: .opening)
7171
do {
7272
try tunnelHandle = TunnelHandle(dylibPath: dest)
7373
} catch {
7474
throw .tunnelSetup(error)
7575
}
76-
pushProgress(msg: "Setting up tunnel...")
76+
pushProgress(stage: .settingUpTunnel)
7777
speaker = await Speaker<Vpn_ManagerMessage, Vpn_TunnelMessage>(
7878
writeFD: tunnelHandle.writeHandle,
7979
readFD: tunnelHandle.readHandle
@@ -168,8 +168,7 @@ actor Manager {
168168
}
169169

170170
func startVPN() async throws(ManagerError) {
171-
// Clear progress message
172-
pushProgress(msg: nil)
171+
pushProgress(stage: .startingTunnel)
173172
logger.info("sending start rpc")
174173
guard let tunFd = ptp.tunnelFileDescriptor else {
175174
logger.error("no fd")
@@ -246,13 +245,13 @@ actor Manager {
246245
}
247246
}
248247

249-
func pushProgress(msg: String?) {
248+
func pushProgress(stage: ProgressStage, downloadProgress: DownloadProgress? = nil) {
250249
guard let conn = globalXPCListenerDelegate.conn else {
251250
logger.warning("couldn't send progress message to app: no connection")
252251
return
253252
}
254-
logger.debug("sending progress message to app: \(msg ?? "nil")")
255-
conn.onProgress(msg: msg)
253+
logger.debug("sending progress message to app")
254+
conn.onProgress(stage: stage, downloadProgress: downloadProgress)
256255
}
257256

258257
struct ManagerConfig {
@@ -333,7 +332,7 @@ private func removeQuarantine(_ dest: URL) async throws(ManagerError) {
333332
let file = NSURL(fileURLWithPath: dest.path)
334333
try? file.getResourceValue(&flag, forKey: kCFURLQuarantinePropertiesKey as URLResourceKey)
335334
if flag != nil {
336-
pushProgress(msg: "Unquarantining download...")
335+
pushProgress(stage: .removingQuarantine)
337336
// Try the privileged helper first (it may not even be registered)
338337
if await globalHelperXPCSpeaker.tryRemoveQuarantine(path: dest.path) {
339338
// Success!

Coder-Desktop/VPN/PacketTunnelProvider.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
9393
self.manager = manager
9494
completionHandler(nil)
9595
// Clear progress message
96-
pushProgress(msg: nil)
96+
pushProgress(stage: .none, downloadProgress: nil)
9797
} catch {
9898
logger.error("error starting manager: \(error.description, privacy: .public)")
9999
completionHandler(

Coder-Desktop/VPNLib/Download.swift

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -251,14 +251,34 @@ extension DownloadManager: URLSessionDownloadDelegate {
251251
}
252252
}
253253

254-
public struct DownloadProgress: Sendable, CustomStringConvertible {
255-
let totalBytesWritten: Int64
256-
let totalBytesToWrite: Int64?
254+
@objc public final class DownloadProgress: NSObject, NSSecureCoding, @unchecked Sendable {
255+
public static var supportsSecureCoding: Bool { true }
257256

258-
public var description: String {
257+
public let totalBytesWritten: Int64
258+
public let totalBytesToWrite: Int64?
259+
260+
public init(totalBytesWritten: Int64, totalBytesToWrite: Int64?) {
261+
self.totalBytesWritten = totalBytesWritten
262+
self.totalBytesToWrite = totalBytesToWrite
263+
}
264+
265+
public required convenience init?(coder: NSCoder) {
266+
let written = coder.decodeInt64(forKey: "written")
267+
let total = coder.containsValue(forKey: "total") ? coder.decodeInt64(forKey: "total") : nil
268+
self.init(totalBytesWritten: written, totalBytesToWrite: total)
269+
}
270+
271+
public func encode(with coder: NSCoder) {
272+
coder.encode(totalBytesWritten, forKey: "written")
273+
if let total = totalBytesToWrite {
274+
coder.encode(total, forKey: "total")
275+
}
276+
}
277+
278+
override public var description: String {
259279
let fmt = ByteCountFormatter()
260280
let done = fmt.string(fromByteCount: totalBytesWritten)
261-
let total = totalBytesToWrite.map { fmt.string(fromByteCount: $0) } ?? "Unknown"
262-
return "\(done) / \(total)"
281+
let tot = totalBytesToWrite.map { fmt.string(fromByteCount: $0) } ?? "Unknown"
282+
return "\(done) / \(tot)"
263283
}
264284
}

Coder-Desktop/VPNLib/XPC.swift

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,35 @@ import Foundation
1010
@objc public protocol VPNXPCClientCallbackProtocol {
1111
// data is a serialized `Vpn_PeerUpdate`
1212
func onPeerUpdate(_ data: Data)
13-
func onProgress(msg: String?)
13+
func onProgress(stage: ProgressStage, downloadProgress: DownloadProgress?)
1414
func removeQuarantine(path: String, reply: @escaping (Bool) -> Void)
1515
}
16+
17+
@objc public enum ProgressStage: Int, Sendable {
18+
case none
19+
case downloading
20+
case validating
21+
case removingQuarantine
22+
case opening
23+
case settingUpTunnel
24+
case startingTunnel
25+
26+
public var description: String? {
27+
switch self {
28+
case .none:
29+
nil
30+
case .downloading:
31+
"Downloading library..."
32+
case .validating:
33+
"Validating library..."
34+
case .removingQuarantine:
35+
"Removing quarantine..."
36+
case .opening:
37+
"Opening library..."
38+
case .settingUpTunnel:
39+
"Setting up tunnel..."
40+
case .startingTunnel:
41+
nil
42+
}
43+
}
44+
}

0 commit comments

Comments
 (0)