Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 960cacf

Browse files
committedMar 10, 2025··
feat: add troubleshooting tab and improve extension management
- Add new Troubleshooting tab to settings with system/network extension controls - Implement extension uninstallation and granular state management - Add "Stop VPN on Quit" setting to control VPN behavior when app closes - Improve error handling for extension operations - Add comprehensive status reporting for troubleshooting 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> Change-Id: Id8327b1c9cd4cc2c4946edd0c8e93cab9a005315 Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent b7ccbca commit 960cacf

File tree

10 files changed

+693
-6
lines changed

10 files changed

+693
-6
lines changed
 

‎CLAUDE.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Coder Desktop Development Guide
2+
3+
## Build & Test Commands
4+
- Build Xcode project: `make`
5+
- Format Swift files: `make fmt`
6+
- Lint Swift files: `make lint`
7+
- Run all tests: `make test`
8+
- Run specific test class: `xcodebuild test -project "Coder Desktop/Coder Desktop.xcodeproj" -scheme "Coder Desktop" -only-testing:"Coder DesktopTests/AgentsTests"`
9+
- Run specific test method: `xcodebuild test -project "Coder Desktop/Coder Desktop.xcodeproj" -scheme "Coder Desktop" -only-testing:"Coder DesktopTests/AgentsTests/agentsWhenVPNOff"`
10+
- Generate Swift from proto: `make proto`
11+
- Watch for project changes: `make watch-gen`
12+
13+
## Code Style Guidelines
14+
- Use Swift 6.0 for development
15+
- Follow SwiftFormat and SwiftLint rules
16+
- Use Swift's Testing framework for tests (`@Test`, `#expect` directives)
17+
- Group files logically (Views, Models, Extensions)
18+
- Use environment objects for dependency injection
19+
- Prefer async/await over completion handlers
20+
- Use clear, descriptive naming for functions and variables
21+
- Implement proper error handling with Swift's throwing functions
22+
- Tests should use descriptive names reflecting what they're testing

‎Coder Desktop/Coder Desktop/Coder_DesktopApp.swift

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,13 @@ class AppDelegate: NSObject, NSApplicationDelegate {
4949
name: .NEVPNStatusDidChange,
5050
object: nil
5151
)
52+
// Subscribe to reconfiguration requests
53+
NotificationCenter.default.addObserver(
54+
self,
55+
selector: #selector(networkExtensionNeedsReconfiguration(_:)),
56+
name: .networkExtensionNeedsReconfiguration,
57+
object: nil
58+
)
5259
Task {
5360
// If there's no NE config, but the user is logged in, such as
5461
// from a previous install, then we need to reconfigure.
@@ -82,9 +89,27 @@ extension AppDelegate {
8289
vpn.vpnDidUpdate(connection)
8390
menuBar?.vpnDidUpdate(connection)
8491
}
92+
93+
@objc private func networkExtensionNeedsReconfiguration(_: Notification) {
94+
// Check if we have a session
95+
if state.hasSession {
96+
// Reconfigure the network extension with full credentials
97+
state.reconfigure()
98+
} else {
99+
// No valid session, the user likely needs to log in again
100+
// Show the login window
101+
NSApp.sendAction(#selector(NSApp.showLoginWindow), to: nil, from: nil)
102+
}
103+
}
85104
}
86105

87106
@MainActor
88107
func appActivate() {
89108
NSApp.activate()
90109
}
110+
111+
extension NSApplication {
112+
@objc func showLoginWindow() {
113+
NSApp.sendAction(#selector(NSWindowController.showWindow(_:)), to: nil, from: Windows.login.rawValue)
114+
}
115+
}

‎Coder Desktop/Coder Desktop/Preview Content/PreviewVPN.swift

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,15 @@ final class PreviewVPN: Coder_Desktop.VPNService {
2626
UUID(): Agent(id: UUID(), name: "dev", status: .off, hosts: ["asdf.coder"], wsName: "example",
2727
wsID: UUID()),
2828
], workspaces: [:])
29+
@Published var sysExtnState: SystemExtensionState = .installed
30+
@Published var neState: NetworkExtensionState = .enabled
2931
let shouldFail: Bool
3032
let longError = "This is a long error to test the UI with long error messages"
3133

32-
init(shouldFail: Bool = false) {
34+
init(shouldFail: Bool = false, extensionInstalled: Bool = true, networkExtensionEnabled: Bool = true) {
3335
self.shouldFail = shouldFail
36+
sysExtnState = extensionInstalled ? .installed : .uninstalled
37+
neState = networkExtensionEnabled ? .enabled : .disabled
3438
}
3539

3640
var startTask: Task<Void, Never>?
@@ -78,4 +82,69 @@ final class PreviewVPN: Coder_Desktop.VPNService {
7882
func configureTunnelProviderProtocol(proto _: NETunnelProviderProtocol?) {
7983
state = .connecting
8084
}
85+
86+
func uninstall() async -> Bool {
87+
// Simulate uninstallation with a delay
88+
do {
89+
try await Task.sleep(for: .seconds(2))
90+
} catch {
91+
return false
92+
}
93+
94+
if !shouldFail {
95+
sysExtnState = .uninstalled
96+
return true
97+
}
98+
return false
99+
}
100+
101+
func installExtension() async {
102+
// Simulate installation with a delay
103+
do {
104+
try await Task.sleep(for: .seconds(2))
105+
sysExtnState = if !shouldFail {
106+
.installed
107+
} else {
108+
.failed("Failed to install extension")
109+
}
110+
} catch {
111+
sysExtnState = .failed("Installation was interrupted")
112+
}
113+
}
114+
115+
func disableExtension() async -> Bool {
116+
// Simulate disabling with a delay
117+
do {
118+
try await Task.sleep(for: .seconds(1))
119+
} catch {
120+
return false
121+
}
122+
123+
if !shouldFail {
124+
neState = .disabled
125+
state = .disabled
126+
return true
127+
} else {
128+
neState = .failed("Failed to disable network extension")
129+
return false
130+
}
131+
}
132+
133+
func enableExtension() async -> Bool {
134+
// Simulate enabling with a delay
135+
do {
136+
try await Task.sleep(for: .seconds(1))
137+
} catch {
138+
return false
139+
}
140+
141+
if !shouldFail {
142+
neState = .enabled
143+
state = .disabled // Just disabled, not connected yet
144+
return true
145+
} else {
146+
neState = .failed("Failed to enable network extension")
147+
return false
148+
}
149+
}
81150
}

‎Coder Desktop/Coder Desktop/State.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ class AppState: ObservableObject {
88
let appId = Bundle.main.bundleIdentifier!
99

1010
// Stored in UserDefaults
11-
@Published private(set) var hasSession: Bool {
11+
@Published var hasSession: Bool {
1212
didSet {
1313
guard persistent else { return }
1414
UserDefaults.standard.set(hasSession, forKey: Keys.hasSession)

‎Coder Desktop/Coder Desktop/SystemExtension.swift

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,121 @@ extension CoderVPNService: SystemExtensionAsyncRecorder {
8181
OSSystemExtensionManager.shared.submitRequest(request)
8282
logger.info("submitted SystemExtension request with bundleID: \(bundleID)")
8383
}
84+
85+
func deregisterSystemExtension() async -> Bool {
86+
logger.info("Starting network extension deregistration...")
87+
88+
// Extension bundle identifier - must match what's used in the app
89+
let extensionBundleIdentifier = "com.coder.Coder-Desktop.VPN"
90+
91+
return await withCheckedContinuation { continuation in
92+
// Create a task to handle the deregistration with timeout
93+
let timeoutTask = Task {
94+
// Set a timeout for the operation
95+
let timeoutInterval: TimeInterval = 30.0 // 30 seconds
96+
97+
// Use a custom holder for the delegate to keep it alive
98+
// and store the result from the callback
99+
final class DelegateHolder {
100+
var delegate: DeregistrationDelegate?
101+
var result: Bool?
102+
}
103+
104+
let holder = DelegateHolder()
105+
106+
// Create the delegate with a completion handler
107+
let delegate = DeregistrationDelegate(completionHandler: { result in
108+
holder.result = result
109+
})
110+
holder.delegate = delegate
111+
112+
// Create and submit the deactivation request
113+
let request = OSSystemExtensionRequest.deactivationRequest(
114+
forExtensionWithIdentifier: extensionBundleIdentifier,
115+
queue: .main
116+
)
117+
request.delegate = delegate
118+
119+
// Submit the request on the main thread
120+
await MainActor.run {
121+
OSSystemExtensionManager.shared.submitRequest(request)
122+
}
123+
124+
// Set up timeout using a separate task
125+
let timeoutDate = Date().addingTimeInterval(timeoutInterval)
126+
127+
// Wait for completion or timeout
128+
while holder.result == nil, Date() < timeoutDate {
129+
// Sleep a bit before checking again (100ms)
130+
try? await Task.sleep(nanoseconds: 100_000_000)
131+
132+
// Check for cancellation
133+
if Task.isCancelled {
134+
break
135+
}
136+
}
137+
138+
// Handle the result
139+
if let result = holder.result {
140+
logger.info("System extension deregistration completed with result: \(result)")
141+
return result
142+
} else {
143+
logger.error("System extension deregistration timed out after \(timeoutInterval) seconds")
144+
return false
145+
}
146+
}
147+
148+
// Use Task.detached to handle potential continuation issues
149+
Task.detached {
150+
let result = await timeoutTask.value
151+
continuation.resume(returning: result)
152+
}
153+
}
154+
}
155+
156+
// A dedicated delegate class for system extension deregistration
157+
private class DeregistrationDelegate: NSObject, OSSystemExtensionRequestDelegate {
158+
private var logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "vpn-deregistrar")
159+
private var completionHandler: (Bool) -> Void
160+
161+
init(completionHandler: @escaping (Bool) -> Void) {
162+
self.completionHandler = completionHandler
163+
super.init()
164+
}
165+
166+
func request(_: OSSystemExtensionRequest, didFinishWithResult result: OSSystemExtensionRequest.Result) {
167+
switch result {
168+
case .completed:
169+
logger.info("System extension was successfully deregistered")
170+
completionHandler(true)
171+
case .willCompleteAfterReboot:
172+
logger.info("System extension will be deregistered after reboot")
173+
completionHandler(true)
174+
@unknown default:
175+
logger.error("System extension deregistration completed with unknown result")
176+
completionHandler(false)
177+
}
178+
}
179+
180+
func request(_: OSSystemExtensionRequest, didFailWithError error: Error) {
181+
logger.error("System extension deregistration failed: \(error.localizedDescription)")
182+
completionHandler(false)
183+
}
184+
185+
func requestNeedsUserApproval(_: OSSystemExtensionRequest) {
186+
logger.info("System extension deregistration needs user approval")
187+
// We don't complete here, as we'll get another callback when approval is granted or denied
188+
}
189+
190+
func request(
191+
_: OSSystemExtensionRequest,
192+
actionForReplacingExtension _: OSSystemExtensionProperties,
193+
withExtension _: OSSystemExtensionProperties
194+
) -> OSSystemExtensionRequest.ReplacementAction {
195+
logger.info("System extension replacement request")
196+
return .replace
197+
}
198+
}
84199
}
85200

86201
/// A delegate for the OSSystemExtensionRequest that maps the callbacks to async calls on the

‎Coder Desktop/Coder Desktop/VPNService.swift

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,23 @@ import os
33
import SwiftUI
44
import VPNLib
55

6+
extension Notification.Name {
7+
static let networkExtensionNeedsReconfiguration = Notification.Name("networkExtensionNeedsReconfiguration")
8+
}
9+
610
@MainActor
711
protocol VPNService: ObservableObject {
812
var state: VPNServiceState { get }
913
var menuState: VPNMenuState { get }
14+
var sysExtnState: SystemExtensionState { get }
15+
var neState: NetworkExtensionState { get }
1016
func start() async
1117
func stop() async
1218
func configureTunnelProviderProtocol(proto: NETunnelProviderProtocol?)
19+
func uninstall() async -> Bool
20+
func installExtension() async
21+
func disableExtension() async -> Bool
22+
func enableExtension() async -> Bool
1323
}
1424

1525
enum VPNServiceState: Equatable {
@@ -114,6 +124,157 @@ final class CoderVPNService: NSObject, VPNService {
114124
}
115125
}
116126

127+
func uninstall() async -> Bool {
128+
logger.info("Uninstalling VPN system extension...")
129+
130+
// First stop any active VPN tunnels
131+
if tunnelState == .connected || tunnelState == .connecting {
132+
await stop()
133+
}
134+
135+
// Remove network extension configuration
136+
do {
137+
try await removeNetworkExtension()
138+
neState = .unconfigured
139+
tunnelState = .disabled
140+
} catch {
141+
logger.error("Failed to remove network extension configuration: \(error.localizedDescription)")
142+
// Continue with deregistration even if removing network extension failed
143+
}
144+
145+
// Deregister the system extension
146+
let success = await deregisterSystemExtension()
147+
if success {
148+
logger.info("Successfully uninstalled VPN system extension")
149+
sysExtnState = .uninstalled
150+
} else {
151+
logger.error("Failed to uninstall VPN system extension")
152+
sysExtnState = .failed("Deregistration failed")
153+
}
154+
155+
return success
156+
}
157+
158+
func installExtension() async {
159+
logger.info("Installing VPN system extension...")
160+
161+
// Install the system extension
162+
installSystemExtension()
163+
164+
// We don't need to await here since the installSystemExtension method
165+
// uses a delegate callback system to update the state
166+
}
167+
168+
func disableExtension() async -> Bool {
169+
logger.info("Disabling VPN network extension without uninstalling...")
170+
171+
// First stop any active VPN tunnel
172+
if tunnelState == .connected || tunnelState == .connecting {
173+
await stop()
174+
}
175+
176+
// Remove network extension configuration but keep the system extension
177+
do {
178+
try await removeNetworkExtension()
179+
neState = .unconfigured
180+
tunnelState = .disabled
181+
logger.info("Successfully disabled network extension")
182+
return true
183+
} catch {
184+
logger.error("Failed to disable network extension: \(error.localizedDescription)")
185+
neState = .failed(error.localizedDescription)
186+
return false
187+
}
188+
}
189+
190+
func enableExtension() async -> Bool {
191+
logger.info("Enabling VPN network extension...")
192+
193+
// Ensure system extension is installed
194+
let extensionInstalled = await ensureSystemExtensionInstalled()
195+
if !extensionInstalled {
196+
return false
197+
}
198+
199+
// Get the initial state for comparison
200+
let initialNeState = neState
201+
202+
// Post a notification that the app should reconfigure
203+
NotificationCenter.default.post(name: .networkExtensionNeedsReconfiguration, object: nil)
204+
205+
// Wait for network extension state to change
206+
let stateChanged = await waitForNetworkExtensionChange(from: initialNeState)
207+
if !stateChanged {
208+
return false
209+
}
210+
211+
logger.info("Network extension was reconfigured successfully")
212+
213+
// Try to connect to VPN if needed
214+
return await tryConnectAfterReconfiguration()
215+
}
216+
217+
private func ensureSystemExtensionInstalled() async -> Bool {
218+
if sysExtnState != .installed {
219+
installSystemExtension()
220+
// Wait for the system extension to be installed
221+
for _ in 0 ..< 30 { // Wait up to 3 seconds
222+
if sysExtnState == .installed {
223+
break
224+
}
225+
try? await Task.sleep(for: .milliseconds(100))
226+
}
227+
228+
if sysExtnState != .installed {
229+
logger.error("Failed to install system extension during enableExtension")
230+
return false
231+
}
232+
}
233+
return true
234+
}
235+
236+
private func waitForNetworkExtensionChange(from initialState: NetworkExtensionState) async -> Bool {
237+
// Wait for network extension state to change from the initial state
238+
for _ in 0 ..< 30 { // Wait up to 3 seconds
239+
// If the state changes at all from the initial state, we consider reconfiguration successful
240+
if neState != initialState || neState == .enabled {
241+
return true
242+
}
243+
try? await Task.sleep(for: .milliseconds(100))
244+
}
245+
246+
logger.error("Network extension configuration didn't change after reconfiguration request")
247+
return false
248+
}
249+
250+
private func tryConnectAfterReconfiguration() async -> Bool {
251+
// If already enabled, we're done
252+
if neState == .enabled {
253+
logger.info("Network extension enabled successfully")
254+
return true
255+
}
256+
257+
// Wait a bit longer for the configuration to be fully applied
258+
try? await Task.sleep(for: .milliseconds(500))
259+
260+
// If the extension is in a state we can work with, try to start the VPN
261+
if case .failed = neState {
262+
logger.error("Network extension in failed state, skipping auto-connection")
263+
} else if neState != .unconfigured {
264+
logger.info("Attempting to automatically connect to VPN after reconfiguration")
265+
await start()
266+
267+
if tunnelState == .connecting || tunnelState == .connected {
268+
logger.info("VPN connection started successfully after reconfiguration")
269+
return true
270+
}
271+
}
272+
273+
// If we get here, the extension was reconfigured but not successfully enabled
274+
// Since configuration was successful, return true so user can manually connect
275+
return true
276+
}
277+
117278
func onExtensionPeerUpdate(_ data: Data) {
118279
logger.info("network extension peer update")
119280
do {
Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import LaunchAtLogin
22
import SwiftUI
33

4-
struct GeneralTab: View {
4+
struct GeneralTab<VPN: VPNService>: View {
55
@EnvironmentObject var state: AppState
6+
@EnvironmentObject private var vpn: VPN
7+
68
var body: some View {
79
Form {
810
Section {
911
LaunchAtLogin.Toggle("Launch at Login")
1012
}
13+
1114
Section {
1215
Toggle(isOn: $state.stopVPNOnQuit) {
1316
Text("Stop VPN on Quit")
@@ -17,6 +20,8 @@ struct GeneralTab: View {
1720
}
1821
}
1922

20-
#Preview {
21-
GeneralTab()
23+
#Preview("GeneralTab") {
24+
GeneralTab<PreviewVPN>()
25+
.environmentObject(AppState())
26+
.environmentObject(PreviewVPN())
2227
}

‎Coder Desktop/Coder Desktop/Views/Settings/Settings.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,18 @@ struct SettingsView<VPN: VPNService>: View {
55

66
var body: some View {
77
TabView(selection: $selection) {
8-
GeneralTab()
8+
GeneralTab<VPN>()
99
.tabItem {
1010
Label("General", systemImage: "gearshape")
1111
}.tag(SettingsTab.general)
1212
NetworkTab<VPN>()
1313
.tabItem {
1414
Label("Network", systemImage: "dot.radiowaves.left.and.right")
1515
}.tag(SettingsTab.network)
16+
TroubleshootingTab<VPN>()
17+
.tabItem {
18+
Label("Troubleshooting", systemImage: "wrench.and.screwdriver")
19+
}.tag(SettingsTab.troubleshooting)
1620
}.frame(width: 600)
1721
.frame(maxHeight: 500)
1822
.scrollContentBackground(.hidden)
@@ -23,4 +27,5 @@ struct SettingsView<VPN: VPNService>: View {
2327
enum SettingsTab: Int {
2428
case general
2529
case network
30+
case troubleshooting
2631
}
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
import SwiftUI
2+
3+
struct TroubleshootingTab<VPN: VPNService>: View {
4+
@EnvironmentObject private var vpn: VPN
5+
@State private var isProcessing = false
6+
@State private var showUninstallAlert = false
7+
@State private var showToggleAlert = false
8+
@State private var systemExtensionError: String?
9+
@State private var networkExtensionError: String?
10+
11+
var body: some View {
12+
Form {
13+
Section(header: Text("System Extension")) {
14+
// Only show install/uninstall buttons here
15+
installOrUninstallButton
16+
17+
if let error = systemExtensionError {
18+
Text(error)
19+
.foregroundColor(.red)
20+
.font(.caption)
21+
}
22+
23+
// Display current extension status
24+
HStack {
25+
Text("Status:")
26+
Spacer()
27+
statusView
28+
}
29+
}
30+
31+
Section(
32+
header: Text("Advanced Information"),
33+
footer: Text("These options are for troubleshooting only. Do not modify unless instructed by support.")
34+
) {
35+
// Show enable/disable button here
36+
if case .installed = vpn.sysExtnState {
37+
enableOrDisableButton
38+
39+
if let error = networkExtensionError {
40+
Text(error)
41+
.foregroundColor(.red)
42+
.font(.caption)
43+
}
44+
}
45+
46+
// Display network extension status
47+
HStack {
48+
Text("Network Extension:")
49+
Spacer()
50+
networkStatusView
51+
}
52+
}
53+
}.formStyle(.grouped)
54+
}
55+
56+
@ViewBuilder
57+
private var statusView: some View {
58+
switch vpn.sysExtnState {
59+
case .installed:
60+
Text("Installed")
61+
.foregroundColor(.green)
62+
case .uninstalled:
63+
Text("Not Installed")
64+
.foregroundColor(.secondary)
65+
case .needsUserApproval:
66+
Text("Needs Approval")
67+
.foregroundColor(.orange)
68+
case let .failed(message):
69+
Text("Failed: \(message)")
70+
.foregroundColor(.red)
71+
.lineLimit(1)
72+
}
73+
}
74+
75+
@ViewBuilder
76+
private var networkStatusView: some View {
77+
switch vpn.neState {
78+
case .enabled:
79+
Text("Enabled")
80+
.foregroundColor(.green)
81+
case .disabled:
82+
Text("Disabled")
83+
.foregroundColor(.orange)
84+
case .unconfigured:
85+
Text("Not Configured")
86+
.foregroundColor(.secondary)
87+
case let .failed(message):
88+
Text("Failed: \(message)")
89+
.foregroundColor(.red)
90+
.lineLimit(1)
91+
}
92+
}
93+
94+
@ViewBuilder
95+
private var installOrUninstallButton: some View {
96+
if case .installed = vpn.sysExtnState {
97+
// Uninstall button
98+
Button {
99+
showUninstallAlert = true
100+
} label: {
101+
HStack {
102+
Image(systemName: "xmark.circle")
103+
.foregroundColor(.red)
104+
Text("Uninstall Network Extension")
105+
Spacer()
106+
if isProcessing, showUninstallAlert {
107+
ProgressView()
108+
.controlSize(.small)
109+
}
110+
}
111+
}
112+
.disabled(isProcessing)
113+
.alert(isPresented: $showUninstallAlert) {
114+
Alert(
115+
title: Text("Uninstall Network Extension"),
116+
message: Text("This will completely uninstall the VPN system extension. " +
117+
"You will need to reinstall it to use the VPN again."),
118+
primaryButton: .destructive(Text("Uninstall")) {
119+
performUninstall()
120+
},
121+
secondaryButton: .cancel()
122+
)
123+
}
124+
} else {
125+
// Show install button when extension is not installed
126+
Button {
127+
performInstall()
128+
} label: {
129+
HStack {
130+
Image(systemName: "arrow.down.circle")
131+
.foregroundColor(.blue)
132+
Text("Install Network Extension")
133+
Spacer()
134+
if isProcessing {
135+
ProgressView()
136+
.controlSize(.small)
137+
}
138+
}
139+
}
140+
.disabled(isProcessing)
141+
}
142+
}
143+
144+
@ViewBuilder
145+
private var enableOrDisableButton: some View {
146+
Button {
147+
showToggleAlert = true
148+
} label: {
149+
HStack {
150+
Image(systemName: vpn.neState == .enabled ? "pause.circle" : "play.circle")
151+
.foregroundColor(vpn.neState == .enabled ? .orange : .green)
152+
Text(vpn.neState == .enabled ? "Disable Network Extension" : "Enable Network Extension")
153+
Spacer()
154+
if isProcessing, showToggleAlert {
155+
ProgressView()
156+
.controlSize(.small)
157+
}
158+
}
159+
}
160+
.disabled(isProcessing)
161+
.alert(isPresented: $showToggleAlert) {
162+
if vpn.neState == .enabled {
163+
Alert(
164+
title: Text("Disable Network Extension"),
165+
message: Text("This will stop the VPN service but keep the system extension " +
166+
"installed. You can enable it again later."),
167+
primaryButton: .default(Text("Disable")) {
168+
performDisable()
169+
},
170+
secondaryButton: .cancel()
171+
)
172+
} else {
173+
Alert(
174+
title: Text("Enable Network Extension"),
175+
message: Text("This will enable the network extension to allow VPN connections."),
176+
primaryButton: .default(Text("Enable")) {
177+
performEnable()
178+
},
179+
secondaryButton: .cancel()
180+
)
181+
}
182+
}
183+
}
184+
185+
private func performUninstall() {
186+
isProcessing = true
187+
systemExtensionError = nil
188+
networkExtensionError = nil
189+
190+
Task {
191+
let success = await vpn.uninstall()
192+
isProcessing = false
193+
194+
if !success {
195+
systemExtensionError = "Failed to uninstall network extension. Check logs for details."
196+
}
197+
}
198+
}
199+
200+
private func performInstall() {
201+
isProcessing = true
202+
systemExtensionError = nil
203+
networkExtensionError = nil
204+
205+
Task {
206+
await vpn.installExtension()
207+
isProcessing = false
208+
209+
// Check if installation failed
210+
if case let .failed(message) = vpn.sysExtnState {
211+
systemExtensionError = "Failed to install: \(message)"
212+
}
213+
}
214+
}
215+
216+
private func performDisable() {
217+
isProcessing = true
218+
systemExtensionError = nil
219+
networkExtensionError = nil
220+
221+
Task {
222+
let success = await vpn.disableExtension()
223+
isProcessing = false
224+
225+
if !success {
226+
networkExtensionError = "Failed to disable network extension. Check logs for details."
227+
}
228+
}
229+
}
230+
231+
private func performEnable() {
232+
isProcessing = true
233+
systemExtensionError = nil
234+
networkExtensionError = nil
235+
236+
Task {
237+
let success = await vpn.enableExtension()
238+
isProcessing = false
239+
240+
if !success {
241+
networkExtensionError = "Failed to enable network extension. Check logs for details."
242+
}
243+
}
244+
}
245+
}
246+
247+
#Preview("Extension Installed") {
248+
TroubleshootingTab<PreviewVPN>()
249+
.environmentObject(AppState())
250+
.environmentObject(PreviewVPN(extensionInstalled: true, networkExtensionEnabled: true))
251+
}
252+
253+
#Preview("Extension Installed, NE Disabled") {
254+
TroubleshootingTab<PreviewVPN>()
255+
.environmentObject(AppState())
256+
.environmentObject(PreviewVPN(extensionInstalled: true, networkExtensionEnabled: false))
257+
}
258+
259+
#Preview("Extension Not Installed") {
260+
TroubleshootingTab<PreviewVPN>()
261+
.environmentObject(AppState())
262+
.environmentObject(PreviewVPN(extensionInstalled: false))
263+
}
264+
265+
#Preview("Extension Failed") {
266+
TroubleshootingTab<PreviewVPN>()
267+
.environmentObject(AppState())
268+
.environmentObject(PreviewVPN(shouldFail: true))
269+
}

‎Coder Desktop/Coder DesktopTests/Util.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ class MockVPNService: VPNService, ObservableObject {
99
@Published var state: Coder_Desktop.VPNServiceState = .disabled
1010
@Published var baseAccessURL: URL = .init(string: "https://dev.coder.com")!
1111
@Published var menuState: VPNMenuState = .init()
12+
@Published var sysExtnState: SystemExtensionState = .installed
13+
@Published var neState: NetworkExtensionState = .enabled
1214
var onStart: (() async -> Void)?
1315
var onStop: (() async -> Void)?
1416

@@ -23,6 +25,20 @@ class MockVPNService: VPNService, ObservableObject {
2325
}
2426

2527
func configureTunnelProviderProtocol(proto _: NETunnelProviderProtocol?) {}
28+
29+
func uninstall() async -> Bool {
30+
true
31+
}
32+
33+
func installExtension() async {}
34+
35+
func disableExtension() async -> Bool {
36+
true
37+
}
38+
39+
func enableExtension() async -> Bool {
40+
true
41+
}
2642
}
2743

2844
extension Inspection: @unchecked Sendable, @retroactive InspectionEmissary {}

0 commit comments

Comments
 (0)
Please sign in to comment.