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
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:
parent
26c7291292
commit
8cf0796692
@ -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
|
||||
|
||||
@ -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) ||
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user