Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8767b73234 |
@@ -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`
|
||||||
@@ -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
|
(see the README release section for the exact block), because the app is
|
||||||
installed through the tap.
|
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
|
||||||
|
|||||||
@@ -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,10 +44,18 @@ 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
|
// The tray is empty without Accessibility permission, so surface the
|
||||||
// system prompt once at launch instead of waiting for the user to
|
// system prompt once at launch instead of waiting for the user to open
|
||||||
// open the panel and find the button.
|
// the panel and find the button.
|
||||||
if !AccessibilityPermission.isGranted,
|
if !AccessibilityPermission.isGranted,
|
||||||
!UserDefaults.standard.bool(forKey: "didRequestAccessibility") {
|
!UserDefaults.standard.bool(forKey: "didRequestAccessibility") {
|
||||||
UserDefaults.standard.set(true, 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.
|
// 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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -2,7 +2,7 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
VERSION="${1:-0.2.1}"
|
VERSION="${1:-0.2.2}"
|
||||||
|
|
||||||
swift build -c release
|
swift build -c release
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user