1 Commits
Author SHA1 Message Date
TZGyn 373fda738e 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
2026-09-26 23:02:28 +08:00
7 changed files with 365 additions and 12 deletions
+24 -4
View File
@@ -14,12 +14,31 @@ executable SwiftPM package, no external dependencies (all Apple frameworks).
splits them by `activationPolicy` into `regularApps` and `backgroundApps` splits them by `activationPolicy` into `regularApps` and `backgroundApps`
(both sorted by name). Subscribes to the `NSWorkspace` launch/terminate/ (both sorted by name). Subscribes to the `NSWorkspace` launch/terminate/
activate/hide/unhide notifications to refresh automatically. activate/hide/unhide notifications to refresh automatically.
- `Sources/SimpleTopBar/TopBarView.swift` — the panel. `section(...)` renders - `Sources/SimpleTopBar/TopBarView.swift` — the panel. `menuBarSection` is the
the regular apps; `backgroundSection` is the collapsible dropdown for hamburger tray of other apps' menu bar items; `section(...)` renders the
regular apps; `backgroundSection` is the collapsible dropdown for
background apps. `AppRow` shows icon + name + active dot with a right-click background apps. `AppRow` shows icon + name + active dot with a right-click
Activate/Hide/Quit menu. Activate/Hide/Quit menu.
- `Sources/SimpleTopBar/MenuBarItems.swift` — `MenuBarItemManager` enumerates
other apps' status items via the Accessibility API (`kAXExtrasMenuBarAttribute`
→ `kAXChildrenAttribute`) and presses them (`kAXPressAction`, falling back to
`kAXShowMenuAction`). `MenuBarHider` owns the zero-width spacer `NSStatusItem`
used to push items off the menu bar. `AccessibilityPermission` wraps
`AXIsProcessTrusted` / `AXIsProcessTrustedWithOptions`.
- `Sources/SimpleTopBar/SelfTest.swift` — `runSelfTest()` powers `--self-test`. - `Sources/SimpleTopBar/SelfTest.swift` — `runSelfTest()` powers `--self-test`.
## The menu bar tray (Accessibility)
- Requires Accessibility permission. `MenuBarItemManager.refresh()` short-circuits
to an empty list when `AXIsProcessTrusted()` is false, and `TopBarView` shows a
"Grant Accessibility…" button in that state. Never assume permission.
- Enumeration must be resilient: `AXUIElementCopyAttributeValue` can fail per-app
or per-element, so every helper returns `nil` instead of force-unwrapping. The
`enumerate()` routine is `nonisolated static` so `--self-test` can run it.
- Items are keyed by `pid-index`; `MenuBarItem` equality ignores the AX element.
- Reading another app's UI is the only TCC permission needed; there is no Screen
Recording or Apple Events usage.
## The background dropdown ## The background dropdown
This is the core feature. Requirements: This is the core feature. Requirements:
@@ -68,7 +87,8 @@ version argument and pass the same version to `release.sh`.
the single-instance guard terminates stale copies with the same bundle id. the single-instance guard terminates stale copies with the same bundle id.
- Reading `activationPolicy` is reliable but must happen on the main thread; - Reading `activationPolicy` is reliable but must happen on the main thread;
keep all `NSWorkspace` access inside the model. keep all `NSWorkspace` access inside the model.
- Don't add Accessibility/Screen Recording permission code: this app only reads - The menu bar tray needs Accessibility permission; do not add Screen Recording
the running-app list, which needs no TCC grants. or Apple Events usage. `MenuBarHider` must always be restored on quit, but
because a status item dies with its process, a crash cleans up on its own.
- `.gitignore` covers `.build/`, `SimpleTopBar.app/`, `dist/` — build artifacts - `.gitignore` covers `.build/`, `SimpleTopBar.app/`, `dist/` — build artifacts
should never be committed. should never be committed.
+24 -2
View File
@@ -1,8 +1,13 @@
# SimpleTopBar # SimpleTopBar
A native macOS menu bar app that shows a compact top bar with every app that is A native macOS menu bar app: a **hamburger menu** in the menu bar whose panel
running on your Mac, split into two sections: collects every other app's menu bar (tray) icons, plus every app running on your
Mac, into three sections:
- **Menu Bar** — a tray of all other apps' menu bar items, discovered through
the Accessibility API. Click one to open its real menu. Optionally **hide the
items from the menu bar** so they only live inside this panel (best-effort;
see below).
- **Apps** — regular apps that live in the Dock. - **Apps** — regular apps that live in the Dock.
- **Background Apps** — accessory/agent apps without a Dock icon. This section - **Background Apps** — accessory/agent apps without a Dock icon. This section
is a **dropdown**: it stays collapsed to a single header, and when you expand is a **dropdown**: it stays collapsed to a single header, and when you expand
@@ -11,6 +16,23 @@ running on your Mac, split into two sections:
Click any row to activate the app, or right-click for Activate / Hide / Quit. Click any row to activate the app, or right-click for Activate / Hide / Quit.
## Accessibility permission
Collecting and opening other apps' menu bar items needs **Accessibility**
permission (System Settings → Privacy & Security → Accessibility). The panel
prompts for it; without it the Menu Bar section is empty and everything else
still works.
## Hiding menu bar items (best-effort)
macOS has no public API to move another app's status item. The **Hide items from
the menu bar** toggle uses the spacer technique: a zero-width status item is
created at launch whose length is expanded to push the items to its left
off-screen. This is reliable only when SimpleTopBar starts at login (its spacer
then sits to the right of later-launched apps' items) and may need re-toggling
after items move around. It is off by default and always undone on quit; if the
app dies, macOS removes the spacer automatically.
## Install with Homebrew ## Install with Homebrew
```bash ```bash
+13 -2
View File
@@ -7,8 +7,12 @@ struct SimpleTopBarApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
var body: some Scene { var body: some Scene {
MenuBarExtra("SimpleTopBar", systemImage: "menubar.rectangle") { MenuBarExtra("SimpleTopBar", systemImage: "line.3.horizontal") {
TopBarView(model: AppListModel.shared) TopBarView(
model: AppListModel.shared,
menuBar: MenuBarItemManager.shared,
hider: MenuBarHider.shared
)
} }
.menuBarExtraStyle(.window) .menuBarExtraStyle(.window)
} }
@@ -31,6 +35,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
} }
AppListModel.shared.start() AppListModel.shared.start()
MenuBarItemManager.shared.start()
MenuBarHider.shared.prepare()
}
func applicationWillTerminate(_ notification: Notification) {
// Never leave the menu bar resized behind us.
MenuBarHider.shared.restore()
} }
} }
+184
View File
@@ -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)
}
}
+20
View File
@@ -1,4 +1,5 @@
import AppKit import AppKit
import ApplicationServices
/// Headless verification for `swift run SimpleTopBar --self-test`. /// Headless verification for `swift run SimpleTopBar --self-test`.
/// Exercises the background-app overflow helpers and reports the live split /// 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)") print("running=\(running.count) regular=\(regular) background=\(background)")
check(regular + background == running.count, "every running app is classified") 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)") print(failures == 0 ? "ALL PASS" : "\(failures) FAILURE(S)")
exit(failures == 0 ? 0 : 1) exit(failures == 0 ? 0 : 1)
} }
+99 -3
View File
@@ -1,9 +1,11 @@
import SwiftUI import SwiftUI
import AppKit 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 { struct TopBarView: View {
@ObservedObject var model: AppListModel @ObservedObject var model: AppListModel
@ObservedObject var menuBar: MenuBarItemManager
@ObservedObject var hider: MenuBarHider
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@@ -11,6 +13,10 @@ struct TopBarView: View {
Divider() Divider()
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
menuBarSection
Divider().padding(.vertical, 4)
section(title: "Apps", apps: model.regularApps, emptyText: "No apps running") section(title: "Apps", apps: model.regularApps, emptyText: "No apps running")
Divider().padding(.vertical, 4) Divider().padding(.vertical, 4)
@@ -19,23 +25,28 @@ struct TopBarView: View {
} }
.padding(.vertical, 4) .padding(.vertical, 4)
} }
.frame(maxHeight: 360) .frame(maxHeight: 420)
Divider() Divider()
footer footer
} }
.frame(width: 320) .frame(width: 320)
.onAppear {
model.refresh()
menuBar.refresh()
}
} }
private var header: some View { private var header: some View {
HStack(spacing: 8) { HStack(spacing: 8) {
Image(systemName: "menubar.rectangle") Image(systemName: "line.3.horizontal")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Text("SimpleTopBar") Text("SimpleTopBar")
.font(.headline) .font(.headline)
Spacer() Spacer()
Button { Button {
model.refresh() model.refresh()
menuBar.refresh()
} label: { } label: {
Image(systemName: "arrow.clockwise") Image(systemName: "arrow.clockwise")
} }
@@ -46,6 +57,69 @@ struct TopBarView: View {
.padding(.vertical, 8) .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 { private func section(title: String, apps: [RunningApp], emptyText: String) -> some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
Text(title.uppercased()) 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 { private struct AppRow: View {
let app: RunningApp let app: RunningApp
@ObservedObject var model: AppListModel @ObservedObject var model: AppListModel
+1 -1
View File
@@ -2,7 +2,7 @@
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")" cd "$(dirname "$0")"
VERSION="${1:-0.1.0}" VERSION="${1:-0.2.0}"
swift build -c release swift build -c release