10 Commits
Author SHA1 Message Date
TZGyn 30d5aff906 Notarize releases so macOS opens the app without a warning
Gatekeeper blocked the app on other machines because it was signed but
not notarized. Sign with a secure timestamp and submit to Apple during
release, so a stapled ticket ships in the zip.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4.1-flash
2026-09-28 12:19:39 +08:00
TZGyn 6f55861be7 Keep the keyboard input-source icon visible
The hiding boundary sat right of the input-source indicator, so the
hamburger took the slot where the language icon had been. Move the
boundary left of it so the language icon stays, with the hamburger still
at the left edge.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4.1-flash
2026-09-27 10:44:39 +08:00
TZGyn ccc172d218 Correct the note on keeping the input-source icon
The override pair that keeps it visible also shifts the hamburger one
slot right; document the exact values.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4.1-flash
2026-09-27 10:41:02 +08:00
TZGyn 4bc6b333dc Put the hamburger at the left of the visible icons
The hamburger was landing among the system icons. Move it to the left
edge of the visible group so it reads as the entry point rather than one
more status item.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4.1-flash
2026-09-27 10:40:27 +08:00
TZGyn abb0b548bf Keep all system icons visible and click Spotlight properly
The spacer boundary cut off Now Playing and the input source, which
should stay like Wi-Fi, so move it left of every Apple item. Spotlight's
status item ignores the accessibility press action, so click it at its
real position instead.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4.1-flash
2026-09-27 10:35:20 +08:00
TZGyn 8d795d0a28 Give up focus when opening a tray menu
Closing the panel was not enough: SimpleTopBar stayed frontmost, so
Spotlight's search field never appeared. Deactivate the app when a tray
item is selected so transient panels can take focus, and allow a little
more time before the bar re-hides.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4.1-flash
2026-09-27 10:30:20 +08:00
TZGyn e465acba12 Close the panel when a tray item is selected
Selecting a tile opened the app's menu while SimpleTopBar's own panel
stayed on top of it, and could leave menus such as Spotlight stranded
behind it. Dismiss the panel first so the selected menu is unobstructed.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4.1-flash
2026-09-27 10:26:09 +08:00
TZGyn 29157c77fb Split the tray into Apps and System sections
Control Center items sat in the same grid as third-party app icons, which
was hard to scan. Group them so Apple's Wi-Fi, Battery, Clock and friends
are listed separately from your apps' icons.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4.1-flash
2026-09-27 10:18:28 +08:00
TZGyn b0671abed8 Set default release version to 0.4.1 2026-09-27 10:14:00 +08:00
TZGyn 569abe967f Revert "Hide menu bar icons with a cover instead of moving them off screen"
This reverts commit 3e57efe312.
2026-09-27 10:13:10 +08:00
8 changed files with 272 additions and 256 deletions
+42 -26
View File
@@ -103,6 +103,13 @@ wants it in the final answer every time. Keep it to the cask upgrade commands
(see the README release section for the exact block), because the app is (see the README release section for the exact block), because the app is
installed through the tap. installed through the tap.
Notarization: `release.sh` submits to Apple when the `SIMPLETOPBAR_NOTARY_*`
env vars are set (provider, `.p8` path, key id, issuer id). The credentials are
the same App Store Connect key used by Kapture (`~/.config/kapture/AuthKey_*.p8`,
team `RX6767S6BW`); the issuer id is a non-secret UUID from App Store Connect.
Verify with `spctl -a -vv SimpleTopBar.app` → `source=Notarized Developer ID`.
Without the vars it still builds, zips and prints the sha, just unnotarized.
## Status item placement (why not MenuBarExtra) ## Status item placement (why not MenuBarExtra)
On a full menu bar, a newly created status item is placed leftmost, which on a On a full menu bar, a newly created status item is placed leftmost, which on a
@@ -120,32 +127,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 `/tmp/simpletopbar-items.txt`; use it to verify the icon is not under the notch
(x should be ~1200, not ~765). (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 `MenuBarHider` creates a zero-width spacer `NSStatusItem` at launch and grows its
our own frosted window ("cover") over them instead of moving them off-screen. length to `hiddenLength` (10_000) to push the icons on its left off-screen. It is
Because the icons keep their real positions, `press` opens their menus in place **on by default** (`hideMenuBarItems` defaults to true, then persisted). Its
with no reveal. Verified with `--diag-cover` (cover windows at level 26) and preferred position controls where the hidden/kept boundary lands:
`--diag-press` (the WireGuard menu opens at a positive x, not off-screen).
- `MenuBarCover.shouldHide` covers everything except the Apple keep-list - Seed **250** (default) puts the boundary just left of Wi-Fi, so every
(Wi-Fi, Battery, Clock, Control Center, Spotlight/Search). third-party app icon goes to x < 0 (cmux, Kapture, moomoo, WireGuard,
- `runs(from:)` merges adjacent item frames into contiguous cover windows; TextInputMenuAgent, Now Playing) while **Wi-Fi (x≈1173), Spotlight, Clock,
`subtracting(_:from:)` carves out the hamburger so it is never covered Battery, Control Center and our hamburger stay visible**.
(`MenuBarItemManager.refresh` passes `avoid:` = our own item's frame). - Verified with `--diag-hide` across positions 100–400. Below ~200 Wi-Fi gets
- Frames come from AX (`kAXPositionAttribute` + `kAXSizeAttribute`) with a hidden too; above ~300 app icons (Now Playing, TextInput) leak back.
**top-left origin**; `windowFrame(for:primaryHeight:)` flips Y for NSWindow. - `debug.spacerPos` (Double) overrides the seed for tuning without rebuilding.
- The cover window level is 26 (just above status items at 25, below menus at - Pressing a tray tile calls `MenuBarItemManager.press`. A hidden item sits
~101), so opened menus draw on top of it. off-screen, and its menu opens off-screen too (verified with `--diag-press`:
- `ignoresMouseEvents = false` makes the covered strip swallow clicks so the the menu window lands at negative x). So press briefly `suspend()`s the hider,
hidden icons cannot be clicked through. waits ~0.2s for the layout to settle, performs the accessibility action
(`kAXPressAction`, then `kAXShowMenuAction`), and `resume()`s ~0.1s later. The
### Legacy: off-screen spacer menu is its own window anchored at the item's on-screen spot, so it survives
re-hiding.
An earlier approach used a zero-width spacer `NSStatusItem` grown to width - `press` first calls `onWillPress`, which `AppDelegate` wires to close the
10_000 to push icons off the left edge. It is no longer used because it required popover **and** `NSApp.deactivate()`. Closing alone left our app frontmost, so
a visible reveal to open a menu. Findings if it is ever revived: preferred transient items (Spotlight's search field) still would not appear.
position ~250 kept Wi-Fi while hiding apps, and `debug.spacerPos` overrode it. - **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 ## Gotchas
@@ -156,7 +172,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; - Reading `activationPolicy` is reliable but must happen on the main thread;
keep all `NSWorkspace` access inside the model. keep all `NSWorkspace` access inside the model.
- The menu bar tray needs Accessibility permission; do not add Screen Recording - The menu bar tray needs Accessibility permission; do not add Screen Recording
or Apple Events usage. `MenuBarHider` is restored on quit, and because the or Apple Events usage. `MenuBarHider` must always be restored on quit, but
cover windows belong to the process, a crash removes them automatically. because a status item dies with its process, a crash cleans up on its own.
- `.gitignore` covers `.build/`, `SimpleTopBar.app/`, `dist/` — build artifacts - `.gitignore` covers `.build/`, `SimpleTopBar.app/`, `dist/` — build artifacts
should never be committed. should never be committed.
+24 -19
View File
@@ -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
@@ -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 prompts for it; without it the Menu Bar section is empty and everything else
still works. 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 macOS has no public API to move another app's status item. The **Hide items from
them, the **Hide items from the menu bar** toggle (on by default, remembered) the menu bar** toggle uses the spacer technique: a zero-width status item is
draws a frosted **cover** window over the other apps' icons. They keep their real created at launch whose length is expanded to push the other icons off-screen so
positions, so tapping a tile in the panel opens that app's menu in the right they only live in this panel. It is **on by default** (the choice is
place with **no flicker**, and the cover leaves a gap for the hamburger so it is remembered), always undone on quit, and if the app dies macOS removes the spacer
never hidden. automatically.
What it does and does not cover: What it can and cannot hide:
- Covers **other third-party apps' icons** (cmux, Kapture, WireGuard, input - It hides **other third-party apps' icons** (cmux, Kapture, moomoo, WireGuard).
source, Now Playing, …). - It **keeps Wi-Fi, Now Playing, the input-source indicator, Spotlight, Clock,
- Keeps **Wi-Fi, Spotlight, Clock, Battery and Control Center** visible — macOS Battery and Control Center** visible, with the **hamburger at the left edge of
reserves that far-right area, so those icons stay put. that group**. The spacer's boundary (preferred position 330) falls just left
- The cover matches the menu bar's frosted look only approximately; over an of the input-source indicator. macOS reserves the far-right area, so an item
unusual wallpaper the covered strip may be slightly visible. can never be placed to the right of the system icons.
- Turning the toggle off removes the cover immediately; quitting does too. - 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 ## Install with Homebrew
@@ -51,7 +55,8 @@ brew install --cask simpletopbar
``` ```
The tap is hosted on Gitea, so the full URL is required. Homebrew may print an The tap is hosted on Gitea, so the full URL is required. Homebrew may print an
"Unsupported tap" warning — it's harmless. "Unsupported tap" warning — it's harmless. Releases from 0.4.8 on are notarized,
so the app opens without a Gatekeeper warning.
## Upgrade ## Upgrade
+30 -13
View File
@@ -43,27 +43,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
AppListModel.shared.start() AppListModel.shared.start()
MenuBarItemManager.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() setupStatusItem()
MenuBarItemManager.shared.refresh() MenuBarHider.shared.prepare()
if CommandLine.arguments.contains("--diag-press") { if CommandLine.arguments.contains("--diag-press") {
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
MenuBarHider.shared.suspend()
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2.25) {
if let item = MenuBarItemManager.enumerate().first(where: { if let item = MenuBarItemManager.enumerate().first(where: {
$0.title.lowercased().contains("wireguard") $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) _ = AXUIElementPerformAction(item.element, kAXPressAction as CFString)
} }
} }
DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) { DispatchQueue.main.asyncAfter(deadline: .now() + 2.45) {
dumpWindows() MenuBarHider.shared.resume()
} }
return DispatchQueue.main.asyncAfter(deadline: .now() + 4.5) {
}
if CommandLine.arguments.contains("--diag-cover") {
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
dumpWindows() dumpWindows()
} }
return return
@@ -103,9 +103,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
let name = "SimpleTopBar" let name = "SimpleTopBar"
let positionKey = "NSStatusItem Preferred Position \(name)" let positionKey = "NSStatusItem Preferred Position \(name)"
// Only seed the position on first run so the user's own drag wins and // Only seed the position on first run so the user's own drag wins and
// persists through the autosave name. // persists through the autosave name. Kept just below the hider's
if UserDefaults.standard.object(forKey: positionKey) == nil { // preferred position so the hamburger lands at the left edge of the
UserDefaults.standard.set(100.0, forKey: positionKey) // 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) let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
@@ -132,6 +136,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() {
+115 -23
View File
@@ -9,6 +9,8 @@ struct MenuBarItem: Identifiable, Equatable {
let appName: String let appName: String
let title: String let title: String
let icon: 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). /// Frame in Accessibility coordinates (origin top-left of the main screen).
let frame: CGRect? let frame: CGRect?
let element: AXUIElement let element: AXUIElement
@@ -30,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() {
@@ -54,15 +59,9 @@ final class MenuBarItemManager: ObservableObject {
authorized = AXIsProcessTrusted() authorized = AXIsProcessTrusted()
guard authorized else { guard authorized else {
items = [] items = []
MenuBarHider.shared.apply(items: [], avoid: nil)
return return
} }
let ownPID = ProcessInfo.processInfo.processIdentifier items = Self.enumerate()
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)
} }
nonisolated static func enumerate(includeOwn: Bool = false) -> [MenuBarItem] { nonisolated static func enumerate(includeOwn: Bool = false) -> [MenuBarItem] {
@@ -108,6 +107,7 @@ final class MenuBarItemManager: ObservableObject {
appName: app.localizedName ?? app.bundleIdentifier ?? "Unknown", appName: app.localizedName ?? app.bundleIdentifier ?? "Unknown",
title: title, title: title,
icon: Self.icon(forTitle: title, app: app), icon: Self.icon(forTitle: title, app: app),
isSystem: Self.isSystemBundle(app.bundleIdentifier),
frame: frame, frame: frame,
element: child element: child
)) ))
@@ -121,20 +121,66 @@ final class MenuBarItemManager: ObservableObject {
} }
} }
/// Opens the real menu bar item, so its menu/popover appears at its own /// Opens the real menu bar item, so its menu/popover appears.
/// on-screen position. With the cover approach the item never moves, so no ///
/// reveal is needed. /// 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) { 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) _ = 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 { 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 // MARK: - Icons
/// Picks an icon for a menu bar item. Control Center owns many unrelated /// 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 /// Best-effort menu bar item hiding. A zero-width spacer status item is created
/// them and are hidden by our own frosted window drawn over them. Because they /// at launch; expanding its length to a huge value pushes the other apps' icons
/// never move, opening one's menu needs no reveal. On by default; the choice is /// off the visible menu bar, so they can only be reached through SimpleTopBar's
/// persisted. Always restored on quit. /// 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 @MainActor
final class MenuBarHider: ObservableObject { final class MenuBarHider: ObservableObject {
static let shared = MenuBarHider() 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" private static let defaultsKey = "hideMenuBarItems"
@Published private(set) var isHiding = false @Published private(set) var isHiding = false
@@ -243,27 +293,69 @@ final class MenuBarHider: ObservableObject {
didSet { didSet {
guard enabled != oldValue else { return } guard enabled != oldValue else { return }
UserDefaults.standard.set(enabled, forKey: Self.defaultsKey) UserDefaults.standard.set(enabled, forKey: Self.defaultsKey)
MenuBarItemManager.shared.refresh() apply(enabled)
} }
} }
private var spacer: NSStatusItem?
private init() { private init() {
// Hide by default: the menu bar then shows only SimpleTopBar's hamburger // 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 enabled = UserDefaults.standard.object(forKey: Self.defaultsKey) as? Bool ?? true
} }
/// Recompute the cover windows for the current menu bar items. `avoid` is /// Create the spacer early (at launch) so it lands as far right as possible.
/// our own hamburger's frame, which the cover must never hide. func prepare() {
func apply(items: [MenuBarItem], avoid: CGRect?) { guard spacer == nil else { return }
isHiding = enabled let name = "SimpleTopBarHider"
MenuBarCover.shared.update(enabled: enabled, items: items, avoid: avoid) 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() { func restore() {
spacer?.length = 0
isHiding = false isHiding = false
MenuBarCover.shared.update(enabled: false, items: [])
} }
} }
-124
View File
@@ -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()
}
}
+8 -24
View File
@@ -35,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
@@ -103,9 +103,9 @@ 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", 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(), 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(), 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 == 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")
@@ -117,26 +117,10 @@ func runSelfTest() {
check(MenuBarItemManager.symbolName(for: "Search") == "magnifyingglass", "search maps to magnifier 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") 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. // Tray sections: Apple bundles are "system", everything else is an app.
let merged = MenuBarCover.runs(from: [CGRect(x: 0, y: 0, width: 10, height: 20), check(MenuBarItemManager.isSystemBundle("com.apple.controlcenter"), "apple bundles classify as system")
CGRect(x: 12, y: 0, width: 10, height: 20)]) check(!MenuBarItemManager.isSystemBundle("com.cmux.app"), "third-party bundles classify as apps")
check(merged == [CGRect(x: 0, y: 0, width: 22, height: 20)], "adjacent frames merge into one cover") check(!MenuBarItemManager.isSystemBundle(nil), "unknown bundles are treated as apps")
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")
let authorized = AXIsProcessTrusted() let authorized = AXIsProcessTrusted()
print("accessibility authorized=\(authorized)") print("accessibility authorized=\(authorized)")
+50 -24
View File
@@ -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 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)
} }
} }
} }
+2 -2
View File
@@ -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.8}"
swift build -c release swift build -c release
@@ -58,7 +58,7 @@ chmod +x scripts/make-icon.sh
if [ -n "$IDENT" ]; then if [ -n "$IDENT" ]; then
security unlock-keychain "$HOME/Library/Keychains/login.keychain-db" 2>/dev/null || true security unlock-keychain "$HOME/Library/Keychains/login.keychain-db" 2>/dev/null || true
codesign --force --options runtime --sign "$IDENT" "$APP" codesign --force --options runtime --timestamp --sign "$IDENT" "$APP"
echo "Built $APP ${VERSION} (signed with Developer ID '$IDENT') — open $APP" echo "Built $APP ${VERSION} (signed with Developer ID '$IDENT') — open $APP"
else else
codesign --force --sign - "$APP" codesign --force --sign - "$APP"