Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
373fda738e |
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
VERSION="${1:-0.1.0}"
|
||||
VERSION="${1:-0.2.0}"
|
||||
|
||||
swift build -c release
|
||||
|
||||
|
||||
Reference in New Issue
Block a user