diff --git a/Sources/GridSelect/MacOSAccessibilityTextExtractor.swift b/Sources/GridSelect/MacOSAccessibilityTextExtractor.swift index 507b702..a4af92b 100644 --- a/Sources/GridSelect/MacOSAccessibilityTextExtractor.swift +++ b/Sources/GridSelect/MacOSAccessibilityTextExtractor.swift @@ -1035,6 +1035,20 @@ struct SystemMacOSAccessibilityClient: MacOSAccessibilityClient { return AccessibilityElementHandle(rawElement: value as! AXUIElement) } + func focusedWindow( + inProcess processIdentifier: pid_t, + messagingTimeout: Float + ) -> AccessibilityElementHandle? { + let application = AXUIElementCreateApplication(processIdentifier) + guard AXUIElementSetMessagingTimeout(application, messagingTimeout) == .success, + let value = copyAttribute(application, kAXFocusedWindowAttribute), + CFGetTypeID(value) == AXUIElementGetTypeID() + else { + return nil + } + return AccessibilityElementHandle(rawElement: value as! AXUIElement) + } + func parent(of element: AccessibilityElementHandle) -> AccessibilityElementHandle? { guard let rawElement = element.rawElement, let value = copyAttribute(rawElement, kAXParentAttribute), diff --git a/Sources/GridSelect/MacOSGlobalShortcut.swift b/Sources/GridSelect/MacOSGlobalShortcut.swift index b0ab7ec..d84fe55 100644 --- a/Sources/GridSelect/MacOSGlobalShortcut.swift +++ b/Sources/GridSelect/MacOSGlobalShortcut.swift @@ -496,11 +496,41 @@ enum MacOSActivationSourceCapturer { return lastExternalApplication } - private struct WindowSnapshot { + struct WindowSnapshot: Equatable { let identifier: UInt32 let frame: CGRect } + enum FocusedWindowResolution: Equatable { + case notTrusted + case resolved(CGRect) + case queryFailed + } + + static func preferredWindow( + candidates: [WindowSnapshot], + focusedWindowResolution: FocusedWindowResolution + ) -> WindowSnapshot? { + switch focusedWindowResolution { + case .notTrusted: + return candidates.first + case .queryFailed: + return nil + case let .resolved(focusedWindowFrame): + let tolerance = 1.0 + let matches = candidates.filter { candidate in + abs(candidate.frame.minX - focusedWindowFrame.minX) <= tolerance + && abs(candidate.frame.minY - focusedWindowFrame.minY) <= tolerance + && abs(candidate.frame.width - focusedWindowFrame.width) <= tolerance + && abs(candidate.frame.height - focusedWindowFrame.height) <= tolerance + } + guard matches.count == 1 else { + return nil + } + return matches[0] + } + } + private static func frontmostWindow(for processIdentifier: pid_t) -> WindowSnapshot? { guard let rawWindows = CGWindowListCopyWindowInfo( [.optionOnScreenOnly, .excludeDesktopElements], @@ -508,6 +538,7 @@ enum MacOSActivationSourceCapturer { ) as? [[String: Any]] else { return nil } + var candidates: [WindowSnapshot] = [] for window in rawWindows { guard (window[kCGWindowOwnerPID as String] as? NSNumber)?.int32Value == processIdentifier, @@ -524,9 +555,41 @@ enum MacOSActivationSourceCapturer { else { continue } - return WindowSnapshot(identifier: identifier, frame: frame) + candidates.append(WindowSnapshot(identifier: identifier, frame: frame)) + } + guard candidates.count > 1 else { + return candidates.first + } + return preferredWindow( + candidates: candidates, + focusedWindowResolution: focusedWindowResolution( + for: processIdentifier + ) + ) + } + + private static func focusedWindowResolution( + for processIdentifier: pid_t + ) -> FocusedWindowResolution { + let client = SystemMacOSAccessibilityClient() + guard client.isTrusted else { + return .notTrusted + } + // Keep this synchronous MainActor lookup to three bounded AX reads: + // focused window, position, and size. The longer focused-element + // traversal runs later on the detached caret-capture path. + let timeout: Float = 0.1 + guard let focusedWindow = client.focusedWindow( + inProcess: processIdentifier, + messagingTimeout: timeout + ) else { + return .queryFailed + } + client.setMessagingTimeout(timeout, for: focusedWindow) + guard let frame = client.frame(of: focusedWindow) else { + return .queryFailed } - return nil + return .resolved(frame) } } diff --git a/Tests/GridSelectCoreTests/MacOSActivationSourceCapturerTests.swift b/Tests/GridSelectCoreTests/MacOSActivationSourceCapturerTests.swift new file mode 100644 index 0000000..d5acf4d --- /dev/null +++ b/Tests/GridSelectCoreTests/MacOSActivationSourceCapturerTests.swift @@ -0,0 +1,112 @@ +@testable import GridSelect +import CoreGraphics +import XCTest + +@MainActor +final class MacOSActivationSourceCapturerTests: XCTestCase { + func testFocusedDocumentWinsOverFrontmostHelperWindow() { + let helper = window(id: 16_045, x: 188, y: 89, width: 66, height: 20) + let document = window(id: 16_037, x: 182, y: 83, width: 656, height: 422) + + XCTAssertEqual( + MacOSActivationSourceCapturer.preferredWindow( + candidates: [helper, document], + focusedWindowResolution: .resolved(document.frame) + ), + document + ) + } + + func testFocusedSmallWindowIsNotFilteredBySize() { + let smallWindow = window(id: 7, x: 30, y: 40, width: 66, height: 20) + let largerWindow = window(id: 8, x: 100, y: 120, width: 800, height: 600) + + XCTAssertEqual( + MacOSActivationSourceCapturer.preferredWindow( + candidates: [smallWindow, largerWindow], + focusedWindowResolution: .resolved(smallWindow.frame) + ), + smallWindow + ) + } + + func testUntrustedAccessibilityPreservesPermissionFallbackOrder() { + let frontmost = window(id: 1, x: 10, y: 20, width: 300, height: 200) + let second = window(id: 2, x: 40, y: 50, width: 600, height: 400) + + XCTAssertEqual( + MacOSActivationSourceCapturer.preferredWindow( + candidates: [frontmost, second], + focusedWindowResolution: .notTrusted + ), + frontmost + ) + } + + func testTrustedFocusedWindowQueryFailureFailsClosed() { + let frontmost = window(id: 1, x: 10, y: 20, width: 300, height: 200) + let second = window(id: 2, x: 40, y: 50, width: 600, height: 400) + + XCTAssertNil( + MacOSActivationSourceCapturer.preferredWindow( + candidates: [frontmost, second], + focusedWindowResolution: .queryFailed + ) + ) + } + + func testFocusedFrameMustMatchExactlyOneCandidate() { + let first = window(id: 1, x: 10, y: 20, width: 300, height: 200) + let duplicate = window(id: 2, x: 10, y: 20, width: 300, height: 200) + let unrelated = CGRect(x: 500, y: 500, width: 100, height: 100) + + XCTAssertNil( + MacOSActivationSourceCapturer.preferredWindow( + candidates: [first, duplicate], + focusedWindowResolution: .resolved(first.frame) + ) + ) + XCTAssertNil( + MacOSActivationSourceCapturer.preferredWindow( + candidates: [ + first, + window(id: 3, x: 30, y: 40, width: 500, height: 400), + ], + focusedWindowResolution: .resolved(unrelated) + ) + ) + } + + func testFocusedFrameUsesExistingOnePointTolerance() { + let candidate = window(id: 1, x: 10, y: 20, width: 300, height: 200) + let withinTolerance = CGRect(x: 11, y: 19, width: 301, height: 199) + let outsideTolerance = CGRect(x: 11.01, y: 20, width: 300, height: 200) + + XCTAssertEqual( + MacOSActivationSourceCapturer.preferredWindow( + candidates: [candidate], + focusedWindowResolution: .resolved(withinTolerance) + ), + candidate + ) + XCTAssertNil( + MacOSActivationSourceCapturer.preferredWindow( + candidates: [candidate], + focusedWindowResolution: .resolved(outsideTolerance) + ) + ) + } + + private func window( + id: UInt32, + x: CGFloat, + y: CGFloat, + width: CGFloat, + height: CGFloat + ) -> MacOSActivationSourceCapturer.WindowSnapshot { + MacOSActivationSourceCapturer.WindowSnapshot( + identifier: id, + frame: CGRect(x: x, y: y, width: width, height: height) + ) + } +}