Files
simpletopbar/Sources/SimpleTopBar/MenuBarItems.swift
T
TZGyn 2312e09105 Shorten the icon reveal when opening a tray menu
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
2026-09-27 02:53:41 +08:00

308 lines
12 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}