Files
simpletopbar/Sources/SimpleTopBar/MenuBarItems.swift
T
TZGyn 3e57efe312 Hide menu bar icons with a cover instead of moving them off screen
Sliding the icons off the screen meant bringing them all back just to
open one menu, which flickered. Draw a frosted window over the other
apps' icons instead: they keep their real positions, so tapping a tile
opens its menu in place with no reveal, and the cover leaves a gap so
the hamburger is never hidden.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4.1-flash
2026-09-27 03:06:15 +08:00

278 lines
11 KiB
Swift
Raw 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
/// 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<String>()
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)
}
}