Collect other apps' menu bar items into a hamburger panel
The menu bar icon is now a hamburger whose panel gathers every other
app's status item via the Accessibility API, so tray icons no longer
need to be hunted down across the menu bar. Items can be opened from
the panel, and a best-effort toggle pushes them off the real menu bar
so they only live there.
💘 Generated with Crush
Assisted-by: Crush:deepseek-v4.1-flash
This commit is contained in:
@@ -7,8 +7,12 @@ struct SimpleTopBarApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
|
||||
var body: some Scene {
|
||||
MenuBarExtra("SimpleTopBar", systemImage: "menubar.rectangle") {
|
||||
TopBarView(model: AppListModel.shared)
|
||||
MenuBarExtra("SimpleTopBar", systemImage: "line.3.horizontal") {
|
||||
TopBarView(
|
||||
model: AppListModel.shared,
|
||||
menuBar: MenuBarItemManager.shared,
|
||||
hider: MenuBarHider.shared
|
||||
)
|
||||
}
|
||||
.menuBarExtraStyle(.window)
|
||||
}
|
||||
@@ -31,6 +35,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
}
|
||||
|
||||
AppListModel.shared.start()
|
||||
MenuBarItemManager.shared.start()
|
||||
MenuBarHider.shared.prepare()
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ notification: Notification) {
|
||||
// Never leave the menu bar resized behind us.
|
||||
MenuBarHider.shared.restore()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
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() -> [MenuBarItem] {
|
||||
let ownPID = ProcessInfo.processInfo.processIdentifier
|
||||
var found: [MenuBarItem] = []
|
||||
|
||||
for app in NSWorkspace.shared.runningApplications {
|
||||
let pid = app.processIdentifier
|
||||
if 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
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 item = NSStatusBar.system.statusItem(withLength: 0)
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import AppKit
|
||||
import ApplicationServices
|
||||
|
||||
/// Headless verification for `swift run SimpleTopBar --self-test`.
|
||||
/// Exercises the background-app overflow helpers and reports the live split
|
||||
@@ -45,6 +46,25 @@ func runSelfTest() {
|
||||
print("running=\(running.count) regular=\(regular) background=\(background)")
|
||||
check(regular + background == running.count, "every running app is classified")
|
||||
|
||||
// Menu bar item discovery degrades gracefully when it cannot read another
|
||||
// app's UI (accessibility not granted, or the element has no extras bar).
|
||||
let systemWide = AXUIElementCreateSystemWide()
|
||||
check(MenuBarItemManager.extrasMenuBar(of: systemWide) == nil, "no extras bar on a non-app element")
|
||||
check(MenuBarItemManager.children(of: systemWide) == nil, "no children on a non-app element")
|
||||
|
||||
let element = AXUIElementCreateSystemWide()
|
||||
let first = MenuBarItem(id: "1-0", pid: 1, appName: "A", title: "T", appIcon: NSImage(), element: element)
|
||||
let same = MenuBarItem(id: "1-0", pid: 1, appName: "A", title: "T", appIcon: NSImage(), element: element)
|
||||
let different = MenuBarItem(id: "1-1", pid: 1, appName: "A", title: "T", appIcon: NSImage(), element: element)
|
||||
check(first == same, "menu bar items compare by id and title")
|
||||
check(first != different, "different ids are not equal")
|
||||
|
||||
let authorized = AXIsProcessTrusted()
|
||||
print("accessibility authorized=\(authorized)")
|
||||
if authorized {
|
||||
print("menu bar items discovered=\(MenuBarItemManager.enumerate().count)")
|
||||
}
|
||||
|
||||
print(failures == 0 ? "ALL PASS" : "\(failures) FAILURE(S)")
|
||||
exit(failures == 0 ? 0 : 1)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
/// The top bar panel shown from the menu bar icon.
|
||||
/// The top bar panel shown from the menu bar hamburger icon.
|
||||
struct TopBarView: View {
|
||||
@ObservedObject var model: AppListModel
|
||||
@ObservedObject var menuBar: MenuBarItemManager
|
||||
@ObservedObject var hider: MenuBarHider
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -11,6 +13,10 @@ struct TopBarView: View {
|
||||
Divider()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
menuBarSection
|
||||
|
||||
Divider().padding(.vertical, 4)
|
||||
|
||||
section(title: "Apps", apps: model.regularApps, emptyText: "No apps running")
|
||||
|
||||
Divider().padding(.vertical, 4)
|
||||
@@ -19,23 +25,28 @@ struct TopBarView: View {
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.frame(maxHeight: 360)
|
||||
.frame(maxHeight: 420)
|
||||
|
||||
Divider()
|
||||
footer
|
||||
}
|
||||
.frame(width: 320)
|
||||
.onAppear {
|
||||
model.refresh()
|
||||
menuBar.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "menubar.rectangle")
|
||||
Image(systemName: "line.3.horizontal")
|
||||
.foregroundStyle(.secondary)
|
||||
Text("SimpleTopBar")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Button {
|
||||
model.refresh()
|
||||
menuBar.refresh()
|
||||
} label: {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}
|
||||
@@ -46,6 +57,69 @@ struct TopBarView: View {
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
/// The hamburger tray: every other app's menu bar item lives here.
|
||||
private var menuBarSection: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(spacing: 6) {
|
||||
Text("MENU BAR")
|
||||
.font(.caption2)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text("\(menuBar.items.count)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 1)
|
||||
.background(Capsule().fill(Color.secondary.opacity(0.15)))
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 4)
|
||||
.padding(.bottom, 4)
|
||||
|
||||
if !menuBar.authorized {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Grant Accessibility to collect and open menu bar items.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Grant Accessibility…") {
|
||||
AccessibilityPermission.request()
|
||||
menuBar.refresh()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 4)
|
||||
} else if menuBar.items.isEmpty {
|
||||
Text("No menu bar items found")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 4)
|
||||
} else {
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 34), spacing: 6)], spacing: 6) {
|
||||
ForEach(menuBar.items) { item in
|
||||
MenuBarTile(item: item, menuBar: menuBar)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
|
||||
if menuBar.authorized {
|
||||
Toggle(isOn: $hider.enabled) {
|
||||
Text("Hide items from the menu bar")
|
||||
.font(.caption)
|
||||
}
|
||||
.toggleStyle(.switch)
|
||||
.controlSize(.mini)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 6)
|
||||
.help("Best-effort: pushes the real items off the menu bar so they only live here. Reliable when SimpleTopBar starts at login.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func section(title: String, apps: [RunningApp], emptyText: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(title.uppercased())
|
||||
@@ -163,6 +237,28 @@ struct TopBarView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct MenuBarTile: View {
|
||||
let item: MenuBarItem
|
||||
@ObservedObject var menuBar: MenuBarItemManager
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
menuBar.press(item)
|
||||
} label: {
|
||||
Image(nsImage: item.appIcon)
|
||||
.resizable()
|
||||
.frame(width: 20, height: 20)
|
||||
.padding(6)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(Color.secondary.opacity(0.12))
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("\(item.appName) — \(item.title)")
|
||||
}
|
||||
}
|
||||
|
||||
private struct AppRow: View {
|
||||
let app: RunningApp
|
||||
@ObservedObject var model: AppListModel
|
||||
|
||||
Reference in New Issue
Block a user