Skip to content

Commit 10c2109

Browse files
feat: pass agent updates to UI (#35)
1 parent 15f2bcc commit 10c2109

21 files changed

+530
-293
lines changed

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

+25-16
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,30 @@ 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(), name: "dev", status: .error, copyableDNS: "asdf.coder", wsName: "dogfood2",
9+
wsID: UUID()),
10+
UUID(): Agent(id: UUID(), name: "dev", status: .okay, copyableDNS: "asdf.coder",
11+
wsName: "testing-a-very-long-name", wsID: UUID()),
12+
UUID(): Agent(id: UUID(), name: "dev", status: .warn, copyableDNS: "asdf.coder", wsName: "opensrc",
13+
wsID: UUID()),
14+
UUID(): Agent(id: UUID(), name: "dev", status: .off, copyableDNS: "asdf.coder", wsName: "gvisor",
15+
wsID: UUID()),
16+
UUID(): Agent(id: UUID(), name: "dev", status: .off, copyableDNS: "asdf.coder", wsName: "example",
17+
wsID: UUID()),
18+
UUID(): Agent(id: UUID(), name: "dev", status: .error, copyableDNS: "asdf.coder", wsName: "dogfood2",
19+
wsID: UUID()),
20+
UUID(): Agent(id: UUID(), name: "dev", status: .okay, copyableDNS: "asdf.coder",
21+
wsName: "testing-a-very-long-name", wsID: UUID()),
22+
UUID(): Agent(id: UUID(), name: "dev", status: .warn, copyableDNS: "asdf.coder", wsName: "opensrc",
23+
wsID: UUID()),
24+
UUID(): Agent(id: UUID(), name: "dev", status: .off, copyableDNS: "asdf.coder", wsName: "gvisor",
25+
wsID: UUID()),
26+
UUID(): Agent(id: UUID(), name: "dev", status: .off, copyableDNS: "asdf.coder", wsName: "example",
27+
wsID: UUID()),
2028
]
2129
let shouldFail: Bool
30+
let longError = "This is a long error to test the UI with long error messages"
2231

2332
init(shouldFail: Bool = false) {
2433
self.shouldFail = shouldFail
@@ -35,10 +44,10 @@ final class PreviewVPN: Coder_Desktop.VPNService {
3544
do {
3645
try await Task.sleep(for: .seconds(5))
3746
} catch {
38-
state = .failed(.longTestError)
47+
state = .failed(.internalError(longError))
3948
return
4049
}
41-
state = shouldFail ? .failed(.longTestError) : .connected
50+
state = shouldFail ? .failed(.internalError(longError)) : .connected
4251
}
4352
defer { startTask = nil }
4453
await startTask?.value
@@ -57,7 +66,7 @@ final class PreviewVPN: Coder_Desktop.VPNService {
5766
do {
5867
try await Task.sleep(for: .seconds(5))
5968
} catch {
60-
state = .failed(.longTestError)
69+
state = .failed(.internalError(longError))
6170
return
6271
}
6372
state = .disabled

Coder Desktop/Coder Desktop/VPNService.swift

+94-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,97 @@ 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+
update.deletedAgents
151+
.compactMap { UUID(uuidData: $0.id) }
152+
.forEach { agentID in
153+
agents[agentID] = nil
154+
}
155+
update.deletedWorkspaces
156+
.compactMap { UUID(uuidData: $0.id) }
157+
.forEach { workspaceID in
158+
workspaces[workspaceID] = nil
159+
for (id, agent) in agents where agent.wsID == workspaceID {
160+
agents[id] = nil
161+
}
162+
}
148163

149-
func onExtensionStop() {
150-
logger.info("network extension reported stopped")
151-
tunnelState = .disabled
152-
if terminating {
153-
NSApp.reply(toApplicationShouldTerminate: true)
164+
// Update workspaces
165+
for workspaceProto in update.upsertedWorkspaces {
166+
if let workspaceID = UUID(uuidData: workspaceProto.id) {
167+
workspaces[workspaceID] = workspaceProto.name
168+
}
169+
}
170+
171+
for agentProto in update.upsertedAgents {
172+
guard let agentID = UUID(uuidData: agentProto.id) else {
173+
continue
174+
}
175+
guard let workspaceID = UUID(uuidData: agentProto.workspaceID) else {
176+
continue
177+
}
178+
let workspaceName = workspaces[workspaceID] ?? "Unknown Workspace"
179+
let newAgent = Agent(
180+
id: agentID,
181+
name: agentProto.name,
182+
// If last handshake was not within last five minutes, the agent is unhealthy
183+
status: agentProto.lastHandshake.date > Date.now.addingTimeInterval(-300) ? .okay : .off,
184+
copyableDNS: agentProto.fqdn.first ?? "UNKNOWN",
185+
wsName: workspaceName,
186+
wsID: workspaceID
187+
)
188+
189+
// An existing agent with the same name, belonging to the same workspace
190+
// is from a previous workspace build, and should be removed.
191+
agents
192+
.filter { $0.value.name == agentProto.name && $0.value.wsID == workspaceID }
193+
.forEach { agents[$0.key] = nil }
194+
195+
agents[agentID] = newAgent
154196
}
155197
}
198+
}
156199

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

Coder Desktop/Coder Desktop/Views/Agent.swift

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

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

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

1726
public var color: Color {
1827
switch self {
@@ -22,16 +31,20 @@ enum AgentStatus: Equatable {
2231
case .off: .gray
2332
}
2433
}
34+
35+
static func < (lhs: AgentStatus, rhs: AgentStatus) -> Bool {
36+
lhs.rawValue < rhs.rawValue
37+
}
2538
}
2639

2740
struct AgentRowView: View {
28-
let workspace: Agent
41+
let agent: Agent
2942
let baseAccessURL: URL
3043
@State private var nameIsSelected: Bool = false
3144
@State private var copyIsSelected: Bool = false
3245

3346
private var fmtWsName: AttributedString {
34-
var formattedName = AttributedString(workspace.name)
47+
var formattedName = AttributedString(agent.wsName)
3548
formattedName.foregroundColor = .primary
3649
var coderPart = AttributedString(".coder")
3750
coderPart.foregroundColor = .gray
@@ -41,7 +54,7 @@ struct AgentRowView: View {
4154

4255
private var wsURL: URL {
4356
// TODO: CoderVPN currently only supports owned workspaces
44-
baseAccessURL.appending(path: "@me").appending(path: workspace.workspaceName)
57+
baseAccessURL.appending(path: "@me").appending(path: agent.wsName)
4558
}
4659

4760
var body: some View {
@@ -50,10 +63,10 @@ struct AgentRowView: View {
5063
HStack(spacing: Theme.Size.trayPadding) {
5164
ZStack {
5265
Circle()
53-
.fill(workspace.status.color.opacity(0.4))
66+
.fill(agent.status.color.opacity(0.4))
5467
.frame(width: 12, height: 12)
5568
Circle()
56-
.fill(workspace.status.color.opacity(1.0))
69+
.fill(agent.status.color.opacity(1.0))
5770
.frame(width: 7, height: 7)
5871
}
5972
Text(fmtWsName).lineLimit(1).truncationMode(.tail)
@@ -69,7 +82,7 @@ struct AgentRowView: View {
6982
}.buttonStyle(.plain)
7083
Button {
7184
// TODO: Proper clipboard abstraction
72-
NSPasteboard.general.setString(workspace.copyableDNS, forType: .string)
85+
NSPasteboard.general.setString(agent.copyableDNS, forType: .string)
7386
} label: {
7487
Image(systemName: "doc.on.doc")
7588
.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

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

0 commit comments

Comments
 (0)