diff --git a/AGENTS.md b/AGENTS.md index 2bb8be7..5c11c3f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/Sources/SimpleTopBar/App.swift b/Sources/SimpleTopBar/App.swift index 5163862..5f8fe28 100644 --- a/Sources/SimpleTopBar/App.swift +++ b/Sources/SimpleTopBar/App.swift @@ -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 ` 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 { diff --git a/Sources/SimpleTopBar/MenuBarItems.swift b/Sources/SimpleTopBar/MenuBarItems.swift index 90b8cb0..d301303 100644 --- a/Sources/SimpleTopBar/MenuBarItems.swift +++ b/Sources/SimpleTopBar/MenuBarItems.swift @@ -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 diff --git a/Sources/SimpleTopBar/SelfTest.swift b/Sources/SimpleTopBar/SelfTest.swift index dac5ac9..384dfcc 100644 --- a/Sources/SimpleTopBar/SelfTest.swift +++ b/Sources/SimpleTopBar/SelfTest.swift @@ -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. diff --git a/build-app.sh b/build-app.sh index 9eb7da7..1f859d2 100755 --- a/build-app.sh +++ b/build-app.sh @@ -2,7 +2,7 @@ set -euo pipefail cd "$(dirname "$0")" -VERSION="${1:-0.2.1}" +VERSION="${1:-0.2.2}" swift build -c release