3 Commits
Author SHA1 Message Date
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
6 changed files with 170 additions and 17 deletions
+37 -4
View File
@@ -5,10 +5,12 @@ executable SwiftPM package, no external dependencies (all Apple frameworks).
## Architecture ## Architecture
- `Sources/SimpleTopBar/App.swift` — `@main` entry. `MenuBarExtra` with - `Sources/SimpleTopBar/App.swift` — `@main` entry using a bare
`.menuBarExtraStyle(.window)` hosting `TopBarView`. `AppDelegate` handles the `Settings { EmptyView() }` scene, because the app owns its status item
`--self-test` flag and the single-instance guard. `LoginItem` wraps manually instead of using `MenuBarExtra`. `AppDelegate.setupStatusItem()`
`SMAppService`. 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` - `Sources/SimpleTopBar/AppListModel.swift` — `@MainActor` `ObservableObject`
singleton. `refresh()` reads `NSWorkspace.shared.runningApplications` and singleton. `refresh()` reads `NSWorkspace.shared.runningApplications` and
splits them by `activationPolicy` into `regularApps` and `backgroundApps` splits them by `activationPolicy` into `regularApps` and `backgroundApps`
@@ -79,6 +81,37 @@ ad-hoc signing (`-`) if none is present. `LSUIElement` is true → no Dock icon.
Bump `CFBundleShortVersionString`/`CFBundleVersion` via the `build-app.sh` Bump `CFBundleShortVersionString`/`CFBundleVersion` via the `build-app.sh`
version argument and pass the same version to `release.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 ## Gotchas
- `AppListModel` is `@MainActor`; the `NSWorkspace` observers hop back with - `AppListModel` is `@MainActor`; the `NSWorkspace` observers hop back with
+15 -2
View File
@@ -44,14 +44,27 @@ brew install --cask simpletopbar
The tap is hosted on Gitea, so the full URL is required. Homebrew may print an The tap is hosted on Gitea, so the full URL is required. Homebrew may print an
"Unsupported tap" warning — it's harmless. "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 ## Build from source
```bash ```bash
swift build # debug build swift build # debug build
swift run SimpleTopBar # run from terminal (app appears in the menu bar) swift run SimpleTopBar # run from terminal (app appears in the menu bar)
swift run SimpleTopBar --self-test # headless check of the dropdown logic swift run SimpleTopBar --self-test # headless check of the dropdown logic
./build-app.sh 0.1.0 # release build + SimpleTopBar.app bundle + icon + codesign ./build-app.sh 0.2.0 # release build + SimpleTopBar.app bundle + icon + codesign
./release.sh 0.1.0 # build-app.sh + dist/SimpleTopBar.zip + sha256 ./release.sh 0.2.0 # build-app.sh + dist/SimpleTopBar.zip + sha256
``` ```
## Around the Background dropdown ## Around the Background dropdown
+78 -8
View File
@@ -7,24 +7,31 @@ struct SimpleTopBarApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
var body: some Scene { var body: some Scene {
MenuBarExtra("SimpleTopBar", systemImage: "line.3.horizontal") { // The app is a menu bar extra, so it has no real windows.
TopBarView( Settings { EmptyView() }
model: AppListModel.shared,
menuBar: MenuBarItemManager.shared,
hider: MenuBarHider.shared
)
}
.menuBarExtraStyle(.window)
} }
} }
/// 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 { final class AppDelegate: NSObject, NSApplicationDelegate {
private var statusItem: NSStatusItem?
private var popover: NSPopover?
func applicationDidFinishLaunching(_ notification: Notification) { func applicationDidFinishLaunching(_ notification: Notification) {
if CommandLine.arguments.contains("--self-test") { if CommandLine.arguments.contains("--self-test") {
runSelfTest() runSelfTest()
return return
} }
if CommandLine.arguments.contains("--dump-items") {
dumpMenuBarItems()
return
}
// Single instance: stale copies keep a menu bar icon around after an // Single instance: stale copies keep a menu bar icon around after an
// update and swallow clicks meant for the new instance. // update and swallow clicks meant for the new instance.
let myPid = ProcessInfo.processInfo.processIdentifier let myPid = ProcessInfo.processInfo.processIdentifier
@@ -37,12 +44,75 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
AppListModel.shared.start() AppListModel.shared.start()
MenuBarItemManager.shared.start() MenuBarItemManager.shared.start()
MenuBarHider.shared.prepare() MenuBarHider.shared.prepare()
setupStatusItem()
if CommandLine.arguments.contains("--diag") {
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
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) { func applicationWillTerminate(_ notification: Notification) {
// Never leave the menu bar resized behind us. // Never leave the menu bar resized behind us.
MenuBarHider.shared.restore() 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()
}
}
} }
enum LoginItem { enum LoginItem {
+12 -2
View File
@@ -57,13 +57,13 @@ final class MenuBarItemManager: ObservableObject {
items = Self.enumerate() items = Self.enumerate()
} }
nonisolated static func enumerate() -> [MenuBarItem] { nonisolated static func enumerate(includeOwn: Bool = false) -> [MenuBarItem] {
let ownPID = ProcessInfo.processInfo.processIdentifier let ownPID = ProcessInfo.processInfo.processIdentifier
var found: [MenuBarItem] = [] var found: [MenuBarItem] = []
for app in NSWorkspace.shared.runningApplications { for app in NSWorkspace.shared.runningApplications {
let pid = app.processIdentifier let pid = app.processIdentifier
if pid == ownPID { continue } if !includeOwn && pid == ownPID { continue }
let axApp = AXUIElementCreateApplication(pid) let axApp = AXUIElementCreateApplication(pid)
guard let bar = extrasMenuBar(of: axApp), guard let bar = extrasMenuBar(of: axApp),
let children = children(of: bar) else { continue } let children = children(of: bar) else { continue }
@@ -126,6 +126,16 @@ final class MenuBarItemManager: ObservableObject {
!string.isEmpty else { return nil } !string.isEmpty else { return nil }
return string 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 /// Best-effort menu bar item hiding. A zero-width spacer status item is created
+27
View File
@@ -1,6 +1,33 @@
import AppKit import AppKit
import ApplicationServices 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`. /// Headless verification for `swift run SimpleTopBar --self-test`.
/// Exercises the background-app overflow helpers and reports the live split /// Exercises the background-app overflow helpers and reports the live split
/// between regular and background applications, then exits 0/1. /// between regular and background applications, then exits 0/1.
+1 -1
View File
@@ -2,7 +2,7 @@
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")" cd "$(dirname "$0")"
VERSION="${1:-0.2.0}" VERSION="${1:-0.2.2}"
swift build -c release swift build -c release