| | |
| | | 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 |
| | |
| | | let privateActivationOutcome = activateViaPrivateWindowServerAPI(window) |
| | | let didPrivatelyActivate = privateActivationOutcome?.succeeded == true |
| | | |
| | | let axWindow = AXWindowMetadataReader |
| | | .metadata(appCategorizer: appCategorizer) |
| | | .first { metadata in |
| | | if let windowID = metadata.windowID { |
| | | return windowID == window.id |
| | | } |
| | | |
| | | return metadata.processIdentifier == window.app.processIdentifier |
| | | && metadata.title == window.title |
| | | } |
| | | let axWindow = axWindow( |
| | | for: window, |
| | | in: AXWindowMetadataReader.metadata(appCategorizer: appCategorizer), |
| | | operation: "windowActivation", |
| | | matchAttempt: "initial" |
| | | ) |
| | | |
| | | DevelopmentDiagnostics.log("windowActivation.activate.axLookup", [ |
| | | "windowID": window.id, |
| | |
| | | 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 |
| | | |
| | | let shouldPreferFocusedPageClose = matchedAXWindow?.windowID != window.id |
| | | if shouldPreferFocusedPageClose, |
| | | let focusedPageCloseResult = closeFocusedPageViaCommandW( |
| | | window: window, |
| | | reason: matchedAXWindow == nil ? "axMissing" : "nonDirectAXMatch" |
| | | ) { |
| | | return focusedPageCloseResult |
| | | } |
| | | |
| | | 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 |
| | | } |
| | | |
| | | if matchedAXWindow.windowID != window.id, |
| | | let focusedPageCloseResult = closeFocusedPageViaCommandW( |
| | | window: window, |
| | | reason: "retryNonDirectAXMatch" |
| | | ) { |
| | | return focusedPageCloseResult |
| | | } |
| | | |
| | | 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 closeFocusedPageViaCommandW( |
| | | window: AlignerWindow, |
| | | reason: String |
| | | ) -> WindowCloseResult? { |
| | | guard window.identifierSource == .cgWindow, |
| | | let processIdentifier = window.app.processIdentifier |
| | | else { |
| | | DevelopmentDiagnostics.log("windowClose.commandW.skipped", [ |
| | | "windowID": window.id, |
| | | "reason": reason, |
| | | "identifierSource": String(describing: window.identifierSource), |
| | | "hasPID": window.app.processIdentifier != nil |
| | | ]) |
| | | return nil |
| | | } |
| | | |
| | | guard let activationOutcome = activateViaPrivateWindowServerAPI(window), |
| | | activationOutcome.succeeded |
| | | else { |
| | | DevelopmentDiagnostics.log("windowClose.commandW.activationFailed", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier, |
| | | "reason": reason |
| | | ]) |
| | | return nil |
| | | } |
| | | |
| | | Thread.sleep(forTimeInterval: 0.08) |
| | | guard postCommandW(to: processIdentifier) else { |
| | | DevelopmentDiagnostics.log("windowClose.commandW.postFailed", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier, |
| | | "reason": reason |
| | | ]) |
| | | return .failed("commandWPostFailed") |
| | | } |
| | | |
| | | DevelopmentDiagnostics.log("windowClose.commandW.requested", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier, |
| | | "reason": reason |
| | | ]) |
| | | return .requested |
| | | } |
| | | |
| | | private func postCommandW(to processIdentifier: Int32) -> Bool { |
| | | let source = CGEventSource(stateID: .combinedSessionState) |
| | | guard let keyDown = CGEvent( |
| | | keyboardEventSource: source, |
| | | virtualKey: Self.commandWVirtualKeyCode, |
| | | keyDown: true |
| | | ), |
| | | let keyUp = CGEvent( |
| | | keyboardEventSource: source, |
| | | virtualKey: Self.commandWVirtualKeyCode, |
| | | keyDown: false |
| | | ) |
| | | else { |
| | | return false |
| | | } |
| | | |
| | | keyDown.flags = .maskCommand |
| | | keyUp.flags = .maskCommand |
| | | keyDown.postToPid(processIdentifier) |
| | | keyUp.postToPid(processIdentifier) |
| | | return true |
| | | } |
| | | |
| | | private func axWindow( |
| | | for window: AlignerWindow, |
| | | in axMetadata: [AXWindowMetadata], |
| | | operation: String = "windowClose", |
| | | matchAttempt: String = "initial" |
| | | ) -> AXWindowMetadata? { |
| | | if let directMatch = axMetadata.first(where: { $0.windowID == window.id }) { |
| | | DevelopmentDiagnostics.log("\(operation).axMatch.directWindowID", [ |
| | | "windowID": window.id, |
| | | "pid": window.app.processIdentifier, |
| | | "attempt": matchAttempt |
| | | ]) |
| | | return directMatch |
| | | } |
| | | |
| | | guard let processIdentifier = window.app.processIdentifier else { |
| | | return nil |
| | | } |
| | | |
| | | if let geometryFingerprint = axWindowGeometryFingerprint(for: window) { |
| | | let geometryMatches = axMetadata.filter { metadata in |
| | | AXWindowGeometryFingerprint(metadata) == geometryFingerprint |
| | | } |
| | | if geometryMatches.count == 1 { |
| | | DevelopmentDiagnostics.log("\(operation).axMatch.geometryFingerprint", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier, |
| | | "attempt": matchAttempt |
| | | ]) |
| | | return geometryMatches[0] |
| | | } |
| | | |
| | | if geometryMatches.count > 1 { |
| | | DevelopmentDiagnostics.log("\(operation).axMatch.geometryFingerprintAmbiguous", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier, |
| | | "candidateCount": geometryMatches.count, |
| | | "attempt": matchAttempt |
| | | ]) |
| | | } |
| | | } |
| | | |
| | | if let cgFingerprint = cgWindowFingerprint(for: window) { |
| | | let fingerprintMatches = axMetadata.filter { metadata in |
| | | AXWindowFingerprint(metadata) == cgFingerprint |
| | | } |
| | | if fingerprintMatches.count == 1 { |
| | | DevelopmentDiagnostics.log("\(operation).axMatch.fingerprint", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier, |
| | | "attempt": matchAttempt |
| | | ]) |
| | | return fingerprintMatches[0] |
| | | } |
| | | |
| | | if fingerprintMatches.count > 1 { |
| | | DevelopmentDiagnostics.log("\(operation).axMatch.fingerprintAmbiguous", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier, |
| | | "candidateCount": fingerprintMatches.count, |
| | | "attempt": matchAttempt |
| | | ]) |
| | | } |
| | | } |
| | | |
| | | let normalizedTitle = normalizedWindowTitle(window.title) |
| | | let titleMatches = axMetadata.filter { metadata in |
| | | metadata.processIdentifier == processIdentifier |
| | | && normalizedWindowTitle(metadata.title ?? "") == normalizedTitle |
| | | } |
| | | if !normalizedTitle.isEmpty, titleMatches.count == 1 { |
| | | DevelopmentDiagnostics.log("\(operation).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("\(operation).axMatch.finderFallback", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier, |
| | | "attempt": matchAttempt |
| | | ]) |
| | | return finderMatch |
| | | } |
| | | |
| | | if titleMatches.count > 1 { |
| | | DevelopmentDiagnostics.log("\(operation).axMatch.titleAmbiguous", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier, |
| | | "candidateCount": titleMatches.count, |
| | | "attempt": matchAttempt |
| | | ]) |
| | | } |
| | | |
| | | 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 axWindowGeometryFingerprint(for window: AlignerWindow) -> AXWindowGeometryFingerprint? { |
| | | guard let processIdentifier = window.app.processIdentifier else { return nil } |
| | | let frame = cgWindowFrame(for: window) ?? window.frame |
| | | return AXWindowGeometryFingerprint( |
| | | processIdentifier: processIdentifier, |
| | | title: normalizedWindowTitle(window.title), |
| | | frame: frame |
| | | ) |
| | | } |
| | | |
| | | private func cgWindowFrame(for window: AlignerWindow) -> CGRect? { |
| | | guard let rawWindows = CGWindowListCopyWindowInfo( |
| | | [.optionIncludingWindow], |
| | | CGWindowID(window.id) |
| | | ) as? [[String: Any]], |
| | | let rawWindow = rawWindows.first |
| | | else { |
| | | return nil |
| | | } |
| | | |
| | | return bounds(rawWindow[kCGWindowBounds as String]) |
| | | } |
| | | |
| | | 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? { |
| | |
| | | return nil |
| | | } |
| | | |
| | | let rawBounds = bounds(rawWindow[kCGWindowBounds as String]) |
| | | let ownerPID = int32Value(rawWindow[kCGWindowOwnerPID as String]) |
| | | let runningApplication = ownerPID.flatMap(NSRunningApplication.init(processIdentifier:)) |
| | | let rawFingerprint = ownerPID.map { |
| | | AXWindowFingerprint( |
| | | processIdentifier: $0, |
| | | title: title(rawWindow: rawWindow, axWindow: nil), |
| | | size: bounds(rawWindow[kCGWindowBounds as String]).size |
| | | size: rawBounds.size |
| | | ) |
| | | } |
| | | let directAXWindow = axMetadata[windowID] |
| | |
| | | app: app, |
| | | activationPolicy: activationPolicy(from: runningApplication), |
| | | title: title(rawWindow: rawWindow, axWindow: axWindow), |
| | | size: bounds(rawWindow[kCGWindowBounds as String]).size, |
| | | size: rawBounds.size, |
| | | frame: rawBounds, |
| | | subrole: axWindow?.subrole ?? .standard, |
| | | isMinimized: axWindow?.isMinimized ?? false, |
| | | isFullscreen: !Set(spaceIDs).isDisjoint(with: fullscreenSpaceIDs), |
| | | isGhost: isGhost( |
| | | rawWindow: rawWindow, |
| | | axWindow: axWindow, |
| | | app: app, |
| | | spaceIDs: spaceIDs, |
| | | hasAXBackedTitleDuplicate: hasAXBackedTitleDuplicate |
| | | ), |
| | |
| | | title: axWindow.title ?? "", |
| | | identifierSource: identifierSource, |
| | | size: axWindow.size, |
| | | frame: axWindow.frame, |
| | | subrole: axWindow.subrole, |
| | | isMinimized: axWindow.isMinimized, |
| | | isFullscreen: !Set(spaceIDs).isDisjoint(with: fullscreenSpaceIDs), |
| | |
| | | private func isGhost( |
| | | rawWindow: [String: Any], |
| | | axWindow: AXWindowMetadata?, |
| | | app: AlignerApp, |
| | | spaceIDs: [UInt64], |
| | | hasAXBackedTitleDuplicate: Bool = false |
| | | ) -> Bool { |
| | |
| | | return false |
| | | } |
| | | if axWindow != nil { |
| | | return layer != 0 || alpha <= 0 |
| | | if alpha <= 0 { |
| | | return true |
| | | } |
| | | if layer == 0 { |
| | | return false |
| | | } |
| | | |
| | | return !isOnscreen |
| | | } |
| | | if hasAXBackedTitleDuplicate { |
| | | return true |
| | | } |
| | | if isKnownCGOnlyMainWindow(rawWindow: rawWindow, app: app) { |
| | | return false |
| | | } |
| | | if !spaceIDs.isEmpty, isLikelyFullscreenAuxiliaryCGWindow(bounds: rawBounds) { |
| | | return true |
| | |
| | | |
| | | private func isLikelyFullscreenAuxiliaryCGWindow(bounds: CGRect) -> Bool { |
| | | bounds.width < 500 || bounds.height < 160 |
| | | } |
| | | |
| | | private func isKnownCGOnlyMainWindow(rawWindow: [String: Any], app: AlignerApp) -> Bool { |
| | | let layer = intValue(rawWindow[kCGWindowLayer as String]) ?? 0 |
| | | let alpha = doubleValue(rawWindow[kCGWindowAlpha as String]) ?? 1 |
| | | let isOnscreen = boolValue(rawWindow[kCGWindowIsOnscreen as String]) |
| | | let title = stringValue(rawWindow[kCGWindowName as String]) ?? "" |
| | | let rawBounds = bounds(rawWindow[kCGWindowBounds as String]) |
| | | let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) |
| | | |
| | | if app.bundleIdentifier == "cc.ffitch.shottr" { |
| | | return isOnscreen |
| | | && alpha > 0 |
| | | && trimmedTitle == "Shottr" |
| | | && rawBounds.width > WindowEnumerationPolicy.minimumVisibleWidth |
| | | && rawBounds.height > WindowEnumerationPolicy.minimumVisibleHeight |
| | | } |
| | | |
| | | if app.bundleIdentifier == "com.apple.finder" { |
| | | return layer == 0 |
| | | && alpha > 0 |
| | | && !trimmedTitle.isEmpty |
| | | && rawBounds.width > WindowEnumerationPolicy.minimumVisibleWidth |
| | | && rawBounds.height > WindowEnumerationPolicy.minimumVisibleHeight |
| | | } |
| | | |
| | | guard app.bundleIdentifier == "io.dcloud.HBuilderX" else { return false } |
| | | |
| | | return layer == 0 |
| | | && alpha > 0 |
| | | && !isOnscreen |
| | | && !trimmedTitle.isEmpty |
| | | && rawBounds.width > WindowEnumerationPolicy.minimumVisibleWidth |
| | | && rawBounds.height > WindowEnumerationPolicy.minimumVisibleHeight |
| | | } |
| | | |
| | | private func isDesktopElement(rawWindow: [String: Any], app: AlignerApp) -> Bool { |
| | |
| | | return "activationFailed" |
| | | } |
| | | } |
| | | |
| | | private static let commandWVirtualKeyCode: CGKeyCode = 13 |
| | | } |
| | | |
| | | private struct AXWindowMetadata { |
| | |
| | | let title: String? |
| | | let isMinimized: Bool |
| | | let size: CGSize |
| | | let frame: CGRect? |
| | | let subrole: AlignerWindowSubrole |
| | | let hasExplicitSubrole: Bool |
| | | let isInteractable: Bool |
| | |
| | | } |
| | | } |
| | | |
| | | private struct AXWindowGeometryFingerprint: Hashable { |
| | | let processIdentifier: Int32 |
| | | let title: String |
| | | let x: Int |
| | | let y: Int |
| | | let width: Int |
| | | let height: Int |
| | | |
| | | init?(processIdentifier: Int32, title: String, frame: CGRect?) { |
| | | guard let frame else { return nil } |
| | | |
| | | self.processIdentifier = processIdentifier |
| | | self.title = title |
| | | self.x = Int(frame.origin.x.rounded()) |
| | | self.y = Int(frame.origin.y.rounded()) |
| | | self.width = Int(frame.size.width.rounded()) |
| | | self.height = Int(frame.size.height.rounded()) |
| | | } |
| | | |
| | | init?(_ metadata: AXWindowMetadata) { |
| | | self.init( |
| | | processIdentifier: metadata.processIdentifier, |
| | | title: normalizedWindowTitle(metadata.title ?? ""), |
| | | frame: metadata.frame |
| | | ) |
| | | } |
| | | } |
| | | |
| | | private struct RestorableAXWindow { |
| | | let processIdentifier: Int32 |
| | | let element: AXUIElement |
| | |
| | | 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) |
| | | let size = size(for: window) |
| | | let position = position(for: window) |
| | | result.append(AXWindowMetadata( |
| | | windowID: windowID(for: window), |
| | | app: app, |
| | | activationPolicy: activationPolicy(from: application), |
| | | title: title(for: window), |
| | | isMinimized: boolAttribute(kAXMinimizedAttribute as String, for: window) ?? false, |
| | | size: size(for: window), |
| | | size: size, |
| | | frame: position.map { CGRect(origin: $0, size: size) }, |
| | | subrole: subrole.value, |
| | | hasExplicitSubrole: subrole.isExplicit, |
| | | isInteractable: boolAttribute(kAXEnabledAttribute as String, for: window) ?? true, |
| | |
| | | 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 |
| | |
| | | return size |
| | | } |
| | | |
| | | private static func position(for window: AXUIElement) -> CGPoint? { |
| | | var value: CFTypeRef? |
| | | guard AXUIElementCopyAttributeValue(window, kAXPositionAttribute as CFString, &value) == .success, |
| | | let axValue = value, |
| | | CFGetTypeID(axValue) == AXValueGetTypeID() |
| | | else { |
| | | return nil |
| | | } |
| | | |
| | | var position = CGPoint.zero |
| | | guard AXValueGetValue(axValue as! AXValue, .cgPoint, &position) else { |
| | | return nil |
| | | } |
| | | |
| | | return position |
| | | } |
| | | |
| | | private static func subrole(for window: AXUIElement) -> (value: AlignerWindowSubrole, isExplicit: Bool) { |
| | | var value: CFTypeRef? |
| | | guard AXUIElementCopyAttributeValue(window, kAXSubroleAttribute as CFString, &value) == .success, |
| | |
| | | default: |
| | | return (.unknown(subrole), true) |
| | | } |
| | | } |
| | | } |
| | | |
| | | 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) |
| | | } |
| | | } |
| | | |
| | |
| | | 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: |