Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8df3e2cd94 | ||
|
|
d9eddf799f | ||
|
|
dec8d60aa3 | ||
|
|
5eb2d0f661 | ||
|
|
5a4e2fbda2 |
@@ -38,6 +38,14 @@ executable SwiftPM package, no external dependencies (all Apple frameworks).
|
|||||||
or per-element, so every helper returns `nil` instead of force-unwrapping. The
|
or per-element, so every helper returns `nil` instead of force-unwrapping. The
|
||||||
`enumerate()` routine is `nonisolated static` so `--self-test` can run it.
|
`enumerate()` routine is `nonisolated static` so `--self-test` can run it.
|
||||||
- Items are keyed by `pid-index`; `MenuBarItem` equality ignores the AX element.
|
- Items are keyed by `pid-index`; `MenuBarItem` equality ignores the AX element.
|
||||||
|
- Control Center is special: its extras menu bar lists **every module it owns**,
|
||||||
|
including ones not in the bar, which are parked off-screen at `y≈982`.
|
||||||
|
`enumerate()` drops items whose AX position `y > 60` and de-duplicates by
|
||||||
|
title per app, otherwise the tray fills with identical "Control Center" tiles.
|
||||||
|
- Icons: every Control Center module shares the same app icon, so
|
||||||
|
`symbolName(for:)` maps titles (Wi‑Fi, Battery, Now Playing, …) to distinct SF
|
||||||
|
Symbols, falling back to the owning app's icon. Note the non-breaking hyphen
|
||||||
|
in "Wi‑Fi" (`\u{2011}`) is normalised before matching.
|
||||||
- Reading another app's UI is the only TCC permission needed; there is no Screen
|
- Reading another app's UI is the only TCC permission needed; there is no Screen
|
||||||
Recording or Apple Events usage.
|
Recording or Apple Events usage.
|
||||||
|
|
||||||
@@ -115,14 +123,23 @@ no control over this, so the app creates its own `NSStatusItem` and:
|
|||||||
## Hiding other menu bar items
|
## Hiding other menu bar items
|
||||||
|
|
||||||
`MenuBarHider` creates a zero-width spacer `NSStatusItem` at launch and grows its
|
`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.
|
length to `hiddenLength` (10_000) to push the icons on its left off-screen. It is
|
||||||
Verified with `--diag-hide`:
|
**on by default** (`hideMenuBarItems` defaults to true, then persisted). Its
|
||||||
|
preferred position controls where the hidden/kept boundary lands:
|
||||||
|
|
||||||
- All third-party apps' icons go to x < 0 (cmux, Kapture, moomoo, WireGuard,
|
- Seed **250** (default) puts the boundary just left of Wi-Fi, so every
|
||||||
TextInputMenuAgent, Now Playing) and so does Wi-Fi.
|
third-party app icon goes to x < 0 (cmux, Kapture, moomoo, WireGuard,
|
||||||
- **Spotlight, Clock, Battery and Control Center stay**: macOS pins them to the
|
TextInputMenuAgent, Now Playing) while **Wi-Fi (x≈1173), Spotlight, Clock,
|
||||||
far right, and a third-party item can never be inserted to their right, so the
|
Battery, Control Center and our hamburger stay visible**.
|
||||||
spacer has nothing to push them with. Do not try to "fix" this; it is a macOS
|
- Verified with `--diag-hide` across positions 100–400. Below ~200 Wi-Fi gets
|
||||||
|
hidden too; above ~300 app icons (Now Playing, TextInput) leak back.
|
||||||
|
- `debug.spacerPos` (Double) overrides the seed for tuning without rebuilding.
|
||||||
|
- **Order matters**: `AppDelegate` must call `setupStatusItem()` *before*
|
||||||
|
`MenuBarHider.shared.prepare()`. If the hider contracts the bar first, the
|
||||||
|
hamburger is created into the collapsed space and lands off-screen.
|
||||||
|
- **Wi-Fi/Spotlight/Clock/Battery/Control Center cannot be hidden**: 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. This is a macOS
|
||||||
constraint, not a bug.
|
constraint, not a bug.
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|||||||
@@ -28,20 +28,25 @@ 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 other icons off-screen so
|
created at launch whose length is expanded to push the other icons off-screen so
|
||||||
they only live in this panel. It is off by default and always undone on quit; if
|
they only live in this panel. It is **on by default** (the choice is
|
||||||
the app dies, macOS removes the spacer automatically.
|
remembered), always undone on quit, and if the app dies macOS removes the spacer
|
||||||
|
automatically.
|
||||||
|
|
||||||
What it can and cannot hide:
|
What it can and cannot hide:
|
||||||
|
|
||||||
- It hides **other third-party apps' icons** (and can also catch some Apple
|
- It hides **other third-party apps' icons** (cmux, Kapture, WireGuard, input
|
||||||
items such as Wi-Fi).
|
source, Now Playing, …) so only your apps' icons leave the bar.
|
||||||
- It **cannot** hide **Spotlight, Clock, Battery, or Control Center**. macOS
|
- It **keeps Wi-Fi, Spotlight, Clock, Battery and Control Center**. The spacer's
|
||||||
reserves the far right of the menu bar for those, and a third-party app cannot
|
boundary is tuned (preferred position 250) to fall just left of Wi-Fi, so the
|
||||||
place its item to their right, so there is nothing to push them with. (This is
|
Apple items on the right stay visible. macOS reserves that far-right area: a
|
||||||
the same limit Bartender and Ice hit; they cover the area with their own
|
third-party item can never be placed to the right of it, so those icons cannot
|
||||||
overlay bar instead.)
|
be pushed away even in principle. (Bartender and Ice hit the same wall and
|
||||||
|
cover the area with their own overlay bar instead.)
|
||||||
- Newly launched apps are placed to the left of the spacer, so they are hidden
|
- Newly launched apps are placed to the left of the spacer, so they are hidden
|
||||||
automatically.
|
automatically.
|
||||||
|
- Advanced: set `defaults write dev.tzgyn.simpletopbar debug.spacerPos -float <n>`
|
||||||
|
to move the boundary (larger = keep more icons; > ~400 starts leaking app
|
||||||
|
icons back into the bar).
|
||||||
|
|
||||||
## Install with Homebrew
|
## Install with Homebrew
|
||||||
|
|
||||||
|
|||||||
@@ -43,8 +43,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
|
|
||||||
AppListModel.shared.start()
|
AppListModel.shared.start()
|
||||||
MenuBarItemManager.shared.start()
|
MenuBarItemManager.shared.start()
|
||||||
MenuBarHider.shared.prepare()
|
// Create the hamburger before the hider so it is placed in the visible
|
||||||
|
// part of the bar; the hider then contracts the space around it.
|
||||||
setupStatusItem()
|
setupStatusItem()
|
||||||
|
MenuBarHider.shared.prepare()
|
||||||
|
|
||||||
if CommandLine.arguments.contains("--diag") {
|
if CommandLine.arguments.contains("--diag") {
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ struct MenuBarItem: Identifiable, Equatable {
|
|||||||
let pid: pid_t
|
let pid: pid_t
|
||||||
let appName: String
|
let appName: String
|
||||||
let title: String
|
let title: String
|
||||||
let appIcon: NSImage
|
let icon: NSImage
|
||||||
let element: AXUIElement
|
let element: AXUIElement
|
||||||
|
|
||||||
static func == (lhs: MenuBarItem, rhs: MenuBarItem) -> Bool {
|
static func == (lhs: MenuBarItem, rhs: MenuBarItem) -> Bool {
|
||||||
@@ -68,17 +68,29 @@ final class MenuBarItemManager: ObservableObject {
|
|||||||
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 }
|
||||||
|
|
||||||
|
var seenTitles = Set<String>()
|
||||||
for (index, child) in children.enumerated() {
|
for (index, child) in children.enumerated() {
|
||||||
|
// Control Center reports every module it owns, including the
|
||||||
|
// ones not in the menu bar, which are parked off-screen around
|
||||||
|
// y=982. Keep only items actually sitting in the bar.
|
||||||
|
if let position = pointAttribute(child, kAXPositionAttribute), position.y > 60 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
let title = stringAttribute(child, kAXDescriptionAttribute)
|
let title = stringAttribute(child, kAXDescriptionAttribute)
|
||||||
?? stringAttribute(child, kAXTitleAttribute)
|
?? stringAttribute(child, kAXTitleAttribute)
|
||||||
?? app.localizedName
|
?? app.localizedName
|
||||||
?? "Item"
|
?? "Item"
|
||||||
|
// Several Control Center modules share the generic title
|
||||||
|
// "Control Center"; show each distinct title once per app.
|
||||||
|
if !seenTitles.insert(title).inserted { continue }
|
||||||
|
|
||||||
found.append(MenuBarItem(
|
found.append(MenuBarItem(
|
||||||
id: "\(pid)-\(index)",
|
id: "\(pid)-\(index)",
|
||||||
pid: pid,
|
pid: pid,
|
||||||
appName: app.localizedName ?? app.bundleIdentifier ?? "Unknown",
|
appName: app.localizedName ?? app.bundleIdentifier ?? "Unknown",
|
||||||
title: title,
|
title: title,
|
||||||
appIcon: app.icon ?? NSImage(named: NSImage.applicationIconName) ?? NSImage(),
|
icon: Self.icon(forTitle: title, app: app),
|
||||||
element: child
|
element: child
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
@@ -101,6 +113,52 @@ final class MenuBarItemManager: ObservableObject {
|
|||||||
return AXUIElementPerformAction(item.element, kAXShowMenuAction as CFString) == .success
|
return AXUIElementPerformAction(item.element, kAXShowMenuAction as CFString) == .success
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Icons
|
||||||
|
|
||||||
|
/// Picks an icon for a menu bar item. Control Center owns many unrelated
|
||||||
|
/// modules, and they all share the Control Center app icon, so map their
|
||||||
|
/// titles to distinct SF Symbols for readability. Anything unknown falls
|
||||||
|
/// back to the owning app's icon.
|
||||||
|
nonisolated static func icon(forTitle title: String, app: NSRunningApplication) -> NSImage {
|
||||||
|
if let symbol = symbolName(for: title),
|
||||||
|
let image = NSImage(systemSymbolName: symbol, accessibilityDescription: title) {
|
||||||
|
image.isTemplate = true
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
return app.icon ?? NSImage(named: NSImage.applicationIconName) ?? NSImage()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keyword table mapping a menu bar item's title/description to an SF Symbol.
|
||||||
|
nonisolated static func symbolName(for title: String) -> String? {
|
||||||
|
// Control Center uses a non-breaking hyphen in "Wi‑Fi".
|
||||||
|
let text = title.lowercased()
|
||||||
|
.replacingOccurrences(of: "\u{2010}", with: "-")
|
||||||
|
.replacingOccurrences(of: "\u{2011}", with: "-")
|
||||||
|
let table: [(matches: [String], symbol: String)] = [
|
||||||
|
(["wi-fi", "wifi"], "wifi"),
|
||||||
|
(["battery"], "battery.100"),
|
||||||
|
(["clock", "time"], "clock"),
|
||||||
|
(["now playing"], "play.circle"),
|
||||||
|
(["focus"], "moon.fill"),
|
||||||
|
(["sound", "volume", "audio"], "speaker.wave.2"),
|
||||||
|
(["airdrop"], "dot.radiowaves.left.and.right"),
|
||||||
|
(["bluetooth"], "dot.radiowaves.left.and.right"),
|
||||||
|
(["mirroring"], "rectangle.on.rectangle"),
|
||||||
|
(["search", "spotlight"], "magnifyingglass"),
|
||||||
|
(["siri"], "waveform"),
|
||||||
|
(["keyboard"], "keyboard"),
|
||||||
|
(["stage manager"], "rectangle.3.group"),
|
||||||
|
(["brightness", "display"], "sun.max"),
|
||||||
|
(["control center"], "switch.2"),
|
||||||
|
(["notification"], "bell"),
|
||||||
|
(["vpn"], "lock.shield"),
|
||||||
|
]
|
||||||
|
for entry in table where entry.matches.contains(where: { text.contains($0) }) {
|
||||||
|
return entry.symbol
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - AX helpers
|
// MARK: - AX helpers
|
||||||
|
|
||||||
nonisolated static func extrasMenuBar(of app: AXUIElement) -> AXUIElement? {
|
nonisolated static func extrasMenuBar(of app: AXUIElement) -> AXUIElement? {
|
||||||
@@ -139,10 +197,10 @@ final class MenuBarItemManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 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
|
||||||
/// at launch; expanding its length to a huge value pushes the items to its left
|
/// at launch; expanding its length to a huge value pushes the other apps' icons
|
||||||
/// off the visible menu bar, so they can only be reached through SimpleTopBar's
|
/// off the visible menu bar, so they can only be reached through SimpleTopBar's
|
||||||
/// panel. Reliable only when SimpleTopBar starts at login (its spacer then sits
|
/// panel. On by default; the choice is persisted. Always restored on quit, and
|
||||||
/// to the right of later-launched apps' items). Always restored on quit.
|
/// a crash cleans up automatically because status items die with the process.
|
||||||
@MainActor
|
@MainActor
|
||||||
final class MenuBarHider: ObservableObject {
|
final class MenuBarHider: ObservableObject {
|
||||||
static let shared = MenuBarHider()
|
static let shared = MenuBarHider()
|
||||||
@@ -150,45 +208,55 @@ final class MenuBarHider: ObservableObject {
|
|||||||
/// Width that pushes the neighbouring items off the visible menu bar.
|
/// Width that pushes the neighbouring items off the visible menu bar.
|
||||||
static let hiddenLength: CGFloat = 10_000
|
static let hiddenLength: CGFloat = 10_000
|
||||||
|
|
||||||
|
private static let defaultsKey = "hideMenuBarItems"
|
||||||
|
|
||||||
@Published private(set) var isHiding = false
|
@Published private(set) var isHiding = false
|
||||||
@Published var enabled = false {
|
@Published var enabled: Bool {
|
||||||
didSet {
|
didSet {
|
||||||
guard enabled != oldValue else { return }
|
guard enabled != oldValue else { return }
|
||||||
|
UserDefaults.standard.set(enabled, forKey: Self.defaultsKey)
|
||||||
apply(enabled)
|
apply(enabled)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var spacer: NSStatusItem?
|
private var spacer: NSStatusItem?
|
||||||
|
|
||||||
private init() {}
|
private init() {
|
||||||
|
// Hide by default: the menu bar then shows only SimpleTopBar's hamburger
|
||||||
|
// plus the Apple items macOS will not let us move.
|
||||||
|
enabled = UserDefaults.standard.object(forKey: Self.defaultsKey) as? Bool ?? true
|
||||||
|
}
|
||||||
|
|
||||||
/// 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 name = "SimpleTopBarHider"
|
||||||
let key = "NSStatusItem Preferred Position \(name)"
|
let key = "NSStatusItem Preferred Position \(name)"
|
||||||
// Seed just left of the hamburger so growing the spacer pushes the
|
// The boundary between "hidden" and "kept" falls just left of Wi-Fi at
|
||||||
// third-party icons on its left off-screen while ours stays put.
|
// ~250, so the other apps hide while Apple's Wi-Fi/Clock/Battery/Control
|
||||||
if UserDefaults.standard.object(forKey: key) == nil {
|
// Center/Spotlight stay visible. `debug.spacerPos` overrides the seed.
|
||||||
UserDefaults.standard.set(150.0, forKey: key)
|
if let override = UserDefaults.standard.object(forKey: "debug.spacerPos") as? Double {
|
||||||
|
UserDefaults.standard.set(override, forKey: key)
|
||||||
|
} else if UserDefaults.standard.object(forKey: key) == nil {
|
||||||
|
UserDefaults.standard.set(250.0, forKey: key)
|
||||||
}
|
}
|
||||||
let item = NSStatusBar.system.statusItem(withLength: 0)
|
let item = NSStatusBar.system.statusItem(withLength: 0)
|
||||||
item.autosaveName = name
|
item.autosaveName = name
|
||||||
item.button?.isHidden = true
|
item.button?.isHidden = true
|
||||||
spacer = item
|
spacer = item
|
||||||
|
apply(enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func apply(_ hiding: Bool) {
|
private func apply(_ hiding: Bool) {
|
||||||
prepare()
|
|
||||||
spacer?.length = hiding ? Self.hiddenLength : 0
|
spacer?.length = hiding ? Self.hiddenLength : 0
|
||||||
isHiding = hiding
|
isHiding = hiding
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Put the menu bar back the way we found it. Called on quit.
|
/// Put the menu bar back the way we found it. Called on quit. Leaves the
|
||||||
|
/// user's preference intact.
|
||||||
func restore() {
|
func restore() {
|
||||||
spacer?.length = 0
|
spacer?.length = 0
|
||||||
isHiding = false
|
isHiding = false
|
||||||
enabled = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -80,12 +80,20 @@ func runSelfTest() {
|
|||||||
check(MenuBarItemManager.children(of: systemWide) == nil, "no children on a non-app element")
|
check(MenuBarItemManager.children(of: systemWide) == nil, "no children on a non-app element")
|
||||||
|
|
||||||
let element = AXUIElementCreateSystemWide()
|
let element = AXUIElementCreateSystemWide()
|
||||||
let first = MenuBarItem(id: "1-0", pid: 1, appName: "A", title: "T", appIcon: NSImage(), element: element)
|
let first = MenuBarItem(id: "1-0", pid: 1, appName: "A", title: "T", icon: NSImage(), element: element)
|
||||||
let same = MenuBarItem(id: "1-0", pid: 1, appName: "A", title: "T", appIcon: NSImage(), element: element)
|
let same = MenuBarItem(id: "1-0", pid: 1, appName: "A", title: "T", icon: NSImage(), element: element)
|
||||||
let different = MenuBarItem(id: "1-1", pid: 1, appName: "A", title: "T", appIcon: NSImage(), element: element)
|
let different = MenuBarItem(id: "1-1", pid: 1, appName: "A", title: "T", icon: NSImage(), element: element)
|
||||||
check(first == same, "menu bar items compare by id and title")
|
check(first == same, "menu bar items compare by id and title")
|
||||||
check(first != different, "different ids are not equal")
|
check(first != different, "different ids are not equal")
|
||||||
|
|
||||||
|
// Control Center items map to distinct symbols instead of one shared icon.
|
||||||
|
check(MenuBarItemManager.symbolName(for: "Wi\u{2011}Fi, connected, 3 bars") == "wifi", "wi-fi maps to wifi symbol")
|
||||||
|
check(MenuBarItemManager.symbolName(for: "Battery") == "battery.100", "battery maps to battery symbol")
|
||||||
|
check(MenuBarItemManager.symbolName(for: "Now Playing") == "play.circle", "now playing maps to play symbol")
|
||||||
|
check(MenuBarItemManager.symbolName(for: "Control Center") == "switch.2", "control center maps to switch symbol")
|
||||||
|
check(MenuBarItemManager.symbolName(for: "Search") == "magnifyingglass", "search maps to magnifier symbol")
|
||||||
|
check(MenuBarItemManager.symbolName(for: "cmux") == nil, "unknown titles fall back to the app icon")
|
||||||
|
|
||||||
let authorized = AXIsProcessTrusted()
|
let authorized = AXIsProcessTrusted()
|
||||||
print("accessibility authorized=\(authorized)")
|
print("accessibility authorized=\(authorized)")
|
||||||
if authorized {
|
if authorized {
|
||||||
|
|||||||
@@ -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 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.")
|
.help("Best-effort: pushes other apps' icons off the menu bar so they only live here. Apple's Wi-Fi, Spotlight, Clock, Battery and Control Center stay visible; macOS won't let any third-party app move them.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -245,8 +245,11 @@ private struct MenuBarTile: View {
|
|||||||
Button {
|
Button {
|
||||||
menuBar.press(item)
|
menuBar.press(item)
|
||||||
} label: {
|
} label: {
|
||||||
Image(nsImage: item.appIcon)
|
// Preserve the symbol's aspect ratio: forcing wide symbols like the
|
||||||
|
// battery into a square stretches them into a blocky shape.
|
||||||
|
Image(nsImage: item.icon)
|
||||||
.resizable()
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
.frame(width: 20, height: 20)
|
.frame(width: 20, height: 20)
|
||||||
.padding(6)
|
.padding(6)
|
||||||
.background(
|
.background(
|
||||||
|
|||||||
Reference in New Issue
Block a user