| | |
| | | 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 |
| | |
| | | private let privateActivationBridge: PrivateWindowActivationBridge? |
| | | private let privateSpaceActivationBridge: PrivateSpaceActivationBridge? |
| | | private var restorableAXWindowsByID: [UInt32: RestorableAXWindow] = [:] |
| | | |
| | | private struct FinderTabControl { |
| | | let button: AXUIElement |
| | | let tabGroup: AXUIElement? |
| | | } |
| | | |
| | | private struct KeyboardShortcut { |
| | | let name: String |
| | | let keyCode: CGKeyCode |
| | | let flags: CGEventFlags |
| | | } |
| | | |
| | | private struct FinderTabActivationBudget { |
| | | let startedAt: TimeInterval |
| | | let limit: TimeInterval |
| | | |
| | | init(limit: TimeInterval) { |
| | | startedAt = ProcessInfo.processInfo.systemUptime |
| | | self.limit = limit |
| | | } |
| | | |
| | | var elapsed: TimeInterval { |
| | | ProcessInfo.processInfo.systemUptime - startedAt |
| | | } |
| | | |
| | | var elapsedMilliseconds: Int { |
| | | CGWindowAXWindowService.milliseconds(elapsed) |
| | | } |
| | | |
| | | var remaining: TimeInterval { |
| | | max(0, limit - elapsed) |
| | | } |
| | | |
| | | var isExhausted: Bool { |
| | | remaining <= 0 |
| | | } |
| | | } |
| | | |
| | | private struct WindowFocusAttempt { |
| | | let focused: AXActionResult |
| | | let main: AXActionResult |
| | | let raised: AXActionResult |
| | | |
| | | var success: Bool { |
| | | focused.success || main.success || raised.success |
| | | } |
| | | } |
| | | |
| | | private static let finderTabActivationBudgetLimit: TimeInterval = 1.50 |
| | | private static let finderTabCycleMaximumAttempts = 5 |
| | | private static let finderTabHostRepresentationScanLimit = 5 |
| | | private static let finderTabFastTitleMatchAttempts = 4 |
| | | private static let finderTabFastTitleMatchDelay: TimeInterval = 0.03 |
| | | private static let finderTabWindowMenuVerificationInitialDelay: TimeInterval = 0.08 |
| | | private static let finderTabWindowMenuVerificationAttempts = 8 |
| | | private static let finderTabWindowMenuVerificationDelay: TimeInterval = 0.04 |
| | | |
| | | init( |
| | | spaceIDsByWindowIDProvider: @escaping ([UInt32]) -> [UInt32: [UInt64]], |
| | |
| | | throw AXWindowRestoreError.setMinimizedFailed(minimizeResult) |
| | | } |
| | | |
| | | let focusedBeforeActivateResult = focusWindowResult( |
| | | let beforeActivateAttempt = focusMainRaiseWindowResult( |
| | | restorableWindow.element, |
| | | processIdentifier: restorableWindow.processIdentifier |
| | | ) |
| | | let raisedBeforeActivateResult = raiseWindowResult(restorableWindow.element) |
| | | let focusedBeforeActivate = focusedBeforeActivateResult.success |
| | | let raisedBeforeActivate = raisedBeforeActivateResult.success |
| | | |
| | | guard activateApplication(processIdentifier: restorableWindow.processIdentifier) else { |
| | | DevelopmentDiagnostics.log("windowActivation.restore.activateApplicationFailed", [ |
| | | "windowID": windowID, |
| | | "pid": restorableWindow.processIdentifier, |
| | | "focusedBeforeActivate": focusedBeforeActivate, |
| | | "focusBeforeAXError": String(describing: focusedBeforeActivateResult.error), |
| | | "raisedBeforeActivate": raisedBeforeActivate, |
| | | "raiseBeforeAXError": String(describing: raisedBeforeActivateResult.error) |
| | | "focusedBeforeActivate": beforeActivateAttempt.focused.success, |
| | | "focusBeforeAXError": String(describing: beforeActivateAttempt.focused.error), |
| | | "mainBeforeActivate": beforeActivateAttempt.main.success, |
| | | "mainBeforeAXError": String(describing: beforeActivateAttempt.main.error), |
| | | "raisedBeforeActivate": beforeActivateAttempt.raised.success, |
| | | "raiseBeforeAXError": String(describing: beforeActivateAttempt.raised.error) |
| | | ]) |
| | | return .activationFailed |
| | | } |
| | | |
| | | let focusedAfterActivateResult = focusWindowResult( |
| | | let afterActivateAttempt = focusMainRaiseWindowResult( |
| | | restorableWindow.element, |
| | | processIdentifier: restorableWindow.processIdentifier |
| | | ) |
| | | let raisedAfterActivateResult = raiseWindowResult(restorableWindow.element) |
| | | let focusedAfterActivate = focusedAfterActivateResult.success |
| | | let raisedAfterActivate = raisedAfterActivateResult.success |
| | | |
| | | let restored = focusedBeforeActivate || focusedAfterActivate || raisedBeforeActivate || raisedAfterActivate |
| | | let restored = beforeActivateAttempt.success || afterActivateAttempt.success |
| | | DevelopmentDiagnostics.log("windowActivation.restore.end", [ |
| | | "windowID": windowID, |
| | | "pid": restorableWindow.processIdentifier, |
| | | "focusedBeforeActivate": focusedBeforeActivate, |
| | | "focusBeforeAXError": String(describing: focusedBeforeActivateResult.error), |
| | | "raisedBeforeActivate": raisedBeforeActivate, |
| | | "raiseBeforeAXError": String(describing: raisedBeforeActivateResult.error), |
| | | "focusedAfterActivate": focusedAfterActivate, |
| | | "focusAfterAXError": String(describing: focusedAfterActivateResult.error), |
| | | "raisedAfterActivate": raisedAfterActivate, |
| | | "raiseAfterAXError": String(describing: raisedAfterActivateResult.error), |
| | | "focusedBeforeActivate": beforeActivateAttempt.focused.success, |
| | | "focusBeforeAXError": String(describing: beforeActivateAttempt.focused.error), |
| | | "mainBeforeActivate": beforeActivateAttempt.main.success, |
| | | "mainBeforeAXError": String(describing: beforeActivateAttempt.main.error), |
| | | "raisedBeforeActivate": beforeActivateAttempt.raised.success, |
| | | "raiseBeforeAXError": String(describing: beforeActivateAttempt.raised.error), |
| | | "focusedAfterActivate": afterActivateAttempt.focused.success, |
| | | "focusAfterAXError": String(describing: afterActivateAttempt.focused.error), |
| | | "mainAfterActivate": afterActivateAttempt.main.success, |
| | | "mainAfterAXError": String(describing: afterActivateAttempt.main.error), |
| | | "raisedAfterActivate": afterActivateAttempt.raised.success, |
| | | "raiseAfterAXError": String(describing: afterActivateAttempt.raised.error), |
| | | "result": restored ? "restored" : "activationFailed" |
| | | ]) |
| | | return focusedBeforeActivate || focusedAfterActivate || raisedBeforeActivate || raisedAfterActivate |
| | | ? .restored |
| | | : .activationFailed |
| | | return restored ? .restored : .activationFailed |
| | | } |
| | | |
| | | func activate(window: AlignerWindow) throws -> WindowActivationResult { |
| | |
| | | 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 currentAXMetadata = AXWindowMetadataReader.metadata(appCategorizer: appCategorizer) |
| | | let axWindow = axWindow( |
| | | for: window, |
| | | in: currentAXMetadata, |
| | | operation: "windowActivation", |
| | | matchAttempt: "initial" |
| | | ) |
| | | |
| | | DevelopmentDiagnostics.log("windowActivation.activate.axLookup", [ |
| | | "windowID": window.id, |
| | |
| | | "spaceFocusError": spaceFocusOutcome?.error |
| | | ]) |
| | | guard let axWindow else { |
| | | if let finderTabActivated = activateFinderTabPageIfNeeded( |
| | | window: window, |
| | | axMetadata: currentAXMetadata, |
| | | operation: "windowActivation", |
| | | reason: "axMissing" |
| | | ) { |
| | | DevelopmentDiagnostics.log("windowActivation.activate.finderTabResult", [ |
| | | "windowID": window.id, |
| | | "pid": window.app.processIdentifier, |
| | | "activated": finderTabActivated |
| | | ]) |
| | | return finderTabActivated ? .activated : .activationFailed |
| | | } |
| | | |
| | | let activated = activateApplication(for: window) |
| | | let retryAttempt = activated |
| | | ? retryFocusMainRaiseWindow( |
| | | for: window, |
| | | delay: 0.10, |
| | | matchAttempt: "axMissingPostActivate" |
| | | ) |
| | | : nil |
| | | let retrySucceeded = retryAttempt?.success ?? false |
| | | DevelopmentDiagnostics.log("windowActivation.activate.axMissing", [ |
| | | "windowID": window.id, |
| | | "appName": window.app.name, |
| | | "pid": window.app.processIdentifier, |
| | | "privateActivated": didPrivatelyActivate, |
| | | "applicationActivated": activated, |
| | | "result": didPrivatelyActivate ? "activated" : (activated ? "appActivatedOnly" : "windowNotFound") |
| | | "retryFocused": retryAttempt?.focused.success, |
| | | "retryFocusAXError": retryAttempt.map { String(describing: $0.focused.error) }, |
| | | "retryMain": retryAttempt?.main.success, |
| | | "retryMainAXError": retryAttempt.map { String(describing: $0.main.error) }, |
| | | "retryRaised": retryAttempt?.raised.success, |
| | | "retryRaiseAXError": retryAttempt.map { String(describing: $0.raised.error) }, |
| | | "result": (didPrivatelyActivate || retrySucceeded) ? "activated" : (activated ? "appActivatedOnly" : "windowNotFound") |
| | | ]) |
| | | if didPrivatelyActivate { |
| | | if didPrivatelyActivate || retrySucceeded { |
| | | return .activated |
| | | } |
| | | |
| | | return activated ? .appActivatedOnly : .windowNotFound |
| | | } |
| | | |
| | | let focusedBeforeActivateResult = focusWindowResult( |
| | | let beforeActivateAttempt = focusMainRaiseWindowResult( |
| | | axWindow.element, |
| | | processIdentifier: axWindow.processIdentifier |
| | | ) |
| | | let raisedBeforeActivateResult = raiseWindowResult(axWindow.element) |
| | | let focusedBeforeActivate = focusedBeforeActivateResult.success |
| | | let raisedBeforeActivate = raisedBeforeActivateResult.success |
| | | |
| | | guard didPrivatelyActivate || activateApplication(for: window) else { |
| | | DevelopmentDiagnostics.log("windowActivation.activate.applicationActivationFailed", [ |
| | | "windowID": window.id, |
| | | "pid": window.app.processIdentifier, |
| | | "privateActivated": didPrivatelyActivate, |
| | | "focusedBeforeActivate": focusedBeforeActivate, |
| | | "focusBeforeAXError": String(describing: focusedBeforeActivateResult.error), |
| | | "raisedBeforeActivate": raisedBeforeActivate, |
| | | "raiseBeforeAXError": String(describing: raisedBeforeActivateResult.error) |
| | | "focusedBeforeActivate": beforeActivateAttempt.focused.success, |
| | | "focusBeforeAXError": String(describing: beforeActivateAttempt.focused.error), |
| | | "mainBeforeActivate": beforeActivateAttempt.main.success, |
| | | "mainBeforeAXError": String(describing: beforeActivateAttempt.main.error), |
| | | "raisedBeforeActivate": beforeActivateAttempt.raised.success, |
| | | "raiseBeforeAXError": String(describing: beforeActivateAttempt.raised.error) |
| | | ]) |
| | | return .activationFailed |
| | | } |
| | | |
| | | let focusedAfterActivateResult = focusWindowResult( |
| | | let afterActivateAttempt = focusMainRaiseWindowResult( |
| | | axWindow.element, |
| | | processIdentifier: axWindow.processIdentifier |
| | | ) |
| | | let raisedAfterActivateResult = raiseWindowResult(axWindow.element) |
| | | let focusedAfterActivate = focusedAfterActivateResult.success |
| | | let raisedAfterActivate = raisedAfterActivateResult.success |
| | | let retryAttempt = (didPrivatelyActivate || beforeActivateAttempt.success || afterActivateAttempt.success) |
| | | ? nil |
| | | : retryFocusMainRaiseWindow( |
| | | for: window, |
| | | delay: 0.10, |
| | | matchAttempt: "postActivateRetry" |
| | | ) |
| | | |
| | | let didFocusOrRaise = focusedBeforeActivate || focusedAfterActivate || raisedBeforeActivate || raisedAfterActivate |
| | | let didFocusOrRaise = beforeActivateAttempt.success || afterActivateAttempt.success || (retryAttempt?.success ?? false) |
| | | let finderDirectFallbackResult: Bool? |
| | | if (didPrivatelyActivate || didFocusOrRaise), |
| | | isFinderCGOnlyPageCandidate(window), |
| | | let processIdentifier = window.app.processIdentifier { |
| | | let normalizedTargetTitle = normalizedWindowTitle(window.title) |
| | | let focusedTitle = finderFocusedOrMainWindowTitle(processIdentifier: processIdentifier) |
| | | let directTitleMatched = normalizedWindowTitle(focusedTitle ?? "") == normalizedTargetTitle |
| | | DevelopmentDiagnostics.log("windowActivation.activate.finderDirectVerification", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier, |
| | | "directTitleMatched": directTitleMatched, |
| | | "focusedTitleHash": DevelopmentDiagnostics.stableFingerprint(focusedTitle), |
| | | "focusedTitleLength": focusedTitle?.count, |
| | | "targetTitleHash": DevelopmentDiagnostics.stableFingerprint(window.title), |
| | | "targetTitleLength": window.title.count |
| | | ]) |
| | | if directTitleMatched { |
| | | finderDirectFallbackResult = true |
| | | } else { |
| | | finderDirectFallbackResult = activateFinderTabPageIfNeeded( |
| | | window: window, |
| | | axMetadata: AXWindowMetadataReader.metadata(appCategorizer: appCategorizer), |
| | | operation: "windowActivation", |
| | | reason: "directAXTitleMismatch" |
| | | ) |
| | | } |
| | | } else { |
| | | finderDirectFallbackResult = nil |
| | | } |
| | | |
| | | let finderPreciseActivationFailed = finderDirectFallbackResult == false |
| | | let activated = finderDirectFallbackResult ?? (didPrivatelyActivate || didFocusOrRaise) |
| | | DevelopmentDiagnostics.log("windowActivation.activate.end", [ |
| | | "windowID": window.id, |
| | | "pid": window.app.processIdentifier, |
| | | "privateActivated": didPrivatelyActivate, |
| | | "focusedBeforeActivate": focusedBeforeActivate, |
| | | "focusBeforeAXError": String(describing: focusedBeforeActivateResult.error), |
| | | "raisedBeforeActivate": raisedBeforeActivate, |
| | | "raiseBeforeAXError": String(describing: raisedBeforeActivateResult.error), |
| | | "focusedAfterActivate": focusedAfterActivate, |
| | | "focusAfterAXError": String(describing: focusedAfterActivateResult.error), |
| | | "raisedAfterActivate": raisedAfterActivate, |
| | | "raiseAfterAXError": String(describing: raisedAfterActivateResult.error), |
| | | "result": (didFocusOrRaise || didPrivatelyActivate) ? "activated" : "appActivatedOnly" |
| | | "focusedBeforeActivate": beforeActivateAttempt.focused.success, |
| | | "focusBeforeAXError": String(describing: beforeActivateAttempt.focused.error), |
| | | "mainBeforeActivate": beforeActivateAttempt.main.success, |
| | | "mainBeforeAXError": String(describing: beforeActivateAttempt.main.error), |
| | | "raisedBeforeActivate": beforeActivateAttempt.raised.success, |
| | | "raiseBeforeAXError": String(describing: beforeActivateAttempt.raised.error), |
| | | "focusedAfterActivate": afterActivateAttempt.focused.success, |
| | | "focusAfterAXError": String(describing: afterActivateAttempt.focused.error), |
| | | "mainAfterActivate": afterActivateAttempt.main.success, |
| | | "mainAfterAXError": String(describing: afterActivateAttempt.main.error), |
| | | "raisedAfterActivate": afterActivateAttempt.raised.success, |
| | | "raiseAfterAXError": String(describing: afterActivateAttempt.raised.error), |
| | | "retryFocused": retryAttempt?.focused.success, |
| | | "retryFocusAXError": retryAttempt.map { String(describing: $0.focused.error) }, |
| | | "retryMain": retryAttempt?.main.success, |
| | | "retryMainAXError": retryAttempt.map { String(describing: $0.main.error) }, |
| | | "retryRaised": retryAttempt?.raised.success, |
| | | "retryRaiseAXError": retryAttempt.map { String(describing: $0.raised.error) }, |
| | | "finderDirectFallbackResult": finderDirectFallbackResult, |
| | | "result": finderPreciseActivationFailed ? "activationFailed" : (activated ? "activated" : "appActivatedOnly") |
| | | ]) |
| | | return didPrivatelyActivate || focusedBeforeActivate || focusedAfterActivate || raisedBeforeActivate || raisedAfterActivate |
| | | ? .activated |
| | | : .appActivatedOnly |
| | | if finderPreciseActivationFailed { |
| | | return .activationFailed |
| | | } |
| | | return activated ? .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 |
| | | } |
| | | |
| | | if let finderTabActivated = activateFinderTabPageIfNeeded( |
| | | window: window, |
| | | axMetadata: AXWindowMetadataReader.metadata(appCategorizer: appCategorizer), |
| | | operation: "windowClose", |
| | | reason: reason |
| | | ) { |
| | | guard finderTabActivated else { |
| | | DevelopmentDiagnostics.log("windowClose.commandW.finderTabActivationFailed", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier, |
| | | "reason": reason |
| | | ]) |
| | | return .failed("finderTabActivationFailed") |
| | | } |
| | | } else { |
| | | 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 activateFinderTabPageIfNeeded( |
| | | window: AlignerWindow, |
| | | axMetadata: [AXWindowMetadata], |
| | | operation: String, |
| | | reason: String |
| | | ) -> Bool? { |
| | | guard isFinderCGOnlyPageCandidate(window), |
| | | let processIdentifier = window.app.processIdentifier |
| | | else { |
| | | return nil |
| | | } |
| | | |
| | | let budget = FinderTabActivationBudget(limit: Self.finderTabActivationBudgetLimit) |
| | | let hostLookupStartedAt = ProcessInfo.processInfo.systemUptime |
| | | let host = finderTabHostAXWindow(for: window, in: axMetadata, budget: budget) |
| | | DevelopmentDiagnostics.log("\(operation).finderTab.hostLookup", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier, |
| | | "reason": reason, |
| | | "hostFound": host != nil, |
| | | "hostWindowID": host?.windowID, |
| | | "elapsedMilliseconds": Self.elapsedMilliseconds(since: hostLookupStartedAt), |
| | | "overallElapsedMilliseconds": budget.elapsedMilliseconds, |
| | | "budgetMilliseconds": Self.milliseconds(budget.limit), |
| | | "budgetRemainingMilliseconds": Self.milliseconds(budget.remaining) |
| | | ]) |
| | | |
| | | guard let host else { |
| | | if budget.isExhausted { |
| | | DevelopmentDiagnostics.log("\(operation).finderTab.hostMissingBudgetExceeded", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier, |
| | | "reason": reason, |
| | | "elapsedMilliseconds": budget.elapsedMilliseconds, |
| | | "budgetMilliseconds": Self.milliseconds(budget.limit) |
| | | ]) |
| | | return false |
| | | } |
| | | |
| | | let normalizedTargetTitle = normalizedWindowTitle(window.title) |
| | | let applicationActivated = activateApplication(processIdentifier: processIdentifier) |
| | | let selectedViaWindowMenu = !normalizedTargetTitle.isEmpty |
| | | && selectFinderWindowMenuItem( |
| | | processIdentifier: processIdentifier, |
| | | window: window, |
| | | normalizedTargetTitle: normalizedTargetTitle, |
| | | budget: budget |
| | | ) |
| | | DevelopmentDiagnostics.log("\(operation).finderTab.hostlessWindowMenuResult", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier, |
| | | "reason": reason, |
| | | "applicationActivated": applicationActivated, |
| | | "selectedViaWindowMenu": selectedViaWindowMenu, |
| | | "spaceIDs": window.spaceIDs, |
| | | "targetTitleHash": DevelopmentDiagnostics.stableFingerprint(window.title), |
| | | "targetTitleLength": window.title.count, |
| | | "elapsedMilliseconds": budget.elapsedMilliseconds, |
| | | "budgetMilliseconds": Self.milliseconds(budget.limit) |
| | | ]) |
| | | if selectedViaWindowMenu { |
| | | return true |
| | | } |
| | | |
| | | DevelopmentDiagnostics.log("\(operation).finderTab.hostMissing", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier, |
| | | "reason": reason, |
| | | "spaceIDs": window.spaceIDs, |
| | | "elapsedMilliseconds": budget.elapsedMilliseconds, |
| | | "budgetMilliseconds": Self.milliseconds(budget.limit) |
| | | ]) |
| | | return false |
| | | } |
| | | |
| | | let hostPrivateActivationSucceeded: Bool |
| | | if let hostWindowID = host.windowID, |
| | | let privateActivationBridge { |
| | | hostPrivateActivationSucceeded = privateActivationBridge.activate( |
| | | processIdentifier: processIdentifier, |
| | | windowID: hostWindowID |
| | | ).succeeded |
| | | } else { |
| | | hostPrivateActivationSucceeded = false |
| | | } |
| | | |
| | | let applicationActivated = activateApplication(processIdentifier: processIdentifier) |
| | | let beforeAttempt = focusMainRaiseWindowResult(host.element, processIdentifier: processIdentifier) |
| | | let selectedTabStartedAt = ProcessInfo.processInfo.systemUptime |
| | | let selectedTab = budget.isExhausted ? false : selectFinderTab(window, in: host, budget: budget) |
| | | DevelopmentDiagnostics.log("\(operation).finderTab.selectElapsed", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier, |
| | | "hostWindowID": host.windowID, |
| | | "selectedTab": selectedTab, |
| | | "elapsedMilliseconds": Self.elapsedMilliseconds(since: selectedTabStartedAt), |
| | | "overallElapsedMilliseconds": budget.elapsedMilliseconds, |
| | | "budgetMilliseconds": Self.milliseconds(budget.limit), |
| | | "budgetRemainingMilliseconds": Self.milliseconds(budget.remaining) |
| | | ]) |
| | | if selectedTab, budget.remaining > 0 { |
| | | Thread.sleep(forTimeInterval: min(0.05, budget.remaining)) |
| | | } |
| | | let afterAttempt: WindowFocusAttempt? |
| | | if selectedTab { |
| | | // For Finder tab/page cards, re-focusing the original host AX window after |
| | | // selecting the tab can switch Finder back to the host's previous tab. |
| | | afterAttempt = nil |
| | | } else { |
| | | afterAttempt = focusMainRaiseWindowResult(host.element, processIdentifier: processIdentifier) |
| | | } |
| | | |
| | | let didFocusOrRaise = beforeAttempt.success || (afterAttempt?.success ?? false) |
| | | let activated = selectedTab |
| | | && (hostPrivateActivationSucceeded || applicationActivated || didFocusOrRaise) |
| | | |
| | | DevelopmentDiagnostics.log("\(operation).finderTab.result", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier, |
| | | "reason": reason, |
| | | "hostWindowID": host.windowID, |
| | | "hostTitleHash": DevelopmentDiagnostics.stableFingerprint(host.title), |
| | | "hostTitleLength": host.title?.count, |
| | | "hostPrivateActivated": hostPrivateActivationSucceeded, |
| | | "applicationActivated": applicationActivated, |
| | | "selectedTab": selectedTab, |
| | | "focusBeforeAXError": String(describing: beforeAttempt.focused.error), |
| | | "mainBeforeAXError": String(describing: beforeAttempt.main.error), |
| | | "raiseBeforeAXError": String(describing: beforeAttempt.raised.error), |
| | | "focusAfterAXError": afterAttempt.map { String(describing: $0.focused.error) } ?? "skippedAfterTabSelection", |
| | | "mainAfterAXError": afterAttempt.map { String(describing: $0.main.error) } ?? "skippedAfterTabSelection", |
| | | "raiseAfterAXError": afterAttempt.map { String(describing: $0.raised.error) } ?? "skippedAfterTabSelection", |
| | | "activated": activated, |
| | | "elapsedMilliseconds": budget.elapsedMilliseconds, |
| | | "budgetMilliseconds": Self.milliseconds(budget.limit), |
| | | "budgetExceeded": budget.isExhausted |
| | | ]) |
| | | |
| | | return activated |
| | | } |
| | | |
| | | private func isFinderCGOnlyPageCandidate(_ window: AlignerWindow) -> Bool { |
| | | window.identifierSource == .cgWindow |
| | | && window.app.bundleIdentifier == "com.apple.finder" |
| | | && !window.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty |
| | | } |
| | | |
| | | private func finderTabHostAXWindow( |
| | | for window: AlignerWindow, |
| | | in axMetadata: [AXWindowMetadata], |
| | | budget: FinderTabActivationBudget |
| | | ) -> AXWindowMetadata? { |
| | | guard let processIdentifier = window.app.processIdentifier else { return nil } |
| | | let candidates = axMetadata.filter { metadata in |
| | | metadata.processIdentifier == processIdentifier |
| | | && metadata.subrole == .standard |
| | | && metadata.frame?.isEmpty == false |
| | | } |
| | | guard !candidates.isEmpty else { return nil } |
| | | |
| | | let targetSpaceIDs = Set(window.spaceIDs) |
| | | let candidateIDs = candidates.compactMap(\.windowID) |
| | | let candidateSpacesByID = targetSpaceIDs.isEmpty ? [:] : spaceIDsByWindowIDProvider(candidateIDs) |
| | | let spaceCompatibleCandidates = targetSpaceIDs.isEmpty |
| | | ? candidates |
| | | : candidates.filter { metadata in |
| | | guard let windowID = metadata.windowID else { return false } |
| | | return !Set(candidateSpacesByID[windowID] ?? []).isDisjoint(with: targetSpaceIDs) |
| | | } |
| | | let spaceScopedCandidates = spaceCompatibleCandidates.isEmpty ? candidates : spaceCompatibleCandidates |
| | | |
| | | let targetFrame = matchingFrame(for: window) |
| | | let frameScoredHosts = spaceScopedCandidates.compactMap { metadata -> (metadata: AXWindowMetadata, score: CGFloat)? in |
| | | guard let score = frameOverlapScore(targetFrame, metadata.frame), |
| | | score >= 0.85 |
| | | else { |
| | | return nil |
| | | } |
| | | |
| | | return (metadata, score) |
| | | } |
| | | .sorted { lhs, rhs in |
| | | if lhs.score != rhs.score { |
| | | return lhs.score > rhs.score |
| | | } |
| | | return (lhs.metadata.windowID ?? UInt32.max) < (rhs.metadata.windowID ?? UInt32.max) |
| | | } |
| | | |
| | | if let best = frameScoredHosts.first { |
| | | if frameScoredHosts.count == 1 { |
| | | DevelopmentDiagnostics.log("windowActivation.finderTab.frameInferredHost", [ |
| | | "windowID": window.id, |
| | | "hostWindowID": best.metadata.windowID, |
| | | "score": Double(best.score), |
| | | "spaceIDCount": targetSpaceIDs.count, |
| | | "candidateCount": spaceScopedCandidates.count, |
| | | "targetTitleHash": DevelopmentDiagnostics.stableFingerprint(window.title), |
| | | "targetTitleLength": window.title.count |
| | | ]) |
| | | return best.metadata |
| | | } |
| | | |
| | | let secondBest = frameScoredHosts[1] |
| | | if best.score - secondBest.score >= 0.05 { |
| | | DevelopmentDiagnostics.log("windowActivation.finderTab.frameInferredHost", [ |
| | | "windowID": window.id, |
| | | "hostWindowID": best.metadata.windowID, |
| | | "score": Double(best.score), |
| | | "secondBestScore": Double(secondBest.score), |
| | | "spaceIDCount": targetSpaceIDs.count, |
| | | "candidateCount": spaceScopedCandidates.count, |
| | | "targetTitleHash": DevelopmentDiagnostics.stableFingerprint(window.title), |
| | | "targetTitleLength": window.title.count |
| | | ]) |
| | | return best.metadata |
| | | } |
| | | } |
| | | |
| | | guard !budget.isExhausted else { |
| | | DevelopmentDiagnostics.log("windowActivation.finderTab.hostLookupBudgetExceeded", [ |
| | | "windowID": window.id, |
| | | "candidateCount": spaceScopedCandidates.count, |
| | | "spaceIDCount": targetSpaceIDs.count, |
| | | "elapsedMilliseconds": budget.elapsedMilliseconds, |
| | | "budgetMilliseconds": Self.milliseconds(budget.limit) |
| | | ]) |
| | | return nil |
| | | } |
| | | |
| | | let representationScanCandidates = Array(spaceScopedCandidates.prefix(Self.finderTabHostRepresentationScanLimit)) |
| | | let selectableHostCandidates = representationScanCandidates.filter { metadata in |
| | | finderHostCanRepresentTarget(window, in: metadata, budget: budget) |
| | | } |
| | | |
| | | if spaceScopedCandidates.count > representationScanCandidates.count { |
| | | DevelopmentDiagnostics.log("windowActivation.finderTab.hostRepresentationScanLimited", [ |
| | | "windowID": window.id, |
| | | "candidateCount": spaceScopedCandidates.count, |
| | | "scannedCandidateCount": representationScanCandidates.count, |
| | | "selectableCandidateCount": selectableHostCandidates.count, |
| | | "elapsedMilliseconds": budget.elapsedMilliseconds, |
| | | "budgetMilliseconds": Self.milliseconds(budget.limit) |
| | | ]) |
| | | } |
| | | |
| | | let hostCandidates = selectableHostCandidates.isEmpty |
| | | ? spaceScopedCandidates |
| | | : selectableHostCandidates |
| | | |
| | | if hostCandidates.count == 1 { |
| | | if selectableHostCandidates.isEmpty { |
| | | DevelopmentDiagnostics.log("windowActivation.finderTab.singleFrameHostFallback", [ |
| | | "windowID": window.id, |
| | | "hostWindowID": hostCandidates[0].windowID, |
| | | "spaceIDCount": targetSpaceIDs.count, |
| | | "targetTitleHash": DevelopmentDiagnostics.stableFingerprint(window.title), |
| | | "targetTitleLength": window.title.count |
| | | ]) |
| | | } |
| | | return hostCandidates[0] |
| | | } |
| | | |
| | | DevelopmentDiagnostics.log("windowActivation.finderTab.hostAmbiguous", [ |
| | | "windowID": window.id, |
| | | "candidateCount": hostCandidates.count, |
| | | "spaceIDCount": targetSpaceIDs.count, |
| | | "hasTargetFrame": targetFrame != nil, |
| | | "targetTitleHash": DevelopmentDiagnostics.stableFingerprint(window.title), |
| | | "targetTitleLength": window.title.count |
| | | ]) |
| | | return nil |
| | | } |
| | | |
| | | private func finderHostCanRepresentTarget( |
| | | _ window: AlignerWindow, |
| | | in host: AXWindowMetadata, |
| | | budget: FinderTabActivationBudget |
| | | ) -> Bool { |
| | | let normalizedTargetTitle = normalizedWindowTitle(window.title) |
| | | guard !normalizedTargetTitle.isEmpty else { return false } |
| | | |
| | | if normalizedWindowTitle(host.title ?? "") == normalizedTargetTitle { |
| | | return true |
| | | } |
| | | |
| | | guard !budget.isExhausted else { return false } |
| | | return finderTabControl(matchingNormalizedTitle: normalizedTargetTitle, in: host.element, budget: budget) != nil |
| | | } |
| | | |
| | | private func selectFinderTab( |
| | | _ window: AlignerWindow, |
| | | in host: AXWindowMetadata, |
| | | budget: FinderTabActivationBudget |
| | | ) -> Bool { |
| | | let normalizedTargetTitle = normalizedWindowTitle(window.title) |
| | | guard !normalizedTargetTitle.isEmpty else { return false } |
| | | |
| | | if normalizedWindowTitle(host.title ?? "") == normalizedTargetTitle { |
| | | return true |
| | | } |
| | | |
| | | guard let tabControl = finderTabControl( |
| | | matchingNormalizedTitle: normalizedTargetTitle, |
| | | in: host.element, |
| | | budget: budget |
| | | ) else { |
| | | DevelopmentDiagnostics.log("windowActivation.finderTab.tabButtonMissing", [ |
| | | "windowID": window.id, |
| | | "hostWindowID": host.windowID, |
| | | "targetTitleHash": DevelopmentDiagnostics.stableFingerprint(window.title), |
| | | "targetTitleLength": window.title.count, |
| | | "elapsedMilliseconds": budget.elapsedMilliseconds, |
| | | "budgetMilliseconds": Self.milliseconds(budget.limit) |
| | | ]) |
| | | if let processIdentifier = window.app.processIdentifier, |
| | | !budget.isExhausted, |
| | | selectFinderTabByCyclingTabs( |
| | | processIdentifier: processIdentifier, |
| | | host: host, |
| | | window: window, |
| | | normalizedTargetTitle: normalizedTargetTitle, |
| | | budget: budget |
| | | ) { |
| | | return true |
| | | } |
| | | if let processIdentifier = window.app.processIdentifier, |
| | | !budget.isExhausted, |
| | | selectFinderWindowMenuItem( |
| | | processIdentifier: processIdentifier, |
| | | window: window, |
| | | normalizedTargetTitle: normalizedTargetTitle, |
| | | budget: budget |
| | | ) { |
| | | return true |
| | | } |
| | | return false |
| | | } |
| | | |
| | | let tabButton = tabControl.button |
| | | let tabButtonRole = axStringAttribute(kAXRoleAttribute as String, for: tabButton) |
| | | let tabButtonSubrole = axStringAttribute(kAXSubroleAttribute as String, for: tabButton) |
| | | let pressResult = AXUIElementPerformAction(tabButton, kAXPressAction as CFString) |
| | | DevelopmentDiagnostics.log("windowActivation.finderTab.pressTab", [ |
| | | "windowID": window.id, |
| | | "hostWindowID": host.windowID, |
| | | "tabButtonRole": tabButtonRole, |
| | | "tabButtonSubrole": tabButtonSubrole, |
| | | "axError": String(describing: pressResult) |
| | | ]) |
| | | if pressResult == .success, |
| | | finderHostTitleMatchesTarget( |
| | | host, |
| | | normalizedTargetTitle: normalizedTargetTitle, |
| | | attempts: Self.finderTabFastTitleMatchAttempts, |
| | | delay: Self.finderTabFastTitleMatchDelay, |
| | | budget: budget |
| | | ) { |
| | | return true |
| | | } |
| | | |
| | | if let tabGroup = tabControl.tabGroup { |
| | | let selectedChildrenResult = AXUIElementSetAttributeValue( |
| | | tabGroup, |
| | | kAXSelectedChildrenAttribute as CFString, |
| | | [tabButton] as CFArray |
| | | ) |
| | | DevelopmentDiagnostics.log("windowActivation.finderTab.setSelectedChildren", [ |
| | | "windowID": window.id, |
| | | "hostWindowID": host.windowID, |
| | | "axError": String(describing: selectedChildrenResult) |
| | | ]) |
| | | if selectedChildrenResult == .success, |
| | | finderHostTitleMatchesTarget( |
| | | host, |
| | | normalizedTargetTitle: normalizedTargetTitle, |
| | | attempts: Self.finderTabFastTitleMatchAttempts, |
| | | delay: Self.finderTabFastTitleMatchDelay, |
| | | budget: budget |
| | | ) { |
| | | return true |
| | | } |
| | | } |
| | | |
| | | let valueResult = AXUIElementSetAttributeValue( |
| | | tabButton, |
| | | kAXValueAttribute as CFString, |
| | | kCFBooleanTrue |
| | | ) |
| | | DevelopmentDiagnostics.log("windowActivation.finderTab.setValue", [ |
| | | "windowID": window.id, |
| | | "hostWindowID": host.windowID, |
| | | "axError": String(describing: valueResult) |
| | | ]) |
| | | if valueResult == .success, |
| | | finderHostTitleMatchesTarget( |
| | | host, |
| | | normalizedTargetTitle: normalizedTargetTitle, |
| | | attempts: Self.finderTabFastTitleMatchAttempts, |
| | | delay: Self.finderTabFastTitleMatchDelay, |
| | | budget: budget |
| | | ) { |
| | | return true |
| | | } |
| | | |
| | | if let processIdentifier = window.app.processIdentifier, |
| | | !budget.isExhausted, |
| | | selectFinderTabByCyclingTabs( |
| | | processIdentifier: processIdentifier, |
| | | host: host, |
| | | window: window, |
| | | normalizedTargetTitle: normalizedTargetTitle, |
| | | budget: budget |
| | | ) { |
| | | return true |
| | | } |
| | | |
| | | if let processIdentifier = window.app.processIdentifier, |
| | | !budget.isExhausted, |
| | | selectFinderWindowMenuItem( |
| | | processIdentifier: processIdentifier, |
| | | window: window, |
| | | normalizedTargetTitle: normalizedTargetTitle, |
| | | budget: budget |
| | | ) { |
| | | return true |
| | | } |
| | | |
| | | let currentHostTitle = axStringAttribute(kAXTitleAttribute as String, for: host.element) ?? "" |
| | | DevelopmentDiagnostics.log("windowActivation.finderTab.selectionVerificationFailed", [ |
| | | "windowID": window.id, |
| | | "hostWindowID": host.windowID, |
| | | "targetTitleHash": DevelopmentDiagnostics.stableFingerprint(window.title), |
| | | "targetTitleLength": window.title.count, |
| | | "currentHostTitleHash": DevelopmentDiagnostics.stableFingerprint(currentHostTitle), |
| | | "currentHostTitleLength": currentHostTitle.count, |
| | | "elapsedMilliseconds": budget.elapsedMilliseconds, |
| | | "budgetMilliseconds": Self.milliseconds(budget.limit), |
| | | "budgetExceeded": budget.isExhausted |
| | | ]) |
| | | return false |
| | | } |
| | | |
| | | private func finderTabControl( |
| | | matchingNormalizedTitle targetTitle: String, |
| | | in root: AXUIElement, |
| | | budget: FinderTabActivationBudget |
| | | ) -> FinderTabControl? { |
| | | var visitedCount = 0 |
| | | |
| | | func search(_ element: AXUIElement, depth: Int, tabGroup: AXUIElement?) -> FinderTabControl? { |
| | | visitedCount += 1 |
| | | guard visitedCount <= 240, depth <= 9, !budget.isExhausted else { return nil } |
| | | |
| | | let role = axStringAttribute(kAXRoleAttribute as String, for: element) |
| | | let subrole = axStringAttribute(kAXSubroleAttribute as String, for: element) |
| | | let title = axStringAttribute(kAXTitleAttribute as String, for: element) |
| | | ?? axStringAttribute(kAXDescriptionAttribute as String, for: element) |
| | | ?? axStringAttribute(kAXValueAttribute as String, for: element) |
| | | let isTabGroup = role == "AXTabGroup" |
| | | let currentTabGroup = isTabGroup ? element : tabGroup |
| | | let isTabControl = subrole == "AXTabButton" |
| | | || (currentTabGroup != nil && (role == kAXRadioButtonRole as String || role == kAXButtonRole as String)) |
| | | |
| | | if normalizedWindowTitle(title ?? "") == targetTitle, |
| | | isTabControl { |
| | | return FinderTabControl(button: element, tabGroup: currentTabGroup) |
| | | } |
| | | |
| | | guard isTabGroup || currentTabGroup != nil || depth < 7 else { return nil } |
| | | for child in axChildren(of: element) { |
| | | if let match = search(child, depth: depth + 1, tabGroup: currentTabGroup) { |
| | | return match |
| | | } |
| | | } |
| | | |
| | | return nil |
| | | } |
| | | |
| | | return search(root, depth: 0, tabGroup: nil) |
| | | } |
| | | |
| | | private func finderHostTitleMatchesTarget( |
| | | _ host: AXWindowMetadata, |
| | | normalizedTargetTitle: String, |
| | | attempts: Int = 10, |
| | | delay: TimeInterval = 0.04, |
| | | budget: FinderTabActivationBudget? = nil |
| | | ) -> Bool { |
| | | let attemptCount = max(1, attempts) |
| | | for attempt in 0..<attemptCount { |
| | | if attempt > 0 { |
| | | let sleepDuration = budget.map { min(delay, $0.remaining) } ?? delay |
| | | if sleepDuration > 0 { |
| | | Thread.sleep(forTimeInterval: sleepDuration) |
| | | } |
| | | } |
| | | |
| | | let selectedTitle = normalizedWindowTitle( |
| | | axStringAttribute(kAXTitleAttribute as String, for: host.element) ?? "" |
| | | ) |
| | | if selectedTitle == normalizedTargetTitle { |
| | | return true |
| | | } |
| | | |
| | | if budget?.isExhausted == true { |
| | | break |
| | | } |
| | | } |
| | | |
| | | return false |
| | | } |
| | | |
| | | private func selectFinderWindowMenuItem( |
| | | processIdentifier: Int32, |
| | | window: AlignerWindow, |
| | | normalizedTargetTitle: String, |
| | | budget: FinderTabActivationBudget |
| | | ) -> Bool { |
| | | guard !budget.isExhausted else { |
| | | DevelopmentDiagnostics.log("windowActivation.finderTab.windowMenuSkippedBudgetExceeded", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier, |
| | | "elapsedMilliseconds": budget.elapsedMilliseconds, |
| | | "budgetMilliseconds": Self.milliseconds(budget.limit) |
| | | ]) |
| | | return false |
| | | } |
| | | |
| | | let appElement = AXUIElementCreateApplication(processIdentifier) |
| | | guard let menuBar = axElementAttribute(kAXMenuBarAttribute as String, for: appElement) else { |
| | | DevelopmentDiagnostics.log("windowActivation.finderTab.windowMenuMissing", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier |
| | | ]) |
| | | return false |
| | | } |
| | | |
| | | for menuBarItem in axChildren(of: menuBar) where isWindowMenuBarItem(menuBarItem) { |
| | | let openResult = AXUIElementPerformAction(menuBarItem, kAXPressAction as CFString) |
| | | if budget.remaining > 0 { |
| | | Thread.sleep(forTimeInterval: min(0.08, budget.remaining)) |
| | | } |
| | | guard openResult == .success, |
| | | let menuItem = finderWindowMenuItem( |
| | | matchingNormalizedTitle: normalizedTargetTitle, |
| | | in: menuBarItem, |
| | | budget: budget |
| | | ) |
| | | else { |
| | | continue |
| | | } |
| | | |
| | | let pressResult = AXUIElementPerformAction(menuItem, kAXPressAction as CFString) |
| | | DevelopmentDiagnostics.log("windowActivation.finderTab.windowMenuPress", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier, |
| | | "axError": String(describing: pressResult) |
| | | ]) |
| | | guard pressResult == .success else { return false } |
| | | |
| | | if finderFocusedWindowTitleMatches( |
| | | processIdentifier: processIdentifier, |
| | | window: window, |
| | | normalizedTargetTitle: normalizedTargetTitle, |
| | | initialDelay: Self.finderTabWindowMenuVerificationInitialDelay, |
| | | attempts: Self.finderTabWindowMenuVerificationAttempts, |
| | | delay: Self.finderTabWindowMenuVerificationDelay, |
| | | requiredConsecutiveMatches: 2, |
| | | budget: budget |
| | | ) { |
| | | DevelopmentDiagnostics.log("windowActivation.finderTab.windowMenuSucceeded", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier, |
| | | "elapsedMilliseconds": budget.elapsedMilliseconds, |
| | | "budgetMilliseconds": Self.milliseconds(budget.limit) |
| | | ]) |
| | | return true |
| | | } |
| | | |
| | | DevelopmentDiagnostics.log("windowActivation.finderTab.windowMenuVerificationFailed", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier, |
| | | "elapsedMilliseconds": budget.elapsedMilliseconds, |
| | | "budgetMilliseconds": Self.milliseconds(budget.limit), |
| | | "budgetExceeded": budget.isExhausted |
| | | ]) |
| | | } |
| | | |
| | | DevelopmentDiagnostics.log("windowActivation.finderTab.windowMenuItemMissing", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier, |
| | | "targetTitleHash": DevelopmentDiagnostics.stableFingerprint(window.title), |
| | | "targetTitleLength": window.title.count, |
| | | "elapsedMilliseconds": budget.elapsedMilliseconds, |
| | | "budgetMilliseconds": Self.milliseconds(budget.limit), |
| | | "budgetExceeded": budget.isExhausted |
| | | ]) |
| | | return false |
| | | } |
| | | |
| | | private func isWindowMenuBarItem(_ element: AXUIElement) -> Bool { |
| | | let title = normalizedWindowTitle(axStringAttribute(kAXTitleAttribute as String, for: element) ?? "") |
| | | return title == "window" || title == "窗口" |
| | | } |
| | | |
| | | private func finderWindowMenuItem( |
| | | matchingNormalizedTitle targetTitle: String, |
| | | in root: AXUIElement, |
| | | budget: FinderTabActivationBudget |
| | | ) -> AXUIElement? { |
| | | var visitedCount = 0 |
| | | |
| | | func search(_ element: AXUIElement, depth: Int) -> AXUIElement? { |
| | | visitedCount += 1 |
| | | guard visitedCount <= 500, depth <= 8, !budget.isExhausted else { return nil } |
| | | |
| | | let role = axStringAttribute(kAXRoleAttribute as String, for: element) |
| | | let title = axStringAttribute(kAXTitleAttribute as String, for: element) |
| | | ?? axStringAttribute(kAXDescriptionAttribute as String, for: element) |
| | | if role == kAXMenuItemRole as String, |
| | | normalizedWindowTitle(title ?? "") == targetTitle { |
| | | return element |
| | | } |
| | | |
| | | for child in axChildren(of: element) { |
| | | if let match = search(child, depth: depth + 1) { |
| | | return match |
| | | } |
| | | } |
| | | |
| | | return nil |
| | | } |
| | | |
| | | return search(root, depth: 0) |
| | | } |
| | | |
| | | private func finderFocusedOrMainWindowTitle(processIdentifier: Int32) -> String? { |
| | | let appElement = AXUIElementCreateApplication(processIdentifier) |
| | | let focusedWindow = axElementAttribute(kAXFocusedWindowAttribute as String, for: appElement) |
| | | ?? axElementAttribute(kAXMainWindowAttribute as String, for: appElement) |
| | | return focusedWindow.flatMap { |
| | | axStringAttribute(kAXTitleAttribute as String, for: $0) |
| | | } |
| | | } |
| | | |
| | | private func finderFocusedWindowTitleMatches( |
| | | processIdentifier: Int32, |
| | | window: AlignerWindow, |
| | | normalizedTargetTitle: String, |
| | | initialDelay: TimeInterval, |
| | | attempts: Int, |
| | | delay: TimeInterval, |
| | | requiredConsecutiveMatches: Int, |
| | | budget: FinderTabActivationBudget |
| | | ) -> Bool { |
| | | var consecutiveMatches = 0 |
| | | var lastTitle: String? |
| | | |
| | | if budget.remaining > 0 { |
| | | Thread.sleep(forTimeInterval: min(initialDelay, budget.remaining)) |
| | | } |
| | | let attemptCount = max(1, attempts) |
| | | for attempt in 0..<attemptCount { |
| | | let title = finderFocusedOrMainWindowTitle(processIdentifier: processIdentifier) |
| | | lastTitle = title |
| | | if normalizedWindowTitle(title ?? "") == normalizedTargetTitle { |
| | | consecutiveMatches += 1 |
| | | } else { |
| | | consecutiveMatches = 0 |
| | | } |
| | | |
| | | if consecutiveMatches >= requiredConsecutiveMatches { |
| | | return true |
| | | } |
| | | if budget.isExhausted || attempt == attemptCount - 1 { |
| | | break |
| | | } |
| | | let sleepDuration = min(delay, budget.remaining) |
| | | if sleepDuration > 0 { |
| | | Thread.sleep(forTimeInterval: sleepDuration) |
| | | } |
| | | } |
| | | |
| | | DevelopmentDiagnostics.log("windowActivation.finderTab.focusedTitleMismatch", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier, |
| | | "targetTitleHash": DevelopmentDiagnostics.stableFingerprint(window.title), |
| | | "targetTitleLength": window.title.count, |
| | | "lastTitleHash": DevelopmentDiagnostics.stableFingerprint(lastTitle), |
| | | "lastTitleLength": lastTitle?.count, |
| | | "elapsedMilliseconds": budget.elapsedMilliseconds, |
| | | "budgetMilliseconds": Self.milliseconds(budget.limit), |
| | | "budgetExceeded": budget.isExhausted |
| | | ]) |
| | | return false |
| | | } |
| | | |
| | | private func selectFinderTabByCyclingTabs( |
| | | processIdentifier: Int32, |
| | | host: AXWindowMetadata, |
| | | window: AlignerWindow, |
| | | normalizedTargetTitle: String, |
| | | budget: FinderTabActivationBudget |
| | | ) -> Bool { |
| | | let maximumAttempts = Self.finderTabCycleMaximumAttempts |
| | | for attempt in 1...maximumAttempts { |
| | | guard !budget.isExhausted else { |
| | | DevelopmentDiagnostics.log("windowActivation.finderTab.cycleBudgetExceeded", [ |
| | | "windowID": window.id, |
| | | "hostWindowID": host.windowID, |
| | | "attempt": attempt, |
| | | "attemptLimit": maximumAttempts, |
| | | "elapsedMilliseconds": budget.elapsedMilliseconds, |
| | | "budgetMilliseconds": Self.milliseconds(budget.limit) |
| | | ]) |
| | | return false |
| | | } |
| | | |
| | | for shortcut in Self.finderNextTabShortcuts { |
| | | guard postKeyboardShortcut(to: processIdentifier, shortcut: shortcut) else { |
| | | DevelopmentDiagnostics.log("windowActivation.finderTab.cyclePostFailed", [ |
| | | "windowID": window.id, |
| | | "hostWindowID": host.windowID, |
| | | "attempt": attempt, |
| | | "shortcut": shortcut.name |
| | | ]) |
| | | return false |
| | | } |
| | | |
| | | if finderHostTitleMatchesTarget( |
| | | host, |
| | | normalizedTargetTitle: normalizedTargetTitle, |
| | | attempts: 2, |
| | | delay: 0.025, |
| | | budget: budget |
| | | ) { |
| | | DevelopmentDiagnostics.log("windowActivation.finderTab.cycleSucceeded", [ |
| | | "windowID": window.id, |
| | | "hostWindowID": host.windowID, |
| | | "attempt": attempt, |
| | | "attemptLimit": maximumAttempts, |
| | | "shortcut": shortcut.name, |
| | | "elapsedMilliseconds": budget.elapsedMilliseconds, |
| | | "budgetMilliseconds": Self.milliseconds(budget.limit) |
| | | ]) |
| | | return true |
| | | } |
| | | } |
| | | } |
| | | |
| | | DevelopmentDiagnostics.log("windowActivation.finderTab.cycleFailed", [ |
| | | "windowID": window.id, |
| | | "hostWindowID": host.windowID, |
| | | "attemptCount": maximumAttempts, |
| | | "elapsedMilliseconds": budget.elapsedMilliseconds, |
| | | "budgetMilliseconds": Self.milliseconds(budget.limit), |
| | | "budgetExceeded": budget.isExhausted |
| | | ]) |
| | | return false |
| | | } |
| | | |
| | | private func postKeyboardShortcut( |
| | | to processIdentifier: Int32, |
| | | shortcut: KeyboardShortcut |
| | | ) -> Bool { |
| | | let source = CGEventSource(stateID: .combinedSessionState) |
| | | guard let keyDown = CGEvent( |
| | | keyboardEventSource: source, |
| | | virtualKey: shortcut.keyCode, |
| | | keyDown: true |
| | | ), |
| | | let keyUp = CGEvent( |
| | | keyboardEventSource: source, |
| | | virtualKey: shortcut.keyCode, |
| | | keyDown: false |
| | | ) |
| | | else { |
| | | return false |
| | | } |
| | | |
| | | keyDown.flags = shortcut.flags |
| | | keyUp.flags = shortcut.flags |
| | | keyDown.postToPid(processIdentifier) |
| | | keyUp.postToPid(processIdentifier) |
| | | return true |
| | | } |
| | | |
| | | private func axChildren(of element: AXUIElement) -> [AXUIElement] { |
| | | var value: CFTypeRef? |
| | | guard AXUIElementCopyAttributeValue(element, kAXChildrenAttribute as CFString, &value) == .success, |
| | | let children = value as? [AXUIElement] |
| | | else { |
| | | return [] |
| | | } |
| | | |
| | | return children |
| | | } |
| | | |
| | | private func axStringAttribute(_ attribute: String, for element: AXUIElement) -> String? { |
| | | var value: CFTypeRef? |
| | | guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success, |
| | | let value |
| | | else { |
| | | return nil |
| | | } |
| | | |
| | | return value as? String |
| | | } |
| | | |
| | | private func axElementAttribute(_ attribute: String, for element: AXUIElement) -> AXUIElement? { |
| | | var value: CFTypeRef? |
| | | guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success, |
| | | let value, |
| | | CFGetTypeID(value) == AXUIElementGetTypeID() |
| | | else { |
| | | return nil |
| | | } |
| | | |
| | | return (value as! AXUIElement) |
| | | } |
| | | |
| | | private func frameOverlapScore(_ lhs: CGRect?, _ rhs: CGRect?) -> CGFloat? { |
| | | guard let lhs, |
| | | let rhs, |
| | | !lhs.isEmpty, |
| | | !rhs.isEmpty |
| | | else { |
| | | return nil |
| | | } |
| | | |
| | | let intersection = lhs.intersection(rhs) |
| | | guard !intersection.isNull, |
| | | !intersection.isEmpty |
| | | else { |
| | | return nil |
| | | } |
| | | |
| | | let denominator = min(lhs.width * lhs.height, rhs.width * rhs.height) |
| | | guard denominator > 0 else { return nil } |
| | | let overlap = (intersection.width * intersection.height) / denominator |
| | | let widthSimilarity = min(lhs.width, rhs.width) / max(lhs.width, rhs.width) |
| | | let heightSimilarity = min(lhs.height, rhs.height) / max(lhs.height, rhs.height) |
| | | let sizeSimilarity = min(widthSimilarity, heightSimilarity) |
| | | guard sizeSimilarity >= 0.80 else { return nil } |
| | | return min(overlap, sizeSimilarity) |
| | | } |
| | | |
| | | 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 = matchingFrame(for: window) |
| | | 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 matchingFrame(for window: AlignerWindow) -> CGRect? { |
| | | if isFinderCGOnlyPageCandidate(window) { |
| | | return window.frame ?? cgWindowFrame(for: window) |
| | | } |
| | | |
| | | return cgWindowFrame(for: window) ?? window.frame |
| | | } |
| | | |
| | | 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), |
| | |
| | | } |
| | | |
| | | let spaceIDs = spaceIDsByWindowID[resolvedWindowID] ?? [] |
| | | nextRestorableAXWindowsByID[resolvedWindowID] = RestorableAXWindow( |
| | | processIdentifier: axWindow.processIdentifier, |
| | | element: axWindow.element |
| | | ) |
| | | let isFullscreen = !Set(spaceIDs).isDisjoint(with: fullscreenSpaceIDs) |
| | | guard WindowEnumerationPolicy.shouldIncludeAXOnlyRecord( |
| | | identifierSource: identifierSource, |
| | | isMinimized: axWindow.isMinimized, |
| | | isFullscreen: isFullscreen, |
| | | spaceIDs: spaceIDs |
| | | ) else { |
| | | DevelopmentDiagnostics.log("windowEnumeration.axOnly.lowConfidenceSkipped", [ |
| | | "windowID": resolvedWindowID, |
| | | "pid": axWindow.processIdentifier, |
| | | "appName": axWindow.app.name, |
| | | "bundleIdentifier": axWindow.app.bundleIdentifier, |
| | | "titleHash": DevelopmentDiagnostics.stableFingerprint(axWindow.title ?? ""), |
| | | "titleLength": axWindow.title?.count ?? 0, |
| | | "identifierSource": String(describing: identifierSource), |
| | | "isMinimized": axWindow.isMinimized, |
| | | "isFullscreen": isFullscreen, |
| | | "spaceIDCount": spaceIDs.count |
| | | ]) |
| | | continue |
| | | } |
| | | if axWindow.isMinimized { |
| | | nextRestorableAXWindowsByID[resolvedWindowID] = RestorableAXWindow( |
| | | processIdentifier: axWindow.processIdentifier, |
| | | element: axWindow.element |
| | | ) |
| | | } |
| | | axOnlyRecords.append(WindowEnumerationRecord( |
| | | id: resolvedWindowID, |
| | | app: axWindow.app, |
| | |
| | | title: axWindow.title ?? "", |
| | | identifierSource: identifierSource, |
| | | size: axWindow.size, |
| | | frame: axWindow.frame, |
| | | subrole: axWindow.subrole, |
| | | isMinimized: axWindow.isMinimized, |
| | | isFullscreen: !Set(spaceIDs).isDisjoint(with: fullscreenSpaceIDs), |
| | | isFullscreen: isFullscreen, |
| | | isGhost: false, |
| | | isDesktopElement: false, |
| | | isSystemCritical: isSystemCritical(app: axWindow.app), |
| | |
| | | } |
| | | |
| | | restorableAXWindowsByID = nextRestorableAXWindowsByID |
| | | return cgRecords + axOnlyRecords |
| | | let records = cgRecords + axOnlyRecords |
| | | let attributedRecords = FinderTabSpaceAttributionPolicy.attributedRecords(records) |
| | | DevelopmentDiagnostics.log("windowEnumeration.finderTabSpaceAttribution", [ |
| | | "finderNoSpaceCandidateCount": records.filter { record in |
| | | record.app.bundleIdentifier == "com.apple.finder" |
| | | && record.identifierSource == .cgWindow |
| | | && record.spaceIDs.isEmpty |
| | | && !record.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty |
| | | && record.frame?.isEmpty == false |
| | | }.count, |
| | | "finderFullscreenHostCount": records.filter { record in |
| | | record.app.bundleIdentifier == "com.apple.finder" |
| | | && record.isFullscreen |
| | | && !record.spaceIDs.isEmpty |
| | | && record.subrole == .standard |
| | | && record.frame?.isEmpty == false |
| | | }.count, |
| | | "attributedCount": zip(records, attributedRecords).filter { before, after in |
| | | before.spaceIDs.isEmpty && !after.spaceIDs.isEmpty |
| | | }.count, |
| | | "attributedWindowIDs": zip(records, attributedRecords).compactMap { before, after in |
| | | before.spaceIDs.isEmpty && !after.spaceIDs.isEmpty ? after.id : nil |
| | | } |
| | | ]) |
| | | return attributedRecords |
| | | } |
| | | |
| | | private func app( |
| | |
| | | return activated |
| | | } |
| | | |
| | | private func retryFocusMainRaiseWindow( |
| | | for window: AlignerWindow, |
| | | delay: TimeInterval, |
| | | matchAttempt: String |
| | | ) -> WindowFocusAttempt? { |
| | | if delay > 0 { |
| | | Thread.sleep(forTimeInterval: delay) |
| | | } |
| | | |
| | | let retryMetadata = AXWindowMetadataReader.metadata(appCategorizer: appCategorizer) |
| | | guard let retryAXWindow = axWindow( |
| | | for: window, |
| | | in: retryMetadata, |
| | | operation: "windowActivation", |
| | | matchAttempt: matchAttempt |
| | | ) else { |
| | | DevelopmentDiagnostics.log("windowActivation.activate.retryAXLookupMissing", [ |
| | | "windowID": window.id, |
| | | "pid": window.app.processIdentifier, |
| | | "attempt": matchAttempt, |
| | | "candidateCount": retryMetadata.filter { $0.processIdentifier == window.app.processIdentifier }.count, |
| | | "matchingTitleCandidateCount": matchingAXTitleCandidateCount(for: window, in: retryMetadata), |
| | | "matchingSizeCandidateCount": matchingAXSizeCandidateCount(for: window, in: retryMetadata) |
| | | ]) |
| | | return nil |
| | | } |
| | | |
| | | let attempt = focusMainRaiseWindowResult( |
| | | retryAXWindow.element, |
| | | processIdentifier: retryAXWindow.processIdentifier |
| | | ) |
| | | DevelopmentDiagnostics.log("windowActivation.activate.retryAXFocus", [ |
| | | "windowID": window.id, |
| | | "pid": retryAXWindow.processIdentifier, |
| | | "attempt": matchAttempt, |
| | | "matchedWindowID": retryAXWindow.windowID, |
| | | "matchedTitleHash": DevelopmentDiagnostics.stableFingerprint(retryAXWindow.title), |
| | | "focused": attempt.focused.success, |
| | | "focusAXError": String(describing: attempt.focused.error), |
| | | "main": attempt.main.success, |
| | | "mainAXError": String(describing: attempt.main.error), |
| | | "raised": attempt.raised.success, |
| | | "raiseAXError": String(describing: attempt.raised.error) |
| | | ]) |
| | | return attempt |
| | | } |
| | | |
| | | private func focusMainRaiseWindowResult( |
| | | _ window: AXUIElement, |
| | | processIdentifier: Int32 |
| | | ) -> WindowFocusAttempt { |
| | | WindowFocusAttempt( |
| | | focused: focusWindowResult(window, processIdentifier: processIdentifier), |
| | | main: mainWindowResult(window), |
| | | raised: raiseWindowResult(window) |
| | | ) |
| | | } |
| | | |
| | | private func focusWindowResult(_ window: AXUIElement, processIdentifier: Int32) -> AXActionResult { |
| | | let appElement = AXUIElementCreateApplication(processIdentifier) |
| | | let error = AXUIElementSetAttributeValue( |
| | |
| | | return AXActionResult(error: error) |
| | | } |
| | | |
| | | private func mainWindowResult(_ window: AXUIElement) -> AXActionResult { |
| | | let error = AXUIElementSetAttributeValue( |
| | | window, |
| | | kAXMainAttribute as CFString, |
| | | kCFBooleanTrue |
| | | ) |
| | | return AXActionResult(error: error) |
| | | } |
| | | |
| | | private func raiseWindowResult(_ window: AXUIElement) -> AXActionResult { |
| | | AXActionResult(error: AXUIElementPerformAction(window, kAXRaiseAction as CFString)) |
| | | } |
| | | |
| | | private static func elapsedMilliseconds(since startedAt: TimeInterval) -> Int { |
| | | milliseconds(ProcessInfo.processInfo.systemUptime - startedAt) |
| | | } |
| | | |
| | | private static func milliseconds(_ interval: TimeInterval) -> Int { |
| | | Int((max(0, interval) * 1_000).rounded()) |
| | | } |
| | | |
| | | private func activationPolicy(from runningApplication: NSRunningApplication?) -> AlignerActivationPolicy { |
| | |
| | | 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 |
| | | } |
| | | |
| | | if app.bundleIdentifier == "com.apple.finder" { |
| | | return layer == 0 |
| | | && alpha > 0 |
| | | && !trimmedTitle.isEmpty |
| | | && rawBounds.width > WindowEnumerationPolicy.minimumVisibleWidth |
| | | && rawBounds.height > WindowEnumerationPolicy.minimumVisibleHeight |
| | | } |
| | |
| | | return "activationFailed" |
| | | } |
| | | } |
| | | |
| | | private static let commandWVirtualKeyCode: CGKeyCode = 13 |
| | | private static let rightBracketVirtualKeyCode: CGKeyCode = 30 |
| | | private static let tabVirtualKeyCode: CGKeyCode = 48 |
| | | private static let finderNextTabShortcuts: [KeyboardShortcut] = [ |
| | | KeyboardShortcut(name: "controlTab", keyCode: tabVirtualKeyCode, flags: .maskControl), |
| | | KeyboardShortcut(name: "commandShiftRightBracket", keyCode: rightBracketVirtualKeyCode, flags: [.maskCommand, .maskShift]) |
| | | ] |
| | | } |
| | | |
| | | 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: |