Skip to content

feat: animate menu bar icon with vpn state #72

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

Merged
merged 2 commits into from
Feb 20, 2025
Merged
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
24 changes: 21 additions & 3 deletions Coder Desktop/Coder Desktop/Coder_DesktopApp.swift
Original file line number Diff line number Diff line change
@@ -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<CoderVPNService>().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()
57 changes: 57 additions & 0 deletions Coder Desktop/Coder Desktop/MenuBarIconController.swift
Original file line number Diff line number Diff line change
@@ -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<Void, Never>?

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
}
}
14 changes: 1 addition & 13 deletions Coder Desktop/Coder Desktop/VPNService.swift
Original file line number Diff line number Diff line change
@@ -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):
6 changes: 4 additions & 2 deletions Coder Desktop/project.yml
Original file line number Diff line number Diff line change
@@ -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