| | |
| | | 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 |
| | | } |
| | | |
| | | init( |
| | | spaceIDsByWindowIDProvider: @escaping ([UInt32]) -> [UInt32: [UInt64]], |
| | | appCategorizer: any AppCategorizer = BundleIDAppCategorizer(), |
| | |
| | | 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) |
| | | DevelopmentDiagnostics.log("windowActivation.activate.axMissing", [ |
| | | "windowID": window.id, |
| | |
| | | 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) |
| | |
| | | "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 { |
| | |
| | | } |
| | | } |
| | | |
| | | 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("windowClose.axMatch.directWindowID", [ |
| | | DevelopmentDiagnostics.log("\(operation).axMatch.directWindowID", [ |
| | | "windowID": window.id, |
| | | "pid": window.app.processIdentifier, |
| | | "attempt": matchAttempt |
| | |
| | | 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("windowClose.axMatch.fingerprint", [ |
| | | DevelopmentDiagnostics.log("\(operation).axMatch.fingerprint", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier, |
| | | "attempt": matchAttempt |
| | |
| | | } |
| | | |
| | | if fingerprintMatches.count > 1 { |
| | | DevelopmentDiagnostics.log("windowClose.axMatch.fingerprintAmbiguous", [ |
| | | DevelopmentDiagnostics.log("\(operation).axMatch.fingerprintAmbiguous", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier, |
| | | "candidateCount": fingerprintMatches.count |
| | | "candidateCount": fingerprintMatches.count, |
| | | "attempt": matchAttempt |
| | | ]) |
| | | } |
| | | } |
| | |
| | | metadata.processIdentifier == processIdentifier |
| | | && normalizedWindowTitle(metadata.title ?? "") == normalizedTitle |
| | | } |
| | | if titleMatches.count == 1 { |
| | | DevelopmentDiagnostics.log("windowClose.axMatch.normalizedTitle", [ |
| | | if !normalizedTitle.isEmpty, titleMatches.count == 1 { |
| | | DevelopmentDiagnostics.log("\(operation).axMatch.normalizedTitle", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier, |
| | | "attempt": matchAttempt |
| | |
| | | processIdentifier: processIdentifier, |
| | | in: axMetadata |
| | | ) { |
| | | DevelopmentDiagnostics.log("windowClose.axMatch.finderFallback", [ |
| | | DevelopmentDiagnostics.log("\(operation).axMatch.finderFallback", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier, |
| | | "attempt": matchAttempt |
| | |
| | | } |
| | | |
| | | if titleMatches.count > 1 { |
| | | DevelopmentDiagnostics.log("windowClose.axMatch.titleAmbiguous", [ |
| | | DevelopmentDiagnostics.log("\(operation).axMatch.titleAmbiguous", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier, |
| | | "candidateCount": titleMatches.count |
| | | "candidateCount": titleMatches.count, |
| | | "attempt": matchAttempt |
| | | ]) |
| | | } |
| | | |
| | |
| | | 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 |
| | | } |
| | | |
| | | guard let host = finderTabHostAXWindow(for: window, in: axMetadata) else { |
| | | let normalizedTargetTitle = normalizedWindowTitle(window.title) |
| | | let applicationActivated = activateApplication(processIdentifier: processIdentifier) |
| | | let selectedViaWindowMenu = !normalizedTargetTitle.isEmpty |
| | | && selectFinderWindowMenuItem( |
| | | processIdentifier: processIdentifier, |
| | | window: window, |
| | | normalizedTargetTitle: normalizedTargetTitle |
| | | ) |
| | | 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 |
| | | ]) |
| | | if selectedViaWindowMenu { |
| | | return true |
| | | } |
| | | |
| | | DevelopmentDiagnostics.log("\(operation).finderTab.hostMissing", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier, |
| | | "reason": reason, |
| | | "spaceIDs": window.spaceIDs |
| | | ]) |
| | | 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 focusBeforeResult = focusWindowResult(host.element, processIdentifier: processIdentifier) |
| | | let raiseBeforeResult = raiseWindowResult(host.element) |
| | | let selectedTab = selectFinderTab(window, in: host) |
| | | Thread.sleep(forTimeInterval: 0.05) |
| | | let focusAfterResult: AXActionResult? |
| | | let raiseAfterResult: AXActionResult? |
| | | 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. |
| | | focusAfterResult = nil |
| | | raiseAfterResult = nil |
| | | } else { |
| | | focusAfterResult = focusWindowResult(host.element, processIdentifier: processIdentifier) |
| | | raiseAfterResult = raiseWindowResult(host.element) |
| | | } |
| | | |
| | | let didFocusOrRaise = focusBeforeResult.success |
| | | || raiseBeforeResult.success |
| | | || (focusAfterResult?.success ?? false) |
| | | || (raiseAfterResult?.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: focusBeforeResult.error), |
| | | "raiseBeforeAXError": String(describing: raiseBeforeResult.error), |
| | | "focusAfterAXError": focusAfterResult.map { String(describing: $0.error) } ?? "skippedAfterTabSelection", |
| | | "raiseAfterAXError": raiseAfterResult.map { String(describing: $0.error) } ?? "skippedAfterTabSelection", |
| | | "activated": activated |
| | | ]) |
| | | |
| | | 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] |
| | | ) -> 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 selectableHostCandidates = spaceScopedCandidates.filter { metadata in |
| | | finderHostCanRepresentTarget(window, in: metadata) |
| | | } |
| | | |
| | | let hostCandidates = selectableHostCandidates.isEmpty |
| | | ? spaceScopedCandidates |
| | | : selectableHostCandidates |
| | | |
| | | let targetFrame = matchingFrame(for: window) |
| | | let scoredHosts = hostCandidates.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 = scoredHosts.first { |
| | | if scoredHosts.count == 1 { |
| | | if selectableHostCandidates.isEmpty { |
| | | DevelopmentDiagnostics.log("windowActivation.finderTab.frameInferredHost", [ |
| | | "windowID": window.id, |
| | | "hostWindowID": best.metadata.windowID, |
| | | "score": Double(best.score), |
| | | "spaceIDCount": targetSpaceIDs.count, |
| | | "candidateCount": hostCandidates.count, |
| | | "targetTitleHash": DevelopmentDiagnostics.stableFingerprint(window.title), |
| | | "targetTitleLength": window.title.count |
| | | ]) |
| | | } |
| | | return best.metadata |
| | | } |
| | | |
| | | let secondBest = scoredHosts[1] |
| | | if best.score - secondBest.score >= 0.05 { |
| | | if selectableHostCandidates.isEmpty { |
| | | DevelopmentDiagnostics.log("windowActivation.finderTab.frameInferredHost", [ |
| | | "windowID": window.id, |
| | | "hostWindowID": best.metadata.windowID, |
| | | "score": Double(best.score), |
| | | "secondBestScore": Double(secondBest.score), |
| | | "spaceIDCount": targetSpaceIDs.count, |
| | | "candidateCount": hostCandidates.count, |
| | | "targetTitleHash": DevelopmentDiagnostics.stableFingerprint(window.title), |
| | | "targetTitleLength": window.title.count |
| | | ]) |
| | | } |
| | | return best.metadata |
| | | } |
| | | } |
| | | |
| | | 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 |
| | | ) -> Bool { |
| | | let normalizedTargetTitle = normalizedWindowTitle(window.title) |
| | | guard !normalizedTargetTitle.isEmpty else { return false } |
| | | |
| | | if normalizedWindowTitle(host.title ?? "") == normalizedTargetTitle { |
| | | return true |
| | | } |
| | | |
| | | return finderTabControl(matchingNormalizedTitle: normalizedTargetTitle, in: host.element) != nil |
| | | } |
| | | |
| | | private func selectFinderTab( |
| | | _ window: AlignerWindow, |
| | | in host: AXWindowMetadata |
| | | ) -> 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 |
| | | ) else { |
| | | DevelopmentDiagnostics.log("windowActivation.finderTab.tabButtonMissing", [ |
| | | "windowID": window.id, |
| | | "hostWindowID": host.windowID, |
| | | "targetTitleHash": DevelopmentDiagnostics.stableFingerprint(window.title), |
| | | "targetTitleLength": window.title.count |
| | | ]) |
| | | if let processIdentifier = window.app.processIdentifier, |
| | | selectFinderWindowMenuItem( |
| | | processIdentifier: processIdentifier, |
| | | window: window, |
| | | normalizedTargetTitle: normalizedTargetTitle |
| | | ) { |
| | | return true |
| | | } |
| | | if let processIdentifier = window.app.processIdentifier, |
| | | selectFinderTabByCyclingTabs( |
| | | processIdentifier: processIdentifier, |
| | | host: host, |
| | | window: window, |
| | | normalizedTargetTitle: normalizedTargetTitle |
| | | ) { |
| | | 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) { |
| | | 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) { |
| | | 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) { |
| | | return true |
| | | } |
| | | |
| | | if let processIdentifier = window.app.processIdentifier, |
| | | selectFinderWindowMenuItem( |
| | | processIdentifier: processIdentifier, |
| | | window: window, |
| | | normalizedTargetTitle: normalizedTargetTitle |
| | | ) { |
| | | return true |
| | | } |
| | | |
| | | if let processIdentifier = window.app.processIdentifier, |
| | | selectFinderTabByCyclingTabs( |
| | | processIdentifier: processIdentifier, |
| | | host: host, |
| | | window: window, |
| | | normalizedTargetTitle: normalizedTargetTitle |
| | | ) { |
| | | 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 |
| | | ]) |
| | | return false |
| | | } |
| | | |
| | | private func finderTabButton( |
| | | matchingNormalizedTitle targetTitle: String, |
| | | in root: AXUIElement |
| | | ) -> AXUIElement? { |
| | | finderTabControl(matchingNormalizedTitle: targetTitle, in: root)?.button |
| | | } |
| | | |
| | | private func finderTabControl( |
| | | matchingNormalizedTitle targetTitle: String, |
| | | in root: AXUIElement |
| | | ) -> FinderTabControl? { |
| | | var visitedCount = 0 |
| | | |
| | | func search(_ element: AXUIElement, depth: Int, tabGroup: AXUIElement?) -> FinderTabControl? { |
| | | visitedCount += 1 |
| | | guard visitedCount <= 240, depth <= 9 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 |
| | | ) -> Bool { |
| | | for _ in 0..<attempts { |
| | | Thread.sleep(forTimeInterval: delay) |
| | | let selectedTitle = normalizedWindowTitle( |
| | | axStringAttribute(kAXTitleAttribute as String, for: host.element) ?? "" |
| | | ) |
| | | if selectedTitle == normalizedTargetTitle { |
| | | return true |
| | | } |
| | | } |
| | | |
| | | return false |
| | | } |
| | | |
| | | private func selectFinderWindowMenuItem( |
| | | processIdentifier: Int32, |
| | | window: AlignerWindow, |
| | | normalizedTargetTitle: String |
| | | ) -> Bool { |
| | | 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) |
| | | Thread.sleep(forTimeInterval: 0.08) |
| | | guard openResult == .success, |
| | | let menuItem = finderWindowMenuItem( |
| | | matchingNormalizedTitle: normalizedTargetTitle, |
| | | in: menuBarItem |
| | | ) |
| | | 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: 0.18, |
| | | attempts: 18, |
| | | delay: 0.06, |
| | | requiredConsecutiveMatches: 3 |
| | | ) { |
| | | DevelopmentDiagnostics.log("windowActivation.finderTab.windowMenuSucceeded", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier |
| | | ]) |
| | | return true |
| | | } |
| | | |
| | | DevelopmentDiagnostics.log("windowActivation.finderTab.windowMenuVerificationFailed", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier |
| | | ]) |
| | | } |
| | | |
| | | DevelopmentDiagnostics.log("windowActivation.finderTab.windowMenuItemMissing", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier, |
| | | "targetTitleHash": DevelopmentDiagnostics.stableFingerprint(window.title), |
| | | "targetTitleLength": window.title.count |
| | | ]) |
| | | 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 |
| | | ) -> AXUIElement? { |
| | | var visitedCount = 0 |
| | | |
| | | func search(_ element: AXUIElement, depth: Int) -> AXUIElement? { |
| | | visitedCount += 1 |
| | | guard visitedCount <= 500, depth <= 8 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 finderFocusedWindowTitleMatches( |
| | | processIdentifier: Int32, |
| | | window: AlignerWindow, |
| | | normalizedTargetTitle: String, |
| | | initialDelay: TimeInterval, |
| | | attempts: Int, |
| | | delay: TimeInterval, |
| | | requiredConsecutiveMatches: Int |
| | | ) -> Bool { |
| | | let appElement = AXUIElementCreateApplication(processIdentifier) |
| | | var consecutiveMatches = 0 |
| | | var lastTitle: String? |
| | | |
| | | Thread.sleep(forTimeInterval: initialDelay) |
| | | for _ in 0..<attempts { |
| | | let focusedWindow = axElementAttribute(kAXFocusedWindowAttribute as String, for: appElement) |
| | | ?? axElementAttribute(kAXMainWindowAttribute as String, for: appElement) |
| | | let title = focusedWindow.flatMap { |
| | | axStringAttribute(kAXTitleAttribute as String, for: $0) |
| | | } |
| | | lastTitle = title |
| | | if normalizedWindowTitle(title ?? "") == normalizedTargetTitle { |
| | | consecutiveMatches += 1 |
| | | } else { |
| | | consecutiveMatches = 0 |
| | | } |
| | | |
| | | if consecutiveMatches >= requiredConsecutiveMatches { |
| | | return true |
| | | } |
| | | Thread.sleep(forTimeInterval: delay) |
| | | } |
| | | |
| | | 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 |
| | | ]) |
| | | return false |
| | | } |
| | | |
| | | private func selectFinderTabByCyclingTabs( |
| | | processIdentifier: Int32, |
| | | host: AXWindowMetadata, |
| | | window: AlignerWindow, |
| | | normalizedTargetTitle: String |
| | | ) -> Bool { |
| | | let maximumAttempts = 24 |
| | | for attempt in 1...maximumAttempts { |
| | | 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: 4, |
| | | delay: 0.035 |
| | | ) { |
| | | DevelopmentDiagnostics.log("windowActivation.finderTab.cycleSucceeded", [ |
| | | "windowID": window.id, |
| | | "hostWindowID": host.windowID, |
| | | "attempt": attempt, |
| | | "shortcut": shortcut.name |
| | | ]) |
| | | return true |
| | | } |
| | | } |
| | | } |
| | | |
| | | DevelopmentDiagnostics.log("windowActivation.finderTab.cycleFailed", [ |
| | | "windowID": window.id, |
| | | "hostWindowID": host.windowID, |
| | | "attemptCount": maximumAttempts |
| | | ]) |
| | | 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], |
| | |
| | | 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( |
| | |
| | | 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), |
| | |
| | | title: axWindow.title ?? "", |
| | | identifierSource: identifierSource, |
| | | size: axWindow.size, |
| | | frame: axWindow.frame, |
| | | subrole: axWindow.subrole, |
| | | isMinimized: axWindow.isMinimized, |
| | | isFullscreen: !Set(spaceIDs).isDisjoint(with: fullscreenSpaceIDs), |
| | |
| | | } |
| | | |
| | | 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( |
| | |
| | | } |
| | | } |
| | | |
| | | 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 |
| | |
| | | |
| | | 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, |
| | |
| | | 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, |