Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92a57308b7 | ||
|
|
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,36 @@ 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).
|
||||||
|
|
||||||
|
## Hiding other menu bar items
|
||||||
|
|
||||||
|
`MenuBarHider` creates a zero-width spacer `NSStatusItem` at launch and grows its
|
||||||
|
length to `hiddenLength` (10_000) to push the icons on its left off-screen.
|
||||||
|
Verified with `--diag-hide`:
|
||||||
|
|
||||||
|
- All third-party apps' icons go to x < 0 (cmux, Kapture, moomoo, WireGuard,
|
||||||
|
TextInputMenuAgent, Now Playing) and so does Wi-Fi.
|
||||||
|
- **Spotlight, Clock, Battery and Control Center stay**: macOS pins them to the
|
||||||
|
far right, and a third-party item can never be inserted to their right, so the
|
||||||
|
spacer has nothing to push them with. Do not try to "fix" this; it is a macOS
|
||||||
|
constraint, not a bug.
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- `AppListModel` is `@MainActor`; the `NSWorkspace` observers hop back with
|
- `AppListModel` is `@MainActor`; the `NSWorkspace` observers hop back with
|
||||||
|
|||||||
@@ -27,11 +27,21 @@ still works.
|
|||||||
|
|
||||||
macOS has no public API to move another app's status item. The **Hide items from
|
macOS has no public API to move another app's status item. The **Hide items from
|
||||||
the menu bar** toggle uses the spacer technique: a zero-width status item is
|
the menu bar** toggle uses the spacer technique: a zero-width status item is
|
||||||
created at launch whose length is expanded to push the items to its left
|
created at launch whose length is expanded to push the other icons off-screen so
|
||||||
off-screen. This is reliable only when SimpleTopBar starts at login (its spacer
|
they only live in this panel. It is off by default and always undone on quit; if
|
||||||
then sits to the right of later-launched apps' items) and may need re-toggling
|
the app dies, macOS removes the spacer automatically.
|
||||||
after items move around. It is off by default and always undone on quit; if the
|
|
||||||
app dies, macOS removes the spacer automatically.
|
What it can and cannot hide:
|
||||||
|
|
||||||
|
- It hides **other third-party apps' icons** (and can also catch some Apple
|
||||||
|
items such as Wi-Fi).
|
||||||
|
- It **cannot** hide **Spotlight, Clock, Battery, or Control Center**. macOS
|
||||||
|
reserves the far right of the menu bar for those, and a third-party app cannot
|
||||||
|
place its item to their right, so there is nothing to push them with. (This is
|
||||||
|
the same limit Bartender and Ice hit; they cover the area with their own
|
||||||
|
overlay bar instead.)
|
||||||
|
- Newly launched apps are placed to the left of the spacer, so they are hidden
|
||||||
|
automatically.
|
||||||
|
|
||||||
## Install with Homebrew
|
## Install with Homebrew
|
||||||
|
|
||||||
|
|||||||
@@ -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,26 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
|
if CommandLine.arguments.contains("--diag-hide") {
|
||||||
|
MenuBarHider.shared.enabled = true
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
|
||||||
|
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 +75,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
|
||||||
@@ -155,7 +165,15 @@ final class MenuBarHider: ObservableObject {
|
|||||||
/// Create the spacer early (at launch) so it lands as far right as possible.
|
/// Create the spacer early (at launch) so it lands as far right as possible.
|
||||||
func prepare() {
|
func prepare() {
|
||||||
guard spacer == nil else { return }
|
guard spacer == nil else { return }
|
||||||
|
let name = "SimpleTopBarHider"
|
||||||
|
let key = "NSStatusItem Preferred Position \(name)"
|
||||||
|
// Seed just left of the hamburger so growing the spacer pushes the
|
||||||
|
// third-party icons on its left off-screen while ours stays put.
|
||||||
|
if UserDefaults.standard.object(forKey: key) == nil {
|
||||||
|
UserDefaults.standard.set(150.0, forKey: key)
|
||||||
|
}
|
||||||
let item = NSStatusBar.system.statusItem(withLength: 0)
|
let item = NSStatusBar.system.statusItem(withLength: 0)
|
||||||
|
item.autosaveName = name
|
||||||
item.button?.isHidden = true
|
item.button?.isHidden = true
|
||||||
spacer = item
|
spacer = item
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ struct TopBarView: View {
|
|||||||
.controlSize(.mini)
|
.controlSize(.mini)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.top, 6)
|
.padding(.top, 6)
|
||||||
.help("Best-effort: pushes the real items off the menu bar so they only live here. Reliable when SimpleTopBar starts at login.")
|
.help("Best-effort: pushes other apps' icons off the menu bar so they only live here. Apple's Spotlight, Clock, Battery and Control Center stay pinned on the right and cannot be moved by any third-party app.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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.3}"
|
||||||
|
|
||||||
swift build -c release
|
swift build -c release
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user