Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abb0b548bf | ||
|
|
8d795d0a28 | ||
|
|
e465acba12 | ||
|
|
29157c77fb | ||
|
|
b0671abed8 | ||
|
|
569abe967f | ||
|
|
3e57efe312 | ||
|
|
2312e09105 | ||
|
|
9c5416d27e |
@@ -134,12 +134,20 @@ preferred position controls where the hidden/kept boundary lands:
|
||||
- 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` (`kAXPressAction`, then
|
||||
`kAXShowMenuAction`). Because a hidden item sits off-screen, its menu would
|
||||
open off-screen; `AppDelegate` therefore calls `MenuBarHider.suspend()`
|
||||
(`popoverWillShow`) to reveal items while the panel is open and `resume()`
|
||||
0.6s after `popoverDidClose`. `suspend`/`resume` do not change the user's
|
||||
`enabled` preference.
|
||||
- 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.
|
||||
|
||||
@@ -5,9 +5,9 @@ collects every other app's menu bar (tray) icons, plus every app running on your
|
||||
Mac, into three sections:
|
||||
|
||||
- **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
|
||||
items from the menu bar** so they only live inside this panel (best-effort;
|
||||
see below).
|
||||
the Accessibility API. It is split into **Apps** and **System** sections.
|
||||
Click one to open its real menu. Optionally **hide the items from the menu
|
||||
bar** so they only live inside this panel (best-effort; see below).
|
||||
- **Apps** — regular apps that live in the Dock.
|
||||
- **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
|
||||
|
||||
@@ -17,7 +17,7 @@ struct SimpleTopBarApp: App {
|
||||
/// 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, NSPopoverDelegate {
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
private var statusItem: NSStatusItem?
|
||||
private var popover: NSPopover?
|
||||
|
||||
@@ -48,6 +48,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
||||
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") {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
|
||||
dumpMenuBarItems()
|
||||
@@ -103,7 +124,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
||||
let popover = NSPopover()
|
||||
popover.behavior = .transient
|
||||
popover.animates = false
|
||||
popover.delegate = self
|
||||
popover.contentViewController = NSHostingController(
|
||||
rootView: TopBarView(
|
||||
model: AppListModel.shared,
|
||||
@@ -112,19 +132,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
||||
)
|
||||
)
|
||||
self.popover = popover
|
||||
}
|
||||
|
||||
// Reveal the hidden menu bar items while the panel is open so pressing a
|
||||
// tray icon opens that app's menu at its real, on-screen position.
|
||||
func popoverWillShow(_ notification: Notification) {
|
||||
MenuBarHider.shared.suspend()
|
||||
}
|
||||
|
||||
func popoverDidClose(_ notification: Notification) {
|
||||
// Small delay so a menu just opened from a tray tile is fully on screen
|
||||
// before the bar contracts again.
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
|
||||
MenuBarHider.shared.resume()
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,10 @@ struct MenuBarItem: Identifiable, Equatable {
|
||||
let appName: String
|
||||
let title: String
|
||||
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
|
||||
|
||||
static func == (lhs: MenuBarItem, rhs: MenuBarItem) -> Bool {
|
||||
@@ -28,6 +32,9 @@ final class MenuBarItemManager: ObservableObject {
|
||||
|
||||
private var observers: [NSObjectProtocol] = []
|
||||
|
||||
/// Called just before pressing an item so the owner can close its panel.
|
||||
var onWillPress: (() -> Void)?
|
||||
|
||||
private init() {}
|
||||
|
||||
func start() {
|
||||
@@ -70,10 +77,11 @@ final class MenuBarItemManager: ObservableObject {
|
||||
|
||||
var seenTitles = Set<String>()
|
||||
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 = pointAttribute(child, kAXPositionAttribute), position.y > 60 {
|
||||
if let position, position.y > 60 {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -85,12 +93,22 @@ final class MenuBarItemManager: ObservableObject {
|
||||
// "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(
|
||||
id: "\(pid)-\(index)",
|
||||
pid: pid,
|
||||
appName: app.localizedName ?? app.bundleIdentifier ?? "Unknown",
|
||||
title: title,
|
||||
icon: Self.icon(forTitle: title, app: app),
|
||||
isSystem: Self.isSystemBundle(app.bundleIdentifier),
|
||||
frame: frame,
|
||||
element: child
|
||||
))
|
||||
}
|
||||
@@ -103,16 +121,66 @@ final class MenuBarItemManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the real menu bar item by pressing its accessibility element, so
|
||||
/// its menu/popover appears at its true screen location.
|
||||
@discardableResult
|
||||
func press(_ item: MenuBarItem) -> Bool {
|
||||
/// Opens the real menu bar item, so its menu/popover appears.
|
||||
///
|
||||
/// A hidden item sits off-screen, and its menu would open off-screen too.
|
||||
/// 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 {
|
||||
return true
|
||||
}
|
||||
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
|
||||
@@ -194,6 +262,16 @@ final class MenuBarItemManager: ObservableObject {
|
||||
guard AXValueGetValue(value as! AXValue, .cgPoint, &point) else { return nil }
|
||||
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
|
||||
@@ -238,7 +316,7 @@ final class MenuBarHider: ObservableObject {
|
||||
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)
|
||||
UserDefaults.standard.set(350.0, forKey: key)
|
||||
}
|
||||
let item = NSStatusBar.system.statusItem(withLength: 0)
|
||||
item.autosaveName = name
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
import AppKit
|
||||
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
|
||||
/// 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)")
|
||||
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))" } ?? "?")")
|
||||
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 {
|
||||
$0.pid == ProcessInfo.processInfo.processIdentifier
|
||||
@@ -80,9 +103,9 @@ func runSelfTest() {
|
||||
check(MenuBarItemManager.children(of: systemWide) == nil, "no children on a non-app element")
|
||||
|
||||
let element = AXUIElementCreateSystemWide()
|
||||
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", icon: NSImage(), element: element)
|
||||
let different = MenuBarItem(id: "1-1", pid: 1, appName: "A", title: "T", icon: 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", icon: NSImage(), isSystem: false, frame: nil, 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 != different, "different ids are not equal")
|
||||
|
||||
@@ -94,6 +117,11 @@ func runSelfTest() {
|
||||
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()
|
||||
print("accessibility authorized=\(authorized)")
|
||||
if authorized {
|
||||
|
||||
@@ -60,23 +60,6 @@ struct TopBarView: View {
|
||||
/// The hamburger tray: every other app's menu bar item lives here.
|
||||
private var menuBarSection: some View {
|
||||
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 {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Grant Accessibility to collect and open menu bar items.")
|
||||
@@ -98,12 +81,9 @@ struct TopBarView: View {
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 4)
|
||||
} else {
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 34), spacing: 6)], spacing: 6) {
|
||||
ForEach(menuBar.items) { item in
|
||||
MenuBarTile(item: item, menuBar: menuBar)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
trayGroup(title: "Apps", items: appTrayItems)
|
||||
Divider().padding(.vertical, 2)
|
||||
trayGroup(title: "System", items: systemTrayItems)
|
||||
}
|
||||
|
||||
if menuBar.authorized {
|
||||
@@ -115,7 +95,53 @@ struct TopBarView: View {
|
||||
.controlSize(.mini)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 6)
|
||||
.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.")
|
||||
.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
VERSION="${1:-0.2.3}"
|
||||
VERSION="${1:-0.4.5}"
|
||||
|
||||
swift build -c release
|
||||
|
||||
|
||||
Reference in New Issue
Block a user