diff --git a/Coder-Desktop/Coder-Desktop/VPN/NetworkExtension.swift b/Coder-Desktop/Coder-Desktop/VPN/NetworkExtension.swift index 660ef37..7c90bd5 100644 --- a/Coder-Desktop/Coder-Desktop/VPN/NetworkExtension.swift +++ b/Coder-Desktop/Coder-Desktop/VPN/NetworkExtension.swift @@ -58,8 +58,9 @@ extension CoderVPNService { try await tm.saveToPreferences() neState = .disabled } catch { + // This typically fails when the user declines the permission dialog logger.error("save tunnel failed: \(error)") - neState = .failed(error.localizedDescription) + neState = .failed("Failed to save tunnel: \(error.localizedDescription). Try logging in and out again.") } } diff --git a/Coder-Desktop/Coder-Desktop/VPN/VPNSystemExtension.swift b/Coder-Desktop/Coder-Desktop/VPN/VPNSystemExtension.swift index aade55d..39d625c 100644 --- a/Coder-Desktop/Coder-Desktop/VPN/VPNSystemExtension.swift +++ b/Coder-Desktop/Coder-Desktop/VPN/VPNSystemExtension.swift @@ -22,6 +22,35 @@ enum SystemExtensionState: Equatable, Sendable { } } +let extensionBundle: Bundle = { + let extensionsDirectoryURL = URL( + fileURLWithPath: "Contents/Library/SystemExtensions", + relativeTo: Bundle.main.bundleURL + ) + let extensionURLs: [URL] + do { + extensionURLs = try FileManager.default.contentsOfDirectory(at: extensionsDirectoryURL, + includingPropertiesForKeys: nil, + options: .skipsHiddenFiles) + } catch { + fatalError("Failed to get the contents of " + + "\(extensionsDirectoryURL.absoluteString): \(error.localizedDescription)") + } + + // here we're just going to assume that there is only ever going to be one SystemExtension + // packaged up in the application bundle. If we ever need to ship multiple versions or have + // multiple extensions, we'll need to revisit this assumption. + guard let extensionURL = extensionURLs.first else { + fatalError("Failed to find any system extensions") + } + + guard let extensionBundle = Bundle(url: extensionURL) else { + fatalError("Failed to create a bundle with URL \(extensionURL.absoluteString)") + } + + return extensionBundle +}() + protocol SystemExtensionAsyncRecorder: Sendable { func recordSystemExtensionState(_ state: SystemExtensionState) async } @@ -36,50 +65,9 @@ extension CoderVPNService: SystemExtensionAsyncRecorder { } } - var extensionBundle: Bundle { - let extensionsDirectoryURL = URL( - fileURLWithPath: "Contents/Library/SystemExtensions", - relativeTo: Bundle.main.bundleURL - ) - let extensionURLs: [URL] - do { - extensionURLs = try FileManager.default.contentsOfDirectory(at: extensionsDirectoryURL, - includingPropertiesForKeys: nil, - options: .skipsHiddenFiles) - } catch { - fatalError("Failed to get the contents of " + - "\(extensionsDirectoryURL.absoluteString): \(error.localizedDescription)") - } - - // here we're just going to assume that there is only ever going to be one SystemExtension - // packaged up in the application bundle. If we ever need to ship multiple versions or have - // multiple extensions, we'll need to revisit this assumption. - guard let extensionURL = extensionURLs.first else { - fatalError("Failed to find any system extensions") - } - - guard let extensionBundle = Bundle(url: extensionURL) else { - fatalError("Failed to create a bundle with URL \(extensionURL.absoluteString)") - } - - return extensionBundle - } - func installSystemExtension() { - logger.info("activating SystemExtension") - guard let bundleID = extensionBundle.bundleIdentifier else { - logger.error("Bundle has no identifier") - return - } - let request = OSSystemExtensionRequest.activationRequest( - forExtensionWithIdentifier: bundleID, - queue: .main - ) - let delegate = SystemExtensionDelegate(asyncDelegate: self) - systemExtnDelegate = delegate - request.delegate = delegate - OSSystemExtensionManager.shared.submitRequest(request) - logger.info("submitted SystemExtension request with bundleID: \(bundleID)") + systemExtnDelegate = SystemExtensionDelegate(asyncDelegate: self) + systemExtnDelegate!.installSystemExtension() } } @@ -90,6 +78,11 @@ class SystemExtensionDelegate: { private var logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "vpn-installer") private var asyncDelegate: AsyncDelegate + // The `didFinishWithResult` function is called for both activation, + // deactivation, and replacement requests. The API provides no way to + // differentiate them. https://developer.apple.com/forums/thread/684021 + // This tracks the last request type made, to handle them accordingly. + private var action: SystemExtensionDelegateAction = .none init(asyncDelegate: AsyncDelegate) { self.asyncDelegate = asyncDelegate @@ -97,6 +90,19 @@ class SystemExtensionDelegate: logger.info("SystemExtensionDelegate initialized") } + func installSystemExtension() { + logger.info("activating SystemExtension") + let bundleID = extensionBundle.bundleIdentifier! + let request = OSSystemExtensionRequest.activationRequest( + forExtensionWithIdentifier: bundleID, + queue: .main + ) + request.delegate = self + action = .installing + OSSystemExtensionManager.shared.submitRequest(request) + logger.info("submitted SystemExtension request with bundleID: \(bundleID)") + } + func request( _: OSSystemExtensionRequest, didFinishWithResult result: OSSystemExtensionRequest.Result @@ -109,9 +115,38 @@ class SystemExtensionDelegate: } return } - logger.info("SystemExtension activated") - Task { [asyncDelegate] in - await asyncDelegate.recordSystemExtensionState(SystemExtensionState.installed) + switch action { + case .installing: + logger.info("SystemExtension installed") + Task { [asyncDelegate] in + await asyncDelegate.recordSystemExtensionState(.installed) + } + action = .none + case .deleting: + logger.info("SystemExtension deleted") + Task { [asyncDelegate] in + await asyncDelegate.recordSystemExtensionState(.uninstalled) + } + let request = OSSystemExtensionRequest.activationRequest( + forExtensionWithIdentifier: extensionBundle.bundleIdentifier!, + queue: .main + ) + request.delegate = self + action = .installing + OSSystemExtensionManager.shared.submitRequest(request) + case .replacing: + logger.info("SystemExtension replaced") + // The installed extension now has the same version strings as this + // bundle, so sending the deactivationRequest will work. + let request = OSSystemExtensionRequest.deactivationRequest( + forExtensionWithIdentifier: extensionBundle.bundleIdentifier!, + queue: .main + ) + request.delegate = self + action = .deleting + OSSystemExtensionManager.shared.submitRequest(request) + case .none: + logger.warning("Received an unexpected request result") } } @@ -119,14 +154,14 @@ class SystemExtensionDelegate: logger.error("System extension request failed: \(error.localizedDescription)") Task { [asyncDelegate] in await asyncDelegate.recordSystemExtensionState( - SystemExtensionState.failed(error.localizedDescription)) + .failed(error.localizedDescription)) } } func requestNeedsUserApproval(_ request: OSSystemExtensionRequest) { logger.error("Extension \(request.identifier) requires user approval") Task { [asyncDelegate] in - await asyncDelegate.recordSystemExtensionState(SystemExtensionState.needsUserApproval) + await asyncDelegate.recordSystemExtensionState(.needsUserApproval) } } @@ -135,8 +170,31 @@ class SystemExtensionDelegate: actionForReplacingExtension existing: OSSystemExtensionProperties, withExtension extension: OSSystemExtensionProperties ) -> OSSystemExtensionRequest.ReplacementAction { - // swiftlint:disable:next line_length - logger.info("Replacing \(request.identifier) v\(existing.bundleShortVersion) with v\(`extension`.bundleShortVersion)") + logger.info("Replacing \(request.identifier) v\(existing.bundleVersion) with v\(`extension`.bundleVersion)") + // This is counterintuitive, but this function is only called if the + // versions are the same in a dev environment. + // In a release build, this only gets called when the version string is + // different. We don't want to manually reinstall the extension in a dev + // environment, because the bug doesn't happen. + if existing.bundleVersion == `extension`.bundleVersion { + return .replace + } + // To work around the bug described in + // https://github.com/coder/coder-desktop-macos/issues/121, + // we're going to manually reinstall after the replacement is done. + // If we returned `.cancel` here the deactivation request will fail as + // it looks for an extension with the *current* version string. + // There's no way to modify the deactivate request to use a different + // version string (i.e. `existing.bundleVersion`). + logger.info("App upgrade detected, replacing and then reinstalling") + action = .replacing return .replace } } + +enum SystemExtensionDelegateAction { + case none + case installing + case replacing + case deleting +} diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenu.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenu.swift index 83757ef..89365fd 100644 --- a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenu.swift +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenu.swift @@ -81,6 +81,21 @@ struct VPNMenu: View { }.buttonStyle(.plain) TrayDivider() } + // This shows when + // 1. The user is logged in + // 2. The network extension is installed + // 3. The VPN is unconfigured + // It's accompanied by a message in the VPNState view + // that the user needs to reconfigure. + if state.hasSession, vpn.state == .failed(.networkExtensionError(.unconfigured)) { + Button { + state.reconfigure() + } label: { + ButtonRowView { + Text("Reconfigure VPN") + } + }.buttonStyle(.plain) + } if vpn.state == .failed(.systemExtensionError(.needsUserApproval)) { Button { openSystemExtensionSettings() @@ -128,7 +143,9 @@ struct VPNMenu: View { vpn.state == .connecting || vpn.state == .disconnecting || // Prevent starting the VPN before the user has approved the system extension. - vpn.state == .failed(.systemExtensionError(.needsUserApproval)) + vpn.state == .failed(.systemExtensionError(.needsUserApproval)) || + // Prevent starting the VPN without a VPN configuration. + 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 64c0856..2331902 100644 --- a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNState.swift +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNState.swift @@ -17,6 +17,10 @@ struct VPNState: View { Text("Sign in to use Coder Desktop") .font(.body) .foregroundColor(.secondary) + case (.failed(.networkExtensionError(.unconfigured)), _): + Text("The system VPN requires reconfiguration.") + .font(.body) + .foregroundStyle(.secondary) case (.disabled, _): Text("Enable Coder Connect to see workspaces") .font(.body) @@ -38,7 +42,7 @@ struct VPNState: View { .padding(.horizontal, Theme.Size.trayInset) .padding(.vertical, Theme.Size.trayPadding) .frame(maxWidth: .infinity) - default: + case (.connected, true): EmptyView() } }