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..2cf10208 100644 --- a/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift +++ b/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift @@ -61,11 +61,30 @@ final class CoderVPNService: NSObject, VPNService { if tunnelState == .connecting { progress = .init(stage: .initial, downloadProgress: nil) } + if case let .failed(tunnelError) = tunnelState, tunnelState != oldValue, + tunnelError != .networkExtensionError(.unconfigured) + { + onFailure?(tunnelError) + } + } + } + + @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)) + } } } - @Published var sysExtnState: SystemExtensionState = .uninstalled - @Published var neState: NetworkExtensionState = .unconfigured var state: VPNServiceState { guard sysExtnState == .installed else { return .failed(.systemExtensionError(sysExtnState)) @@ -87,6 +106,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