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
128 lines
6.7 KiB
Markdown
128 lines
6.7 KiB
Markdown
# 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` — `@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. `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:
|
|
|
|
- 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.
|
|
- `AppListModel` drives all of this: `backgroundExpanded`,
|
|
`backgroundVisibleCount`, `visibleBackgroundApps`, `hiddenBackgroundCount`,
|
|
`canShowMoreBackgroundApps`, `toggleBackgroundExpanded()`,
|
|
`showMoreBackgroundApps()`.
|
|
- The pure helpers `clampedVisibleCount` and `nextVisibleCount` are the
|
|
overflow math and are covered by `--self-test`. Keep them pure and
|
|
`nonisolated` (no AppKit, no actor state).
|
|
|
|
## Commands
|
|
|
|
```bash
|
|
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
|
|
|
|
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).
|
|
|
|
## Gotchas
|
|
|
|
- `AppListModel` is `@MainActor`; the `NSWorkspace` observers hop back with
|
|
`Task { @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 `activationPolicy` is reliable but must happen on the main thread;
|
|
keep all `NSWorkspace` access inside the model.
|
|
- 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.
|