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() 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) } }