From 156f4e0ccfc9886f3dd5c5ab6c1bb9151501cba4 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 31 Jan 2025 16:26:17 +1100 Subject: [PATCH 1/2] feat: pass agent updates to UI --- .../Preview Content/PreviewVPN.swift | 41 +-- Coder Desktop/Coder Desktop/VPNService.swift | 118 +++++++-- Coder Desktop/Coder Desktop/Views/Agent.swift | 39 ++- .../Coder Desktop/Views/Agents.swift | 9 +- Coder Desktop/Coder Desktop/Views/Util.swift | 19 ++ .../Coder Desktop/XPCInterface.swift | 21 +- .../Coder DesktopTests/AgentsTests.swift | 15 +- Coder Desktop/Coder DesktopTests/Util.swift | 2 +- .../Coder DesktopTests/VPNMenuTests.swift | 2 +- .../Coder DesktopTests/VPNServiceTests.swift | 116 ++++++++ .../Coder DesktopTests/VPNStateTests.swift | 5 +- Coder Desktop/VPN/Manager.swift | 11 +- Coder Desktop/VPN/PacketTunnelProvider.swift | 20 +- Coder Desktop/VPN/XPCInterface.swift | 27 +- Coder Desktop/VPN/main.swift | 12 +- Coder Desktop/VPNLib/Convert.swift | 9 + .../Protocol.swift => VPNLib/XPC.swift} | 5 +- Coder Desktop/VPNLib/vpn.pb.swift | 67 ++++- Coder Desktop/VPNLib/vpn.proto | 250 +++++++++--------- Coder Desktop/VPNXPC/VPNXPC.h | 11 - Coder Desktop/project.yml | 24 +- 21 files changed, 530 insertions(+), 293 deletions(-) create mode 100644 Coder Desktop/Coder DesktopTests/VPNServiceTests.swift rename Coder Desktop/{VPNXPC/Protocol.swift => VPNLib/XPC.swift} (68%) delete mode 100644 Coder Desktop/VPNXPC/VPNXPC.h diff --git a/Coder Desktop/Coder Desktop/Preview Content/PreviewVPN.swift b/Coder Desktop/Coder Desktop/Preview Content/PreviewVPN.swift index 91900b8e..5e66eb72 100644 --- a/Coder Desktop/Coder Desktop/Preview Content/PreviewVPN.swift +++ b/Coder Desktop/Coder Desktop/Preview Content/PreviewVPN.swift @@ -4,21 +4,30 @@ import SwiftUI @MainActor final class PreviewVPN: Coder_Desktop.VPNService { @Published var state: Coder_Desktop.VPNServiceState = .disabled - @Published var agents: [Coder_Desktop.Agent] = [ - Agent(id: UUID(), name: "dogfood2", status: .error, copyableDNS: "asdf.coder", workspaceName: "dogfood2"), - Agent(id: UUID(), name: "testing-a-very-long-name", status: .okay, copyableDNS: "asdf.coder", - workspaceName: "testing-a-very-long-name"), - Agent(id: UUID(), name: "opensrc", status: .warn, copyableDNS: "asdf.coder", workspaceName: "opensrc"), - Agent(id: UUID(), name: "gvisor", status: .off, copyableDNS: "asdf.coder", workspaceName: "gvisor"), - Agent(id: UUID(), name: "example", status: .off, copyableDNS: "asdf.coder", workspaceName: "example"), - Agent(id: UUID(), name: "dogfood2", status: .error, copyableDNS: "asdf.coder", workspaceName: "dogfood2"), - Agent(id: UUID(), name: "testing-a-very-long-name", status: .okay, copyableDNS: "asdf.coder", - workspaceName: "testing-a-very-long-name"), - Agent(id: UUID(), name: "opensrc", status: .warn, copyableDNS: "asdf.coder", workspaceName: "opensrc"), - Agent(id: UUID(), name: "gvisor", status: .off, copyableDNS: "asdf.coder", workspaceName: "gvisor"), - Agent(id: UUID(), name: "example", status: .off, copyableDNS: "asdf.coder", workspaceName: "example"), + @Published var agents: [UUID: Coder_Desktop.Agent] = [ + UUID(): Agent(id: UUID(), name: "dev", status: .error, copyableDNS: "asdf.coder", wsName: "dogfood2", + wsID: UUID()), + UUID(): Agent(id: UUID(), name: "dev", status: .okay, copyableDNS: "asdf.coder", + wsName: "testing-a-very-long-name", wsID: UUID()), + UUID(): Agent(id: UUID(), name: "dev", status: .warn, copyableDNS: "asdf.coder", wsName: "opensrc", + wsID: UUID()), + UUID(): Agent(id: UUID(), name: "dev", status: .off, copyableDNS: "asdf.coder", wsName: "gvisor", + wsID: UUID()), + UUID(): Agent(id: UUID(), name: "dev", status: .off, copyableDNS: "asdf.coder", wsName: "example", + wsID: UUID()), + UUID(): Agent(id: UUID(), name: "dev", status: .error, copyableDNS: "asdf.coder", wsName: "dogfood2", + wsID: UUID()), + UUID(): Agent(id: UUID(), name: "dev", status: .okay, copyableDNS: "asdf.coder", + wsName: "testing-a-very-long-name", wsID: UUID()), + UUID(): Agent(id: UUID(), name: "dev", status: .warn, copyableDNS: "asdf.coder", wsName: "opensrc", + wsID: UUID()), + UUID(): Agent(id: UUID(), name: "dev", status: .off, copyableDNS: "asdf.coder", wsName: "gvisor", + wsID: UUID()), + UUID(): Agent(id: UUID(), name: "dev", status: .off, copyableDNS: "asdf.coder", wsName: "example", + wsID: UUID()), ] let shouldFail: Bool + let longError = "This is a long error to test the UI with long error messages" init(shouldFail: Bool = false) { self.shouldFail = shouldFail @@ -35,10 +44,10 @@ final class PreviewVPN: Coder_Desktop.VPNService { do { try await Task.sleep(for: .seconds(5)) } catch { - state = .failed(.longTestError) + state = .failed(.internalError(longError)) return } - state = shouldFail ? .failed(.longTestError) : .connected + state = shouldFail ? .failed(.internalError(longError)) : .connected } defer { startTask = nil } await startTask?.value @@ -57,7 +66,7 @@ final class PreviewVPN: Coder_Desktop.VPNService { do { try await Task.sleep(for: .seconds(5)) } catch { - state = .failed(.longTestError) + state = .failed(.internalError(longError)) return } state = .disabled diff --git a/Coder Desktop/Coder Desktop/VPNService.swift b/Coder Desktop/Coder Desktop/VPNService.swift index 3506e103..60e7ace3 100644 --- a/Coder Desktop/Coder Desktop/VPNService.swift +++ b/Coder Desktop/Coder Desktop/VPNService.swift @@ -2,14 +2,12 @@ import NetworkExtension import os import SwiftUI import VPNLib -import VPNXPC @MainActor protocol VPNService: ObservableObject { var state: VPNServiceState { get } - var agents: [Agent] { get } + var agents: [UUID: Agent] { get } func start() async - // Stop must be idempotent func stop() async func configureTunnelProviderProtocol(proto: NETunnelProviderProtocol?) } @@ -26,12 +24,9 @@ enum VPNServiceError: Error, Equatable { case internalError(String) case systemExtensionError(SystemExtensionState) case networkExtensionError(NetworkExtensionState) - case longTestError var description: String { switch self { - case .longTestError: - "This is a long error to test the UI with long errors" case let .internalError(description): "Internal Error: \(description)" case let .systemExtensionError(state): @@ -47,6 +42,7 @@ final class CoderVPNService: NSObject, VPNService { var logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "vpn") lazy var xpc: VPNXPCInterface = .init(vpn: self) var terminating = false + var workspaces: [UUID: String] = [:] @Published var tunnelState: VPNServiceState = .disabled @Published var sysExtnState: SystemExtensionState = .uninstalled @@ -61,7 +57,7 @@ final class CoderVPNService: NSObject, VPNService { return tunnelState } - @Published var agents: [Agent] = [] + @Published var agents: [UUID: Agent] = [:] // systemExtnDelegate holds a reference to the SystemExtensionDelegate so that it doesn't get // garbage collected while the OSSystemExtensionRequest is in flight, since the OS framework @@ -74,6 +70,16 @@ final class CoderVPNService: NSObject, VPNService { Task { await loadNetworkExtension() } + NotificationCenter.default.addObserver( + self, + selector: #selector(vpnDidUpdate(_:)), + name: .NEVPNStatusDidChange, + object: nil + ) + } + + deinit { + NotificationCenter.default.removeObserver(self) } func start() async { @@ -84,16 +90,14 @@ final class CoderVPNService: NSObject, VPNService { return } + await enableNetworkExtension() // this ping is somewhat load bearing since it causes xpc to init xpc.ping() - tunnelState = .connecting - await enableNetworkExtension() logger.debug("network extension enabled") } func stop() async { guard tunnelState == .connected else { return } - tunnelState = .disconnecting await disableNetworkExtension() logger.info("network extension stopped") } @@ -131,31 +135,97 @@ final class CoderVPNService: NSObject, VPNService { } func onExtensionPeerUpdate(_ data: Data) { - // TODO: handle peer update logger.info("network extension peer update") do { - let msg = try Vpn_TunnelMessage(serializedBytes: data) + let msg = try Vpn_PeerUpdate(serializedBytes: data) debugPrint(msg) + applyPeerUpdate(with: msg) } catch { logger.error("failed to decode peer update \(error)") } } - func onExtensionStart() { - logger.info("network extension reported started") - tunnelState = .connected - } + func applyPeerUpdate(with update: Vpn_PeerUpdate) { + // Delete agents + update.deletedAgents + .compactMap { UUID(uuidData: $0.id) } + .forEach { agentID in + agents[agentID] = nil + } + update.deletedWorkspaces + .compactMap { UUID(uuidData: $0.id) } + .forEach { workspaceID in + workspaces[workspaceID] = nil + for (id, agent) in agents where agent.wsID == workspaceID { + agents[id] = nil + } + } - func onExtensionStop() { - logger.info("network extension reported stopped") - tunnelState = .disabled - if terminating { - NSApp.reply(toApplicationShouldTerminate: true) + // Update workspaces + for workspaceProto in update.upsertedWorkspaces { + if let workspaceID = UUID(uuidData: workspaceProto.id) { + workspaces[workspaceID] = workspaceProto.name + } + } + + for agentProto in update.upsertedAgents { + guard let agentID = UUID(uuidData: agentProto.id) else { + continue + } + guard let workspaceID = UUID(uuidData: agentProto.workspaceID) else { + continue + } + let workspaceName = workspaces[workspaceID] ?? "Unknown Workspace" + let newAgent = Agent( + id: agentID, + name: agentProto.name, + // If last handshake was not within last five minutes, the agent is unhealthy + status: agentProto.lastHandshake.date > Date.now.addingTimeInterval(-300) ? .okay : .off, + copyableDNS: agentProto.fqdn.first ?? "UNKNOWN", + wsName: workspaceName, + wsID: workspaceID + ) + + // An existing agent with the same name, belonging to the same workspace + // is from a previous workspace build, and should be removed. + agents + .filter { $0.value.name == agentProto.name && $0.value.wsID == workspaceID } + .forEach { agents[$0.key] = nil } + + agents[agentID] = newAgent } } +} - func onExtensionError(_ error: NSError) { - logger.error("network extension reported error: \(error)") - tunnelState = .failed(.internalError(error.localizedDescription)) +extension CoderVPNService { + @objc private func vpnDidUpdate(_ notification: Notification) { + guard let connection = notification.object as? NETunnelProviderSession else { + return + } + switch connection.status { + case .disconnected: + if terminating { + NSApp.reply(toApplicationShouldTerminate: true) + } + connection.fetchLastDisconnectError { err in + self.tunnelState = if let err { + .failed(.internalError(err.localizedDescription)) + } else { + .disabled + } + } + case .connecting: + tunnelState = .connecting + case .connected: + tunnelState = .connected + case .reasserting: + tunnelState = .connecting + case .disconnecting: + tunnelState = .disconnecting + case .invalid: + tunnelState = .failed(.networkExtensionError(.unconfigured)) + @unknown default: + tunnelState = .disabled + } } } diff --git a/Coder Desktop/Coder Desktop/Views/Agent.swift b/Coder Desktop/Coder Desktop/Views/Agent.swift index 7b5bbc28..a24a5f79 100644 --- a/Coder Desktop/Coder Desktop/Views/Agent.swift +++ b/Coder Desktop/Coder Desktop/Views/Agent.swift @@ -1,18 +1,27 @@ import SwiftUI -struct Agent: Identifiable, Equatable { +struct Agent: Identifiable, Equatable, Comparable { let id: UUID let name: String let status: AgentStatus let copyableDNS: String - let workspaceName: String + let wsName: String + let wsID: UUID + + // Agents are sorted by status, and then by name + static func < (lhs: Agent, rhs: Agent) -> Bool { + if lhs.status != rhs.status { + return lhs.status < rhs.status + } + return lhs.wsName.localizedCompare(rhs.wsName) == .orderedAscending + } } -enum AgentStatus: Equatable { - case okay - case warn - case error - case off +enum AgentStatus: Int, Equatable, Comparable { + case okay = 0 + case warn = 1 + case error = 2 + case off = 3 public var color: Color { switch self { @@ -22,16 +31,20 @@ enum AgentStatus: Equatable { case .off: .gray } } + + static func < (lhs: AgentStatus, rhs: AgentStatus) -> Bool { + lhs.rawValue < rhs.rawValue + } } struct AgentRowView: View { - let workspace: Agent + let agent: Agent let baseAccessURL: URL @State private var nameIsSelected: Bool = false @State private var copyIsSelected: Bool = false private var fmtWsName: AttributedString { - var formattedName = AttributedString(workspace.name) + var formattedName = AttributedString(agent.wsName) formattedName.foregroundColor = .primary var coderPart = AttributedString(".coder") coderPart.foregroundColor = .gray @@ -41,7 +54,7 @@ struct AgentRowView: View { private var wsURL: URL { // TODO: CoderVPN currently only supports owned workspaces - baseAccessURL.appending(path: "@me").appending(path: workspace.workspaceName) + baseAccessURL.appending(path: "@me").appending(path: agent.wsName) } var body: some View { @@ -50,10 +63,10 @@ struct AgentRowView: View { HStack(spacing: Theme.Size.trayPadding) { ZStack { Circle() - .fill(workspace.status.color.opacity(0.4)) + .fill(agent.status.color.opacity(0.4)) .frame(width: 12, height: 12) Circle() - .fill(workspace.status.color.opacity(1.0)) + .fill(agent.status.color.opacity(1.0)) .frame(width: 7, height: 7) } Text(fmtWsName).lineLimit(1).truncationMode(.tail) @@ -69,7 +82,7 @@ struct AgentRowView: View { }.buttonStyle(.plain) Button { // TODO: Proper clipboard abstraction - NSPasteboard.general.setString(workspace.copyableDNS, forType: .string) + NSPasteboard.general.setString(agent.copyableDNS, forType: .string) } label: { Image(systemName: "doc.on.doc") .symbolVariant(.fill) diff --git a/Coder Desktop/Coder Desktop/Views/Agents.swift b/Coder Desktop/Coder Desktop/Views/Agents.swift index 35333c97..949ab109 100644 --- a/Coder Desktop/Coder Desktop/Views/Agents.swift +++ b/Coder Desktop/Coder Desktop/Views/Agents.swift @@ -10,11 +10,12 @@ struct Agents: View { var body: some View { Group { - // Workspaces List + // Agents List if vpn.state == .connected { - let visibleData = viewAll ? vpn.agents : Array(vpn.agents.prefix(defaultVisibleRows)) - ForEach(visibleData, id: \.id) { workspace in - AgentRowView(workspace: workspace, baseAccessURL: session.baseAccessURL!) + let sortedAgents = vpn.agents.values.sorted() + let visibleData = viewAll ? sortedAgents[...] : sortedAgents.prefix(defaultVisibleRows) + ForEach(visibleData, id: \.id) { agent in + AgentRowView(agent: agent, baseAccessURL: session.baseAccessURL!) .padding(.horizontal, Theme.Size.trayMargin) } if vpn.agents.count > defaultVisibleRows { diff --git a/Coder Desktop/Coder Desktop/Views/Util.swift b/Coder Desktop/Coder Desktop/Views/Util.swift index ce61c667..693dc935 100644 --- a/Coder Desktop/Coder Desktop/Views/Util.swift +++ b/Coder Desktop/Coder Desktop/Views/Util.swift @@ -12,3 +12,22 @@ final class Inspection { } } } + +extension UUID { + var uuidData: Data { + withUnsafePointer(to: uuid) { + Data(bytes: $0, count: MemoryLayout.size(ofValue: uuid)) + } + } + + init?(uuidData: Data) { + guard uuidData.count == 16 else { + return nil + } + var uuid: uuid_t = (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) + withUnsafeMutableBytes(of: &uuid) { + $0.copyBytes(from: uuidData) + } + self.init(uuid: uuid) + } +} diff --git a/Coder Desktop/Coder Desktop/XPCInterface.swift b/Coder Desktop/Coder Desktop/XPCInterface.swift index 6c0861c6..4bdc2b22 100644 --- a/Coder Desktop/Coder Desktop/XPCInterface.swift +++ b/Coder Desktop/Coder Desktop/XPCInterface.swift @@ -1,6 +1,7 @@ import Foundation +import NetworkExtension import os -import VPNXPC +import VPNLib @objc final class VPNXPCInterface: NSObject, VPNXPCClientCallbackProtocol, @unchecked Sendable { private var svc: CoderVPNService @@ -49,22 +50,4 @@ import VPNXPC svc.onExtensionPeerUpdate(data) } } - - func onStart() { - Task { @MainActor in - svc.onExtensionStart() - } - } - - func onStop() { - Task { @MainActor in - svc.onExtensionStop() - } - } - - func onError(_ err: NSError) { - Task { @MainActor in - svc.onExtensionError(err) - } - } } diff --git a/Coder Desktop/Coder DesktopTests/AgentsTests.swift b/Coder Desktop/Coder DesktopTests/AgentsTests.swift index 537bbfd2..8e06c8df 100644 --- a/Coder Desktop/Coder DesktopTests/AgentsTests.swift +++ b/Coder Desktop/Coder DesktopTests/AgentsTests.swift @@ -18,16 +18,18 @@ struct AgentsTests { view = sut.environmentObject(vpn).environmentObject(session) } - private func createMockAgents(count: Int) -> [Agent] { - (1 ... count).map { - Agent( + private func createMockAgents(count: Int) -> [UUID: Agent] { + Dictionary(uniqueKeysWithValues: (1 ... count).map { + let agent = Agent( id: UUID(), - name: "a\($0)", + name: "dev", status: .okay, copyableDNS: "a\($0).example.com", - workspaceName: "w\($0)" + wsName: "a\($0)", + wsID: UUID() ) - } + return (agent.id, agent) + }) } @Test @@ -46,6 +48,7 @@ struct AgentsTests { let forEach = try view.inspect().find(ViewType.ForEach.self) #expect(forEach.count == Theme.defaultVisibleAgents) + // Agents are sorted by status, and then by name in alphabetical order #expect(throws: Never.self) { try view.inspect().find(link: "a1.coder") } } diff --git a/Coder Desktop/Coder DesktopTests/Util.swift b/Coder Desktop/Coder DesktopTests/Util.swift index 2cf4d38e..d224615e 100644 --- a/Coder Desktop/Coder DesktopTests/Util.swift +++ b/Coder Desktop/Coder DesktopTests/Util.swift @@ -8,7 +8,7 @@ import ViewInspector class MockVPNService: VPNService, ObservableObject { @Published var state: Coder_Desktop.VPNServiceState = .disabled @Published var baseAccessURL: URL = .init(string: "https://dev.coder.com")! - @Published var agents: [Coder_Desktop.Agent] = [] + @Published var agents: [UUID: Coder_Desktop.Agent] = [:] var onStart: (() async -> Void)? var onStop: (() async -> Void)? diff --git a/Coder Desktop/Coder DesktopTests/VPNMenuTests.swift b/Coder Desktop/Coder DesktopTests/VPNMenuTests.swift index 6aaf5b06..4b446ac0 100644 --- a/Coder Desktop/Coder DesktopTests/VPNMenuTests.swift +++ b/Coder Desktop/Coder DesktopTests/VPNMenuTests.swift @@ -111,7 +111,7 @@ struct VPNMenuTests { #expect(try !toggle.isOn()) vpn.onStart = { - vpn.state = .failed(.longTestError) + vpn.state = .failed(.internalError("This is a long error message!")) } await vpn.start() diff --git a/Coder Desktop/Coder DesktopTests/VPNServiceTests.swift b/Coder Desktop/Coder DesktopTests/VPNServiceTests.swift new file mode 100644 index 00000000..9d1370a3 --- /dev/null +++ b/Coder Desktop/Coder DesktopTests/VPNServiceTests.swift @@ -0,0 +1,116 @@ +@testable import Coder_Desktop +import Testing +@testable import VPNLib + +@MainActor +@Suite +struct CoderVPNServiceTests { + let service = CoderVPNService() + + init() { + service.workspaces = [:] + service.agents = [:] + } + + @Test + func testApplyPeerUpdate_upsertsAgents() async throws { + let agentID = UUID() + let workspaceID = UUID() + service.workspaces[workspaceID] = "foo" + + let update = Vpn_PeerUpdate.with { + $0.upsertedAgents = [Vpn_Agent.with { + $0.id = agentID.uuidData + $0.workspaceID = workspaceID.uuidData + $0.name = "dev" + $0.lastHandshake = .init(date: Date.now) + $0.fqdn = ["foo.coder"] + }] + } + + service.applyPeerUpdate(with: update) + + let agent = try #require(service.agents[agentID]) + #expect(agent.name == "dev") + #expect(agent.wsID == workspaceID) + #expect(agent.wsName == "foo") + #expect(agent.copyableDNS == "foo.coder") + #expect(agent.status == .okay) + } + + @Test + func testApplyPeerUpdate_deletesAgentsAndWorkspaces() async throws { + let agentID = UUID() + let workspaceID = UUID() + + service.agents[agentID] = Agent( + id: agentID, name: "agent1", status: .okay, + copyableDNS: "foo.coder", wsName: "foo", wsID: workspaceID + ) + service.workspaces[workspaceID] = "foo" + + let update = Vpn_PeerUpdate.with { + $0.deletedAgents = [Vpn_Agent.with { $0.id = agentID.uuidData }] + $0.deletedWorkspaces = [Vpn_Workspace.with { $0.id = workspaceID.uuidData }] + } + + service.applyPeerUpdate(with: update) + + #expect(service.agents[agentID] == nil) + #expect(service.workspaces[workspaceID] == nil) + } + + @Test + func testApplyPeerUpdate_unhealthyAgent() async throws { + let agentID = UUID() + let workspaceID = UUID() + service.workspaces[workspaceID] = "foo" + + let update = Vpn_PeerUpdate.with { + $0.upsertedAgents = [Vpn_Agent.with { + $0.id = agentID.uuidData + $0.workspaceID = workspaceID.uuidData + $0.name = "agent1" + $0.lastHandshake = .init(date: Date.now.addingTimeInterval(-600)) + $0.fqdn = ["foo.coder"] + }] + } + + service.applyPeerUpdate(with: update) + + let agent = try #require(service.agents[agentID]) + #expect(agent.status == .off) + } + + @Test + func testApplyPeerUpdate_replaceOldAgent() async throws { + let workspaceID = UUID() + let oldAgentID = UUID() + let newAgentID = UUID() + service.workspaces[workspaceID] = "foo" + + service.agents[oldAgentID] = Agent( + id: oldAgentID, name: "agent1", status: .off, + copyableDNS: "foo.coder", wsName: "foo", wsID: workspaceID + ) + + let update = Vpn_PeerUpdate.with { + $0.upsertedAgents = [Vpn_Agent.with { + $0.id = newAgentID.uuidData + $0.workspaceID = workspaceID.uuidData + $0.name = "agent1" // Same name as old agent + $0.lastHandshake = .init(date: Date.now) + $0.fqdn = ["foo.coder"] + }] + } + + service.applyPeerUpdate(with: update) + + #expect(service.agents[oldAgentID] == nil) + let newAgent = try #require(service.agents[newAgentID]) + #expect(newAgent.name == "agent1") + #expect(newAgent.wsID == workspaceID) + #expect(newAgent.copyableDNS == "foo.coder") + #expect(newAgent.status == .okay) + } +} diff --git a/Coder Desktop/Coder DesktopTests/VPNStateTests.swift b/Coder Desktop/Coder DesktopTests/VPNStateTests.swift index 4d826a5f..4d630cd0 100644 --- a/Coder Desktop/Coder DesktopTests/VPNStateTests.swift +++ b/Coder Desktop/Coder DesktopTests/VPNStateTests.swift @@ -55,12 +55,13 @@ struct VPNStateTests { @Test func testFailedState() async throws { - vpn.state = .failed(.longTestError) + let errMsg = "Internal error occured!" + vpn.state = .failed(.internalError(errMsg)) try await ViewHosting.host(view.environmentObject(vpn)) { try await sut.inspection.inspect { view in let text = try view.find(ViewType.Text.self) - #expect(try text.string() == VPNServiceError.longTestError.description) + #expect(try text.string() == "Internal Error: \(errMsg)") } } } diff --git a/Coder Desktop/VPN/Manager.swift b/Coder Desktop/VPN/Manager.swift index 05a42412..ee2adc50 100644 --- a/Coder Desktop/VPN/Manager.swift +++ b/Coder Desktop/VPN/Manager.swift @@ -2,7 +2,6 @@ import CoderSDK import NetworkExtension import os import VPNLib -import VPNXPC actor Manager { let ptp: PacketTunnelProvider @@ -86,16 +85,12 @@ actor Manager { } catch { logger.error("tunnel read loop failed: \(error.localizedDescription, privacy: .public)") try await tunnelHandle.close() - if let conn = globalXPCListenerDelegate.getActiveConnection() { - conn.onError(error as NSError) - } + ptp.cancelTunnelWithError(error) return } logger.info("tunnel read loop exited") try await tunnelHandle.close() - if let conn = globalXPCListenerDelegate.getActiveConnection() { - conn.onStop() - } + ptp.cancelTunnelWithError(nil) } func handleMessage(_ msg: Vpn_TunnelMessage) { @@ -105,7 +100,7 @@ actor Manager { } switch msgType { case .peerUpdate: - if let conn = globalXPCListenerDelegate.getActiveConnection() { + if let conn = globalXPCListenerDelegate.conn { do { let data = try msg.peerUpdate.serializedData() conn.onPeerUpdate(data) diff --git a/Coder Desktop/VPN/PacketTunnelProvider.swift b/Coder Desktop/VPN/PacketTunnelProvider.swift index 33020cd7..3cad498b 100644 --- a/Coder Desktop/VPN/PacketTunnelProvider.swift +++ b/Coder Desktop/VPN/PacketTunnelProvider.swift @@ -1,7 +1,6 @@ import NetworkExtension import os import VPNLib -import VPNXPC /* From */ let CTLIOCGINFO: UInt = 0xC064_4E03 @@ -77,23 +76,13 @@ class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { apiToken: token, serverUrl: .init(string: baseAccessURL)! ) ) - globalXPCListenerDelegate.vpnXPCInterface.setManager(manager) + globalXPCListenerDelegate.vpnXPCInterface.manager = manager logger.debug("starting vpn") try await manager!.startVPN() logger.info("vpn started") - if let conn = globalXPCListenerDelegate.getActiveConnection() { - conn.onStart() - } else { - logger.info("no active XPC connection") - } completionHandler(nil) } catch { logger.error("error starting manager: \(error.description, privacy: .public)") - if let conn = globalXPCListenerDelegate.getActiveConnection() { - conn.onError(error as NSError) - } else { - logger.info("no active XPC connection") - } completionHandler(error as NSError) } } @@ -116,12 +105,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { } catch { logger.error("error stopping manager: \(error.description, privacy: .public)") } - if let conn = globalXPCListenerDelegate.getActiveConnection() { - conn.onStop() - } else { - logger.info("no active XPC connection") - } - globalXPCListenerDelegate.vpnXPCInterface.setManager(nil) + globalXPCListenerDelegate.vpnXPCInterface.manager = nil completionHandler() } self.manager = nil diff --git a/Coder Desktop/VPN/XPCInterface.swift b/Coder Desktop/VPN/XPCInterface.swift index 3520fe8e..a71b12b7 100644 --- a/Coder Desktop/VPN/XPCInterface.swift +++ b/Coder Desktop/VPN/XPCInterface.swift @@ -1,28 +1,27 @@ import Foundation import os.log import VPNLib -import VPNXPC @objc final class XPCInterface: NSObject, VPNXPCProtocol, @unchecked Sendable { - private var manager: Manager? + private var manager_: Manager? private let managerLock = NSLock() private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "VPNXPCInterface") - func setManager(_ newManager: Manager?) { - managerLock.lock() - defer { managerLock.unlock() } - manager = newManager - } - - func getManager() -> Manager? { - managerLock.lock() - defer { managerLock.unlock() } - let m = manager - - return m + var manager: Manager? { + get { + managerLock.lock() + defer { managerLock.unlock() } + return manager_ + } + set { + managerLock.lock() + defer { managerLock.unlock() } + manager_ = newValue + } } func getPeerInfo(with reply: @escaping () -> Void) { + // TODO: Retrieve from Manager reply() } diff --git a/Coder Desktop/VPN/main.swift b/Coder Desktop/VPN/main.swift index d350d8dd..1055fc07 100644 --- a/Coder Desktop/VPN/main.swift +++ b/Coder Desktop/VPN/main.swift @@ -1,21 +1,21 @@ import Foundation import NetworkExtension import os -import VPNXPC +import VPNLib let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "provider") final class XPCListenerDelegate: NSObject, NSXPCListenerDelegate, @unchecked Sendable { let vpnXPCInterface = XPCInterface() - var activeConnection: NSXPCConnection? - var connMutex: NSLock = .init() + private var activeConnection: NSXPCConnection? + private var connMutex: NSLock = .init() - func getActiveConnection() -> VPNXPCClientCallbackProtocol? { + var conn: VPNXPCClientCallbackProtocol? { connMutex.lock() defer { connMutex.unlock() } - let client = activeConnection?.remoteObjectProxy as? VPNXPCClientCallbackProtocol - return client + let conn = activeConnection?.remoteObjectProxy as? VPNXPCClientCallbackProtocol + return conn } func setActiveConnection(_ connection: NSXPCConnection?) { diff --git a/Coder Desktop/VPNLib/Convert.swift b/Coder Desktop/VPNLib/Convert.swift index 6784693f..5acec02c 100644 --- a/Coder Desktop/VPNLib/Convert.swift +++ b/Coder Desktop/VPNLib/Convert.swift @@ -1,5 +1,6 @@ import NetworkExtension import os +import SwiftProtobuf public func convertDnsSettings(_ req: Vpn_NetworkSettingsRequest.DNSSettings) -> NEDNSSettings { let dnsSettings = NEDNSSettings(servers: req.servers) @@ -59,3 +60,11 @@ public func convertIPv6Settings(_ req: Vpn_NetworkSettingsRequest.IPv6Settings) } return ipv6Settings } + +extension Google_Protobuf_Timestamp { + var date: Date { + let seconds = TimeInterval(seconds) + let nanos = TimeInterval(nanos) / 1_000_000_000 + return Date(timeIntervalSince1970: seconds + nanos) + } +} diff --git a/Coder Desktop/VPNXPC/Protocol.swift b/Coder Desktop/VPNLib/XPC.swift similarity index 68% rename from Coder Desktop/VPNXPC/Protocol.swift rename to Coder Desktop/VPNLib/XPC.swift index 598a9051..ffbf6d85 100644 --- a/Coder Desktop/VPNXPC/Protocol.swift +++ b/Coder Desktop/VPNLib/XPC.swift @@ -8,9 +8,6 @@ import Foundation @preconcurrency @objc public protocol VPNXPCClientCallbackProtocol { - /// Called when the server has a status update to share + // data is a serialized `Vpn_PeerUpdate` func onPeerUpdate(_ data: Data) - func onStart() - func onStop() - func onError(_ err: NSError) } diff --git a/Coder Desktop/VPNLib/vpn.pb.swift b/Coder Desktop/VPNLib/vpn.pb.swift index e3bdd3b3..0dd7238b 100644 --- a/Coder Desktop/VPNLib/vpn.pb.swift +++ b/Coder Desktop/VPNLib/vpn.pb.swift @@ -393,7 +393,7 @@ public struct Vpn_Agent: @unchecked Sendable { /// UUID public var workspaceID: Data = Data() - public var fqdn: String = String() + public var fqdn: [String] = [] public var ipAddrs: [String] = [] @@ -597,8 +597,25 @@ public struct Vpn_StartRequest: Sendable { public var apiToken: String = String() + public var headers: [Vpn_StartRequest.Header] = [] + public var unknownFields = SwiftProtobuf.UnknownStorage() + /// Additional HTTP headers added to all requests + public struct Header: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var name: String = String() + + public var value: String = String() + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} + } + public init() {} } @@ -1176,7 +1193,7 @@ extension Vpn_Agent: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementation case 1: try { try decoder.decodeSingularBytesField(value: &self.id) }() case 2: try { try decoder.decodeSingularStringField(value: &self.name) }() case 3: try { try decoder.decodeSingularBytesField(value: &self.workspaceID) }() - case 4: try { try decoder.decodeSingularStringField(value: &self.fqdn) }() + case 4: try { try decoder.decodeRepeatedStringField(value: &self.fqdn) }() case 5: try { try decoder.decodeRepeatedStringField(value: &self.ipAddrs) }() case 6: try { try decoder.decodeSingularMessageField(value: &self._lastHandshake) }() default: break @@ -1199,7 +1216,7 @@ extension Vpn_Agent: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementation try visitor.visitSingularBytesField(value: self.workspaceID, fieldNumber: 3) } if !self.fqdn.isEmpty { - try visitor.visitSingularStringField(value: self.fqdn, fieldNumber: 4) + try visitor.visitRepeatedStringField(value: self.fqdn, fieldNumber: 4) } if !self.ipAddrs.isEmpty { try visitor.visitRepeatedStringField(value: self.ipAddrs, fieldNumber: 5) @@ -1632,6 +1649,7 @@ extension Vpn_StartRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImpleme 1: .standard(proto: "tunnel_file_descriptor"), 2: .standard(proto: "coder_url"), 3: .standard(proto: "api_token"), + 4: .same(proto: "headers"), ] public mutating func decodeMessage(decoder: inout D) throws { @@ -1643,6 +1661,7 @@ extension Vpn_StartRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImpleme case 1: try { try decoder.decodeSingularInt32Field(value: &self.tunnelFileDescriptor) }() case 2: try { try decoder.decodeSingularStringField(value: &self.coderURL) }() case 3: try { try decoder.decodeSingularStringField(value: &self.apiToken) }() + case 4: try { try decoder.decodeRepeatedMessageField(value: &self.headers) }() default: break } } @@ -1658,6 +1677,9 @@ extension Vpn_StartRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImpleme if !self.apiToken.isEmpty { try visitor.visitSingularStringField(value: self.apiToken, fieldNumber: 3) } + if !self.headers.isEmpty { + try visitor.visitRepeatedMessageField(value: self.headers, fieldNumber: 4) + } try unknownFields.traverse(visitor: &visitor) } @@ -1665,6 +1687,45 @@ extension Vpn_StartRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImpleme if lhs.tunnelFileDescriptor != rhs.tunnelFileDescriptor {return false} if lhs.coderURL != rhs.coderURL {return false} if lhs.apiToken != rhs.apiToken {return false} + if lhs.headers != rhs.headers {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Vpn_StartRequest.Header: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = Vpn_StartRequest.protoMessageName + ".Header" + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "name"), + 2: .same(proto: "value"), + ] + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &self.name) }() + case 2: try { try decoder.decodeSingularStringField(value: &self.value) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if !self.name.isEmpty { + try visitor.visitSingularStringField(value: self.name, fieldNumber: 1) + } + if !self.value.isEmpty { + try visitor.visitSingularStringField(value: self.value, fieldNumber: 2) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Vpn_StartRequest.Header, rhs: Vpn_StartRequest.Header) -> Bool { + if lhs.name != rhs.name {return false} + if lhs.value != rhs.value {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } diff --git a/Coder Desktop/VPNLib/vpn.proto b/Coder Desktop/VPNLib/vpn.proto index 1d21f7ca..9d9c2435 100644 --- a/Coder Desktop/VPNLib/vpn.proto +++ b/Coder Desktop/VPNLib/vpn.proto @@ -17,55 +17,55 @@ package vpn; // msg_id which it sets on the request, the responder sets response_to that msg_id on the response // message message RPC { - uint64 msg_id = 1; - uint64 response_to = 2; + uint64 msg_id = 1; + uint64 response_to = 2; } // ManagerMessage is a message from the manager (to the tunnel). message ManagerMessage { - RPC rpc = 1; - oneof msg { - GetPeerUpdate get_peer_update = 2; - NetworkSettingsResponse network_settings = 3; - StartRequest start = 4; - StopRequest stop = 5; - } + RPC rpc = 1; + oneof msg { + GetPeerUpdate get_peer_update = 2; + NetworkSettingsResponse network_settings = 3; + StartRequest start = 4; + StopRequest stop = 5; + } } // TunnelMessage is a message from the tunnel (to the manager). message TunnelMessage { - RPC rpc = 1; - oneof msg { - Log log = 2; - PeerUpdate peer_update = 3; - NetworkSettingsRequest network_settings = 4; - StartResponse start = 5; - StopResponse stop = 6; - } + RPC rpc = 1; + oneof msg { + Log log = 2; + PeerUpdate peer_update = 3; + NetworkSettingsRequest network_settings = 4; + StartResponse start = 5; + StopResponse stop = 6; + } } // Log is a log message generated by the tunnel. The manager should log it to the system log. It is // one-way tunnel -> manager with no response. message Log { - enum Level { - // these are designed to match slog levels - DEBUG = 0; - INFO = 1; - WARN = 2; - ERROR = 3; - CRITICAL = 4; - FATAL = 5; - } - Level level = 1; - - string message = 2; - repeated string logger_names = 3; - - message Field { - string name = 1; - string value = 2; - } - repeated Field fields = 4; + enum Level { + // these are designed to match slog levels + DEBUG = 0; + INFO = 1; + WARN = 2; + ERROR = 3; + CRITICAL = 4; + FATAL = 5; + } + Level level = 1; + + string message = 2; + repeated string logger_names = 3; + + message Field { + string name = 1; + string value = 2; + } + repeated Field fields = 4; } // GetPeerUpdate asks for a PeerUpdate with a full set of data. @@ -75,115 +75,121 @@ message GetPeerUpdate {} // response to GetPeerUpdate (which dumps the full set). It is also generated on any changes (not in // response to any request). message PeerUpdate { - repeated Workspace upserted_workspaces = 1; - repeated Agent upserted_agents = 2; - repeated Workspace deleted_workspaces = 3; - repeated Agent deleted_agents = 4; + repeated Workspace upserted_workspaces = 1; + repeated Agent upserted_agents = 2; + repeated Workspace deleted_workspaces = 3; + repeated Agent deleted_agents = 4; } message Workspace { - bytes id = 1; // UUID - string name = 2; - - enum Status { - UNKNOWN = 0; - PENDING = 1; - STARTING = 2; - RUNNING = 3; - STOPPING = 4; - STOPPED = 5; - FAILED = 6; - CANCELING = 7; - CANCELED = 8; - DELETING = 9; - DELETED = 10; - } - Status status = 3; + bytes id = 1; // UUID + string name = 2; + + enum Status { + UNKNOWN = 0; + PENDING = 1; + STARTING = 2; + RUNNING = 3; + STOPPING = 4; + STOPPED = 5; + FAILED = 6; + CANCELING = 7; + CANCELED = 8; + DELETING = 9; + DELETED = 10; + } + Status status = 3; } message Agent { - bytes id = 1; // UUID - string name = 2; - bytes workspace_id = 3; // UUID - string fqdn = 4; - repeated string ip_addrs = 5; - // last_handshake is the primary indicator of whether we are connected to a peer. Zero value or - // anything longer than 5 minutes ago means there is a problem. - google.protobuf.Timestamp last_handshake = 6; + bytes id = 1; // UUID + string name = 2; + bytes workspace_id = 3; // UUID + repeated string fqdn = 4; + repeated string ip_addrs = 5; + // last_handshake is the primary indicator of whether we are connected to a peer. Zero value or + // anything longer than 5 minutes ago means there is a problem. + google.protobuf.Timestamp last_handshake = 6; } // NetworkSettingsRequest is based on // https://developer.apple.com/documentation/networkextension/nepackettunnelnetworksettings for // macOS. It is a request/response message with response NetworkSettingsResponse message NetworkSettingsRequest { - uint32 tunnel_overhead_bytes = 1; - uint32 mtu = 2; - - message DNSSettings { - repeated string servers = 1; - repeated string search_domains = 2; - // domain_name is the primary domain name of the tunnel - string domain_name = 3; - repeated string match_domains = 4; - // match_domains_no_search specifies if the domains in the matchDomains list should not be - // appended to the resolver’s list of search domains. - bool match_domains_no_search = 5; - } - DNSSettings dns_settings = 3; - - string tunnel_remote_address = 4; - - message IPv4Settings { - repeated string addrs = 1; - repeated string subnet_masks = 2; - // router is the next-hop router in dotted-decimal format - string router = 3; - - message IPv4Route { - string destination = 1; - string mask = 2; - // router is the next-hop router in dotted-decimal format - string router = 3; - } - repeated IPv4Route included_routes = 4; - repeated IPv4Route excluded_routes = 5; - } - IPv4Settings ipv4_settings = 5; - - message IPv6Settings { - repeated string addrs = 1; - repeated uint32 prefix_lengths = 2; - - message IPv6Route { - string destination = 1; - uint32 prefix_length = 2; - // router is the address of the next-hop - string router = 3; - } - repeated IPv6Route included_routes = 3; - repeated IPv6Route excluded_routes = 4; - } - IPv6Settings ipv6_settings = 6; + uint32 tunnel_overhead_bytes = 1; + uint32 mtu = 2; + + message DNSSettings { + repeated string servers = 1; + repeated string search_domains = 2; + // domain_name is the primary domain name of the tunnel + string domain_name = 3; + repeated string match_domains = 4; + // match_domains_no_search specifies if the domains in the matchDomains list should not be + // appended to the resolver’s list of search domains. + bool match_domains_no_search = 5; + } + DNSSettings dns_settings = 3; + + string tunnel_remote_address = 4; + + message IPv4Settings { + repeated string addrs = 1; + repeated string subnet_masks = 2; + // router is the next-hop router in dotted-decimal format + string router = 3; + + message IPv4Route { + string destination = 1; + string mask = 2; + // router is the next-hop router in dotted-decimal format + string router = 3; + } + repeated IPv4Route included_routes = 4; + repeated IPv4Route excluded_routes = 5; + } + IPv4Settings ipv4_settings = 5; + + message IPv6Settings { + repeated string addrs = 1; + repeated uint32 prefix_lengths = 2; + + message IPv6Route { + string destination = 1; + uint32 prefix_length = 2; + // router is the address of the next-hop + string router = 3; + } + repeated IPv6Route included_routes = 3; + repeated IPv6Route excluded_routes = 4; + } + IPv6Settings ipv6_settings = 6; } // NetworkSettingsResponse is the response from the manager to the tunnel for a // NetworkSettingsRequest message NetworkSettingsResponse { - bool success = 1; - string error_message = 2; + bool success = 1; + string error_message = 2; } // StartRequest is a request from the manager to start the tunnel. The tunnel replies with a // StartResponse. message StartRequest { - int32 tunnel_file_descriptor = 1; - string coder_url = 2; - string api_token = 3; + int32 tunnel_file_descriptor = 1; + string coder_url = 2; + string api_token = 3; + // Additional HTTP headers added to all requests + message Header { + string name = 1; + string value = 2; + } + repeated Header headers = 4; } message StartResponse { - bool success = 1; - string error_message = 2; + bool success = 1; + string error_message = 2; } // StopRequest is a request from the manager to stop the tunnel. The tunnel replies with a @@ -193,6 +199,6 @@ message StopRequest {} // StopResponse is a response to stopping the tunnel. After sending this response, the tunnel closes // its side of the bidirectional stream for writing. message StopResponse { - bool success = 1; - string error_message = 2; + bool success = 1; + string error_message = 2; } diff --git a/Coder Desktop/VPNXPC/VPNXPC.h b/Coder Desktop/VPNXPC/VPNXPC.h deleted file mode 100644 index 0fb9c0e4..00000000 --- a/Coder Desktop/VPNXPC/VPNXPC.h +++ /dev/null @@ -1,11 +0,0 @@ -#import - -//! Project version number for VPNXPC. -FOUNDATION_EXPORT double VPNXPCVersionNumber; - -//! Project version string for VPNXPC. -FOUNDATION_EXPORT const unsigned char VPNXPCVersionString[]; - -// In this header, you should import all the public headers of your framework using statements like #import - - diff --git a/Coder Desktop/project.yml b/Coder Desktop/project.yml index 2c23c886..255bc538 100644 --- a/Coder Desktop/project.yml +++ b/Coder Desktop/project.yml @@ -2,7 +2,7 @@ name: "Coder Desktop" options: bundleIdPrefix: com.coder deploymentTarget: - macOS: "14.6" + macOS: "14.0" xcodeVersion: "1600" minimumXcodeGenVersion: "2.42.0" @@ -146,7 +146,7 @@ targets: dependencies: - target: CoderSDK embed: true - - target: VPNXPC + - target: VPNLib embed: true - target: VPN embed: without-signing # Embed without signing. @@ -220,8 +220,6 @@ targets: embed: true - target: CoderSDK embed: true - - target: VPNXPC - embed: true - sdk: NetworkExtension.framework VPNLib: @@ -299,20 +297,4 @@ targets: settings: base: TEST_HOST: "$(BUILT_PRODUCTS_DIR)/Coder Desktop.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Coder Desktop" - PRODUCT_BUNDLE_IDENTIFIER: com.coder.Coder-Desktop.CoderSDKTests - - VPNXPC: - type: framework - platform: macOS - sources: - - path: VPNXPC - settings: - base: - INFOPLIST_KEY_NSHumanReadableCopyright: "" - PRODUCT_NAME: "$(TARGET_NAME:c99extidentifier)" - SWIFT_EMIT_LOC_STRINGS: YES - GENERATE_INFOPLIST_FILE: YES - DYLIB_COMPATIBILITY_VERSION: 1 - DYLIB_CURRENT_VERSION: 1 - DYLIB_INSTALL_NAME_BASE: "@rpath" - dependencies: [] + PRODUCT_BUNDLE_IDENTIFIER: com.coder.Coder-Desktop.CoderSDKTests \ No newline at end of file From f53faddd889f495d7e2a0b8bb5e2fa37b8cc5ad9 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 4 Feb 2025 03:17:01 +1100 Subject: [PATCH 2/2] better locked variable name --- Coder Desktop/VPN/XPCInterface.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Coder Desktop/VPN/XPCInterface.swift b/Coder Desktop/VPN/XPCInterface.swift index a71b12b7..6ecb1199 100644 --- a/Coder Desktop/VPN/XPCInterface.swift +++ b/Coder Desktop/VPN/XPCInterface.swift @@ -3,7 +3,7 @@ import os.log import VPNLib @objc final class XPCInterface: NSObject, VPNXPCProtocol, @unchecked Sendable { - private var manager_: Manager? + private var lockedManager: Manager? private let managerLock = NSLock() private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "VPNXPCInterface") @@ -11,12 +11,12 @@ import VPNLib get { managerLock.lock() defer { managerLock.unlock() } - return manager_ + return lockedManager } set { managerLock.lock() defer { managerLock.unlock() } - manager_ = newValue + lockedManager = newValue } }