Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f55861be7 | ||
|
|
ccc172d218 | ||
|
|
4bc6b333dc | ||
|
|
abb0b548bf | ||
|
|
8d795d0a28 | ||
|
|
e465acba12 | ||
|
|
29157c77fb | ||
|
|
b0671abed8 | ||
|
|
569abe967f |
@@ -120,32 +120,41 @@ no control over this, so the app creates its own `NSStatusItem` and:
|
||||
`/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 (cover)
|
||||
## Hiding other menu bar items
|
||||
|
||||
`MenuBarHider` drives `MenuBarCover`, which hides other apps' icons by drawing
|
||||
our own frosted window ("cover") over them instead of moving them off-screen.
|
||||
Because the icons keep their real positions, `press` opens their menus in place
|
||||
with no reveal. Verified with `--diag-cover` (cover windows at level 26) and
|
||||
`--diag-press` (the WireGuard menu opens at a positive x, not off-screen).
|
||||
`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. It is
|
||||
**on by default** (`hideMenuBarItems` defaults to true, then persisted). Its
|
||||
preferred position controls where the hidden/kept boundary lands:
|
||||
|
||||
- `MenuBarCover.shouldHide` covers everything except the Apple keep-list
|
||||
(Wi-Fi, Battery, Clock, Control Center, Spotlight/Search).
|
||||
- `runs(from:)` merges adjacent item frames into contiguous cover windows;
|
||||
`subtracting(_:from:)` carves out the hamburger so it is never covered
|
||||
(`MenuBarItemManager.refresh` passes `avoid:` = our own item's frame).
|
||||
- Frames come from AX (`kAXPositionAttribute` + `kAXSizeAttribute`) with a
|
||||
**top-left origin**; `windowFrame(for:primaryHeight:)` flips Y for NSWindow.
|
||||
- The cover window level is 26 (just above status items at 25, below menus at
|
||||
~101), so opened menus draw on top of it.
|
||||
- `ignoresMouseEvents = false` makes the covered strip swallow clicks so the
|
||||
hidden icons cannot be clicked through.
|
||||
|
||||
### Legacy: off-screen spacer
|
||||
|
||||
An earlier approach used a zero-width spacer `NSStatusItem` grown to width
|
||||
10_000 to push icons off the left edge. It is no longer used because it required
|
||||
a visible reveal to open a menu. Findings if it is ever revived: preferred
|
||||
position ~250 kept Wi-Fi while hiding apps, and `debug.spacerPos` overrode it.
|
||||
- Seed **250** (default) puts the boundary just left of Wi-Fi, so every
|
||||
third-party app icon goes to x < 0 (cmux, Kapture, moomoo, WireGuard,
|
||||
TextInputMenuAgent, Now Playing) while **Wi-Fi (x≈1173), Spotlight, Clock,
|
||||
Battery, Control Center and our hamburger stay visible**.
|
||||
- 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.
|
||||
|
||||
## Gotchas
|
||||
|
||||
@@ -156,7 +165,7 @@ position ~250 kept Wi-Fi while hiding apps, and `debug.spacerPos` overrode it.
|
||||
- Reading `activationPolicy` is reliable but must happen on the main thread;
|
||||
keep all `NSWorkspace` access inside the model.
|
||||
- The menu bar tray needs Accessibility permission; do not add Screen Recording
|
||||
or Apple Events usage. `MenuBarHider` is restored on quit, and because the
|
||||
cover windows belong to the process, a crash removes them automatically.
|
||||
or Apple Events usage. `MenuBarHider` must always be restored on quit, but
|
||||
because a status item dies with its process, a crash cleans up on its own.
|
||||
- `.gitignore` covers `.build/`, `SimpleTopBar.app/`, `dist/` — build artifacts
|
||||
should never be committed.
|
||||
|
||||
@@ -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
|
||||
@@ -23,24 +23,28 @@ permission (System Settings → Privacy & Security → Accessibility). The panel
|
||||
prompts for it; without it the Menu Bar section is empty and everything else
|
||||
still works.
|
||||
|
||||
## Hiding menu bar items
|
||||
## Hiding menu bar items (best-effort)
|
||||
|
||||
macOS has no public API to move another app's status item. Instead of moving
|
||||
them, the **Hide items from the menu bar** toggle (on by default, remembered)
|
||||
draws a frosted **cover** window over the other apps' icons. They keep their real
|
||||
positions, so tapping a tile in the panel opens that app's menu in the right
|
||||
place with **no flicker**, and the cover leaves a gap for the hamburger so it is
|
||||
never hidden.
|
||||
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
|
||||
created at launch whose length is expanded to push the other icons off-screen so
|
||||
they only live in this panel. It is **on by default** (the choice is
|
||||
remembered), always undone on quit, and if the app dies macOS removes the spacer
|
||||
automatically.
|
||||
|
||||
What it does and does not cover:
|
||||
What it can and cannot hide:
|
||||
|
||||
- Covers **other third-party apps' icons** (cmux, Kapture, WireGuard, input
|
||||
source, Now Playing, …).
|
||||
- Keeps **Wi-Fi, Spotlight, Clock, Battery and Control Center** visible — macOS
|
||||
reserves that far-right area, so those icons stay put.
|
||||
- The cover matches the menu bar's frosted look only approximately; over an
|
||||
unusual wallpaper the covered strip may be slightly visible.
|
||||
- Turning the toggle off removes the cover immediately; quitting does too.
|
||||
- It hides **other third-party apps' icons** (cmux, Kapture, moomoo, WireGuard).
|
||||
- It **keeps Wi-Fi, Now Playing, the input-source indicator, Spotlight, Clock,
|
||||
Battery and Control Center** visible, with the **hamburger at the left edge of
|
||||
that group**. The spacer's boundary (preferred position 330) falls just left
|
||||
of the input-source indicator. macOS reserves the far-right area, so an item
|
||||
can never be placed to the right of the system icons.
|
||||
- Newly launched apps are placed to the left of the spacer, so they are hidden
|
||||
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
|
||||
|
||||
|
||||
@@ -43,27 +43,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
|
||||
AppListModel.shared.start()
|
||||
MenuBarItemManager.shared.start()
|
||||
// Create the hamburger before the cover so its own icon is never covered.
|
||||
// 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()
|
||||
MenuBarItemManager.shared.refresh()
|
||||
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)")
|
||||
NSLog("diag-press: pressing \(item.appName) after brief reveal")
|
||||
_ = AXUIElementPerformAction(item.element, kAXPressAction as CFString)
|
||||
}
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) {
|
||||
dumpWindows()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.45) {
|
||||
MenuBarHider.shared.resume()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if CommandLine.arguments.contains("--diag-cover") {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 4.5) {
|
||||
dumpWindows()
|
||||
}
|
||||
return
|
||||
@@ -103,9 +103,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
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)
|
||||
// persists through the autosave name. Kept just below the hider's
|
||||
// preferred position so the hamburger lands at the left edge of the
|
||||
// visible icons. `debug.hamburgerPos` overrides it.
|
||||
if let override = UserDefaults.standard.object(forKey: "debug.hamburgerPos") as? Double {
|
||||
UserDefaults.standard.set(override, forKey: positionKey)
|
||||
} else if UserDefaults.standard.object(forKey: positionKey) == nil {
|
||||
UserDefaults.standard.set(320.0, forKey: positionKey)
|
||||
}
|
||||
|
||||
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
||||
@@ -132,6 +136,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
)
|
||||
)
|
||||
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() {
|
||||
|
||||
@@ -9,6 +9,8 @@ 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
|
||||
@@ -30,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() {
|
||||
@@ -54,15 +59,9 @@ final class MenuBarItemManager: ObservableObject {
|
||||
authorized = AXIsProcessTrusted()
|
||||
guard authorized else {
|
||||
items = []
|
||||
MenuBarHider.shared.apply(items: [], avoid: nil)
|
||||
return
|
||||
}
|
||||
let ownPID = ProcessInfo.processInfo.processIdentifier
|
||||
let all = Self.enumerate(includeOwn: true)
|
||||
items = all.filter { $0.pid != ownPID }
|
||||
// Our own hamburger must never be covered, even if it lands inside a run.
|
||||
let avoid = all.first { $0.pid == ownPID }?.frame
|
||||
MenuBarHider.shared.apply(items: items, avoid: avoid)
|
||||
items = Self.enumerate()
|
||||
}
|
||||
|
||||
nonisolated static func enumerate(includeOwn: Bool = false) -> [MenuBarItem] {
|
||||
@@ -108,6 +107,7 @@ final class MenuBarItemManager: ObservableObject {
|
||||
appName: app.localizedName ?? app.bundleIdentifier ?? "Unknown",
|
||||
title: title,
|
||||
icon: Self.icon(forTitle: title, app: app),
|
||||
isSystem: Self.isSystemBundle(app.bundleIdentifier),
|
||||
frame: frame,
|
||||
element: child
|
||||
))
|
||||
@@ -121,20 +121,66 @@ final class MenuBarItemManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the real menu bar item, so its menu/popover appears at its own
|
||||
/// on-screen position. With the cover approach the item never moves, so no
|
||||
/// reveal is needed.
|
||||
/// 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
|
||||
@@ -228,14 +274,18 @@ final class MenuBarItemManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Menu bar item hiding via a "cover": the icons stay exactly where macOS put
|
||||
/// them and are hidden by our own frosted window drawn over them. Because they
|
||||
/// never move, opening one's menu needs no reveal. On by default; the choice is
|
||||
/// persisted. Always restored on quit.
|
||||
/// 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 other apps' icons
|
||||
/// off the visible menu bar, so they can only be reached through SimpleTopBar's
|
||||
/// panel. On by default; the choice is persisted. Always restored on quit, and
|
||||
/// a crash cleans up automatically because status items die with the process.
|
||||
@MainActor
|
||||
final class MenuBarHider: ObservableObject {
|
||||
static let shared = MenuBarHider()
|
||||
|
||||
/// Width that pushes the neighbouring items off the visible menu bar.
|
||||
static let hiddenLength: CGFloat = 10_000
|
||||
|
||||
private static let defaultsKey = "hideMenuBarItems"
|
||||
|
||||
@Published private(set) var isHiding = false
|
||||
@@ -243,27 +293,69 @@ final class MenuBarHider: ObservableObject {
|
||||
didSet {
|
||||
guard enabled != oldValue else { return }
|
||||
UserDefaults.standard.set(enabled, forKey: Self.defaultsKey)
|
||||
MenuBarItemManager.shared.refresh()
|
||||
apply(enabled)
|
||||
}
|
||||
}
|
||||
|
||||
private var spacer: NSStatusItem?
|
||||
|
||||
private init() {
|
||||
// Hide by default: the menu bar then shows only SimpleTopBar's hamburger
|
||||
// plus the Apple items macOS will not let us cover.
|
||||
// plus the Apple items macOS will not let us move.
|
||||
enabled = UserDefaults.standard.object(forKey: Self.defaultsKey) as? Bool ?? true
|
||||
}
|
||||
|
||||
/// Recompute the cover windows for the current menu bar items. `avoid` is
|
||||
/// our own hamburger's frame, which the cover must never hide.
|
||||
func apply(items: [MenuBarItem], avoid: CGRect?) {
|
||||
isHiding = enabled
|
||||
MenuBarCover.shared.update(enabled: enabled, items: items, avoid: avoid)
|
||||
/// Create the spacer early (at launch) so it lands as far right as possible.
|
||||
func prepare() {
|
||||
guard spacer == nil else { return }
|
||||
let name = "SimpleTopBarHider"
|
||||
let key = "NSStatusItem Preferred Position \(name)"
|
||||
// The boundary between "hidden" and "kept" falls just left of Wi-Fi at
|
||||
// ~250, so the other apps hide while Apple's Wi-Fi/Clock/Battery/Control
|
||||
// Center/Spotlight stay visible. `debug.spacerPos` overrides the seed.
|
||||
// The boundary sits just left of the input-source indicator, so every
|
||||
// system item (input source, Now Playing, Wi-Fi, …) stays visible while
|
||||
// the apps hide. It must stay *above* the hamburger's preferred position
|
||||
// (see AppDelegate) for the hamburger to remain on the visible side.
|
||||
// `debug.spacerPos` overrides the seed.
|
||||
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(330.0, forKey: key)
|
||||
}
|
||||
let item = NSStatusBar.system.statusItem(withLength: 0)
|
||||
item.autosaveName = name
|
||||
item.button?.isHidden = true
|
||||
spacer = item
|
||||
apply(enabled)
|
||||
}
|
||||
|
||||
/// Remove the cover. Called on quit. Leaves the user's preference intact.
|
||||
private func apply(_ hiding: Bool) {
|
||||
spacer?.length = hiding ? Self.hiddenLength : 0
|
||||
isHiding = hiding
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
spacer?.length = 0
|
||||
isHiding = false
|
||||
MenuBarCover.shared.update(enabled: false, items: [])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
import AppKit
|
||||
|
||||
/// Hides other apps' menu bar icons by drawing a frosted window ("cover") over
|
||||
/// them, instead of moving them off-screen. The icons keep their real positions,
|
||||
/// so their menus open in the right place with no reveal, and a target item's
|
||||
/// real icon can be left visible by cutting a hole in the cover.
|
||||
@MainActor
|
||||
final class MenuBarCover {
|
||||
static let shared = MenuBarCover()
|
||||
|
||||
/// Just above the status items (level 25) but well below menus (~101).
|
||||
private static let windowLevel = NSWindow.Level(rawValue: 26)
|
||||
|
||||
private var windows: [NSWindow] = []
|
||||
|
||||
private init() {}
|
||||
|
||||
func update(enabled: Bool, items: [MenuBarItem], avoid: CGRect? = nil) {
|
||||
removeAllWindows()
|
||||
guard enabled else { return }
|
||||
|
||||
let frames = items.compactMap { item -> CGRect? in
|
||||
guard Self.shouldHide(item), let frame = item.frame else { return nil }
|
||||
return frame
|
||||
}
|
||||
var runs = Self.runs(from: frames)
|
||||
if let avoid {
|
||||
runs = Self.subtracting(avoid, from: runs)
|
||||
}
|
||||
let primaryHeight = NSScreen.screens.first?.frame.height ?? 0
|
||||
for run in runs {
|
||||
let frame = Self.windowFrame(for: run, primaryHeight: primaryHeight)
|
||||
windows.append(makeWindow(frame: frame))
|
||||
}
|
||||
}
|
||||
|
||||
/// Which items the cover hides. Everything is covered except the Apple
|
||||
/// items the user asked to keep visible.
|
||||
nonisolated static func shouldHide(_ item: MenuBarItem) -> Bool {
|
||||
let text = item.title.lowercased()
|
||||
.replacingOccurrences(of: "\u{2010}", with: "-")
|
||||
.replacingOccurrences(of: "\u{2011}", with: "-")
|
||||
let keep = ["wi-fi", "wifi", "battery", "clock", "control center", "search", "spotlight"]
|
||||
return !keep.contains(where: { text.contains($0) })
|
||||
}
|
||||
|
||||
/// Merge overlapping or near frames (in Accessibility coordinates) into
|
||||
/// contiguous runs, so adjacent icons get one cover window rather than many.
|
||||
nonisolated static func runs(from frames: [CGRect], gap: CGFloat = 6) -> [CGRect] {
|
||||
let sorted = frames.sorted { $0.minX < $1.minX }
|
||||
var result: [CGRect] = []
|
||||
for frame in sorted {
|
||||
if let last = result.last, frame.minX <= last.maxX + gap {
|
||||
result[result.count - 1] = last.union(frame)
|
||||
} else {
|
||||
result.append(frame)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Carve `avoid` out of the runs so the cover never hides our own icon.
|
||||
nonisolated static func subtracting(_ avoid: CGRect, from runs: [CGRect]) -> [CGRect] {
|
||||
var result: [CGRect] = []
|
||||
for run in runs {
|
||||
guard run.intersects(avoid) else {
|
||||
result.append(run)
|
||||
continue
|
||||
}
|
||||
if avoid.minX > run.minX {
|
||||
result.append(CGRect(x: run.minX, y: run.minY, width: avoid.minX - run.minX, height: run.height))
|
||||
}
|
||||
if avoid.maxX < run.maxX {
|
||||
result.append(CGRect(x: avoid.maxX, y: run.minY, width: run.maxX - avoid.maxX, height: run.height))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Accessibility coordinates use a top-left origin; NSWindow uses a
|
||||
/// bottom-left one, so flip Y against the primary screen's height.
|
||||
nonisolated static func windowFrame(for axFrame: CGRect, primaryHeight: CGFloat) -> CGRect {
|
||||
CGRect(
|
||||
x: axFrame.minX,
|
||||
y: primaryHeight - axFrame.maxY,
|
||||
width: axFrame.width,
|
||||
height: axFrame.height
|
||||
)
|
||||
}
|
||||
|
||||
private func makeWindow(frame: CGRect) -> NSWindow {
|
||||
let window = NSWindow(
|
||||
contentRect: frame,
|
||||
styleMask: [.borderless],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
window.level = Self.windowLevel
|
||||
window.isOpaque = false
|
||||
window.backgroundColor = .clear
|
||||
window.hasShadow = false
|
||||
// Swallow clicks so the covered icons cannot be clicked through.
|
||||
window.ignoresMouseEvents = false
|
||||
window.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary, .ignoresCycle]
|
||||
window.animationBehavior = .none
|
||||
|
||||
let effect = NSVisualEffectView(frame: CGRect(origin: .zero, size: frame.size))
|
||||
effect.material = .menu
|
||||
effect.blendingMode = .behindWindow
|
||||
effect.state = .active
|
||||
effect.autoresizingMask = [.width, .height]
|
||||
window.contentView = effect
|
||||
|
||||
window.orderFrontRegardless()
|
||||
return window
|
||||
}
|
||||
|
||||
private func removeAllWindows() {
|
||||
for window in windows {
|
||||
window.orderOut(nil)
|
||||
}
|
||||
windows.removeAll()
|
||||
}
|
||||
}
|
||||
@@ -35,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
|
||||
@@ -103,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(), frame: nil, element: element)
|
||||
let same = MenuBarItem(id: "1-0", pid: 1, appName: "A", title: "T", icon: NSImage(), frame: nil, element: element)
|
||||
let different = MenuBarItem(id: "1-1", pid: 1, appName: "A", title: "T", icon: NSImage(), frame: nil, 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")
|
||||
|
||||
@@ -117,26 +117,10 @@ 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")
|
||||
|
||||
// Cover geometry: adjacent icons merge into one run, far ones stay separate.
|
||||
let merged = MenuBarCover.runs(from: [CGRect(x: 0, y: 0, width: 10, height: 20),
|
||||
CGRect(x: 12, y: 0, width: 10, height: 20)])
|
||||
check(merged == [CGRect(x: 0, y: 0, width: 22, height: 20)], "adjacent frames merge into one cover")
|
||||
let split = MenuBarCover.runs(from: [CGRect(x: 0, y: 0, width: 10, height: 20),
|
||||
CGRect(x: 100, y: 0, width: 10, height: 20)])
|
||||
check(split.count == 2, "distant frames stay separate covers")
|
||||
|
||||
// Cover hides third-party apps but keeps the Apple items.
|
||||
let hiddenApp = MenuBarItem(id: "1-0", pid: 1, appName: "cmux", title: "cmux", icon: NSImage(), frame: nil, element: element)
|
||||
let keptWiFi = MenuBarItem(id: "2-0", pid: 2, appName: "Control Center", title: "Wi\u{2011}Fi, connected", icon: NSImage(), frame: nil, element: element)
|
||||
let keptBattery = MenuBarItem(id: "2-1", pid: 2, appName: "Control Center", title: "Battery", icon: NSImage(), frame: nil, element: element)
|
||||
let hiddenPlaying = MenuBarItem(id: "2-2", pid: 2, appName: "Control Center", title: "Now Playing", icon: NSImage(), frame: nil, element: element)
|
||||
check(MenuBarCover.shouldHide(hiddenApp), "third-party apps are covered")
|
||||
check(!MenuBarCover.shouldHide(keptWiFi), "wi-fi stays visible")
|
||||
check(!MenuBarCover.shouldHide(keptBattery), "battery stays visible")
|
||||
check(MenuBarCover.shouldHide(hiddenPlaying), "now playing is covered")
|
||||
|
||||
let converted = MenuBarCover.windowFrame(for: CGRect(x: 10, y: 0, width: 30, height: 24), primaryHeight: 1000)
|
||||
check(converted == CGRect(x: 10, y: 976, width: 30, height: 24), "AX frame flips Y for NSWindow")
|
||||
// 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)")
|
||||
|
||||
@@ -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.7}"
|
||||
|
||||
swift build -c release
|
||||
|
||||
|
||||
Reference in New Issue
Block a user