Control Center reports every module it owns, so the tray filled with
identical tiles for items that are not actually in the menu bar. Keep
only items whose accessibility position is in the bar and de-duplicate
by title, so each tray icon appears once.
💘 Generated with Crush
Assisted-by: Crush:deepseek-v4.1-flash
218 lines
8.2 KiB
Swift
218 lines
8.2 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 appIcon: 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,
|
|
appIcon: app.icon ?? NSImage(named: NSImage.applicationIconName) ?? NSImage(),
|
|
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 by pressing its accessibility element, so
|
|
/// its menu/popover appears at its true screen location.
|
|
@discardableResult
|
|
func press(_ item: MenuBarItem) -> Bool {
|
|
if AXUIElementPerformAction(item.element, kAXPressAction as CFString) == .success {
|
|
return true
|
|
}
|
|
return AXUIElementPerformAction(item.element, kAXShowMenuAction as CFString) == .success
|
|
}
|
|
|
|
// 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 items to its left
|
|
/// off the visible menu bar, so they can only be reached through SimpleTopBar's
|
|
/// panel. Reliable only when SimpleTopBar starts at login (its spacer then sits
|
|
/// to the right of later-launched apps' items). Always restored on quit.
|
|
@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
|
|
|
|
@Published private(set) var isHiding = false
|
|
@Published var enabled = false {
|
|
didSet {
|
|
guard enabled != oldValue else { return }
|
|
apply(enabled)
|
|
}
|
|
}
|
|
|
|
private var spacer: NSStatusItem?
|
|
|
|
private init() {}
|
|
|
|
/// 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
|
|
}
|
|
|
|
private func apply(_ hiding: Bool) {
|
|
prepare()
|
|
spacer?.length = hiding ? Self.hiddenLength : 0
|
|
isHiding = hiding
|
|
}
|
|
|
|
/// Put the menu bar back the way we found it. Called on quit.
|
|
func restore() {
|
|
spacer?.length = 0
|
|
isHiding = false
|
|
enabled = false
|
|
}
|
|
}
|
|
|
|
enum AccessibilityPermission {
|
|
static var isGranted: Bool { AXIsProcessTrusted() }
|
|
|
|
static func request() {
|
|
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary
|
|
_ = AXIsProcessTrustedWithOptions(options)
|
|
}
|
|
}
|