Files
simpletopbar/Sources/SimpleTopBar/MenuBarItems.swift
T
TZGyn 5a4e2fbda2 Keep Wi-Fi visible when hiding other menu bar icons
Hiding pushed Wi-Fi off along with the third-party app icons. Tune the
spacer boundary to sit just left of Wi-Fi so apps disappear while the
Apple Wi-Fi, Spotlight, Clock, Battery and Control Center icons remain.

💘 Generated with Crush

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

206 lines
7.6 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 }
for (index, child) in children.enumerated() {
let title = stringAttribute(child, kAXDescriptionAttribute)
?? stringAttribute(child, kAXTitleAttribute)
?? app.localizedName
?? "Item"
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)
}
}