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
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
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
Expand Down Expand Up @@ -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

Expand All @@ -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)`
Expand All @@ -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()
Expand Down
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
Expand Up @@ -70,12 +70,6 @@ final class CoderVPNService: NSObject, VPNService {
Task {
await loadNetworkExtensionConfig()
}
NotificationCenter.default.addObserver(
self,
selector: #selector(vpnDidUpdate(_:)),
name: .NEVPNStatusDidChange,
object: nil
)
}

deinit {
Expand Down Expand Up @@ -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):
Expand Down
6 changes: 4 additions & 2 deletions Coder Desktop/project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading