Skip to content

Commit fed46bd

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 <noreply@anthropic.com> Change-Id: Id8327b1c9cd4cc2c4946edd0c8e93cab9a005315 Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent b7ccbca commit fed46bd

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

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy