Fix clicks on hidden menu extras (#188)
Some checks failed
macOS CI / PeekabooCore build & tests (push) Waiting to run
macOS CI / Peekaboo CLI build & tests (push) Blocked by required conditions
macOS CI / Tachikoma build & tests (push) Blocked by required conditions
macOS CI / Build macOS apps (Peekaboo + Inspector) (push) Blocked by required conditions
macOS CI / SwiftLint (core + CLI) (push) Blocked by required conditions
Website (GitHub Pages) / build (push) Has been cancelled
Website (GitHub Pages) / deploy (push) Has been cancelled

* fix: reject hidden menu extras

* docs: note hidden menu extra safety
This commit is contained in:
Peter Steinberger 2026-06-13 02:59:25 -07:00 committed by GitHub
parent 26c7291292
commit 8cf0796692
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 228 additions and 19 deletions

View File

@ -2,6 +2,9 @@
## Unreleased
### Fixed
- Menu-extra clicks now reject items parked outside active displays by menu bar managers instead of moving the pointer to offscreen coordinates and reporting false success.
## [3.5.2] - 2026-06-13
### Changed

View File

@ -74,6 +74,16 @@ extension MenuService {
context: context.build())
}
if let position = menuExtra.position(),
Self.isIndividuallyHiddenMenuExtra(
position: position,
allPositions: extras.compactMap { $0.position() },
displayBounds: self.activeDisplayBounds())
{
throw PeekabooError.operationError(
message: self.hiddenMenuExtraMessage(title: title))
}
if !menuExtra.showMenu(), !menuExtra.press() {
throw OperationError.interactionFailed(
action: "click menu extra",
@ -226,9 +236,17 @@ extension MenuService {
}
let extra = extras[index]
guard extra.isVisible else {
throw PeekabooError.operationError(
message: self.hiddenMenuExtraMessage(title: extra.title))
}
guard let clickPoint = self.resolveMenuExtraClickPoint(for: extra) else {
throw PeekabooError.operationError(message: "Menu bar item has no clickable position")
}
guard self.isMenuExtraPointVisible(clickPoint) else {
throw PeekabooError.operationError(
message: self.hiddenMenuExtraMessage(title: extra.title))
}
try? InputDriver.move(to: clickPoint)
@ -245,6 +263,10 @@ extension MenuService {
location: clickPoint)
}
private func hiddenMenuExtraMessage(title: String) -> String {
"Menu bar item '\(title)' is outside the active displays. It may be hidden by a menu bar manager."
}
@_spi(Testing) public func resolvedMenuBarTitle(for extra: MenuExtraInfo, index: Int) -> String {
let title = extra.title
let titleIsPlaceholder = isPlaceholderMenuTitle(title) ||

View File

@ -80,7 +80,7 @@ extension MenuService {
bundleIdentifier: host.bundleIdentifier,
ownerName: host.localizedName,
position: position,
isVisible: true,
isVisible: self.isMenuExtraAXPositionVisible(position),
identifier: identifier,
source: "ax-control-center")
items.append(info)
@ -149,7 +149,7 @@ extension MenuService {
bundleIdentifier: bundleIdentifier,
ownerName: ownerName,
position: position,
isVisible: true,
isVisible: self.isMenuExtraAXPositionVisible(position),
identifier: identifier,
ownerPID: ownerPID,
source: "ax-menubar")
@ -308,7 +308,7 @@ extension MenuService {
bundleIdentifier: app.bundleIdentifier,
ownerName: app.localizedName,
position: position,
isVisible: true,
isVisible: self.isMenuExtraAXPositionVisible(position),
identifier: identifier,
ownerPID: app.processIdentifier,
source: "ax-app")

View File

@ -233,7 +233,7 @@ extension MenuExtraInfo {
bundleIdentifier: self.bundleIdentifier ?? candidate.bundleIdentifier,
ownerName: self.ownerName ?? candidate.ownerName,
position: self.preferredPosition(comparedTo: candidate),
isVisible: self.isVisible || candidate.isVisible,
isVisible: self.preferredVisibility(comparedTo: candidate),
identifier: self.identifier ?? candidate.identifier,
windowID: self.windowID ?? candidate.windowID,
windowLayer: self.windowLayer ?? candidate.windowLayer,
@ -241,6 +241,16 @@ extension MenuExtraInfo {
source: self.source ?? candidate.source)
}
private func preferredVisibility(comparedTo candidate: MenuExtraInfo) -> Bool {
if self.windowID != nil {
return self.isVisible
}
if candidate.windowID != nil {
return candidate.isVisible
}
return self.isVisible || candidate.isVisible
}
private static func preferredTitle(primary: MenuExtraInfo, secondary: MenuExtraInfo) -> String? {
let primaryTitle = sanitizedMenuText(primary.title) ?? sanitizedMenuText(primary.rawTitle)
let secondaryTitle = sanitizedMenuText(secondary.title) ?? sanitizedMenuText(secondary.rawTitle)

View File

@ -11,9 +11,10 @@ import Foundation
extension MenuService {
func getMenuBarItemsViaWindows() -> [MenuExtraInfo] {
var items: [MenuExtraInfo] = []
let displayBounds = self.activeDisplayBounds()
// Preferred: call LSUIElement helper (AppKit context) to get WindowServer view like Ice.
if let helperItems = self.getMenuBarItemsViaHelper(), !helperItems.isEmpty {
if let helperItems = self.getMenuBarItemsViaHelper(displayBounds: displayBounds), !helperItems.isEmpty {
self.logger.debug("MenuService helper returned \(helperItems.count) items")
return helperItems
}
@ -31,7 +32,7 @@ extension MenuService {
if !combinedIDs.isEmpty {
// Use CGWindow metadata per window ID to resolve owner/bundle.
for id in combinedIDs {
if let item = self.makeMenuExtra(from: id) {
if let item = self.makeMenuExtra(from: id, displayBounds: displayBounds) {
items.append(item)
seenIDs.insert(id)
} else {
@ -50,7 +51,7 @@ extension MenuService {
for windowInfo in windowList {
guard let windowID = windowInfo[kCGWindowNumber as String] as? CGWindowID else { continue }
guard !seenIDs.contains(windowID) else { continue }
if let item = self.makeMenuExtra(from: windowID, info: windowInfo) {
if let item = self.makeMenuExtra(from: windowID, info: windowInfo, displayBounds: displayBounds) {
items.append(item)
seenIDs.insert(windowID)
}
@ -73,6 +74,66 @@ extension MenuService {
return nil
}
func isMenuExtraPointVisible(_ point: CGPoint) -> Bool {
Self.isMenuExtraPointVisible(point, displayBounds: self.activeDisplayBounds())
}
func isMenuExtraAXPositionVisible(_ position: CGPoint) -> Bool {
position != .zero && self.isMenuExtraPointVisible(position)
}
static func isMenuExtraPointVisible(_ point: CGPoint, displayBounds: [CGRect]) -> Bool {
displayBounds.contains { $0.contains(point) }
}
static func isMenuExtraFrameVisible(_ frame: CGRect, displayBounds: [CGRect]) -> Bool {
guard frame.width > 0, frame.height > 0 else { return false }
return self.isMenuExtraPointVisible(
CGPoint(x: frame.midX, y: frame.midY),
displayBounds: displayBounds)
}
static func isIndividuallyHiddenMenuExtra(
position: CGPoint,
allPositions: [CGPoint],
displayBounds: [CGRect]) -> Bool
{
guard position != .zero,
!self.isMenuExtraPointVisible(position, displayBounds: displayBounds)
else {
return false
}
return allPositions.contains { candidate in
candidate != .zero && self.isMenuExtraPointVisible(candidate, displayBounds: displayBounds)
}
}
func activeDisplayBounds() -> [CGRect] {
let appKitBounds: () -> [CGRect] = {
NSScreen.screens.compactMap { screen in
guard let displayID = screen.deviceDescription[
NSDeviceDescriptionKey("NSScreenNumber"),
] as? CGDirectDisplayID
else {
return nil
}
return CGDisplayBounds(displayID)
}
}
var displayCount: UInt32 = 0
guard CGGetActiveDisplayList(0, nil, &displayCount) == .success, displayCount > 0 else {
return appKitBounds()
}
var displayIDs = [CGDirectDisplayID](repeating: 0, count: Int(displayCount))
guard CGGetActiveDisplayList(displayCount, &displayIDs, &displayCount) == .success else {
return appKitBounds()
}
return displayIDs.prefix(Int(displayCount)).map { CGDisplayBounds($0) }
}
func windowBounds(for windowID: CGWindowID) -> CGRect? {
guard let info = CGWindowListCopyWindowInfo([.optionIncludingWindow], windowID) as? [[String: Any]],
let first = info.first,
@ -89,6 +150,9 @@ extension MenuService {
}
func tryWindowTargetedClick(extra: MenuExtraInfo, point: CGPoint) -> Bool {
guard self.isMenuExtraPointVisible(point) else {
return false
}
guard let windowID = extra.windowID else {
return false
}
@ -140,19 +204,20 @@ extension MenuService {
func menuBarAXMaxY(for position: CGPoint) -> CGFloat {
let fallbackHeight: CGFloat = 24
guard let screen = NSScreen.screens.first(where: { screen in
let matchingScreens = NSScreen.screens.filter { screen in
position.x >= screen.frame.minX && position.x <= screen.frame.maxX
}) else {
return fallbackHeight + 12
}
let height = max(0, screen.frame.maxY - screen.visibleFrame.maxY)
let menuBarHeight = height > 0 ? height : fallbackHeight
return menuBarHeight + 12
let candidateScreens = matchingScreens.isEmpty ? NSScreen.screens : matchingScreens
return candidateScreens
.map { screen in
let height = max(0, screen.frame.maxY - screen.visibleFrame.maxY)
return (height > 0 ? height : fallbackHeight) + 12
}
.max() ?? fallbackHeight + 12
}
/// Invoke the LSUIElement helper (if built) to enumerate menu bar windows from a GUI context.
func getMenuBarItemsViaHelper() -> [MenuExtraInfo]? {
func getMenuBarItemsViaHelper(displayBounds: [CGRect]) -> [MenuExtraInfo]? {
let helperPath = [
FileManager.default.currentDirectoryPath,
"Helpers",
@ -188,14 +253,18 @@ extension MenuService {
// Enrich each window ID locally via CGWindowList so we can keep coordinates/owner.
var items: [MenuExtraInfo] = []
for id in ids {
if let item = self.makeMenuExtra(from: CGWindowID(id)) {
if let item = self.makeMenuExtra(from: CGWindowID(id), displayBounds: displayBounds) {
items.append(item)
}
}
return items
}
func makeMenuExtra(from windowID: CGWindowID, info: [String: Any]? = nil) -> MenuExtraInfo? {
func makeMenuExtra(
from windowID: CGWindowID,
info: [String: Any]? = nil,
displayBounds: [CGRect]) -> MenuExtraInfo?
{
let windowInfo: [String: Any]
if let info {
windowInfo = info
@ -222,6 +291,8 @@ extension MenuService {
guard let ownerPID = windowInfo[kCGWindowOwnerPID as String] as? pid_t else { return nil }
let ownerName = windowInfo[kCGWindowOwnerName as String] as? String ?? "Unknown"
let windowTitle = windowInfo[kCGWindowName as String] as? String ?? ""
let frame = CGRect(x: x, y: y, width: width, height: height)
let isVisible = Self.isMenuExtraFrameVisible(frame, displayBounds: displayBounds)
if ownerName == "Window Server", windowTitle == "Menubar" {
return nil
@ -241,7 +312,7 @@ extension MenuService {
bundleIdentifier: bundleID,
ownerName: appName,
position: CGPoint(x: x + width / 2, y: y + height / 2),
isVisible: true,
isVisible: isVisible,
identifier: bundleID ?? windowTitle,
windowID: windowID,
windowLayer: windowLayer,
@ -264,7 +335,7 @@ extension MenuService {
bundleIdentifier: bundleID,
ownerName: ownerName,
position: CGPoint(x: x + width / 2, y: y + height / 2),
isVisible: true,
isVisible: isVisible,
identifier: bundleID ?? windowTitle,
windowID: windowID,
windowLayer: windowLayer,

View File

@ -0,0 +1,103 @@
import CoreGraphics
import Testing
@testable @_spi(Testing) import PeekabooAutomationKit
@MainActor
struct MenuExtraVisibilityTests {
private let primaryDisplay = CGRect(x: 0, y: 0, width: 1800, height: 1169)
private let leftDisplay = CGRect(x: -1440, y: 0, width: 1440, height: 900)
@Test
func `offscreen parked menu extra is not visible`() {
let frame = CGRect(x: -4520, y: 8, width: 26, height: 24)
#expect(!MenuService.isMenuExtraFrameVisible(frame, displayBounds: [self.primaryDisplay]))
#expect(!MenuService.isMenuExtraPointVisible(
CGPoint(x: frame.midX, y: frame.midY),
displayBounds: [self.primaryDisplay]))
}
@Test
func `menu extra on primary display is visible`() {
let frame = CGRect(x: 1258, y: 8, width: 28, height: 24)
#expect(MenuService.isMenuExtraFrameVisible(frame, displayBounds: [self.primaryDisplay]))
#expect(MenuService.isMenuExtraPointVisible(
CGPoint(x: frame.midX, y: frame.midY),
displayBounds: [self.primaryDisplay]))
}
@Test
func `negative coordinate secondary display remains valid`() {
let frame = CGRect(x: -100, y: 8, width: 28, height: 24)
let displays = [self.primaryDisplay, self.leftDisplay]
#expect(MenuService.isMenuExtraFrameVisible(frame, displayBounds: displays))
#expect(MenuService.isMenuExtraPointVisible(
CGPoint(x: frame.midX, y: frame.midY),
displayBounds: displays))
}
@Test
func `parked section spanning onto display is not visible`() {
let frame = CGRect(x: -3793, y: 0, width: 5010, height: 39)
#expect(frame.intersects(self.primaryDisplay))
#expect(!MenuService.isMenuExtraFrameVisible(frame, displayBounds: [self.primaryDisplay]))
}
@Test
func `offscreen AX position remains a plausible hidden menu extra`() {
let service = MenuService()
#expect(service.isLikelyMenuBarAXPosition(CGPoint(x: -4500, y: 20)))
#expect(!service.isMenuExtraAXPositionVisible(CGPoint(x: -4500, y: 20)))
#expect(!service.isLikelyMenuBarAXPosition(CGPoint(x: -4500, y: 500)))
}
@Test
func `offscreen target with visible peers is individually hidden`() {
let target = CGPoint(x: -4500, y: 20)
#expect(MenuService.isIndividuallyHiddenMenuExtra(
position: target,
allPositions: [target, CGPoint(x: 1200, y: 20)],
displayBounds: [self.primaryDisplay]))
}
@Test
func `fully auto hidden menu bar can use AX action`() {
let target = CGPoint(x: -4500, y: 20)
#expect(!MenuService.isIndividuallyHiddenMenuExtra(
position: target,
allPositions: [target, CGPoint(x: -4400, y: 20)],
displayBounds: [self.primaryDisplay]))
}
@Test
func `window visibility remains authoritative when merging AX metadata`() {
let position = CGPoint(x: -4500, y: 20)
let windowExtra = MenuExtraInfo(
title: "Hidden Item",
position: position,
isVisible: false,
windowID: 42,
source: "cgs")
let accessibilityExtra = MenuExtraInfo(
title: "Hidden Item",
position: position,
isVisible: true,
identifier: "example.hidden-item",
source: "ax-menubar")
let merged = MenuService.mergeMenuExtras(
accessibilityExtras: [accessibilityExtra],
fallbackExtras: [windowExtra])
#expect(merged.count == 1)
#expect(merged.first?.isVisible == false)
#expect(merged.first?.identifier == "example.hidden-item")
#expect(merged.first?.windowID == 42)
}
}