Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abb0b548bf | ||
|
|
8d795d0a28 | ||
|
|
e465acba12 | ||
|
|
29157c77fb | ||
|
|
b0671abed8 | ||
|
|
569abe967f | ||
|
|
3e57efe312 | ||
|
|
2312e09105 | ||
|
|
9c5416d27e | ||
|
|
61d2689cbc | ||
|
|
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,37 @@ 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.
|
||||||
|
- Pressing a tray tile calls `MenuBarItemManager.press`. A hidden item sits
|
||||||
|
off-screen, and its menu opens off-screen too (verified with `--diag-press`:
|
||||||
|
the menu window lands at negative x). So press briefly `suspend()`s the hider,
|
||||||
|
waits ~0.2s for the layout to settle, performs the accessibility action
|
||||||
|
(`kAXPressAction`, then `kAXShowMenuAction`), and `resume()`s ~0.1s later. The
|
||||||
|
menu is its own window anchored at the item's on-screen spot, so it survives
|
||||||
|
re-hiding.
|
||||||
|
- `press` first calls `onWillPress`, which `AppDelegate` wires to close the
|
||||||
|
popover **and** `NSApp.deactivate()`. Closing alone left our app frontmost, so
|
||||||
|
transient items (Spotlight's search field) still would not appear.
|
||||||
|
- **Cannot reveal a single icon.** The spacer hides a contiguous block to its
|
||||||
|
left, so a press can only reveal the whole block. Isolating one item would
|
||||||
|
need either per-item ordering control (not available) or covering the icons
|
||||||
|
with an overlay window instead of moving them off-screen.
|
||||||
|
- **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
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ collects every other app's menu bar (tray) icons, plus every app running on your
|
|||||||
Mac, into three sections:
|
Mac, into three sections:
|
||||||
|
|
||||||
- **Menu Bar** — a tray of all other apps' menu bar items, discovered through
|
- **Menu Bar** — a tray of all other apps' menu bar items, discovered through
|
||||||
the Accessibility API. Click one to open its real menu. Optionally **hide the
|
the Accessibility API. It is split into **Apps** and **System** sections.
|
||||||
items from the menu bar** so they only live inside this panel (best-effort;
|
Click one to open its real menu. Optionally **hide the items from the menu
|
||||||
see below).
|
bar** so they only live inside this panel (best-effort; see below).
|
||||||
- **Apps** — regular apps that live in the Dock.
|
- **Apps** — regular apps that live in the Dock.
|
||||||
- **Background Apps** — accessory/agent apps without a Dock icon. This section
|
- **Background Apps** — accessory/agent apps without a Dock icon. This section
|
||||||
is a **dropdown**: it stays collapsed to a single header, and when you expand
|
is a **dropdown**: it stays collapsed to a single header, and when you expand
|
||||||
@@ -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,31 @@ 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-press") {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
||||||
|
MenuBarHider.shared.suspend()
|
||||||
|
}
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2.25) {
|
||||||
|
if let item = MenuBarItemManager.enumerate().first(where: {
|
||||||
|
$0.title.lowercased().contains("wireguard")
|
||||||
|
}) {
|
||||||
|
NSLog("diag-press: pressing \(item.appName) after brief reveal")
|
||||||
|
_ = AXUIElementPerformAction(item.element, kAXPressAction as CFString)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2.45) {
|
||||||
|
MenuBarHider.shared.resume()
|
||||||
|
}
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 4.5) {
|
||||||
|
dumpWindows()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if CommandLine.arguments.contains("--diag") {
|
if CommandLine.arguments.contains("--diag") {
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
|
||||||
@@ -109,6 +132,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.popover = popover
|
self.popover = popover
|
||||||
|
|
||||||
|
// Selecting a tray item must dismiss our panel and give up focus,
|
||||||
|
// otherwise the panel stays on top of the menu and our app stays
|
||||||
|
// frontmost, which stops transient items (Spotlight's search field)
|
||||||
|
// from appearing at all.
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
MenuBarItemManager.shared.onWillPress = {
|
||||||
|
self?.popover?.performClose(nil)
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||||
|
NSApp.deactivate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func togglePopover() {
|
@objc private func togglePopover() {
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ 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
|
||||||
|
/// True for Apple's own items (Control Center, Spotlight, …).
|
||||||
|
let isSystem: Bool
|
||||||
|
/// Frame in Accessibility coordinates (origin top-left of the main screen).
|
||||||
|
let frame: CGRect?
|
||||||
let element: AXUIElement
|
let element: AXUIElement
|
||||||
|
|
||||||
static func == (lhs: MenuBarItem, rhs: MenuBarItem) -> Bool {
|
static func == (lhs: MenuBarItem, rhs: MenuBarItem) -> Bool {
|
||||||
@@ -28,6 +32,9 @@ final class MenuBarItemManager: ObservableObject {
|
|||||||
|
|
||||||
private var observers: [NSObjectProtocol] = []
|
private var observers: [NSObjectProtocol] = []
|
||||||
|
|
||||||
|
/// Called just before pressing an item so the owner can close its panel.
|
||||||
|
var onWillPress: (() -> Void)?
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
func start() {
|
func start() {
|
||||||
@@ -68,17 +75,40 @@ 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() {
|
||||||
|
let position = pointAttribute(child, kAXPositionAttribute)
|
||||||
|
// 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, 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 }
|
||||||
|
|
||||||
|
let size = sizeAttribute(child, kAXSizeAttribute)
|
||||||
|
let frame: CGRect?
|
||||||
|
if let position, let size {
|
||||||
|
frame = CGRect(origin: position, size: size)
|
||||||
|
} else {
|
||||||
|
frame = nil
|
||||||
|
}
|
||||||
|
|
||||||
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),
|
||||||
|
isSystem: Self.isSystemBundle(app.bundleIdentifier),
|
||||||
|
frame: frame,
|
||||||
element: child
|
element: child
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
@@ -91,16 +121,112 @@ final class MenuBarItemManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Opens the real menu bar item by pressing its accessibility element, so
|
/// Opens the real menu bar item, so its menu/popover appears.
|
||||||
/// its menu/popover appears at its true screen location.
|
///
|
||||||
@discardableResult
|
/// A hidden item sits off-screen, and its menu would open off-screen too.
|
||||||
func press(_ item: MenuBarItem) -> Bool {
|
/// So briefly reveal the items, let the layout settle, press the item, then
|
||||||
|
/// hide again shortly after. The reveal is momentary rather than lasting the
|
||||||
|
/// whole time the panel is open.
|
||||||
|
func press(_ item: MenuBarItem) {
|
||||||
|
// Close our own panel first, otherwise it stays open on top of the
|
||||||
|
// menu that is about to appear (and can leave menus behind it).
|
||||||
|
onWillPress?()
|
||||||
|
|
||||||
|
let hider = MenuBarHider.shared
|
||||||
|
let needsReveal = hider.isHiding
|
||||||
|
guard needsReveal else {
|
||||||
|
_ = performPress(item)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hider.suspend()
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
|
||||||
|
_ = self.performPress(item)
|
||||||
|
// The menu is its own window, positioned at the item's on-screen
|
||||||
|
// spot, so the bar can be re-hidden shortly after.
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
|
||||||
|
hider.resume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func performPress(_ item: MenuBarItem) -> Bool {
|
||||||
|
// Spotlight's status item accepts the accessibility press action but
|
||||||
|
// does nothing with it; it needs a genuine click.
|
||||||
|
if item.appName.lowercased().contains("spotlight"), let frame = item.frame {
|
||||||
|
postClick(at: CGPoint(x: frame.midX, y: frame.midY))
|
||||||
|
return true
|
||||||
|
}
|
||||||
if AXUIElementPerformAction(item.element, kAXPressAction as CFString) == .success {
|
if AXUIElementPerformAction(item.element, kAXPressAction as CFString) == .success {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return AXUIElementPerformAction(item.element, kAXShowMenuAction as CFString) == .success
|
return AXUIElementPerformAction(item.element, kAXShowMenuAction as CFString) == .success
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Posts a real left click at a point in Accessibility/global coordinates.
|
||||||
|
private func postClick(at point: CGPoint) {
|
||||||
|
let source = CGEventSource(stateID: .hidSystemState)
|
||||||
|
let down = CGEvent(mouseEventSource: source, mouseType: .leftMouseDown, mouseCursorPosition: point, mouseButton: .left)
|
||||||
|
let up = CGEvent(mouseEventSource: source, mouseType: .leftMouseUp, mouseCursorPosition: point, mouseButton: .left)
|
||||||
|
down?.post(tap: .cghidEventTap)
|
||||||
|
up?.post(tap: .cghidEventTap)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Classification
|
||||||
|
|
||||||
|
/// Apple's own menu bar items (Control Center, Spotlight, the input-source
|
||||||
|
/// agent, …) live under `com.apple.*` bundles; everything else is a third
|
||||||
|
/// party app.
|
||||||
|
nonisolated static func isSystemBundle(_ bundleID: String?) -> Bool {
|
||||||
|
guard let bundleID else { return false }
|
||||||
|
return bundleID.hasPrefix("com.apple.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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? {
|
||||||
@@ -136,13 +262,23 @@ final class MenuBarItemManager: ObservableObject {
|
|||||||
guard AXValueGetValue(value as! AXValue, .cgPoint, &point) else { return nil }
|
guard AXValueGetValue(value as! AXValue, .cgPoint, &point) else { return nil }
|
||||||
return point
|
return point
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nonisolated static func sizeAttribute(_ element: AXUIElement, _ attribute: String) -> CGSize? {
|
||||||
|
var value: CFTypeRef?
|
||||||
|
guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success,
|
||||||
|
let value,
|
||||||
|
CFGetTypeID(value) == AXValueGetTypeID() else { return nil }
|
||||||
|
var size = CGSize.zero
|
||||||
|
guard AXValueGetValue(value as! AXValue, .cgSize, &size) else { return nil }
|
||||||
|
return size
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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 +286,71 @@ 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(350.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.
|
/// Temporarily reveal the items (e.g. while the panel is open) so pressing
|
||||||
|
/// a tray icon opens its menu at its real, on-screen position. Keeps the
|
||||||
|
/// user's `enabled` preference untouched.
|
||||||
|
func suspend() {
|
||||||
|
guard enabled else { return }
|
||||||
|
spacer?.length = 0
|
||||||
|
isHiding = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore hiding after a `suspend()`.
|
||||||
|
func resume() {
|
||||||
|
guard enabled else { return }
|
||||||
|
spacer?.length = Self.hiddenLength
|
||||||
|
isHiding = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,28 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
import ApplicationServices
|
import ApplicationServices
|
||||||
|
import CoreGraphics
|
||||||
|
|
||||||
|
/// Diagnostic used by `open SimpleTopBar.app --args --diag-press`. Presses a
|
||||||
|
/// hidden item's accessibility element without revealing it and dumps the
|
||||||
|
/// resulting high-level windows, to see where its menu actually opens.
|
||||||
|
func dumpWindows() {
|
||||||
|
var lines: [String] = []
|
||||||
|
if let list = CGWindowListCopyWindowInfo([], kCGNullWindowID) as? [[String: Any]] {
|
||||||
|
for window in list {
|
||||||
|
let layer = window[kCGWindowLayer as String] as? Int ?? -1
|
||||||
|
guard layer > 20 else { continue }
|
||||||
|
let owner = window[kCGWindowOwnerName as String] as? String ?? "?"
|
||||||
|
let bounds = window[kCGWindowBounds as String] as? [String: Any] ?? [:]
|
||||||
|
let x = bounds["X"] as? Double ?? 0
|
||||||
|
let y = bounds["Y"] as? Double ?? 0
|
||||||
|
let width = bounds["Width"] as? Double ?? 0
|
||||||
|
let height = bounds["Height"] as? Double ?? 0
|
||||||
|
lines.append("\(owner) layer=\(layer) frame=\(Int(x)),\(Int(y)) \(Int(width))x\(Int(height))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try? lines.joined(separator: "\n").write(toFile: "/tmp/simpletopbar-windows.txt", atomically: true, encoding: .utf8)
|
||||||
|
exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
/// Diagnostic used by `open SimpleTopBar.app --args --dump-items`. Writes the
|
/// 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
|
/// live Accessibility view of the menu bar to /tmp so it can be inspected
|
||||||
@@ -12,7 +35,7 @@ func dumpMenuBarItems() {
|
|||||||
lines.append("count=\(items.count)")
|
lines.append("count=\(items.count)")
|
||||||
for item in items {
|
for item in items {
|
||||||
let pos = MenuBarItemManager.pointAttribute(item.element, kAXPositionAttribute)
|
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))" } ?? "?")")
|
lines.append("\(item.appName) | \(item.title) | pid=\(item.pid) | id=\(item.id) | system=\(item.isSystem) | pos=\(pos.map { "\(Int($0.x)),\(Int($0.y))" } ?? "?")")
|
||||||
}
|
}
|
||||||
let own = MenuBarItemManager.enumerate(includeOwn: true).filter {
|
let own = MenuBarItemManager.enumerate(includeOwn: true).filter {
|
||||||
$0.pid == ProcessInfo.processInfo.processIdentifier
|
$0.pid == ProcessInfo.processInfo.processIdentifier
|
||||||
@@ -80,12 +103,25 @@ 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(), isSystem: false, frame: nil, 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(), isSystem: false, frame: nil, 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(), isSystem: false, frame: nil, 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")
|
||||||
|
|
||||||
|
// Tray sections: Apple bundles are "system", everything else is an app.
|
||||||
|
check(MenuBarItemManager.isSystemBundle("com.apple.controlcenter"), "apple bundles classify as system")
|
||||||
|
check(!MenuBarItemManager.isSystemBundle("com.cmux.app"), "third-party bundles classify as apps")
|
||||||
|
check(!MenuBarItemManager.isSystemBundle(nil), "unknown bundles are treated as apps")
|
||||||
|
|
||||||
let authorized = AXIsProcessTrusted()
|
let authorized = AXIsProcessTrusted()
|
||||||
print("accessibility authorized=\(authorized)")
|
print("accessibility authorized=\(authorized)")
|
||||||
if authorized {
|
if authorized {
|
||||||
|
|||||||
@@ -60,23 +60,6 @@ struct TopBarView: View {
|
|||||||
/// The hamburger tray: every other app's menu bar item lives here.
|
/// The hamburger tray: every other app's menu bar item lives here.
|
||||||
private var menuBarSection: some View {
|
private var menuBarSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
HStack(spacing: 6) {
|
|
||||||
Text("MENU BAR")
|
|
||||||
.font(.caption2)
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
Spacer()
|
|
||||||
Text("\(menuBar.items.count)")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.tertiary)
|
|
||||||
.padding(.horizontal, 6)
|
|
||||||
.padding(.vertical, 1)
|
|
||||||
.background(Capsule().fill(Color.secondary.opacity(0.15)))
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.top, 4)
|
|
||||||
.padding(.bottom, 4)
|
|
||||||
|
|
||||||
if !menuBar.authorized {
|
if !menuBar.authorized {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text("Grant Accessibility to collect and open menu bar items.")
|
Text("Grant Accessibility to collect and open menu bar items.")
|
||||||
@@ -98,12 +81,9 @@ struct TopBarView: View {
|
|||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
} else {
|
} else {
|
||||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 34), spacing: 6)], spacing: 6) {
|
trayGroup(title: "Apps", items: appTrayItems)
|
||||||
ForEach(menuBar.items) { item in
|
Divider().padding(.vertical, 2)
|
||||||
MenuBarTile(item: item, menuBar: menuBar)
|
trayGroup(title: "System", items: systemTrayItems)
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if menuBar.authorized {
|
if menuBar.authorized {
|
||||||
@@ -115,7 +95,53 @@ 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 system items (Wi-Fi, Now Playing, Clock, Battery, Control Center, Spotlight) stay visible; macOS won't let any third-party app move them.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Third-party apps' menu bar icons.
|
||||||
|
private var appTrayItems: [MenuBarItem] {
|
||||||
|
menuBar.items.filter { !$0.isSystem }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apple's own menu bar items (Control Center, Spotlight, …).
|
||||||
|
private var systemTrayItems: [MenuBarItem] {
|
||||||
|
menuBar.items.filter { $0.isSystem }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func trayGroup(title: String, items: [MenuBarItem]) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(title.uppercased())
|
||||||
|
.font(.caption2)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Text("\(items.count)")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 1)
|
||||||
|
.background(Capsule().fill(Color.secondary.opacity(0.15)))
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.top, 4)
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
|
||||||
|
if items.isEmpty {
|
||||||
|
Text("None")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
} else {
|
||||||
|
LazyVGrid(columns: [GridItem(.adaptive(minimum: 34), spacing: 6)], spacing: 6) {
|
||||||
|
ForEach(items) { item in
|
||||||
|
MenuBarTile(item: item, menuBar: menuBar)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -245,8 +271,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(
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
VERSION="${1:-0.2.3}"
|
VERSION="${1:-0.4.5}"
|
||||||
|
|
||||||
swift build -c release
|
swift build -c release
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user