The menu is its own window anchored where the item was revealed, so it
survives if the bar re-hides immediately. Revealing for ~0.3s instead of
over a second makes the flash far less noticeable while still opening
the menu in view.
💘 Generated with Crush
Assisted-by: Crush:deepseek-v4.1-flash
308 lines
12 KiB
Swift
308 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.2) {
|
||
_ = self.performPress(item)
|
||
// The menu is its own window, positioned at the item's on-screen
|
||
// spot, so the bar can be re-hidden almost immediately.
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||
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)
|
||
}
|
||
}
|