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

Lines changed: 25 additions & 16 deletions
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

Lines changed: 94 additions & 24 deletions
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

Lines changed: 26 additions & 13 deletions
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

Lines changed: 5 additions & 4 deletions
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

Lines changed: 19 additions & 0 deletions
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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy