Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 3470cf0

Browse files
committedJan 31, 2025·
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

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

Lines changed: 85 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,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

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

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: 13 additions & 0 deletions
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

Lines changed: 2 additions & 19 deletions
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
}

‎Coder Desktop/Coder DesktopTests/AgentsTests.swift

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,17 @@ struct AgentsTests {
1818
view = sut.environmentObject(vpn).environmentObject(session)
1919
}
2020

21-
private func createMockAgents(count: Int) -> [Agent] {
22-
(1 ... count).map {
23-
Agent(
21+
private func createMockAgents(count: Int) -> [UUID: Agent] {
22+
Dictionary(uniqueKeysWithValues: (1 ... count).map {
23+
let agent = Agent(
2424
id: UUID(),
25-
name: "a\($0)",
2625
status: .okay,
2726
copyableDNS: "a\($0).example.com",
28-
workspaceName: "w\($0)"
27+
wsName: "a\($0)",
28+
wsID: UUID()
2929
)
30-
}
30+
return (agent.id, agent)
31+
})
3132
}
3233

3334
@Test
@@ -46,6 +47,7 @@ struct AgentsTests {
4647

4748
let forEach = try view.inspect().find(ViewType.ForEach.self)
4849
#expect(forEach.count == Theme.defaultVisibleAgents)
50+
// Agents are sorted by status, and then by name in alphabetical order
4951
#expect(throws: Never.self) { try view.inspect().find(link: "a1.coder") }
5052
}
5153

‎Coder Desktop/Coder DesktopTests/Util.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import ViewInspector
88
class MockVPNService: VPNService, ObservableObject {
99
@Published var state: Coder_Desktop.VPNServiceState = .disabled
1010
@Published var baseAccessURL: URL = .init(string: "https://dev.coder.com")!
11-
@Published var agents: [Coder_Desktop.Agent] = []
11+
@Published var agents: [UUID: Coder_Desktop.Agent] = [:]
1212
var onStart: (() async -> Void)?
1313
var onStop: (() async -> Void)?
1414

‎Coder Desktop/Coder DesktopTests/VPNMenuTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ struct VPNMenuTests {
111111
#expect(try !toggle.isOn())
112112

113113
vpn.onStart = {
114-
vpn.state = .failed(.longTestError)
114+
vpn.state = .failed(.internalError("This is a long error message!"))
115115
}
116116
await vpn.start()
117117

‎Coder Desktop/Coder DesktopTests/VPNStateTests.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,13 @@ struct VPNStateTests {
5555

5656
@Test
5757
func testFailedState() async throws {
58-
vpn.state = .failed(.longTestError)
58+
let errMsg = "Internal error occured!"
59+
vpn.state = .failed(.internalError(errMsg))
5960

6061
try await ViewHosting.host(view.environmentObject(vpn)) {
6162
try await sut.inspection.inspect { view in
6263
let text = try view.find(ViewType.Text.self)
63-
#expect(try text.string() == VPNServiceError.longTestError.description)
64+
#expect(try text.string() == "Internal Error: \(errMsg)")
6465
}
6566
}
6667
}

‎Coder Desktop/VPN/Manager.swift

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import CoderSDK
22
import NetworkExtension
33
import os
44
import VPNLib
5-
import VPNXPC
65

76
actor Manager {
87
let ptp: PacketTunnelProvider
@@ -86,16 +85,12 @@ actor Manager {
8685
} catch {
8786
logger.error("tunnel read loop failed: \(error.localizedDescription, privacy: .public)")
8887
try await tunnelHandle.close()
89-
if let conn = globalXPCListenerDelegate.getActiveConnection() {
90-
conn.onError(error as NSError)
91-
}
88+
ptp.cancelTunnelWithError(error)
9289
return
9390
}
9491
logger.info("tunnel read loop exited")
9592
try await tunnelHandle.close()
96-
if let conn = globalXPCListenerDelegate.getActiveConnection() {
97-
conn.onStop()
98-
}
93+
ptp.cancelTunnelWithError(nil)
9994
}
10095

10196
func handleMessage(_ msg: Vpn_TunnelMessage) {
@@ -105,7 +100,7 @@ actor Manager {
105100
}
106101
switch msgType {
107102
case .peerUpdate:
108-
if let conn = globalXPCListenerDelegate.getActiveConnection() {
103+
if let conn = globalXPCListenerDelegate.conn {
109104
do {
110105
let data = try msg.peerUpdate.serializedData()
111106
conn.onPeerUpdate(data)

‎Coder Desktop/VPN/PacketTunnelProvider.swift

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import NetworkExtension
22
import os
33
import VPNLib
4-
import VPNXPC
54

65
/* From <sys/kern_control.h> */
76
let CTLIOCGINFO: UInt = 0xC064_4E03
@@ -77,23 +76,13 @@ class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
7776
apiToken: token, serverUrl: .init(string: baseAccessURL)!
7877
)
7978
)
80-
globalXPCListenerDelegate.vpnXPCInterface.setManager(manager)
79+
globalXPCListenerDelegate.vpnXPCInterface.manager = manager
8180
logger.debug("starting vpn")
8281
try await manager!.startVPN()
8382
logger.info("vpn started")
84-
if let conn = globalXPCListenerDelegate.getActiveConnection() {
85-
conn.onStart()
86-
} else {
87-
logger.info("no active XPC connection")
88-
}
8983
completionHandler(nil)
9084
} catch {
9185
logger.error("error starting manager: \(error.description, privacy: .public)")
92-
if let conn = globalXPCListenerDelegate.getActiveConnection() {
93-
conn.onError(error as NSError)
94-
} else {
95-
logger.info("no active XPC connection")
96-
}
9786
completionHandler(error as NSError)
9887
}
9988
}
@@ -116,12 +105,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
116105
} catch {
117106
logger.error("error stopping manager: \(error.description, privacy: .public)")
118107
}
119-
if let conn = globalXPCListenerDelegate.getActiveConnection() {
120-
conn.onStop()
121-
} else {
122-
logger.info("no active XPC connection")
123-
}
124-
globalXPCListenerDelegate.vpnXPCInterface.setManager(nil)
108+
globalXPCListenerDelegate.vpnXPCInterface.manager = nil
125109
completionHandler()
126110
}
127111
self.manager = nil

‎Coder Desktop/VPN/XPCInterface.swift

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,27 @@
11
import Foundation
22
import os.log
33
import VPNLib
4-
import VPNXPC
54

65
@objc final class XPCInterface: NSObject, VPNXPCProtocol, @unchecked Sendable {
7-
private var manager: Manager?
6+
private var manager_: Manager?
87
private let managerLock = NSLock()
98
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "VPNXPCInterface")
109

11-
func setManager(_ newManager: Manager?) {
12-
managerLock.lock()
13-
defer { managerLock.unlock() }
14-
manager = newManager
15-
}
16-
17-
func getManager() -> Manager? {
18-
managerLock.lock()
19-
defer { managerLock.unlock() }
20-
let m = manager
21-
22-
return m
10+
var manager: Manager? {
11+
get {
12+
managerLock.lock()
13+
defer { managerLock.unlock() }
14+
return manager_
15+
}
16+
set {
17+
managerLock.lock()
18+
defer { managerLock.unlock() }
19+
manager_ = newValue
20+
}
2321
}
2422

2523
func getPeerInfo(with reply: @escaping () -> Void) {
24+
// TODO: Retrieve from Manager
2625
reply()
2726
}
2827

‎Coder Desktop/VPN/main.swift

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,21 @@
11
import Foundation
22
import NetworkExtension
33
import os
4-
import VPNXPC
4+
import VPNLib
55

66
let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "provider")
77

88
final class XPCListenerDelegate: NSObject, NSXPCListenerDelegate, @unchecked Sendable {
99
let vpnXPCInterface = XPCInterface()
10-
var activeConnection: NSXPCConnection?
11-
var connMutex: NSLock = .init()
10+
private var activeConnection: NSXPCConnection?
11+
private var connMutex: NSLock = .init()
1212

13-
func getActiveConnection() -> VPNXPCClientCallbackProtocol? {
13+
var conn: VPNXPCClientCallbackProtocol? {
1414
connMutex.lock()
1515
defer { connMutex.unlock() }
1616

17-
let client = activeConnection?.remoteObjectProxy as? VPNXPCClientCallbackProtocol
18-
return client
17+
let conn = activeConnection?.remoteObjectProxy as? VPNXPCClientCallbackProtocol
18+
return conn
1919
}
2020

2121
func setActiveConnection(_ connection: NSXPCConnection?) {

‎Coder Desktop/VPNLib/Convert.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import NetworkExtension
22
import os
3+
import SwiftProtobuf
34

45
public func convertDnsSettings(_ req: Vpn_NetworkSettingsRequest.DNSSettings) -> NEDNSSettings {
56
let dnsSettings = NEDNSSettings(servers: req.servers)
@@ -59,3 +60,11 @@ public func convertIPv6Settings(_ req: Vpn_NetworkSettingsRequest.IPv6Settings)
5960
}
6061
return ipv6Settings
6162
}
63+
64+
extension Google_Protobuf_Timestamp {
65+
var date: Date {
66+
let seconds = TimeInterval(seconds)
67+
let nanos = TimeInterval(nanos) / 1_000_000_000
68+
return Date(timeIntervalSince1970: seconds + nanos)
69+
}
70+
}

‎Coder Desktop/VPNXPC/Protocol.swift renamed to ‎Coder Desktop/VPNLib/XPC.swift

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,6 @@ import Foundation
88

99
@preconcurrency
1010
@objc public protocol VPNXPCClientCallbackProtocol {
11-
/// Called when the server has a status update to share
11+
// data is a serialized `Vpn_PeerUpdate`
1212
func onPeerUpdate(_ data: Data)
13-
func onStart()
14-
func onStop()
15-
func onError(_ err: NSError)
1613
}

‎Coder Desktop/VPNLib/vpn.pb.swift

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -393,7 +393,7 @@ public struct Vpn_Agent: @unchecked Sendable {
393393
/// UUID
394394
public var workspaceID: Data = Data()
395395

396-
public var fqdn: String = String()
396+
public var fqdn: [String] = []
397397

398398
public var ipAddrs: [String] = []
399399

@@ -597,8 +597,25 @@ public struct Vpn_StartRequest: Sendable {
597597

598598
public var apiToken: String = String()
599599

600+
public var headers: [Vpn_StartRequest.Header] = []
601+
600602
public var unknownFields = SwiftProtobuf.UnknownStorage()
601603

604+
/// Additional HTTP headers added to all requests
605+
public struct Header: Sendable {
606+
// SwiftProtobuf.Message conformance is added in an extension below. See the
607+
// `Message` and `Message+*Additions` files in the SwiftProtobuf library for
608+
// methods supported on all messages.
609+
610+
public var name: String = String()
611+
612+
public var value: String = String()
613+
614+
public var unknownFields = SwiftProtobuf.UnknownStorage()
615+
616+
public init() {}
617+
}
618+
602619
public init() {}
603620
}
604621

@@ -1176,7 +1193,7 @@ extension Vpn_Agent: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementation
11761193
case 1: try { try decoder.decodeSingularBytesField(value: &self.id) }()
11771194
case 2: try { try decoder.decodeSingularStringField(value: &self.name) }()
11781195
case 3: try { try decoder.decodeSingularBytesField(value: &self.workspaceID) }()
1179-
case 4: try { try decoder.decodeSingularStringField(value: &self.fqdn) }()
1196+
case 4: try { try decoder.decodeRepeatedStringField(value: &self.fqdn) }()
11801197
case 5: try { try decoder.decodeRepeatedStringField(value: &self.ipAddrs) }()
11811198
case 6: try { try decoder.decodeSingularMessageField(value: &self._lastHandshake) }()
11821199
default: break
@@ -1199,7 +1216,7 @@ extension Vpn_Agent: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementation
11991216
try visitor.visitSingularBytesField(value: self.workspaceID, fieldNumber: 3)
12001217
}
12011218
if !self.fqdn.isEmpty {
1202-
try visitor.visitSingularStringField(value: self.fqdn, fieldNumber: 4)
1219+
try visitor.visitRepeatedStringField(value: self.fqdn, fieldNumber: 4)
12031220
}
12041221
if !self.ipAddrs.isEmpty {
12051222
try visitor.visitRepeatedStringField(value: self.ipAddrs, fieldNumber: 5)
@@ -1632,6 +1649,7 @@ extension Vpn_StartRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImpleme
16321649
1: .standard(proto: "tunnel_file_descriptor"),
16331650
2: .standard(proto: "coder_url"),
16341651
3: .standard(proto: "api_token"),
1652+
4: .same(proto: "headers"),
16351653
]
16361654

16371655
public mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
@@ -1643,6 +1661,7 @@ extension Vpn_StartRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImpleme
16431661
case 1: try { try decoder.decodeSingularInt32Field(value: &self.tunnelFileDescriptor) }()
16441662
case 2: try { try decoder.decodeSingularStringField(value: &self.coderURL) }()
16451663
case 3: try { try decoder.decodeSingularStringField(value: &self.apiToken) }()
1664+
case 4: try { try decoder.decodeRepeatedMessageField(value: &self.headers) }()
16461665
default: break
16471666
}
16481667
}
@@ -1658,13 +1677,55 @@ extension Vpn_StartRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImpleme
16581677
if !self.apiToken.isEmpty {
16591678
try visitor.visitSingularStringField(value: self.apiToken, fieldNumber: 3)
16601679
}
1680+
if !self.headers.isEmpty {
1681+
try visitor.visitRepeatedMessageField(value: self.headers, fieldNumber: 4)
1682+
}
16611683
try unknownFields.traverse(visitor: &visitor)
16621684
}
16631685

16641686
public static func ==(lhs: Vpn_StartRequest, rhs: Vpn_StartRequest) -> Bool {
16651687
if lhs.tunnelFileDescriptor != rhs.tunnelFileDescriptor {return false}
16661688
if lhs.coderURL != rhs.coderURL {return false}
16671689
if lhs.apiToken != rhs.apiToken {return false}
1690+
if lhs.headers != rhs.headers {return false}
1691+
if lhs.unknownFields != rhs.unknownFields {return false}
1692+
return true
1693+
}
1694+
}
1695+
1696+
extension Vpn_StartRequest.Header: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
1697+
public static let protoMessageName: String = Vpn_StartRequest.protoMessageName + ".Header"
1698+
public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
1699+
1: .same(proto: "name"),
1700+
2: .same(proto: "value"),
1701+
]
1702+
1703+
public mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
1704+
while let fieldNumber = try decoder.nextFieldNumber() {
1705+
// The use of inline closures is to circumvent an issue where the compiler
1706+
// allocates stack space for every case branch when no optimizations are
1707+
// enabled. https://github.com/apple/swift-protobuf/issues/1034
1708+
switch fieldNumber {
1709+
case 1: try { try decoder.decodeSingularStringField(value: &self.name) }()
1710+
case 2: try { try decoder.decodeSingularStringField(value: &self.value) }()
1711+
default: break
1712+
}
1713+
}
1714+
}
1715+
1716+
public func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
1717+
if !self.name.isEmpty {
1718+
try visitor.visitSingularStringField(value: self.name, fieldNumber: 1)
1719+
}
1720+
if !self.value.isEmpty {
1721+
try visitor.visitSingularStringField(value: self.value, fieldNumber: 2)
1722+
}
1723+
try unknownFields.traverse(visitor: &visitor)
1724+
}
1725+
1726+
public static func ==(lhs: Vpn_StartRequest.Header, rhs: Vpn_StartRequest.Header) -> Bool {
1727+
if lhs.name != rhs.name {return false}
1728+
if lhs.value != rhs.value {return false}
16681729
if lhs.unknownFields != rhs.unknownFields {return false}
16691730
return true
16701731
}

‎Coder Desktop/VPNLib/vpn.proto

Lines changed: 128 additions & 122 deletions
Original file line numberDiff line numberDiff line change
@@ -17,55 +17,55 @@ package vpn;
1717
// msg_id which it sets on the request, the responder sets response_to that msg_id on the response
1818
// message
1919
message RPC {
20-
uint64 msg_id = 1;
21-
uint64 response_to = 2;
20+
uint64 msg_id = 1;
21+
uint64 response_to = 2;
2222
}
2323

2424
// ManagerMessage is a message from the manager (to the tunnel).
2525
message ManagerMessage {
26-
RPC rpc = 1;
27-
oneof msg {
28-
GetPeerUpdate get_peer_update = 2;
29-
NetworkSettingsResponse network_settings = 3;
30-
StartRequest start = 4;
31-
StopRequest stop = 5;
32-
}
26+
RPC rpc = 1;
27+
oneof msg {
28+
GetPeerUpdate get_peer_update = 2;
29+
NetworkSettingsResponse network_settings = 3;
30+
StartRequest start = 4;
31+
StopRequest stop = 5;
32+
}
3333
}
3434

3535
// TunnelMessage is a message from the tunnel (to the manager).
3636
message TunnelMessage {
37-
RPC rpc = 1;
38-
oneof msg {
39-
Log log = 2;
40-
PeerUpdate peer_update = 3;
41-
NetworkSettingsRequest network_settings = 4;
42-
StartResponse start = 5;
43-
StopResponse stop = 6;
44-
}
37+
RPC rpc = 1;
38+
oneof msg {
39+
Log log = 2;
40+
PeerUpdate peer_update = 3;
41+
NetworkSettingsRequest network_settings = 4;
42+
StartResponse start = 5;
43+
StopResponse stop = 6;
44+
}
4545
}
4646

4747
// Log is a log message generated by the tunnel. The manager should log it to the system log. It is
4848
// one-way tunnel -> manager with no response.
4949
message Log {
50-
enum Level {
51-
// these are designed to match slog levels
52-
DEBUG = 0;
53-
INFO = 1;
54-
WARN = 2;
55-
ERROR = 3;
56-
CRITICAL = 4;
57-
FATAL = 5;
58-
}
59-
Level level = 1;
60-
61-
string message = 2;
62-
repeated string logger_names = 3;
63-
64-
message Field {
65-
string name = 1;
66-
string value = 2;
67-
}
68-
repeated Field fields = 4;
50+
enum Level {
51+
// these are designed to match slog levels
52+
DEBUG = 0;
53+
INFO = 1;
54+
WARN = 2;
55+
ERROR = 3;
56+
CRITICAL = 4;
57+
FATAL = 5;
58+
}
59+
Level level = 1;
60+
61+
string message = 2;
62+
repeated string logger_names = 3;
63+
64+
message Field {
65+
string name = 1;
66+
string value = 2;
67+
}
68+
repeated Field fields = 4;
6969
}
7070

7171
// GetPeerUpdate asks for a PeerUpdate with a full set of data.
@@ -75,115 +75,121 @@ message GetPeerUpdate {}
7575
// response to GetPeerUpdate (which dumps the full set). It is also generated on any changes (not in
7676
// response to any request).
7777
message PeerUpdate {
78-
repeated Workspace upserted_workspaces = 1;
79-
repeated Agent upserted_agents = 2;
80-
repeated Workspace deleted_workspaces = 3;
81-
repeated Agent deleted_agents = 4;
78+
repeated Workspace upserted_workspaces = 1;
79+
repeated Agent upserted_agents = 2;
80+
repeated Workspace deleted_workspaces = 3;
81+
repeated Agent deleted_agents = 4;
8282
}
8383

8484
message Workspace {
85-
bytes id = 1; // UUID
86-
string name = 2;
87-
88-
enum Status {
89-
UNKNOWN = 0;
90-
PENDING = 1;
91-
STARTING = 2;
92-
RUNNING = 3;
93-
STOPPING = 4;
94-
STOPPED = 5;
95-
FAILED = 6;
96-
CANCELING = 7;
97-
CANCELED = 8;
98-
DELETING = 9;
99-
DELETED = 10;
100-
}
101-
Status status = 3;
85+
bytes id = 1; // UUID
86+
string name = 2;
87+
88+
enum Status {
89+
UNKNOWN = 0;
90+
PENDING = 1;
91+
STARTING = 2;
92+
RUNNING = 3;
93+
STOPPING = 4;
94+
STOPPED = 5;
95+
FAILED = 6;
96+
CANCELING = 7;
97+
CANCELED = 8;
98+
DELETING = 9;
99+
DELETED = 10;
100+
}
101+
Status status = 3;
102102
}
103103

104104
message Agent {
105-
bytes id = 1; // UUID
106-
string name = 2;
107-
bytes workspace_id = 3; // UUID
108-
string fqdn = 4;
109-
repeated string ip_addrs = 5;
110-
// last_handshake is the primary indicator of whether we are connected to a peer. Zero value or
111-
// anything longer than 5 minutes ago means there is a problem.
112-
google.protobuf.Timestamp last_handshake = 6;
105+
bytes id = 1; // UUID
106+
string name = 2;
107+
bytes workspace_id = 3; // UUID
108+
repeated string fqdn = 4;
109+
repeated string ip_addrs = 5;
110+
// last_handshake is the primary indicator of whether we are connected to a peer. Zero value or
111+
// anything longer than 5 minutes ago means there is a problem.
112+
google.protobuf.Timestamp last_handshake = 6;
113113
}
114114

115115
// NetworkSettingsRequest is based on
116116
// https://developer.apple.com/documentation/networkextension/nepackettunnelnetworksettings for
117117
// macOS. It is a request/response message with response NetworkSettingsResponse
118118
message NetworkSettingsRequest {
119-
uint32 tunnel_overhead_bytes = 1;
120-
uint32 mtu = 2;
121-
122-
message DNSSettings {
123-
repeated string servers = 1;
124-
repeated string search_domains = 2;
125-
// domain_name is the primary domain name of the tunnel
126-
string domain_name = 3;
127-
repeated string match_domains = 4;
128-
// match_domains_no_search specifies if the domains in the matchDomains list should not be
129-
// appended to the resolver’s list of search domains.
130-
bool match_domains_no_search = 5;
131-
}
132-
DNSSettings dns_settings = 3;
133-
134-
string tunnel_remote_address = 4;
135-
136-
message IPv4Settings {
137-
repeated string addrs = 1;
138-
repeated string subnet_masks = 2;
139-
// router is the next-hop router in dotted-decimal format
140-
string router = 3;
141-
142-
message IPv4Route {
143-
string destination = 1;
144-
string mask = 2;
145-
// router is the next-hop router in dotted-decimal format
146-
string router = 3;
147-
}
148-
repeated IPv4Route included_routes = 4;
149-
repeated IPv4Route excluded_routes = 5;
150-
}
151-
IPv4Settings ipv4_settings = 5;
152-
153-
message IPv6Settings {
154-
repeated string addrs = 1;
155-
repeated uint32 prefix_lengths = 2;
156-
157-
message IPv6Route {
158-
string destination = 1;
159-
uint32 prefix_length = 2;
160-
// router is the address of the next-hop
161-
string router = 3;
162-
}
163-
repeated IPv6Route included_routes = 3;
164-
repeated IPv6Route excluded_routes = 4;
165-
}
166-
IPv6Settings ipv6_settings = 6;
119+
uint32 tunnel_overhead_bytes = 1;
120+
uint32 mtu = 2;
121+
122+
message DNSSettings {
123+
repeated string servers = 1;
124+
repeated string search_domains = 2;
125+
// domain_name is the primary domain name of the tunnel
126+
string domain_name = 3;
127+
repeated string match_domains = 4;
128+
// match_domains_no_search specifies if the domains in the matchDomains list should not be
129+
// appended to the resolver’s list of search domains.
130+
bool match_domains_no_search = 5;
131+
}
132+
DNSSettings dns_settings = 3;
133+
134+
string tunnel_remote_address = 4;
135+
136+
message IPv4Settings {
137+
repeated string addrs = 1;
138+
repeated string subnet_masks = 2;
139+
// router is the next-hop router in dotted-decimal format
140+
string router = 3;
141+
142+
message IPv4Route {
143+
string destination = 1;
144+
string mask = 2;
145+
// router is the next-hop router in dotted-decimal format
146+
string router = 3;
147+
}
148+
repeated IPv4Route included_routes = 4;
149+
repeated IPv4Route excluded_routes = 5;
150+
}
151+
IPv4Settings ipv4_settings = 5;
152+
153+
message IPv6Settings {
154+
repeated string addrs = 1;
155+
repeated uint32 prefix_lengths = 2;
156+
157+
message IPv6Route {
158+
string destination = 1;
159+
uint32 prefix_length = 2;
160+
// router is the address of the next-hop
161+
string router = 3;
162+
}
163+
repeated IPv6Route included_routes = 3;
164+
repeated IPv6Route excluded_routes = 4;
165+
}
166+
IPv6Settings ipv6_settings = 6;
167167
}
168168

169169
// NetworkSettingsResponse is the response from the manager to the tunnel for a
170170
// NetworkSettingsRequest
171171
message NetworkSettingsResponse {
172-
bool success = 1;
173-
string error_message = 2;
172+
bool success = 1;
173+
string error_message = 2;
174174
}
175175

176176
// StartRequest is a request from the manager to start the tunnel. The tunnel replies with a
177177
// StartResponse.
178178
message StartRequest {
179-
int32 tunnel_file_descriptor = 1;
180-
string coder_url = 2;
181-
string api_token = 3;
179+
int32 tunnel_file_descriptor = 1;
180+
string coder_url = 2;
181+
string api_token = 3;
182+
// Additional HTTP headers added to all requests
183+
message Header {
184+
string name = 1;
185+
string value = 2;
186+
}
187+
repeated Header headers = 4;
182188
}
183189

184190
message StartResponse {
185-
bool success = 1;
186-
string error_message = 2;
191+
bool success = 1;
192+
string error_message = 2;
187193
}
188194

189195
// StopRequest is a request from the manager to stop the tunnel. The tunnel replies with a
@@ -193,6 +199,6 @@ message StopRequest {}
193199
// StopResponse is a response to stopping the tunnel. After sending this response, the tunnel closes
194200
// its side of the bidirectional stream for writing.
195201
message StopResponse {
196-
bool success = 1;
197-
string error_message = 2;
202+
bool success = 1;
203+
string error_message = 2;
198204
}

‎Coder Desktop/VPNXPC/VPNXPC.h

Lines changed: 0 additions & 11 deletions
This file was deleted.

‎Coder Desktop/project.yml

Lines changed: 3 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ name: "Coder Desktop"
22
options:
33
bundleIdPrefix: com.coder
44
deploymentTarget:
5-
macOS: "14.6"
5+
macOS: "14.0"
66
xcodeVersion: "1600"
77
minimumXcodeGenVersion: "2.42.0"
88

@@ -146,7 +146,7 @@ targets:
146146
dependencies:
147147
- target: CoderSDK
148148
embed: true
149-
- target: VPNXPC
149+
- target: VPNLib
150150
embed: true
151151
- target: VPN
152152
embed: without-signing # Embed without signing.
@@ -220,8 +220,6 @@ targets:
220220
embed: true
221221
- target: CoderSDK
222222
embed: true
223-
- target: VPNXPC
224-
embed: true
225223
- sdk: NetworkExtension.framework
226224

227225
VPNLib:
@@ -299,20 +297,4 @@ targets:
299297
settings:
300298
base:
301299
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/Coder Desktop.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Coder Desktop"
302-
PRODUCT_BUNDLE_IDENTIFIER: com.coder.Coder-Desktop.CoderSDKTests
303-
304-
VPNXPC:
305-
type: framework
306-
platform: macOS
307-
sources:
308-
- path: VPNXPC
309-
settings:
310-
base:
311-
INFOPLIST_KEY_NSHumanReadableCopyright: ""
312-
PRODUCT_NAME: "$(TARGET_NAME:c99extidentifier)"
313-
SWIFT_EMIT_LOC_STRINGS: YES
314-
GENERATE_INFOPLIST_FILE: YES
315-
DYLIB_COMPATIBILITY_VERSION: 1
316-
DYLIB_CURRENT_VERSION: 1
317-
DYLIB_INSTALL_NAME_BASE: "@rpath"
318-
dependencies: []
300+
PRODUCT_BUNDLE_IDENTIFIER: com.coder.Coder-Desktop.CoderSDKTests

0 commit comments

Comments
 (0)
Please sign in to comment.