Keeping every icon on the menu bar for as long as the panel was open
looked cluttered. Reveal them just long enough to open the clicked
item's menu, then hide again, so the panel stays clean.
💘 Generated with Crush
Assisted-by: Crush:deepseek-v4.1-flash
9.1 KiB
AGENTS.md
SimpleTopBar is a native macOS menu-bar app written in Swift/SwiftUI. Single executable SwiftPM package, no external dependencies (all Apple frameworks).
Architecture
Sources/SimpleTopBar/App.swift—@mainentry using a bareSettings { EmptyView() }scene, because the app owns its status item manually instead of usingMenuBarExtra.AppDelegate.setupStatusItem()creates theNSStatusItem, setsautosaveName = "SimpleTopBar", and showsTopBarViewinside anNSPopover. Handles--self-test,--dump-items,--diag, and the single-instance guard.LoginItemwrapsSMAppService.Sources/SimpleTopBar/AppListModel.swift—@MainActorObservableObjectsingleton.refresh()readsNSWorkspace.shared.runningApplicationsand splits them byactivationPolicyintoregularAppsandbackgroundApps(both sorted by name). Subscribes to theNSWorkspacelaunch/terminate/ activate/hide/unhide notifications to refresh automatically.Sources/SimpleTopBar/TopBarView.swift— the panel.menuBarSectionis the hamburger tray of other apps' menu bar items;section(...)renders the regular apps;backgroundSectionis the collapsible dropdown for background apps.AppRowshows icon + name + active dot with a right-click Activate/Hide/Quit menu.Sources/SimpleTopBar/MenuBarItems.swift—MenuBarItemManagerenumerates other apps' status items via the Accessibility API (kAXExtrasMenuBarAttribute→kAXChildrenAttribute) and presses them (kAXPressAction, falling back tokAXShowMenuAction).MenuBarHiderowns the zero-width spacerNSStatusItemused to push items off the menu bar.AccessibilityPermissionwrapsAXIsProcessTrusted/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 whenAXIsProcessTrusted()is false, andTopBarViewshows a "Grant Accessibility…" button in that state. Never assume permission. - Enumeration must be resilient:
AXUIElementCopyAttributeValuecan fail per-app or per-element, so every helper returnsnilinstead of force-unwrapping. Theenumerate()routine isnonisolated staticso--self-testcan run it. - Items are keyed by
pid-index;MenuBarItemequality 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 positiony > 60and 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:
- The background-app section is a dropdown, not a flat list. Collapsed, it is one header row showing the count.
- Expanded, it shows at most
AppListModel.backgroundPageSize(5) apps. Any overflow is hidden behind a "Show N more" row, so the panel never grows unbounded. Collapsing resets the page. AppListModeldrives all of this:backgroundExpanded,backgroundVisibleCount,visibleBackgroundApps,hiddenBackgroundCount,canShowMoreBackgroundApps,toggleBackgroundExpanded(),showMoreBackgroundApps().- The pure helpers
clampedVisibleCountandnextVisibleCountare the overflow math and are covered by--self-test. Keep them pure andnonisolated(no AppKit, no actor state).
Commands
swift build # debug build
swift run SimpleTopBar # dev run (menu bar icon appears)
swift run SimpleTopBar --self-test # headless overflow + classification check, exit 0/1
./build-app.sh 0.1.0 # release build + .app bundle + icon + codesign
./release.sh 0.1.0 # build-app.sh + dist/SimpleTopBar.zip + sha256
scripts/make-icon.sh # regenerate SimpleTopBar.app/Contents/Resources/Icon.icns
There is no test target and no linter/formatter configured. Verification = build
--self-test.
Bundle build & signing
build-app.sh does: release build → handwritten Info.plist (NOT generated
from Package.swift) → make-icon.sh → codesign. It resolves a
Developer ID Application identity from the login keychain and falls back to
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
./release.sh <version>— builds, zipsSimpleTopBar.apptodist/SimpleTopBar.zip, and prints its sha256 (it does not tag or upload).- Commit and push
main. tea releases create --tag v<version> --asset dist/SimpleTopBar.zip --repo TZGyn/simpletopbar --login gitea.tzgyn.dev.- Bump
version/sha256in~/.cache/homebrew-tap/Casks/simpletopbar.rb, push, thengit -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" = 100intoUserDefaults.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-hideacross 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.- Pressing a tray tile calls
MenuBarItemManager.press. A hidden item sits off-screen, and its menu would open off-screen too, so press brieflysuspend()s the hider, waits ~0.18s for the layout to settle, performs the accessibility action (kAXPressAction, thenkAXShowMenuAction), andresume()s ~1s later. The reveal is momentary, not for the whole time the panel is open. macOS does not let us place or anchor another app's menu anywhere else, so a momentary reveal is the only way to show it in view. - Order matters:
AppDelegatemust callsetupStatusItem()beforeMenuBarHider.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
AppListModelis@MainActor; theNSWorkspaceobservers hop back withTask { @MainActor in ... }because the notification closure is nonisolated.- The app excludes itself from the list via
Bundle.main.bundleIdentifier, and the single-instance guard terminates stale copies with the same bundle id. - Reading
activationPolicyis reliable but must happen on the main thread; keep allNSWorkspaceaccess inside the model. - The menu bar tray needs Accessibility permission; do not add Screen Recording
or Apple Events usage.
MenuBarHidermust always be restored on quit, but because a status item dies with its process, a crash cleans up on its own. .gitignorecovers.build/,SimpleTopBar.app/,dist/— build artifacts should never be committed.