From a4d3cace04f63a0d9116fc368ec83cee61aade38 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 24 Jul 2025 14:09:25 +1000 Subject: [PATCH 1/3] chore: make helper launchdaemon approval mandatory --- .../Coder-Desktop/Coder_DesktopApp.swift | 12 ++- .../Coder-Desktop/HelperService.swift | 90 +++++++++++++------ .../Preview Content/PreviewVPN.swift | 2 + .../Coder-Desktop/VPN/VPNService.swift | 20 +++++ .../Views/Settings/ExperimentalTab.swift | 10 --- .../Views/Settings/HelperSection.swift | 82 ----------------- .../Views/Settings/Settings.swift | 6 -- .../Coder-Desktop/Views/VPN/VPNMenu.swift | 6 +- .../Coder-Desktop/Views/VPN/VPNState.swift | 90 ++++++++++++++----- 9 files changed, 162 insertions(+), 156 deletions(-) delete mode 100644 Coder-Desktop/Coder-Desktop/Views/Settings/ExperimentalTab.swift delete mode 100644 Coder-Desktop/Coder-Desktop/Views/Settings/HelperSection.swift diff --git a/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift b/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift index de12c6e1..1000311a 100644 --- a/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift +++ b/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift @@ -26,7 +26,6 @@ struct DesktopApp: App { SettingsView() .environmentObject(appDelegate.vpn) .environmentObject(appDelegate.state) - .environmentObject(appDelegate.helper) .environmentObject(appDelegate.autoUpdater) } .windowResizability(.contentSize) @@ -48,13 +47,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 = { @@ -95,10 +92,16 @@ class AppDelegate: NSObject, NSApplicationDelegate { image: "MenuBarIcon", onAppear: { // If the VPN is enabled, it's likely the token isn't expired - guard self.vpn.state != .connected, self.state.hasSession else { return } Task { @MainActor in + guard self.vpn.state != .connected, self.state.hasSession else { return } await self.state.handleTokenExpiry() } + // If the Helper is pending approval, we should check if it's + // been approved when the tray is opened. + Task { @MainActor in + guard self.vpn.state == .failed(.helperError(.requiresApproval)) else { return } + self.vpn.refreshHelperState() + } }, content: { VPNMenu().frame(width: 256) .environmentObject(self.vpn) @@ -119,6 +122,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { if await !vpn.loadNetworkExtensionConfig() { state.reconfigure() } + await vpn.setupHelper() } } diff --git a/Coder-Desktop/Coder-Desktop/HelperService.swift b/Coder-Desktop/Coder-Desktop/HelperService.swift index 17bdc72a..8a43bae3 100644 --- a/Coder-Desktop/Coder-Desktop/HelperService.swift +++ b/Coder-Desktop/Coder-Desktop/HelperService.swift @@ -1,54 +1,84 @@ 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)") - } - } +extension CoderVPNService { + var plistName: String { "com.coder.Coder-Desktop.Helper.plist" } - init() { - update() + func refreshHelperState() { + let daemon = SMAppService.daemon(plistName: plistName) + helperState = HelperState(status: daemon.status) } - func update() { - let daemon = SMAppService.daemon(plistName: plistName) - state = HelperState(status: daemon.status) + func setupHelper() async { + refreshHelperState() + switch helperState { + case .uninstalled, .failed: + await installHelper() + case .installed: + uninstallHelper() + await installHelper() + case .requiresApproval, .installing: + break + } } - 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)) + private func installHelper() async { + // Worst case, this setup takes a few seconds. We'll show a loading + // indicator in the meantime. + helperState = .installing + var lastUnknownError: Error? + // Registration may fail with a permissions error if it was + // just unregistered, so we retry a few times. + for _ in 0 ... 10 { + let daemon = SMAppService.daemon(plistName: plistName) + do { + try daemon.register() + helperState = HelperState(status: daemon.status) + return + } catch { + if daemon.status == .requiresApproval { + helperState = .requiresApproval + return + } + let helperError = HelperError(error: error as NSError) + switch helperError { + case .alreadyRegistered: + helperState = .installed + return + case .launchDeniedByUser, .invalidSignature: + // Something weird happened, we should update the UI + helperState = .failed(helperError) + return + case .unknown: + // Likely intermittent permissions error, we'll retry + lastUnknownError = error + logger.warning("failed to register helper: \(helperError.localizedDescription)") + } + + // Short delay before retrying + try? await Task.sleep(for: .milliseconds(500)) + } } - state = HelperState(status: daemon.status) + // Give up, update the UI with the error + helperState = .failed(.unknown(lastUnknownError?.localizedDescription ?? "Unknown")) } - func uninstall() { + private func uninstallHelper() { let daemon = SMAppService.daemon(plistName: plistName) do { try daemon.unregister() } catch let error as NSError { - self.state = .failed(.init(error: error)) + helperState = .failed(.init(error: error)) } catch { - state = .failed(.unknown(error.localizedDescription)) + helperState = .failed(.unknown(error.localizedDescription)) } - state = HelperState(status: daemon.status) + helperState = HelperState(status: daemon.status) } } enum HelperState: Equatable { case uninstalled + case installing case installed case requiresApproval case failed(HelperError) @@ -57,6 +87,8 @@ enum HelperState: Equatable { switch self { case .uninstalled: "Uninstalled" + case .installing: + "Installing" case .installed: "Installed" case .requiresApproval: diff --git a/Coder-Desktop/Coder-Desktop/Preview Content/PreviewVPN.swift b/Coder-Desktop/Coder-Desktop/Preview Content/PreviewVPN.swift index 91d5bf5e..796e2b67 100644 --- a/Coder-Desktop/Coder-Desktop/Preview Content/PreviewVPN.swift +++ b/Coder-Desktop/Coder-Desktop/Preview Content/PreviewVPN.swift @@ -81,5 +81,7 @@ final class PreviewVPN: Coder_Desktop.VPNService { state = .connecting } + func updateHelperState() {} + var startWhenReady: Bool = false } diff --git a/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift b/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift index 224174ae..26f41431 100644 --- a/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift +++ b/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift @@ -36,6 +36,7 @@ enum VPNServiceError: Error, Equatable { case internalError(String) case systemExtensionError(SystemExtensionState) case networkExtensionError(NetworkExtensionState) + case helperError(HelperState) var description: String { switch self { @@ -45,6 +46,8 @@ enum VPNServiceError: Error, Equatable { "SystemExtensionError: \(state.description)" case let .networkExtensionError(state): "NetworkExtensionError: \(state.description)" + case let .helperError(state): + "HelperError: \(state.description)" } } @@ -67,6 +70,13 @@ final class CoderVPNService: NSObject, VPNService { @Published var sysExtnState: SystemExtensionState = .uninstalled @Published var neState: NetworkExtensionState = .unconfigured var state: VPNServiceState { + // The ordering here is important. The button to open the settings page + // where the helper is approved is a no-op if the user has a settings + // window on the page where the system extension is approved. + // So, we want to ensure the helper settings button is clicked first. + guard helperState == .installed else { + return .failed(.helperError(helperState)) + } guard sysExtnState == .installed else { return .failed(.systemExtensionError(sysExtnState)) } @@ -80,6 +90,8 @@ final class CoderVPNService: NSObject, VPNService { return tunnelState } + @Published var helperState: HelperState = .uninstalled + @Published var progress: VPNProgress = .init(stage: .initial, downloadProgress: nil) @Published var menuState: VPNMenuState = .init() @@ -107,6 +119,14 @@ final class CoderVPNService: NSObject, VPNService { return } + // We have to manually fetch the helper state, + // and we don't want to start the VPN + // if the helper is not ready. + refreshHelperState() + if helperState != .installed { + return + } + menuState.clear() await startTunnel() logger.debug("network extension enabled") 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/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: 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: View { enum SettingsTab: Int { case general case network - case experimental } diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenu.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenu.swift index a48be35f..9804ddf7 100644 --- a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenu.swift +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenu.swift @@ -124,7 +124,11 @@ struct VPNMenu: View { // Prevent starting the VPN before the user has approved the system extension. vpn.state == .failed(.systemExtensionError(.needsUserApproval)) || // Prevent starting the VPN without a VPN configuration. - vpn.state == .failed(.networkExtensionError(.unconfigured)) + vpn.state == .failed(.networkExtensionError(.unconfigured)) || + // Prevent starting the VPN before the Helper is approved + vpn.state == .failed(.helperError(.requiresApproval)) || + // Prevent starting the VPN before the Helper is installed + vpn.state == .failed(.helperError(.installing)) ) } } diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNState.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNState.swift index 9584ced2..f8c26cc9 100644 --- a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNState.swift +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNState.swift @@ -1,3 +1,4 @@ +import ServiceManagement import SwiftUI struct VPNState: View { @@ -10,20 +11,10 @@ struct VPNState: 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,11 +23,7 @@ struct VPNState: 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: { @@ -47,6 +34,13 @@ struct VPNState: View { // open the menu bar an extra time state.reconfigure() } + case (.failed(.helperError(.requiresApproval)), _): + ApprovalRequiredView( + message: "Awaiting Background Item approval", + action: SMAppService.openSystemSettingsLoginItems + ) + case (.failed(.helperError(.installing)), _): + HelperProgressView() case (.disabled, _): Text("Enable Coder Connect to see workspaces") .font(.body) @@ -61,11 +55,7 @@ struct VPNState: 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 +63,55 @@ struct VPNState: View { .onReceive(inspection.notice) { inspection.visit(self, $0) } // viewInspector } } + +struct HelperProgressView: View { + var body: some View { + HStack { + Spacer() + VStack { + CircularProgressView(value: nil) + Text("Installing Helper...") + .multilineTextAlignment(.center) + } + .padding() + .foregroundStyle(.secondary) + Spacer() + } + } +} + +struct ApprovalRequiredView: View { + @EnvironmentObject var vpn: VPN + 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()) + } +} From 6a93fac210b17eaa7ad308f3a5da0fcc55cf12bf Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Mon, 4 Aug 2025 12:59:14 +1000 Subject: [PATCH 2/3] install launch daemon as part of installer --- .../Coder-Desktop/Coder_DesktopApp.swift | 7 - .../Coder-Desktop/HelperService.swift | 149 ------------------ .../Coder-Desktop/VPN/VPNService.swift | 20 --- .../Coder-Desktop/Views/VPN/VPNMenu.swift | 6 +- .../Coder-Desktop/Views/VPN/VPNState.swift | 7 - .../com.coder.Coder-Desktop.Helper.plist | 4 +- pkgbuild/scripts/postinstall | 19 +++ pkgbuild/scripts/preinstall | 4 + scripts/update-cask.sh | 1 + 9 files changed, 27 insertions(+), 190 deletions(-) delete mode 100644 Coder-Desktop/Coder-Desktop/HelperService.swift diff --git a/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift b/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift index 1000311a..23e367b8 100644 --- a/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift +++ b/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift @@ -96,12 +96,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { guard self.vpn.state != .connected, self.state.hasSession else { return } await self.state.handleTokenExpiry() } - // If the Helper is pending approval, we should check if it's - // been approved when the tray is opened. - Task { @MainActor in - guard self.vpn.state == .failed(.helperError(.requiresApproval)) else { return } - self.vpn.refreshHelperState() - } }, content: { VPNMenu().frame(width: 256) .environmentObject(self.vpn) @@ -122,7 +116,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { if await !vpn.loadNetworkExtensionConfig() { state.reconfigure() } - await vpn.setupHelper() } } diff --git a/Coder-Desktop/Coder-Desktop/HelperService.swift b/Coder-Desktop/Coder-Desktop/HelperService.swift deleted file mode 100644 index 8a43bae3..00000000 --- a/Coder-Desktop/Coder-Desktop/HelperService.swift +++ /dev/null @@ -1,149 +0,0 @@ -import os -import ServiceManagement - -extension CoderVPNService { - var plistName: String { "com.coder.Coder-Desktop.Helper.plist" } - - func refreshHelperState() { - let daemon = SMAppService.daemon(plistName: plistName) - helperState = HelperState(status: daemon.status) - } - - func setupHelper() async { - refreshHelperState() - switch helperState { - case .uninstalled, .failed: - await installHelper() - case .installed: - uninstallHelper() - await installHelper() - case .requiresApproval, .installing: - break - } - } - - private func installHelper() async { - // Worst case, this setup takes a few seconds. We'll show a loading - // indicator in the meantime. - helperState = .installing - var lastUnknownError: Error? - // Registration may fail with a permissions error if it was - // just unregistered, so we retry a few times. - for _ in 0 ... 10 { - let daemon = SMAppService.daemon(plistName: plistName) - do { - try daemon.register() - helperState = HelperState(status: daemon.status) - return - } catch { - if daemon.status == .requiresApproval { - helperState = .requiresApproval - return - } - let helperError = HelperError(error: error as NSError) - switch helperError { - case .alreadyRegistered: - helperState = .installed - return - case .launchDeniedByUser, .invalidSignature: - // Something weird happened, we should update the UI - helperState = .failed(helperError) - return - case .unknown: - // Likely intermittent permissions error, we'll retry - lastUnknownError = error - logger.warning("failed to register helper: \(helperError.localizedDescription)") - } - - // Short delay before retrying - try? await Task.sleep(for: .milliseconds(500)) - } - } - // Give up, update the UI with the error - helperState = .failed(.unknown(lastUnknownError?.localizedDescription ?? "Unknown")) - } - - private func uninstallHelper() { - let daemon = SMAppService.daemon(plistName: plistName) - do { - try daemon.unregister() - } catch let error as NSError { - helperState = .failed(.init(error: error)) - } catch { - helperState = .failed(.unknown(error.localizedDescription)) - } - helperState = HelperState(status: daemon.status) - } -} - -enum HelperState: Equatable { - case uninstalled - case installing - case installed - case requiresApproval - case failed(HelperError) - - var description: String { - switch self { - case .uninstalled: - "Uninstalled" - case .installing: - "Installing" - 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/VPN/VPNService.swift b/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift index 26f41431..224174ae 100644 --- a/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift +++ b/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift @@ -36,7 +36,6 @@ enum VPNServiceError: Error, Equatable { case internalError(String) case systemExtensionError(SystemExtensionState) case networkExtensionError(NetworkExtensionState) - case helperError(HelperState) var description: String { switch self { @@ -46,8 +45,6 @@ enum VPNServiceError: Error, Equatable { "SystemExtensionError: \(state.description)" case let .networkExtensionError(state): "NetworkExtensionError: \(state.description)" - case let .helperError(state): - "HelperError: \(state.description)" } } @@ -70,13 +67,6 @@ final class CoderVPNService: NSObject, VPNService { @Published var sysExtnState: SystemExtensionState = .uninstalled @Published var neState: NetworkExtensionState = .unconfigured var state: VPNServiceState { - // The ordering here is important. The button to open the settings page - // where the helper is approved is a no-op if the user has a settings - // window on the page where the system extension is approved. - // So, we want to ensure the helper settings button is clicked first. - guard helperState == .installed else { - return .failed(.helperError(helperState)) - } guard sysExtnState == .installed else { return .failed(.systemExtensionError(sysExtnState)) } @@ -90,8 +80,6 @@ final class CoderVPNService: NSObject, VPNService { return tunnelState } - @Published var helperState: HelperState = .uninstalled - @Published var progress: VPNProgress = .init(stage: .initial, downloadProgress: nil) @Published var menuState: VPNMenuState = .init() @@ -119,14 +107,6 @@ final class CoderVPNService: NSObject, VPNService { return } - // We have to manually fetch the helper state, - // and we don't want to start the VPN - // if the helper is not ready. - refreshHelperState() - if helperState != .installed { - return - } - menuState.clear() await startTunnel() logger.debug("network extension enabled") diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenu.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenu.swift index 9804ddf7..a48be35f 100644 --- a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenu.swift +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenu.swift @@ -124,11 +124,7 @@ struct VPNMenu: View { // Prevent starting the VPN before the user has approved the system extension. vpn.state == .failed(.systemExtensionError(.needsUserApproval)) || // Prevent starting the VPN without a VPN configuration. - vpn.state == .failed(.networkExtensionError(.unconfigured)) || - // Prevent starting the VPN before the Helper is approved - vpn.state == .failed(.helperError(.requiresApproval)) || - // Prevent starting the VPN before the Helper is installed - vpn.state == .failed(.helperError(.installing)) + vpn.state == .failed(.networkExtensionError(.unconfigured)) ) } } diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNState.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNState.swift index f8c26cc9..bb3af403 100644 --- a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNState.swift +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNState.swift @@ -34,13 +34,6 @@ struct VPNState: View { // open the menu bar an extra time state.reconfigure() } - case (.failed(.helperError(.requiresApproval)), _): - ApprovalRequiredView( - message: "Awaiting Background Item approval", - action: SMAppService.openSystemSettingsLoginItems - ) - case (.failed(.helperError(.installing)), _): - HelperProgressView() case (.disabled, _): Text("Enable Coder Connect to see workspaces") .font(.body) 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..f2309a19 100644 --- a/Coder-Desktop/Coder-DesktopHelper/com.coder.Coder-Desktop.Helper.plist +++ b/Coder-Desktop/Coder-DesktopHelper/com.coder.Coder-Desktop.Helper.plist @@ -4,8 +4,8 @@ Label com.coder.Coder-Desktop.Helper - BundleProgram - Contents/MacOS/com.coder.Coder-Desktop.Helper + Program + /Applications/Coder Desktop.app/Contents/MacOS/com.coder.Coder-Desktop.Helper MachServices diff --git a/pkgbuild/scripts/postinstall b/pkgbuild/scripts/postinstall index 758776f6..359a9dd4 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 -R 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" 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/update-cask.sh b/scripts/update-cask.sh index 478ea610..8ce6e145 100755 --- a/scripts/update-cask.sh +++ b/scripts/update-cask.sh @@ -94,6 +94,7 @@ cask "coder-desktop" do uninstall quit: [ "com.coder.Coder-Desktop", "com.coder.Coder-Desktop.VPN", + "com.coder.Coder-Desktop.Helper", ], login_item: "Coder Desktop" From 6eeb8aadd19c569c627eae1c1aab057c5d4cce2a Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Mon, 4 Aug 2025 13:03:43 +1000 Subject: [PATCH 3/3] cleanup --- .../Coder-Desktop/Coder_DesktopApp.swift | 2 +- .../Preview Content/PreviewVPN.swift | 2 -- .../Coder-Desktop/Views/VPN/VPNState.swift | 16 ---------------- 3 files changed, 1 insertion(+), 19 deletions(-) diff --git a/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift b/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift index 23e367b8..e4b462c5 100644 --- a/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift +++ b/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift @@ -91,9 +91,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { title: "Coder Desktop", image: "MenuBarIcon", onAppear: { + guard self.vpn.state != .connected, self.state.hasSession else { return } // If the VPN is enabled, it's likely the token isn't expired Task { @MainActor in - guard self.vpn.state != .connected, self.state.hasSession else { return } await self.state.handleTokenExpiry() } }, content: { diff --git a/Coder-Desktop/Coder-Desktop/Preview Content/PreviewVPN.swift b/Coder-Desktop/Coder-Desktop/Preview Content/PreviewVPN.swift index 796e2b67..91d5bf5e 100644 --- a/Coder-Desktop/Coder-Desktop/Preview Content/PreviewVPN.swift +++ b/Coder-Desktop/Coder-Desktop/Preview Content/PreviewVPN.swift @@ -81,7 +81,5 @@ final class PreviewVPN: Coder_Desktop.VPNService { state = .connecting } - func updateHelperState() {} - var startWhenReady: Bool = false } diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNState.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNState.swift index bb3af403..250602c2 100644 --- a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNState.swift +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNState.swift @@ -57,22 +57,6 @@ struct VPNState: View { } } -struct HelperProgressView: View { - var body: some View { - HStack { - Spacer() - VStack { - CircularProgressView(value: nil) - Text("Installing Helper...") - .multilineTextAlignment(.center) - } - .padding() - .foregroundStyle(.secondary) - Spacer() - } - } -} - struct ApprovalRequiredView: View { @EnvironmentObject var vpn: VPN let message: String