diff --git a/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift b/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift index b952e98..4e7cebc 100644 --- a/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift +++ b/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift @@ -47,9 +47,13 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } + // This function MUST eventually call `NSApp.reply(toApplicationShouldTerminate: true)` + // or return `.terminateNow` func applicationShouldTerminate(_: NSApplication) -> NSApplication.TerminateReply { + if !settings.stopVPNOnQuit { return .terminateNow } Task { - await vpn.quit() + await vpn.stop() + NSApp.reply(toApplicationShouldTerminate: true) } return .terminateLater } diff --git a/Coder Desktop/Coder Desktop/NetworkExtension.swift b/Coder Desktop/Coder Desktop/NetworkExtension.swift index 28aa78f..16d18bb 100644 --- a/Coder Desktop/Coder Desktop/NetworkExtension.swift +++ b/Coder Desktop/Coder Desktop/NetworkExtension.swift @@ -24,13 +24,12 @@ enum NetworkExtensionState: Equatable { /// An actor that handles configuring, enabling, and disabling the VPN tunnel via the /// NetworkExtension APIs. extension CoderVPNService { - // Updates the UI if a previous configuration exists - func loadNetworkExtension() async { + func hasNetworkExtensionConfig() async -> Bool { do { - try await getTunnelManager() - neState = .disabled + _ = try await getTunnelManager() + return true } catch { - neState = .unconfigured + return false } } @@ -71,37 +70,29 @@ extension CoderVPNService { } } - func enableNetworkExtension() async { + func startTunnel() async { do { let tm = try await getTunnelManager() - if !tm.isEnabled { - tm.isEnabled = true - try await tm.saveToPreferences() - logger.debug("saved tunnel with enabled=true") - } try tm.connection.startVPNTunnel() } catch { - logger.error("enable network extension: \(error)") + logger.error("start tunnel: \(error)") neState = .failed(error.localizedDescription) return } - logger.debug("enabled and started tunnel") + logger.debug("started tunnel") neState = .enabled } - func disableNetworkExtension() async { + func stopTunnel() async { do { let tm = try await getTunnelManager() tm.connection.stopVPNTunnel() - tm.isEnabled = false - - try await tm.saveToPreferences() } catch { - logger.error("disable network extension: \(error)") + logger.error("stop tunnel: \(error)") neState = .failed(error.localizedDescription) return } - logger.debug("saved tunnel with enabled=false") + logger.debug("stopped tunnel") neState = .disabled } diff --git a/Coder Desktop/Coder Desktop/State.swift b/Coder Desktop/Coder Desktop/State.swift index cb51450..c98a09f 100644 --- a/Coder Desktop/Coder Desktop/State.swift +++ b/Coder Desktop/Coder Desktop/State.swift @@ -104,6 +104,8 @@ class Settings: ObservableObject { } } + @AppStorage(Keys.stopVPNOnQuit) var stopVPNOnQuit = true + init(store: UserDefaults = .standard) { self.store = store _literalHeaders = Published( @@ -116,6 +118,7 @@ class Settings: ObservableObject { enum Keys { static let useLiteralHeaders = "UseLiteralHeaders" static let literalHeaders = "LiteralHeaders" + static let stopVPNOnQuit = "StopVPNOnQuit" } } diff --git a/Coder Desktop/Coder Desktop/VPNService.swift b/Coder Desktop/Coder Desktop/VPNService.swift index 60e7ace..657d994 100644 --- a/Coder Desktop/Coder Desktop/VPNService.swift +++ b/Coder Desktop/Coder Desktop/VPNService.swift @@ -41,7 +41,6 @@ enum VPNServiceError: Error, Equatable { 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 @@ -68,8 +67,14 @@ final class CoderVPNService: NSObject, VPNService { super.init() installSystemExtension() Task { - await loadNetworkExtension() + neState = if await hasNetworkExtensionConfig() { + .disabled + } else { + .unconfigured + } } + xpc.connect() + xpc.getPeerState() NotificationCenter.default.addObserver( self, selector: #selector(vpnDidUpdate(_:)), @@ -82,6 +87,11 @@ final class CoderVPNService: NSObject, VPNService { NotificationCenter.default.removeObserver(self) } + func clearPeers() { + agents = [:] + workspaces = [:] + } + func start() async { switch tunnelState { case .disabled, .failed: @@ -90,31 +100,18 @@ final class CoderVPNService: NSObject, VPNService { return } - await enableNetworkExtension() - // this ping is somewhat load bearing since it causes xpc to init + await startTunnel() + xpc.connect() xpc.ping() logger.debug("network extension enabled") } func stop() async { guard tunnelState == .connected else { return } - await disableNetworkExtension() + await stopTunnel() logger.info("network extension stopped") } - // Instructs the service to stop the VPN and then quit once the stop event - // is read over XPC. - // MUST only be called from `NSApplicationDelegate.applicationShouldTerminate` - // MUST eventually call `NSApp.reply(toApplicationShouldTerminate: true)` - func quit() async { - guard tunnelState == .connected else { - NSApp.reply(toApplicationShouldTerminate: true) - return - } - terminating = true - await stop() - } - func configureTunnelProviderProtocol(proto: NETunnelProviderProtocol?) { Task { if let proto { @@ -145,6 +142,22 @@ final class CoderVPNService: NSObject, VPNService { } } + func onExtensionPeerState(_ data: Data?) { + guard let data else { + logger.error("could not retrieve peer state from network extension, it may not be running") + return + } + logger.info("received network extension peer state") + do { + let msg = try Vpn_PeerUpdate(serializedBytes: data) + debugPrint(msg) + clearPeers() + applyPeerUpdate(with: msg) + } catch { + logger.error("failed to decode peer update \(error)") + } + } + func applyPeerUpdate(with update: Vpn_PeerUpdate) { // Delete agents update.deletedAgents @@ -204,9 +217,6 @@ extension CoderVPNService { } switch connection.status { case .disconnected: - if terminating { - NSApp.reply(toApplicationShouldTerminate: true) - } connection.fetchLastDisconnectError { err in self.tunnelState = if let err { .failed(.internalError(err.localizedDescription)) diff --git a/Coder Desktop/Coder Desktop/Views/Settings/GeneralTab.swift b/Coder Desktop/Coder Desktop/Views/Settings/GeneralTab.swift index 0c1bb9e..1dc1cf9 100644 --- a/Coder Desktop/Coder Desktop/Views/Settings/GeneralTab.swift +++ b/Coder Desktop/Coder Desktop/Views/Settings/GeneralTab.swift @@ -2,11 +2,17 @@ import LaunchAtLogin import SwiftUI struct GeneralTab: View { + @EnvironmentObject var settings: Settings var body: some View { Form { Section { LaunchAtLogin.Toggle("Launch at Login") } + Section { + Toggle(isOn: $settings.stopVPNOnQuit) { + Text("Stop VPN on Quit") + } + } }.formStyle(.grouped) } } diff --git a/Coder Desktop/Coder Desktop/XPCInterface.swift b/Coder Desktop/Coder Desktop/XPCInterface.swift index 4bdc2b2..74baab5 100644 --- a/Coder Desktop/Coder Desktop/XPCInterface.swift +++ b/Coder Desktop/Coder Desktop/XPCInterface.swift @@ -6,11 +6,17 @@ import VPNLib @objc final class VPNXPCInterface: NSObject, VPNXPCClientCallbackProtocol, @unchecked Sendable { private var svc: CoderVPNService private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "VPNXPCInterface") - private let xpc: VPNXPCProtocol + private var xpc: VPNXPCProtocol? init(vpn: CoderVPNService) { svc = vpn + super.init() + } + func connect() { + guard xpc == nil else { + return + } let networkExtDict = Bundle.main.object(forInfoDictionaryKey: "NetworkExtension") as? [String: Any] let machServiceName = networkExtDict?["NEMachServiceName"] as? String let xpcConn = NSXPCConnection(machServiceName: machServiceName!) @@ -21,30 +27,38 @@ import VPNLib } xpc = proxy - super.init() - xpcConn.exportedObject = self xpcConn.invalidationHandler = { [logger] in Task { @MainActor in logger.error("XPC connection invalidated.") + self.xpc = nil } } xpcConn.interruptionHandler = { [logger] in Task { @MainActor in logger.error("XPC connection interrupted.") + self.xpc = nil } } xpcConn.resume() } func ping() { - xpc.ping { + xpc?.ping { Task { @MainActor in self.logger.info("Connected to NE over XPC") } } } + func getPeerState() { + xpc?.getPeerState { data in + Task { @MainActor in + self.svc.onExtensionPeerState(data) + } + } + } + func onPeerUpdate(_ data: Data) { Task { @MainActor in svc.onExtensionPeerUpdate(data) diff --git a/Coder Desktop/VPN/Manager.swift b/Coder Desktop/VPN/Manager.swift index ee2adc5..c938818 100644 --- a/Coder Desktop/VPN/Manager.swift +++ b/Coder Desktop/VPN/Manager.swift @@ -194,7 +194,7 @@ actor Manager { // Retrieves the current state of all peers, // as required when starting the app whilst the network extension is already running - func getPeerInfo() async throws(ManagerError) -> Vpn_PeerUpdate { + func getPeerState() async throws(ManagerError) -> Vpn_PeerUpdate { logger.info("sending peer state request") let resp: Vpn_TunnelMessage do { diff --git a/Coder Desktop/VPN/XPCInterface.swift b/Coder Desktop/VPN/XPCInterface.swift index 6ecb119..d83f7d7 100644 --- a/Coder Desktop/VPN/XPCInterface.swift +++ b/Coder Desktop/VPN/XPCInterface.swift @@ -20,9 +20,12 @@ import VPNLib } } - func getPeerInfo(with reply: @escaping () -> Void) { - // TODO: Retrieve from Manager - reply() + func getPeerState(with reply: @escaping (Data?) -> Void) { + let reply = CallbackWrapper(reply) + Task { + let data = try? await manager?.getPeerState().serializedData() + reply(data) + } } func ping(with reply: @escaping () -> Void) { diff --git a/Coder Desktop/VPNLib/XPC.swift b/Coder Desktop/VPNLib/XPC.swift index ffbf6d8..eda8ab0 100644 --- a/Coder Desktop/VPNLib/XPC.swift +++ b/Coder Desktop/VPNLib/XPC.swift @@ -2,7 +2,7 @@ import Foundation @preconcurrency @objc public protocol VPNXPCProtocol { - func getPeerInfo(with reply: @escaping () -> Void) + func getPeerState(with reply: @escaping (Data?) -> Void) func ping(with reply: @escaping () -> Void) }