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 /// Frame in Accessibility coordinates (origin top-left of the main screen). let frame: CGRect? 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 = [] MenuBarHider.shared.apply(items: [], avoid: nil) return } let ownPID = ProcessInfo.processInfo.processIdentifier let all = Self.enumerate(includeOwn: true) items = all.filter { $0.pid != ownPID } // Our own hamburger must never be covered, even if it lands inside a run. let avoid = all.first { $0.pid == ownPID }?.frame MenuBarHider.shared.apply(items: items, avoid: avoid) } 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() for (index, child) in children.enumerated() { let position = pointAttribute(child, kAXPositionAttribute) // 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, 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 } let size = sizeAttribute(child, kAXSizeAttribute) let frame: CGRect? if let position, let size { frame = CGRect(origin: position, size: size) } else { frame = nil } found.append(MenuBarItem( id: "\(pid)-\(index)", pid: pid, appName: app.localizedName ?? app.bundleIdentifier ?? "Unknown", title: title, icon: Self.icon(forTitle: title, app: app), frame: frame, 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 at its own /// on-screen position. With the cover approach the item never moves, so no /// reveal is needed. func press(_ item: MenuBarItem) { _ = performPress(item) } 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 } nonisolated static func sizeAttribute(_ element: AXUIElement, _ attribute: String) -> CGSize? { var value: CFTypeRef? guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success, let value, CFGetTypeID(value) == AXValueGetTypeID() else { return nil } var size = CGSize.zero guard AXValueGetValue(value as! AXValue, .cgSize, &size) else { return nil } return size } } /// Menu bar item hiding via a "cover": the icons stay exactly where macOS put /// them and are hidden by our own frosted window drawn over them. Because they /// never move, opening one's menu needs no reveal. On by default; the choice is /// persisted. Always restored on quit. @MainActor final class MenuBarHider: ObservableObject { static let shared = MenuBarHider() 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) MenuBarItemManager.shared.refresh() } } private init() { // Hide by default: the menu bar then shows only SimpleTopBar's hamburger // plus the Apple items macOS will not let us cover. enabled = UserDefaults.standard.object(forKey: Self.defaultsKey) as? Bool ?? true } /// Recompute the cover windows for the current menu bar items. `avoid` is /// our own hamburger's frame, which the cover must never hide. func apply(items: [MenuBarItem], avoid: CGRect?) { isHiding = enabled MenuBarCover.shared.update(enabled: enabled, items: items, avoid: avoid) } /// Remove the cover. Called on quit. Leaves the user's preference intact. func restore() { isHiding = false MenuBarCover.shared.update(enabled: false, items: []) } } enum AccessibilityPermission { static var isGranted: Bool { AXIsProcessTrusted() } static func request() { let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary _ = AXIsProcessTrustedWithOptions(options) } }