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 } } 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