568 lines
21 KiB
Swift
568 lines
21 KiB
Swift
import AppKit
|
|
import Combine
|
|
import SwiftUI
|
|
import MacToolsPluginKit
|
|
|
|
enum MenuBarStatusItemInvocation: Equatable {
|
|
case featurePanel
|
|
case componentPanel
|
|
|
|
static func invocation(
|
|
for event: NSEvent?,
|
|
swapped: Bool = false
|
|
) -> MenuBarStatusItemInvocation {
|
|
// Option+left-click always triggers the right-click action.
|
|
let isSecondary: Bool = {
|
|
guard let event else { return false }
|
|
let isLeftClick = event.type == .leftMouseDown || event.type == .leftMouseUp
|
|
if isLeftClick, event.modifierFlags.contains(.option) {
|
|
return true
|
|
}
|
|
return event.type == .rightMouseDown || event.type == .rightMouseUp
|
|
}()
|
|
|
|
let primary: MenuBarStatusItemInvocation = swapped ? .featurePanel : .componentPanel
|
|
let secondary: MenuBarStatusItemInvocation = swapped ? .componentPanel : .featurePanel
|
|
return isSecondary ? secondary : primary
|
|
}
|
|
|
|
/// Expanded-interface session callbacks carry no NSEvent; Option held at
|
|
/// session begin selects the right-click action.
|
|
static func invocationForExpandedSession(
|
|
swapped: Bool,
|
|
liveModifierFlags: NSEvent.ModifierFlags
|
|
) -> MenuBarStatusItemInvocation {
|
|
let isSecondary = liveModifierFlags.contains(.option)
|
|
let primary: MenuBarStatusItemInvocation = swapped ? .featurePanel : .componentPanel
|
|
let secondary: MenuBarStatusItemInvocation = swapped ? .componentPanel : .featurePanel
|
|
return isSecondary ? secondary : primary
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class MenuBarStatusItemController: NSObject {
|
|
private let pluginHost: PluginHost
|
|
private let windowRouter: AppWindowRouter
|
|
private let iconSettings: MenuBarIconSettings
|
|
private var statusItem: NSStatusItem
|
|
private var panelPresenter: MenuBarPanelPresenter!
|
|
private var cancellables: Set<AnyCancellable> = []
|
|
private var localEventMonitor: Any?
|
|
private var globalEventMonitor: Any?
|
|
private var appActivationObserver: NSObjectProtocol?
|
|
private var appearanceObserver: NSObjectProtocol?
|
|
private var appTerminationObserver: NSObjectProtocol?
|
|
private var statusItemWindowMoveObserver: NSObjectProtocol?
|
|
// The status item holds its expanded-interface delegate weakly; the
|
|
// controller must own the adapter strongly or callbacks silently stop.
|
|
private let expandedInterfaceAdapter = MenuBarStatusItemExpandedInterfaceAdapter()
|
|
private let expandedSessionCoordinator = MenuBarExpandedSessionCoordinator()
|
|
private var animationTimer: DispatchSourceTimer?
|
|
private var animationLoadSampleTimer: Timer?
|
|
private let animationLoadMonitor = MenuBarIconAnimationLoadMonitor()
|
|
private var animationFrames: [NSImage] = []
|
|
private var animationFrameIndex = 0
|
|
private var animationBaseFrameDuration: TimeInterval = 1.0 / MenuBarIconProcessing.animationFramesPerSecond
|
|
private var animationSpeedMode: MenuBarIconAnimationSpeedMode = .manual
|
|
private var manualAnimationSpeedMultiplier: Double = MenuBarIconAnimationSpeedPolicy.defaultManualMultiplier
|
|
private var currentAnimationSystemLoad: MenuBarIconAnimationSystemLoad?
|
|
|
|
init(
|
|
pluginHost: PluginHost,
|
|
windowRouter: AppWindowRouter,
|
|
iconSettings: MenuBarIconSettings
|
|
) {
|
|
self.pluginHost = pluginHost
|
|
self.windowRouter = windowRouter
|
|
self.iconSettings = iconSettings
|
|
MenuBarControlItemDefaults.prepareVisibleControlItem()
|
|
self.statusItem = NSStatusBar.system.statusItem(withLength: 0)
|
|
self.statusItem.autosaveName = MenuBarControlItemDefaults.visibleAutosaveName
|
|
super.init()
|
|
panelPresenter = MenuBarPanelPresenter(
|
|
pluginHost: pluginHost,
|
|
onDismiss: { [weak self] in
|
|
self?.requestPanelClose()
|
|
},
|
|
onOpenSettings: { [weak self] in
|
|
self?.windowRouter.showSettings()
|
|
},
|
|
onPresentDiskCleanConfiguration: { [weak self] in
|
|
self?.pluginHost.presentPluginConfiguration(pluginID: "disk-clean")
|
|
},
|
|
onPresentLaunchControlConfiguration: { [weak self] in
|
|
self?.pluginHost.presentPluginConfiguration(pluginID: "launch-control")
|
|
},
|
|
onAllPanelsClosed: { [weak self] in
|
|
self?.removeDismissMonitorsIfNeeded()
|
|
}
|
|
)
|
|
observeStatusItemPositionPersistence()
|
|
configureStatusItem()
|
|
observePluginHost()
|
|
observeIconSettings()
|
|
updateStatusIcon()
|
|
pluginHost.resetStatusItemPosition = { [weak self] in
|
|
self?.resetStatusItemPosition()
|
|
}
|
|
pluginHost.statusItemButtonFrameProvider = { [weak self] in
|
|
self?.statusItemButtonScreenRect()
|
|
}
|
|
}
|
|
|
|
private func statusItemButtonScreenRect() -> NSRect? {
|
|
guard let button = statusItem.button, let window = button.window else { return nil }
|
|
let frameInWindow = button.convert(button.bounds, to: nil)
|
|
return window.convertToScreen(frameInWindow)
|
|
}
|
|
|
|
deinit {
|
|
MainActor.assumeIsolated {
|
|
animationTimer?.cancel()
|
|
animationLoadSampleTimer?.invalidate()
|
|
if let appearanceObserver {
|
|
DistributedNotificationCenter.default().removeObserver(appearanceObserver)
|
|
}
|
|
if let appTerminationObserver {
|
|
NotificationCenter.default.removeObserver(appTerminationObserver)
|
|
}
|
|
if let statusItemWindowMoveObserver {
|
|
NotificationCenter.default.removeObserver(statusItemWindowMoveObserver)
|
|
}
|
|
}
|
|
}
|
|
|
|
func dismissPanels() {
|
|
panelPresenter.dismissPanels()
|
|
removeDismissMonitorsIfNeeded()
|
|
}
|
|
|
|
/// Single close gate for user-driven dismissals. While an expanded
|
|
/// interface session is active, close through `cancel()` so AppKit owns
|
|
/// the session lifecycle. With no active session this is a plain
|
|
/// `dismissPanels()`.
|
|
private func requestPanelClose() {
|
|
expandedSessionCoordinator.requestClose(
|
|
cancel: { session in
|
|
MenuBarStatusItemExpandedInterfaceAdapter.cancel(session: session)
|
|
},
|
|
directDismiss: { [weak self] in
|
|
self?.dismissPanels()
|
|
}
|
|
)
|
|
}
|
|
|
|
/// Expanded-interface session begin. No NSEvent exists on this path, so
|
|
/// the target panel comes from the click-behavior preference plus the live
|
|
/// keyboard state.
|
|
private func handleExpandedSessionBegin(_ session: NSObject) {
|
|
if let activeSession = expandedSessionCoordinator.activeSession, activeSession !== session {
|
|
MenuBarStatusItemExpandedInterfaceAdapter.cancel(session: activeSession)
|
|
}
|
|
expandedSessionCoordinator.sessionDidBegin(session)
|
|
|
|
if panelPresenter.isAnyPanelShown {
|
|
// A fresh session means the host considers nothing presented, so
|
|
// settle stale panels before reopening.
|
|
panelPresenter.dismissPanels()
|
|
}
|
|
|
|
let swapped = MenuBarClickBehaviorPreference.current().isSwapped
|
|
let invocation = MenuBarStatusItemInvocation.invocationForExpandedSession(
|
|
swapped: swapped,
|
|
liveModifierFlags: NSEvent.modifierFlags
|
|
)
|
|
guard let button = statusItem.button else {
|
|
// Nothing to anchor to. Leaving the session open would make the
|
|
// item permanently inert (no further didBegin until it ends), so
|
|
// close it instead of failing silently.
|
|
AppLog.pluginHost.error(
|
|
"Expanded session began but the status item button is unavailable; cancelling the session"
|
|
)
|
|
requestPanelClose()
|
|
return
|
|
}
|
|
|
|
switch invocation {
|
|
case .featurePanel:
|
|
toggleFeaturePanel(relativeTo: button)
|
|
case .componentPanel:
|
|
toggleComponentPanel(relativeTo: button)
|
|
}
|
|
|
|
if !panelPresenter.isAnyPanelShown {
|
|
// Presentation failed (the desync guard above closed everything,
|
|
// so the toggle can only have tried to open). A session left open
|
|
// with nothing shown keeps the item inert — no further didBegin
|
|
// arrives until the session ends — so cancel it.
|
|
AppLog.pluginHost.error(
|
|
"Expanded session began but no panel was presented; cancelling the session"
|
|
)
|
|
requestPanelClose()
|
|
}
|
|
}
|
|
|
|
/// Expanded-interface session end. Reached synchronously from `cancel()`
|
|
/// inside `requestPanelClose()` (coordinator re-entry guard absorbs the
|
|
/// loop) or directly should the host ever end a session itself.
|
|
private func handleExpandedSessionEnd() {
|
|
expandedSessionCoordinator.sessionDidEnd { [weak self] in
|
|
self?.dismissPanels()
|
|
}
|
|
}
|
|
|
|
private func configureStatusItem() {
|
|
guard let button = statusItem.button else {
|
|
return
|
|
}
|
|
|
|
button.target = self
|
|
button.action = #selector(handleStatusItemAction(_:))
|
|
button.sendAction(on: [.leftMouseDown, .rightMouseDown])
|
|
button.toolTip = AppMetadata.appName
|
|
|
|
// Expanded-interface route. The API is discovered at runtime so the
|
|
// app can still build and run on older systems. When
|
|
// attached, the session route replaces the button action channel;
|
|
// failed attach falls back to the action route above.
|
|
if MenuBarStatusItemExpandedInterfaceAdapter.isSupported(by: statusItem) {
|
|
expandedInterfaceAdapter.onSessionBegin = { [weak self] session in
|
|
self?.handleExpandedSessionBegin(session)
|
|
}
|
|
expandedInterfaceAdapter.onSessionEnd = { [weak self] _ in
|
|
self?.handleExpandedSessionEnd()
|
|
}
|
|
let attached = expandedInterfaceAdapter.attach(to: statusItem)
|
|
if !attached {
|
|
AppLog.pluginHost.warning(
|
|
"Expanded-interface delegate attach failed; status item falls back to the action route"
|
|
)
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
private func observePluginHost() {
|
|
pluginHost.$hasActivePlugin
|
|
.removeDuplicates()
|
|
.sink { [weak self] _ in
|
|
self?.updateStatusIcon()
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
pluginHost.$settingsPresentationRequestCount
|
|
.dropFirst()
|
|
.sink { [weak self] _ in
|
|
self?.windowRouter.showSettings()
|
|
// Route through the session gate: settings can be opened from
|
|
// a panel while an expanded-interface session is still active.
|
|
self?.requestPanelClose()
|
|
}
|
|
.store(in: &cancellables)
|
|
}
|
|
|
|
private func observeIconSettings() {
|
|
iconSettings.$settingsRevision
|
|
.dropFirst()
|
|
.sink { [weak self] _ in
|
|
self?.updateStatusIcon()
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
appearanceObserver = DistributedNotificationCenter.default().addObserver(
|
|
forName: NSNotification.Name("AppleInterfaceThemeChangedNotification"),
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
Task { @MainActor in
|
|
self?.updateStatusIcon()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func updateStatusIcon() {
|
|
let payload = iconSettings.imagePayload(for: statusItem.button?.effectiveAppearance)
|
|
payload.image.isTemplate = payload.isTemplate
|
|
|
|
statusItem.length = NSStatusItem.variableLength
|
|
statusItem.button?.image = payload.image
|
|
statusItem.button?.imagePosition = .imageOnly
|
|
configureAnimationIfNeeded(payload)
|
|
}
|
|
|
|
private func observeStatusItemPositionPersistence() {
|
|
appTerminationObserver = NotificationCenter.default.addObserver(
|
|
forName: NSApplication.willTerminateNotification,
|
|
object: nil,
|
|
queue: .main
|
|
) { _ in
|
|
MenuBarControlItemDefaults.snapshotVisibleControlItemPreferredPosition()
|
|
}
|
|
|
|
statusItemWindowMoveObserver = NotificationCenter.default.addObserver(
|
|
forName: NSWindow.didMoveNotification,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] notification in
|
|
let movedWindowIdentifier = (notification.object as? NSWindow).map { ObjectIdentifier($0) }
|
|
MainActor.assumeIsolated {
|
|
self?.snapshotVisibleControlItemPreferredPositionIfNeeded(
|
|
forMovedWindowIdentifier: movedWindowIdentifier
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func snapshotVisibleControlItemPreferredPositionIfNeeded(
|
|
forMovedWindowIdentifier movedWindowIdentifier: ObjectIdentifier?
|
|
) {
|
|
guard
|
|
let movedWindowIdentifier,
|
|
let statusItemWindow = statusItem.button?.window,
|
|
movedWindowIdentifier == ObjectIdentifier(statusItemWindow)
|
|
else {
|
|
return
|
|
}
|
|
|
|
MenuBarControlItemDefaults.snapshotVisibleControlItemPreferredPosition()
|
|
}
|
|
|
|
private func resetStatusItemPosition() {
|
|
// Cancel any active expanded-interface session while its owning item
|
|
// is still alive.
|
|
requestPanelClose()
|
|
|
|
let oldItem = statusItem
|
|
NSStatusBar.system.removeStatusItem(oldItem)
|
|
MenuBarControlItemDefaults.resetVisibleControlItemPosition()
|
|
MenuBarControlItemDefaults.snapshotVisibleControlItemPreferredPosition()
|
|
|
|
let newItem = NSStatusBar.system.statusItem(withLength: 0)
|
|
newItem.autosaveName = MenuBarControlItemDefaults.visibleAutosaveName
|
|
statusItem = newItem
|
|
|
|
configureStatusItem()
|
|
updateStatusIcon()
|
|
}
|
|
|
|
private func configureAnimationIfNeeded(_ payload: MenuBarIconImagePayload) {
|
|
animationTimer?.cancel()
|
|
animationTimer = nil
|
|
animationLoadSampleTimer?.invalidate()
|
|
animationLoadSampleTimer = nil
|
|
animationFrames = []
|
|
animationFrameIndex = 0
|
|
animationBaseFrameDuration = payload.frameDuration
|
|
animationSpeedMode = payload.speedMode
|
|
manualAnimationSpeedMultiplier = payload.manualSpeedMultiplier
|
|
currentAnimationSystemLoad = nil
|
|
|
|
guard payload.isAnimated else {
|
|
return
|
|
}
|
|
|
|
animationFrames = payload.animationFrames
|
|
refreshAnimationLoadIfNeeded()
|
|
scheduleAnimationTimer()
|
|
scheduleAnimationLoadSamplingIfNeeded()
|
|
}
|
|
|
|
private func scheduleAnimationTimer() {
|
|
animationTimer?.cancel()
|
|
let frameDuration = effectiveAnimationFrameDuration()
|
|
let timer = DispatchSource.makeTimerSource(queue: .main)
|
|
timer.schedule(
|
|
deadline: .now() + frameDuration,
|
|
repeating: frameDuration,
|
|
leeway: .milliseconds(Int((frameDuration * 500).rounded()))
|
|
)
|
|
timer.setEventHandler { [weak self] in
|
|
self?.advanceAnimationFrame()
|
|
}
|
|
animationTimer = timer
|
|
timer.resume()
|
|
}
|
|
|
|
private func scheduleAnimationLoadSamplingIfNeeded() {
|
|
guard animationSpeedMode == .adaptiveSystemLoad else {
|
|
return
|
|
}
|
|
|
|
let timer = Timer(timeInterval: 5, repeats: true) { [weak self] _ in
|
|
Task { @MainActor in
|
|
self?.refreshAnimationLoadIfNeeded()
|
|
self?.scheduleAnimationTimer()
|
|
}
|
|
}
|
|
timer.tolerance = 2
|
|
animationLoadSampleTimer = timer
|
|
RunLoop.main.add(timer, forMode: .common)
|
|
}
|
|
|
|
private func refreshAnimationLoadIfNeeded() {
|
|
guard animationSpeedMode == .adaptiveSystemLoad else {
|
|
return
|
|
}
|
|
|
|
currentAnimationSystemLoad = animationLoadMonitor.sample()
|
|
}
|
|
|
|
private func effectiveAnimationFrameDuration() -> TimeInterval {
|
|
let multiplier = MenuBarIconAnimationSpeedPolicy.multiplier(
|
|
mode: animationSpeedMode,
|
|
manualMultiplier: manualAnimationSpeedMultiplier,
|
|
systemLoad: currentAnimationSystemLoad
|
|
)
|
|
let normalizedMultiplier = max(multiplier, MenuBarIconAnimationSpeedPolicy.minimumMultiplier)
|
|
return max(animationBaseFrameDuration / normalizedMultiplier, 0.04)
|
|
}
|
|
|
|
private func advanceAnimationFrame() {
|
|
guard
|
|
!animationFrames.isEmpty,
|
|
let button = statusItem.button
|
|
else {
|
|
animationTimer?.cancel()
|
|
animationTimer = nil
|
|
animationLoadSampleTimer?.invalidate()
|
|
animationLoadSampleTimer = nil
|
|
return
|
|
}
|
|
|
|
animationFrameIndex = (animationFrameIndex + 1) % animationFrames.count
|
|
button.image = animationFrames[animationFrameIndex]
|
|
button.needsDisplay = true
|
|
}
|
|
|
|
@objc
|
|
private func handleStatusItemAction(_ sender: NSStatusBarButton) {
|
|
// Read the preference live on each click so a settings change takes
|
|
// effect immediately without re-observing.
|
|
let swapped = MenuBarClickBehaviorPreference.current().isSwapped
|
|
switch MenuBarStatusItemInvocation.invocation(for: NSApp.currentEvent, swapped: swapped) {
|
|
case .featurePanel:
|
|
toggleFeaturePanel(relativeTo: sender)
|
|
case .componentPanel:
|
|
toggleComponentPanel(relativeTo: sender)
|
|
}
|
|
}
|
|
|
|
private func toggleFeaturePanel(relativeTo button: NSStatusBarButton) {
|
|
panelPresenter.toggleFeaturePanel(relativeTo: button)
|
|
handlePresentationResult()
|
|
}
|
|
|
|
private func toggleComponentPanel(relativeTo button: NSStatusBarButton) {
|
|
panelPresenter.toggleComponentPanel(relativeTo: button)
|
|
handlePresentationResult()
|
|
}
|
|
|
|
private func handlePresentationResult() {
|
|
guard panelPresenter.isAnyPanelShown else {
|
|
return
|
|
}
|
|
|
|
installDismissMonitorsIfNeeded()
|
|
}
|
|
|
|
private func installDismissMonitorsIfNeeded() {
|
|
let mouseEvents: NSEvent.EventTypeMask = [
|
|
.leftMouseDown,
|
|
.rightMouseDown,
|
|
.otherMouseDown
|
|
]
|
|
|
|
if localEventMonitor == nil {
|
|
localEventMonitor = NSEvent.addLocalMonitorForEvents(matching: mouseEvents) { [weak self] event in
|
|
self?.handleLocalMouseEvent(event) ?? event
|
|
}
|
|
}
|
|
|
|
if globalEventMonitor == nil {
|
|
globalEventMonitor = NSEvent.addGlobalMonitorForEvents(matching: mouseEvents) { [weak self] _ in
|
|
Task { @MainActor in
|
|
self?.requestPanelClose()
|
|
}
|
|
}
|
|
}
|
|
|
|
if appActivationObserver == nil {
|
|
appActivationObserver = NSWorkspace.shared.notificationCenter.addObserver(
|
|
forName: NSWorkspace.didActivateApplicationNotification,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] notification in
|
|
guard !Self.isCurrentApplicationActivationNotification(notification) else {
|
|
return
|
|
}
|
|
|
|
Task { @MainActor in
|
|
self?.requestPanelClose()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func removeDismissMonitorsIfNeeded() {
|
|
if let localEventMonitor {
|
|
NSEvent.removeMonitor(localEventMonitor)
|
|
self.localEventMonitor = nil
|
|
}
|
|
|
|
if let globalEventMonitor {
|
|
NSEvent.removeMonitor(globalEventMonitor)
|
|
self.globalEventMonitor = nil
|
|
}
|
|
|
|
if let appActivationObserver {
|
|
NSWorkspace.shared.notificationCenter.removeObserver(appActivationObserver)
|
|
self.appActivationObserver = nil
|
|
}
|
|
}
|
|
|
|
private func handleLocalMouseEvent(_ event: NSEvent) -> NSEvent {
|
|
guard panelPresenter.isAnyPanelShown else {
|
|
removeDismissMonitorsIfNeeded()
|
|
return event
|
|
}
|
|
|
|
guard !isEventInsidePresentedPanel(event), !isEventInsideStatusButton(event) else {
|
|
return event
|
|
}
|
|
|
|
requestPanelClose()
|
|
return event
|
|
}
|
|
|
|
private func isEventInsidePresentedPanel(_ event: NSEvent) -> Bool {
|
|
guard let eventWindow = event.window else {
|
|
return false
|
|
}
|
|
|
|
return panelPresenter.containsPresentedWindow(eventWindow)
|
|
}
|
|
|
|
private func isEventInsideStatusButton(_ event: NSEvent) -> Bool {
|
|
guard
|
|
let button = statusItem.button,
|
|
event.window === button.window
|
|
else {
|
|
return false
|
|
}
|
|
|
|
let pointInButton = button.convert(event.locationInWindow, from: nil)
|
|
return button.bounds.contains(pointInButton)
|
|
}
|
|
|
|
nonisolated private static func isCurrentApplicationActivationNotification(_ notification: Notification) -> Bool {
|
|
guard
|
|
let activatedApplication = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication
|
|
else {
|
|
return false
|
|
}
|
|
|
|
return activatedApplication.processIdentifier == ProcessInfo.processInfo.processIdentifier
|
|
}
|
|
|
|
}
|