Skip to content

chore: make helper launchdaemon approval mandatory #205

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ struct DesktopApp: App {
SettingsView<CoderVPNService>()
.environmentObject(appDelegate.vpn)
.environmentObject(appDelegate.state)
.environmentObject(appDelegate.helper)
.environmentObject(appDelegate.autoUpdater)
}
.windowResizability(.contentSize)
Expand All @@ -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 = {
Expand Down Expand Up @@ -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<CoderVPNService, MutagenDaemon>().frame(width: 256)
.environmentObject(self.vpn)
Expand All @@ -119,6 +122,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
if await !vpn.loadNetworkExtensionConfig() {
state.reconfigure()
}
await vpn.setupHelper()
}
}

Expand Down
90 changes: 61 additions & 29 deletions Coder-Desktop/Coder-Desktop/HelperService.swift
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -57,6 +87,8 @@ enum HelperState: Equatable {
switch self {
case .uninstalled:
"Uninstalled"
case .installing:
"Installing"
case .installed:
"Installed"
case .requiresApproval:
Expand Down
2 changes: 2 additions & 0 deletions Coder-Desktop/Coder-Desktop/Preview Content/PreviewVPN.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,5 +81,7 @@ final class PreviewVPN: Coder_Desktop.VPNService {
state = .connecting
}

func updateHelperState() {}

var startWhenReady: Bool = false
}
20 changes: 20 additions & 0 deletions Coder-Desktop/Coder-Desktop/VPN/VPNService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)"
}
}

Expand All @@ -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))
}
Expand All @@ -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()
Expand Down Expand Up @@ -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")
Expand Down
10 changes: 0 additions & 10 deletions Coder-Desktop/Coder-Desktop/Views/Settings/ExperimentalTab.swift

This file was deleted.

82 changes: 0 additions & 82 deletions Coder-Desktop/Coder-Desktop/Views/Settings/HelperSection.swift

This file was deleted.

6 changes: 0 additions & 6 deletions Coder-Desktop/Coder-Desktop/Views/Settings/Settings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -28,5 +23,4 @@ struct SettingsView<VPN: VPNService>: View {
enum SettingsTab: Int {
case general
case network
case experimental
}
6 changes: 5 additions & 1 deletion Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,11 @@ struct VPNMenu<VPN: VPNService, FS: FileSyncDaemon>: 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))
)
}
}
Expand Down
Loading
Loading