diff --git a/AGENTS.md b/AGENTS.md index 7815706..fa953ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,12 +14,31 @@ executable SwiftPM package, no external dependencies (all Apple frameworks). splits them by `activationPolicy` into `regularApps` and `backgroundApps` (both sorted by name). Subscribes to the `NSWorkspace` launch/terminate/ activate/hide/unhide notifications to refresh automatically. -- `Sources/SimpleTopBar/TopBarView.swift` — the panel. `section(...)` renders - the regular apps; `backgroundSection` is the collapsible dropdown for +- `Sources/SimpleTopBar/TopBarView.swift` — the panel. `menuBarSection` is the + 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 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`. +## 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 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. - Reading `activationPolicy` is reliable but must happen on the main thread; keep all `NSWorkspace` access inside the model. -- Don't add Accessibility/Screen Recording permission code: this app only reads - the running-app list, which needs no TCC grants. +- The menu bar tray needs Accessibility permission; do not add Screen Recording + 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 should never be committed. diff --git a/README.md b/README.md index 7fe6ebe..a1be52a 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,13 @@ # SimpleTopBar -A native macOS menu bar app that shows a compact top bar with every app that is -running on your Mac, split into two sections: +A native macOS menu bar app: a **hamburger menu** in the menu bar whose panel +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. - **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 @@ -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. +## 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 ```bash diff --git a/Sources/SimpleTopBar/App.swift b/Sources/SimpleTopBar/App.swift index bce4a06..66122f9 100644 --- a/Sources/SimpleTopBar/App.swift +++ b/Sources/SimpleTopBar/App.swift @@ -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() } } diff --git a/Sources/SimpleTopBar/MenuBarItems.swift b/Sources/SimpleTopBar/MenuBarItems.swift new file mode 100644 index 0000000..90b8cb0 --- /dev/null +++ b/Sources/SimpleTopBar/MenuBarItems.swift @@ -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) + } +} diff --git a/Sources/SimpleTopBar/SelfTest.swift b/Sources/SimpleTopBar/SelfTest.swift index 16d68aa..dac5ac9 100644 --- a/Sources/SimpleTopBar/SelfTest.swift +++ b/Sources/SimpleTopBar/SelfTest.swift @@ -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) } diff --git a/Sources/SimpleTopBar/TopBarView.swift b/Sources/SimpleTopBar/TopBarView.swift index fd68c68..0fd873f 100644 --- a/Sources/SimpleTopBar/TopBarView.swift +++ b/Sources/SimpleTopBar/TopBarView.swift @@ -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 diff --git a/build-app.sh b/build-app.sh index f3ba5c9..9b78905 100755 --- a/build-app.sh +++ b/build-app.sh @@ -2,7 +2,7 @@ set -euo pipefail cd "$(dirname "$0")" -VERSION="${1:-0.1.0}" +VERSION="${1:-0.2.0}" swift build -c release