Sliding the icons off the screen meant bringing them all back just to
open one menu, which flickered. Draw a frosted window over the other
apps' icons instead: they keep their real positions, so tapping a tile
opens its menu in place with no reveal, and the cover leaves a gap so
the hamburger is never hidden.
💘 Generated with Crush
Assisted-by: Crush:deepseek-v4.1-flash
163 lines
8.7 KiB
Markdown
163 lines
8.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.
|
||
- 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 (cover)
|
||
|
||
`MenuBarHider` drives `MenuBarCover`, which hides other apps' icons by drawing
|
||
our own frosted window ("cover") over them instead of moving them off-screen.
|
||
Because the icons keep their real positions, `press` opens their menus in place
|
||
with no reveal. Verified with `--diag-cover` (cover windows at level 26) and
|
||
`--diag-press` (the WireGuard menu opens at a positive x, not off-screen).
|
||
|
||
- `MenuBarCover.shouldHide` covers everything except the Apple keep-list
|
||
(Wi-Fi, Battery, Clock, Control Center, Spotlight/Search).
|
||
- `runs(from:)` merges adjacent item frames into contiguous cover windows;
|
||
`subtracting(_:from:)` carves out the hamburger so it is never covered
|
||
(`MenuBarItemManager.refresh` passes `avoid:` = our own item's frame).
|
||
- Frames come from AX (`kAXPositionAttribute` + `kAXSizeAttribute`) with a
|
||
**top-left origin**; `windowFrame(for:primaryHeight:)` flips Y for NSWindow.
|
||
- The cover window level is 26 (just above status items at 25, below menus at
|
||
~101), so opened menus draw on top of it.
|
||
- `ignoresMouseEvents = false` makes the covered strip swallow clicks so the
|
||
hidden icons cannot be clicked through.
|
||
|
||
### Legacy: off-screen spacer
|
||
|
||
An earlier approach used a zero-width spacer `NSStatusItem` grown to width
|
||
10_000 to push icons off the left edge. It is no longer used because it required
|
||
a visible reveal to open a menu. Findings if it is ever revived: preferred
|
||
position ~250 kept Wi-Fi while hiding apps, and `debug.spacerPos` overrode it.
|
||
|
||
## 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` is restored on quit, and because the
|
||
cover windows belong to the process, a crash removes them automatically.
|
||
- `.gitignore` covers `.build/`, `SimpleTopBar.app/`, `dist/` — build artifacts
|
||
should never be committed.
|