Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions docs/experiments/macos27-menubar.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Experiment: what macOS 27 exposes for hiding menu-bar icons

**Status:** exploration / not shipped. **Does NOT hide anything.** Read-only probe +
findings, to scope a future macOS-27 hide mechanism (#360, managed-overflow epic #366).

PR #370 makes Hidden Bar detect-and-degrade on macOS 27 (the length-inflation trick is
dead there). This note answers the next question: **is there any path to actually hide
menu-bar icons on macOS 27, and at what cost?** It is the recon the backlog's Option D
("managed-overflow / second-bar redesign, likely via Accessibility") needs before design.

The probe (`experiments/macos27-menubar-probe.swift`) only READS state — it never moves,
presses, hides, or modifies anything.

```sh
swiftc experiments/macos27-menubar-probe.swift -o /tmp/mbprobe && /tmp/mbprobe
```

## Findings (macOS 27.0, build 26A5368g)

### 1. A new agent owns the menu bar, and per-item windows are gone

`MenuBarAgent` (`/System/Library/CoreServices/MenuBarAgent.app`) is running and owns the
status-item area. In the **public** `CGWindowListCopyWindowInfo` list, the menu-bar band
contains a single full-width `Window Server` window — there are **no per-item status
windows** anymore. On macOS ≤ 26 each status item was its own small `CGWindow`; that is
exactly what the length-inflation trick and window-capture tools (Ice/Thaw/Bartender)
relied on, and it is gone. This matches the BetterTouchTool/Thaw reports (`CGSGetProcessMenuBarWindowList`
now returns one window).

### 2. Accessibility still ENUMERATES items and their positions

`AXUIElementCreateApplication(MenuBarAgent).AXExtrasMenuBar` returns an `AXMenuBar` with
the foreign status items as children, with readable positions:

```
AXExtrasMenuBar: role=AXMenuBar childCount=5
[0] pos=(1307,0) [1] pos=(1201,0) [2] pos=(1387,0) [3] pos=(1345,0) [4] pos=(1265,0)
```

So a hide mechanism CAN discover items and where they are — but only with **Accessibility
permission** (`AXIsProcessTrusted` must be true; it is incompatible with the App Sandbox).

### 3. ...but Accessibility offers NO primitive to hide, move, or reorder them

Inspecting the items' AX capabilities is where the public path ends:

| Owner | role / subrole | `AXPosition` settable | actions |
|---|---|---|---|
| **MenuBarAgent** (the modern bar) | `AXGroup` / `AXHostingView` (SwiftUI) | **false** | **`[]`** (none) |
| `SystemUIServer` (legacy extra) | `AXMenuBarItem` / `AXMenuExtra` | **false** | `AXPress`, `AXCancel` |

The modern macOS-27 items are read-only SwiftUI hosting views: **no settable position, no
actions at all** — you cannot reposition them (to shove them off-screen) or invoke any
hide. Legacy `AXMenuExtra`s can still be `AXPress`ed (to open their menu) but also can't be
repositioned. There is no `AXHide`, no settable order, no public lever.

## Conclusion

On macOS 27, **public APIs let you READ the menu bar but not REARRANGE it.** No
public-API hide mechanism exists. Restoring real hiding requires a **private** path —
the SkyLight/`CGS*` calls or the `MenuBarAgent` XPC/Stage-Manager-era interfaces that the
maintained tools reverse-engineered — which:

- needs **Accessibility** (and historically Screen-Recording) permission → cannot run in
the current sandboxed, permission-free build, and is not App-Store-shippable as-is;
- is **unofficial and unstable** — Apple can (and, per the BetterTouchTool author, may)
change or block it during the macOS 27 beta;
- is a deliberate architecture decision, not a drop-in — exactly the maintainer's Option D
(#366) "managed-overflow / second-bar redesign, likely via Accessibility."

### Recommended path

1. Treat #366 as the home for real macOS-27 hiding; this recon scopes its public-API
ceiling (read-only) and its private-API requirement.
2. Reference implementation that already hides on macOS 27 Golden Gate: **`stonerl/Thaw`**
(MIT, the maintained Ice fork) — see its issue #687. It uses the new menu-bar API +
Accessibility, in a non-sandboxed build, and is still iterating on reliability.
3. Existing in-repo starting points for the second-bar/overflow UI: community PRs **#358**
(separate hidden-items bar) and **#350** (notch overflow).
4. If pursued in this repo, the hide module must be isolated behind a build flag
(e.g. `EXPERIMENTAL_MENUBAR_27`) and a separate **non-sandboxed** target/entitlements,
so the default sandboxed/MAS build and PR #370's clean posture stay untouched.

PR #370 (detect-and-degrade) remains the correct shipping behavior until such a mechanism
is designed and accepted.
135 changes: 135 additions & 0 deletions experiments/macos27-menubar-probe.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// macos27-menubar-probe.swift
//
// EXPERIMENTAL diagnostic — NOT part of the Hidden Bar app target, NOT shipped.
// Characterizes the macOS 27 ("Golden Gate") menu bar so a future hide mechanism
// can be designed (#360 / managed-overflow epic #366). It only READS state; it
// does not move, hide, or modify anything.
//
// Build & run (standalone, non-sandboxed so Accessibility can be granted):
// swiftc experiments/macos27-menubar-probe.swift -o /tmp/mbprobe
// /tmp/mbprobe
//
// Background: on macOS <= 26 each menu-bar status item was its own small CGWindow,
// and Hidden Bar hid neighbours by inflating its separator so they were pushed
// off-screen. On macOS 27 that no longer works (see #360 / PR #370). This probe
// gathers the signals a real macOS-27 hide path would need: who owns the bar now,
// what Accessibility still exposes, and how the status-layer windows look.

import AppKit
import ApplicationServices

func line(_ s: String) { print(s) }

// MARK: - 1. Accessibility trust

line("=== Accessibility ===")
line("AXIsProcessTrusted: \(AXIsProcessTrusted())")

// MARK: - 2. Menu-bar owning processes

let menuBarOwners = ["MenuBarAgent", "ControlCenter", "SystemUIServer", "WindowServer", "Window Server"]
let running = NSWorkspace.shared.runningApplications
.compactMap { $0.localizedName }
line("\n=== Candidate menu-bar processes running ===")
for name in menuBarOwners where running.contains(name) {
line(" running: \(name)")
}

// MARK: - 3. Public CGWindowList: the status-layer window landscape
//
// Reads geometry/owner only (no titles, no images) so it needs NO screen-recording
// permission. The macOS-26 model showed many small per-item windows here; the macOS-27
// consolidation should show very few (often one) menu-bar window per owner.

line("\n=== CGWindowList (on-screen) menu-bar / status windows ===")
let statusLevel = CGWindowLevelForKey(.statusWindow) // NSStatusWindowLevel, ~25
let mainMenuLevel = CGWindowLevelForKey(.mainMenuWindow)
line("statusWindow level=\(statusLevel) mainMenu level=\(mainMenuLevel)")

if let infoList = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] {
var perOwner: [String: Int] = [:]
var rows: [String] = []
for w in infoList {
let layer = (w[kCGWindowLayer as String] as? Int) ?? 0
let owner = (w[kCGWindowOwnerName as String] as? String) ?? "?"
let ownerPID = (w[kCGWindowOwnerPID as String] as? Int) ?? -1
let b = (w[kCGWindowBounds as String] as? [String: CGFloat]) ?? [:]
let y = b["Y"] ?? -1, h = b["Height"] ?? -1
// The menu bar lives at the very top, ~24-40pt tall.
let inMenuBarBand = (y >= 0 && y <= 8 && h > 0 && h <= 64)
let atStatusLayer = (layer == Int(statusLevel) || layer == Int(mainMenuLevel))
if inMenuBarBand || atStatusLayer {
perOwner[owner, default: 0] += 1
rows.append(String(format: " layer=%4d owner=%@ pid=%d x=%.0f y=%.0f w=%.0f h=%.0f",
layer, owner, ownerPID, b["X"] ?? -1, y, b["Width"] ?? -1, h))
}
}
line("menu-bar/status windows by owner: \(perOwner.sorted { $0.value > $1.value })")
line("rows (\(rows.count)):")
rows.prefix(60).forEach { line($0) }
} else {
line(" CGWindowListCopyWindowInfo returned nil")
}

// MARK: - 4. Accessibility: what the new menu bar exposes
//
// On macOS <= 26 the status extras were reachable as AXExtrasMenuBar / per-process
// menu bar 2. Probe whether macOS 27 still exposes them and via which element.

line("\n=== Accessibility: AXExtrasMenuBar probe ===")
func axChildrenSummary(_ element: AXUIElement, label: String) {
var roleRef: CFTypeRef?
AXUIElementCopyAttributeValue(element, kAXRoleAttribute as CFString, &roleRef)
var childrenRef: CFTypeRef?
let err = AXUIElementCopyAttributeValue(element, kAXChildrenAttribute as CFString, &childrenRef)
let children = (childrenRef as? [AXUIElement]) ?? []
line(" \(label): role=\(roleRef as? String ?? "?") childrenErr=\(err.rawValue) childCount=\(children.count)")
for (i, child) in children.prefix(20).enumerated() {
var titleRef: CFTypeRef?
AXUIElementCopyAttributeValue(child, kAXTitleAttribute as CFString, &titleRef)
var posRef: CFTypeRef?
AXUIElementCopyAttributeValue(child, kAXPositionAttribute as CFString, &posRef)
var pos = CGPoint.zero
if let posRef { AXValueGetValue(posRef as! AXValue, .cgPoint, &pos) }
line(" [\(i)] title=\(titleRef as? String ?? "-") pos=(\(Int(pos.x)),\(Int(pos.y)))")
}
}

// Dump the operations a hide mechanism would need: which attributes/actions a
// status item exposes, and whether its position is settable (= reorder/hide lever).
func dumpItemCapabilities(_ item: AXUIElement) {
var attrNames: CFArray?
AXUIElementCopyAttributeNames(item, &attrNames)
var actionNames: CFArray?
AXUIElementCopyActionNames(item, &actionNames)
var settable: DarwinBoolean = false
AXUIElementIsAttributeSettable(item, kAXPositionAttribute as CFString, &settable)
var roleRef: CFTypeRef?, subroleRef: CFTypeRef?
AXUIElementCopyAttributeValue(item, kAXRoleAttribute as CFString, &roleRef)
AXUIElementCopyAttributeValue(item, kAXSubroleAttribute as CFString, &subroleRef)
line(" first-item role=\(roleRef as? String ?? "?") subrole=\(subroleRef as? String ?? "?") positionSettable=\(settable.boolValue)")
line(" attributes=\((attrNames as? [String]) ?? [])")
line(" actions=\((actionNames as? [String]) ?? [])")
}

for app in NSWorkspace.shared.runningApplications where menuBarOwners.contains(app.localizedName ?? "") {
let pid = app.processIdentifier
let axApp = AXUIElementCreateApplication(pid)
line("\n -- \(app.localizedName ?? "?") (pid \(pid)) --")
var extrasRef: CFTypeRef?
let extrasErr = AXUIElementCopyAttributeValue(axApp, "AXExtrasMenuBar" as CFString, &extrasRef)
if extrasErr == .success, let extras = extrasRef, CFGetTypeID(extras) == AXUIElementGetTypeID() {
let extrasElement = extras as! AXUIElement
axChildrenSummary(extrasElement, label: "AXExtrasMenuBar")
var childrenRef: CFTypeRef?
AXUIElementCopyAttributeValue(extrasElement, kAXChildrenAttribute as CFString, &childrenRef)
if let first = (childrenRef as? [AXUIElement])?.first {
dumpItemCapabilities(first)
}
} else {
line(" AXExtrasMenuBar: err=\(extrasErr.rawValue)")
}
axChildrenSummary(axApp, label: "app root")
}

line("\n=== done ===")