9 Commits
Author SHA1 Message Date
TZGyn d9eddf799f Give Control Center items distinct icons
Every Control Center module shares one app icon, so the tray was a wall
of identical tiles. Map each item's title to its own SF Symbol so Wi-Fi,
Battery, Now Playing and friends are distinguishable at a glance.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4.1-flash
2026-09-27 02:18:57 +08:00
TZGyn dec8d60aa3 Hide other menu bar icons by default
The point of the app is a single hamburger that replaces the tray, so
hiding the other icons should be the out-of-the-box state. Remember the
user's choice, and create the hamburger before the hider so it is not
itself pushed off-screen.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4.1-flash
2026-09-27 02:15:01 +08:00
TZGyn 5eb2d0f661 Stop listing Control Center's off-screen module duplicates
Control Center reports every module it owns, so the tray filled with
identical tiles for items that are not actually in the menu bar. Keep
only items whose accessibility position is in the bar and de-duplicate
by title, so each tray icon appears once.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4.1-flash
2026-09-27 02:11:57 +08:00
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
TZGyn 92a57308b7 Explain which menu bar items can be hidden
Hiding other apps' icons worked, but Apple pins Spotlight, Clock, Battery
and Control Center to the right of the menu bar, so a third-party item can
never be placed after them to push them away. Document the real limit so
the behaviour is not mistaken for a bug.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4.1-flash
2026-09-27 00:35:05 +08:00
TZGyn 8767b73234 Make the menu bar icon visible on a full menu bar
New status items land leftmost, which on a notched Mac lands under the
notch and is invisible, so the app appeared broken. Replace MenuBarExtra
with a managed status item that carries an autosave name and a persisted
preferred position, and add a diagnostic dump to verify placement.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4.1-flash
2026-09-27 00:07:56 +08:00
TZGyn 83cb12eddc Request Accessibility at launch so the tray can populate
The Menu Bar section silently stayed empty because nothing prompted for
Accessibility permission until a button inside the panel was clicked, so
the app looked inert. Prompt once on first launch instead.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4.1-flash
2026-09-26 23:39:20 +08:00
TZGyn 9b5031aff2 Document the upgrade steps and always print them after a release
Readers and future sessions need one obvious place that says how to move
an installed app to the latest version, and the release flow now requires
surfacing those commands in every release report.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4.1-flash
2026-09-26 23:05:12 +08:00
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 665 additions and 20 deletions
+91 -8
View File
@@ -5,21 +5,50 @@ executable SwiftPM package, no external dependencies (all Apple frameworks).
## Architecture
- `Sources/SimpleTopBar/App.swift` — `@main` entry. `MenuBarExtra` with
`.menuBarExtraStyle(.window)` hosting `TopBarView`. `AppDelegate` handles the
`--self-test` flag and the single-instance guard. `LoginItem` wraps
`SMAppService`.
- `Sources/SimpleTopBar/App.swift` — `@main` entry using a bare
`Settings { EmptyView() }` scene, because the app owns its status item
manually instead of using `MenuBarExtra`. `AppDelegate.setupStatusItem()`
creates the `NSStatusItem`, sets `autosaveName = "SimpleTopBar"`, and shows
`TopBarView` inside an `NSPopover`. Handles `--self-test`, `--dump-items`,
`--diag`, and the single-instance guard. `LoginItem` wraps `SMAppService`.
- `Sources/SimpleTopBar/AppListModel.swift` — `@MainActor` `ObservableObject`
singleton. `refresh()` reads `NSWorkspace.shared.runningApplications` and
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.
- Control Center is special: its extras menu bar lists **every module it owns**,
including ones not in the bar, which are parked off-screen at `y≈982`.
`enumerate()` drops items whose AX position `y > 60` and de-duplicates by
title per app, otherwise the tray fills with identical "Control Center" tiles.
- Icons: every Control Center module shares the same app icon, so
`symbolName(for:)` maps titles (Wi‑Fi, Battery, Now Playing, …) to distinct SF
Symbols, falling back to the owning app's icon. Note the non-breaking hyphen
in "Wi‑Fi" (`\u{2011}`) is normalised before matching.
- 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:
@@ -60,6 +89,59 @@ ad-hoc signing (`-`) if none is present. `LSUIElement` is true → no Dock icon.
Bump `CFBundleShortVersionString`/`CFBundleVersion` via the `build-app.sh`
version argument and pass the same version to `release.sh`.
## Release process
1. `./release.sh <version>` — builds, zips `SimpleTopBar.app` to
`dist/SimpleTopBar.zip`, and prints its sha256 (it does not tag or upload).
2. Commit and push `main`.
3. `tea releases create --tag v<version> --asset dist/SimpleTopBar.zip --repo TZGyn/simpletopbar --login gitea.tzgyn.dev`.
4. Bump `version`/`sha256` in `~/.cache/homebrew-tap/Casks/simpletopbar.rb`,
push, then `git -C /opt/homebrew/Library/Taps/tzgyn/homebrew-tap pull --ff-only`.
**Always print the upgrade script after any release or version bump** — the user
wants it in the final answer every time. Keep it to the cask upgrade commands
(see the README release section for the exact block), because the app is
installed through the tap.
## Status item placement (why not MenuBarExtra)
On a full menu bar, a newly created status item is placed leftmost, which on a
notched MacBook is under the notch and therefore invisible. `MenuBarExtra` gives
no control over this, so the app creates its own `NSStatusItem` and:
- Sets `autosaveName = "SimpleTopBar"`.
- On first run, writes `"NSStatusItem Preferred Position SimpleTopBar" = 100`
into `UserDefaults.standard`. This private-ish key is what macOS reads to
place the item, and 100 lands it at the visible right side. Because it is only
written when absent, the user's own ⌘-drag wins and persists via the autosave
name.
`--diag` runs full startup and writes every status item's AX position to
`/tmp/simpletopbar-items.txt`; use it to verify the icon is not under the notch
(x should be ~1200, not ~765).
## Hiding other menu bar items
`MenuBarHider` creates a zero-width spacer `NSStatusItem` at launch and grows its
length to `hiddenLength` (10_000) to push the icons on its left off-screen. It is
**on by default** (`hideMenuBarItems` defaults to true, then persisted). Its
preferred position controls where the hidden/kept boundary lands:
- Seed **250** (default) puts the boundary just left of Wi-Fi, so every
third-party app icon goes to x < 0 (cmux, Kapture, moomoo, WireGuard,
TextInputMenuAgent, Now Playing) while **Wi-Fi (x≈1173), Spotlight, Clock,
Battery, Control Center and our hamburger stay visible**.
- Verified with `--diag-hide` across positions 100–400. Below ~200 Wi-Fi gets
hidden too; above ~300 app icons (Now Playing, TextInput) leak back.
- `debug.spacerPos` (Double) overrides the seed for tuning without rebuilding.
- **Order matters**: `AppDelegate` must call `setupStatusItem()` *before*
`MenuBarHider.shared.prepare()`. If the hider contracts the bar first, the
hamburger is created into the collapsed space and lands off-screen.
- **Wi-Fi/Spotlight/Clock/Battery/Control Center cannot be hidden**: macOS pins
them to the far right and a third-party item can never be inserted to their
right, so the spacer has nothing to push them with. This is a macOS
constraint, not a bug.
## Gotchas
- `AppListModel` is `@MainActor`; the `NSWorkspace` observers hop back with
@@ -68,7 +150,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.
+54 -4
View File
@@ -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,38 @@ 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 other icons off-screen so
they only live in this panel. It is **on by default** (the choice is
remembered), always undone on quit, and if the app dies macOS removes the spacer
automatically.
What it can and cannot hide:
- It hides **other third-party apps' icons** (cmux, Kapture, WireGuard, input
source, Now Playing, …) so only your apps' icons leave the bar.
- It **keeps Wi-Fi, Spotlight, Clock, Battery and Control Center**. The spacer's
boundary is tuned (preferred position 250) to fall just left of Wi-Fi, so the
Apple items on the right stay visible. macOS reserves that far-right area: a
third-party item can never be placed to the right of it, so those icons cannot
be pushed away even in principle. (Bartender and Ice hit the same wall and
cover the area with their own overlay bar instead.)
- Newly launched apps are placed to the left of the spacer, so they are hidden
automatically.
- Advanced: set `defaults write dev.tzgyn.simpletopbar debug.spacerPos -float <n>`
to move the boundary (larger = keep more icons; > ~400 starts leaking app
icons back into the bar).
## Install with Homebrew
```bash
@@ -22,14 +59,27 @@ brew install --cask simpletopbar
The tap is hosted on Gitea, so the full URL is required. Homebrew may print an
"Unsupported tap" warning — it's harmless.
## Upgrade
```bash
brew update
brew upgrade --cask simpletopbar
```
If Gatekeeper still blocks the app after upgrading (it is not notarized yet):
```bash
xattr -dr com.apple.quarantine /Applications/SimpleTopBar.app
```
## Build from source
```bash
swift build # debug build
swift run SimpleTopBar # run from terminal (app appears in the menu bar)
swift run SimpleTopBar --self-test # headless check of the dropdown logic
./build-app.sh 0.1.0 # release build + SimpleTopBar.app bundle + icon + codesign
./release.sh 0.1.0 # build-app.sh + dist/SimpleTopBar.zip + sha256
./build-app.sh 0.2.0 # release build + SimpleTopBar.app bundle + icon + codesign
./release.sh 0.2.0 # build-app.sh + dist/SimpleTopBar.zip + sha256
```
## Around the Background dropdown
+95 -4
View File
@@ -7,20 +7,31 @@ struct SimpleTopBarApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
var body: some Scene {
MenuBarExtra("SimpleTopBar", systemImage: "menubar.rectangle") {
TopBarView(model: AppListModel.shared)
}
.menuBarExtraStyle(.window)
// The app is a menu bar extra, so it has no real windows.
Settings { EmptyView() }
}
}
/// Owns the menu bar status item directly (instead of `MenuBarExtra`) so the
/// item can be given an `autosaveName` and a persisted preferred position. On a
/// full menu bar, a brand new item lands under the notch and is invisible;
/// writing `NSStatusItem Preferred Position <name>` into defaults puts it in a
/// visible slot, and the autosave name lets the user's own drag persist.
final class AppDelegate: NSObject, NSApplicationDelegate {
private var statusItem: NSStatusItem?
private var popover: NSPopover?
func applicationDidFinishLaunching(_ notification: Notification) {
if CommandLine.arguments.contains("--self-test") {
runSelfTest()
return
}
if CommandLine.arguments.contains("--dump-items") {
dumpMenuBarItems()
return
}
// Single instance: stale copies keep a menu bar icon around after an
// update and swallow clicks meant for the new instance.
let myPid = ProcessInfo.processInfo.processIdentifier
@@ -31,6 +42,86 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
AppListModel.shared.start()
MenuBarItemManager.shared.start()
// Create the hamburger before the hider so it is placed in the visible
// part of the bar; the hider then contracts the space around it.
setupStatusItem()
MenuBarHider.shared.prepare()
if CommandLine.arguments.contains("--diag") {
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
dumpMenuBarItems()
}
return
}
if CommandLine.arguments.contains("--diag-hide") {
MenuBarHider.shared.enabled = true
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
dumpMenuBarItems()
}
return
}
// The tray is empty without Accessibility permission, so surface the
// system prompt once at launch instead of waiting for the user to open
// the panel and find the button.
if !AccessibilityPermission.isGranted,
!UserDefaults.standard.bool(forKey: "didRequestAccessibility") {
UserDefaults.standard.set(true, forKey: "didRequestAccessibility")
AccessibilityPermission.request()
}
}
func applicationWillTerminate(_ notification: Notification) {
// Never leave the menu bar resized behind us.
MenuBarHider.shared.restore()
}
private func setupStatusItem() {
let name = "SimpleTopBar"
let positionKey = "NSStatusItem Preferred Position \(name)"
// Only seed the position on first run so the user's own drag wins and
// persists through the autosave name.
if UserDefaults.standard.object(forKey: positionKey) == nil {
UserDefaults.standard.set(100.0, forKey: positionKey)
}
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
item.autosaveName = name
if let button = item.button {
let image = NSImage(systemSymbolName: "line.3.horizontal", accessibilityDescription: name)
image?.isTemplate = true
button.image = image
button.target = self
button.action = #selector(togglePopover)
button.setAccessibilityLabel("SimpleTopBar")
}
statusItem = item
let popover = NSPopover()
popover.behavior = .transient
popover.animates = false
popover.contentViewController = NSHostingController(
rootView: TopBarView(
model: AppListModel.shared,
menuBar: MenuBarItemManager.shared,
hider: MenuBarHider.shared
)
)
self.popover = popover
}
@objc private func togglePopover() {
guard let popover, let button = statusItem?.button else { return }
if popover.isShown {
popover.performClose(nil)
} else {
NSApp.activate(ignoringOtherApps: true)
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
popover.contentViewController?.view.window?.makeKey()
}
}
}
+270
View File
@@ -0,0 +1,270 @@
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 icon: 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 }
var seenTitles = Set<String>()
for (index, child) in children.enumerated() {
// Control Center reports every module it owns, including the
// ones not in the menu bar, which are parked off-screen around
// y=982. Keep only items actually sitting in the bar.
if let position = pointAttribute(child, kAXPositionAttribute), position.y > 60 {
continue
}
let title = stringAttribute(child, kAXDescriptionAttribute)
?? stringAttribute(child, kAXTitleAttribute)
?? app.localizedName
?? "Item"
// Several Control Center modules share the generic title
// "Control Center"; show each distinct title once per app.
if !seenTitles.insert(title).inserted { continue }
found.append(MenuBarItem(
id: "\(pid)-\(index)",
pid: pid,
appName: app.localizedName ?? app.bundleIdentifier ?? "Unknown",
title: title,
icon: Self.icon(forTitle: title, app: app),
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: - Icons
/// Picks an icon for a menu bar item. Control Center owns many unrelated
/// modules, and they all share the Control Center app icon, so map their
/// titles to distinct SF Symbols for readability. Anything unknown falls
/// back to the owning app's icon.
nonisolated static func icon(forTitle title: String, app: NSRunningApplication) -> NSImage {
if let symbol = symbolName(for: title),
let image = NSImage(systemSymbolName: symbol, accessibilityDescription: title) {
image.isTemplate = true
return image
}
return app.icon ?? NSImage(named: NSImage.applicationIconName) ?? NSImage()
}
/// Keyword table mapping a menu bar item's title/description to an SF Symbol.
nonisolated static func symbolName(for title: String) -> String? {
// Control Center uses a non-breaking hyphen in "Wi‑Fi".
let text = title.lowercased()
.replacingOccurrences(of: "\u{2010}", with: "-")
.replacingOccurrences(of: "\u{2011}", with: "-")
let table: [(matches: [String], symbol: String)] = [
(["wi-fi", "wifi"], "wifi"),
(["battery"], "battery.100"),
(["clock", "time"], "clock"),
(["now playing"], "play.circle"),
(["focus"], "moon.fill"),
(["sound", "volume", "audio"], "speaker.wave.2"),
(["airdrop"], "dot.radiowaves.left.and.right"),
(["bluetooth"], "dot.radiowaves.left.and.right"),
(["mirroring"], "rectangle.on.rectangle"),
(["search", "spotlight"], "magnifyingglass"),
(["siri"], "waveform"),
(["keyboard"], "keyboard"),
(["stage manager"], "rectangle.3.group"),
(["brightness", "display"], "sun.max"),
(["control center"], "switch.2"),
(["notification"], "bell"),
(["vpn"], "lock.shield"),
]
for entry in table where entry.matches.contains(where: { text.contains($0) }) {
return entry.symbol
}
return nil
}
// 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 other apps' icons
/// off the visible menu bar, so they can only be reached through SimpleTopBar's
/// panel. On by default; the choice is persisted. Always restored on quit, and
/// a crash cleans up automatically because status items die with the process.
@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
private static let defaultsKey = "hideMenuBarItems"
@Published private(set) var isHiding = false
@Published var enabled: Bool {
didSet {
guard enabled != oldValue else { return }
UserDefaults.standard.set(enabled, forKey: Self.defaultsKey)
apply(enabled)
}
}
private var spacer: NSStatusItem?
private init() {
// Hide by default: the menu bar then shows only SimpleTopBar's hamburger
// plus the Apple items macOS will not let us move.
enabled = UserDefaults.standard.object(forKey: Self.defaultsKey) as? Bool ?? true
}
/// 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
apply(enabled)
}
private func apply(_ hiding: Bool) {
spacer?.length = hiding ? Self.hiddenLength : 0
isHiding = hiding
}
/// Put the menu bar back the way we found it. Called on quit. Leaves the
/// user's preference intact.
func restore() {
spacer?.length = 0
isHiding = false
}
}
enum AccessibilityPermission {
static var isGranted: Bool { AXIsProcessTrusted() }
static func request() {
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary
_ = AXIsProcessTrustedWithOptions(options)
}
}
+55
View File
@@ -1,4 +1,32 @@
import AppKit
import ApplicationServices
/// Diagnostic used by `open SimpleTopBar.app --args --dump-items`. Writes the
/// live Accessibility view of the menu bar to /tmp so it can be inspected
/// without a GUI, then exits.
func dumpMenuBarItems() {
var lines: [String] = []
lines.append("bundle=\(Bundle.main.bundleIdentifier ?? "?")")
lines.append("authorized=\(AXIsProcessTrusted())")
let items = MenuBarItemManager.enumerate()
lines.append("count=\(items.count)")
for item in items {
let pos = MenuBarItemManager.pointAttribute(item.element, kAXPositionAttribute)
lines.append("\(item.appName) | \(item.title) | pid=\(item.pid) | id=\(item.id) | pos=\(pos.map { "\(Int($0.x)),\(Int($0.y))" } ?? "?")")
}
let own = MenuBarItemManager.enumerate(includeOwn: true).filter {
$0.pid == ProcessInfo.processInfo.processIdentifier
}
lines.append("own-status-items=\(own.count)")
for item in own {
let pos = MenuBarItemManager.pointAttribute(item.element, kAXPositionAttribute)
lines.append("OWN \(item.appName) | \(item.title) | id=\(item.id) | pos=\(pos.map { "\(Int($0.x)),\(Int($0.y))" } ?? "?")")
}
let text = lines.joined(separator: "\n")
try? text.write(toFile: "/tmp/simpletopbar-items.txt", atomically: true, encoding: .utf8)
print(text)
exit(0)
}
/// Headless verification for `swift run SimpleTopBar --self-test`.
/// Exercises the background-app overflow helpers and reports the live split
@@ -45,6 +73,33 @@ 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", icon: NSImage(), element: element)
let same = MenuBarItem(id: "1-0", pid: 1, appName: "A", title: "T", icon: NSImage(), element: element)
let different = MenuBarItem(id: "1-1", pid: 1, appName: "A", title: "T", icon: NSImage(), element: element)
check(first == same, "menu bar items compare by id and title")
check(first != different, "different ids are not equal")
// Control Center items map to distinct symbols instead of one shared icon.
check(MenuBarItemManager.symbolName(for: "Wi\u{2011}Fi, connected, 3 bars") == "wifi", "wi-fi maps to wifi symbol")
check(MenuBarItemManager.symbolName(for: "Battery") == "battery.100", "battery maps to battery symbol")
check(MenuBarItemManager.symbolName(for: "Now Playing") == "play.circle", "now playing maps to play symbol")
check(MenuBarItemManager.symbolName(for: "Control Center") == "switch.2", "control center maps to switch symbol")
check(MenuBarItemManager.symbolName(for: "Search") == "magnifyingglass", "search maps to magnifier symbol")
check(MenuBarItemManager.symbolName(for: "cmux") == nil, "unknown titles fall back to the app icon")
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)
}
+99 -3
View File
@@ -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 other apps' icons off the menu bar so they only live here. Apple's Wi-Fi, Spotlight, Clock, Battery and Control Center stay visible; macOS won't let any third-party app move them.")
}
}
}
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.icon)
.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
View File
@@ -2,7 +2,7 @@
set -euo pipefail
cd "$(dirname "$0")"
VERSION="${1:-0.1.0}"
VERSION="${1:-0.2.3}"
swift build -c release