Keeping every icon on the menu bar for as long as the panel was open
looked cluttered. Reveal them just long enough to open the clicked
item's menu, then hide again, so the panel stays clean.
💘 Generated with Crush
Assisted-by: Crush:deepseek-v4.1-flash
307 lines
12 KiB
Swift
307 lines
12 KiB
Swift
import AppKit
|
||
import ApplicationServices
|
||
import Combine
|
||
|
||
/// One live menu bar (status/tray) item discovered via the Accessibility API.
|
||
struct MenuBarItem: Identifiable, Equatable {
|
||
let id: String
|
||
let pid: pid_t
|
||
let appName: String
|
||
let title: String
|
||
let icon: NSImage
|
||
let element: AXUIElement
|
||
|
||
static func == (lhs: MenuBarItem, rhs: MenuBarItem) -> Bool {
|
||
lhs.id == rhs.id && lhs.title == rhs.title
|
||
}
|
||
}
|
||
|
||
/// Enumerates every other app's menu bar items so they can be shown (and
|
||
/// pressed) from SimpleTopBar's hamburger panel. Requires Accessibility
|
||
/// permission; without it the list is empty.
|
||
@MainActor
|
||
final class MenuBarItemManager: ObservableObject {
|
||
static let shared = MenuBarItemManager()
|
||
|
||
@Published private(set) var items: [MenuBarItem] = []
|
||
@Published private(set) var authorized = AXIsProcessTrusted()
|
||
|
||
private var observers: [NSObjectProtocol] = []
|
||
|
||
private init() {}
|
||
|
||
func start() {
|
||
authorized = AXIsProcessTrusted()
|
||
refresh()
|
||
let center = NSWorkspace.shared.notificationCenter
|
||
let names: [Notification.Name] = [
|
||
NSWorkspace.didLaunchApplicationNotification,
|
||
NSWorkspace.didTerminateApplicationNotification,
|
||
NSWorkspace.didActivateApplicationNotification,
|
||
NSWorkspace.didHideApplicationNotification,
|
||
NSWorkspace.didUnhideApplicationNotification,
|
||
]
|
||
for name in names {
|
||
observers.append(center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
|
||
Task { @MainActor in self?.refresh() }
|
||
})
|
||
}
|
||
}
|
||
|
||
func refresh() {
|
||
authorized = AXIsProcessTrusted()
|
||
guard authorized else {
|
||
items = []
|
||
return
|
||
}
|
||
items = Self.enumerate()
|
||
}
|
||
|
||
nonisolated static func enumerate(includeOwn: Bool = false) -> [MenuBarItem] {
|
||
let ownPID = ProcessInfo.processInfo.processIdentifier
|
||
var found: [MenuBarItem] = []
|
||
|
||
for app in NSWorkspace.shared.runningApplications {
|
||
let pid = app.processIdentifier
|
||
if !includeOwn && pid == ownPID { continue }
|
||
let axApp = AXUIElementCreateApplication(pid)
|
||
guard let bar = extrasMenuBar(of: axApp),
|
||
let children = children(of: bar) else { continue }
|
||
|
||
var seenTitles = Set<String>()
|
||
for (index, child) in children.enumerated() {
|
||
// Control Center reports every module it owns, including the
|
||
// ones not in the menu bar, which are parked off-screen around
|
||
// y=982. Keep only items actually sitting in the bar.
|
||
if let position = pointAttribute(child, kAXPositionAttribute), position.y > 60 {
|
||
continue
|
||
}
|
||
|
||
let title = stringAttribute(child, kAXDescriptionAttribute)
|
||
?? stringAttribute(child, kAXTitleAttribute)
|
||
?? app.localizedName
|
||
?? "Item"
|
||
// Several Control Center modules share the generic title
|
||
// "Control Center"; show each distinct title once per app.
|
||
if !seenTitles.insert(title).inserted { continue }
|
||
|
||
found.append(MenuBarItem(
|
||
id: "\(pid)-\(index)",
|
||
pid: pid,
|
||
appName: app.localizedName ?? app.bundleIdentifier ?? "Unknown",
|
||
title: title,
|
||
icon: Self.icon(forTitle: title, app: app),
|
||
element: child
|
||
))
|
||
}
|
||
}
|
||
|
||
return found.sorted { a, b in
|
||
let byApp = a.appName.localizedCaseInsensitiveCompare(b.appName)
|
||
if byApp == .orderedSame { return a.title.localizedCaseInsensitiveCompare(b.title) == .orderedAscending }
|
||
return byApp == .orderedAscending
|
||
}
|
||
}
|
||
|
||
/// Opens the real menu bar item, so its menu/popover appears.
|
||
///
|
||
/// A hidden item sits off-screen, and its menu would open off-screen too.
|
||
/// So briefly reveal the items, let the layout settle, press the item, then
|
||
/// hide again shortly after. The reveal is momentary rather than lasting the
|
||
/// whole time the panel is open.
|
||
func press(_ item: MenuBarItem) {
|
||
let hider = MenuBarHider.shared
|
||
let needsReveal = hider.isHiding
|
||
guard needsReveal else {
|
||
_ = performPress(item)
|
||
return
|
||
}
|
||
hider.suspend()
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.18) {
|
||
_ = self.performPress(item)
|
||
// Let the menu finish appearing before the bar contracts again.
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||
hider.resume()
|
||
}
|
||
}
|
||
}
|
||
|
||
private func performPress(_ item: MenuBarItem) -> Bool {
|
||
if AXUIElementPerformAction(item.element, kAXPressAction as CFString) == .success {
|
||
return true
|
||
}
|
||
return AXUIElementPerformAction(item.element, kAXShowMenuAction as CFString) == .success
|
||
}
|
||
|
||
// MARK: - Icons
|
||
|
||
/// Picks an icon for a menu bar item. Control Center owns many unrelated
|
||
/// modules, and they all share the Control Center app icon, so map their
|
||
/// titles to distinct SF Symbols for readability. Anything unknown falls
|
||
/// back to the owning app's icon.
|
||
nonisolated static func icon(forTitle title: String, app: NSRunningApplication) -> NSImage {
|
||
if let symbol = symbolName(for: title),
|
||
let image = NSImage(systemSymbolName: symbol, accessibilityDescription: title) {
|
||
image.isTemplate = true
|
||
return image
|
||
}
|
||
return app.icon ?? NSImage(named: NSImage.applicationIconName) ?? NSImage()
|
||
}
|
||
|
||
/// Keyword table mapping a menu bar item's title/description to an SF Symbol.
|
||
nonisolated static func symbolName(for title: String) -> String? {
|
||
// Control Center uses a non-breaking hyphen in "Wi‑Fi".
|
||
let text = title.lowercased()
|
||
.replacingOccurrences(of: "\u{2010}", with: "-")
|
||
.replacingOccurrences(of: "\u{2011}", with: "-")
|
||
let table: [(matches: [String], symbol: String)] = [
|
||
(["wi-fi", "wifi"], "wifi"),
|
||
(["battery"], "battery.100"),
|
||
(["clock", "time"], "clock"),
|
||
(["now playing"], "play.circle"),
|
||
(["focus"], "moon.fill"),
|
||
(["sound", "volume", "audio"], "speaker.wave.2"),
|
||
(["airdrop"], "dot.radiowaves.left.and.right"),
|
||
(["bluetooth"], "dot.radiowaves.left.and.right"),
|
||
(["mirroring"], "rectangle.on.rectangle"),
|
||
(["search", "spotlight"], "magnifyingglass"),
|
||
(["siri"], "waveform"),
|
||
(["keyboard"], "keyboard"),
|
||
(["stage manager"], "rectangle.3.group"),
|
||
(["brightness", "display"], "sun.max"),
|
||
(["control center"], "switch.2"),
|
||
(["notification"], "bell"),
|
||
(["vpn"], "lock.shield"),
|
||
]
|
||
for entry in table where entry.matches.contains(where: { text.contains($0) }) {
|
||
return entry.symbol
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// MARK: - AX helpers
|
||
|
||
nonisolated static func extrasMenuBar(of app: AXUIElement) -> AXUIElement? {
|
||
var value: CFTypeRef?
|
||
guard AXUIElementCopyAttributeValue(app, kAXExtrasMenuBarAttribute as CFString, &value) == .success,
|
||
let value,
|
||
CFGetTypeID(value) == AXUIElementGetTypeID() else { return nil }
|
||
return (value as! AXUIElement)
|
||
}
|
||
|
||
nonisolated static func children(of element: AXUIElement) -> [AXUIElement]? {
|
||
var value: CFTypeRef?
|
||
guard AXUIElementCopyAttributeValue(element, kAXChildrenAttribute as CFString, &value) == .success else {
|
||
return nil
|
||
}
|
||
return value as? [AXUIElement]
|
||
}
|
||
|
||
nonisolated static func stringAttribute(_ element: AXUIElement, _ attribute: String) -> String? {
|
||
var value: CFTypeRef?
|
||
guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success,
|
||
let string = value as? String,
|
||
!string.isEmpty else { return nil }
|
||
return string
|
||
}
|
||
|
||
nonisolated static func pointAttribute(_ element: AXUIElement, _ attribute: String) -> CGPoint? {
|
||
var value: CFTypeRef?
|
||
guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success,
|
||
let value,
|
||
CFGetTypeID(value) == AXValueGetTypeID() else { return nil }
|
||
var point = CGPoint.zero
|
||
guard AXValueGetValue(value as! AXValue, .cgPoint, &point) else { return nil }
|
||
return point
|
||
}
|
||
}
|
||
|
||
/// Best-effort menu bar item hiding. A zero-width spacer status item is created
|
||
/// at launch; expanding its length to a huge value pushes the other apps' icons
|
||
/// off the visible menu bar, so they can only be reached through SimpleTopBar's
|
||
/// panel. On by default; the choice is persisted. Always restored on quit, and
|
||
/// a crash cleans up automatically because status items die with the process.
|
||
@MainActor
|
||
final class MenuBarHider: ObservableObject {
|
||
static let shared = MenuBarHider()
|
||
|
||
/// Width that pushes the neighbouring items off the visible menu bar.
|
||
static let hiddenLength: CGFloat = 10_000
|
||
|
||
private static let defaultsKey = "hideMenuBarItems"
|
||
|
||
@Published private(set) var isHiding = false
|
||
@Published var enabled: Bool {
|
||
didSet {
|
||
guard enabled != oldValue else { return }
|
||
UserDefaults.standard.set(enabled, forKey: Self.defaultsKey)
|
||
apply(enabled)
|
||
}
|
||
}
|
||
|
||
private var spacer: NSStatusItem?
|
||
|
||
private init() {
|
||
// Hide by default: the menu bar then shows only SimpleTopBar's hamburger
|
||
// plus the Apple items macOS will not let us move.
|
||
enabled = UserDefaults.standard.object(forKey: Self.defaultsKey) as? Bool ?? true
|
||
}
|
||
|
||
/// Create the spacer early (at launch) so it lands as far right as possible.
|
||
func prepare() {
|
||
guard spacer == nil else { return }
|
||
let name = "SimpleTopBarHider"
|
||
let key = "NSStatusItem Preferred Position \(name)"
|
||
// The boundary between "hidden" and "kept" falls just left of Wi-Fi at
|
||
// ~250, so the other apps hide while Apple's Wi-Fi/Clock/Battery/Control
|
||
// Center/Spotlight stay visible. `debug.spacerPos` overrides the seed.
|
||
if let override = UserDefaults.standard.object(forKey: "debug.spacerPos") as? Double {
|
||
UserDefaults.standard.set(override, forKey: key)
|
||
} else if UserDefaults.standard.object(forKey: key) == nil {
|
||
UserDefaults.standard.set(250.0, forKey: key)
|
||
}
|
||
let item = NSStatusBar.system.statusItem(withLength: 0)
|
||
item.autosaveName = name
|
||
item.button?.isHidden = true
|
||
spacer = item
|
||
apply(enabled)
|
||
}
|
||
|
||
private func apply(_ hiding: Bool) {
|
||
spacer?.length = hiding ? Self.hiddenLength : 0
|
||
isHiding = hiding
|
||
}
|
||
|
||
/// Temporarily reveal the items (e.g. while the panel is open) so pressing
|
||
/// a tray icon opens its menu at its real, on-screen position. Keeps the
|
||
/// user's `enabled` preference untouched.
|
||
func suspend() {
|
||
guard enabled else { return }
|
||
spacer?.length = 0
|
||
isHiding = false
|
||
}
|
||
|
||
/// Restore hiding after a `suspend()`.
|
||
func resume() {
|
||
guard enabled else { return }
|
||
spacer?.length = Self.hiddenLength
|
||
isHiding = true
|
||
}
|
||
|
||
/// Put the menu bar back the way we found it. Called on quit. Leaves the
|
||
/// user's preference intact.
|
||
func restore() {
|
||
spacer?.length = 0
|
||
isHiding = false
|
||
}
|
||
}
|
||
|
||
enum AccessibilityPermission {
|
||
static var isGranted: Bool { AXIsProcessTrusted() }
|
||
|
||
static func request() {
|
||
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary
|
||
_ = AXIsProcessTrustedWithOptions(options)
|
||
}
|
||
}
|