diff --git a/src/api-wrappers/MissionControl.swift b/src/api-wrappers/MissionControl.swift index 8731c75e..2832121d 100644 --- a/src/api-wrappers/MissionControl.swift +++ b/src/api-wrappers/MissionControl.swift @@ -1,17 +1,60 @@ import Foundation class MissionControl { - static func isActive() -> Bool { - // when Mission Control is active, the Dock process spawns some windows. We observe this side-effect and infer + private static var state_ = MissionControlState.inactive + private static var axObserver: AXObserver? + private static var axUiElement: AXUIElement? + + static func state() -> MissionControlState { + if #available(macOS 12.0, *) { + return state_ + } else { + return isActive() ? .showAllWindows : .inactive + } + } + + static func setState(_ state: MissionControlState) { + state_ = state + } + + // on macOS < 12, this is the way we used to guess if Mission Control is active + // on macOS >= 12, we listen to private notifications, which is accurate + // when Mission Control is active, the Dock process spawns some windows. We observe this side-effect and infer + private static func isActive() -> Bool { for window in CGWindow.windows(.optionOnScreenOnly) { // ownerName == "Dock" && title == nil is a sign that Mission Control may be active if window.ownerName() == "Dock" && window.title() == nil - // layer == 500 can be a false positive when a user drags a file from a Dock folder - // see https://github.com/lwouis/alt-tab-macos/issues/706 - && window.layer() != 500 { + // layer == 500 can be a false positive when a user drags a file from a Dock folder + // see https://github.com/lwouis/alt-tab-macos/issues/706 + && window.layer() != 500 { return true } } return false } + + static func observe(_ dockPid: pid_t) { + if #available(macOS 12.0, *) { + axUiElement = AXUIElementCreateApplication(dockPid) + AXObserverCreate(dockPid, axObserverCallbackDock, &axObserver) + // are we sure we always get a non-nil axObserver? + for notification in MissionControlState.allCases { + retryAxCallUntilTimeout { + try axUiElement!.subscribeToNotification(axObserver!, notification.rawValue, nil) + } + } + CFRunLoopAddSource(BackgroundWork.missionControlThread.runLoop, AXObserverGetRunLoopSource(axObserver!), .defaultMode) + } else { + // we could handle macOS < 12 here like yabai does. However, they poll with ax calls until they notice Mission Control stops + // this takes up ressources when Mission Control is open. If the user keeps it open for a few hours, this would accelerate battery usage + // SLSRegisterConnectionNotifyProc(g_connection, connection_handler, 1204, NULL); + // then listen every 0.1f * NSEC_PER_SEC for layer == 18 and owner = "Dock" + // when found, mission control is not active anymore + } + } +} + +fileprivate let axObserverCallbackDock: AXObserverCallback = { _, element, notificationName, _ in + logger.d(notificationName, element) + MissionControl.setState(MissionControlState(rawValue: notificationName as String)!) } diff --git a/src/api-wrappers/PrivateApis.swift b/src/api-wrappers/PrivateApis.swift index 454a9cf6..fd13128c 100644 --- a/src/api-wrappers/PrivateApis.swift +++ b/src/api-wrappers/PrivateApis.swift @@ -207,6 +207,14 @@ let kAXStatusLabelAttribute = "AXStatusLabel" // for some reason, these attributes are missing from AXRoleConstants let kAXDocumentWindowSubrole = "AXDocumentWindow" +// mission control private notifications +enum MissionControlState: String, CaseIterable { + case showAllWindows = "AXExposeShowAllWindows" + case showFrontWindows = "AXExposeShowFrontWindows" + case showDesktop = "AXExposeShowDesktop" + case inactive = "AXExposeExit" +} + enum CGSSymbolicHotKey: Int, CaseIterable { case commandTab = 1 case commandShiftTab = 2 diff --git a/src/logic/ATShortcut.swift b/src/logic/ATShortcut.swift index 0f2de543..464b3c79 100644 --- a/src/logic/ATShortcut.swift +++ b/src/logic/ATShortcut.swift @@ -76,6 +76,7 @@ class ATShortcut { } func executeAction(_ isARepeat: Bool) { + logger.i(id) DispatchQueue.main.async { [weak self] in guard let self = self else { return } KeyRepeatTimer.isARepeat = isARepeat diff --git a/src/logic/Applications.swift b/src/logic/Applications.swift index d5ef4dd2..cf29cfc6 100644 --- a/src/logic/Applications.swift +++ b/src/logic/Applications.swift @@ -47,6 +47,9 @@ class Applications { static func addRunningApplications(_ runningApps: [NSRunningApplication]) { runningApps.forEach { + if $0.bundleIdentifier == "com.apple.dock" { + MissionControl.observe($0.processIdentifier) + } if isActualApplication($0) { Applications.list.append(Application($0)) } diff --git a/src/logic/BackgroundWork.swift b/src/logic/BackgroundWork.swift index e9bb877c..2a556573 100644 --- a/src/logic/BackgroundWork.swift +++ b/src/logic/BackgroundWork.swift @@ -10,6 +10,7 @@ class BackgroundWork { static var mouseEventsThread: BackgroundThreadWithRunLoop! static var systemPermissionsThread: BackgroundThreadWithRunLoop! static var repeatingKeyThread: BackgroundThreadWithRunLoop! + static var missionControlThread: BackgroundThreadWithRunLoop! // we cap concurrent tasks to .processorCount to avoid thread explosion on the .global queue static let globalSemaphore = DispatchSemaphore(value: ProcessInfo.processInfo.processorCount) @@ -25,6 +26,7 @@ class BackgroundWork { accessibilityEventsThread = BackgroundThreadWithRunLoop("accessibilityEventsThread", .userInteractive) mouseEventsThread = BackgroundThreadWithRunLoop("mouseEventsThread", .userInteractive) repeatingKeyThread = BackgroundThreadWithRunLoop("repeatingKeyThread", .userInteractive) + missionControlThread = BackgroundThreadWithRunLoop("missionControlThread", .userInteractive) } static func startSystemPermissionThread() { diff --git a/src/logic/events/KeyboardEvents.swift b/src/logic/events/KeyboardEvents.swift index 90a6d357..ccf430da 100644 --- a/src/logic/events/KeyboardEvents.swift +++ b/src/logic/events/KeyboardEvents.swift @@ -132,6 +132,7 @@ class KeyboardEvents { @discardableResult fileprivate func handleEvent(_ id: EventHotKeyID?, _ shortcutState: ShortcutState?, _ keyCode: UInt32?, _ modifiers: UInt32?, _ isARepeat: Bool) -> Bool { + logger.d(id, shortcutState, keyCode, modifiers, isARepeat) var someShortcutTriggered = false for shortcut in ControlsTab.shortcuts.values { if shortcut.matches(id, shortcutState, keyCode, modifiers, isARepeat) && shortcut.shouldTrigger() { diff --git a/src/ui/App.swift b/src/ui/App.swift index 15d9dffb..bb579b7b 100644 --- a/src/ui/App.swift +++ b/src/ui/App.swift @@ -229,7 +229,7 @@ class App: AppCenterApplication, NSApplicationDelegate { func focusSelectedWindow(_ selectedWindow: Window?) { hideUi(true) - if let window = selectedWindow, !MissionControl.isActive() { + if let window = selectedWindow, MissionControl.state() == .inactive { window.focus() } else { previewPanel.orderOut(nil) @@ -269,12 +269,11 @@ class App: AppCenterApplication, NSApplicationDelegate { } func showUiOrCycleSelection(_ shortcutIndex: Int) { - logger.i("showUiOrCycleSelection") + logger.d(shortcutIndex, self.shortcutIndex, isFirstSummon) App.app.appIsBeingUsed = true if isFirstSummon || shortcutIndex != self.shortcutIndex { - logger.i("showUiOrCycleSelection: isFirstSummon") isFirstSummon = false - if Windows.list.count == 0 || MissionControl.isActive() { hideUi(); return } + if Windows.list.count == 0 || MissionControl.state() != .inactive { hideUi(); return } // TODO: can the CGS call inside detectTabbedWindows introduce latency when WindowServer is busy? Windows.detectTabbedWindows() // TODO: find a way to update space info when spaces are changed, instead of on every trigger