Skip to content

Commit 3470cf0

Browse files
committed
feat: pass agent updates to UI
1 parent 15f2bcc commit 3470cf0

20 files changed

+390
-294
lines changed

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

+17-16
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,22 @@ import SwiftUI
44
@MainActor
55
final class PreviewVPN: Coder_Desktop.VPNService {
66
@Published var state: Coder_Desktop.VPNServiceState = .disabled
7-
@Published var agents: [Coder_Desktop.Agent] = [
8-
Agent(id: UUID(), name: "dogfood2", status: .error, copyableDNS: "asdf.coder", workspaceName: "dogfood2"),
9-
Agent(id: UUID(), name: "testing-a-very-long-name", status: .okay, copyableDNS: "asdf.coder",
10-
workspaceName: "testing-a-very-long-name"),
11-
Agent(id: UUID(), name: "opensrc", status: .warn, copyableDNS: "asdf.coder", workspaceName: "opensrc"),
12-
Agent(id: UUID(), name: "gvisor", status: .off, copyableDNS: "asdf.coder", workspaceName: "gvisor"),
13-
Agent(id: UUID(), name: "example", status: .off, copyableDNS: "asdf.coder", workspaceName: "example"),
14-
Agent(id: UUID(), name: "dogfood2", status: .error, copyableDNS: "asdf.coder", workspaceName: "dogfood2"),
15-
Agent(id: UUID(), name: "testing-a-very-long-name", status: .okay, copyableDNS: "asdf.coder",
16-
workspaceName: "testing-a-very-long-name"),
17-
Agent(id: UUID(), name: "opensrc", status: .warn, copyableDNS: "asdf.coder", workspaceName: "opensrc"),
18-
Agent(id: UUID(), name: "gvisor", status: .off, copyableDNS: "asdf.coder", workspaceName: "gvisor"),
19-
Agent(id: UUID(), name: "example", status: .off, copyableDNS: "asdf.coder", workspaceName: "example"),
7+
@Published var agents: [UUID: Coder_Desktop.Agent] = [
8+
UUID(): Agent(id: UUID(), status: .error, copyableDNS: "asdf.coder", wsName: "dogfood2", wsID: UUID()),
9+
UUID(): Agent(id: UUID(), status: .okay, copyableDNS: "asdf.coder", wsName: "testing-a-very-long-name",
10+
wsID: UUID()),
11+
UUID(): Agent(id: UUID(), status: .warn, copyableDNS: "asdf.coder", wsName: "opensrc", wsID: UUID()),
12+
UUID(): Agent(id: UUID(), status: .off, copyableDNS: "asdf.coder", wsName: "gvisor", wsID: UUID()),
13+
UUID(): Agent(id: UUID(), status: .off, copyableDNS: "asdf.coder", wsName: "example", wsID: UUID()),
14+
UUID(): Agent(id: UUID(), status: .error, copyableDNS: "asdf.coder", wsName: "dogfood2", wsID: UUID()),
15+
UUID(): Agent(id: UUID(), status: .okay, copyableDNS: "asdf.coder", wsName: "testing-a-very-long-name",
16+
wsID: UUID()),
17+
UUID(): Agent(id: UUID(), status: .warn, copyableDNS: "asdf.coder", wsName: "opensrc", wsID: UUID()),
18+
UUID(): Agent(id: UUID(), status: .off, copyableDNS: "asdf.coder", wsName: "gvisor", wsID: UUID()),
19+
UUID(): Agent(id: UUID(), status: .off, copyableDNS: "asdf.coder", wsName: "example", wsID: UUID()),
2020
]
2121
let shouldFail: Bool
22+
let longError = "This is a long error to test the UI with long error messages"
2223

2324
init(shouldFail: Bool = false) {
2425
self.shouldFail = shouldFail
@@ -35,10 +36,10 @@ final class PreviewVPN: Coder_Desktop.VPNService {
3536
do {
3637
try await Task.sleep(for: .seconds(5))
3738
} catch {
38-
state = .failed(.longTestError)
39+
state = .failed(.internalError(longError))
3940
return
4041
}
41-
state = shouldFail ? .failed(.longTestError) : .connected
42+
state = shouldFail ? .failed(.internalError(longError)) : .connected
4243
}
4344
defer { startTask = nil }
4445
await startTask?.value
@@ -57,7 +58,7 @@ final class PreviewVPN: Coder_Desktop.VPNService {
5758
do {
5859
try await Task.sleep(for: .seconds(5))
5960
} catch {
60-
state = .failed(.longTestError)
61+
state = .failed(.internalError(longError))
6162
return
6263
}
6364
state = .disabled

Coder Desktop/Coder Desktop/VPNService.swift

+85-24
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,12 @@ import NetworkExtension
22
import os
33
import SwiftUI
44
import VPNLib
5-
import VPNXPC
65

76
@MainActor
87
protocol VPNService: ObservableObject {
98
var state: VPNServiceState { get }
10-
var agents: [Agent] { get }
9+
var agents: [UUID: Agent] { get }
1110
func start() async
12-
// Stop must be idempotent
1311
func stop() async
1412
func configureTunnelProviderProtocol(proto: NETunnelProviderProtocol?)
1513
}
@@ -26,12 +24,9 @@ enum VPNServiceError: Error, Equatable {
2624
case internalError(String)
2725
case systemExtensionError(SystemExtensionState)
2826
case networkExtensionError(NetworkExtensionState)
29-
case longTestError
3027

3128
var description: String {
3229
switch self {
33-
case .longTestError:
34-
"This is a long error to test the UI with long errors"
3530
case let .internalError(description):
3631
"Internal Error: \(description)"
3732
case let .systemExtensionError(state):
@@ -47,6 +42,7 @@ final class CoderVPNService: NSObject, VPNService {
4742
var logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "vpn")
4843
lazy var xpc: VPNXPCInterface = .init(vpn: self)
4944
var terminating = false
45+
var workspaces: [UUID: String] = [:]
5046

5147
@Published var tunnelState: VPNServiceState = .disabled
5248
@Published var sysExtnState: SystemExtensionState = .uninstalled
@@ -61,7 +57,7 @@ final class CoderVPNService: NSObject, VPNService {
6157
return tunnelState
6258
}
6359

64-
@Published var agents: [Agent] = []
60+
@Published var agents: [UUID: Agent] = [:]
6561

6662
// systemExtnDelegate holds a reference to the SystemExtensionDelegate so that it doesn't get
6763
// garbage collected while the OSSystemExtensionRequest is in flight, since the OS framework
@@ -74,6 +70,16 @@ final class CoderVPNService: NSObject, VPNService {
7470
Task {
7571
await loadNetworkExtension()
7672
}
73+
NotificationCenter.default.addObserver(
74+
self,
75+
selector: #selector(vpnDidUpdate(_:)),
76+
name: .NEVPNStatusDidChange,
77+
object: nil
78+
)
79+
}
80+
81+
deinit {
82+
NotificationCenter.default.removeObserver(self)
7783
}
7884

7985
func start() async {
@@ -84,16 +90,14 @@ final class CoderVPNService: NSObject, VPNService {
8490
return
8591
}
8692

93+
await enableNetworkExtension()
8794
// this ping is somewhat load bearing since it causes xpc to init
8895
xpc.ping()
89-
tunnelState = .connecting
90-
await enableNetworkExtension()
9196
logger.debug("network extension enabled")
9297
}
9398

9499
func stop() async {
95100
guard tunnelState == .connected else { return }
96-
tunnelState = .disconnecting
97101
await disableNetworkExtension()
98102
logger.info("network extension stopped")
99103
}
@@ -131,31 +135,88 @@ final class CoderVPNService: NSObject, VPNService {
131135
}
132136

133137
func onExtensionPeerUpdate(_ data: Data) {
134-
// TODO: handle peer update
135138
logger.info("network extension peer update")
136139
do {
137-
let msg = try Vpn_TunnelMessage(serializedBytes: data)
140+
let msg = try Vpn_PeerUpdate(serializedBytes: data)
138141
debugPrint(msg)
142+
applyPeerUpdate(with: msg)
139143
} catch {
140144
logger.error("failed to decode peer update \(error)")
141145
}
142146
}
143147

144-
func onExtensionStart() {
145-
logger.info("network extension reported started")
146-
tunnelState = .connected
147-
}
148+
func applyPeerUpdate(with update: Vpn_PeerUpdate) {
149+
// Delete agents
150+
let deletedWorkspaceIDs = Set(update.deletedWorkspaces.compactMap { UUID(uuidData: $0.id) })
151+
let deletedAgentIDs = Set(update.deletedAgents.compactMap { UUID(uuidData: $0.id) })
152+
for agentID in deletedAgentIDs {
153+
agents[agentID] = nil
154+
}
155+
for workspaceID in deletedWorkspaceIDs {
156+
workspaces[workspaceID] = nil
157+
for (id, agent) in agents where agent.wsID == workspaceID {
158+
agents[id] = nil
159+
}
160+
}
148161

149-
func onExtensionStop() {
150-
logger.info("network extension reported stopped")
151-
tunnelState = .disabled
152-
if terminating {
153-
NSApp.reply(toApplicationShouldTerminate: true)
162+
// Update workspaces
163+
for workspaceProto in update.upsertedWorkspaces {
164+
if let workspaceID = UUID(uuidData: workspaceProto.id) {
165+
workspaces[workspaceID] = workspaceProto.name
166+
}
167+
}
168+
169+
for agentProto in update.upsertedAgents {
170+
guard let agentID = UUID(uuidData: agentProto.id) else {
171+
continue
172+
}
173+
guard let workspaceID = UUID(uuidData: agentProto.workspaceID) else {
174+
continue
175+
}
176+
let workspaceName = workspaces[workspaceID] ?? "Unknown Workspace"
177+
let newAgent = Agent(
178+
id: agentID,
179+
// If last handshake was not within last five minutes, the agent is unhealthy
180+
status: agentProto.lastHandshake.date > Date.now.addingTimeInterval(-300) ? .okay : .off,
181+
copyableDNS: agentProto.fqdn.first ?? "UNKNOWN",
182+
wsName: workspaceName,
183+
wsID: workspaceID
184+
)
185+
186+
agents[agentID] = newAgent
154187
}
155188
}
189+
}
156190

157-
func onExtensionError(_ error: NSError) {
158-
logger.error("network extension reported error: \(error)")
159-
tunnelState = .failed(.internalError(error.localizedDescription))
191+
extension CoderVPNService {
192+
@objc private func vpnDidUpdate(_ notification: Notification) {
193+
guard let connection = notification.object as? NETunnelProviderSession else {
194+
return
195+
}
196+
switch connection.status {
197+
case .disconnected:
198+
if terminating {
199+
NSApp.reply(toApplicationShouldTerminate: true)
200+
}
201+
connection.fetchLastDisconnectError { err in
202+
self.tunnelState = if let err {
203+
.failed(.internalError(err.localizedDescription))
204+
} else {
205+
.disabled
206+
}
207+
}
208+
case .connecting:
209+
tunnelState = .connecting
210+
case .connected:
211+
tunnelState = .connected
212+
case .reasserting:
213+
tunnelState = .connecting
214+
case .disconnecting:
215+
tunnelState = .disconnecting
216+
case .invalid:
217+
tunnelState = .failed(.networkExtensionError(.unconfigured))
218+
@unknown default:
219+
tunnelState = .disabled
220+
}
160221
}
161222
}

Coder Desktop/Coder Desktop/Views/Agent.swift

+26-14
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,26 @@
11
import SwiftUI
22

3-
struct Agent: Identifiable, Equatable {
3+
struct Agent: Identifiable, Equatable, Comparable {
44
let id: UUID
5-
let name: String
65
let status: AgentStatus
76
let copyableDNS: String
8-
let workspaceName: String
7+
let wsName: String
8+
let wsID: UUID
9+
10+
// Agents are sorted by status, and then by name
11+
static func < (lhs: Agent, rhs: Agent) -> Bool {
12+
if lhs.status != rhs.status {
13+
return lhs.status < rhs.status
14+
}
15+
return lhs.wsName.localizedCompare(rhs.wsName) == .orderedAscending
16+
}
917
}
1018

11-
enum AgentStatus: Equatable {
12-
case okay
13-
case warn
14-
case error
15-
case off
19+
enum AgentStatus: Int, Equatable, Comparable {
20+
case okay = 0
21+
case warn = 1
22+
case error = 2
23+
case off = 3
1624

1725
public var color: Color {
1826
switch self {
@@ -22,16 +30,20 @@ enum AgentStatus: Equatable {
2230
case .off: .gray
2331
}
2432
}
33+
34+
static func < (lhs: AgentStatus, rhs: AgentStatus) -> Bool {
35+
lhs.rawValue < rhs.rawValue
36+
}
2537
}
2638

2739
struct AgentRowView: View {
28-
let workspace: Agent
40+
let agent: Agent
2941
let baseAccessURL: URL
3042
@State private var nameIsSelected: Bool = false
3143
@State private var copyIsSelected: Bool = false
3244

3345
private var fmtWsName: AttributedString {
34-
var formattedName = AttributedString(workspace.name)
46+
var formattedName = AttributedString(agent.wsName)
3547
formattedName.foregroundColor = .primary
3648
var coderPart = AttributedString(".coder")
3749
coderPart.foregroundColor = .gray
@@ -41,7 +53,7 @@ struct AgentRowView: View {
4153

4254
private var wsURL: URL {
4355
// TODO: CoderVPN currently only supports owned workspaces
44-
baseAccessURL.appending(path: "@me").appending(path: workspace.workspaceName)
56+
baseAccessURL.appending(path: "@me").appending(path: agent.wsName)
4557
}
4658

4759
var body: some View {
@@ -50,10 +62,10 @@ struct AgentRowView: View {
5062
HStack(spacing: Theme.Size.trayPadding) {
5163
ZStack {
5264
Circle()
53-
.fill(workspace.status.color.opacity(0.4))
65+
.fill(agent.status.color.opacity(0.4))
5466
.frame(width: 12, height: 12)
5567
Circle()
56-
.fill(workspace.status.color.opacity(1.0))
68+
.fill(agent.status.color.opacity(1.0))
5769
.frame(width: 7, height: 7)
5870
}
5971
Text(fmtWsName).lineLimit(1).truncationMode(.tail)
@@ -69,7 +81,7 @@ struct AgentRowView: View {
6981
}.buttonStyle(.plain)
7082
Button {
7183
// TODO: Proper clipboard abstraction
72-
NSPasteboard.general.setString(workspace.copyableDNS, forType: .string)
84+
NSPasteboard.general.setString(agent.copyableDNS, forType: .string)
7385
} label: {
7486
Image(systemName: "doc.on.doc")
7587
.symbolVariant(.fill)

Coder Desktop/Coder Desktop/Views/Agents.swift

+5-4
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,12 @@ struct Agents<VPN: VPNService, S: Session>: View {
1010

1111
var body: some View {
1212
Group {
13-
// Workspaces List
13+
// Agents List
1414
if vpn.state == .connected {
15-
let visibleData = viewAll ? vpn.agents : Array(vpn.agents.prefix(defaultVisibleRows))
16-
ForEach(visibleData, id: \.id) { workspace in
17-
AgentRowView(workspace: workspace, baseAccessURL: session.baseAccessURL!)
15+
let sortedAgents = vpn.agents.values.sorted()
16+
let visibleData = viewAll ? sortedAgents[...] : sortedAgents.prefix(defaultVisibleRows)
17+
ForEach(visibleData, id: \.id) { agent in
18+
AgentRowView(agent: agent, baseAccessURL: session.baseAccessURL!)
1819
.padding(.horizontal, Theme.Size.trayMargin)
1920
}
2021
if vpn.agents.count > defaultVisibleRows {

Coder Desktop/Coder Desktop/Views/Util.swift

+13
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,16 @@ final class Inspection<V> {
1212
}
1313
}
1414
}
15+
16+
extension UUID {
17+
init?(uuidData: Data) {
18+
guard uuidData.count == 16 else {
19+
return nil
20+
}
21+
var uuid: uuid_t = (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
22+
withUnsafeMutableBytes(of: &uuid) {
23+
$0.copyBytes(from: uuidData)
24+
}
25+
self.init(uuid: uuid)
26+
}
27+
}

Coder Desktop/Coder Desktop/XPCInterface.swift

+2-19
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import Foundation
2+
import NetworkExtension
23
import os
3-
import VPNXPC
4+
import VPNLib
45

56
@objc final class VPNXPCInterface: NSObject, VPNXPCClientCallbackProtocol, @unchecked Sendable {
67
private var svc: CoderVPNService
@@ -49,22 +50,4 @@ import VPNXPC
4950
svc.onExtensionPeerUpdate(data)
5051
}
5152
}
52-
53-
func onStart() {
54-
Task { @MainActor in
55-
svc.onExtensionStart()
56-
}
57-
}
58-
59-
func onStop() {
60-
Task { @MainActor in
61-
svc.onExtensionStop()
62-
}
63-
}
64-
65-
func onError(_ err: NSError) {
66-
Task { @MainActor in
67-
svc.onExtensionError(err)
68-
}
69-
}
7053
}

0 commit comments

Comments
 (0)