diff --git a/.env b/.env
index 9eb149b6..6ee8f2bb 100644
--- a/.env
+++ b/.env
@@ -10,3 +10,5 @@ APPLE_ID_PASSWORD="op://Apple/3apcadvvcojjbpxnd7m5fgh5wm/password"
 
 APP_PROF="op://Apple/Provisioning Profiles/profiles/application_base64"
 EXT_PROF="op://Apple/Provisioning Profiles/profiles/extension_base64"
+
+SPARKLE_PRIVATE_KEY="op://Apple/Private key for signing Sparkle updates/notesPlain"
\ No newline at end of file
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index c5129913..adbc130d 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -56,6 +56,7 @@ jobs:
           APPLE_ID_PASSWORD: ${{ secrets.APPLE_NOTARYTOOL_PASSWORD }}
           APP_PROF: ${{ secrets.CODER_DESKTOP_APP_PROVISIONPROFILE_B64 }}
           EXT_PROF: ${{ secrets.CODER_DESKTOP_EXTENSION_PROVISIONPROFILE_B64 }}
+          SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
         run: make release
 
       # Upload as artifact in dry-run mode
diff --git a/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift b/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift
index 307e0797..35aed082 100644
--- a/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift
+++ b/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift
@@ -25,6 +25,7 @@ struct DesktopApp: App {
             SettingsView<CoderVPNService>()
                 .environmentObject(appDelegate.vpn)
                 .environmentObject(appDelegate.state)
+                .environmentObject(appDelegate.helper)
         }
         .windowResizability(.contentSize)
         Window("Coder File Sync", id: Windows.fileSync.rawValue) {
@@ -45,10 +46,12 @@ class AppDelegate: NSObject, NSApplicationDelegate {
     let fileSyncDaemon: MutagenDaemon
     let urlHandler: URLHandler
     let notifDelegate: NotifDelegate
+    let helper: HelperService
 
     override init() {
         notifDelegate = NotifDelegate()
         vpn = CoderVPNService()
+        helper = HelperService()
         let state = AppState(onChange: vpn.configureTunnelProviderProtocol)
         vpn.onStart = {
             // We don't need this to have finished before the VPN actually starts
diff --git a/Coder-Desktop/Coder-Desktop/HelperService.swift b/Coder-Desktop/Coder-Desktop/HelperService.swift
new file mode 100644
index 00000000..17bdc72a
--- /dev/null
+++ b/Coder-Desktop/Coder-Desktop/HelperService.swift
@@ -0,0 +1,117 @@
+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/Info.plist b/Coder-Desktop/Coder-Desktop/Info.plist
index 4712604f..c1bf929a 100644
--- a/Coder-Desktop/Coder-Desktop/Info.plist
+++ b/Coder-Desktop/Coder-Desktop/Info.plist
@@ -31,5 +31,7 @@
 		<key>NEMachServiceName</key>
 		<string>$(TeamIdentifierPrefix)com.coder.Coder-Desktop.VPN</string>
 	</dict>
+	<key>SUPublicEDKey</key>
+    <string>Ae2oQLTcx89/a73XrpOt+IVvqdo+fMTjo3UKEm77VdA=</string>
 </dict>
 </plist>
diff --git a/Coder-Desktop/Coder-Desktop/Preview Content/PreviewVPN.swift b/Coder-Desktop/Coder-Desktop/Preview Content/PreviewVPN.swift
index 2c6e8d02..4d4e9f90 100644
--- a/Coder-Desktop/Coder-Desktop/Preview Content/PreviewVPN.swift	
+++ b/Coder-Desktop/Coder-Desktop/Preview Content/PreviewVPN.swift	
@@ -33,6 +33,8 @@ final class PreviewVPN: Coder_Desktop.VPNService {
         self.shouldFail = shouldFail
     }
 
+    @Published var progress: VPNProgress = .init(stage: .initial, downloadProgress: nil)
+
     var startTask: Task<Void, Never>?
     func start() async {
         if await startTask?.value != nil {
diff --git a/Coder-Desktop/Coder-Desktop/VPN/VPNProgress.swift b/Coder-Desktop/Coder-Desktop/VPN/VPNProgress.swift
new file mode 100644
index 00000000..56593b20
--- /dev/null
+++ b/Coder-Desktop/Coder-Desktop/VPN/VPNProgress.swift
@@ -0,0 +1,63 @@
+import SwiftUI
+import VPNLib
+
+struct VPNProgress {
+    let stage: ProgressStage
+    let downloadProgress: DownloadProgress?
+}
+
+struct VPNProgressView: View {
+    let state: VPNServiceState
+    let progress: VPNProgress
+
+    var body: some View {
+        VStack {
+            CircularProgressView(value: value)
+                // We estimate that the last half takes 8 seconds
+                // so it doesn't appear stuck
+                .autoComplete(threshold: 0.5, duration: 8)
+            Text(progressMessage)
+                .multilineTextAlignment(.center)
+        }
+        .padding()
+        .foregroundStyle(.secondary)
+    }
+
+    var progressMessage: String {
+        "\(progress.stage.description ?? defaultMessage)\(downloadProgressMessage)"
+    }
+
+    var downloadProgressMessage: String {
+        progress.downloadProgress.flatMap { "\n\($0.description)" } ?? ""
+    }
+
+    var defaultMessage: String {
+        state == .connecting ? "Starting Coder Connect..." : "Stopping Coder Connect..."
+    }
+
+    var value: Float? {
+        guard state == .connecting else {
+            return nil
+        }
+        switch progress.stage {
+        case .initial:
+            return 0
+        case .downloading:
+            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
+            }
+            // 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
+        case .validating:
+            return 0.43
+        case .removingQuarantine:
+            return 0.46
+        case .startingTunnel:
+            return 0.50
+        }
+    }
+}
diff --git a/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift b/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift
index c3c17738..224174ae 100644
--- a/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift
+++ b/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift
@@ -7,6 +7,7 @@ import VPNLib
 protocol VPNService: ObservableObject {
     var state: VPNServiceState { get }
     var menuState: VPNMenuState { get }
+    var progress: VPNProgress { get }
     func start() async
     func stop() async
     func configureTunnelProviderProtocol(proto: NETunnelProviderProtocol?)
@@ -55,7 +56,14 @@ final class CoderVPNService: NSObject, VPNService {
     var logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "vpn")
     lazy var xpc: VPNXPCInterface = .init(vpn: self)
 
-    @Published var tunnelState: VPNServiceState = .disabled
+    @Published var tunnelState: VPNServiceState = .disabled {
+        didSet {
+            if tunnelState == .connecting {
+                progress = .init(stage: .initial, downloadProgress: nil)
+            }
+        }
+    }
+
     @Published var sysExtnState: SystemExtensionState = .uninstalled
     @Published var neState: NetworkExtensionState = .unconfigured
     var state: VPNServiceState {
@@ -72,6 +80,8 @@ final class CoderVPNService: NSObject, VPNService {
         return tunnelState
     }
 
+    @Published var progress: VPNProgress = .init(stage: .initial, downloadProgress: nil)
+
     @Published var menuState: VPNMenuState = .init()
 
     // Whether the VPN should start as soon as possible
@@ -155,6 +165,10 @@ final class CoderVPNService: NSObject, VPNService {
         }
     }
 
+    func onProgress(stage: ProgressStage, downloadProgress: DownloadProgress?) {
+        progress = .init(stage: stage, downloadProgress: downloadProgress)
+    }
+
     func applyPeerUpdate(with update: Vpn_PeerUpdate) {
         // Delete agents
         update.deletedAgents.forEach { menuState.deleteAgent(withId: $0.id) }
diff --git a/Coder-Desktop/Coder-Desktop/Views/CircularProgressView.swift b/Coder-Desktop/Coder-Desktop/Views/CircularProgressView.swift
new file mode 100644
index 00000000..fc359e83
--- /dev/null
+++ b/Coder-Desktop/Coder-Desktop/Views/CircularProgressView.swift
@@ -0,0 +1,80 @@
+import SwiftUI
+
+struct CircularProgressView: View {
+    let value: Float?
+
+    var strokeWidth: CGFloat = 4
+    var diameter: CGFloat = 22
+    var primaryColor: Color = .secondary
+    var backgroundColor: Color = .secondary.opacity(0.3)
+
+    @State private var rotation = 0.0
+    @State private var trimAmount: CGFloat = 0.15
+
+    var autoCompleteThreshold: Float?
+    var autoCompleteDuration: TimeInterval?
+
+    var body: some View {
+        ZStack {
+            // Background circle
+            Circle()
+                .stroke(backgroundColor, style: StrokeStyle(lineWidth: strokeWidth, lineCap: .round))
+                .frame(width: diameter, height: diameter)
+            Group {
+                if let value {
+                    // Determinate gauge
+                    Circle()
+                        .trim(from: 0, to: CGFloat(displayValue(for: value)))
+                        .stroke(primaryColor, style: StrokeStyle(lineWidth: strokeWidth, lineCap: .round))
+                        .frame(width: diameter, height: diameter)
+                        .rotationEffect(.degrees(-90))
+                        .animation(autoCompleteAnimation(for: value), value: value)
+                } else {
+                    // Indeterminate gauge
+                    Circle()
+                        .trim(from: 0, to: trimAmount)
+                        .stroke(primaryColor, style: StrokeStyle(lineWidth: strokeWidth, lineCap: .round))
+                        .frame(width: diameter, height: diameter)
+                        .rotationEffect(.degrees(rotation))
+                }
+            }
+        }
+        .frame(width: diameter + strokeWidth * 2, height: diameter + strokeWidth * 2)
+        .onAppear {
+            if value == nil {
+                withAnimation(.linear(duration: 0.8).repeatForever(autoreverses: false)) {
+                    rotation = 360
+                }
+            }
+        }
+    }
+
+    private func displayValue(for value: Float) -> Float {
+        if let threshold = autoCompleteThreshold,
+           value >= threshold, value < 1.0
+        {
+            return 1.0
+        }
+        return value
+    }
+
+    private func autoCompleteAnimation(for value: Float) -> Animation? {
+        guard let threshold = autoCompleteThreshold,
+              let duration = autoCompleteDuration,
+              value >= threshold, value < 1.0
+        else {
+            return .default
+        }
+
+        return .easeOut(duration: duration)
+    }
+}
+
+extension CircularProgressView {
+    func autoComplete(threshold: Float, duration: TimeInterval) -> CircularProgressView {
+        var view = self
+        view.autoCompleteThreshold = threshold
+        view.autoCompleteDuration = duration
+        return view
+    }
+}
diff --git a/Coder-Desktop/Coder-Desktop/Views/FileSync/FilePicker.swift b/Coder-Desktop/Coder-Desktop/Views/FileSync/FilePicker.swift
index 032a0c3b..6f392961 100644
--- a/Coder-Desktop/Coder-Desktop/Views/FileSync/FilePicker.swift
+++ b/Coder-Desktop/Coder-Desktop/Views/FileSync/FilePicker.swift
@@ -23,8 +23,7 @@ struct FilePicker: View {
         VStack(spacing: 0) {
             if model.rootIsLoading {
                 Spacer()
-                ProgressView()
-                    .controlSize(.large)
+                CircularProgressView(value: nil)
                 Spacer()
             } else if let loadError = model.error {
                 Text("\(loadError.description)")
@@ -125,7 +124,8 @@ struct FilePickerEntry: View {
             Label {
                 Text(entry.name)
                 ZStack {
-                    ProgressView().controlSize(.small).opacity(entry.isLoading && entry.error == nil ? 1 : 0)
+                    CircularProgressView(value: nil, strokeWidth: 2, diameter: 10)
+                        .opacity(entry.isLoading && entry.error == nil ? 1 : 0)
                     Image(systemName: "exclamationmark.triangle.fill")
                         .opacity(entry.error != nil ? 1 : 0)
                 }
diff --git a/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncSessionModal.swift b/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncSessionModal.swift
index 3e48ffd4..b5108670 100644
--- a/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncSessionModal.swift
+++ b/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncSessionModal.swift
@@ -68,7 +68,7 @@ struct FileSyncSessionModal<VPN: VPNService, FS: FileSyncDaemon>: View {
                     Text(msg).foregroundStyle(.secondary)
                 }
                 if loading {
-                    ProgressView().controlSize(.small)
+                    CircularProgressView(value: nil, strokeWidth: 3, diameter: 15)
                 }
                 Button("Cancel", action: { dismiss() }).keyboardShortcut(.cancelAction)
                 Button(existingSession == nil ? "Add" : "Save") { Task { await submit() }}
diff --git a/Coder-Desktop/Coder-Desktop/Views/Settings/ExperimentalTab.swift b/Coder-Desktop/Coder-Desktop/Views/Settings/ExperimentalTab.swift
new file mode 100644
index 00000000..838f4587
--- /dev/null
+++ b/Coder-Desktop/Coder-Desktop/Views/Settings/ExperimentalTab.swift
@@ -0,0 +1,10 @@
+import LaunchAtLogin
+import SwiftUI
+
+struct ExperimentalTab: View {
+    var body: some View {
+        Form {
+            HelperSection()
+        }.formStyle(.grouped)
+    }
+}
diff --git a/Coder-Desktop/Coder-Desktop/Views/Settings/HelperSection.swift b/Coder-Desktop/Coder-Desktop/Views/Settings/HelperSection.swift
new file mode 100644
index 00000000..66fdc534
--- /dev/null
+++ b/Coder-Desktop/Coder-Desktop/Views/Settings/HelperSection.swift
@@ -0,0 +1,82 @@
+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 8aac9a0c..170d171b 100644
--- a/Coder-Desktop/Coder-Desktop/Views/Settings/Settings.swift
+++ b/Coder-Desktop/Coder-Desktop/Views/Settings/Settings.swift
@@ -13,6 +13,11 @@ 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)
@@ -23,4 +28,5 @@ struct SettingsView<VPN: VPNService>: View {
 enum SettingsTab: Int {
     case general
     case network
+    case experimental
 }
diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift
index fb3928f6..33fa71c5 100644
--- a/Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift
+++ b/Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift
@@ -33,7 +33,9 @@ struct Agents<VPN: VPNService>: View {
                     if hasToggledExpansion {
                         return
                     }
-                    expandedItem = visibleItems.first?.id
+                    withAnimation(.snappy(duration: Theme.Animation.collapsibleDuration)) {
+                        expandedItem = visibleItems.first?.id
+                    }
                     hasToggledExpansion = true
                 }
                 if items.count == 0 {
diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift
index c10b9322..3b92dc9d 100644
--- a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift
+++ b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift
@@ -72,6 +72,8 @@ struct MenuItemView: View {
 
     @State private var apps: [WorkspaceApp] = []
 
+    @State private var loadingApps: Bool = true
+
     var hasApps: Bool { !apps.isEmpty }
 
     private var itemName: AttributedString {
@@ -129,9 +131,13 @@ struct MenuItemView: View {
                 MenuItemIcons(item: item, wsURL: wsURL)
             }
             if isExpanded {
-                if hasApps {
+                switch (loadingApps, hasApps) {
+                case (true, _):
+                    CircularProgressView(value: nil, strokeWidth: 3, diameter: 15)
+                        .padding(.top, 5)
+                case (false, true):
                     MenuItemCollapsibleView(apps: apps)
-                } else {
+                case (false, false):
                     HStack {
                         Text(item.status == .off ? "Workspace is offline." : "No apps available.")
                             .font(.body)
@@ -146,6 +152,7 @@ struct MenuItemView: View {
     }
 
     func loadApps() async {
+        defer { loadingApps = false }
         // If this menu item is an agent, and the user is logged in
         if case let .agent(agent) = item,
            let client = state.client,
diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNState.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNState.swift
index 23319020..e2aa1d8d 100644
--- a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNState.swift
+++ b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNState.swift
@@ -28,9 +28,7 @@ struct VPNState<VPN: VPNService>: View {
             case (.connecting, _), (.disconnecting, _):
                 HStack {
                     Spacer()
-                    ProgressView(
-                        vpn.state == .connecting ? "Starting Coder Connect..." : "Stopping Coder Connect..."
-                    ).padding()
+                    VPNProgressView(state: vpn.state, progress: vpn.progress)
                     Spacer()
                 }
             case let (.failed(vpnErr), _):
diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift
index 2eb45cc5..94104d27 100644
--- a/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift
+++ b/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift
@@ -19,7 +19,7 @@ struct WorkspaceAppIcon: View {
                 ) { $0 }
                     placeholder: {
                         if app.icon != nil {
-                            ProgressView().controlSize(.small)
+                            CircularProgressView(value: nil, strokeWidth: 2, diameter: 10)
                         } else {
                             Image(systemName: "questionmark").frame(
                                 width: Theme.Size.appIconWidth,
diff --git a/Coder-Desktop/Coder-Desktop/XPCInterface.swift b/Coder-Desktop/Coder-Desktop/XPCInterface.swift
index 43c6f09b..e6c78d6d 100644
--- a/Coder-Desktop/Coder-Desktop/XPCInterface.swift
+++ b/Coder-Desktop/Coder-Desktop/XPCInterface.swift
@@ -14,9 +14,9 @@ import VPNLib
     }
 
     func connect() {
-        logger.debug("xpc connect called")
+        logger.debug("VPN xpc connect called")
         guard xpc == nil else {
-            logger.debug("xpc already exists")
+            logger.debug("VPN xpc already exists")
             return
         }
         let networkExtDict = Bundle.main.object(forInfoDictionaryKey: "NetworkExtension") as? [String: Any]
@@ -34,14 +34,14 @@ import VPNLib
         xpcConn.exportedObject = self
         xpcConn.invalidationHandler = { [logger] in
             Task { @MainActor in
-                logger.error("XPC connection invalidated.")
+                logger.error("VPN XPC connection invalidated.")
                 self.xpc = nil
                 self.connect()
             }
         }
         xpcConn.interruptionHandler = { [logger] in
             Task { @MainActor in
-                logger.error("XPC connection interrupted.")
+                logger.error("VPN XPC connection interrupted.")
                 self.xpc = nil
                 self.connect()
             }
@@ -71,6 +71,12 @@ import VPNLib
         }
     }
 
+    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)
diff --git a/Coder-Desktop/Coder-DesktopHelper/HelperXPCProtocol.swift b/Coder-Desktop/Coder-DesktopHelper/HelperXPCProtocol.swift
new file mode 100644
index 00000000..5ffed59a
--- /dev/null
+++ b/Coder-Desktop/Coder-DesktopHelper/HelperXPCProtocol.swift
@@ -0,0 +1,5 @@
+import Foundation
+
+@objc protocol HelperXPCProtocol {
+    func removeQuarantine(path: String, withReply reply: @escaping (Int32, String) -> Void)
+}
diff --git a/Coder-Desktop/Coder-DesktopHelper/com.coder.Coder-Desktop.Helper.plist b/Coder-Desktop/Coder-DesktopHelper/com.coder.Coder-Desktop.Helper.plist
new file mode 100644
index 00000000..c00eed40
--- /dev/null
+++ b/Coder-Desktop/Coder-DesktopHelper/com.coder.Coder-Desktop.Helper.plist
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>Label</key>
+	<string>com.coder.Coder-Desktop.Helper</string>
+	<key>BundleProgram</key>
+	<string>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>
+		<true/>
+	</dict>
+	<key>AssociatedBundleIdentifiers</key>
+	<array>
+		<string>com.coder.Coder-Desktop</string>
+	</array>
+</dict>
+</plist>
diff --git a/Coder-Desktop/Coder-DesktopHelper/main.swift b/Coder-Desktop/Coder-DesktopHelper/main.swift
new file mode 100644
index 00000000..0e94af21
--- /dev/null
+++ b/Coder-Desktop/Coder-DesktopHelper/main.swift
@@ -0,0 +1,72 @@
+import Foundation
+import os
+
+class HelperToolDelegate: NSObject, NSXPCListenerDelegate, HelperXPCProtocol {
+    private var logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "HelperToolDelegate")
+
+    override init() {
+        super.init()
+    }
+
+    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
+    }
+
+    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/Util.swift b/Coder-Desktop/Coder-DesktopTests/Util.swift
index 6c7bc206..60751274 100644
--- a/Coder-Desktop/Coder-DesktopTests/Util.swift
+++ b/Coder-Desktop/Coder-DesktopTests/Util.swift
@@ -10,6 +10,7 @@ class MockVPNService: VPNService, ObservableObject {
     @Published var state: Coder_Desktop.VPNServiceState = .disabled
     @Published var baseAccessURL: URL = .init(string: "https://dev.coder.com")!
     @Published var menuState: VPNMenuState = .init()
+    @Published var progress: VPNProgress = .init(stage: .initial, downloadProgress: nil)
     var onStart: (() async -> Void)?
     var onStop: (() async -> Void)?
 
diff --git a/Coder-Desktop/Coder-DesktopTests/VPNStateTests.swift b/Coder-Desktop/Coder-DesktopTests/VPNStateTests.swift
index 92827cf8..abad6abd 100644
--- a/Coder-Desktop/Coder-DesktopTests/VPNStateTests.swift
+++ b/Coder-Desktop/Coder-DesktopTests/VPNStateTests.swift
@@ -38,8 +38,7 @@ struct VPNStateTests {
 
         try await ViewHosting.host(view) {
             try await sut.inspection.inspect { view in
-                let progressView = try view.find(ViewType.ProgressView.self)
-                #expect(try progressView.labelView().text().string() == "Starting Coder Connect...")
+                _ = try view.find(text: "Starting Coder Connect...")
             }
         }
     }
@@ -50,8 +49,7 @@ struct VPNStateTests {
 
         try await ViewHosting.host(view) {
             try await sut.inspection.inspect { view in
-                let progressView = try view.find(ViewType.ProgressView.self)
-                #expect(try progressView.labelView().text().string() == "Stopping Coder Connect...")
+                _ = try view.find(text: "Stopping Coder Connect...")
             }
         }
     }
diff --git a/Coder-Desktop/VPN/AppXPCListener.swift b/Coder-Desktop/VPN/AppXPCListener.swift
new file mode 100644
index 00000000..3d77f01e
--- /dev/null
+++ b/Coder-Desktop/VPN/AppXPCListener.swift
@@ -0,0 +1,43 @@
+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
new file mode 100644
index 00000000..77de1f3a
--- /dev/null
+++ b/Coder-Desktop/VPN/HelperXPCSpeaker.swift
@@ -0,0 +1,55 @@
+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/Manager.swift b/Coder-Desktop/VPN/Manager.swift
index b9573810..649a1612 100644
--- a/Coder-Desktop/VPN/Manager.swift
+++ b/Coder-Desktop/VPN/Manager.swift
@@ -35,10 +35,18 @@ actor Manager {
             // Timeout after 5 minutes, or if there's no data for 60 seconds
             sessionConfig.timeoutIntervalForRequest = 60
             sessionConfig.timeoutIntervalForResource = 300
-            try await download(src: dylibPath, dest: dest, urlSession: URLSession(configuration: sessionConfig))
+            try await download(
+                src: dylibPath,
+                dest: dest,
+                urlSession: URLSession(configuration: sessionConfig)
+            ) { progress in
+                // TODO: Debounce, somehow
+                pushProgress(stage: .downloading, downloadProgress: progress)
+            }
         } catch {
             throw .download(error)
         }
+        pushProgress(stage: .validating)
         let client = Client(url: cfg.serverUrl)
         let buildInfo: BuildInfoResponse
         do {
@@ -158,6 +166,7 @@ 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")
@@ -234,6 +243,15 @@ 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)
+}
+
 struct ManagerConfig {
     let apiToken: String
     let serverUrl: URL
@@ -312,7 +330,15 @@ private func removeQuarantine(_ dest: URL) async throws(ManagerError) {
     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
diff --git a/Coder-Desktop/VPN/main.swift b/Coder-Desktop/VPN/main.swift
index 708c2e0c..bf6c371a 100644
--- a/Coder-Desktop/VPN/main.swift
+++ b/Coder-Desktop/VPN/main.swift
@@ -5,45 +5,6 @@ import VPNLib
 
 let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "provider")
 
-final class XPCListenerDelegate: 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
-    }
-}
-
 guard
     let netExt = Bundle.main.object(forInfoDictionaryKey: "NetworkExtension") as? [String: Any],
     let serviceName = netExt["NEMachServiceName"] as? String
@@ -57,9 +18,11 @@ autoreleasepool {
     NEProvider.startSystemExtensionMode()
 }
 
-let globalXPCListenerDelegate = XPCListenerDelegate()
+let globalXPCListenerDelegate = AppXPCListener()
 let xpcListener = NSXPCListener(machServiceName: serviceName)
 xpcListener.delegate = globalXPCListenerDelegate
 xpcListener.resume()
 
+let globalHelperXPCSpeaker = HelperXPCSpeaker()
+
 dispatchMain()
diff --git a/Coder-Desktop/VPNLib/Download.swift b/Coder-Desktop/VPNLib/Download.swift
index 559be37f..99febc29 100644
--- a/Coder-Desktop/VPNLib/Download.swift
+++ b/Coder-Desktop/VPNLib/Download.swift
@@ -125,47 +125,18 @@ public class SignatureValidator {
     }
 }
 
-public func download(src: URL, dest: URL, urlSession: URLSession) async throws(DownloadError) {
-    var req = URLRequest(url: src)
-    if FileManager.default.fileExists(atPath: dest.path) {
-        if let existingFileData = try? Data(contentsOf: dest, options: .mappedIfSafe) {
-            req.setValue(etag(data: existingFileData), forHTTPHeaderField: "If-None-Match")
-        }
-    }
-    // TODO: Add Content-Length headers to coderd, add download progress delegate
-    let tempURL: URL
-    let response: URLResponse
-    do {
-        (tempURL, response) = try await urlSession.download(for: req)
-    } catch {
-        throw .networkError(error, url: src.absoluteString)
-    }
-    defer {
-        if FileManager.default.fileExists(atPath: tempURL.path) {
-            try? FileManager.default.removeItem(at: tempURL)
-        }
-    }
-
-    guard let httpResponse = response as? HTTPURLResponse else {
-        throw .invalidResponse
-    }
-    guard httpResponse.statusCode != 304 else {
-        // We already have the latest dylib downloaded on disk
-        return
-    }
-
-    guard httpResponse.statusCode == 200 else {
-        throw .unexpectedStatusCode(httpResponse.statusCode)
-    }
-
-    do {
-        if FileManager.default.fileExists(atPath: dest.path) {
-            try FileManager.default.removeItem(at: dest)
-        }
-        try FileManager.default.moveItem(at: tempURL, to: dest)
-    } catch {
-        throw .fileOpError(error)
-    }
+public func download(
+    src: URL,
+    dest: URL,
+    urlSession: URLSession,
+    progressUpdates: (@Sendable (DownloadProgress) -> Void)? = nil
+) async throws(DownloadError) {
+    try await DownloadManager().download(
+        src: src,
+        dest: dest,
+        urlSession: urlSession,
+        progressUpdates: progressUpdates.flatMap { throttle(interval: .milliseconds(10), $0) }
+    )
 }
 
 func etag(data: Data) -> String {
@@ -195,3 +166,126 @@ public enum DownloadError: Error {
 
     public var localizedDescription: String { description }
 }
+
+// The async `URLSession.download` api ignores the passed-in delegate, so we
+// wrap the older delegate methods in an async adapter with a continuation.
+private final class DownloadManager: NSObject, @unchecked Sendable {
+    private var continuation: CheckedContinuation<Void, Error>!
+    private var progressHandler: ((DownloadProgress) -> Void)?
+    private var dest: URL!
+
+    func download(
+        src: URL,
+        dest: URL,
+        urlSession: URLSession,
+        progressUpdates: (@Sendable (DownloadProgress) -> Void)?
+    ) async throws(DownloadError) {
+        var req = URLRequest(url: src)
+        if FileManager.default.fileExists(atPath: dest.path) {
+            if let existingFileData = try? Data(contentsOf: dest, options: .mappedIfSafe) {
+                req.setValue(etag(data: existingFileData), forHTTPHeaderField: "If-None-Match")
+            }
+        }
+
+        let downloadTask = urlSession.downloadTask(with: req)
+        progressHandler = progressUpdates
+        self.dest = dest
+        downloadTask.delegate = self
+        do {
+            try await withCheckedThrowingContinuation { continuation in
+                self.continuation = continuation
+                downloadTask.resume()
+            }
+        } catch let error as DownloadError {
+            throw error
+        } catch {
+            throw .networkError(error, url: src.absoluteString)
+        }
+    }
+}
+
+extension DownloadManager: URLSessionDownloadDelegate {
+    // Progress
+    func urlSession(
+        _: URLSession,
+        downloadTask: URLSessionDownloadTask,
+        didWriteData _: Int64,
+        totalBytesWritten: Int64,
+        totalBytesExpectedToWrite _: Int64
+    ) {
+        let maybeLength = (downloadTask.response as? HTTPURLResponse)?
+            .value(forHTTPHeaderField: "X-Original-Content-Length")
+            .flatMap(Int64.init)
+        progressHandler?(.init(totalBytesWritten: totalBytesWritten, totalBytesToWrite: maybeLength))
+    }
+
+    // Completion
+    func urlSession(_: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
+        guard let httpResponse = downloadTask.response as? HTTPURLResponse else {
+            continuation.resume(throwing: DownloadError.invalidResponse)
+            return
+        }
+        guard httpResponse.statusCode != 304 else {
+            // We already have the latest dylib downloaded in dest
+            continuation.resume()
+            return
+        }
+
+        guard httpResponse.statusCode == 200 else {
+            continuation.resume(throwing: DownloadError.unexpectedStatusCode(httpResponse.statusCode))
+            return
+        }
+
+        do {
+            if FileManager.default.fileExists(atPath: dest.path) {
+                try FileManager.default.removeItem(at: dest)
+            }
+            try FileManager.default.moveItem(at: location, to: dest)
+        } catch {
+            continuation.resume(throwing: DownloadError.fileOpError(error))
+            return
+        }
+
+        continuation.resume()
+    }
+
+    // Failure
+    func urlSession(_: URLSession, task _: URLSessionTask, didCompleteWithError error: Error?) {
+        if let error {
+            continuation.resume(throwing: error)
+        }
+    }
+}
+
+@objc public final class DownloadProgress: NSObject, NSSecureCoding, @unchecked Sendable {
+    public static var supportsSecureCoding: Bool { true }
+
+    public let totalBytesWritten: Int64
+    public let totalBytesToWrite: Int64?
+
+    public init(totalBytesWritten: Int64, totalBytesToWrite: Int64?) {
+        self.totalBytesWritten = totalBytesWritten
+        self.totalBytesToWrite = totalBytesToWrite
+    }
+
+    public required convenience init?(coder: NSCoder) {
+        let written = coder.decodeInt64(forKey: "written")
+        let total = coder.containsValue(forKey: "total") ? coder.decodeInt64(forKey: "total") : nil
+        self.init(totalBytesWritten: written, totalBytesToWrite: total)
+    }
+
+    public func encode(with coder: NSCoder) {
+        coder.encode(totalBytesWritten, forKey: "written")
+        if let total = totalBytesToWrite {
+            coder.encode(total, forKey: "total")
+        }
+    }
+
+    override public var description: String {
+        let fmt = ByteCountFormatter()
+        let done = fmt.string(fromByteCount: totalBytesWritten)
+            .padding(toLength: 7, withPad: " ", startingAt: 0)
+        let total = totalBytesToWrite.map { fmt.string(fromByteCount: $0) } ?? "Unknown"
+        return "\(done) / \(total)"
+    }
+}
diff --git a/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift b/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift
index 98807e3a..d4b36065 100644
--- a/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift
+++ b/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift
@@ -32,7 +32,7 @@ public class MutagenDaemon: FileSyncDaemon {
 
     @Published public var state: DaemonState = .stopped {
         didSet {
-            logger.info("daemon state set: \(self.state.description, privacy: .public)")
+            logger.info("mutagen daemon state set: \(self.state.description, privacy: .public)")
             if case .failed = state {
                 Task {
                     try? await cleanupGRPC()
diff --git a/Coder-Desktop/VPNLib/Util.swift b/Coder-Desktop/VPNLib/Util.swift
index fd9bbc3f..9ce03766 100644
--- a/Coder-Desktop/VPNLib/Util.swift
+++ b/Coder-Desktop/VPNLib/Util.swift
@@ -29,3 +29,32 @@ public func makeNSError(suffix: String, code: Int = -1, desc: String) -> NSError
         userInfo: [NSLocalizedDescriptionKey: desc]
     )
 }
+
+private actor Throttler<T: Sendable> {
+    let interval: Duration
+    let send: @Sendable (T) -> Void
+    var lastFire: ContinuousClock.Instant?
+
+    init(interval: Duration, send: @escaping @Sendable (T) -> Void) {
+        self.interval = interval
+        self.send = send
+    }
+
+    func push(_ value: T) {
+        let now = ContinuousClock.now
+        if let lastFire, now - lastFire < interval { return }
+        lastFire = now
+        send(value)
+    }
+}
+
+public func throttle<T: Sendable>(
+    interval: Duration,
+    _ send: @escaping @Sendable (T) -> Void
+) -> @Sendable (T) -> Void {
+    let box = Throttler(interval: interval, send: send)
+
+    return { value in
+        Task { await box.push(value) }
+    }
+}
diff --git a/Coder-Desktop/VPNLib/XPC.swift b/Coder-Desktop/VPNLib/XPC.swift
index dc79651e..baea7fe9 100644
--- a/Coder-Desktop/VPNLib/XPC.swift
+++ b/Coder-Desktop/VPNLib/XPC.swift
@@ -10,5 +10,29 @@ import Foundation
 @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 enum ProgressStage: Int, Sendable {
+    case initial
+    case downloading
+    case validating
+    case removingQuarantine
+    case startingTunnel
+
+    public var description: String? {
+        switch self {
+        case .initial:
+            nil
+        case .downloading:
+            "Downloading library..."
+        case .validating:
+            "Validating library..."
+        case .removingQuarantine:
+            "Removing quarantine..."
+        case .startingTunnel:
+            nil
+        }
+    }
+}
diff --git a/Coder-Desktop/project.yml b/Coder-Desktop/project.yml
index f2c96fac..224add81 100644
--- a/Coder-Desktop/project.yml
+++ b/Coder-Desktop/project.yml
@@ -129,6 +129,9 @@ packages:
   URLRouting:
     url: https://github.com/pointfreeco/swift-url-routing
     revision: 09b155d
+  Sparkle:
+    url: https://github.com/sparkle-project/Sparkle
+    exactVersion: 2.7.0
 
 
 targets:
@@ -139,6 +142,13 @@ targets:
       - path: Coder-Desktop
       - path: Resources
         buildPhase: resources
+      - path: Coder-DesktopHelper/com.coder.Coder-Desktop.Helper.plist
+        attributes:
+          - CodeSignOnCopy
+        buildPhase:
+          copyFiles:
+            destination: wrapper
+            subpath: Contents/Library/LaunchDaemons
     entitlements:
       path: Coder-Desktop/Coder-Desktop.entitlements
       properties:
@@ -185,11 +195,17 @@ targets:
         embed: false # Loaded from SE bundle
       - target: VPN
         embed: without-signing # Embed without signing.
+      - target: Coder-DesktopHelper
+        embed: true
+        codeSign: true
+        copy:
+          destination: executables
       - package: FluidMenuBarExtra
       - package: KeychainAccess
       - package: LaunchAtLogin
       - package: SDWebImageSwiftUI
       - package: SDWebImageSVGCoder
+      - package: Sparkle
     scheme:
       testPlans:
         - path: Coder-Desktop.xctestplan
@@ -235,6 +251,7 @@ targets:
     platform: macOS
     sources:
       - path: VPN
+      - path: Coder-DesktopHelper/HelperXPCProtocol.swift
     entitlements:
       path: VPN/VPN.entitlements
       properties:
@@ -347,3 +364,15 @@ targets:
       base:
         TEST_HOST: "$(BUILT_PRODUCTS_DIR)/Coder Desktop.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Coder Desktop"
         PRODUCT_BUNDLE_IDENTIFIER: com.coder.Coder-Desktop.CoderSDKTests
+
+  Coder-DesktopHelper:
+    type: tool
+    platform: macOS
+    sources: Coder-DesktopHelper
+    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
diff --git a/Makefile b/Makefile
index a21b756b..e50b060c 100644
--- a/Makefile
+++ b/Makefile
@@ -106,7 +106,8 @@ release: $(KEYCHAIN_FILE) ## Create a release build of Coder Desktop
 		--app-prof-path "$$APP_PROF_PATH" \
 		--ext-prof-path "$$EXT_PROF_PATH" \
 		--version $(MARKETING_VERSION) \
-		--keychain "$(APP_SIGNING_KEYCHAIN)"; \
+		--keychain "$(APP_SIGNING_KEYCHAIN)" \
+		--sparkle-private-key "$$SPARKLE_PRIVATE_KEY"; \
 	rm "$$APP_PROF_PATH" "$$EXT_PROF_PATH"
 
 .PHONY: fmt
diff --git a/scripts/build.sh b/scripts/build.sh
index de6f34aa..f6e537a6 100755
--- a/scripts/build.sh
+++ b/scripts/build.sh
@@ -16,15 +16,17 @@ APP_PROF_PATH=${APP_PROF_PATH:-""}
 EXT_PROF_PATH=${EXT_PROF_PATH:-""}
 KEYCHAIN=${KEYCHAIN:-""}
 VERSION=${VERSION:-""}
+SPARKLE_PRIVATE_KEY=${SPARKLE_PRIVATE_KEY:-""}
 
 # Function to display usage
 usage() {
   echo "Usage: $0 [--app-prof-path <path>] [--ext-prof-path <path>] [--keychain <path>]"
-  echo "  --app-prof-path <path>     Set the APP_PROF_PATH variable"
-  echo "  --ext-prof-path <path>     Set the EXT_PROF_PATH variable"
-  echo "  --keychain      <path>     Set the KEYCHAIN variable"
-  echo "  --version       <version>  Set the VERSION variable to fetch and generate the cask file for"
-  echo "  -h, --help                 Display this help message"
+  echo "  --app-prof-path       <path>     Set the APP_PROF_PATH variable"
+  echo "  --ext-prof-path       <path>     Set the EXT_PROF_PATH variable"
+  echo "  --keychain            <path>     Set the KEYCHAIN variable"
+  echo "  --sparkle-private-key <path>     Set the SPARKLE_PRIVATE_KEY variable"
+  echo "  --version             <version>  Set the VERSION variable to fetch and generate the cask file for"
+  echo "  -h, --help                       Display this help message"
 }
 
 # Parse command line arguments
@@ -42,6 +44,10 @@ while [[ "$#" -gt 0 ]]; do
     KEYCHAIN="$2"
     shift 2
     ;;
+  --sparkle-private-key)
+    SPARKLE_PRIVATE_KEY="$2"
+    shift 2
+    ;;
   --version)
     VERSION="$2"
     shift 2
@@ -59,7 +65,7 @@ while [[ "$#" -gt 0 ]]; do
 done
 
 # Check if required variables are set
-if [[ -z "$APP_PROF_PATH" || -z "$EXT_PROF_PATH" || -z "$KEYCHAIN" ]]; then
+if [[ -z "$APP_PROF_PATH" || -z "$EXT_PROF_PATH" || -z "$KEYCHAIN" || -z "$SPARKLE_PRIVATE_KEY" ]]; then
   echo "Missing required values"
   echo "APP_PROF_PATH: $APP_PROF_PATH"
   echo "EXT_PROF_PATH: $EXT_PROF_PATH"
@@ -195,6 +201,9 @@ xcrun notarytool submit "$PKG_PATH" \
 xcrun stapler staple "$PKG_PATH"
 xcrun stapler staple "$BUILT_APP_PATH"
 
+signature=$(echo "$SPARKLE_PRIVATE_KEY" | ~/Library/Developer/Xcode/DerivedData/Coder-Desktop-*/SourcePackages/artifacts/sparkle/Sparkle/bin/sign_update "$PKG_PATH" --ed-key-file -)
+echo "$signature" >"$PKG_PATH.sig"
+
 # Add dsym to build artifacts
 (cd "$ARCHIVE_PATH/dSYMs" && zip -9 -r --symlinks "$DSYM_ZIPPED_PATH" ./*)
 
