| | |
| | | let privateActivationOutcome = activateViaPrivateWindowServerAPI(window) |
| | | let didPrivatelyActivate = privateActivationOutcome?.succeeded == true |
| | | |
| | | let currentAXMetadata = AXWindowMetadataReader.metadata(appCategorizer: appCategorizer) |
| | | let axWindow = axWindow( |
| | | for: window, |
| | | in: AXWindowMetadataReader.metadata(appCategorizer: appCategorizer), |
| | | in: currentAXMetadata, |
| | | operation: "windowActivation", |
| | | matchAttempt: "initial" |
| | | ) |
| | |
| | | "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, |
| | |
| | | return nil |
| | | } |
| | | |
| | | guard let activationOutcome = activateViaPrivateWindowServerAPI(window), |
| | | activationOutcome.succeeded |
| | | else { |
| | | DevelopmentDiagnostics.log("windowClose.commandW.activationFailed", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier, |
| | | "reason": reason |
| | | ]) |
| | | 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) |
| | |
| | | && 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 |
| | | } |
| | | |
| | | guard let host = finderTabHostAXWindow(for: window, in: axMetadata) else { |
| | | 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 = focusWindowResult(host.element, processIdentifier: processIdentifier) |
| | | let raiseAfterResult = raiseWindowResult(host.element) |
| | | |
| | | let didFocusOrRaise = focusBeforeResult.success |
| | | || raiseBeforeResult.success |
| | | || focusAfterResult.success |
| | | || raiseAfterResult.success |
| | | 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": String(describing: focusAfterResult.error), |
| | | "raiseAfterAXError": String(describing: raiseAfterResult.error), |
| | | "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 hostCandidates = spaceCompatibleCandidates.isEmpty ? candidates : spaceCompatibleCandidates |
| | | let targetFrame = cgWindowFrame(for: window) ?? window.frame |
| | | 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 { |
| | | return best.metadata |
| | | } |
| | | |
| | | let secondBest = scoredHosts[1] |
| | | if best.score - secondBest.score >= 0.05 { |
| | | return best.metadata |
| | | } |
| | | } |
| | | |
| | | return hostCandidates.count == 1 ? hostCandidates[0] : 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 tabButton = finderTabButton( |
| | | 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 |
| | | ]) |
| | | return false |
| | | } |
| | | |
| | | 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) |
| | | ]) |
| | | guard pressResult == .success else { return false } |
| | | |
| | | for _ in 0..<10 { |
| | | Thread.sleep(forTimeInterval: 0.04) |
| | | let selectedTitle = normalizedWindowTitle( |
| | | axStringAttribute(kAXTitleAttribute as String, for: host.element) ?? "" |
| | | ) |
| | | if selectedTitle == 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? { |
| | | var visitedCount = 0 |
| | | |
| | | func search(_ element: AXUIElement, depth: Int, insideTabGroup: Bool) -> AXUIElement? { |
| | | 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 isTabControl = subrole == "AXTabButton" |
| | | || (insideTabGroup && (role == kAXRadioButtonRole as String || role == kAXButtonRole as String)) |
| | | |
| | | if normalizedWindowTitle(title ?? "") == targetTitle, |
| | | isTabControl { |
| | | return element |
| | | } |
| | | |
| | | guard isTabGroup || insideTabGroup || depth < 7 else { return nil } |
| | | for child in axChildren(of: element) { |
| | | if let match = search(child, depth: depth + 1, insideTabGroup: insideTabGroup || isTabGroup) { |
| | | return match |
| | | } |
| | | } |
| | | |
| | | return nil |
| | | } |
| | | |
| | | return search(root, depth: 0, insideTabGroup: false) |
| | | } |
| | | |
| | | 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 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 } |
| | | return (intersection.width * intersection.height) / denominator |
| | | } |
| | | |
| | | private func cgWindowFingerprint(for window: AlignerWindow) -> AXWindowFingerprint? { |
| | |
| | | } |
| | | |
| | | 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( |