From 87ec698b966a6e6100435d0799b1f6d552232f82 Mon Sep 17 00:00:00 2001
From: Ethan Dickson <ethan@coder.com>
Date: Mon, 10 Feb 2025 16:50:36 +1100
Subject: [PATCH 1/3] fix: unquarantine dylib after download

---
 .../Coder Desktop/Coder_Desktop.entitlements  |  6 ---
 .../Coder Desktop/NetworkExtension.swift      |  9 ++--
 Coder Desktop/Coder Desktop/VPNService.swift  |  9 ++--
 .../Coder Desktop/Views/VPNMenu.swift         | 28 ++++++++----
 .../Coder Desktop/Views/VPNState.swift        | 21 ++++++---
 .../Coder Desktop/XPCInterface.swift          | 35 +++++++++++++++
 .../Coder DesktopTests/VPNStateTests.swift    |  9 ++--
 Coder Desktop/VPN/Manager.swift               | 42 +++++++++++++++++-
 Coder Desktop/VPN/PacketTunnelProvider.swift  | 44 ++++++++++++++-----
 Coder Desktop/VPNLib/Util.swift               |  6 +--
 Coder Desktop/VPNLib/XPC.swift                |  1 +
 Coder Desktop/project.yml                     |  3 --
 12 files changed, 161 insertions(+), 52 deletions(-)

diff --git a/Coder Desktop/Coder Desktop/Coder_Desktop.entitlements b/Coder Desktop/Coder Desktop/Coder_Desktop.entitlements
index 7d90a161..0d80c22d 100644
--- a/Coder Desktop/Coder Desktop/Coder_Desktop.entitlements	
+++ b/Coder Desktop/Coder Desktop/Coder_Desktop.entitlements	
@@ -8,15 +8,9 @@
 	</array>
 	<key>com.apple.developer.system-extension.install</key>
 	<true/>
-	<key>com.apple.security.app-sandbox</key>
-	<true/>
 	<key>com.apple.security.application-groups</key>
 	<array>
 		<string>$(TeamIdentifierPrefix)com.coder.Coder-Desktop</string>
 	</array>
-	<key>com.apple.security.files.user-selected.read-only</key>
-	<true/>
-	<key>com.apple.security.network.client</key>
-	<true/>
 </dict>
 </plist>
diff --git a/Coder Desktop/Coder Desktop/NetworkExtension.swift b/Coder Desktop/Coder Desktop/NetworkExtension.swift
index 16d18bb4..effd1946 100644
--- a/Coder Desktop/Coder Desktop/NetworkExtension.swift	
+++ b/Coder Desktop/Coder Desktop/NetworkExtension.swift	
@@ -24,12 +24,13 @@ enum NetworkExtensionState: Equatable {
 /// An actor that handles configuring, enabling, and disabling the VPN tunnel via the
 /// NetworkExtension APIs.
 extension CoderVPNService {
-    func hasNetworkExtensionConfig() async -> Bool {
+    func loadNetworkExtensionConfig() async {
         do {
-            _ = try await getTunnelManager()
-            return true
+            let tm = try await getTunnelManager()
+            neState = .disabled
+            serverAddress = tm.protocolConfiguration?.serverAddress
         } catch {
-            return false
+            neState = .unconfigured
         }
     }
 
diff --git a/Coder Desktop/Coder Desktop/VPNService.swift b/Coder Desktop/Coder Desktop/VPNService.swift
index 657d9949..9d8abb84 100644
--- a/Coder Desktop/Coder Desktop/VPNService.swift	
+++ b/Coder Desktop/Coder Desktop/VPNService.swift	
@@ -63,15 +63,13 @@ final class CoderVPNService: NSObject, VPNService {
     // only stores a weak reference to the delegate.
     var systemExtnDelegate: SystemExtensionDelegate<CoderVPNService>?
 
+    var serverAddress: String?
+
     override init() {
         super.init()
         installSystemExtension()
         Task {
-            neState = if await hasNetworkExtensionConfig() {
-                .disabled
-            } else {
-                .unconfigured
-            }
+            await loadNetworkExtensionConfig()
         }
         xpc.connect()
         xpc.getPeerState()
@@ -115,6 +113,7 @@ final class CoderVPNService: NSObject, VPNService {
     func configureTunnelProviderProtocol(proto: NETunnelProviderProtocol?) {
         Task {
             if let proto {
+                serverAddress = proto.serverAddress
                 await configureNetworkExtension(proto: proto)
                 // this just configures the VPN, it doesn't enable it
                 tunnelState = .disabled
diff --git a/Coder Desktop/Coder Desktop/Views/VPNMenu.swift b/Coder Desktop/Coder Desktop/Views/VPNMenu.swift
index 26266c8d..759bce80 100644
--- a/Coder Desktop/Coder Desktop/Views/VPNMenu.swift	
+++ b/Coder Desktop/Coder Desktop/Views/VPNMenu.swift	
@@ -31,13 +31,7 @@ struct VPNMenu<VPN: VPNService, S: Session>: View {
                 Text("Workspace Agents")
                     .font(.headline)
                     .foregroundColor(.gray)
-                if session.hasSession {
-                    VPNState<VPN>()
-                } else {
-                    Text("Sign in to use CoderVPN")
-                        .font(.body)
-                        .foregroundColor(.gray)
-                }
+                VPNState<VPN, S>()
             }.padding([.horizontal, .top], Theme.Size.trayInset)
             Agents<VPN, S>()
             // Trailing stack
@@ -52,7 +46,15 @@ struct VPNMenu<VPN: VPNService, S: Session>: View {
                     }.buttonStyle(.plain)
                     TrayDivider()
                 }
-                AuthButton<VPN, S>()
+                if vpn.state == .failed(.systemExtensionError(.needsUserApproval)) {
+                    Button {
+                        openSystemExtensionSettings()
+                    } label: {
+                        ButtonRowView { Text("Open System Preferences") }
+                    }.buttonStyle(.plain)
+                } else {
+                    AuthButton<VPN, S>()
+                }
                 Button {
                     openSettings()
                     appActivate()
@@ -84,10 +86,18 @@ struct VPNMenu<VPN: VPNService, S: Session>: View {
     private var vpnDisabled: Bool {
         !session.hasSession ||
             vpn.state == .connecting ||
-            vpn.state == .disconnecting
+            vpn.state == .disconnecting ||
+            vpn.state == .failed(.systemExtensionError(.needsUserApproval))
     }
 }
 
+func openSystemExtensionSettings() {
+    // TODO: Check this still works in a new macOS version
+    // https://gist.github.com/rmcdongit/f66ff91e0dad78d4d6346a75ded4b751?permalink_comment_id=5261757
+    // swiftlint:disable:next line_length
+    NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.ExtensionsPreferences?extensionPointIdentifier=com.apple.system_extension.network_extension.extension-point")!)
+}
+
 #Preview {
     VPNMenu<PreviewVPN, PreviewSession>().frame(width: 256)
         .environmentObject(PreviewVPN())
diff --git a/Coder Desktop/Coder Desktop/Views/VPNState.swift b/Coder Desktop/Coder Desktop/Views/VPNState.swift
index 17102039..706b8cfb 100644
--- a/Coder Desktop/Coder Desktop/Views/VPNState.swift	
+++ b/Coder Desktop/Coder Desktop/Views/VPNState.swift	
@@ -1,18 +1,27 @@
 import SwiftUI
 
-struct VPNState<VPN: VPNService>: View {
+struct VPNState<VPN: VPNService, S: Session>: View {
     @EnvironmentObject var vpn: VPN
+    @EnvironmentObject var session: S
 
     let inspection = Inspection<Self>()
 
     var body: some View {
         Group {
-            switch vpn.state {
-            case .disabled:
-                Text("Enable CoderVPN to see agents")
+            switch (vpn.state, session.hasSession) {
+            case (.failed(.systemExtensionError(.needsUserApproval)), _):
+                Text("Awaiting System Extension Approval")
+                    .font(.body)
+                    .foregroundStyle(.gray)
+            case (_, false):
+                Text("Sign in to use CoderVPN")
                     .font(.body)
                     .foregroundColor(.gray)
-            case .connecting, .disconnecting:
+            case (.disabled, _):
+                Text("Enable CoderVPN to see agents")
+                    .font(.body)
+                    .foregroundStyle(.gray)
+            case (.connecting, _), (.disconnecting, _):
                 HStack {
                     Spacer()
                     ProgressView(
@@ -20,7 +29,7 @@ struct VPNState<VPN: VPNService>: View {
                     ).padding()
                     Spacer()
                 }
-            case let .failed(vpnErr):
+            case let (.failed(vpnErr), _):
                 Text("\(vpnErr.description)")
                     .font(.headline)
                     .foregroundColor(.red)
diff --git a/Coder Desktop/Coder Desktop/XPCInterface.swift b/Coder Desktop/Coder Desktop/XPCInterface.swift
index 74baab5a..73586cae 100644
--- a/Coder Desktop/Coder Desktop/XPCInterface.swift	
+++ b/Coder Desktop/Coder Desktop/XPCInterface.swift	
@@ -64,4 +64,39 @@ import VPNLib
             svc.onExtensionPeerUpdate(data)
         }
     }
+
+    // The NE has verified the dylib and knows better than Gatekeeper
+    func removeQuarantine(path: String, reply: @escaping (Bool) -> Void) {
+        let reply = CallbackWrapper(reply)
+        Task { @MainActor in
+            let prompt = """
+            Coder Desktop wants to execute code downloaded from \
+            \(svc.serverAddress ?? "the Coder deployment"). The code has been \
+            verified to be signed by Coder.
+            """
+            let source = """
+            do shell script "xattr -d com.apple.quarantine \(path)" \
+            with prompt "\(prompt)" \
+            with administrator privileges
+            """
+            let success = await withCheckedContinuation { continuation in
+                guard let script = NSAppleScript(source: source) else {
+                    continuation.resume(returning: false)
+                    return
+                }
+                // Run on a background thread
+                Task.detached {
+                    var error: NSDictionary?
+                    script.executeAndReturnError(&error)
+                    if let error {
+                        self.logger.error("AppleScript error: \(error)")
+                        continuation.resume(returning: false)
+                    } else {
+                        continuation.resume(returning: true)
+                    }
+                }
+            }
+            reply(success)
+        }
+    }
 }
diff --git a/Coder Desktop/Coder DesktopTests/VPNStateTests.swift b/Coder Desktop/Coder DesktopTests/VPNStateTests.swift
index 4d630cd0..298bacd5 100644
--- a/Coder Desktop/Coder DesktopTests/VPNStateTests.swift	
+++ b/Coder Desktop/Coder DesktopTests/VPNStateTests.swift	
@@ -7,13 +7,16 @@ import ViewInspector
 @Suite(.timeLimit(.minutes(1)))
 struct VPNStateTests {
     let vpn: MockVPNService
-    let sut: VPNState<MockVPNService>
+    let session: MockSession
+    let sut: VPNState<MockVPNService, MockSession>
     let view: any View
 
     init() {
         vpn = MockVPNService()
-        sut = VPNState<MockVPNService>()
-        view = sut.environmentObject(vpn)
+        sut = VPNState<MockVPNService, MockSession>()
+        session = MockSession()
+        session.hasSession = true
+        view = sut.environmentObject(vpn).environmentObject(session)
     }
 
     @Test
diff --git a/Coder Desktop/VPN/Manager.swift b/Coder Desktop/VPN/Manager.swift
index c9388183..13afbd01 100644
--- a/Coder Desktop/VPN/Manager.swift	
+++ b/Coder Desktop/VPN/Manager.swift	
@@ -46,6 +46,11 @@ actor Manager {
         } catch {
             throw .validation(error)
         }
+
+        // HACK: The downloaded dylib may be quarantined, but we've validated it's signature
+        // so it's safe to execute. However, this SE must be sandboxed, so we defer to the app.
+        try await removeQuarantine(dest)
+
         do {
             try tunnelHandle = TunnelHandle(dylibPath: dest)
         } catch {
@@ -85,7 +90,13 @@ actor Manager {
         } catch {
             logger.error("tunnel read loop failed: \(error.localizedDescription, privacy: .public)")
             try await tunnelHandle.close()
-            ptp.cancelTunnelWithError(error)
+            ptp.cancelTunnelWithError(
+                NSError(
+                    domain: "\(Bundle.main.bundleIdentifier!).Manager",
+                    code: -1,
+                    userInfo: [NSLocalizedDescriptionKey: "Tunnel read loop failed: \(error.localizedDescription)"]
+                )
+            )
             return
         }
         logger.info("tunnel read loop exited")
@@ -227,6 +238,9 @@ enum ManagerError: Error {
     case serverInfo(String)
     case errorResponse(msg: String)
     case noTunnelFileDescriptor
+    case noApp
+    case permissionDenied
+    case tunnelFail(any Error)
 
     var description: String {
         switch self {
@@ -248,6 +262,12 @@ enum ManagerError: Error {
             msg
         case .noTunnelFileDescriptor:
             "Could not find a tunnel file descriptor"
+        case .noApp:
+            "The VPN must be started with the app open during first-time setup."
+        case .permissionDenied:
+            "Permission was not granted to execute the CoderVPN dylib"
+        case let .tunnelFail(err):
+            "Failed to communicate with dylib over tunnel: \(err)"
         }
     }
 }
@@ -272,3 +292,23 @@ func writeVpnLog(_ log: Vpn_Log) {
     let fields = log.fields.map { "\($0.name): \($0.value)" }.joined(separator: ", ")
     logger.log(level: level, "\(log.message, privacy: .public): \(fields, privacy: .public)")
 }
+
+private func removeQuarantine(_ dest: URL) async throws(ManagerError) {
+    var flag: AnyObject?
+    let file = NSURL(fileURLWithPath: dest.path)
+    try? file.getResourceValue(&flag, forKey: kCFURLQuarantinePropertiesKey as URLResourceKey)
+    if flag != nil {
+        guard let conn = globalXPCListenerDelegate.conn else {
+            throw .noApp
+        }
+        // Wait for unsandboxed app to accept our file
+        let success = await withCheckedContinuation { [dest] continuation in
+            conn.removeQuarantine(path: dest.path) { success in
+                continuation.resume(returning: success)
+            }
+        }
+        if !success {
+            throw .permissionDenied
+        }
+    }
+}
diff --git a/Coder Desktop/VPN/PacketTunnelProvider.swift b/Coder Desktop/VPN/PacketTunnelProvider.swift
index 3cad498b..71304dd8 100644
--- a/Coder Desktop/VPN/PacketTunnelProvider.swift	
+++ b/Coder Desktop/VPN/PacketTunnelProvider.swift	
@@ -43,26 +43,45 @@ class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
         return nil
     }
 
+    // swiftlint:disable:next function_body_length
     override func startTunnel(
         options _: [String: NSObject]?, completionHandler: @escaping (Error?) -> Void
     ) {
         logger.info("startTunnel called")
         guard manager == nil else {
             logger.error("startTunnel called with non-nil Manager")
-            completionHandler(PTPError.alreadyRunning)
+            completionHandler(
+                NSError(
+                    domain: "\(Bundle.main.bundleIdentifier!).PTP",
+                    code: -1,
+                    userInfo: [NSLocalizedDescriptionKey: "Already running"]
+                )
+            )
             return
         }
         guard let proto = protocolConfiguration as? NETunnelProviderProtocol,
               let baseAccessURL = proto.serverAddress
         else {
             logger.error("startTunnel called with nil protocolConfiguration")
-            completionHandler(PTPError.missingConfiguration)
+            completionHandler(
+                NSError(
+                    domain: "\(Bundle.main.bundleIdentifier!).PTP",
+                    code: -1,
+                    userInfo: [NSLocalizedDescriptionKey: "Missing Configuration"]
+                )
+            )
             return
         }
         // HACK: We can't write to the system keychain, and the NE can't read the user keychain.
         guard let token = proto.providerConfiguration?["token"] as? String else {
             logger.error("startTunnel called with nil token")
-            completionHandler(PTPError.missingToken)
+            completionHandler(
+                NSError(
+                    domain: "\(Bundle.main.bundleIdentifier!).PTP",
+                    code: -1,
+                    userInfo: [NSLocalizedDescriptionKey: "Missing Token"]
+                )
+            )
             return
         }
         logger.debug("retrieved token & access URL")
@@ -70,7 +89,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
         Task {
             do throws(ManagerError) {
                 logger.debug("creating manager")
-                manager = try await Manager(
+                let manager = try await Manager(
                     with: self,
                     cfg: .init(
                         apiToken: token, serverUrl: .init(string: baseAccessURL)!
@@ -78,12 +97,19 @@ class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
                 )
                 globalXPCListenerDelegate.vpnXPCInterface.manager = manager
                 logger.debug("starting vpn")
-                try await manager!.startVPN()
+                try await manager.startVPN()
                 logger.info("vpn started")
+                self.manager = manager
                 completionHandler(nil)
             } catch {
                 logger.error("error starting manager: \(error.description, privacy: .public)")
-                completionHandler(error as NSError)
+                completionHandler(
+                    NSError(
+                        domain: "\(Bundle.main.bundleIdentifier!).Manager",
+                        code: -1,
+                        userInfo: [NSLocalizedDescriptionKey: error.description]
+                    )
+                )
             }
         }
     }
@@ -152,9 +178,3 @@ class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
         try await setTunnelNetworkSettings(currentSettings)
     }
 }
-
-enum PTPError: Error {
-    case alreadyRunning
-    case missingConfiguration
-    case missingToken
-}
diff --git a/Coder Desktop/VPNLib/Util.swift b/Coder Desktop/VPNLib/Util.swift
index ff31e4fd..e4716331 100644
--- a/Coder Desktop/VPNLib/Util.swift	
+++ b/Coder Desktop/VPNLib/Util.swift	
@@ -1,11 +1,11 @@
 public struct CallbackWrapper<T, U>: @unchecked Sendable {
-    private let block: (T?) -> U
+    private let block: (T) -> U
 
-    public init(_ block: @escaping (T?) -> U) {
+    public init(_ block: @escaping (T) -> U) {
         self.block = block
     }
 
-    public func callAsFunction(_ error: T?) -> U {
+    public func callAsFunction(_ error: T) -> U {
         block(error)
     }
 }
diff --git a/Coder Desktop/VPNLib/XPC.swift b/Coder Desktop/VPNLib/XPC.swift
index eda8ab01..dc79651e 100644
--- a/Coder Desktop/VPNLib/XPC.swift	
+++ b/Coder Desktop/VPNLib/XPC.swift	
@@ -10,4 +10,5 @@ import Foundation
 @objc public protocol VPNXPCClientCallbackProtocol {
     // data is a serialized `Vpn_PeerUpdate`
     func onPeerUpdate(_ data: Data)
+    func removeQuarantine(path: String, reply: @escaping (Bool) -> Void)
 }
diff --git a/Coder Desktop/project.yml b/Coder Desktop/project.yml
index 255bc538..54ce06af 100644
--- a/Coder Desktop/project.yml	
+++ b/Coder Desktop/project.yml	
@@ -116,9 +116,6 @@ targets:
         com.apple.developer.networking.networkextension:
           - packet-tunnel-provider
         com.apple.developer.system-extension.install: true
-        com.apple.security.app-sandbox: true
-        com.apple.security.files.user-selected.read-only: true
-        com.apple.security.network.client: true
         com.apple.security.application-groups:
           - $(TeamIdentifierPrefix)com.coder.Coder-Desktop
     settings:

From 024d7e3d7989dba236c0cf871b3ce7133d6e7a44 Mon Sep 17 00:00:00 2001
From: Ethan Dickson <ethan@coder.com>
Date: Mon, 10 Feb 2025 19:18:40 +1100
Subject: [PATCH 2/3] capitalization

---
 Coder Desktop/Coder Desktop/Views/AuthButton.swift  | 2 +-
 Coder Desktop/Coder Desktop/Views/VPNMenu.swift     | 2 +-
 Coder Desktop/Coder Desktop/Views/VPNState.swift    | 2 +-
 Coder Desktop/Coder DesktopTests/VPNMenuTests.swift | 2 +-
 4 files changed, 4 insertions(+), 4 deletions(-)

diff --git a/Coder Desktop/Coder Desktop/Views/AuthButton.swift b/Coder Desktop/Coder Desktop/Views/AuthButton.swift
index cfab0880..de102083 100644
--- a/Coder Desktop/Coder Desktop/Views/AuthButton.swift	
+++ b/Coder Desktop/Coder Desktop/Views/AuthButton.swift	
@@ -17,7 +17,7 @@ struct AuthButton<VPN: VPNService, S: Session>: View {
             }
         } label: {
             ButtonRowView {
-                Text(session.hasSession ? "Sign Out" : "Sign In")
+                Text(session.hasSession ? "Sign out" : "Sign in")
             }
         }.buttonStyle(.plain)
     }
diff --git a/Coder Desktop/Coder Desktop/Views/VPNMenu.swift b/Coder Desktop/Coder Desktop/Views/VPNMenu.swift
index 759bce80..9137ac54 100644
--- a/Coder Desktop/Coder Desktop/Views/VPNMenu.swift	
+++ b/Coder Desktop/Coder Desktop/Views/VPNMenu.swift	
@@ -50,7 +50,7 @@ struct VPNMenu<VPN: VPNService, S: Session>: View {
                     Button {
                         openSystemExtensionSettings()
                     } label: {
-                        ButtonRowView { Text("Open System Preferences") }
+                        ButtonRowView { Text("Approve in System Settings") }
                     }.buttonStyle(.plain)
                 } else {
                     AuthButton<VPN, S>()
diff --git a/Coder Desktop/Coder Desktop/Views/VPNState.swift b/Coder Desktop/Coder Desktop/Views/VPNState.swift
index 706b8cfb..4afc6c26 100644
--- a/Coder Desktop/Coder Desktop/Views/VPNState.swift	
+++ b/Coder Desktop/Coder Desktop/Views/VPNState.swift	
@@ -10,7 +10,7 @@ struct VPNState<VPN: VPNService, S: Session>: View {
         Group {
             switch (vpn.state, session.hasSession) {
             case (.failed(.systemExtensionError(.needsUserApproval)), _):
-                Text("Awaiting System Extension Approval")
+                Text("Awaiting System Extension approval")
                     .font(.body)
                     .foregroundStyle(.gray)
             case (_, false):
diff --git a/Coder Desktop/Coder DesktopTests/VPNMenuTests.swift b/Coder Desktop/Coder DesktopTests/VPNMenuTests.swift
index 4b446ac0..b0484a9f 100644
--- a/Coder Desktop/Coder DesktopTests/VPNMenuTests.swift	
+++ b/Coder Desktop/Coder DesktopTests/VPNMenuTests.swift	
@@ -27,7 +27,7 @@ struct VPNMenuTests {
                 let toggle = try view.find(ViewType.Toggle.self)
                 #expect(toggle.isDisabled())
                 #expect(throws: Never.self) { try view.find(text: "Sign in to use CoderVPN") }
-                #expect(throws: Never.self) { try view.find(button: "Sign In") }
+                #expect(throws: Never.self) { try view.find(button: "Sign in") }
             }
         }
     }

From e64ea22eeb66dacc3323fc757839f391ddbc8dbf Mon Sep 17 00:00:00 2001
From: Ethan Dickson <ethan@coder.com>
Date: Tue, 11 Feb 2025 22:10:58 +1100
Subject: [PATCH 3/3] review

---
 .../Coder Desktop/Views/VPNMenu.swift         |  3 +-
 Coder Desktop/VPN/Manager.swift               |  6 +---
 Coder Desktop/VPN/PacketTunnelProvider.swift  | 31 +++----------------
 Coder Desktop/VPNLib/Util.swift               |  8 +++++
 4 files changed, 15 insertions(+), 33 deletions(-)

diff --git a/Coder Desktop/Coder Desktop/Views/VPNMenu.swift b/Coder Desktop/Coder Desktop/Views/VPNMenu.swift
index 9137ac54..3f253e19 100644
--- a/Coder Desktop/Coder Desktop/Views/VPNMenu.swift	
+++ b/Coder Desktop/Coder Desktop/Views/VPNMenu.swift	
@@ -92,8 +92,9 @@ struct VPNMenu<VPN: VPNService, S: Session>: View {
 }
 
 func openSystemExtensionSettings() {
-    // TODO: Check this still works in a new macOS version
+    // Sourced from:
     // https://gist.github.com/rmcdongit/f66ff91e0dad78d4d6346a75ded4b751?permalink_comment_id=5261757
+    // We'll need to ensure this continues to work in future macOS versions
     // swiftlint:disable:next line_length
     NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.ExtensionsPreferences?extensionPointIdentifier=com.apple.system_extension.network_extension.extension-point")!)
 }
diff --git a/Coder Desktop/VPN/Manager.swift b/Coder Desktop/VPN/Manager.swift
index 13afbd01..58a65b53 100644
--- a/Coder Desktop/VPN/Manager.swift	
+++ b/Coder Desktop/VPN/Manager.swift	
@@ -91,11 +91,7 @@ actor Manager {
             logger.error("tunnel read loop failed: \(error.localizedDescription, privacy: .public)")
             try await tunnelHandle.close()
             ptp.cancelTunnelWithError(
-                NSError(
-                    domain: "\(Bundle.main.bundleIdentifier!).Manager",
-                    code: -1,
-                    userInfo: [NSLocalizedDescriptionKey: "Tunnel read loop failed: \(error.localizedDescription)"]
-                )
+                makeNSError(suffix: "Manager", desc: "Tunnel read loop failed: \(error.localizedDescription)")
             )
             return
         }
diff --git a/Coder Desktop/VPN/PacketTunnelProvider.swift b/Coder Desktop/VPN/PacketTunnelProvider.swift
index 71304dd8..01022950 100644
--- a/Coder Desktop/VPN/PacketTunnelProvider.swift	
+++ b/Coder Desktop/VPN/PacketTunnelProvider.swift	
@@ -43,45 +43,26 @@ class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
         return nil
     }
 
-    // swiftlint:disable:next function_body_length
     override func startTunnel(
         options _: [String: NSObject]?, completionHandler: @escaping (Error?) -> Void
     ) {
         logger.info("startTunnel called")
         guard manager == nil else {
             logger.error("startTunnel called with non-nil Manager")
-            completionHandler(
-                NSError(
-                    domain: "\(Bundle.main.bundleIdentifier!).PTP",
-                    code: -1,
-                    userInfo: [NSLocalizedDescriptionKey: "Already running"]
-                )
-            )
+            completionHandler(makeNSError(suffix: "PTP", desc: "Already running"))
             return
         }
         guard let proto = protocolConfiguration as? NETunnelProviderProtocol,
               let baseAccessURL = proto.serverAddress
         else {
             logger.error("startTunnel called with nil protocolConfiguration")
-            completionHandler(
-                NSError(
-                    domain: "\(Bundle.main.bundleIdentifier!).PTP",
-                    code: -1,
-                    userInfo: [NSLocalizedDescriptionKey: "Missing Configuration"]
-                )
-            )
+            completionHandler(makeNSError(suffix: "PTP", desc: "Missing Configuration"))
             return
         }
         // HACK: We can't write to the system keychain, and the NE can't read the user keychain.
         guard let token = proto.providerConfiguration?["token"] as? String else {
             logger.error("startTunnel called with nil token")
-            completionHandler(
-                NSError(
-                    domain: "\(Bundle.main.bundleIdentifier!).PTP",
-                    code: -1,
-                    userInfo: [NSLocalizedDescriptionKey: "Missing Token"]
-                )
-            )
+            completionHandler(makeNSError(suffix: "PTP", desc: "Missing Token"))
             return
         }
         logger.debug("retrieved token & access URL")
@@ -104,11 +85,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
             } catch {
                 logger.error("error starting manager: \(error.description, privacy: .public)")
                 completionHandler(
-                    NSError(
-                        domain: "\(Bundle.main.bundleIdentifier!).Manager",
-                        code: -1,
-                        userInfo: [NSLocalizedDescriptionKey: error.description]
-                    )
+                    makeNSError(suffix: "Manager", desc: error.description)
                 )
             }
         }
diff --git a/Coder Desktop/VPNLib/Util.swift b/Coder Desktop/VPNLib/Util.swift
index e4716331..fd9bbc3f 100644
--- a/Coder Desktop/VPNLib/Util.swift	
+++ b/Coder Desktop/VPNLib/Util.swift	
@@ -21,3 +21,11 @@ public struct CompletionWrapper<T>: @unchecked Sendable {
         block()
     }
 }
+
+public func makeNSError(suffix: String, code: Int = -1, desc: String) -> NSError {
+    NSError(
+        domain: "\(Bundle.main.bundleIdentifier!).\(suffix)",
+        code: code,
+        userInfo: [NSLocalizedDescriptionKey: desc]
+    )
+}