| | |
| | | import AppKit |
| | | import ApplicationServices |
| | | @preconcurrency import ApplicationServices |
| | | import CoreGraphics |
| | | import Foundation |
| | | import AlignerCore |
| | | |
| | | final class CGWindowAXWindowService: WindowServiceProtocol, WindowRestorationServiceProtocol, WindowActivationServiceProtocol { |
| | | final class CGWindowAXWindowService: WindowServiceProtocol, WindowRestorationServiceProtocol, WindowActivationServiceProtocol, WindowCloseServiceProtocol { |
| | | weak var delegate: WindowServiceDelegate? |
| | | |
| | | private let appCategorizer: any AppCategorizer |
| | |
| | | return didPrivatelyActivate || focusedBeforeActivate || focusedAfterActivate || raisedBeforeActivate || raisedAfterActivate |
| | | ? .activated |
| | | : .appActivatedOnly |
| | | } |
| | | |
| | | func close(window: AlignerWindow) throws -> WindowCloseResult { |
| | | DevelopmentDiagnostics.log("windowClose.window.start", [ |
| | | "windowID": window.id, |
| | | "identifierSource": String(describing: window.identifierSource), |
| | | "titleHash": DevelopmentDiagnostics.stableFingerprint(window.title), |
| | | "titleLength": window.title.count, |
| | | "titleIsEmpty": window.title.isEmpty, |
| | | "appName": window.app.name, |
| | | "bundleIdentifier": window.app.bundleIdentifier, |
| | | "pid": window.app.processIdentifier |
| | | ]) |
| | | |
| | | let axMetadata = AXWindowMetadataReader.metadata(appCategorizer: appCategorizer) |
| | | var matchedAXWindow = axWindow(for: window, in: axMetadata) |
| | | var finalAXMetadata = axMetadata |
| | | |
| | | if matchedAXWindow == nil { |
| | | Thread.sleep(forTimeInterval: 0.12) |
| | | finalAXMetadata = AXWindowMetadataReader.metadata(appCategorizer: appCategorizer) |
| | | matchedAXWindow = axWindow(for: window, in: finalAXMetadata, matchAttempt: "retry") |
| | | } |
| | | |
| | | guard let matchedAXWindow else { |
| | | DevelopmentDiagnostics.log("windowClose.window.axMissing", [ |
| | | "windowID": window.id, |
| | | "appName": window.app.name, |
| | | "pid": window.app.processIdentifier, |
| | | "candidateCount": finalAXMetadata.filter { $0.processIdentifier == window.app.processIdentifier }.count, |
| | | "matchingTitleCandidateCount": matchingAXTitleCandidateCount(for: window, in: finalAXMetadata), |
| | | "matchingSizeCandidateCount": matchingAXSizeCandidateCount(for: window, in: finalAXMetadata) |
| | | ]) |
| | | return .windowNotFound |
| | | } |
| | | |
| | | guard let closeButton = closeButton(for: matchedAXWindow) else { |
| | | Thread.sleep(forTimeInterval: 0.12) |
| | | let retryMetadata = AXWindowMetadataReader.metadata(appCategorizer: appCategorizer) |
| | | if let retryAXWindow = axWindow(for: window, in: retryMetadata, matchAttempt: "closeButtonRetry"), |
| | | let retryCloseButton = closeButton(for: retryAXWindow) { |
| | | return performCloseButtonPress( |
| | | retryCloseButton, |
| | | window: window, |
| | | processIdentifier: retryAXWindow.processIdentifier, |
| | | attempt: "closeButtonRetry" |
| | | ) |
| | | } |
| | | DevelopmentDiagnostics.log("windowClose.window.closeButtonMissing", [ |
| | | "windowID": window.id, |
| | | "pid": matchedAXWindow.processIdentifier, |
| | | "appName": window.app.name |
| | | ]) |
| | | return .windowNotFound |
| | | } |
| | | |
| | | return performCloseButtonPress( |
| | | closeButton, |
| | | window: window, |
| | | processIdentifier: matchedAXWindow.processIdentifier, |
| | | attempt: "initial" |
| | | ) |
| | | } |
| | | |
| | | private func closeButton(for axWindow: AXWindowMetadata) -> AXUIElement? { |
| | | var closeButtonValue: CFTypeRef? |
| | | let closeButtonLookupResult = AXUIElementCopyAttributeValue( |
| | | axWindow.element, |
| | | kAXCloseButtonAttribute as CFString, |
| | | &closeButtonValue |
| | | ) |
| | | guard closeButtonLookupResult == .success, |
| | | let closeButton = closeButtonValue, |
| | | CFGetTypeID(closeButton) == AXUIElementGetTypeID() |
| | | else { |
| | | DevelopmentDiagnostics.log("windowClose.window.closeButtonLookupFailed", [ |
| | | "pid": axWindow.processIdentifier, |
| | | "axError": String(describing: closeButtonLookupResult) |
| | | ]) |
| | | return nil |
| | | } |
| | | |
| | | return (closeButton as! AXUIElement) |
| | | } |
| | | |
| | | private func performCloseButtonPress( |
| | | _ closeButton: AXUIElement, |
| | | window: AlignerWindow, |
| | | processIdentifier: Int32, |
| | | attempt: String |
| | | ) -> WindowCloseResult { |
| | | let closeResult = AXUIElementPerformAction(closeButton, kAXPressAction as CFString) |
| | | DevelopmentDiagnostics.log("windowClose.window.result", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier, |
| | | "attempt": attempt, |
| | | "axError": String(describing: closeResult) |
| | | ]) |
| | | |
| | | switch closeResult { |
| | | case .success: |
| | | return .requested |
| | | case .actionUnsupported, .notImplemented: |
| | | return .unsupported |
| | | default: |
| | | return .failed(String(describing: closeResult)) |
| | | } |
| | | } |
| | | |
| | | private func axWindow( |
| | | for window: AlignerWindow, |
| | | in axMetadata: [AXWindowMetadata], |
| | | matchAttempt: String = "initial" |
| | | ) -> AXWindowMetadata? { |
| | | if let directMatch = axMetadata.first(where: { $0.windowID == window.id }) { |
| | | DevelopmentDiagnostics.log("windowClose.axMatch.directWindowID", [ |
| | | "windowID": window.id, |
| | | "pid": window.app.processIdentifier, |
| | | "attempt": matchAttempt |
| | | ]) |
| | | return directMatch |
| | | } |
| | | |
| | | guard let processIdentifier = window.app.processIdentifier else { |
| | | return nil |
| | | } |
| | | |
| | | if let cgFingerprint = cgWindowFingerprint(for: window) { |
| | | let fingerprintMatches = axMetadata.filter { metadata in |
| | | AXWindowFingerprint(metadata) == cgFingerprint |
| | | } |
| | | if fingerprintMatches.count == 1 { |
| | | DevelopmentDiagnostics.log("windowClose.axMatch.fingerprint", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier, |
| | | "attempt": matchAttempt |
| | | ]) |
| | | return fingerprintMatches[0] |
| | | } |
| | | |
| | | if fingerprintMatches.count > 1 { |
| | | DevelopmentDiagnostics.log("windowClose.axMatch.fingerprintAmbiguous", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier, |
| | | "candidateCount": fingerprintMatches.count |
| | | ]) |
| | | } |
| | | } |
| | | |
| | | let normalizedTitle = normalizedWindowTitle(window.title) |
| | | let titleMatches = axMetadata.filter { metadata in |
| | | metadata.processIdentifier == processIdentifier |
| | | && normalizedWindowTitle(metadata.title ?? "") == normalizedTitle |
| | | } |
| | | if titleMatches.count == 1 { |
| | | DevelopmentDiagnostics.log("windowClose.axMatch.normalizedTitle", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier, |
| | | "attempt": matchAttempt |
| | | ]) |
| | | return titleMatches[0] |
| | | } |
| | | |
| | | if window.app.bundleIdentifier == "com.apple.finder", |
| | | let finderMatch = finderFallbackAXWindow( |
| | | for: window, |
| | | processIdentifier: processIdentifier, |
| | | in: axMetadata |
| | | ) { |
| | | DevelopmentDiagnostics.log("windowClose.axMatch.finderFallback", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier, |
| | | "attempt": matchAttempt |
| | | ]) |
| | | return finderMatch |
| | | } |
| | | |
| | | if titleMatches.count > 1 { |
| | | DevelopmentDiagnostics.log("windowClose.axMatch.titleAmbiguous", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier, |
| | | "candidateCount": titleMatches.count |
| | | ]) |
| | | } |
| | | |
| | | return nil |
| | | } |
| | | |
| | | private func finderFallbackAXWindow( |
| | | for window: AlignerWindow, |
| | | processIdentifier: Int32, |
| | | in axMetadata: [AXWindowMetadata] |
| | | ) -> AXWindowMetadata? { |
| | | let normalizedTitle = normalizedWindowTitle(window.title) |
| | | guard !normalizedTitle.isEmpty else { return nil } |
| | | |
| | | let finderCandidates = axMetadata.filter { metadata in |
| | | metadata.processIdentifier == processIdentifier |
| | | && normalizedWindowTitle(metadata.title ?? "") == normalizedTitle |
| | | && metadata.subrole == .standard |
| | | } |
| | | return finderCandidates.count == 1 ? finderCandidates[0] : nil |
| | | } |
| | | |
| | | private func cgWindowFingerprint(for window: AlignerWindow) -> AXWindowFingerprint? { |
| | | guard let rawWindows = CGWindowListCopyWindowInfo( |
| | | [.optionIncludingWindow], |
| | | CGWindowID(window.id) |
| | | ) as? [[String: Any]], |
| | | let rawWindow = rawWindows.first, |
| | | let ownerPID = int32Value(rawWindow[kCGWindowOwnerPID as String]) |
| | | else { |
| | | return nil |
| | | } |
| | | |
| | | return AXWindowFingerprint( |
| | | processIdentifier: ownerPID, |
| | | title: normalizedWindowTitle(title(rawWindow: rawWindow, axWindow: nil)), |
| | | size: bounds(rawWindow[kCGWindowBounds as String]).size |
| | | ) |
| | | } |
| | | |
| | | private func matchingAXTitleCandidateCount( |
| | | for window: AlignerWindow, |
| | | in axMetadata: [AXWindowMetadata] |
| | | ) -> Int { |
| | | guard let processIdentifier = window.app.processIdentifier else { return 0 } |
| | | let normalizedTitle = normalizedWindowTitle(window.title) |
| | | return axMetadata.filter { metadata in |
| | | metadata.processIdentifier == processIdentifier |
| | | && normalizedWindowTitle(metadata.title ?? "") == normalizedTitle |
| | | }.count |
| | | } |
| | | |
| | | private func matchingAXSizeCandidateCount( |
| | | for window: AlignerWindow, |
| | | in axMetadata: [AXWindowMetadata] |
| | | ) -> Int { |
| | | guard let processIdentifier = window.app.processIdentifier, |
| | | let cgFingerprint = cgWindowFingerprint(for: window) |
| | | else { |
| | | return 0 |
| | | } |
| | | |
| | | return axMetadata.filter { metadata in |
| | | metadata.processIdentifier == processIdentifier |
| | | && Int(metadata.size.width.rounded()) == cgFingerprint.width |
| | | && Int(metadata.size.height.rounded()) == cgFingerprint.height |
| | | }.count |
| | | } |
| | | |
| | | func close(app: AlignerApp) throws -> WindowCloseResult { |
| | | DevelopmentDiagnostics.log("windowClose.app.start", [ |
| | | "appName": app.name, |
| | | "bundleIdentifier": app.bundleIdentifier, |
| | | "pid": app.processIdentifier |
| | | ]) |
| | | |
| | | guard let processIdentifier = app.processIdentifier else { |
| | | DevelopmentDiagnostics.log("windowClose.app.missingPID", [ |
| | | "appName": app.name, |
| | | "bundleIdentifier": app.bundleIdentifier |
| | | ]) |
| | | return .appNotFound |
| | | } |
| | | guard let runningApplication = NSRunningApplication(processIdentifier: processIdentifier) else { |
| | | DevelopmentDiagnostics.log("windowClose.app.missingApplication", [ |
| | | "appName": app.name, |
| | | "pid": processIdentifier |
| | | ]) |
| | | return .appNotFound |
| | | } |
| | | |
| | | let requested = runningApplication.terminate() |
| | | DevelopmentDiagnostics.log("windowClose.app.result", [ |
| | | "appName": app.name, |
| | | "pid": processIdentifier, |
| | | "requested": requested |
| | | ]) |
| | | return requested ? .requested : .failed("terminateFailed") |
| | | } |
| | | |
| | | private func activateViaPrivateWindowServerAPI(_ window: AlignerWindow) -> PrivateWindowActivationOutcome? { |
| | |
| | | let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) |
| | | |
| | | if app.bundleIdentifier == "cc.ffitch.shottr" { |
| | | return alpha > 0 |
| | | return isOnscreen |
| | | && alpha > 0 |
| | | && trimmedTitle == "Shottr" |
| | | && rawBounds.width > WindowEnumerationPolicy.minimumVisibleWidth |
| | | && rawBounds.height > WindowEnumerationPolicy.minimumVisibleHeight |
| | |
| | | return "activationFailed" |
| | | } |
| | | } |
| | | |
| | | } |
| | | |
| | | private struct AXWindowMetadata { |
| | |
| | | let app = app(from: application, appCategorizer: appCategorizer) |
| | | |
| | | let appElement = AXUIElementCreateApplication(application.processIdentifier) |
| | | var windowsValue: CFTypeRef? |
| | | guard AXUIElementCopyAttributeValue(appElement, kAXWindowsAttribute as CFString, &windowsValue) == .success, |
| | | let windows = windowsValue as? [AXUIElement] |
| | | else { |
| | | continue |
| | | } |
| | | let windows = windows(for: appElement) |
| | | guard !windows.isEmpty else { continue } |
| | | |
| | | for (windowIndex, window) in windows.enumerated() { |
| | | let subrole = subrole(for: window) |
| | |
| | | element: window |
| | | )) |
| | | } |
| | | } |
| | | |
| | | return result |
| | | } |
| | | |
| | | private static func windows(for appElement: AXUIElement) -> [AXUIElement] { |
| | | var result: [AXUIElement] = [] |
| | | var seen = Set<AXWindowElementKey>() |
| | | |
| | | func append(_ window: AXUIElement) { |
| | | let key = AXWindowElementKey(window) |
| | | guard !seen.contains(key) else { return } |
| | | seen.insert(key) |
| | | result.append(window) |
| | | } |
| | | |
| | | var windowsValue: CFTypeRef? |
| | | if AXUIElementCopyAttributeValue(appElement, kAXWindowsAttribute as CFString, &windowsValue) == .success, |
| | | let windows = windowsValue as? [AXUIElement] { |
| | | windows.forEach(append) |
| | | } |
| | | |
| | | for attribute in [kAXFocusedWindowAttribute, kAXMainWindowAttribute] { |
| | | var windowValue: CFTypeRef? |
| | | guard AXUIElementCopyAttributeValue(appElement, attribute as CFString, &windowValue) == .success, |
| | | let window = windowValue, |
| | | CFGetTypeID(window) == AXUIElementGetTypeID() |
| | | else { |
| | | continue |
| | | } |
| | | |
| | | append(window as! AXUIElement) |
| | | } |
| | | |
| | | return result |
| | |
| | | } |
| | | } |
| | | |
| | | private struct AXWindowElementKey: Hashable { |
| | | private let typeID: CFTypeID |
| | | private let pointer: UInt |
| | | private let windowID: UInt32? |
| | | |
| | | init(_ element: AXUIElement) { |
| | | self.typeID = CFGetTypeID(element) |
| | | self.pointer = UInt(bitPattern: Unmanaged.passUnretained(element).toOpaque()) |
| | | self.windowID = PrivateAXWindowIDReader.windowID(for: element) |
| | | } |
| | | |
| | | static func == (lhs: AXWindowElementKey, rhs: AXWindowElementKey) -> Bool { |
| | | if let lhsWindowID = lhs.windowID, |
| | | let rhsWindowID = rhs.windowID { |
| | | return lhsWindowID == rhsWindowID |
| | | } |
| | | |
| | | return lhs.typeID == rhs.typeID && lhs.pointer == rhs.pointer |
| | | } |
| | | |
| | | func hash(into hasher: inout Hasher) { |
| | | if let windowID { |
| | | hasher.combine("windowID") |
| | | hasher.combine(windowID) |
| | | return |
| | | } |
| | | |
| | | hasher.combine("pointer") |
| | | hasher.combine(typeID) |
| | | hasher.combine(pointer) |
| | | } |
| | | } |
| | | |
| | | private extension AlignerWindowSubrole { |
| | | var isExplicitlySupported: Bool { |
| | | switch self { |
| | |
| | | value as? String |
| | | } |
| | | |
| | | private func normalizedWindowTitle(_ title: String) -> String { |
| | | title.trimmingCharacters(in: .whitespacesAndNewlines) |
| | | } |
| | | |
| | | private func boolValue(_ value: Any?) -> Bool { |
| | | switch value { |
| | | case let bool as Bool: |