Skip to content

Commit df3d755

Browse files
chore: support operating the VPN without the app (#36)
1 parent 10c2109 commit df3d755

File tree

9 files changed

+81
-50
lines changed

9 files changed

+81
-50
lines changed

Coder Desktop/Coder Desktop/Coder_DesktopApp.swift

+5-1
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,13 @@ class AppDelegate: NSObject, NSApplicationDelegate {
4747
}
4848
}
4949

50+
// This function MUST eventually call `NSApp.reply(toApplicationShouldTerminate: true)`
51+
// or return `.terminateNow`
5052
func applicationShouldTerminate(_: NSApplication) -> NSApplication.TerminateReply {
53+
if !settings.stopVPNOnQuit { return .terminateNow }
5154
Task {
52-
await vpn.quit()
55+
await vpn.stop()
56+
NSApp.reply(toApplicationShouldTerminate: true)
5357
}
5458
return .terminateLater
5559
}

Coder Desktop/Coder Desktop/NetworkExtension.swift

+10-19
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,12 @@ enum NetworkExtensionState: Equatable {
2424
/// An actor that handles configuring, enabling, and disabling the VPN tunnel via the
2525
/// NetworkExtension APIs.
2626
extension CoderVPNService {
27-
// Updates the UI if a previous configuration exists
28-
func loadNetworkExtension() async {
27+
func hasNetworkExtensionConfig() async -> Bool {
2928
do {
30-
try await getTunnelManager()
31-
neState = .disabled
29+
_ = try await getTunnelManager()
30+
return true
3231
} catch {
33-
neState = .unconfigured
32+
return false
3433
}
3534
}
3635

@@ -71,37 +70,29 @@ extension CoderVPNService {
7170
}
7271
}
7372

74-
func enableNetworkExtension() async {
73+
func startTunnel() async {
7574
do {
7675
let tm = try await getTunnelManager()
77-
if !tm.isEnabled {
78-
tm.isEnabled = true
79-
try await tm.saveToPreferences()
80-
logger.debug("saved tunnel with enabled=true")
81-
}
8276
try tm.connection.startVPNTunnel()
8377
} catch {
84-
logger.error("enable network extension: \(error)")
78+
logger.error("start tunnel: \(error)")
8579
neState = .failed(error.localizedDescription)
8680
return
8781
}
88-
logger.debug("enabled and started tunnel")
82+
logger.debug("started tunnel")
8983
neState = .enabled
9084
}
9185

92-
func disableNetworkExtension() async {
86+
func stopTunnel() async {
9387
do {
9488
let tm = try await getTunnelManager()
9589
tm.connection.stopVPNTunnel()
96-
tm.isEnabled = false
97-
98-
try await tm.saveToPreferences()
9990
} catch {
100-
logger.error("disable network extension: \(error)")
91+
logger.error("stop tunnel: \(error)")
10192
neState = .failed(error.localizedDescription)
10293
return
10394
}
104-
logger.debug("saved tunnel with enabled=false")
95+
logger.debug("stopped tunnel")
10596
neState = .disabled
10697
}
10798

Coder Desktop/Coder Desktop/State.swift

+3
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ class Settings: ObservableObject {
104104
}
105105
}
106106

107+
@AppStorage(Keys.stopVPNOnQuit) var stopVPNOnQuit = true
108+
107109
init(store: UserDefaults = .standard) {
108110
self.store = store
109111
_literalHeaders = Published(
@@ -116,6 +118,7 @@ class Settings: ObservableObject {
116118
enum Keys {
117119
static let useLiteralHeaders = "UseLiteralHeaders"
118120
static let literalHeaders = "LiteralHeaders"
121+
static let stopVPNOnQuit = "StopVPNOnQuit"
119122
}
120123
}
121124

Coder Desktop/Coder Desktop/VPNService.swift

+31-21
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,6 @@ enum VPNServiceError: Error, Equatable {
4141
final class CoderVPNService: NSObject, VPNService {
4242
var logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "vpn")
4343
lazy var xpc: VPNXPCInterface = .init(vpn: self)
44-
var terminating = false
4544
var workspaces: [UUID: String] = [:]
4645

4746
@Published var tunnelState: VPNServiceState = .disabled
@@ -68,8 +67,14 @@ final class CoderVPNService: NSObject, VPNService {
6867
super.init()
6968
installSystemExtension()
7069
Task {
71-
await loadNetworkExtension()
70+
neState = if await hasNetworkExtensionConfig() {
71+
.disabled
72+
} else {
73+
.unconfigured
74+
}
7275
}
76+
xpc.connect()
77+
xpc.getPeerState()
7378
NotificationCenter.default.addObserver(
7479
self,
7580
selector: #selector(vpnDidUpdate(_:)),
@@ -82,6 +87,11 @@ final class CoderVPNService: NSObject, VPNService {
8287
NotificationCenter.default.removeObserver(self)
8388
}
8489

90+
func clearPeers() {
91+
agents = [:]
92+
workspaces = [:]
93+
}
94+
8595
func start() async {
8696
switch tunnelState {
8797
case .disabled, .failed:
@@ -90,31 +100,18 @@ final class CoderVPNService: NSObject, VPNService {
90100
return
91101
}
92102

93-
await enableNetworkExtension()
94-
// this ping is somewhat load bearing since it causes xpc to init
103+
await startTunnel()
104+
xpc.connect()
95105
xpc.ping()
96106
logger.debug("network extension enabled")
97107
}
98108

99109
func stop() async {
100110
guard tunnelState == .connected else { return }
101-
await disableNetworkExtension()
111+
await stopTunnel()
102112
logger.info("network extension stopped")
103113
}
104114

105-
// Instructs the service to stop the VPN and then quit once the stop event
106-
// is read over XPC.
107-
// MUST only be called from `NSApplicationDelegate.applicationShouldTerminate`
108-
// MUST eventually call `NSApp.reply(toApplicationShouldTerminate: true)`
109-
func quit() async {
110-
guard tunnelState == .connected else {
111-
NSApp.reply(toApplicationShouldTerminate: true)
112-
return
113-
}
114-
terminating = true
115-
await stop()
116-
}
117-
118115
func configureTunnelProviderProtocol(proto: NETunnelProviderProtocol?) {
119116
Task {
120117
if let proto {
@@ -145,6 +142,22 @@ final class CoderVPNService: NSObject, VPNService {
145142
}
146143
}
147144

145+
func onExtensionPeerState(_ data: Data?) {
146+
guard let data else {
147+
logger.error("could not retrieve peer state from network extension, it may not be running")
148+
return
149+
}
150+
logger.info("received network extension peer state")
151+
do {
152+
let msg = try Vpn_PeerUpdate(serializedBytes: data)
153+
debugPrint(msg)
154+
clearPeers()
155+
applyPeerUpdate(with: msg)
156+
} catch {
157+
logger.error("failed to decode peer update \(error)")
158+
}
159+
}
160+
148161
func applyPeerUpdate(with update: Vpn_PeerUpdate) {
149162
// Delete agents
150163
update.deletedAgents
@@ -204,9 +217,6 @@ extension CoderVPNService {
204217
}
205218
switch connection.status {
206219
case .disconnected:
207-
if terminating {
208-
NSApp.reply(toApplicationShouldTerminate: true)
209-
}
210220
connection.fetchLastDisconnectError { err in
211221
self.tunnelState = if let err {
212222
.failed(.internalError(err.localizedDescription))

Coder Desktop/Coder Desktop/Views/Settings/GeneralTab.swift

+6
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,17 @@ import LaunchAtLogin
22
import SwiftUI
33

44
struct GeneralTab: View {
5+
@EnvironmentObject var settings: Settings
56
var body: some View {
67
Form {
78
Section {
89
LaunchAtLogin.Toggle("Launch at Login")
910
}
11+
Section {
12+
Toggle(isOn: $settings.stopVPNOnQuit) {
13+
Text("Stop VPN on Quit")
14+
}
15+
}
1016
}.formStyle(.grouped)
1117
}
1218
}

Coder Desktop/Coder Desktop/XPCInterface.swift

+18-4
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,17 @@ import VPNLib
66
@objc final class VPNXPCInterface: NSObject, VPNXPCClientCallbackProtocol, @unchecked Sendable {
77
private var svc: CoderVPNService
88
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "VPNXPCInterface")
9-
private let xpc: VPNXPCProtocol
9+
private var xpc: VPNXPCProtocol?
1010

1111
init(vpn: CoderVPNService) {
1212
svc = vpn
13+
super.init()
14+
}
1315

16+
func connect() {
17+
guard xpc == nil else {
18+
return
19+
}
1420
let networkExtDict = Bundle.main.object(forInfoDictionaryKey: "NetworkExtension") as? [String: Any]
1521
let machServiceName = networkExtDict?["NEMachServiceName"] as? String
1622
let xpcConn = NSXPCConnection(machServiceName: machServiceName!)
@@ -21,30 +27,38 @@ import VPNLib
2127
}
2228
xpc = proxy
2329

24-
super.init()
25-
2630
xpcConn.exportedObject = self
2731
xpcConn.invalidationHandler = { [logger] in
2832
Task { @MainActor in
2933
logger.error("XPC connection invalidated.")
34+
self.xpc = nil
3035
}
3136
}
3237
xpcConn.interruptionHandler = { [logger] in
3338
Task { @MainActor in
3439
logger.error("XPC connection interrupted.")
40+
self.xpc = nil
3541
}
3642
}
3743
xpcConn.resume()
3844
}
3945

4046
func ping() {
41-
xpc.ping {
47+
xpc?.ping {
4248
Task { @MainActor in
4349
self.logger.info("Connected to NE over XPC")
4450
}
4551
}
4652
}
4753

54+
func getPeerState() {
55+
xpc?.getPeerState { data in
56+
Task { @MainActor in
57+
self.svc.onExtensionPeerState(data)
58+
}
59+
}
60+
}
61+
4862
func onPeerUpdate(_ data: Data) {
4963
Task { @MainActor in
5064
svc.onExtensionPeerUpdate(data)

Coder Desktop/VPN/Manager.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ actor Manager {
194194

195195
// Retrieves the current state of all peers,
196196
// as required when starting the app whilst the network extension is already running
197-
func getPeerInfo() async throws(ManagerError) -> Vpn_PeerUpdate {
197+
func getPeerState() async throws(ManagerError) -> Vpn_PeerUpdate {
198198
logger.info("sending peer state request")
199199
let resp: Vpn_TunnelMessage
200200
do {

Coder Desktop/VPN/XPCInterface.swift

+6-3
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,12 @@ import VPNLib
2020
}
2121
}
2222

23-
func getPeerInfo(with reply: @escaping () -> Void) {
24-
// TODO: Retrieve from Manager
25-
reply()
23+
func getPeerState(with reply: @escaping (Data?) -> Void) {
24+
let reply = CallbackWrapper(reply)
25+
Task {
26+
let data = try? await manager?.getPeerState().serializedData()
27+
reply(data)
28+
}
2629
}
2730

2831
func ping(with reply: @escaping () -> Void) {

Coder Desktop/VPNLib/XPC.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import Foundation
22

33
@preconcurrency
44
@objc public protocol VPNXPCProtocol {
5-
func getPeerInfo(with reply: @escaping () -> Void)
5+
func getPeerState(with reply: @escaping (Data?) -> Void)
66
func ping(with reply: @escaping () -> Void)
77
}
88

0 commit comments

Comments
 (0)