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
This commit is contained in:
@@ -5,10 +5,12 @@ executable SwiftPM package, no external dependencies (all Apple frameworks).
|
||||
|
||||
## Architecture
|
||||
|
||||
- `Sources/SimpleTopBar/App.swift` — `@main` entry. `MenuBarExtra` with
|
||||
`.menuBarExtraStyle(.window)` hosting `TopBarView`. `AppDelegate` handles the
|
||||
`--self-test` flag and the single-instance guard. `LoginItem` wraps
|
||||
`SMAppService`.
|
||||
- `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`
|
||||
@@ -93,6 +95,23 @@ 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
|
||||
|
||||
@@ -7,24 +7,31 @@ struct SimpleTopBarApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
|
||||
var body: some Scene {
|
||||
MenuBarExtra("SimpleTopBar", systemImage: "line.3.horizontal") {
|
||||
TopBarView(
|
||||
model: AppListModel.shared,
|
||||
menuBar: MenuBarItemManager.shared,
|
||||
hider: MenuBarHider.shared
|
||||
)
|
||||
}
|
||||
.menuBarExtraStyle(.window)
|
||||
// The app is a menu bar extra, so it has no real windows.
|
||||
Settings { EmptyView() }
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
private var statusItem: NSStatusItem?
|
||||
private var popover: NSPopover?
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
if CommandLine.arguments.contains("--self-test") {
|
||||
runSelfTest()
|
||||
return
|
||||
}
|
||||
|
||||
if CommandLine.arguments.contains("--dump-items") {
|
||||
dumpMenuBarItems()
|
||||
return
|
||||
}
|
||||
|
||||
// Single instance: stale copies keep a menu bar icon around after an
|
||||
// update and swallow clicks meant for the new instance.
|
||||
let myPid = ProcessInfo.processInfo.processIdentifier
|
||||
@@ -37,10 +44,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
AppListModel.shared.start()
|
||||
MenuBarItemManager.shared.start()
|
||||
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.
|
||||
// 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")
|
||||
@@ -52,6 +67,52 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
// Never leave the menu bar resized behind us.
|
||||
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 {
|
||||
|
||||
@@ -57,13 +57,13 @@ final class MenuBarItemManager: ObservableObject {
|
||||
items = Self.enumerate()
|
||||
}
|
||||
|
||||
nonisolated static func enumerate() -> [MenuBarItem] {
|
||||
nonisolated static func enumerate(includeOwn: Bool = false) -> [MenuBarItem] {
|
||||
let ownPID = ProcessInfo.processInfo.processIdentifier
|
||||
var found: [MenuBarItem] = []
|
||||
|
||||
for app in NSWorkspace.shared.runningApplications {
|
||||
let pid = app.processIdentifier
|
||||
if pid == ownPID { continue }
|
||||
if !includeOwn && pid == ownPID { continue }
|
||||
let axApp = AXUIElementCreateApplication(pid)
|
||||
guard let bar = extrasMenuBar(of: axApp),
|
||||
let children = children(of: bar) else { continue }
|
||||
@@ -126,6 +126,16 @@ final class MenuBarItemManager: ObservableObject {
|
||||
!string.isEmpty else { return nil }
|
||||
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
|
||||
|
||||
@@ -1,6 +1,33 @@
|
||||
import AppKit
|
||||
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`.
|
||||
/// Exercises the background-app overflow helpers and reports the live split
|
||||
/// between regular and background applications, then exits 0/1.
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
VERSION="${1:-0.2.1}"
|
||||
VERSION="${1:-0.2.2}"
|
||||
|
||||
swift build -c release
|
||||
|
||||
|
||||
Reference in New Issue
Block a user