menubar: add LSUIElement helper and invoke it before CGS fallbacks

This commit is contained in:
Peter Steinberger 2025-11-22 19:03:21 +00:00
parent 4051bb69ff
commit 5c5030f3bb
7 changed files with 281 additions and 0 deletions

View File

@ -188,6 +188,12 @@ extension MenuService {
private func getMenuBarItemsViaWindows() -> [MenuExtraInfo] {
var items: [MenuExtraInfo] = []
// Preferred: call LSUIElement helper (AppKit context) to get WindowServer view like Ice.
if let helperItems = self.getMenuBarItemsViaHelper(), !helperItems.isEmpty {
self.logger.debug("MenuService helper returned \(helperItems.count) items")
return helperItems
}
// Preferred path: CGS menuBarItems window list (private API, mirrored from Ice).
let cgsIDs = cgsMenuBarWindowIDs(onScreen: true, activeSpace: true)
let legacyIDs = cgsProcessMenuBarWindowIDs(onScreenOnly: true)
@ -222,6 +228,41 @@ extension MenuService {
return items
}
/// Invoke the LSUIElement helper (if built) to enumerate menu bar windows from a GUI context.
private func getMenuBarItemsViaHelper() -> [MenuExtraInfo]? {
let helperPath = "\(FileManager.default.currentDirectoryPath)/Helpers/MenuBarHelper/build/MenubarHelper.app/Contents/MacOS/menubar-helper"
guard FileManager.default.isExecutableFile(atPath: helperPath) else {
return nil
}
let process = Process()
process.launchPath = helperPath
let pipe = Pipe()
process.standardOutput = pipe
do {
try process.run()
} catch {
self.logger.debug("Failed to run menubar helper: \(error.localizedDescription)")
return nil
}
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let windows = json["windows"] as? [[String: Any]]
else { return nil }
var items: [MenuExtraInfo] = []
for windowInfo in windows {
guard let windowID = windowInfo["CGSWindowID"] as? UInt32 else { continue }
if let item = self.makeMenuExtra(from: CGWindowID(windowID), info: windowInfo) {
items.append(item)
}
}
return items
}
private func makeMenuExtra(from windowID: CGWindowID, info: [String: Any]? = nil) -> MenuExtraInfo? {
let windowInfo: [String: Any]
if let info {

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>menubar-helper</string>
<key>CFBundleIdentifier</key>
<string>boo.peekaboo.menubarhelper</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>MenuBar Helper</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSUIElement</key>
<true/>
</dict>
</plist>

View File

@ -0,0 +1,86 @@
import AppKit
import CoreGraphics
import Foundation
// LSUIElement helper that enumerates menu bar windows via private CGS APIs and prints JSON.
// Running inside AppKit provides the GUI WindowServer connection needed to see third-party extras.
private struct CGSWindowListOption: OptionSet {
let rawValue: UInt32
static let onScreen = CGSWindowListOption(rawValue: 1 << 0)
static let menuBarItems = CGSWindowListOption(rawValue: 1 << 1)
static let activeSpace = CGSWindowListOption(rawValue: 1 << 2)
}
private func loadSymbol<T>(_ name: String, handle: UnsafeMutableRawPointer?) -> T? {
guard let sym = dlsym(handle, name) else { return nil }
return unsafeBitCast(sym, to: T.self)
}
private func loadCGSHandle() -> UnsafeMutableRawPointer? {
let handles = [
"/System/Library/PrivateFrameworks/SkyLight.framework/SkyLight",
"/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics",
]
for path in handles {
if let h = dlopen(path, RTLD_NOW) { return h }
}
return nil
}
private func listMenuBarWindows() -> [[String: Any]] {
guard let handle = loadCGSHandle(),
let mainConnSym: @convention(c) () -> UInt32 = loadSymbol("CGSMainConnectionID", handle: handle),
let copySym: @convention(c) (UInt32, Int32, UInt32) -> CFArray? =
loadSymbol("CGSCopyWindowsWithOptions", handle: handle),
let getCountSym: @convention(c) (UInt32, UInt32, UnsafeMutablePointer<Int32>) -> Int32 =
loadSymbol("CGSGetWindowCount", handle: handle),
let getMenuBarSym: @convention(c) (
UInt32, UInt32, Int32, UnsafeMutablePointer<CGWindowID>, UnsafeMutablePointer<Int32>) -> Int32 =
loadSymbol("CGSGetProcessMenuBarWindowList", handle: handle)
else {
return []
}
let cid = mainConnSym()
// Process-level list (Ice primary path).
var total: Int32 = 0
_ = getCountSym(cid, 0, &total)
var buf = [CGWindowID](repeating: 0, count: Int(max(total, 32)))
var out: Int32 = 0
_ = getMenuBarSym(cid, 0, total, &buf, &out)
let procIDs = Array(buf.prefix(Int(out)))
// Copy-with-options (sometimes returns extras).
let opts: CGSWindowListOption = [.menuBarItems, .onScreen, .activeSpace]
let copyIDs = (copySym(cid, 0, opts.rawValue) as? [UInt32]) ?? []
let ids = Array(Set(procIDs + copyIDs))
guard !ids.isEmpty else { return [] }
let windowInfo = CGWindowListCopyWindowInfo(.optionAll, kCGNullWindowID) as? [[String: Any]] ?? []
let dictByID: [CGWindowID: [String: Any]] = Dictionary(uniqueKeysWithValues: windowInfo.compactMap { info in
guard let id = info[kCGWindowNumber as String] as? CGWindowID else { return nil }
return (id, info)
})
return ids.compactMap { id in
var info = dictByID[id] ?? [:]
info["CGSWindowID"] = id
return info
}
}
// Initialize AppKit to get a GUI connection (LSUIElement).
NSApplication.shared
let windows = listMenuBarWindows()
let payload: [String: Any] = ["windows": windows]
if let data = try? JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted]) {
FileHandle.standardOutput.write(data)
} else {
fputs("{\"error\":\"serialization_failed\"}", stdout)
}
fflush(stdout)
exit(0)

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>cgs-menu-probe-app</string>
<key>CFBundleIdentifier</key>
<string>boo.peekaboo.cgs-menu-probe</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>cgs-menu-probe-app</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSUIElement</key>
<true/>
</dict>
</plist>

View File

@ -0,0 +1,85 @@
import CoreGraphics
import Foundation
import AppKit
import Darwin
// Dynamic loader mirroring Peekaboo CGS bridge
private struct CGSWindowListOption: OptionSet {
let rawValue: UInt32
static let onScreen = CGSWindowListOption(rawValue: 1 << 0)
static let menuBarItems = CGSWindowListOption(rawValue: 1 << 1)
static let activeSpace = CGSWindowListOption(rawValue: 1 << 2)
}
func loadHandle() -> UnsafeMutableRawPointer? {
let candidates = [
"/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics",
"/System/Library/PrivateFrameworks/SkyLight.framework/SkyLight",
"/System/Library/PrivateFrameworks/SkyLight.framework/Versions/A/SkyLight",
]
for path in candidates {
if let h = dlopen(path, RTLD_NOW) { return h }
}
return nil
}
func cgsMenuBarWindowIDs() -> [UInt32] {
guard let handle = loadHandle(),
let mainSym = dlsym(handle, "CGSMainConnectionID"),
let copySym = dlsym(handle, "CGSCopyWindowsWithOptions") else { return [] }
typealias MainConn = @convention(c) () -> UInt32
typealias CopyWins = @convention(c) (UInt32, Int32, UInt32) -> CFArray?
let mainConnection = unsafeBitCast(mainSym, to: MainConn.self)
let copyWindows = unsafeBitCast(copySym, to: CopyWins.self)
let cid = mainConnection()
let opts: CGSWindowListOption = [.menuBarItems, .onScreen, .activeSpace]
if let arr = copyWindows(cid, 0, opts.rawValue) as? [UInt32] {
return arr
}
return []
}
func cgsProcessMenuBarIDs() -> [UInt32] {
guard let handle = loadHandle(),
let mainSym = dlsym(handle, "CGSMainConnectionID"),
let countSym = dlsym(handle, "CGSGetWindowCount"),
let listSym = dlsym(handle, "CGSGetProcessMenuBarWindowList") else { return [] }
typealias MainConn = @convention(c) () -> UInt32
typealias GetCount = @convention(c) (UInt32, UInt32, UnsafeMutablePointer<Int32>) -> Int32
typealias GetList = @convention(c) (UInt32, UInt32, Int32, UnsafeMutablePointer<UInt32>, UnsafeMutablePointer<Int32>) -> Int32
let mainConnection = unsafeBitCast(mainSym, to: MainConn.self)
let getCount = unsafeBitCast(countSym, to: GetCount.self)
let getList = unsafeBitCast(listSym, to: GetList.self)
let cid = mainConnection()
var total: Int32 = 0
_ = getCount(cid, 0, &total)
var buf = [UInt32](repeating: 0, count: Int(max(total, 32)))
var out: Int32 = 0
_ = getList(cid, 0, total, &buf, &out)
return Array(buf.prefix(Int(out)))
}
func cgLayer25Count() -> Int {
let cgList = CGWindowListCopyWindowInfo([.optionAll, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] ?? []
return cgList.filter { ($0[kCGWindowLayer as String] as? Int) == 25 }.count
}
@main
struct ProbeApp {
static func main() {
NSApplication.shared // ensure AppKit init for LSUIElement context
let idsCopy = cgsMenuBarWindowIDs()
let idsProc = cgsProcessMenuBarIDs()
let layer25 = cgLayer25Count()
print("CGSCopy menuBar=\(idsCopy.count) ids=\(idsCopy)")
print("CGSGetProcessMenuBarWindowList=\(idsProc.count) ids=\(idsProc)")
print("CGWindowList layer25=\(layer25)")
fflush(stdout)
exit(0)
}
}

21
scripts/build-menubar-helper.sh Executable file
View File

@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
HELPER_DIR="$ROOT_DIR/Helpers/MenuBarHelper"
BUILD_DIR="$HELPER_DIR/build"
APP_DIR="$BUILD_DIR/MenubarHelper.app"
rm -rf "$BUILD_DIR"
mkdir -p "$APP_DIR/Contents/MacOS"
# Build the helper binary; allow undefined private symbols (resolved at runtime via dlopen).
swiftc -O -framework AppKit \
-Xlinker -undefined -Xlinker dynamic_lookup \
"$HELPER_DIR/main.swift" \
-o "$APP_DIR/Contents/MacOS/menubar-helper"
# Copy Info.plist to make it LSUIElement.
cp "$HELPER_DIR/Info.plist" "$APP_DIR/Contents/Info.plist"
echo "Built helper at $APP_DIR"