Closing the panel was not enough: SimpleTopBar stayed frontmost, so
Spotlight's search field never appeared. Deactivate the app when a tray
item is selected so transient panels can take focus, and allow a little
more time before the bar re-hides.
💘 Generated with Crush
Assisted-by: Crush:deepseek-v4.1-flash
172 lines
9.5 KiB
Markdown
172 lines
9.5 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.
|
||
- 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:
|
||
|
||
- 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).
|
||
|
||
## 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.
|
||
- Pressing a tray tile calls `MenuBarItemManager.press`. A hidden item sits
|
||
off-screen, and its menu opens off-screen too (verified with `--diag-press`:
|
||
the menu window lands at negative x). So press briefly `suspend()`s the hider,
|
||
waits ~0.2s for the layout to settle, performs the accessibility action
|
||
(`kAXPressAction`, then `kAXShowMenuAction`), and `resume()`s ~0.1s later. The
|
||
menu is its own window anchored at the item's on-screen spot, so it survives
|
||
re-hiding.
|
||
- `press` first calls `onWillPress`, which `AppDelegate` wires to close the
|
||
popover **and** `NSApp.deactivate()`. Closing alone left our app frontmost, so
|
||
transient items (Spotlight's search field) still would not appear.
|
||
- **Cannot reveal a single icon.** The spacer hides a contiguous block to its
|
||
left, so a press can only reveal the whole block. Isolating one item would
|
||
need either per-item ordering control (not available) or covering the icons
|
||
with an overlay window instead of moving them off-screen.
|
||
- **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
|
||
`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.
|