Skip to content

Commit 960cacf

Browse files
committed
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 <[email protected]> Change-Id: Id8327b1c9cd4cc2c4946edd0c8e93cab9a005315 Signed-off-by: Thomas Kosiewski <[email protected]>
1 parent b7ccbca commit 960cacf

File tree

10 files changed

+693
-6
lines changed

10 files changed

+693
-6
lines changed

CLAUDE.md

+22
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

+25
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

+70-1
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

+1-1
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

+115
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

0 commit comments

Comments
 (0)