diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a2239cf9..fc8de504 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -25,7 +25,7 @@ jobs:
     runs-on: ${{ github.repository_owner == 'coder' && 'depot-macos-latest' || 'macos-latest'}}
     steps:
       - name: Checkout
-        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+        uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
         with:
           fetch-depth: 0
           fetch-tags: true
@@ -46,7 +46,7 @@ jobs:
     runs-on: ${{ github.repository_owner == 'coder' && 'depot-macos-latest' || 'macos-latest'}}
     steps:
       - name: Checkout
-        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+        uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
         with:
           fetch-depth: 0
           fetch-tags: true
@@ -67,7 +67,7 @@ jobs:
     runs-on: ${{ github.repository_owner == 'coder' && 'depot-macos-latest' || 'macos-latest'}}
     steps:
       - name: Checkout
-        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+        uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
         with:
           fetch-depth: 0
           fetch-tags: true
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 96c7c4d9..d8d2e841 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -34,7 +34,7 @@ jobs:
       id-token: write
     steps:
       - name: Checkout
-        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+        uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
         with:
           fetch-depth: 0
           fetch-tags: true
@@ -50,14 +50,14 @@ jobs:
 
       - name: Authenticate to Google Cloud
         id: gcloud_auth
-        uses: google-github-actions/auth@ba79af03959ebeac9769e648f473a284504d9193 # v2.1.10
+        uses: google-github-actions/auth@b7593ed2efd1c1617e1b0254da33b86225adb2a5 # v2.1.12
         with:
           workload_identity_provider: ${{ secrets.GCP_WORKLOAD_ID_PROVIDER }}
           service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }}
           token_format: "access_token"
 
       - name: Setup GCloud SDK
-        uses: google-github-actions/setup-gcloud@77e7a554d41e2ee56fc945c52dfd3f33d12def9a # v2.1.4
+        uses: google-github-actions/setup-gcloud@cb1e50a9932213ecece00a606661ae9ca44f3397 # v2.2.0
 
       - name: Build
         env:
@@ -112,7 +112,7 @@ jobs:
     needs: build
     steps:
       - name: Checkout
-        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+        uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
         with:
           fetch-depth: 0
           fetch-tags: true
diff --git a/Coder-Desktop/.swiftlint.yml b/Coder-Desktop/.swiftlint.yml
index 1cf2d055..9085646f 100644
--- a/Coder-Desktop/.swiftlint.yml
+++ b/Coder-Desktop/.swiftlint.yml
@@ -10,3 +10,5 @@ type_name:
 identifier_name:
   allowed_symbols: "_"
   min_length: 1
+line_length:
+  ignores_urls: true
diff --git a/Coder-Desktop/Coder-Desktop/AppHelperXPCClient.swift b/Coder-Desktop/Coder-Desktop/AppHelperXPCClient.swift
new file mode 100644
index 00000000..b663533d
--- /dev/null
+++ b/Coder-Desktop/Coder-Desktop/AppHelperXPCClient.swift
@@ -0,0 +1,103 @@
+import Foundation
+import NetworkExtension
+import os
+import VPNLib
+
+// This is the client for the app to communicate with the privileged helper.
+@objc final class HelperXPCClient: NSObject, @unchecked Sendable {
+    private var svc: CoderVPNService
+    private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "HelperXPCClient")
+    private var connection: NSXPCConnection?
+
+    init(vpn: CoderVPNService) {
+        svc = vpn
+        super.init()
+    }
+
+    func connect() -> NSXPCConnection {
+        if let connection {
+            return connection
+        }
+
+        let connection = NSXPCConnection(
+            machServiceName: helperAppMachServiceName,
+            options: .privileged
+        )
+        connection.remoteObjectInterface = NSXPCInterface(with: HelperAppXPCInterface.self)
+        connection.exportedInterface = NSXPCInterface(with: AppXPCInterface.self)
+        connection.exportedObject = self
+        connection.invalidationHandler = {
+            self.logger.error("XPC connection invalidated")
+            self.connection = nil
+            _ = self.connect()
+        }
+        connection.interruptionHandler = {
+            self.logger.error("XPC connection interrupted")
+            self.connection = nil
+            _ = self.connect()
+        }
+        logger.info("connecting to \(helperAppMachServiceName)")
+        connection.setCodeSigningRequirement(Validator.xpcPeerRequirement)
+        connection.resume()
+        self.connection = connection
+        return connection
+    }
+
+    // Establishes a connection to the Helper, so it can send messages back.
+    func ping() async throws {
+        let conn = connect()
+        return try await withCheckedThrowingContinuation { continuation in
+            guard let proxy = conn.remoteObjectProxyWithErrorHandler({ err in
+                self.logger.error("failed to connect to HelperXPC \(err.localizedDescription, privacy: .public)")
+                continuation.resume(throwing: err)
+            }) as? HelperAppXPCInterface else {
+                self.logger.error("failed to get proxy for HelperXPC")
+                continuation.resume(throwing: XPCError.wrongProxyType)
+                return
+            }
+            proxy.ping {
+                self.logger.info("Connected to Helper over XPC")
+                continuation.resume()
+            }
+        }
+    }
+
+    func getPeerState() async throws {
+        let conn = connect()
+        return try await withCheckedThrowingContinuation { continuation in
+            guard let proxy = conn.remoteObjectProxyWithErrorHandler({ err in
+                self.logger.error("failed to connect to HelperXPC \(err.localizedDescription, privacy: .public)")
+                continuation.resume(throwing: err)
+            }) as? HelperAppXPCInterface else {
+                self.logger.error("failed to get proxy for HelperXPC")
+                continuation.resume(throwing: XPCError.wrongProxyType)
+                return
+            }
+            proxy.getPeerState { data in
+                Task { @MainActor in
+                    self.svc.onExtensionPeerState(data)
+                }
+                continuation.resume()
+            }
+        }
+    }
+}
+
+// These methods are called by the Helper over XPC
+extension HelperXPCClient: AppXPCInterface {
+    func onPeerUpdate(_ diff: Data, reply: @escaping () -> Void) {
+        let reply = CompletionWrapper(reply)
+        Task { @MainActor in
+            svc.onExtensionPeerUpdate(diff)
+            reply()
+        }
+    }
+
+    func onProgress(stage: ProgressStage, downloadProgress: DownloadProgress?, reply: @escaping () -> Void) {
+        let reply = CompletionWrapper(reply)
+        Task { @MainActor in
+            svc.onProgress(stage: stage, downloadProgress: downloadProgress)
+            reply()
+        }
+    }
+}
diff --git a/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift b/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift
index de12c6e1..eab01ea2 100644
--- a/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift
+++ b/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift
@@ -20,14 +20,15 @@ struct DesktopApp: App {
         Window("Sign In", id: Windows.login.rawValue) {
             LoginForm()
                 .environmentObject(appDelegate.state)
+                .showDockIconWhenOpen()
         }.handlesExternalEvents(matching: Set()) // Don't handle deep links
             .windowResizability(.contentSize)
         SwiftUI.Settings {
             SettingsView<CoderVPNService>()
                 .environmentObject(appDelegate.vpn)
                 .environmentObject(appDelegate.state)
-                .environmentObject(appDelegate.helper)
                 .environmentObject(appDelegate.autoUpdater)
+                .showDockIconWhenOpen()
         }
         .windowResizability(.contentSize)
         Window("Coder File Sync", id: Windows.fileSync.rawValue) {
@@ -35,6 +36,7 @@ struct DesktopApp: App {
                 .environmentObject(appDelegate.state)
                 .environmentObject(appDelegate.fileSyncDaemon)
                 .environmentObject(appDelegate.vpn)
+                .showDockIconWhenOpen()
         }.handlesExternalEvents(matching: Set()) // Don't handle deep links
     }
 }
@@ -48,13 +50,11 @@ class AppDelegate: NSObject, NSApplicationDelegate {
     let fileSyncDaemon: MutagenDaemon
     let urlHandler: URLHandler
     let notifDelegate: NotifDelegate
-    let helper: HelperService
     let autoUpdater: UpdaterService
 
     override init() {
         notifDelegate = NotifDelegate()
         vpn = CoderVPNService()
-        helper = HelperService()
         autoUpdater = UpdaterService()
         let state = AppState(onChange: vpn.configureTunnelProviderProtocol)
         vpn.onStart = {
diff --git a/Coder-Desktop/Coder-Desktop/HelperService.swift b/Coder-Desktop/Coder-Desktop/HelperService.swift
deleted file mode 100644
index 17bdc72a..00000000
--- a/Coder-Desktop/Coder-Desktop/HelperService.swift
+++ /dev/null
@@ -1,117 +0,0 @@
-import os
-import ServiceManagement
-
-// Whilst the GUI app installs the helper, the System Extension communicates
-// with it over XPC
-@MainActor
-class HelperService: ObservableObject {
-    private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "HelperService")
-    let plistName = "com.coder.Coder-Desktop.Helper.plist"
-    @Published var state: HelperState = .uninstalled {
-        didSet {
-            logger.info("helper daemon state set: \(self.state.description, privacy: .public)")
-        }
-    }
-
-    init() {
-        update()
-    }
-
-    func update() {
-        let daemon = SMAppService.daemon(plistName: plistName)
-        state = HelperState(status: daemon.status)
-    }
-
-    func install() {
-        let daemon = SMAppService.daemon(plistName: plistName)
-        do {
-            try daemon.register()
-        } catch let error as NSError {
-            self.state = .failed(.init(error: error))
-        } catch {
-            state = .failed(.unknown(error.localizedDescription))
-        }
-        state = HelperState(status: daemon.status)
-    }
-
-    func uninstall() {
-        let daemon = SMAppService.daemon(plistName: plistName)
-        do {
-            try daemon.unregister()
-        } catch let error as NSError {
-            self.state = .failed(.init(error: error))
-        } catch {
-            state = .failed(.unknown(error.localizedDescription))
-        }
-        state = HelperState(status: daemon.status)
-    }
-}
-
-enum HelperState: Equatable {
-    case uninstalled
-    case installed
-    case requiresApproval
-    case failed(HelperError)
-
-    var description: String {
-        switch self {
-        case .uninstalled:
-            "Uninstalled"
-        case .installed:
-            "Installed"
-        case .requiresApproval:
-            "Requires Approval"
-        case let .failed(error):
-            "Failed: \(error.localizedDescription)"
-        }
-    }
-
-    init(status: SMAppService.Status) {
-        self = switch status {
-        case .notRegistered:
-            .uninstalled
-        case .enabled:
-            .installed
-        case .requiresApproval:
-            .requiresApproval
-        case .notFound:
-            // `Not found`` is the initial state, if `register` has never been called
-            .uninstalled
-        @unknown default:
-            .failed(.unknown("Unknown status: \(status)"))
-        }
-    }
-}
-
-enum HelperError: Error, Equatable {
-    case alreadyRegistered
-    case launchDeniedByUser
-    case invalidSignature
-    case unknown(String)
-
-    init(error: NSError) {
-        self = switch error.code {
-        case kSMErrorAlreadyRegistered:
-            .alreadyRegistered
-        case kSMErrorLaunchDeniedByUser:
-            .launchDeniedByUser
-        case kSMErrorInvalidSignature:
-            .invalidSignature
-        default:
-            .unknown(error.localizedDescription)
-        }
-    }
-
-    var localizedDescription: String {
-        switch self {
-        case .alreadyRegistered:
-            "Already registered"
-        case .launchDeniedByUser:
-            "Launch denied by user"
-        case .invalidSignature:
-            "Invalid signature"
-        case let .unknown(message):
-            message
-        }
-    }
-}
diff --git a/Coder-Desktop/Coder-Desktop/UpdaterService.swift b/Coder-Desktop/Coder-Desktop/UpdaterService.swift
index ce7bc9d2..c0f5eaa6 100644
--- a/Coder-Desktop/Coder-Desktop/UpdaterService.swift
+++ b/Coder-Desktop/Coder-Desktop/UpdaterService.swift
@@ -2,42 +2,59 @@ import Sparkle
 import SwiftUI
 
 final class UpdaterService: NSObject, ObservableObject {
-    private lazy var inner: SPUStandardUpdaterController = .init(
-        startingUpdater: true,
-        updaterDelegate: self,
-        userDriverDelegate: self
-    )
-    private var updater: SPUUpdater!
+    // The auto-updater can be entirely disabled by setting the
+    // `disableUpdater` UserDefaults key to `true`. This is designed for use in
+    // MDM configurations, where the value can be set to `true` permanently.
+    let disabled: Bool = UserDefaults.standard.bool(forKey: Keys.disableUpdater)
+
     @Published var canCheckForUpdates = true
 
     @Published var autoCheckForUpdates: Bool! {
         didSet {
             if let autoCheckForUpdates, autoCheckForUpdates != oldValue {
-                updater.automaticallyChecksForUpdates = autoCheckForUpdates
+                inner?.updater.automaticallyChecksForUpdates = autoCheckForUpdates
             }
         }
     }
 
     @Published var updateChannel: UpdateChannel {
         didSet {
-            UserDefaults.standard.set(updateChannel.rawValue, forKey: Self.updateChannelKey)
+            UserDefaults.standard.set(updateChannel.rawValue, forKey: Keys.updateChannel)
         }
     }
 
-    static let updateChannelKey = "updateChannel"
+    private var inner: (controller: SPUStandardUpdaterController, updater: SPUUpdater)?
 
     override init() {
-        updateChannel = UserDefaults.standard.string(forKey: Self.updateChannelKey)
+        updateChannel = UserDefaults.standard.string(forKey: Keys.updateChannel)
             .flatMap { UpdateChannel(rawValue: $0) } ?? .stable
         super.init()
-        updater = inner.updater
+
+        guard !disabled else {
+            return
+        }
+
+        let inner = SPUStandardUpdaterController(
+            startingUpdater: true,
+            updaterDelegate: self,
+            userDriverDelegate: self
+        )
+
+        let updater = inner.updater
+        self.inner = (inner, updater)
+
         autoCheckForUpdates = updater.automaticallyChecksForUpdates
         updater.publisher(for: \.canCheckForUpdates).assign(to: &$canCheckForUpdates)
     }
 
     func checkForUpdates() {
-        guard canCheckForUpdates else { return }
-        updater.checkForUpdates()
+        guard let inner, canCheckForUpdates else { return }
+        inner.updater.checkForUpdates()
+    }
+
+    enum Keys {
+        static let disableUpdater = "disableUpdater"
+        static let updateChannel = "updateChannel"
     }
 }
 
diff --git a/Coder-Desktop/Coder-Desktop/VPN/VPNProgress.swift b/Coder-Desktop/Coder-Desktop/VPN/VPNProgress.swift
index 56593b20..a9146145 100644
--- a/Coder-Desktop/Coder-Desktop/VPN/VPNProgress.swift
+++ b/Coder-Desktop/Coder-Desktop/VPN/VPNProgress.swift
@@ -13,9 +13,12 @@ struct VPNProgressView: View {
     var body: some View {
         VStack {
             CircularProgressView(value: value)
-                // We estimate that the last half takes 8 seconds
+                // We estimate the duration of the last 35%
                 // so it doesn't appear stuck
-                .autoComplete(threshold: 0.5, duration: 8)
+                .autoComplete(threshold: 0.65, duration: 8)
+                // We estimate the duration of the first 25% (spawning Helper)
+                // so it doesn't appear stuck
+                .autoStart(until: 0.25, duration: 2)
             Text(progressMessage)
                 .multilineTextAlignment(.center)
         }
@@ -46,18 +49,16 @@ struct VPNProgressView: View {
             guard let downloadProgress = progress.downloadProgress else {
                 // We can't make this illegal state unrepresentable because XPC
                 // doesn't support enums with associated values.
-                return 0.05
+                return 0.15
             }
             // 35MB if the server doesn't give us the expected size
             let totalBytes = downloadProgress.totalBytesToWrite ?? 35_000_000
             let downloadPercent = min(1.0, Float(downloadProgress.totalBytesWritten) / Float(totalBytes))
-            return 0.4 * downloadPercent
+            return 0.25 + (0.35 * downloadPercent)
         case .validating:
-            return 0.43
-        case .removingQuarantine:
-            return 0.46
+            return 0.63
         case .startingTunnel:
-            return 0.50
+            return 0.65
         }
     }
 }
diff --git a/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift b/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift
index 1bf4a842..9da39d5b 100644
--- a/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift
+++ b/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift
@@ -54,9 +54,9 @@ enum VPNServiceError: Error, Equatable {
 @MainActor
 final class CoderVPNService: NSObject, VPNService {
     var logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "vpn")
-    lazy var xpc: VPNXPCInterface = .init(vpn: self)
+    lazy var xpc: HelperXPCClient = .init(vpn: self)
 
-    @Published var tunnelState: VPNServiceState = .disabled {
+    @Published private(set) var tunnelState: VPNServiceState = .disabled {
         didSet {
             if tunnelState == .connecting {
                 progress = .init(stage: .initial, downloadProgress: nil)
@@ -80,9 +80,9 @@ final class CoderVPNService: NSObject, VPNService {
         return tunnelState
     }
 
-    @Published var progress: VPNProgress = .init(stage: .initial, downloadProgress: nil)
+    @Published private(set) var progress: VPNProgress = .init(stage: .initial, downloadProgress: nil)
 
-    @Published var menuState: VPNMenuState = .init()
+    @Published private(set) var menuState: VPNMenuState = .init()
 
     // Whether the VPN should start as soon as possible
     var startWhenReady: Bool = false
@@ -138,10 +138,10 @@ final class CoderVPNService: NSObject, VPNService {
         }
     }
 
-    func onExtensionPeerUpdate(_ data: Data) {
+    func onExtensionPeerUpdate(_ diff: Data) {
         logger.info("network extension peer update")
         do {
-            let msg = try Vpn_PeerUpdate(serializedBytes: data)
+            let msg = try Vpn_PeerUpdate(serializedBytes: diff)
             debugPrint(msg)
             applyPeerUpdate(with: msg)
         } catch {
@@ -185,10 +185,12 @@ extension CoderVPNService {
         // Any -> Disconnected: Update UI w/ error if present
         case (_, .disconnected):
             connection.fetchLastDisconnectError { err in
-                self.tunnelState = if let err {
-                    .failed(.internalError(err.localizedDescription))
-                } else {
-                    .disabled
+                Task { @MainActor in
+                    self.tunnelState = if let err {
+                        .failed(.internalError(err.localizedDescription))
+                    } else {
+                        .disabled
+                    }
                 }
             }
         // Connecting -> Connecting: no-op
@@ -199,16 +201,18 @@ extension CoderVPNService {
             break
         // Non-connecting -> Connecting: Establish XPC
         case (_, .connecting):
-            xpc.connect()
-            xpc.ping()
+            // Detached to run ASAP
+            // TODO: Switch to `Task.immediate` once stable
+            Task.detached { try? await self.xpc.ping() }
             tunnelState = .connecting
         // Non-connected -> Connected:
         // - Retrieve Peers
         // - Run `onStart` closure
         case (_, .connected):
             onStart?()
-            xpc.connect()
-            xpc.getPeerState()
+            // Detached to run ASAP
+            // TODO: Switch to `Task.immediate` once stable
+            Task.detached { try? await self.xpc.getPeerState() }
             tunnelState = .connected
         // Any -> Reasserting
         case (_, .reasserting):
diff --git a/Coder-Desktop/Coder-Desktop/Views/CircularProgressView.swift b/Coder-Desktop/Coder-Desktop/Views/CircularProgressView.swift
index 7b143969..3f97aa15 100644
--- a/Coder-Desktop/Coder-Desktop/Views/CircularProgressView.swift
+++ b/Coder-Desktop/Coder-Desktop/Views/CircularProgressView.swift
@@ -3,13 +3,24 @@ import SwiftUI
 struct CircularProgressView: View {
     let value: Float?
 
-    var strokeWidth: CGFloat = 4
-    var diameter: CGFloat = 22
+    var strokeWidth: CGFloat
+    var diameter: CGFloat
     var primaryColor: Color = .secondary
     var backgroundColor: Color = .secondary.opacity(0.3)
 
-    var autoCompleteThreshold: Float?
-    var autoCompleteDuration: TimeInterval?
+    private var autoComplete: (threshold: Float, duration: TimeInterval)?
+    private var autoStart: (until: Float, duration: TimeInterval)?
+
+    @State private var currentProgress: Float = 0
+
+    init(value: Float? = nil,
+         strokeWidth: CGFloat = 4,
+         diameter: CGFloat = 22)
+    {
+        self.value = value
+        self.strokeWidth = strokeWidth
+        self.diameter = diameter
+    }
 
     var body: some View {
         ZStack {
@@ -19,13 +30,23 @@ struct CircularProgressView: View {
                         .stroke(backgroundColor, style: StrokeStyle(lineWidth: strokeWidth, lineCap: .round))
 
                     Circle()
-                        .trim(from: 0, to: CGFloat(displayValue(for: value)))
+                        .trim(from: 0, to: CGFloat(displayValue(for: currentProgress)))
                         .stroke(primaryColor, style: StrokeStyle(lineWidth: strokeWidth, lineCap: .round))
                         .rotationEffect(.degrees(-90))
-                        .animation(autoCompleteAnimation(for: value), value: value)
                 }
                 .frame(width: diameter, height: diameter)
-
+                .onAppear {
+                    if let autoStart, value == 0 {
+                        withAnimation(.easeOut(duration: autoStart.duration)) {
+                            currentProgress = autoStart.until
+                        }
+                    }
+                }
+                .onChange(of: value) {
+                    withAnimation(currentAnimation(for: value)) {
+                        currentProgress = value
+                    }
+                }
             } else {
                 IndeterminateSpinnerView(
                     diameter: diameter,
@@ -40,7 +61,7 @@ struct CircularProgressView: View {
     }
 
     private func displayValue(for value: Float) -> Float {
-        if let threshold = autoCompleteThreshold,
+        if let threshold = autoComplete?.threshold,
            value >= threshold, value < 1.0
         {
             return 1.0
@@ -48,23 +69,31 @@ struct CircularProgressView: View {
         return value
     }
 
-    private func autoCompleteAnimation(for value: Float) -> Animation? {
-        guard let threshold = autoCompleteThreshold,
-              let duration = autoCompleteDuration,
-              value >= threshold, value < 1.0
+    private func currentAnimation(for value: Float) -> Animation {
+        guard let autoComplete,
+              value >= autoComplete.threshold, value < 1.0
         else {
+            // Use the auto-start animation if it's running, otherwise default.
+            if let autoStart {
+                return .easeOut(duration: autoStart.duration)
+            }
             return .default
         }
 
-        return .easeOut(duration: duration)
+        return .easeOut(duration: autoComplete.duration)
     }
 }
 
 extension CircularProgressView {
     func autoComplete(threshold: Float, duration: TimeInterval) -> CircularProgressView {
         var view = self
-        view.autoCompleteThreshold = threshold
-        view.autoCompleteDuration = duration
+        view.autoComplete = (threshold: threshold, duration: duration)
+        return view
+    }
+
+    func autoStart(until value: Float, duration: TimeInterval) -> CircularProgressView {
+        var view = self
+        view.autoStart = (until: value, duration: duration)
         return view
     }
 }
diff --git a/Coder-Desktop/Coder-Desktop/Views/FileSync/FilePicker.swift b/Coder-Desktop/Coder-Desktop/Views/FileSync/FilePicker.swift
index 9ec26231..24e938a4 100644
--- a/Coder-Desktop/Coder-Desktop/Views/FileSync/FilePicker.swift
+++ b/Coder-Desktop/Coder-Desktop/Views/FileSync/FilePicker.swift
@@ -69,9 +69,9 @@ struct FilePicker: View {
 
 @MainActor
 class FilePickerModel: ObservableObject {
-    @Published var rootEntries: [FilePickerEntryModel] = []
-    @Published var rootIsLoading: Bool = false
-    @Published var error: SDKError?
+    @Published private(set) var rootEntries: [FilePickerEntryModel] = []
+    @Published private(set) var rootIsLoading: Bool = false
+    @Published private(set) var error: SDKError?
 
     // It's important that `AgentClient` is a reference type (class)
     // as we were having performance issues with a struct (unless it was a binding).
@@ -153,9 +153,9 @@ class FilePickerEntryModel: Identifiable, Hashable, ObservableObject {
 
     let client: AgentClient
 
-    @Published var entries: [FilePickerEntryModel]?
-    @Published var isLoading = false
-    @Published var error: SDKError?
+    @Published private(set) var entries: [FilePickerEntryModel]?
+    @Published private(set) var isLoading = false
+    @Published private(set) var error: SDKError?
     @Published private var innerIsExpanded = false
     var isExpanded: Bool {
         get { innerIsExpanded }
diff --git a/Coder-Desktop/Coder-Desktop/Views/LoginForm.swift b/Coder-Desktop/Coder-Desktop/Views/LoginForm.swift
index 5e9227ff..0ac4030c 100644
--- a/Coder-Desktop/Coder-Desktop/Views/LoginForm.swift
+++ b/Coder-Desktop/Coder-Desktop/Views/LoginForm.swift
@@ -90,7 +90,7 @@ struct LoginForm: View {
             return
         }
         // x.compare(y) is .orderedDescending if x > y
-        guard SignatureValidator.minimumCoderVersion.compare(semver, options: .numeric) != .orderedDescending else {
+        guard Validator.minimumCoderVersion.compare(semver, options: .numeric) != .orderedDescending else {
             loginError = .outdatedCoderVersion
             return
         }
@@ -192,19 +192,19 @@ struct LoginForm: View {
 @discardableResult
 func validateURL(_ url: String) throws(LoginError) -> URL {
     guard let url = URL(string: url) else {
-        throw LoginError.invalidURL
+        throw .invalidURL
     }
-    guard url.scheme == "https" else {
-        throw LoginError.httpsRequired
+    guard url.scheme == "https" || url.scheme == "http" else {
+        throw .invalidScheme
     }
     guard url.host != nil else {
-        throw LoginError.noHost
+        throw .noHost
     }
     return url
 }
 
 enum LoginError: Error {
-    case httpsRequired
+    case invalidScheme
     case noHost
     case invalidURL
     case outdatedCoderVersion
@@ -213,16 +213,16 @@ enum LoginError: Error {
 
     var description: String {
         switch self {
-        case .httpsRequired:
-            "URL must use HTTPS"
+        case .invalidScheme:
+            "Coder URL must use HTTPS or HTTP"
         case .noHost:
-            "URL must have a host"
+            "Coder URL must have a host"
         case .invalidURL:
-            "Invalid URL"
+            "Invalid Coder URL"
         case .outdatedCoderVersion:
             """
-            The Coder deployment must be version \(SignatureValidator.minimumCoderVersion)
-            or higher to use Coder Desktop.
+            The Coder deployment must be version \(Validator.minimumCoderVersion)
+            or higher to use this version of Coder Desktop.
             """
         case let .failedAuth(err):
             "Could not authenticate with Coder deployment:\n\(err.localizedDescription)"
diff --git a/Coder-Desktop/Coder-Desktop/Views/Settings/ExperimentalTab.swift b/Coder-Desktop/Coder-Desktop/Views/Settings/ExperimentalTab.swift
deleted file mode 100644
index 838f4587..00000000
--- a/Coder-Desktop/Coder-Desktop/Views/Settings/ExperimentalTab.swift
+++ /dev/null
@@ -1,10 +0,0 @@
-import LaunchAtLogin
-import SwiftUI
-
-struct ExperimentalTab: View {
-    var body: some View {
-        Form {
-            HelperSection()
-        }.formStyle(.grouped)
-    }
-}
diff --git a/Coder-Desktop/Coder-Desktop/Views/Settings/GeneralTab.swift b/Coder-Desktop/Coder-Desktop/Views/Settings/GeneralTab.swift
index 7af41e4b..d779a9ac 100644
--- a/Coder-Desktop/Coder-Desktop/Views/Settings/GeneralTab.swift
+++ b/Coder-Desktop/Coder-Desktop/Views/Settings/GeneralTab.swift
@@ -19,18 +19,25 @@ struct GeneralTab: View {
                     Text("Start Coder Connect on launch")
                 }
             }
-            Section {
-                Toggle(isOn: $updater.autoCheckForUpdates) {
-                    Text("Automatically check for updates")
-                }
-                Picker("Update channel", selection: $updater.updateChannel) {
-                    ForEach(UpdateChannel.allCases) { channel in
-                        Text(channel.name).tag(channel)
+            if !updater.disabled {
+                Section {
+                    Toggle(isOn: $updater.autoCheckForUpdates) {
+                        Text("Automatically check for updates")
+                    }
+                    Picker("Update channel", selection: $updater.updateChannel) {
+                        ForEach(UpdateChannel.allCases) { channel in
+                            Text(channel.name).tag(channel)
+                        }
+                    }
+                    HStack {
+                        Spacer()
+                        Button("Check for updates") { updater.checkForUpdates() }.disabled(!updater.canCheckForUpdates)
                     }
                 }
-                HStack {
-                    Spacer()
-                    Button("Check for updates") { updater.checkForUpdates() }.disabled(!updater.canCheckForUpdates)
+            } else {
+                Section {
+                    Text("The app updater has been disabled by a device management policy.")
+                        .foregroundColor(.secondary)
                 }
             }
         }.formStyle(.grouped)
diff --git a/Coder-Desktop/Coder-Desktop/Views/Settings/HelperSection.swift b/Coder-Desktop/Coder-Desktop/Views/Settings/HelperSection.swift
deleted file mode 100644
index 66fdc534..00000000
--- a/Coder-Desktop/Coder-Desktop/Views/Settings/HelperSection.swift
+++ /dev/null
@@ -1,82 +0,0 @@
-import LaunchAtLogin
-import ServiceManagement
-import SwiftUI
-
-struct HelperSection: View {
-    var body: some View {
-        Section {
-            HelperButton()
-            Text("""
-            Coder Connect executes a dynamic library downloaded from the Coder deployment.
-            Administrator privileges are required when executing a copy of this library for the first time.
-            Without this helper, these are granted by the user entering their password.
-            With this helper, this is done automatically.
-            This is useful if the Coder deployment updates frequently.
-
-            Coder Desktop will not execute code unless it has been signed by Coder.
-            """)
-            .font(.subheadline)
-            .foregroundColor(.secondary)
-        }
-    }
-}
-
-struct HelperButton: View {
-    @EnvironmentObject var helperService: HelperService
-
-    var buttonText: String {
-        switch helperService.state {
-        case .uninstalled, .failed:
-            "Install"
-        case .installed:
-            "Uninstall"
-        case .requiresApproval:
-            "Open Settings"
-        }
-    }
-
-    var buttonDescription: String {
-        switch helperService.state {
-        case .uninstalled, .installed:
-            ""
-        case .requiresApproval:
-            "Requires approval"
-        case let .failed(err):
-            err.localizedDescription
-        }
-    }
-
-    func buttonAction() {
-        switch helperService.state {
-        case .uninstalled, .failed:
-            helperService.install()
-            if helperService.state == .requiresApproval {
-                SMAppService.openSystemSettingsLoginItems()
-            }
-        case .installed:
-            helperService.uninstall()
-        case .requiresApproval:
-            SMAppService.openSystemSettingsLoginItems()
-        }
-    }
-
-    var body: some View {
-        HStack {
-            Text("Privileged Helper")
-            Spacer()
-            Text(buttonDescription)
-                .foregroundColor(.secondary)
-            Button(action: buttonAction) {
-                Text(buttonText)
-            }
-        }.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
-            helperService.update()
-        }.onAppear {
-            helperService.update()
-        }
-    }
-}
-
-#Preview {
-    HelperSection().environmentObject(HelperService())
-}
diff --git a/Coder-Desktop/Coder-Desktop/Views/Settings/Settings.swift b/Coder-Desktop/Coder-Desktop/Views/Settings/Settings.swift
index 170d171b..8aac9a0c 100644
--- a/Coder-Desktop/Coder-Desktop/Views/Settings/Settings.swift
+++ b/Coder-Desktop/Coder-Desktop/Views/Settings/Settings.swift
@@ -13,11 +13,6 @@ struct SettingsView<VPN: VPNService>: View {
                 .tabItem {
                     Label("Network", systemImage: "dot.radiowaves.left.and.right")
                 }.tag(SettingsTab.network)
-            ExperimentalTab()
-                .tabItem {
-                    Label("Experimental", systemImage: "gearshape.2")
-                }.tag(SettingsTab.experimental)
-
         }.frame(width: 600)
             .frame(maxHeight: 500)
             .scrollContentBackground(.hidden)
@@ -28,5 +23,4 @@ struct SettingsView<VPN: VPNService>: View {
 enum SettingsTab: Int {
     case general
     case network
-    case experimental
 }
diff --git a/Coder-Desktop/Coder-Desktop/Views/Util.swift b/Coder-Desktop/Coder-Desktop/Views/Util.swift
index 69981a25..10d07479 100644
--- a/Coder-Desktop/Coder-Desktop/Views/Util.swift
+++ b/Coder-Desktop/Coder-Desktop/Views/Util.swift
@@ -44,3 +44,26 @@ public extension View {
         }
     }
 }
+
+@MainActor
+private struct ActivationPolicyModifier: ViewModifier {
+    func body(content: Content) -> some View {
+        content
+            // This lets us show and hide the app from the dock and cmd+tab
+            // when a window is open.
+            .onAppear {
+                NSApp.setActivationPolicy(.regular)
+            }
+            .onDisappear {
+                if NSApp.windows.filter { $0.level != .statusBar && $0.isVisible }.count <= 1 {
+                    NSApp.setActivationPolicy(.accessory)
+                }
+            }
+    }
+}
+
+public extension View {
+    func showDockIconWhenOpen() -> some View {
+        modifier(ActivationPolicyModifier())
+    }
+}
diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNState.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNState.swift
index 9584ced2..c3bf0d1b 100644
--- a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNState.swift
+++ b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNState.swift
@@ -10,20 +10,10 @@ struct VPNState<VPN: VPNService>: View {
         Group {
             switch (vpn.state, state.hasSession) {
             case (.failed(.systemExtensionError(.needsUserApproval)), _):
-                VStack {
-                    Text("Awaiting System Extension approval")
-                        .foregroundColor(.secondary)
-                        .multilineTextAlignment(.center)
-                        .fixedSize(horizontal: false, vertical: true)
-                        .padding(.horizontal, Theme.Size.trayInset)
-                        .padding(.vertical, Theme.Size.trayPadding)
-                        .frame(maxWidth: .infinity)
-                    Button {
-                        openSystemExtensionSettings()
-                    } label: {
-                        Text("Approve in System Settings")
-                    }
-                }
+                ApprovalRequiredView(
+                    message: "Awaiting System Extension approval",
+                    action: openSystemExtensionSettings
+                )
             case (_, false):
                 Text("Sign in to use Coder Desktop")
                     .font(.body)
@@ -32,20 +22,12 @@ struct VPNState<VPN: VPNService>: View {
                 VStack {
                     Text("The system VPN requires reconfiguration")
                         .foregroundColor(.secondary)
-                        .multilineTextAlignment(.center)
-                        .fixedSize(horizontal: false, vertical: true)
-                        .padding(.horizontal, Theme.Size.trayInset)
-                        .padding(.vertical, Theme.Size.trayPadding)
-                        .frame(maxWidth: .infinity)
+                        .vpnStateMessage()
                     Button {
                         state.reconfigure()
                     } label: {
                         Text("Reconfigure VPN")
                     }
-                }.onAppear {
-                    // Show the prompt onAppear, so the user doesn't have to
-                    // open the menu bar an extra time
-                    state.reconfigure()
                 }
             case (.disabled, _):
                 Text("Enable Coder Connect to see workspaces")
@@ -61,11 +43,7 @@ struct VPNState<VPN: VPNService>: View {
                 Text("\(vpnErr.description)")
                     .font(.headline)
                     .foregroundColor(.red)
-                    .multilineTextAlignment(.center)
-                    .fixedSize(horizontal: false, vertical: true)
-                    .padding(.horizontal, Theme.Size.trayInset)
-                    .padding(.vertical, Theme.Size.trayPadding)
-                    .frame(maxWidth: .infinity)
+                    .vpnStateMessage()
             case (.connected, true):
                 EmptyView()
             }
@@ -73,3 +51,38 @@ struct VPNState<VPN: VPNService>: View {
         .onReceive(inspection.notice) { inspection.visit(self, $0) } // viewInspector
     }
 }
+
+struct ApprovalRequiredView: View {
+    let message: String
+    let action: () -> Void
+
+    var body: some View {
+        VStack {
+            Text(message)
+                .foregroundColor(.secondary)
+                .vpnStateMessage()
+            Button {
+                action()
+            } label: {
+                Text("Approve in System Settings")
+            }
+        }
+    }
+}
+
+struct VPNStateMessageTextModifier: ViewModifier {
+    func body(content: Content) -> some View {
+        content
+            .multilineTextAlignment(.center)
+            .fixedSize(horizontal: false, vertical: true)
+            .padding(.horizontal, Theme.Size.trayInset)
+            .padding(.vertical, Theme.Size.trayPadding)
+            .frame(maxWidth: .infinity)
+    }
+}
+
+extension View {
+    func vpnStateMessage() -> some View {
+        modifier(VPNStateMessageTextModifier())
+    }
+}
diff --git a/Coder-Desktop/Coder-Desktop/XPCInterface.swift b/Coder-Desktop/Coder-Desktop/XPCInterface.swift
deleted file mode 100644
index e6c78d6d..00000000
--- a/Coder-Desktop/Coder-Desktop/XPCInterface.swift
+++ /dev/null
@@ -1,114 +0,0 @@
-import Foundation
-import NetworkExtension
-import os
-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 var xpc: VPNXPCProtocol?
-
-    init(vpn: CoderVPNService) {
-        svc = vpn
-        super.init()
-    }
-
-    func connect() {
-        logger.debug("VPN xpc connect called")
-        guard xpc == nil else {
-            logger.debug("VPN xpc already exists")
-            return
-        }
-        let networkExtDict = Bundle.main.object(forInfoDictionaryKey: "NetworkExtension") as? [String: Any]
-        let machServiceName = networkExtDict?["NEMachServiceName"] as? String
-        let xpcConn = NSXPCConnection(machServiceName: machServiceName!)
-        xpcConn.remoteObjectInterface = NSXPCInterface(with: VPNXPCProtocol.self)
-        xpcConn.exportedInterface = NSXPCInterface(with: VPNXPCClientCallbackProtocol.self)
-        guard let proxy = xpcConn.remoteObjectProxy as? VPNXPCProtocol else {
-            fatalError("invalid xpc cast")
-        }
-        xpc = proxy
-
-        logger.debug("connecting to machServiceName: \(machServiceName!)")
-
-        xpcConn.exportedObject = self
-        xpcConn.invalidationHandler = { [logger] in
-            Task { @MainActor in
-                logger.error("VPN XPC connection invalidated.")
-                self.xpc = nil
-                self.connect()
-            }
-        }
-        xpcConn.interruptionHandler = { [logger] in
-            Task { @MainActor in
-                logger.error("VPN XPC connection interrupted.")
-                self.xpc = nil
-                self.connect()
-            }
-        }
-        xpcConn.resume()
-    }
-
-    func 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)
-        }
-    }
-
-    func onProgress(stage: ProgressStage, downloadProgress: DownloadProgress?) {
-        Task { @MainActor in
-            svc.onProgress(stage: stage, downloadProgress: downloadProgress)
-        }
-    }
-
-    // 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-DesktopHelper/HelperXPCListeners.swift b/Coder-Desktop/Coder-DesktopHelper/HelperXPCListeners.swift
new file mode 100644
index 00000000..9b65d8e5
--- /dev/null
+++ b/Coder-Desktop/Coder-DesktopHelper/HelperXPCListeners.swift
@@ -0,0 +1,204 @@
+import CoderSDK
+import Foundation
+import os
+import VPNLib
+
+// This listener handles XPC connections from the Coder Desktop System Network
+// Extension (`com.coder.Coder-Desktop.VPN`).
+class HelperNEXPCServer: NSObject, NSXPCListenerDelegate, @unchecked Sendable {
+    private var logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "HelperNEXPCServer")
+    private var conns: [NSXPCConnection] = []
+
+    // Hold a reference to the tun file handle
+    // to prevent it from being closed.
+    private var tunFile: FileHandle?
+
+    override init() {
+        super.init()
+    }
+
+    func listener(_: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool {
+        logger.info("new active connection")
+        newConnection.exportedInterface = NSXPCInterface(with: HelperNEXPCInterface.self)
+        newConnection.exportedObject = self
+        newConnection.remoteObjectInterface = NSXPCInterface(with: NEXPCInterface.self)
+        newConnection.invalidationHandler = { [weak self] in
+            guard let self else { return }
+            conns.removeAll { $0 == newConnection }
+            logger.debug("connection invalidated")
+        }
+        newConnection.interruptionHandler = { [weak self] in
+            guard let self else { return }
+            conns.removeAll { $0 == newConnection }
+            logger.debug("connection interrupted")
+        }
+        newConnection.setCodeSigningRequirement(Validator.xpcPeerRequirement)
+        newConnection.resume()
+        conns.append(newConnection)
+        return true
+    }
+
+    func cancelProvider(error: Error?) async throws {
+        try await withCheckedThrowingContinuation { continuation in
+            guard let proxy = conns.last?.remoteObjectProxyWithErrorHandler({ err in
+                self.logger.error("failed to connect to HelperNEXPC \(err.localizedDescription, privacy: .public)")
+                continuation.resume(throwing: err)
+            }) as? NEXPCInterface else {
+                self.logger.error("failed to get proxy for HelperNEXPCInterface")
+                continuation.resume(throwing: XPCError.wrongProxyType)
+                return
+            }
+            proxy.cancelProvider(error: error) {
+                self.logger.info("provider cancelled")
+                continuation.resume()
+            }
+        } as Void
+    }
+
+    func applyTunnelNetworkSettings(diff: Vpn_NetworkSettingsRequest) async throws {
+        let bytes = try diff.serializedData()
+        return try await withCheckedThrowingContinuation { continuation in
+            guard let proxy = conns.last?.remoteObjectProxyWithErrorHandler({ err in
+                self.logger.error("failed to connect to HelperNEXPC \(err.localizedDescription, privacy: .public)")
+                continuation.resume(throwing: err)
+            }) as? NEXPCInterface else {
+                self.logger.error("failed to get proxy for HelperNEXPCInterface")
+                continuation.resume(throwing: XPCError.wrongProxyType)
+                return
+            }
+            proxy.applyTunnelNetworkSettings(diff: bytes) {
+                self.logger.info("applied tunnel network setting")
+                continuation.resume()
+            }
+        }
+    }
+}
+
+extension HelperNEXPCServer: HelperNEXPCInterface {
+    func startDaemon(
+        accessURL: URL,
+        token: String,
+        tun: FileHandle,
+        headers: Data?,
+        reply: @escaping (Error?) -> Void
+    ) {
+        logger.info("startDaemon called")
+        tunFile = tun
+        let reply = CallbackWrapper(reply)
+        Task { @MainActor in
+            do throws(ManagerError) {
+                let manager = try await Manager(
+                    cfg: .init(
+                        apiToken: token,
+                        serverUrl: accessURL,
+                        tunFd: tun.fileDescriptor,
+                        literalHeaders: headers.flatMap { try? JSONDecoder().decode([HTTPHeader].self, from: $0) } ?? []
+                    )
+                )
+                try await manager.startVPN()
+                globalManager = manager
+            } catch {
+                reply(makeNSError(suffix: "Manager", desc: error.description))
+                return
+            }
+            reply(nil)
+        }
+    }
+
+    func stopDaemon(reply: @escaping (Error?) -> Void) {
+        logger.info("stopDaemon called")
+        let reply = CallbackWrapper(reply)
+        Task { @MainActor in
+            guard let manager = globalManager else {
+                logger.error("stopDaemon called with nil Manager")
+                reply(makeNSError(suffix: "Manager", desc: "Missing Manager"))
+                return
+            }
+            do throws(ManagerError) {
+                try await manager.stopVPN()
+            } catch {
+                reply(makeNSError(suffix: "Manager", desc: error.description))
+                return
+            }
+            globalManager = nil
+            reply(nil)
+        }
+    }
+}
+
+// This listener handles XPC connections from the Coder Desktop App
+// (`com.coder.Coder-Desktop`).
+class HelperAppXPCServer: NSObject, NSXPCListenerDelegate, @unchecked Sendable {
+    private var logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "HelperAppXPCServer")
+    private var conns: [NSXPCConnection] = []
+
+    override init() {
+        super.init()
+    }
+
+    func listener(_: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool {
+        logger.info("new app connection")
+        newConnection.exportedInterface = NSXPCInterface(with: HelperAppXPCInterface.self)
+        newConnection.exportedObject = self
+        newConnection.remoteObjectInterface = NSXPCInterface(with: AppXPCInterface.self)
+        newConnection.invalidationHandler = { [weak self] in
+            guard let self else { return }
+            conns.removeAll { $0 == newConnection }
+            logger.debug("app connection invalidated")
+        }
+        newConnection.setCodeSigningRequirement(Validator.xpcPeerRequirement)
+        newConnection.resume()
+        conns.append(newConnection)
+        return true
+    }
+
+    func onPeerUpdate(update: Vpn_PeerUpdate) async throws {
+        let bytes = try update.serializedData()
+        return try await withCheckedThrowingContinuation { continuation in
+            guard let proxy = conns.last?.remoteObjectProxyWithErrorHandler({ err in
+                self.logger.error("failed to connect to HelperAppXPC \(err.localizedDescription, privacy: .public)")
+                continuation.resume(throwing: err)
+            }) as? AppXPCInterface else {
+                self.logger.error("failed to get proxy for HelperAppXPCInterface")
+                continuation.resume(throwing: XPCError.wrongProxyType)
+                return
+            }
+            proxy.onPeerUpdate(bytes) {
+                self.logger.info("sent peer update")
+                continuation.resume()
+            }
+        }
+    }
+
+    func onProgress(stage: ProgressStage, downloadProgress: DownloadProgress?) async throws {
+        try await withCheckedThrowingContinuation { continuation in
+            guard let proxy = conns.last?.remoteObjectProxyWithErrorHandler({ err in
+                self.logger.error("failed to connect to HelperAppXPC \(err.localizedDescription, privacy: .public)")
+                continuation.resume(throwing: err)
+            }) as? AppXPCInterface else {
+                self.logger.error("failed to get proxy for HelperAppXPCInterface")
+                continuation.resume(throwing: XPCError.wrongProxyType)
+                return
+            }
+            proxy.onProgress(stage: stage, downloadProgress: downloadProgress) {
+                self.logger.info("sent progress update")
+                continuation.resume()
+            }
+        } as Void
+    }
+}
+
+extension HelperAppXPCServer: HelperAppXPCInterface {
+    func getPeerState(with reply: @escaping (Data?) -> Void) {
+        logger.info("getPeerState called")
+        let reply = CallbackWrapper(reply)
+        Task { @MainActor in
+            let data = try? await globalManager?.getPeerState().serializedData()
+            reply(data)
+        }
+    }
+
+    func ping(reply: @escaping () -> Void) {
+        reply()
+    }
+}
diff --git a/Coder-Desktop/Coder-DesktopHelper/HelperXPCProtocol.swift b/Coder-Desktop/Coder-DesktopHelper/HelperXPCProtocol.swift
deleted file mode 100644
index 5ffed59a..00000000
--- a/Coder-Desktop/Coder-DesktopHelper/HelperXPCProtocol.swift
+++ /dev/null
@@ -1,5 +0,0 @@
-import Foundation
-
-@objc protocol HelperXPCProtocol {
-    func removeQuarantine(path: String, withReply reply: @escaping (Int32, String) -> Void)
-}
diff --git a/Coder-Desktop/VPN/Manager.swift b/Coder-Desktop/Coder-DesktopHelper/Manager.swift
similarity index 68%
rename from Coder-Desktop/VPN/Manager.swift
rename to Coder-Desktop/Coder-DesktopHelper/Manager.swift
index 952e301e..7ef3d617 100644
--- a/Coder-Desktop/VPN/Manager.swift
+++ b/Coder-Desktop/Coder-DesktopHelper/Manager.swift
@@ -4,30 +4,59 @@ import os
 import VPNLib
 
 actor Manager {
-    let ptp: PacketTunnelProvider
     let cfg: ManagerConfig
     let telemetryEnricher: TelemetryEnricher
 
-    let tunnelHandle: TunnelHandle
+    let tunnelDaemon: TunnelDaemon
     let speaker: Speaker<Vpn_ManagerMessage, Vpn_TunnelMessage>
     var readLoop: Task<Void, any Error>!
 
-    private let dest = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
-        .first!.appending(path: "coder-vpn.dylib")
+    #if arch(arm64)
+        private static let binaryName = "coder-darwin-arm64"
+    #else
+        private static let binaryName = "coder-darwin-amd64"
+    #endif
+
+    // /var/root/Library/Application Support/com.coder.Coder-Desktop/coder-darwin-{arm64,amd64}
+    private let dest = try? FileManager.default
+        .url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
+        .appendingPathComponent(Bundle.main.bundleIdentifier ?? "com.coder.Coder-Desktop", isDirectory: true)
+        .appendingPathComponent(binaryName)
+
     private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "manager")
 
     // swiftlint:disable:next function_body_length
-    init(with: PacketTunnelProvider, cfg: ManagerConfig) async throws(ManagerError) {
-        ptp = with
+    init(cfg: ManagerConfig) async throws(ManagerError) {
         self.cfg = cfg
         telemetryEnricher = TelemetryEnricher()
-        #if arch(arm64)
-            let dylibPath = cfg.serverUrl.appending(path: "bin/coder-vpn-darwin-arm64.dylib")
-        #elseif arch(x86_64)
-            let dylibPath = cfg.serverUrl.appending(path: "bin/coder-vpn-darwin-amd64.dylib")
-        #else
-            fatalError("unknown architecture")
-        #endif
+        guard let dest else {
+            // This should never happen
+            throw .fileError("Failed to create path for binary destination" +
+                "(/var/root/Library/Application Support/com.coder.Coder-Desktop)")
+        }
+        do {
+            try FileManager.default.ensureDirectories(for: dest)
+        } catch {
+            throw .fileError(
+                "Failed to create directories for binary destination (\(dest)): \(error.localizedDescription)"
+            )
+        }
+        let client = Client(url: cfg.serverUrl)
+        let buildInfo: BuildInfoResponse
+        do {
+            buildInfo = try await client.buildInfo()
+        } catch {
+            throw .serverInfo(error.description)
+        }
+        guard let serverSemver = buildInfo.semver else {
+            throw .serverInfo("invalid version: \(buildInfo.version)")
+        }
+        guard Validator.minimumCoderVersion
+            .compare(serverSemver, options: .numeric) != .orderedDescending
+        else {
+            throw .belowMinimumCoderVersion(actualVersion: serverSemver)
+        }
+        let binaryPath = cfg.serverUrl.appending(path: "bin").appending(path: Manager.binaryName)
         do {
             let sessionConfig = URLSessionConfiguration.default
             // The tunnel might be asked to start before the network interfaces have woken up from sleep
@@ -36,7 +65,7 @@ actor Manager {
             sessionConfig.timeoutIntervalForRequest = 60
             sessionConfig.timeoutIntervalForResource = 300
             try await download(
-                src: dylibPath,
+                src: binaryPath,
                 dest: dest,
                 urlSession: URLSession(configuration: sessionConfig)
             ) { progress in
@@ -46,52 +75,47 @@ actor Manager {
             throw .download(error)
         }
         pushProgress(stage: .validating)
-        let client = Client(url: cfg.serverUrl)
-        let buildInfo: BuildInfoResponse
         do {
-            buildInfo = try await client.buildInfo()
+            try Validator.validateSignature(binaryPath: dest)
+            try await Validator.validateVersion(binaryPath: dest, serverVersion: buildInfo.version)
         } catch {
-            throw .serverInfo(error.description)
-        }
-        guard let semver = buildInfo.semver else {
-            throw .serverInfo("invalid version: \(buildInfo.version)")
+            // Cleanup unvalid binary
+            try? FileManager.default.removeItem(at: dest)
+            throw .validation(error)
         }
+
+        // Without this, the TUN fd isn't recognised as a socket in the
+        // spawned process, and the tunnel fails to start.
         do {
-            try SignatureValidator.validate(path: dest, expectedVersion: semver)
+            try unsetCloseOnExec(fd: cfg.tunFd)
         } catch {
-            throw .validation(error)
+            throw .cloexec(error)
         }
 
-        // HACK: The downloaded dylib may be quarantined, but we've validated it's signature
-        // so it's safe to execute. However, the SE must be sandboxed, so we defer to the app.
-        try await removeQuarantine(dest)
-
         do {
-            try tunnelHandle = TunnelHandle(dylibPath: dest)
+            try tunnelDaemon = await TunnelDaemon(binaryPath: dest) { err in
+                Task { try? await NEXPCServerDelegate.cancelProvider(error:
+                    makeNSError(suffix: "TunnelDaemon", desc: "Tunnel daemon: \(err.description)")
+                ) }
+            }
         } catch {
             throw .tunnelSetup(error)
         }
         speaker = await Speaker<Vpn_ManagerMessage, Vpn_TunnelMessage>(
-            writeFD: tunnelHandle.writeHandle,
-            readFD: tunnelHandle.readHandle
+            writeFD: tunnelDaemon.writeHandle,
+            readFD: tunnelDaemon.readHandle
         )
         do {
             try await speaker.handshake()
         } catch {
             throw .handshake(error)
         }
-        do {
-            try await tunnelHandle.openTunnelTask?.value
-        } catch let error as TunnelHandleError {
-            logger.error("failed to wait for dylib to open tunnel: \(error, privacy: .public) ")
-            throw .tunnelSetup(error)
-        } catch {
-            fatalError("openTunnelTask must only throw TunnelHandleError")
-        }
 
         readLoop = Task { try await run() }
     }
 
+    deinit { logger.debug("manager deinit") }
+
     func run() async throws {
         do {
             for try await m in speaker {
@@ -104,15 +128,15 @@ actor Manager {
             }
         } catch {
             logger.error("tunnel read loop failed: \(error.localizedDescription, privacy: .public)")
-            try await tunnelHandle.close()
-            ptp.cancelTunnelWithError(
+            try await tunnelDaemon.close()
+            try await NEXPCServerDelegate.cancelProvider(error:
                 makeNSError(suffix: "Manager", desc: "Tunnel read loop failed: \(error.localizedDescription)")
             )
             return
         }
         logger.info("tunnel read loop exited")
-        try await tunnelHandle.close()
-        ptp.cancelTunnelWithError(nil)
+        try await tunnelDaemon.close()
+        try await NEXPCServerDelegate.cancelProvider(error: nil)
     }
 
     func handleMessage(_ msg: Vpn_TunnelMessage) {
@@ -122,14 +146,7 @@ actor Manager {
         }
         switch msgType {
         case .peerUpdate:
-            if let conn = globalXPCListenerDelegate.conn {
-                do {
-                    let data = try msg.peerUpdate.serializedData()
-                    conn.onPeerUpdate(data)
-                } catch {
-                    logger.error("failed to send peer update to client: \(error)")
-                }
-            }
+            Task { try? await appXPCServerDelegate.onPeerUpdate(update: msg.peerUpdate) }
         case let .log(logMsg):
             writeVpnLog(logMsg)
         case .networkSettings, .start, .stop:
@@ -145,7 +162,7 @@ actor Manager {
         switch msgType {
         case let .networkSettings(ns):
             do {
-                try await ptp.applyTunnelNetworkSettings(ns)
+                try await NEXPCServerDelegate.applyTunnelNetworkSettings(diff: ns)
                 try? await rpc.sendReply(.with { resp in
                     resp.networkSettings = .with { settings in
                         settings.success = true
@@ -167,16 +184,12 @@ actor Manager {
     func startVPN() async throws(ManagerError) {
         pushProgress(stage: .startingTunnel)
         logger.info("sending start rpc")
-        guard let tunFd = ptp.tunnelFileDescriptor else {
-            logger.error("no fd")
-            throw .noTunnelFileDescriptor
-        }
         let resp: Vpn_TunnelMessage
         do {
             resp = try await speaker.unaryRPC(
                 .with { msg in
                     msg.start = .with { req in
-                        req.tunnelFileDescriptor = tunFd
+                        req.tunnelFileDescriptor = cfg.tunFd
                         req.apiToken = cfg.apiToken
                         req.coderURL = cfg.serverUrl.absoluteString
                         req.headers = cfg.literalHeaders.map { header in
@@ -220,6 +233,12 @@ actor Manager {
         if !stopResp.success {
             throw .errorResponse(msg: stopResp.errorMessage)
         }
+        do {
+            try await tunnelDaemon.close()
+        } catch {
+            throw .tunnelFail(error)
+        }
+        readLoop.cancel()
     }
 
     // Retrieves the current state of all peers,
@@ -243,44 +262,44 @@ actor Manager {
 }
 
 func pushProgress(stage: ProgressStage, downloadProgress: DownloadProgress? = nil) {
-    guard let conn = globalXPCListenerDelegate.conn else {
-        logger.warning("couldn't send progress message to app: no connection")
-        return
-    }
-    logger.debug("sending progress message to app")
-    conn.onProgress(stage: stage, downloadProgress: downloadProgress)
+    Task { try? await appXPCServerDelegate.onProgress(stage: stage, downloadProgress: downloadProgress) }
 }
 
 struct ManagerConfig {
     let apiToken: String
     let serverUrl: URL
+    let tunFd: Int32
     let literalHeaders: [HTTPHeader]
 }
 
 enum ManagerError: Error {
     case download(DownloadError)
-    case tunnelSetup(TunnelHandleError)
+    case fileError(String)
+    case tunnelSetup(TunnelDaemonError)
     case handshake(HandshakeError)
     case validation(ValidationError)
     case incorrectResponse(Vpn_TunnelMessage)
+    case cloexec(POSIXError)
     case failedRPC(any Error)
     case serverInfo(String)
     case errorResponse(msg: String)
-    case noTunnelFileDescriptor
-    case noApp
-    case permissionDenied
     case tunnelFail(any Error)
+    case belowMinimumCoderVersion(actualVersion: String)
 
     var description: String {
         switch self {
         case let .download(err):
             "Download error: \(err.localizedDescription)"
+        case let .fileError(msg):
+            msg
         case let .tunnelSetup(err):
             "Tunnel setup error: \(err.localizedDescription)"
         case let .handshake(err):
             "Handshake error: \(err.localizedDescription)"
         case let .validation(err):
             "Validation error: \(err.localizedDescription)"
+        case let .cloexec(err):
+            "Failed to mark TUN fd as non-cloexec: \(err.localizedDescription)"
         case .incorrectResponse:
             "Received unexpected response over tunnel"
         case let .failedRPC(err):
@@ -289,14 +308,13 @@ enum ManagerError: Error {
             msg
         case let .errorResponse(msg):
             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.localizedDescription)"
+            "Failed to communicate with daemon over tunnel: \(err.localizedDescription)"
+        case let .belowMinimumCoderVersion(actualVersion):
+            """
+            The Coder deployment must be version \(Validator.minimumCoderVersion)
+            or higher to use Coder Desktop. Current version: \(actualVersion)
+            """
         }
     }
 
@@ -317,37 +335,16 @@ func writeVpnLog(_ log: Vpn_Log) {
         case .UNRECOGNIZED: .info
         }
     let logger = Logger(
-        subsystem: "\(Bundle.main.bundleIdentifier!).dylib",
+        subsystem: "\(Bundle.main.bundleIdentifier!).daemon",
         category: log.loggerNames.joined(separator: ".")
     )
     let fields = log.fields.map { "\($0.name): \($0.value)" }.joined(separator: ", ")
     logger.log(level: level, "\(log.message, privacy: .public)\(fields.isEmpty ? "" : ": \(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 {
-        pushProgress(stage: .removingQuarantine)
-        // Try the privileged helper first (it may not even be registered)
-        if await globalHelperXPCSpeaker.tryRemoveQuarantine(path: dest.path) {
-            // Success!
-            return
-        }
-        // Then try the app
-        guard let conn = globalXPCListenerDelegate.conn else {
-            // If neither are available, we can't execute the dylib
-            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
-        }
+extension FileManager {
+    func ensureDirectories(for url: URL) throws {
+        let dir = url.hasDirectoryPath ? url : url.deletingLastPathComponent()
+        try createDirectory(at: dir, withIntermediateDirectories: true, attributes: nil)
     }
 }
diff --git a/Coder-Desktop/Coder-DesktopHelper/com.coder.Coder-Desktop.Helper.plist b/Coder-Desktop/Coder-DesktopHelper/com.coder.Coder-Desktop.Helper.plist
index c00eed40..fdecff2c 100644
--- a/Coder-Desktop/Coder-DesktopHelper/com.coder.Coder-Desktop.Helper.plist
+++ b/Coder-Desktop/Coder-DesktopHelper/com.coder.Coder-Desktop.Helper.plist
@@ -4,12 +4,14 @@
 <dict>
 	<key>Label</key>
 	<string>com.coder.Coder-Desktop.Helper</string>
-	<key>BundleProgram</key>
-	<string>Contents/MacOS/com.coder.Coder-Desktop.Helper</string>
+	<key>Program</key>
+	<string>/Applications/Coder Desktop.app/Contents/MacOS/com.coder.Coder-Desktop.Helper</string>
 	<key>MachServices</key>
 	<dict>
 		<!-- $(TeamIdentifierPrefix) isn't populated here, so this value is hardcoded -->
-		<key>4399GN35BJ.com.coder.Coder-Desktop.Helper</key>
+		<key>4399GN35BJ.com.coder.Coder-Desktop.HelperNE</key>
+		<true/>
+		<key>4399GN35BJ.com.coder.Coder-Desktop.HelperApp</key>
 		<true/>
 	</dict>
 	<key>AssociatedBundleIdentifiers</key>
diff --git a/Coder-Desktop/Coder-DesktopHelper/main.swift b/Coder-Desktop/Coder-DesktopHelper/main.swift
index 0e94af21..da777746 100644
--- a/Coder-Desktop/Coder-DesktopHelper/main.swift
+++ b/Coder-Desktop/Coder-DesktopHelper/main.swift
@@ -1,72 +1,18 @@
+import CoderSDK
 import Foundation
 import os
+import VPNLib
 
-class HelperToolDelegate: NSObject, NSXPCListenerDelegate, HelperXPCProtocol {
-    private var logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "HelperToolDelegate")
+var globalManager: Manager?
 
-    override init() {
-        super.init()
-    }
+let NEXPCServerDelegate = HelperNEXPCServer()
+let NEXPCServer = NSXPCListener(machServiceName: helperNEMachServiceName)
+NEXPCServer.delegate = NEXPCServerDelegate
+NEXPCServer.resume()
 
-    func listener(_: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool {
-        newConnection.exportedInterface = NSXPCInterface(with: HelperXPCProtocol.self)
-        newConnection.exportedObject = self
-        newConnection.invalidationHandler = { [weak self] in
-            self?.logger.info("Helper XPC connection invalidated")
-        }
-        newConnection.interruptionHandler = { [weak self] in
-            self?.logger.debug("Helper XPC connection interrupted")
-        }
-        logger.info("new active connection")
-        newConnection.resume()
-        return true
-    }
+let appXPCServerDelegate = HelperAppXPCServer()
+let appXPCServer = NSXPCListener(machServiceName: helperAppMachServiceName)
+appXPCServer.delegate = appXPCServerDelegate
+appXPCServer.resume()
 
-    func removeQuarantine(path: String, withReply reply: @escaping (Int32, String) -> Void) {
-        guard isCoderDesktopDylib(at: path) else {
-            reply(1, "Path is not to a Coder Desktop dylib: \(path)")
-            return
-        }
-
-        let task = Process()
-        let pipe = Pipe()
-
-        task.standardOutput = pipe
-        task.standardError = pipe
-        task.arguments = ["-d", "com.apple.quarantine", path]
-        task.executableURL = URL(fileURLWithPath: "/usr/bin/xattr")
-
-        do {
-            try task.run()
-        } catch {
-            reply(1, "Failed to start command: \(error)")
-            return
-        }
-
-        let data = pipe.fileHandleForReading.readDataToEndOfFile()
-        let output = String(data: data, encoding: .utf8) ?? ""
-
-        task.waitUntilExit()
-        reply(task.terminationStatus, output)
-    }
-}
-
-func isCoderDesktopDylib(at rawPath: String) -> Bool {
-    let url = URL(fileURLWithPath: rawPath)
-        .standardizedFileURL
-        .resolvingSymlinksInPath()
-
-    // *Must* be within the Coder Desktop System Extension sandbox
-    let requiredPrefix = ["/", "var", "root", "Library", "Containers",
-                          "com.coder.Coder-Desktop.VPN"]
-    guard url.pathComponents.starts(with: requiredPrefix) else { return false }
-    guard url.pathExtension.lowercased() == "dylib" else { return false }
-    guard FileManager.default.fileExists(atPath: url.path) else { return false }
-    return true
-}
-
-let delegate = HelperToolDelegate()
-let listener = NSXPCListener(machServiceName: "4399GN35BJ.com.coder.Coder-Desktop.Helper")
-listener.delegate = delegate
-listener.resume()
 RunLoop.main.run()
diff --git a/Coder-Desktop/Coder-DesktopTests/LoginFormTests.swift b/Coder-Desktop/Coder-DesktopTests/LoginFormTests.swift
index 24ab1f0f..78f34d9b 100644
--- a/Coder-Desktop/Coder-DesktopTests/LoginFormTests.swift
+++ b/Coder-Desktop/Coder-DesktopTests/LoginFormTests.swift
@@ -4,6 +4,7 @@ import Mocker
 import SwiftUI
 import Testing
 import ViewInspector
+@testable import VPNLib
 
 @MainActor
 @Suite(.timeLimit(.minutes(1)))
@@ -134,7 +135,7 @@ struct LoginTests {
             username: "admin"
         )
         let buildInfo = BuildInfoResponse(
-            version: "v2.20.0"
+            version: "v\(Validator.minimumCoderVersion)"
         )
 
         try Mock(
diff --git a/Coder-Desktop/VPN/AppXPCListener.swift b/Coder-Desktop/VPN/AppXPCListener.swift
deleted file mode 100644
index 3d77f01e..00000000
--- a/Coder-Desktop/VPN/AppXPCListener.swift
+++ /dev/null
@@ -1,43 +0,0 @@
-import Foundation
-import NetworkExtension
-import os
-import VPNLib
-
-final class AppXPCListener: NSObject, NSXPCListenerDelegate, @unchecked Sendable {
-    let vpnXPCInterface = XPCInterface()
-    private var activeConnection: NSXPCConnection?
-    private var connMutex: NSLock = .init()
-
-    var conn: VPNXPCClientCallbackProtocol? {
-        connMutex.lock()
-        defer { connMutex.unlock() }
-
-        let conn = activeConnection?.remoteObjectProxy as? VPNXPCClientCallbackProtocol
-        return conn
-    }
-
-    func setActiveConnection(_ connection: NSXPCConnection?) {
-        connMutex.lock()
-        defer { connMutex.unlock() }
-        activeConnection = connection
-    }
-
-    func listener(_: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool {
-        newConnection.exportedInterface = NSXPCInterface(with: VPNXPCProtocol.self)
-        newConnection.exportedObject = vpnXPCInterface
-        newConnection.remoteObjectInterface = NSXPCInterface(with: VPNXPCClientCallbackProtocol.self)
-        newConnection.invalidationHandler = { [weak self] in
-            logger.info("active connection dead")
-            self?.setActiveConnection(nil)
-        }
-        newConnection.interruptionHandler = { [weak self] in
-            logger.debug("connection interrupted")
-            self?.setActiveConnection(nil)
-        }
-        logger.info("new active connection")
-        setActiveConnection(newConnection)
-
-        newConnection.resume()
-        return true
-    }
-}
diff --git a/Coder-Desktop/VPN/HelperXPCSpeaker.swift b/Coder-Desktop/VPN/HelperXPCSpeaker.swift
deleted file mode 100644
index 77de1f3a..00000000
--- a/Coder-Desktop/VPN/HelperXPCSpeaker.swift
+++ /dev/null
@@ -1,55 +0,0 @@
-import Foundation
-import os
-
-final class HelperXPCSpeaker: @unchecked Sendable {
-    private var logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "HelperXPCSpeaker")
-    private var connection: NSXPCConnection?
-
-    func tryRemoveQuarantine(path: String) async -> Bool {
-        let conn = connect()
-        return await withCheckedContinuation { continuation in
-            guard let proxy = conn.remoteObjectProxyWithErrorHandler({ err in
-                self.logger.error("Failed to connect to HelperXPC \(err)")
-                continuation.resume(returning: false)
-            }) as? HelperXPCProtocol else {
-                self.logger.error("Failed to get proxy for HelperXPC")
-                continuation.resume(returning: false)
-                return
-            }
-            proxy.removeQuarantine(path: path) { status, output in
-                if status == 0 {
-                    self.logger.info("Successfully removed quarantine for \(path)")
-                    continuation.resume(returning: true)
-                } else {
-                    self.logger.error("Failed to remove quarantine for \(path): \(output)")
-                    continuation.resume(returning: false)
-                }
-            }
-        }
-    }
-
-    private func connect() -> NSXPCConnection {
-        if let connection = self.connection {
-            return connection
-        }
-
-        // Though basically undocumented, System Extensions can communicate with
-        // LaunchDaemons over XPC if the machServiceName used is prefixed with
-        // the team identifier.
-        // https://developer.apple.com/forums/thread/654466
-        let connection = NSXPCConnection(
-            machServiceName: "4399GN35BJ.com.coder.Coder-Desktop.Helper",
-            options: .privileged
-        )
-        connection.remoteObjectInterface = NSXPCInterface(with: HelperXPCProtocol.self)
-        connection.invalidationHandler = { [weak self] in
-            self?.connection = nil
-        }
-        connection.interruptionHandler = { [weak self] in
-            self?.connection = nil
-        }
-        connection.resume()
-        self.connection = connection
-        return connection
-    }
-}
diff --git a/Coder-Desktop/VPN/NEHelperXPCClient.swift b/Coder-Desktop/VPN/NEHelperXPCClient.swift
new file mode 100644
index 00000000..05737c46
--- /dev/null
+++ b/Coder-Desktop/VPN/NEHelperXPCClient.swift
@@ -0,0 +1,106 @@
+import Foundation
+import os
+import VPNLib
+
+final class HelperXPCClient: @unchecked Sendable {
+    var ptp: PacketTunnelProvider?
+    private var logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "HelperXPCSpeaker")
+    private var connection: NSXPCConnection?
+
+    private func connect() -> NSXPCConnection {
+        if let connection = self.connection {
+            return connection
+        }
+
+        // Though basically undocumented, System Extensions can communicate with
+        // LaunchDaemons over XPC if the machServiceName used is prefixed with
+        // the team identifier.
+        // https://developer.apple.com/forums/thread/654466
+        let connection = NSXPCConnection(
+            machServiceName: helperNEMachServiceName,
+            options: .privileged
+        )
+        connection.remoteObjectInterface = NSXPCInterface(with: HelperNEXPCInterface.self)
+        connection.exportedInterface = NSXPCInterface(with: NEXPCInterface.self)
+        connection.exportedObject = self
+        connection.invalidationHandler = { [weak self] in
+            self?.connection = nil
+        }
+        connection.interruptionHandler = { [weak self] in
+            self?.connection = nil
+        }
+        connection.setCodeSigningRequirement(Validator.xpcPeerRequirement)
+        connection.resume()
+        self.connection = connection
+        return connection
+    }
+
+    func startDaemon(accessURL: URL, token: String, tun: FileHandle, headers: Data?) async throws {
+        let conn = connect()
+        return try await withCheckedThrowingContinuation { continuation in
+            guard let proxy = conn.remoteObjectProxyWithErrorHandler({ err in
+                self.logger.error("failed to connect to HelperXPC \(err.localizedDescription, privacy: .public)")
+                continuation.resume(throwing: err)
+            }) as? HelperNEXPCInterface else {
+                self.logger.error("failed to get proxy for HelperXPC")
+                continuation.resume(throwing: XPCError.wrongProxyType)
+                return
+            }
+            proxy.startDaemon(accessURL: accessURL, token: token, tun: tun, headers: headers) { err in
+                if let error = err {
+                    self.logger.error("Failed to start daemon: \(error.localizedDescription, privacy: .public)")
+                    continuation.resume(throwing: error)
+                } else {
+                    self.logger.info("successfully started daemon")
+                    continuation.resume()
+                }
+            }
+        }
+    }
+
+    func stopDaemon() async throws {
+        let conn = connect()
+        return try await withCheckedThrowingContinuation { continuation in
+            guard let proxy = conn.remoteObjectProxyWithErrorHandler({ err in
+                self.logger.error("failed to connect to HelperXPC \(err)")
+                continuation.resume(throwing: err)
+            }) as? HelperNEXPCInterface else {
+                self.logger.error("failed to get proxy for HelperXPC")
+                continuation.resume(throwing: XPCError.wrongProxyType)
+                return
+            }
+            proxy.stopDaemon { err in
+                if let error = err {
+                    self.logger.error("failed to stop daemon: \(error.localizedDescription)")
+                    continuation.resume(throwing: error)
+                } else {
+                    self.logger.info("Successfully stopped daemon")
+                    continuation.resume()
+                }
+            }
+        }
+    }
+}
+
+// These methods are called over XPC by the helper.
+extension HelperXPCClient: NEXPCInterface {
+    func applyTunnelNetworkSettings(diff: Data, reply: @escaping () -> Void) {
+        let reply = CompletionWrapper(reply)
+        guard let diff = try? Vpn_NetworkSettingsRequest(serializedBytes: diff) else {
+            reply()
+            return
+        }
+        Task {
+            try? await ptp?.applyTunnelNetworkSettings(diff)
+            reply()
+        }
+    }
+
+    func cancelProvider(error: Error?, reply: @escaping () -> Void) {
+        let reply = CompletionWrapper(reply)
+        Task {
+            ptp?.cancelTunnelWithError(error)
+            reply()
+        }
+    }
+}
diff --git a/Coder-Desktop/VPN/PacketTunnelProvider.swift b/Coder-Desktop/VPN/PacketTunnelProvider.swift
index 140cb5cc..a2d35597 100644
--- a/Coder-Desktop/VPN/PacketTunnelProvider.swift
+++ b/Coder-Desktop/VPN/PacketTunnelProvider.swift
@@ -8,7 +8,6 @@ let CTLIOCGINFO: UInt = 0xC064_4E03
 
 class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
     private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "provider")
-    private var manager: Manager?
     // a `tunnelRemoteAddress` is required, but not currently used.
     private var currentSettings: NEPacketTunnelNetworkSettings = .init(tunnelRemoteAddress: "127.0.0.1")
 
@@ -45,90 +44,41 @@ class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
     }
 
     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")
-            // If the tunnel is already running, then we can just mark as connected.
-            completionHandler(nil)
-            return
-        }
-        start(completionHandler)
-    }
-
-    // called by `startTunnel`
-    func start(_ completionHandler: @escaping (Error?) -> Void) {
+        options _: [String: NSObject]?
+    ) async throws {
+        globalHelperXPCClient.ptp = self
         guard let proto = protocolConfiguration as? NETunnelProviderProtocol,
               let baseAccessURL = proto.serverAddress
         else {
             logger.error("startTunnel called with nil protocolConfiguration")
-            completionHandler(makeNSError(suffix: "PTP", desc: "Missing Configuration"))
-            return
+            throw makeNSError(suffix: "PTP", desc: "Missing Configuration")
         }
         // 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(makeNSError(suffix: "PTP", desc: "Missing Token"))
-            return
+            throw makeNSError(suffix: "PTP", desc: "Missing Token")
         }
-        let headers: [HTTPHeader] = (proto.providerConfiguration?["literalHeaders"] as? Data)
-            .flatMap { try? JSONDecoder().decode([HTTPHeader].self, from: $0) } ?? []
+        let headers = proto.providerConfiguration?["literalHeaders"] as? Data
         logger.debug("retrieved token & access URL")
-        let completionHandler = CallbackWrapper(completionHandler)
-        Task {
-            do throws(ManagerError) {
-                logger.debug("creating manager")
-                let manager = try await Manager(
-                    with: self,
-                    cfg: .init(
-                        apiToken: token, serverUrl: .init(string: baseAccessURL)!,
-                        literalHeaders: headers
-                    )
-                )
-                globalXPCListenerDelegate.vpnXPCInterface.manager = manager
-                logger.debug("starting vpn")
-                try await manager.startVPN()
-                logger.info("vpn started")
-                self.manager = manager
-                completionHandler(nil)
-            } catch {
-                logger.error("error starting manager: \(error.description, privacy: .public)")
-                completionHandler(
-                    makeNSError(suffix: "Manager", desc: error.description)
-                )
-            }
+        guard let tunFd = tunnelFileDescriptor else {
+            logger.error("startTunnel called with nil tunnelFileDescriptor")
+            throw makeNSError(suffix: "PTP", desc: "Missing Tunnel File Descriptor")
         }
+        try await globalHelperXPCClient.startDaemon(
+            accessURL: .init(string: baseAccessURL)!,
+            token: token,
+            tun: FileHandle(fileDescriptor: tunFd),
+            headers: headers
+        )
     }
 
     override func stopTunnel(
-        with _: NEProviderStopReason, completionHandler: @escaping () -> Void
-    ) {
-        logger.debug("stopTunnel called")
-        teardown(completionHandler)
-    }
-
-    // called by `stopTunnel`
-    func teardown(_ completionHandler: @escaping () -> Void) {
-        guard let manager else {
-            logger.error("teardown called with nil Manager")
-            completionHandler()
-            return
-        }
-
-        let completionHandler = CompletionWrapper(completionHandler)
-        Task { [manager] in
-            do throws(ManagerError) {
-                try await manager.stopVPN()
-            } catch {
-                logger.error("error stopping manager: \(error.description, privacy: .public)")
-            }
-            globalXPCListenerDelegate.vpnXPCInterface.manager = nil
-            // Mark teardown as complete by setting manager to nil, and
-            // calling the completion handler.
-            self.manager = nil
-            completionHandler()
-        }
+        with _: NEProviderStopReason
+    ) async {
+        logger.debug("stopping tunnel")
+        try? await globalHelperXPCClient.stopDaemon()
+        logger.info("tunnel stopped")
+        globalHelperXPCClient.ptp = nil
     }
 
     override func handleAppMessage(_ messageData: Data, completionHandler: ((Data?) -> Void)?) {
diff --git a/Coder-Desktop/VPN/TunnelHandle.swift b/Coder-Desktop/VPN/TunnelHandle.swift
deleted file mode 100644
index 425a0ccb..00000000
--- a/Coder-Desktop/VPN/TunnelHandle.swift
+++ /dev/null
@@ -1,116 +0,0 @@
-import Foundation
-import os
-
-let startSymbol = "OpenTunnel"
-
-actor TunnelHandle {
-    private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "tunnel-handle")
-
-    private let tunnelWritePipe: Pipe
-    private let tunnelReadPipe: Pipe
-    private let dylibHandle: UnsafeMutableRawPointer
-
-    var writeHandle: FileHandle { tunnelReadPipe.fileHandleForWriting }
-    var readHandle: FileHandle { tunnelWritePipe.fileHandleForReading }
-
-    // MUST only ever throw TunnelHandleError
-    var openTunnelTask: Task<Void, any Error>?
-
-    init(dylibPath: URL) throws(TunnelHandleError) {
-        guard let dylibHandle = dlopen(dylibPath.path, RTLD_NOW | RTLD_LOCAL) else {
-            throw .dylib(dlerror().flatMap { String(cString: $0) } ?? "UNKNOWN")
-        }
-        self.dylibHandle = dylibHandle
-
-        guard let startSym = dlsym(dylibHandle, startSymbol) else {
-            throw .symbol(startSymbol, dlerror().flatMap { String(cString: $0) } ?? "UNKNOWN")
-        }
-        let openTunnelFn = SendableOpenTunnel(unsafeBitCast(startSym, to: OpenTunnel.self))
-        tunnelReadPipe = Pipe()
-        tunnelWritePipe = Pipe()
-        let rfd = tunnelReadPipe.fileHandleForReading.fileDescriptor
-        let wfd = tunnelWritePipe.fileHandleForWriting.fileDescriptor
-        openTunnelTask = Task { [openTunnelFn] in
-            try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, any Error>) in
-                DispatchQueue.global().async {
-                    let res = openTunnelFn(rfd, wfd)
-                    guard res == 0 else {
-                        cont.resume(throwing: TunnelHandleError.openTunnel(OpenTunnelError(rawValue: res) ?? .unknown))
-                        return
-                    }
-                    cont.resume()
-                }
-            }
-        }
-    }
-
-    // This could be an isolated deinit in Swift 6.1
-    func close() throws(TunnelHandleError) {
-        var errs: [Error] = []
-        if dlclose(dylibHandle) == 0 {
-            errs.append(TunnelHandleError.dylib(dlerror().flatMap { String(cString: $0) } ?? "UNKNOWN"))
-        }
-        do {
-            try writeHandle.close()
-        } catch {
-            errs.append(error)
-        }
-        do {
-            try readHandle.close()
-        } catch {
-            errs.append(error)
-        }
-        if !errs.isEmpty {
-            throw .close(errs)
-        }
-    }
-}
-
-enum TunnelHandleError: Error {
-    case dylib(String)
-    case symbol(String, String)
-    case openTunnel(OpenTunnelError)
-    case pipe(any Error)
-    case close([any Error])
-
-    var description: String {
-        switch self {
-        case let .pipe(err): "pipe error: \(err.localizedDescription)"
-        case let .dylib(d): d
-        case let .symbol(symbol, message): "\(symbol): \(message)"
-        case let .openTunnel(error): "OpenTunnel: \(error.message)"
-        case let .close(errs): "close tunnel: \(errs.map(\.localizedDescription).joined(separator: ", "))"
-        }
-    }
-
-    var localizedDescription: String { description }
-}
-
-enum OpenTunnelError: Int32 {
-    case errDupReadFD = -2
-    case errDupWriteFD = -3
-    case errOpenPipe = -4
-    case errNewTunnel = -5
-    case unknown = -99
-
-    var message: String {
-        switch self {
-        case .errDupReadFD: "Failed to duplicate read file descriptor"
-        case .errDupWriteFD: "Failed to duplicate write file descriptor"
-        case .errOpenPipe: "Failed to open the pipe"
-        case .errNewTunnel: "Failed to create a new tunnel"
-        case .unknown: "Unknown error code"
-        }
-    }
-}
-
-struct SendableOpenTunnel: @unchecked Sendable {
-    let fn: OpenTunnel
-    init(_ function: OpenTunnel) {
-        fn = function
-    }
-
-    func callAsFunction(_ lhs: Int32, _ rhs: Int32) -> Int32 {
-        fn(lhs, rhs)
-    }
-}
diff --git a/Coder-Desktop/VPN/XPCInterface.swift b/Coder-Desktop/VPN/XPCInterface.swift
deleted file mode 100644
index d83f7d79..00000000
--- a/Coder-Desktop/VPN/XPCInterface.swift
+++ /dev/null
@@ -1,34 +0,0 @@
-import Foundation
-import os.log
-import VPNLib
-
-@objc final class XPCInterface: NSObject, VPNXPCProtocol, @unchecked Sendable {
-    private var lockedManager: Manager?
-    private let managerLock = NSLock()
-    private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "VPNXPCInterface")
-
-    var manager: Manager? {
-        get {
-            managerLock.lock()
-            defer { managerLock.unlock() }
-            return lockedManager
-        }
-        set {
-            managerLock.lock()
-            defer { managerLock.unlock() }
-            lockedManager = newValue
-        }
-    }
-
-    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) {
-        reply()
-    }
-}
diff --git a/Coder-Desktop/VPN/com_coder_Coder_Desktop_VPN-Bridging-Header.h b/Coder-Desktop/VPN/com_coder_Coder_Desktop_VPN-Bridging-Header.h
deleted file mode 100644
index 6c8e5b48..00000000
--- a/Coder-Desktop/VPN/com_coder_Coder_Desktop_VPN-Bridging-Header.h
+++ /dev/null
@@ -1,7 +0,0 @@
-#ifndef CoderPacketTunnelProvider_Bridging_Header_h
-#define CoderPacketTunnelProvider_Bridging_Header_h
-
-// GoInt32 OpenTunnel(GoInt32 cReadFD, GoInt32 cWriteFD);
-typedef int(*OpenTunnel)(int, int);
-
-#endif /* CoderPacketTunnelProvider_Bridging_Header_h */
diff --git a/Coder-Desktop/VPN/main.swift b/Coder-Desktop/VPN/main.swift
index bf6c371a..96533bc8 100644
--- a/Coder-Desktop/VPN/main.swift
+++ b/Coder-Desktop/VPN/main.swift
@@ -5,24 +5,10 @@ import VPNLib
 
 let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "provider")
 
-guard
-    let netExt = Bundle.main.object(forInfoDictionaryKey: "NetworkExtension") as? [String: Any],
-    let serviceName = netExt["NEMachServiceName"] as? String
-else {
-    fatalError("Missing NEMachServiceName in Info.plist")
-}
-
-logger.debug("listening on machServiceName: \(serviceName)")
-
 autoreleasepool {
     NEProvider.startSystemExtensionMode()
 }
 
-let globalXPCListenerDelegate = AppXPCListener()
-let xpcListener = NSXPCListener(machServiceName: serviceName)
-xpcListener.delegate = globalXPCListenerDelegate
-xpcListener.resume()
-
-let globalHelperXPCSpeaker = HelperXPCSpeaker()
+let globalHelperXPCClient = HelperXPCClient()
 
 dispatchMain()
diff --git a/Coder-Desktop/VPNLib/Download.swift b/Coder-Desktop/VPNLib/Download.swift
index f6ffe5bc..37c53ec5 100644
--- a/Coder-Desktop/VPNLib/Download.swift
+++ b/Coder-Desktop/VPNLib/Download.swift
@@ -1,130 +1,6 @@
 import CryptoKit
 import Foundation
 
-public enum ValidationError: Error {
-    case fileNotFound
-    case unableToCreateStaticCode
-    case invalidSignature
-    case unableToRetrieveInfo
-    case invalidIdentifier(identifier: String?)
-    case invalidTeamIdentifier(identifier: String?)
-    case missingInfoPList
-    case invalidVersion(version: String?)
-    case belowMinimumCoderVersion
-
-    public var description: String {
-        switch self {
-        case .fileNotFound:
-            "The file does not exist."
-        case .unableToCreateStaticCode:
-            "Unable to create a static code object."
-        case .invalidSignature:
-            "The file's signature is invalid."
-        case .unableToRetrieveInfo:
-            "Unable to retrieve signing information."
-        case let .invalidIdentifier(identifier):
-            "Invalid identifier: \(identifier ?? "unknown")."
-        case let .invalidVersion(version):
-            "Invalid runtime version: \(version ?? "unknown")."
-        case let .invalidTeamIdentifier(identifier):
-            "Invalid team identifier: \(identifier ?? "unknown")."
-        case .missingInfoPList:
-            "Info.plist is not embedded within the dylib."
-        case .belowMinimumCoderVersion:
-            """
-            The Coder deployment must be version \(SignatureValidator.minimumCoderVersion)
-            or higher to use Coder Desktop.
-            """
-        }
-    }
-
-    public var localizedDescription: String { description }
-}
-
-public class SignatureValidator {
-    // Whilst older dylibs exist, this app assumes v2.20 or later.
-    public static let minimumCoderVersion = "2.20.0"
-
-    private static let expectedName = "CoderVPN"
-    private static let expectedIdentifier = "com.coder.Coder-Desktop.VPN.dylib"
-    private static let expectedTeamIdentifier = "4399GN35BJ"
-
-    private static let infoIdentifierKey = "CFBundleIdentifier"
-    private static let infoNameKey = "CFBundleName"
-    private static let infoShortVersionKey = "CFBundleShortVersionString"
-
-    private static let signInfoFlags: SecCSFlags = .init(rawValue: kSecCSSigningInformation)
-
-    // `expectedVersion` must be of the form `[0-9]+.[0-9]+.[0-9]+`
-    public static func validate(path: URL, expectedVersion: String) throws(ValidationError) {
-        guard FileManager.default.fileExists(atPath: path.path) else {
-            throw .fileNotFound
-        }
-
-        var staticCode: SecStaticCode?
-        let status = SecStaticCodeCreateWithPath(path as CFURL, SecCSFlags(), &staticCode)
-        guard status == errSecSuccess, let code = staticCode else {
-            throw .unableToCreateStaticCode
-        }
-
-        let validateStatus = SecStaticCodeCheckValidity(code, SecCSFlags(), nil)
-        guard validateStatus == errSecSuccess else {
-            throw .invalidSignature
-        }
-
-        var information: CFDictionary?
-        let infoStatus = SecCodeCopySigningInformation(code, signInfoFlags, &information)
-        guard infoStatus == errSecSuccess, let info = information as? [String: Any] else {
-            throw .unableToRetrieveInfo
-        }
-
-        guard let identifier = info[kSecCodeInfoIdentifier as String] as? String,
-              identifier == expectedIdentifier
-        else {
-            throw .invalidIdentifier(identifier: info[kSecCodeInfoIdentifier as String] as? String)
-        }
-
-        guard let teamIdentifier = info[kSecCodeInfoTeamIdentifier as String] as? String,
-              teamIdentifier == expectedTeamIdentifier
-        else {
-            throw .invalidTeamIdentifier(
-                identifier: info[kSecCodeInfoTeamIdentifier as String] as? String
-            )
-        }
-
-        guard let infoPlist = info[kSecCodeInfoPList as String] as? [String: AnyObject] else {
-            throw .missingInfoPList
-        }
-
-        try validateInfo(infoPlist: infoPlist, expectedVersion: expectedVersion)
-    }
-
-    private static func validateInfo(infoPlist: [String: AnyObject], expectedVersion: String) throws(ValidationError) {
-        guard let plistIdent = infoPlist[infoIdentifierKey] as? String, plistIdent == expectedIdentifier else {
-            throw .invalidIdentifier(identifier: infoPlist[infoIdentifierKey] as? String)
-        }
-
-        guard let plistName = infoPlist[infoNameKey] as? String, plistName == expectedName else {
-            throw .invalidIdentifier(identifier: infoPlist[infoNameKey] as? String)
-        }
-
-        // Downloaded dylib must match the version of the server
-        guard let dylibVersion = infoPlist[infoShortVersionKey] as? String,
-              expectedVersion == dylibVersion
-        else {
-            throw .invalidVersion(version: infoPlist[infoShortVersionKey] as? String)
-        }
-
-        // Downloaded dylib must be at least the minimum Coder server version
-        guard let dylibVersion = infoPlist[infoShortVersionKey] as? String,
-              // x.compare(y) is .orderedDescending if x > y
-              minimumCoderVersion.compare(dylibVersion, options: .numeric) != .orderedDescending
-        else {
-            throw .belowMinimumCoderVersion
-        }
-    }
-}
-
 public func download(
     src: URL,
     dest: URL,
@@ -226,7 +102,7 @@ extension DownloadManager: URLSessionDownloadDelegate {
             return
         }
         guard httpResponse.statusCode != 304 else {
-            // We already have the latest dylib downloaded in dest
+            // We already have the latest binary downloaded in dest
             continuation.resume()
             return
         }
diff --git a/Coder-Desktop/VPNLib/Receiver.swift b/Coder-Desktop/VPNLib/Receiver.swift
index 699d46f3..b5129ab8 100644
--- a/Coder-Desktop/VPNLib/Receiver.swift
+++ b/Coder-Desktop/VPNLib/Receiver.swift
@@ -69,7 +69,7 @@ actor Receiver<RecvMsg: Message> {
             },
             onCancel: {
                 self.logger.debug("async stream canceled")
-                self.dispatch.close()
+                self.dispatch.close(flags: [.stop])
             }
         )
     }
diff --git a/Coder-Desktop/VPNLib/Speaker.swift b/Coder-Desktop/VPNLib/Speaker.swift
index 88e46b05..74597b1c 100644
--- a/Coder-Desktop/VPNLib/Speaker.swift
+++ b/Coder-Desktop/VPNLib/Speaker.swift
@@ -86,6 +86,8 @@ public actor Speaker<SendMsg: RPCMessage & Message, RecvMsg: RPCMessage & Messag
         }
     }
 
+    deinit { logger.debug("speaker deinit") }
+
     /// Does the VPN Protocol handshake and validates the result
     public func handshake() async throws(HandshakeError) {
         let hndsh = Handshaker(writeFD: writeFD, dispatch: dispatch, queue: queue, role: role,
@@ -227,7 +229,7 @@ actor Handshaker {
             return try validateHeader(theirsString)
         } catch {
             writeFD.closeFile()
-            dispatch.close()
+            dispatch.close(flags: [.stop])
             throw error
         }
     }
diff --git a/Coder-Desktop/VPNLib/System.swift b/Coder-Desktop/VPNLib/System.swift
new file mode 100644
index 00000000..9692e5de
--- /dev/null
+++ b/Coder-Desktop/VPNLib/System.swift
@@ -0,0 +1,80 @@
+public func spawn(executable: String, args: [String]) throws(TunnelDaemonError) -> pid_t {
+    var pid: pid_t = 0
+
+    // argv = [executable, args..., nil]
+    var argv: [UnsafeMutablePointer<CChar>?] = []
+    argv.append(strdup(executable))
+    for a in args {
+        argv.append(strdup(a))
+    }
+    argv.append(nil)
+    defer { for p in argv where p != nil {
+        free(p)
+    } }
+
+    let rc: Int32 = argv.withUnsafeMutableBufferPointer { argvBuf in
+        posix_spawn(&pid, executable, nil, nil, argvBuf.baseAddress, nil)
+    }
+    if rc != 0 {
+        throw .spawn(POSIXError(POSIXErrorCode(rawValue: rc) ?? .EPERM))
+    }
+    return pid
+}
+
+public func unsetCloseOnExec(fd: Int32) throws(POSIXError) {
+    let cur = fcntl(fd, F_GETFD)
+    guard cur != -1 else {
+        throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EPERM)
+    }
+    let newFlags: Int32 = (cur & ~FD_CLOEXEC)
+    guard fcntl(fd, F_SETFD, newFlags) != -1 else {
+        throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EPERM)
+    }
+}
+
+public func chmodX(at url: URL) throws(POSIXError) {
+    var st = stat()
+    guard stat(url.path, &st) == 0 else {
+        throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EPERM)
+    }
+
+    let newMode: mode_t = st.st_mode | mode_t(S_IXUSR | S_IXGRP | S_IXOTH)
+
+    guard chmod(url.path, newMode) == 0 else {
+        throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EPERM)
+    }
+}
+
+// SPDX-License-Identifier: Apache-2.0 WITH Swift-exception
+//
+// Derived from swiftlang/swift-subprocess
+// Original: https://github.com/swiftlang/swift-subprocess/blob/7fb7ee86df8ca4f172697bfbafa89cdc583ac016/Sources/Subprocess/Platforms/Subprocess%2BDarwin.swift#L487-L525
+// Copyright (c) 2025 Apple Inc. and the Swift project authors
+@Sendable public func monitorProcessTermination(pid: pid_t) async throws -> Termination {
+    try await withCheckedThrowingContinuation { continuation in
+        let source = DispatchSource.makeProcessSource(
+            identifier: pid,
+            eventMask: [.exit],
+            queue: .global()
+        )
+        source.setEventHandler {
+            source.cancel()
+            var siginfo = siginfo_t()
+            let rc = waitid(P_PID, id_t(pid), &siginfo, WEXITED)
+            guard rc == 0 else {
+                let err = POSIXError(POSIXErrorCode(rawValue: errno) ?? .EINTR)
+                continuation.resume(throwing: err)
+                return
+            }
+            switch siginfo.si_code {
+            case .init(CLD_EXITED):
+                continuation.resume(returning: .exited(siginfo.si_status))
+            case .init(CLD_KILLED), .init(CLD_DUMPED):
+                continuation.resume(returning: .unhandledException(siginfo.si_status))
+            default:
+                continuation.resume(returning: .unhandledException(siginfo.si_status))
+            }
+        }
+        source.resume()
+    }
+}
diff --git a/Coder-Desktop/VPNLib/TunnelDaemon.swift b/Coder-Desktop/VPNLib/TunnelDaemon.swift
new file mode 100644
index 00000000..9797d0e4
--- /dev/null
+++ b/Coder-Desktop/VPNLib/TunnelDaemon.swift
@@ -0,0 +1,161 @@
+import Darwin
+import Foundation
+import os
+
+public actor TunnelDaemon {
+    private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "TunnelDaemon")
+    private let tunnelWritePipe: Pipe
+    private let tunnelReadPipe: Pipe
+    private(set) var state: TunnelDaemonState = .stopped {
+        didSet {
+            if case let .failed(err) = state {
+                onFail(err)
+            }
+        }
+    }
+
+    private var monitorTask: Task<Void, Never>?
+    private var onFail: (TunnelDaemonError) -> Void
+
+    public var writeHandle: FileHandle { tunnelReadPipe.fileHandleForWriting }
+    public var readHandle: FileHandle { tunnelWritePipe.fileHandleForReading }
+
+    var pid: pid_t?
+
+    public init(binaryPath: URL, onFail: @escaping (TunnelDaemonError) -> Void) async throws(TunnelDaemonError) {
+        self.onFail = onFail
+        tunnelReadPipe = Pipe()
+        tunnelWritePipe = Pipe()
+        let rfd = tunnelReadPipe.fileHandleForReading.fileDescriptor
+        let wfd = tunnelWritePipe.fileHandleForWriting.fileDescriptor
+
+        // Not necessary, but can't hurt.
+        do {
+            try unsetCloseOnExec(fd: rfd)
+            try unsetCloseOnExec(fd: wfd)
+        } catch {
+            throw .cloexec(error)
+        }
+
+        // Ensure the binary is executable.
+        do {
+            try chmodX(at: binaryPath)
+        } catch {
+            throw .chmod(error)
+        }
+
+        let childPID = try spawn(
+            executable: binaryPath.path,
+            args: ["vpn-daemon", "run",
+                   "--rpc-read-fd", String(rfd),
+                   "--rpc-write-fd", String(wfd)]
+        )
+        pid = childPID
+        state = .running
+
+        monitorTask = Task { [weak self] in
+            guard let self else { return }
+            do {
+                let term = try await monitorProcessTermination(pid: childPID)
+                await onTermination(term)
+            } catch {
+                logger.error("failed to monitor daemon termination: \(error.localizedDescription)")
+                await setFailed(.monitoringFailed(error))
+            }
+        }
+    }
+
+    deinit { logger.debug("tunnel daemon deinit") }
+
+    // This could be an isolated deinit in Swift 6.1
+    public func close() throws(TunnelDaemonError) {
+        state = .stopped
+
+        monitorTask?.cancel()
+        monitorTask = nil
+
+        if let pid {
+            if kill(pid, SIGTERM) != 0, errno != ESRCH {
+                throw .close(POSIXError(POSIXErrorCode(rawValue: errno) ?? .EINTR))
+            } else {
+                var info = siginfo_t()
+                _ = waitid(P_PID, id_t(pid), &info, WEXITED | WNOHANG)
+            }
+        }
+
+        // Closing the Pipe FileHandles here manually results in a process crash:
+        // "BUG IN CLIENT OF LIBDISPATCH: Unexpected EV_VANISHED
+        // (do not destroy random mach ports or file descriptors)"
+        // I've manually verified that the file descriptors are closed when the
+        // `Manager` is deallocated (when `globalManager` is set to `nil`).
+    }
+
+    private func setFailed(_ err: TunnelDaemonError) {
+        state = .failed(err)
+    }
+
+    private func onTermination(_ termination: Termination) async {
+        switch state {
+        case .stopped:
+            return
+        default:
+            setFailed(.terminated(termination))
+        }
+    }
+}
+
+public enum TunnelDaemonState: Sendable {
+    case running
+    case stopped
+    case failed(TunnelDaemonError)
+    case unavailable
+
+    public var description: String {
+        switch self {
+        case .running:
+            "Running"
+        case .stopped:
+            "Stopped"
+        case let .failed(err):
+            "Failed: \(err.localizedDescription)"
+        case .unavailable:
+            "Unavailable"
+        }
+    }
+}
+
+public enum Termination: Sendable {
+    case exited(Int32)
+    case unhandledException(Int32)
+
+    var description: String {
+        switch self {
+        case let .exited(status):
+            "Process exited with status \(status)"
+        case let .unhandledException(status):
+            "Process terminated with unhandled exception status \(status)"
+        }
+    }
+}
+
+public enum TunnelDaemonError: Error, Sendable {
+    case spawn(POSIXError)
+    case cloexec(POSIXError)
+    case chmod(POSIXError)
+    case terminated(Termination)
+    case monitoringFailed(any Error)
+    case close(any Error)
+
+    public var description: String {
+        switch self {
+        case let .terminated(reason): "daemon terminated: \(reason.description)"
+        case let .spawn(err): "spawn daemon: \(err.localizedDescription)"
+        case let .cloexec(err): "unset close-on-exec: \(err.localizedDescription)"
+        case let .chmod(err): "change permissions: \(err.localizedDescription)"
+        case let .monitoringFailed(err): "monitoring daemon termination: \(err.localizedDescription)"
+        case let .close(err): "close tunnel: \(err.localizedDescription)"
+        }
+    }
+
+    public var localizedDescription: String { description }
+}
diff --git a/Coder-Desktop/VPNLib/Validate.swift b/Coder-Desktop/VPNLib/Validate.swift
new file mode 100644
index 00000000..8fbf40bd
--- /dev/null
+++ b/Coder-Desktop/VPNLib/Validate.swift
@@ -0,0 +1,125 @@
+import Foundation
+import Subprocess
+
+public enum ValidationError: Error {
+    case fileNotFound
+    case unableToCreateStaticCode
+    case invalidSignature
+    case unableToRetrieveSignature
+    case invalidIdentifier(identifier: String?)
+    case invalidTeamIdentifier(identifier: String?)
+    case unableToReadVersion(any Error)
+    case binaryVersionMismatch(binaryVersion: String, serverVersion: String)
+    case internalError(OSStatus)
+
+    public var description: String {
+        switch self {
+        case .fileNotFound:
+            "The file does not exist."
+        case .unableToCreateStaticCode:
+            "Unable to create a static code object."
+        case .invalidSignature:
+            "The file's signature is invalid."
+        case .unableToRetrieveSignature:
+            "Unable to retrieve signing information."
+        case let .invalidIdentifier(identifier):
+            "Invalid identifier: \(identifier ?? "unknown")."
+        case let .binaryVersionMismatch(binaryVersion, serverVersion):
+            "Binary version does not match server. Binary: \(binaryVersion), Server: \(serverVersion)."
+        case let .invalidTeamIdentifier(identifier):
+            "Invalid team identifier: \(identifier ?? "unknown")."
+        case let .unableToReadVersion(error):
+            "Unable to execute the binary to read version: \(error.localizedDescription)"
+        case let .internalError(status):
+            "Internal error with OSStatus code: \(status)."
+        }
+    }
+
+    public var localizedDescription: String { description }
+}
+
+public class Validator {
+    // This version of the app has a strict version requirement.
+    public static let minimumCoderVersion = "2.24.3"
+
+    private static let expectedIdentifier = "com.coder.cli"
+    // The Coder team identifier
+    private static let expectedTeamIdentifier = "4399GN35BJ"
+
+    // Apple-issued certificate chain
+    public static let anchorRequirement = "anchor apple generic"
+
+    private static let signInfoFlags: SecCSFlags = .init(rawValue: kSecCSSigningInformation)
+
+    public static func validateSignature(binaryPath: URL) throws(ValidationError) {
+        guard FileManager.default.fileExists(atPath: binaryPath.path) else {
+            throw .fileNotFound
+        }
+
+        var staticCode: SecStaticCode?
+        let status = SecStaticCodeCreateWithPath(binaryPath as CFURL, SecCSFlags(), &staticCode)
+        guard status == errSecSuccess, let code = staticCode else {
+            throw .unableToCreateStaticCode
+        }
+
+        var requirement: SecRequirement?
+        let reqStatus = SecRequirementCreateWithString(anchorRequirement as CFString, SecCSFlags(), &requirement)
+        guard reqStatus == errSecSuccess, let requirement else {
+            throw .internalError(OSStatus(reqStatus))
+        }
+
+        let validateStatus = SecStaticCodeCheckValidity(code, SecCSFlags(), requirement)
+        guard validateStatus == errSecSuccess else {
+            throw .invalidSignature
+        }
+
+        var information: CFDictionary?
+        let infoStatus = SecCodeCopySigningInformation(code, signInfoFlags, &information)
+        guard infoStatus == errSecSuccess, let info = information as? [String: Any] else {
+            throw .unableToRetrieveSignature
+        }
+
+        guard let identifier = info[kSecCodeInfoIdentifier as String] as? String,
+              identifier == expectedIdentifier
+        else {
+            throw .invalidIdentifier(identifier: info[kSecCodeInfoIdentifier as String] as? String)
+        }
+
+        guard let teamIdentifier = info[kSecCodeInfoTeamIdentifier as String] as? String,
+              teamIdentifier == expectedTeamIdentifier
+        else {
+            throw .invalidTeamIdentifier(
+                identifier: info[kSecCodeInfoTeamIdentifier as String] as? String
+            )
+        }
+    }
+
+    // This function executes the binary to read its version, and so it assumes
+    // the signature has already been validated.
+    public static func validateVersion(binaryPath: URL, serverVersion: String) async throws(ValidationError) {
+        guard FileManager.default.fileExists(atPath: binaryPath.path) else {
+            throw .fileNotFound
+        }
+
+        let version: String
+        do {
+            try chmodX(at: binaryPath)
+            let versionOutput = try await Subprocess.data(for: [binaryPath.path, "version", "--output=json"])
+            let parsed: VersionOutput = try JSONDecoder().decode(VersionOutput.self, from: versionOutput)
+            version = parsed.version
+        } catch {
+            throw .unableToReadVersion(error)
+        }
+
+        guard version == serverVersion else {
+            throw .binaryVersionMismatch(binaryVersion: version, serverVersion: serverVersion)
+        }
+    }
+
+    struct VersionOutput: Codable {
+        let version: String
+    }
+
+    public static let xpcPeerRequirement = anchorRequirement +
+        " and certificate leaf[subject.OU] = \"" + expectedTeamIdentifier + "\"" // Signed by the Coder team
+}
diff --git a/Coder-Desktop/VPNLib/XPC.swift b/Coder-Desktop/VPNLib/XPC.swift
index baea7fe9..daf902f2 100644
--- a/Coder-Desktop/VPNLib/XPC.swift
+++ b/Coder-Desktop/VPNLib/XPC.swift
@@ -1,24 +1,47 @@
 import Foundation
 
+// The Helper listens on two mach services, one for the GUI app
+// and one for the system network extension.
+// These must be kept in sync with `com.coder.Coder-Desktop.Helper.plist`
+public let helperAppMachServiceName = "4399GN35BJ.com.coder.Coder-Desktop.HelperApp"
+public let helperNEMachServiceName = "4399GN35BJ.com.coder.Coder-Desktop.HelperNE"
+
+// This is the XPC interface the Network Extension exposes to the Helper.
 @preconcurrency
-@objc public protocol VPNXPCProtocol {
-    func getPeerState(with reply: @escaping (Data?) -> Void)
-    func ping(with reply: @escaping () -> Void)
+@objc public protocol NEXPCInterface {
+    // diff is a serialized Vpn_NetworkSettingsRequest
+    func applyTunnelNetworkSettings(diff: Data, reply: @escaping () -> Void)
+    func cancelProvider(error: Error?, reply: @escaping () -> Void)
+}
+
+// This is the XPC interface the GUI app exposes to the Helper.
+@preconcurrency
+@objc public protocol AppXPCInterface {
+    // diff is a serialized `Vpn_PeerUpdate`
+    func onPeerUpdate(_ diff: Data, reply: @escaping () -> Void)
+    func onProgress(stage: ProgressStage, downloadProgress: DownloadProgress?, reply: @escaping () -> Void)
+}
+
+// This is the XPC interface the Helper exposes to the Network Extension.
+@preconcurrency
+@objc public protocol HelperNEXPCInterface {
+    // headers is a JSON `[HTTPHeader]`
+    func startDaemon(accessURL: URL, token: String, tun: FileHandle, headers: Data?, reply: @escaping (Error?) -> Void)
+    func stopDaemon(reply: @escaping (Error?) -> Void)
 }
 
+// This is the XPC interface the Helper exposes to the GUI app.
 @preconcurrency
-@objc public protocol VPNXPCClientCallbackProtocol {
-    // data is a serialized `Vpn_PeerUpdate`
-    func onPeerUpdate(_ data: Data)
-    func onProgress(stage: ProgressStage, downloadProgress: DownloadProgress?)
-    func removeQuarantine(path: String, reply: @escaping (Bool) -> Void)
+@objc public protocol HelperAppXPCInterface {
+    func ping(reply: @escaping () -> Void)
+    // Data is a serialized `Vpn_PeerUpdate`
+    func getPeerState(with reply: @escaping (Data?) -> Void)
 }
 
 @objc public enum ProgressStage: Int, Sendable {
     case initial
     case downloading
     case validating
-    case removingQuarantine
     case startingTunnel
 
     public var description: String? {
@@ -26,13 +49,24 @@ import Foundation
         case .initial:
             nil
         case .downloading:
-            "Downloading library..."
+            "Downloading binary..."
         case .validating:
-            "Validating library..."
-        case .removingQuarantine:
-            "Removing quarantine..."
+            "Validating binary..."
         case .startingTunnel:
             nil
         }
     }
 }
+
+public enum XPCError: Error {
+    case wrongProxyType
+
+    var description: String {
+        switch self {
+        case .wrongProxyType:
+            "Wrong proxy type"
+        }
+    }
+
+    var localizedDescription: String { description }
+}
diff --git a/Coder-Desktop/VPNLibTests/TunnelDaemonTests.swift b/Coder-Desktop/VPNLibTests/TunnelDaemonTests.swift
new file mode 100644
index 00000000..ac1861e6
--- /dev/null
+++ b/Coder-Desktop/VPNLibTests/TunnelDaemonTests.swift
@@ -0,0 +1,160 @@
+import Foundation
+import Testing
+@testable import VPNLib
+
+@Suite(.timeLimit(.minutes(1)))
+struct TunnelDaemonTests {
+    func createTempExecutable(content: String) throws -> URL {
+        let tempDir = FileManager.default.temporaryDirectory
+        let executableURL = tempDir.appendingPathComponent("test_daemon_\(UUID().uuidString)")
+
+        try content.write(to: executableURL, atomically: true, encoding: .utf8)
+        // We purposefully don't mark as executable
+        return executableURL
+    }
+
+    @Test func daemonStarts() async throws {
+        let longRunningScript = """
+        #!/bin/bash
+        sleep 10
+        """
+
+        let executableURL = try createTempExecutable(content: longRunningScript)
+        defer { try? FileManager.default.removeItem(at: executableURL) }
+
+        var failureCalled = false
+        let daemon = try await TunnelDaemon(binaryPath: executableURL) { _ in
+            failureCalled = true
+        }
+
+        await #expect(daemon.state.isRunning)
+        #expect(!failureCalled)
+        await #expect(daemon.readHandle.fileDescriptor >= 0)
+        await #expect(daemon.writeHandle.fileDescriptor >= 0)
+
+        try await daemon.close()
+        await #expect(daemon.state.isStopped)
+    }
+
+    @Test func daemonHandlesFailure() async throws {
+        let immediateExitScript = """
+        #!/bin/bash
+        exit 1
+        """
+
+        let executableURL = try createTempExecutable(content: immediateExitScript)
+        defer { try? FileManager.default.removeItem(at: executableURL) }
+
+        var capturedError: TunnelDaemonError?
+        let daemon = try await TunnelDaemon(binaryPath: executableURL) { error in
+            capturedError = error
+        }
+
+        #expect(await eventually(timeout: .milliseconds(500), interval: .milliseconds(10)) { @MainActor in
+            capturedError != nil
+        })
+
+        if case let .terminated(termination) = capturedError {
+            if case let .exited(status) = termination {
+                #expect(status == 1)
+            } else {
+                Issue.record("Expected exited termination, got \(termination)")
+            }
+        } else {
+            Issue.record("Expected terminated error, got \(String(describing: capturedError))")
+        }
+
+        await #expect(daemon.state.isFailed)
+    }
+
+    @Test func daemonExternallyKilled() async throws {
+        let script = """
+        #!/bin/bash
+        # Process that will be killed with SIGKILL
+        sleep 30
+        """
+
+        let executableURL = try createTempExecutable(content: script)
+        defer { try? FileManager.default.removeItem(at: executableURL) }
+
+        var capturedError: TunnelDaemonError?
+        let daemon = try await TunnelDaemon(binaryPath: executableURL) { error in
+            capturedError = error
+        }
+
+        await #expect(daemon.state.isRunning)
+
+        guard let pid = await daemon.pid else {
+            Issue.record("Daemon pid is nil")
+            return
+        }
+
+        kill(pid, SIGKILL)
+
+        #expect(await eventually(timeout: .milliseconds(500), interval: .milliseconds(10)) { @MainActor in
+            capturedError != nil
+        })
+
+        if case let .terminated(termination) = capturedError {
+            if case let .unhandledException(status) = termination {
+                #expect(status == SIGKILL)
+            } else {
+                Issue.record("Expected unhandledException termination, got \(termination)")
+            }
+        } else {
+            Issue.record("Expected terminated error, got \(String(describing: capturedError))")
+        }
+    }
+
+    @Test func invalidBinaryPathThrowsError() async throws {
+        let nonExistentPath = URL(fileURLWithPath: "/this/path/does/not/exist/binary")
+
+        await #expect(throws: TunnelDaemonError.self) {
+            _ = try await TunnelDaemon(binaryPath: nonExistentPath) { _ in }
+        }
+    }
+}
+
+public func eventually(
+    timeout: Duration = .milliseconds(500),
+    interval: Duration = .milliseconds(10),
+    condition: @Sendable () async throws -> Bool
+) async rethrows -> Bool {
+    let endTime = ContinuousClock.now.advanced(by: timeout)
+
+    while ContinuousClock.now < endTime {
+        do {
+            if try await condition() { return true }
+        } catch {
+            try await Task.sleep(for: interval)
+        }
+    }
+
+    return try await condition()
+}
+
+extension TunnelDaemonState {
+    var isRunning: Bool {
+        if case .running = self {
+            true
+        } else {
+            false
+        }
+    }
+
+    var isStopped: Bool {
+        if case .stopped = self {
+            true
+        } else {
+            false
+        }
+    }
+
+    var isFailed: Bool {
+        if case .failed = self {
+            true
+        } else {
+            false
+        }
+    }
+}
diff --git a/Coder-Desktop/project.yml b/Coder-Desktop/project.yml
index f97ebddd..fd648e4b 100644
--- a/Coder-Desktop/project.yml
+++ b/Coder-Desktop/project.yml
@@ -216,6 +216,29 @@ targets:
     buildToolPlugins:
       - plugin: SwiftLintBuildToolPlugin
         package: SwiftLintPlugins
+    postBuildScripts:
+      # This is a dependency of the app, not the helper, as it copies the
+      # helper plist from the app bundle to the system store.
+      - name: "Upsert Helper for Local Development"
+        # Only run this script (and prompt for admin) when the helper or any of
+        # it's frameworks have changed.
+        inputFiles:
+          - "$(BUILT_PRODUCTS_DIR)/com.coder.Coder-Desktop.Helper"
+          - "$(BUILT_PRODUCTS_DIR)/CoderSDK.framework/Versions/A/CoderSDK"
+          - "$(BUILT_PRODUCTS_DIR)/VPNLib.framework/Versions/A/VPNLib"
+        outputFiles:
+          - "$(DERIVED_FILE_DIR)/upsert-helper.stamp"
+        script: |
+          if [ -n "${CI}" ]; then
+            # Skip in CI
+            exit 0
+          fi
+          /usr/bin/osascript <<'APPLESCRIPT'
+          do shell script "/bin/bash -c " & quoted form of ((system attribute "SRCROOT") & "/../scripts/upsert-dev-helper.sh") with administrator privileges
+          APPLESCRIPT
+          /usr/bin/touch "${DERIVED_FILE_DIR}/upsert-helper.stamp"
+        basedOnDependencyAnalysis: true
+        runOnlyWhenInstalling: false
 
   Coder-DesktopTests:
     type: bundle.unit-test
@@ -233,6 +256,8 @@ targets:
       - target: "Coder Desktop"
       - target: CoderSDK
         embed: false # Do not embed the framework.
+      - target: VPNLib
+        embed: false # Do not embed the framework.
       - package: ViewInspector
       - package: Mocker
 
@@ -252,7 +277,6 @@ targets:
     platform: macOS
     sources:
       - path: VPN
-      - path: Coder-DesktopHelper/HelperXPCProtocol.swift
     entitlements:
       path: VPN/VPN.entitlements
       properties:
@@ -272,7 +296,6 @@ targets:
         PRODUCT_MODULE_NAME: "$(PRODUCT_NAME:c99extidentifier)"
         PRODUCT_NAME: "$(PRODUCT_BUNDLE_IDENTIFIER)"
         SWIFT_EMIT_LOC_STRINGS: YES
-        SWIFT_OBJC_BRIDGING_HEADER: "VPN/com_coder_Coder_Desktop_VPN-Bridging-Header.h"
         # `CODE_SIGN_*` are overriden during a release build
         CODE_SIGN_IDENTITY: "Apple Development"
         CODE_SIGN_STYLE: Automatic
@@ -370,10 +393,19 @@ targets:
     type: tool
     platform: macOS
     sources: Coder-DesktopHelper
+    dependencies:
+      - target: VPNLib
+        embed: false # Loaded from SE bundle.
     settings:
       base:
         ENABLE_HARDENED_RUNTIME: YES
         PRODUCT_BUNDLE_IDENTIFIER: "com.coder.Coder-Desktop.Helper"
         PRODUCT_MODULE_NAME: "$(PRODUCT_NAME:c99extidentifier)"
         PRODUCT_NAME: "$(PRODUCT_BUNDLE_IDENTIFIER)"
-        SKIP_INSTALL: YES
\ No newline at end of file
+        SKIP_INSTALL: YES
+        LD_RUNPATH_SEARCH_PATHS:
+          # Load frameworks from the SE bundle.
+          - "@executable_path/../../Contents/Library/SystemExtensions/com.coder.Coder-Desktop.VPN.systemextension/Contents/Frameworks"
+          - "@executable_path/../Frameworks"
+          - "@loader_path/Frameworks"
+
diff --git a/pkgbuild/scripts/postinstall b/pkgbuild/scripts/postinstall
index 758776f6..a12b9cb0 100755
--- a/pkgbuild/scripts/postinstall
+++ b/pkgbuild/scripts/postinstall
@@ -2,6 +2,25 @@
 
 RUNNING_MARKER_FILE="/tmp/coder_desktop_running"
 
+LAUNCH_DAEMON_PLIST_SRC="/Applications/Coder Desktop.app/Contents/Library/LaunchDaemons"
+LAUNCH_DAEMON_PLIST_DEST="/Library/LaunchDaemons"
+LAUNCH_DAEMON_NAME="com.coder.Coder-Desktop.Helper"
+LAUNCH_DAEMON_PLIST_NAME="$LAUNCH_DAEMON_NAME.plist"
+LAUNCH_DAEMON_BINARY_PATH="/Applications/Coder Desktop.app/Contents/MacOS/com.coder.Coder-Desktop.Helper"
+
+# Install daemon
+# Copy plist into system dir
+sudo cp "$LAUNCH_DAEMON_PLIST_SRC"/"$LAUNCH_DAEMON_PLIST_NAME" "$LAUNCH_DAEMON_PLIST_DEST"/"$LAUNCH_DAEMON_PLIST_NAME"
+# Set necessary permissions
+sudo chmod 755 "$LAUNCH_DAEMON_BINARY_PATH"
+sudo chmod 644 "$LAUNCH_DAEMON_PLIST_DEST"/"$LAUNCH_DAEMON_PLIST_NAME"
+sudo chown root:wheel "$LAUNCH_DAEMON_PLIST_DEST"/"$LAUNCH_DAEMON_PLIST_NAME"
+
+# Load daemon
+sudo launchctl enable "system/$LAUNCH_DAEMON_NAME" || true # Might already be enabled
+sudo launchctl bootstrap system "$LAUNCH_DAEMON_PLIST_DEST/$LAUNCH_DAEMON_PLIST_NAME"
+sudo launchctl kickstart -k "system/$LAUNCH_DAEMON_NAME"
+
 # Before this script, or the user, opens the app, make sure
 # Gatekeeper has ingested the notarization ticket.
 spctl -avvv "/Applications/Coder Desktop.app"
@@ -13,7 +32,10 @@ spctl -avvv "/Applications/Coder Desktop.app/Contents/Library/SystemExtensions/c
 # Restart Coder Desktop if it was running before
 if [ -f "$RUNNING_MARKER_FILE" ]; then
   echo "Starting Coder Desktop..."
-  open -a "Coder Desktop"
+  # When deploying the app via MDM, this script runs as root. The app cannot
+  # function properly when launched as root.
+  currentUser=$(/usr/bin/stat -f "%Su" /dev/console)
+  /bin/launchctl asuser "$( /usr/bin/id -u "$currentUser")" /usr/bin/open "/Applications/Coder Desktop.app"
   rm "$RUNNING_MARKER_FILE"
   echo "Coder Desktop started."
 fi
diff --git a/pkgbuild/scripts/preinstall b/pkgbuild/scripts/preinstall
index d52c1330..5582c635 100755
--- a/pkgbuild/scripts/preinstall
+++ b/pkgbuild/scripts/preinstall
@@ -1,6 +1,10 @@
 #!/usr/bin/env bash
 
 RUNNING_MARKER_FILE="/tmp/coder_desktop_running"
+LAUNCH_DAEMON_NAME="com.coder.Coder-Desktop.Helper"
+
+# Stop an existing launch daemon, if it exists
+sudo launchctl bootout "system/$LAUNCH_DAEMON_NAME" 2>/dev/null || true
 
 rm $RUNNING_MARKER_FILE || true
 
diff --git a/scripts/build.sh b/scripts/build.sh
index f6e537a6..e1589dbb 100755
--- a/scripts/build.sh
+++ b/scripts/build.sh
@@ -206,6 +206,3 @@ echo "$signature" >"$PKG_PATH.sig"
 
 # Add dsym to build artifacts
 (cd "$ARCHIVE_PATH/dSYMs" && zip -9 -r --symlinks "$DSYM_ZIPPED_PATH" ./*)
-
-# Add zipped app to build artifacts
-zip -9 -r --symlinks "$APP_ZIPPED_PATH" "$BUILT_APP_PATH"
diff --git a/scripts/update-cask.sh b/scripts/update-cask.sh
index 478ea610..770e8203 100755
--- a/scripts/update-cask.sh
+++ b/scripts/update-cask.sh
@@ -14,23 +14,23 @@ ASSIGNEE=""
 # Parse command line arguments
 while [[ "$#" -gt 0 ]]; do
   case $1 in
-  --version)
-    VERSION="$2"
-    shift 2
-    ;;
-  --assignee)
-    ASSIGNEE="$2"
-    shift 2
-    ;;
-  -h | --help)
-    usage
-    exit 0
-    ;;
-  *)
-    echo "Unknown parameter passed: $1"
-    usage
-    exit 1
-    ;;
+    --version)
+      VERSION="$2"
+      shift 2
+      ;;
+    --assignee)
+      ASSIGNEE="$2"
+      shift 2
+      ;;
+    -h | --help)
+      usage
+      exit 0
+      ;;
+    *)
+      echo "Unknown parameter passed: $1"
+      usage
+      exit 1
+      ;;
   esac
 done
 
@@ -93,11 +93,16 @@ cask "coder-desktop" do
 
   uninstall quit:       [
               "com.coder.Coder-Desktop",
+              "com.coder.Coder-Desktop.Helper",
               "com.coder.Coder-Desktop.VPN",
             ],
             login_item: "Coder Desktop"
 
-  zap delete: "/var/root/Library/Containers/com.Coder-Desktop.VPN/Data/Documents/coder-vpn.dylib",
+  zap delete: [
+        "/var/root/Library/Application Support/com.coder.Coder-Desktop/coder-darwin-arm64",
+        "/var/root/Library/Application Support/com.coder.Coder-Desktop/coder-darwin-amd64",
+        "/var/root/Library/Containers/com.Coder-Desktop.VPN/Data/Documents/coder-vpn.dylib",
+      ],
       trash:  [
         "~/Library/Caches/com.coder.Coder-Desktop",
         "~/Library/HTTPStorages/com.coder.Coder-Desktop",
diff --git a/scripts/upsert-dev-helper.sh b/scripts/upsert-dev-helper.sh
new file mode 100755
index 00000000..c7f42828
--- /dev/null
+++ b/scripts/upsert-dev-helper.sh
@@ -0,0 +1,30 @@
+# This script operates like postinstall + preinstall, but for local development
+# builds, where the helper is necessary. Instead of looking for
+# /Applications/Coder Desktop.app, it looks for
+# /Applications/Coder/Coder Desktop.app, which is where the local build is
+# installed.
+
+set -euxo pipefail
+
+LAUNCH_DAEMON_PLIST_SRC="/Applications/Coder/Coder Desktop.app/Contents/Library/LaunchDaemons"
+LAUNCH_DAEMON_PLIST_DEST="/Library/LaunchDaemons"
+LAUNCH_DAEMON_NAME="com.coder.Coder-Desktop.Helper"
+LAUNCH_DAEMON_PLIST_NAME="$LAUNCH_DAEMON_NAME.plist"
+LAUNCH_DAEMON_BINARY_PATH="/Applications/Coder/Coder Desktop.app/Contents/MacOS/com.coder.Coder-Desktop.Helper"
+
+# Stop an existing launch daemon, if it exists
+sudo launchctl bootout "system/$LAUNCH_DAEMON_NAME" 2>/dev/null || true
+
+# Install daemon
+# Copy plist into system dir, with the path corrected to the local build
+sed 's|/Applications/Coder Desktop\.app|/Applications/Coder/Coder Desktop.app|g' "$LAUNCH_DAEMON_PLIST_SRC"/"$LAUNCH_DAEMON_PLIST_NAME" | sudo tee "$LAUNCH_DAEMON_PLIST_DEST"/"$LAUNCH_DAEMON_PLIST_NAME" >/dev/null
+# Set necessary permissions
+sudo chmod 755 "$LAUNCH_DAEMON_BINARY_PATH"
+sudo chmod 644 "$LAUNCH_DAEMON_PLIST_DEST"/"$LAUNCH_DAEMON_PLIST_NAME"
+sudo chown root:wheel "$LAUNCH_DAEMON_PLIST_DEST"/"$LAUNCH_DAEMON_PLIST_NAME"
+
+# Load daemon
+sudo launchctl enable "system/$LAUNCH_DAEMON_NAME" || true # Might already be enabled
+sudo launchctl bootstrap system "$LAUNCH_DAEMON_PLIST_DEST/$LAUNCH_DAEMON_PLIST_NAME"
+sudo launchctl kickstart -k "system/$LAUNCH_DAEMON_NAME"
+
