diff --git a/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift b/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift index ae50519..13f7086 100644 --- a/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift +++ b/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift @@ -1,4 +1,5 @@ import FluidMenuBarExtra +import NetworkExtension import SwiftUI @main @@ -26,7 +27,7 @@ struct DesktopApp: App { @MainActor class AppDelegate: NSObject, NSApplicationDelegate { - private var menuBarExtra: FluidMenuBarExtra? + private var menuBar: MenuBarController? let vpn: CoderVPNService let state: AppState @@ -36,11 +37,18 @@ class AppDelegate: NSObject, NSApplicationDelegate { } func applicationDidFinishLaunching(_: Notification) { - menuBarExtra = FluidMenuBarExtra(title: "Coder Desktop", image: "MenuBarIcon") { + menuBar = .init(menuBarExtra: FluidMenuBarExtra(title: "Coder Desktop", image: "MenuBarIcon") { VPNMenu().frame(width: 256) .environmentObject(self.vpn) .environmentObject(self.state) - } + }) + // Subscribe to system VPN updates + NotificationCenter.default.addObserver( + self, + selector: #selector(vpnDidUpdate(_:)), + name: .NEVPNStatusDidChange, + object: nil + ) } // This function MUST eventually call `NSApp.reply(toApplicationShouldTerminate: true)` @@ -59,6 +67,16 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } +extension AppDelegate { + @objc private func vpnDidUpdate(_ notification: Notification) { + guard let connection = notification.object as? NETunnelProviderSession else { + return + } + vpn.vpnDidUpdate(connection) + menuBar?.vpnDidUpdate(connection) + } +} + @MainActor func appActivate() { NSApp.activate() diff --git a/Coder Desktop/Coder Desktop/MenuBarIconController.swift b/Coder Desktop/Coder Desktop/MenuBarIconController.swift new file mode 100644 index 0000000..867e183 --- /dev/null +++ b/Coder Desktop/Coder Desktop/MenuBarIconController.swift @@ -0,0 +1,57 @@ +import FluidMenuBarExtra +import NetworkExtension +import SwiftUI + +@MainActor +class MenuBarController { + let menuBarExtra: FluidMenuBarExtra + private let onImage = NSImage(named: "MenuBarIcon")! + private let offOpacity = CGFloat(0.3) + private let onOpacity = CGFloat(1.0) + + private var animationTask: Task? + + init(menuBarExtra: FluidMenuBarExtra) { + self.menuBarExtra = menuBarExtra + } + + func vpnDidUpdate(_ connection: NETunnelProviderSession) { + switch connection.status { + case .connected: + stopAnimation() + menuBarExtra.setOpacity(onOpacity) + case .connecting, .reasserting, .disconnecting: + startAnimation() + case .invalid, .disconnected: + stopAnimation() + menuBarExtra.setOpacity(offOpacity) + @unknown default: + stopAnimation() + menuBarExtra.setOpacity(offOpacity) + } + } + + func startAnimation() { + if animationTask != nil { return } + animationTask = Task { + defer { animationTask = nil } + let totalFrames = 60 + let cycleDurationMs: UInt64 = 2000 + let frameDurationMs = cycleDurationMs / UInt64(totalFrames - 1) + repeat { + for frame in 0 ..< totalFrames { + if Task.isCancelled { break } + let progress = Double(frame) / Double(totalFrames - 1) + let alpha = 0.3 + 0.7 * (0.5 - 0.5 * cos(2 * Double.pi * progress)) + menuBarExtra.setOpacity(CGFloat(alpha)) + try? await Task.sleep(for: .milliseconds(frameDurationMs)) + } + } while !Task.isCancelled + } + } + + func stopAnimation() { + animationTask?.cancel() + animationTask = nil + } +} diff --git a/Coder Desktop/Coder Desktop/VPNService.swift b/Coder Desktop/Coder Desktop/VPNService.swift index 793b0eb..1e29ae7 100644 --- a/Coder Desktop/Coder Desktop/VPNService.swift +++ b/Coder Desktop/Coder Desktop/VPNService.swift @@ -70,12 +70,6 @@ final class CoderVPNService: NSObject, VPNService { Task { await loadNetworkExtensionConfig() } - NotificationCenter.default.addObserver( - self, - selector: #selector(vpnDidUpdate(_:)), - name: .NEVPNStatusDidChange, - object: nil - ) } deinit { @@ -159,13 +153,7 @@ final class CoderVPNService: NSObject, VPNService { } extension CoderVPNService { - // The number of NETunnelProviderSession states makes the excessive branching - // necessary. - // swiftlint:disable:next cyclomatic_complexity - @objc private func vpnDidUpdate(_ notification: Notification) { - guard let connection = notification.object as? NETunnelProviderSession else { - return - } + public func vpnDidUpdate(_ connection: NETunnelProviderSession) { switch (tunnelState, connection.status) { // Any -> Disconnected: Update UI w/ error if present case (_, .disconnected): diff --git a/Coder Desktop/project.yml b/Coder Desktop/project.yml index 8b9b18f..2872515 100644 --- a/Coder Desktop/project.yml +++ b/Coder Desktop/project.yml @@ -89,8 +89,10 @@ packages: url: https://github.com/SimplyDanny/SwiftLintPlugins from: 0.57.1 FluidMenuBarExtra: - url: https://github.com/lfroms/fluid-menu-bar-extra - from: 1.1.0 + # Forked so we can dynamically update the menu bar icon. + # The upstream repo has a purposefully limited API + url: https://github.com/coder/fluid-menu-bar-extra + revision: 020be37 KeychainAccess: url: https://github.com/kishikawakatsumi/KeychainAccess branch: e0c7eebc5a4465a3c4680764f26b7a61f567cdaf