From 8d6d74a07f587d4fb6c0878b8ddb5d139aaf91df Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 8 Sep 2026 10:59:08 +1000 Subject: [PATCH 1/3] feat: send push notifications on VPN failures Relates to #195. Supersedes #196. When Coder Connect enters a failed state, the app now sends a push notification with the same error message that the menu shows. Clicking the notification opens the menu. `CoderVPNService` exposes an `onFailure` callback next to the existing `onStart`, fired from `tunnelState.didSet` on each transition into `.failed`. `AppDelegate` wires it to `sendNotification`, so the service stays free of UI code. --- .../Coder-Desktop/Coder_DesktopApp.swift | 15 ++++++++++++++- .../Coder-Desktop/Notifications.swift | 19 +++++++++++++++++++ .../Coder-Desktop/VPN/VPNService.swift | 4 ++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift b/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift index 79f653e3..13609a95 100644 --- a/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift +++ b/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift @@ -44,7 +44,7 @@ struct DesktopApp: App { @MainActor class AppDelegate: NSObject, NSApplicationDelegate { private var logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "app-delegate") - private var menuBar: MenuBarController? + var menuBar: MenuBarController? let vpn: CoderVPNService let state: AppState let fileSyncDaemon: MutagenDaemon @@ -81,6 +81,19 @@ class AppDelegate: NSObject, NSApplicationDelegate { super.init() // `delegate` is weak UNUserNotificationCenter.current().delegate = self + vpn.onFailure = { [logger] tunnelError in + Task { + do { + try await sendNotification( + title: "Coder Connect has failed!", + body: tunnelError.description, + category: .vpnFailure + ) + } catch let notifError { + logger.error("Failed to send notification (\(tunnelError.description)): \(notifError)") + } + } + } } func applicationDidFinishLaunching(_: Notification) { diff --git a/Coder-Desktop/Coder-Desktop/Notifications.swift b/Coder-Desktop/Coder-Desktop/Notifications.swift index 3ddf8c6e..041cc363 100644 --- a/Coder-Desktop/Coder-Desktop/Notifications.swift +++ b/Coder-Desktop/Coder-Desktop/Notifications.swift @@ -17,6 +17,24 @@ extension AppDelegate: UNUserNotificationCenterDelegate { ) async -> UNNotificationPresentationOptions { [.banner] } + + nonisolated func userNotificationCenter( + _: UNUserNotificationCenter, + didReceive response: UNNotificationResponse + ) async { + let category = response.notification.request.content.categoryIdentifier + let action = response.actionIdentifier + switch (category, action) { + case (NotificationCategory.vpnFailure.rawValue, UNNotificationDefaultActionIdentifier): + await showMenuBarWindow() + default: + break + } + } + + private func showMenuBarWindow() { + menuBar?.menuBarExtra.toggleVisibility() + } } func sendNotification(title: String, body: String, category: NotificationCategory) async throws { @@ -33,5 +51,6 @@ func sendNotification(title: String, body: String, category: NotificationCategor } enum NotificationCategory: String, CaseIterable { + case vpnFailure = "VPN_FAILURE" case uriFailure = "URI_FAILURE" } diff --git a/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift b/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift index 9da39d5b..992ef304 100644 --- a/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift +++ b/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift @@ -61,6 +61,9 @@ final class CoderVPNService: NSObject, VPNService { if tunnelState == .connecting { progress = .init(stage: .initial, downloadProgress: nil) } + if case let .failed(tunnelError) = tunnelState, tunnelState != oldValue { + onFailure?(tunnelError) + } } } @@ -87,6 +90,7 @@ final class CoderVPNService: NSObject, VPNService { // Whether the VPN should start as soon as possible var startWhenReady: Bool = false var onStart: (() -> Void)? + var onFailure: ((VPNServiceError) -> Void)? // systemExtnDelegate holds a reference to the SystemExtensionDelegate so that it doesn't get // garbage collected while the OSSystemExtensionRequest is in flight, since the OS framework From 3dd2858cf32f06b4c5f3fa8c52766d2ead9f366b Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 8 Sep 2026 12:15:10 +1000 Subject: [PATCH 2/3] fix: notify on network and system extension failures --- .../Coder-Desktop/VPN/VPNService.swift | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift b/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift index 992ef304..4d2aa37e 100644 --- a/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift +++ b/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift @@ -67,8 +67,22 @@ final class CoderVPNService: NSObject, VPNService { } } - @Published var sysExtnState: SystemExtensionState = .uninstalled - @Published var neState: NetworkExtensionState = .unconfigured + @Published var sysExtnState: SystemExtensionState = .uninstalled { + didSet { + if case .failed = sysExtnState, sysExtnState != oldValue { + onFailure?(.systemExtensionError(sysExtnState)) + } + } + } + + @Published var neState: NetworkExtensionState = .unconfigured { + didSet { + if case .failed = neState, neState != oldValue { + onFailure?(.networkExtensionError(neState)) + } + } + } + var state: VPNServiceState { guard sysExtnState == .installed else { return .failed(.systemExtensionError(sysExtnState)) From 2d33e7764eb7a8f133aea29cabe5d51c4df4134a Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 8 Sep 2026 12:28:04 +1000 Subject: [PATCH 3/3] fix: do not notify on unconfigured tunnel state The tunnel reports .invalid while an existing configuration is removed on login, logout, or reconfiguration. The views treat the resulting .unconfigured state as a prompt, not an error, so it should not notify. --- Coder-Desktop/Coder-Desktop/VPN/VPNService.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift b/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift index 4d2aa37e..2cf10208 100644 --- a/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift +++ b/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift @@ -61,7 +61,9 @@ final class CoderVPNService: NSObject, VPNService { if tunnelState == .connecting { progress = .init(stage: .initial, downloadProgress: nil) } - if case let .failed(tunnelError) = tunnelState, tunnelState != oldValue { + if case let .failed(tunnelError) = tunnelState, tunnelState != oldValue, + tunnelError != .networkExtensionError(.unconfigured) + { onFailure?(tunnelError) } }