diff --git a/docs/experiments/macos27-menubar.md b/docs/experiments/macos27-menubar.md new file mode 100644 index 0000000..4521a39 --- /dev/null +++ b/docs/experiments/macos27-menubar.md @@ -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. diff --git a/experiments/macos27-menubar-probe.swift b/experiments/macos27-menubar-probe.swift new file mode 100644 index 0000000..adb0495 --- /dev/null +++ b/experiments/macos27-menubar-probe.swift @@ -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 ===")