Skip to content
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

feat: add troubleshooting tab and improve extension management #105

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
22 changes: 22 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Coder Desktop Development Guide

## Build & Test Commands
- Build Xcode project: `make`
- Format Swift files: `make fmt`
- Lint Swift files: `make lint`
- Run all tests: `make test`
- Run specific test class: `xcodebuild test -project "Coder Desktop/Coder Desktop.xcodeproj" -scheme "Coder Desktop" -only-testing:"Coder DesktopTests/AgentsTests"`
- Run specific test method: `xcodebuild test -project "Coder Desktop/Coder Desktop.xcodeproj" -scheme "Coder Desktop" -only-testing:"Coder DesktopTests/AgentsTests/agentsWhenVPNOff"`
- Generate Swift from proto: `make proto`
- Watch for project changes: `make watch-gen`

## Code Style Guidelines
- Use Swift 6.0 for development
- Follow SwiftFormat and SwiftLint rules
- Use Swift's Testing framework for tests (`@Test`, `#expect` directives)
- Group files logically (Views, Models, Extensions)
- Use environment objects for dependency injection
- Prefer async/await over completion handlers
- Use clear, descriptive naming for functions and variables
- Implement proper error handling with Swift's throwing functions
- Tests should use descriptive names reflecting what they're testing
6 changes: 6 additions & 0 deletions Coder Desktop/Coder Desktop/Coder_DesktopApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,9 @@ extension AppDelegate {
func appActivate() {
NSApp.activate()
}

extension NSApplication {
@objc func showLoginWindow() {
NSApp.sendAction(#selector(NSWindowController.showWindow(_:)), to: nil, from: Windows.login.rawValue)
}
}
Comment on lines +92 to +96
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why would we use this over @Environment(\.openWindow)?

71 changes: 70 additions & 1 deletion Coder Desktop/Coder Desktop/Preview Content/PreviewVPN.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,15 @@ final class PreviewVPN: Coder_Desktop.VPNService {
UUID(): Agent(id: UUID(), name: "dev", status: .off, hosts: ["asdf.coder"], wsName: "example",
wsID: UUID()),
], workspaces: [:])
@Published var sysExtnState: SystemExtensionState = .installed
@Published var neState: NetworkExtensionState = .enabled
let shouldFail: Bool
let longError = "This is a long error to test the UI with long error messages"

init(shouldFail: Bool = false) {
init(shouldFail: Bool = false, extensionInstalled: Bool = true, networkExtensionEnabled: Bool = true) {
self.shouldFail = shouldFail
sysExtnState = extensionInstalled ? .installed : .uninstalled
neState = networkExtensionEnabled ? .enabled : .disabled
}

var startTask: Task<Void, Never>?
Expand Down Expand Up @@ -78,4 +82,69 @@ final class PreviewVPN: Coder_Desktop.VPNService {
func configureTunnelProviderProtocol(proto _: NETunnelProviderProtocol?) {
state = .connecting
}

func uninstall() async -> Bool {
// Simulate uninstallation with a delay
do {
try await Task.sleep(for: .seconds(2))
} catch {
return false
}

if !shouldFail {
sysExtnState = .uninstalled
return true
}
return false
}

func installExtension() async {
// Simulate installation with a delay
do {
try await Task.sleep(for: .seconds(2))
sysExtnState = if !shouldFail {
.installed
} else {
.failed("Failed to install extension")
}
} catch {
sysExtnState = .failed("Installation was interrupted")
}
}

func disableExtension() async -> Bool {
// Simulate disabling with a delay
do {
try await Task.sleep(for: .seconds(1))
} catch {
return false
}

if !shouldFail {
neState = .disabled
state = .disabled
return true
} else {
neState = .failed("Failed to disable network extension")
return false
}
}

func enableExtension() async -> Bool {
// Simulate enabling with a delay
do {
try await Task.sleep(for: .seconds(1))
} catch {
return false
}

if !shouldFail {
neState = .enabled
state = .disabled // Just disabled, not connected yet
return true
} else {
neState = .failed("Failed to enable network extension")
return false
}
}
}
71 changes: 71 additions & 0 deletions Coder Desktop/Coder Desktop/SystemExtension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,77 @@ extension CoderVPNService: SystemExtensionAsyncRecorder {
OSSystemExtensionManager.shared.submitRequest(request)
logger.info("submitted SystemExtension request with bundleID: \(bundleID)")
}

func deregisterSystemExtension() async -> Bool {
logger.info("Starting network extension deregistration...")

// Use the existing delegate pattern
let result = await withCheckedContinuation { (continuation: CheckedContinuation<Bool, Never>) in
// Extension bundle identifier - must match what's used in the app
guard let bundleID = extensionBundle.bundleIdentifier else {
logger.error("Bundle has no identifier")
continuation.resume(returning: false)
return
}

// Set a temporary state for deregistration
sysExtnState = .uninstalled

// Create a special delegate that will handle the deregistration and resolve the continuation
let delegate = SystemExtensionDelegate(asyncDelegate: self)
systemExtnDelegate = delegate

// Create the deactivation request
let request = OSSystemExtensionRequest.deactivationRequest(
forExtensionWithIdentifier: bundleID,
queue: .main
)
request.delegate = delegate

// Start a timeout task
Task {
// Allow up to 30 seconds for deregistration
try? await Task.sleep(for: .seconds(30))

// If we're still waiting after timeout, consider it failed
if case .uninstalled = self.sysExtnState {
// Only update if still in uninstalled state (meaning callback never updated it)
self.sysExtnState = .failed("Deregistration timed out")
continuation.resume(returning: false)
}
}

// Submit the request and wait for the delegate to handle completion
OSSystemExtensionManager.shared.submitRequest(request)
logger.info("Submitted system extension deregistration request for \(bundleID)")

// The SystemExtensionDelegate will update our state via recordSystemExtensionState
// We'll monitor this in another task to resolve the continuation
Task {
// Check every 100ms for state changes
for _ in 0 ..< 300 { // 30 seconds max
// If state changed from uninstalled, the delegate has processed the result
if case .installed = self.sysExtnState {
// This should never happen during deregistration
continuation.resume(returning: false)
break
} else if case .failed = self.sysExtnState {
// Failed state was set by delegate
continuation.resume(returning: false)
break
} else if case .uninstalled = self.sysExtnState, self.systemExtnDelegate == nil {
// Uninstalled AND delegate is nil means success (delegate cleared itself)
continuation.resume(returning: true)
break
}

try? await Task.sleep(for: .milliseconds(100))
}
}
}

return result
}
}

/// A delegate for the OSSystemExtensionRequest that maps the callbacks to async calls on the
Expand Down
179 changes: 179 additions & 0 deletions Coder Desktop/Coder Desktop/VPNService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,15 @@
protocol VPNService: ObservableObject {
var state: VPNServiceState { get }
var menuState: VPNMenuState { get }
var sysExtnState: SystemExtensionState { get }
var neState: NetworkExtensionState { get }
Comment on lines +10 to +11
Copy link
Member

@ethanndickson ethanndickson Mar 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should expose these, I think it's sufficient to rely on specific VPNServiceStates.

EDIT: It's probably better to, actually, but I wish we didn't :/

func start() async
func stop() async
func configureTunnelProviderProtocol(proto: NETunnelProviderProtocol?)
func uninstall() async -> Bool
func installExtension() async
func disableExtension() async -> Bool
func enableExtension() async -> Bool
}

enum VPNServiceState: Equatable {
Expand Down Expand Up @@ -114,6 +120,179 @@
}
}

func uninstall() async -> Bool {
logger.info("Uninstalling VPN system extension...")

// First stop any active VPN tunnels
if tunnelState == .connected || tunnelState == .connecting {
await stop()

// Wait for tunnel state to actually change to disabled
let startTime = Date()
let timeout = TimeInterval(10) // 10 seconds timeout

while tunnelState != .disabled {
// Check for timeout
if Date().timeIntervalSince(startTime) > timeout {
logger.warning("Timeout waiting for VPN to disconnect before uninstall")
break
}

// Wait a bit before checking again
try? await Task.sleep(for: .milliseconds(100))
}
}

// Remove network extension configuration
do {
try await removeNetworkExtension()
neState = .unconfigured
tunnelState = .disabled
} catch {
logger.error("Failed to remove network extension configuration: \(error.localizedDescription)")
// Continue with deregistration even if removing network extension failed
}

// Deregister the system extension
let success = await deregisterSystemExtension()
if success {
logger.info("Successfully uninstalled VPN system extension")
sysExtnState = .uninstalled
} else {
logger.error("Failed to uninstall VPN system extension")
sysExtnState = .failed("Deregistration failed")
}

return success
}

func installExtension() async {
logger.info("Installing VPN system extension...")

// Install the system extension
installSystemExtension()

// We don't need to await here since the installSystemExtension method
// uses a delegate callback system to update the state
}

func disableExtension() async -> Bool {
logger.info("Disabling VPN network extension without uninstalling...")

// First stop any active VPN tunnel
if tunnelState == .connected || tunnelState == .connecting {
await stop()
}

// Remove network extension configuration but keep the system extension
do {
try await removeNetworkExtension()
neState = .unconfigured
tunnelState = .disabled
logger.info("Successfully disabled network extension")
return true
} catch {
logger.error("Failed to disable network extension: \(error.localizedDescription)")
neState = .failed(error.localizedDescription)
return false
}
}

func enableExtension() async -> Bool {
logger.info("Enabling VPN network extension...")

// Ensure system extension is installed
let extensionInstalled = await ensureSystemExtensionInstalled()
if !extensionInstalled {
return false
}

// Get the initial state for comparison
let initialNeState = neState

// Directly inject AppState dependency to call reconfigure
if let appState = (NSApp.delegate as? AppDelegate)?.state, appState.hasSession {
appState.reconfigure()
} else {
// No valid session, the user likely needs to log in again
await MainActor.run {

Check warning on line 218 in Coder Desktop/Coder Desktop/VPNService.swift

View workflow job for this annotation

GitHub Actions / test

result of call to 'run(resultType:body:)' is unused
NSApp.sendAction(#selector(NSApplication.showLoginWindow), to: nil, from: nil)
}
}

// Wait for network extension state to change
let stateChanged = await waitForNetworkExtensionChange(from: initialNeState)
if !stateChanged {
return false
}

logger.info("Network extension was reconfigured successfully")

// Try to connect to VPN if needed
return await tryConnectAfterReconfiguration()
}

private func ensureSystemExtensionInstalled() async -> Bool {
if sysExtnState != .installed {
installSystemExtension()
// Wait for the system extension to be installed
for _ in 0 ..< 30 { // Wait up to 3 seconds
if sysExtnState == .installed {
break
}
try? await Task.sleep(for: .milliseconds(100))
}

if sysExtnState != .installed {
logger.error("Failed to install system extension during enableExtension")
return false
}
}
return true
}

private func waitForNetworkExtensionChange(from initialState: NetworkExtensionState) async -> Bool {
// Wait for network extension state to change from the initial state
for _ in 0 ..< 30 { // Wait up to 3 seconds
// If the state changes at all from the initial state, we consider reconfiguration successful
if neState != initialState || neState == .enabled {
return true
}
try? await Task.sleep(for: .milliseconds(100))
}

logger.error("Network extension configuration didn't change after reconfiguration request")
return false
}

private func tryConnectAfterReconfiguration() async -> Bool {
// If already enabled, we're done
if neState == .enabled {
logger.info("Network extension enabled successfully")
return true
}

// Wait a bit longer for the configuration to be fully applied
try? await Task.sleep(for: .milliseconds(500))

// If the extension is in a state we can work with, try to start the VPN
if case .failed = neState {
logger.error("Network extension in failed state, skipping auto-connection")
} else if neState != .unconfigured {
logger.info("Attempting to automatically connect to VPN after reconfiguration")
await start()

if tunnelState == .connecting || tunnelState == .connected {
logger.info("VPN connection started successfully after reconfiguration")
return true
}
}

// If we get here, the extension was reconfigured but not successfully enabled
// Since configuration was successful, return true so user can manually connect
return true
}

func onExtensionPeerUpdate(_ data: Data) {
logger.info("network extension peer update")
do {
Expand Down
Loading
Loading