121 lines
3.9 KiB
Swift
121 lines
3.9 KiB
Swift
#if canImport(AppKit)
|
|
import AppKit
|
|
|
|
/// A lightweight wrapper around `NSStatusItem` that manages a macOS menu bar icon.
|
|
///
|
|
/// Supports two modes:
|
|
/// - **Click mode**: A single click on the icon triggers a closure.
|
|
/// - **Menu mode**: Clicking the icon opens a dropdown `NSMenu`.
|
|
@MainActor
|
|
public final class MenuBarAgent: NSObject {
|
|
|
|
private var statusItem: NSStatusItem?
|
|
private let icon: MenuBarIcon
|
|
private var trampolines: [MenuItemTrampoline] = []
|
|
/// Keeps the previous generation of trampolines alive so that menu items
|
|
/// from a still-displayed menu can still dispatch their actions.
|
|
private var retiredTrampolines: [MenuItemTrampoline] = []
|
|
|
|
private enum Mode {
|
|
case click(() -> Void)
|
|
case menu([MenuBarItem])
|
|
}
|
|
|
|
private let mode: Mode
|
|
|
|
// MARK: - Initializers
|
|
|
|
/// Creates an agent in **click mode**: a single click on the status item triggers `clickAction`.
|
|
public init(icon: MenuBarIcon, clickAction: @escaping () -> Void) {
|
|
self.icon = icon
|
|
self.mode = .click(clickAction)
|
|
super.init()
|
|
}
|
|
|
|
/// Creates an agent in **menu mode**: clicking the status item opens a dropdown built from `menu`.
|
|
public init(icon: MenuBarIcon, menu: [MenuBarItem]) {
|
|
self.icon = icon
|
|
self.mode = .menu(menu)
|
|
super.init()
|
|
}
|
|
|
|
// MARK: - Lifecycle
|
|
|
|
/// Installs the status item into the system menu bar and optionally sets the activation policy.
|
|
///
|
|
/// - Parameter activationPolicy: The application activation policy. Defaults to `.accessory`
|
|
/// (no Dock icon, no main menu).
|
|
public func install(activationPolicy: NSApplication.ActivationPolicy = .accessory) {
|
|
NSApplication.shared.setActivationPolicy(activationPolicy)
|
|
|
|
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
|
|
item.button?.image = icon.resolve()
|
|
|
|
switch mode {
|
|
case .click(let handler):
|
|
let trampoline = MenuItemTrampoline(handler: handler)
|
|
trampolines.append(trampoline)
|
|
item.button?.target = trampoline
|
|
item.button?.action = #selector(MenuItemTrampoline.invoke(_:))
|
|
|
|
case .menu(let items):
|
|
item.menu = buildMenu(from: items)
|
|
}
|
|
|
|
statusItem = item
|
|
}
|
|
|
|
/// Removes the status item from the menu bar and releases associated resources.
|
|
public func uninstall() {
|
|
if let item = statusItem {
|
|
NSStatusBar.system.removeStatusItem(item)
|
|
}
|
|
statusItem = nil
|
|
trampolines.removeAll()
|
|
retiredTrampolines.removeAll()
|
|
}
|
|
|
|
/// Replaces the current icon with `newIcon`.
|
|
public func updateIcon(_ newIcon: MenuBarIcon) {
|
|
statusItem?.button?.image = newIcon.resolve()
|
|
}
|
|
|
|
/// Replaces the menu items on an already-installed status item.
|
|
/// This preserves the `NSStatusItem` so that the menu bar icon doesn't flicker
|
|
/// and action targets remain valid while the menu is open.
|
|
public func updateMenu(_ items: [MenuBarItem]) {
|
|
retiredTrampolines = trampolines
|
|
trampolines = []
|
|
statusItem?.menu = buildMenu(from: items)
|
|
}
|
|
|
|
// MARK: - Private
|
|
|
|
private func buildMenu(from items: [MenuBarItem]) -> NSMenu {
|
|
let menu = NSMenu()
|
|
menu.autoenablesItems = false
|
|
|
|
for item in items {
|
|
switch item {
|
|
case .action(let title, let key, let handler):
|
|
let trampoline = MenuItemTrampoline(handler: handler)
|
|
trampolines.append(trampoline)
|
|
|
|
let menuItem = NSMenuItem(
|
|
title: title,
|
|
action: #selector(MenuItemTrampoline.invoke(_:)),
|
|
keyEquivalent: key
|
|
)
|
|
menuItem.target = trampoline
|
|
menuItem.isEnabled = true
|
|
menu.addItem(menuItem)
|
|
|
|
case .separator:
|
|
menu.addItem(.separator())
|
|
}
|
|
}
|
|
|
|
return menu
|
|
}
|
|
}
|
|
#endif
|