| | |
| | | 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(), |
| | |
| | | : .activationFailed |
| | | } |
| | | |
| | | func activate(window: AlignerWindow) throws -> WindowActivationResult { |
| | | func activate(window requestedWindow: AlignerWindow) throws -> WindowActivationResult { |
| | | let window = refreshedFinderWindowForPreciseAction(requestedWindow, operation: "windowActivation") |
| | | DevelopmentDiagnostics.log("windowActivation.activate.start", [ |
| | | "windowID": window.id, |
| | | "identifierSource": String(describing: window.identifierSource), |
| | |
| | | : .appActivatedOnly |
| | | } |
| | | |
| | | func close(window: AlignerWindow) throws -> WindowCloseResult { |
| | | func close(window requestedWindow: AlignerWindow) throws -> WindowCloseResult { |
| | | let window = refreshedFinderWindowForPreciseAction(requestedWindow, operation: "windowClose") |
| | | DevelopmentDiagnostics.log("windowClose.window.start", [ |
| | | "windowID": window.id, |
| | | "identifierSource": String(describing: window.identifierSource), |
| | |
| | | processIdentifier: matchedAXWindow.processIdentifier, |
| | | attempt: "initial" |
| | | ) |
| | | } |
| | | |
| | | private func refreshedFinderWindowForPreciseAction( |
| | | _ window: AlignerWindow, |
| | | operation: String |
| | | ) -> AlignerWindow { |
| | | guard isFinderCGOnlyPageCandidate(window) else { return window } |
| | | |
| | | guard let refreshedWindow = try? allWindows().first(where: { refreshed in |
| | | refreshed.id == window.id |
| | | && refreshed.identifierSource == window.identifierSource |
| | | && refreshed.app.bundleIdentifier == window.app.bundleIdentifier |
| | | }) else { |
| | | DevelopmentDiagnostics.log("\(operation).finderRefresh.missing", [ |
| | | "windowID": window.id, |
| | | "spaceIDCount": window.spaceIDs.count, |
| | | "hasFrame": window.frame != nil |
| | | ]) |
| | | return window |
| | | } |
| | | |
| | | let mergedWindow = AlignerWindow( |
| | | id: window.id, |
| | | app: window.app.processIdentifier == nil ? refreshedWindow.app : window.app, |
| | | title: window.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty |
| | | ? refreshedWindow.title |
| | | : window.title, |
| | | identifierSource: window.identifierSource, |
| | | isMinimized: window.isMinimized || refreshedWindow.isMinimized, |
| | | isFullscreen: window.isFullscreen || refreshedWindow.isFullscreen, |
| | | isGhost: window.isGhost, |
| | | frame: window.frame ?? refreshedWindow.frame, |
| | | spaceIDs: window.spaceIDs.isEmpty ? refreshedWindow.spaceIDs : window.spaceIDs |
| | | ) |
| | | |
| | | if mergedWindow.spaceIDs != window.spaceIDs |
| | | || mergedWindow.isFullscreen != window.isFullscreen |
| | | || (window.frame == nil && refreshedWindow.frame != nil) { |
| | | DevelopmentDiagnostics.log("\(operation).finderRefresh.merged", [ |
| | | "windowID": window.id, |
| | | "originalSpaceIDCount": window.spaceIDs.count, |
| | | "refreshedSpaceIDCount": refreshedWindow.spaceIDs.count, |
| | | "mergedSpaceIDCount": mergedWindow.spaceIDs.count, |
| | | "originalIsFullscreen": window.isFullscreen, |
| | | "refreshedIsFullscreen": refreshedWindow.isFullscreen, |
| | | "mergedIsFullscreen": mergedWindow.isFullscreen, |
| | | "originalHasFrame": window.frame != nil, |
| | | "refreshedHasFrame": refreshedWindow.frame != nil, |
| | | "mergedHasFrame": mergedWindow.frame != nil |
| | | ]) |
| | | } |
| | | |
| | | return mergedWindow |
| | | } |
| | | |
| | | private func closeButton(for axWindow: AXWindowMetadata) -> AXUIElement? { |
| | |
| | | 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 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 |
| | | || raiseAfterResult.success |
| | | || (focusAfterResult?.success ?? false) |
| | | || (raiseAfterResult?.success ?? false) |
| | | let activated = selectedTab |
| | | && (hostPrivateActivationSucceeded || applicationActivated || didFocusOrRaise) |
| | | |
| | |
| | | "selectedTab": selectedTab, |
| | | "focusBeforeAXError": String(describing: focusBeforeResult.error), |
| | | "raiseBeforeAXError": String(describing: raiseBeforeResult.error), |
| | | "focusAfterAXError": String(describing: focusAfterResult.error), |
| | | "raiseAfterAXError": String(describing: raiseAfterResult.error), |
| | | "focusAfterAXError": focusAfterResult.map { String(describing: $0.error) } ?? "skippedAfterTabSelection", |
| | | "raiseAfterAXError": raiseAfterResult.map { String(describing: $0.error) } ?? "skippedAfterTabSelection", |
| | | "activated": activated |
| | | ]) |
| | | |
| | |
| | | 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 spaceScopedCandidates = spaceCompatibleCandidates.isEmpty ? candidates : spaceCompatibleCandidates |
| | | let selectableHostCandidates = spaceScopedCandidates.filter { metadata in |
| | | finderHostCanRepresentTarget(window, in: metadata) |
| | | } |
| | | |
| | | let hostCandidates: [AXWindowMetadata] |
| | | if targetSpaceIDs.isEmpty { |
| | | guard !selectableHostCandidates.isEmpty else { |
| | | DevelopmentDiagnostics.log("windowActivation.finderTab.noSelectableHost", [ |
| | | "windowID": window.id, |
| | | "candidateCount": candidates.count, |
| | | "targetTitleHash": DevelopmentDiagnostics.stableFingerprint(window.title), |
| | | "targetTitleLength": window.title.count |
| | | ]) |
| | | return nil |
| | | } |
| | | hostCandidates = selectableHostCandidates |
| | | } else { |
| | | 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 |
| | |
| | | } |
| | | } |
| | | |
| | | return hostCandidates.count == 1 ? hostCandidates[0] : nil |
| | | if hostCandidates.count == 1 { |
| | | 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( |
| | |
| | | return true |
| | | } |
| | | |
| | | guard let tabButton = finderTabButton( |
| | | guard let tabControl = finderTabControl( |
| | | matchingNormalizedTitle: normalizedTargetTitle, |
| | | in: host.element |
| | | ) else { |
| | |
| | | "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) |
| | |
| | | "tabButtonSubrole": tabButtonSubrole, |
| | | "axError": String(describing: pressResult) |
| | | ]) |
| | | guard pressResult == .success else { return false } |
| | | if pressResult == .success, |
| | | finderHostTitleMatchesTarget(host, normalizedTargetTitle: normalizedTargetTitle) { |
| | | return true |
| | | } |
| | | |
| | | for _ in 0..<10 { |
| | | Thread.sleep(forTimeInterval: 0.04) |
| | | let selectedTitle = normalizedWindowTitle( |
| | | axStringAttribute(kAXTitleAttribute as String, for: host.element) ?? "" |
| | | if let tabGroup = tabControl.tabGroup { |
| | | let selectedChildrenResult = AXUIElementSetAttributeValue( |
| | | tabGroup, |
| | | kAXSelectedChildrenAttribute as CFString, |
| | | [tabButton] as CFArray |
| | | ) |
| | | if selectedTitle == normalizedTargetTitle { |
| | | 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) ?? "" |
| | |
| | | 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, insideTabGroup: Bool) -> AXUIElement? { |
| | | func search(_ element: AXUIElement, depth: Int, tabGroup: AXUIElement?) -> FinderTabControl? { |
| | | visitedCount += 1 |
| | | guard visitedCount <= 240, depth <= 9 else { return nil } |
| | | |
| | |
| | | ?? 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" |
| | | || (insideTabGroup && (role == kAXRadioButtonRole as String || role == kAXButtonRole as String)) |
| | | || (currentTabGroup != nil && (role == kAXRadioButtonRole as String || role == kAXButtonRole as String)) |
| | | |
| | | if normalizedWindowTitle(title ?? "") == targetTitle, |
| | | isTabControl { |
| | | return element |
| | | return FinderTabControl(button: element, tabGroup: currentTabGroup) |
| | | } |
| | | |
| | | guard isTabGroup || insideTabGroup || depth < 7 else { return nil } |
| | | guard isTabGroup || currentTabGroup != nil || depth < 7 else { return nil } |
| | | for child in axChildren(of: element) { |
| | | if let match = search(child, depth: depth + 1, insideTabGroup: insideTabGroup || isTabGroup) { |
| | | if let match = search(child, depth: depth + 1, tabGroup: currentTabGroup) { |
| | | return match |
| | | } |
| | | } |
| | |
| | | return nil |
| | | } |
| | | |
| | | return search(root, depth: 0, insideTabGroup: false) |
| | | 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] { |
| | |
| | | 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, |
| | |
| | | |
| | | let denominator = min(lhs.width * lhs.height, rhs.width * rhs.height) |
| | | guard denominator > 0 else { return nil } |
| | | return (intersection.width * intersection.height) / denominator |
| | | 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? { |
| | |
| | | |
| | | private func axWindowGeometryFingerprint(for window: AlignerWindow) -> AXWindowGeometryFingerprint? { |
| | | guard let processIdentifier = window.app.processIdentifier else { return nil } |
| | | let frame = cgWindowFrame(for: window) ?? window.frame |
| | | let frame = matchingFrame(for: window) |
| | | return AXWindowGeometryFingerprint( |
| | | processIdentifier: processIdentifier, |
| | | title: normalizedWindowTitle(window.title), |
| | |
| | | } |
| | | |
| | | 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( |
| | |
| | | } |
| | | |
| | | 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 { |