fix: works without screen-recording permissions (closes #3819)

This commit is contained in:
lwouis committed 2024-11-10 13:10:20 +01:00
1 parent d430f835a4
commit f7de2bb6d9
7 files changed
+66 -9

No files matched your search

+48 -5
View File
@@ -1,17 +1,60 @@
import Foundation import Foundation
class MissionControl { class MissionControl {
static func isActive() -> Bool { private static var state_ = MissionControlState.inactive
// when Mission Control is active, the Dock process spawns some windows. We observe this side-effect and infer 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) { for window in CGWindow.windows(.optionOnScreenOnly) {
// ownerName == "Dock" && title == nil is a sign that Mission Control may be active // ownerName == "Dock" && title == nil is a sign that Mission Control may be active
if window.ownerName() == "Dock" && window.title() == nil if window.ownerName() == "Dock" && window.title() == nil
// layer == 500 can be a false positive when a user drags a file from a Dock folder // 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 // see https://github.com/lwouis/alt-tab-macos/issues/706
&& window.layer() != 500 { && window.layer() != 500 {
return true return true
} }
} }
return false 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)!)
} }
+8
View File
@@ -207,6 +207,14 @@ let kAXStatusLabelAttribute = "AXStatusLabel"
// for some reason, these attributes are missing from AXRoleConstants // for some reason, these attributes are missing from AXRoleConstants
let kAXDocumentWindowSubrole = "AXDocumentWindow" 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 { enum CGSSymbolicHotKey: Int, CaseIterable {
case commandTab = 1 case commandTab = 1
case commandShiftTab = 2 case commandShiftTab = 2
+1
View File
@@ -76,6 +76,7 @@ class ATShortcut {
} }
func executeAction(_ isARepeat: Bool) { func executeAction(_ isARepeat: Bool) {
logger.i(id)
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
guard let self = self else { return } guard let self = self else { return }
KeyRepeatTimer.isARepeat = isARepeat KeyRepeatTimer.isARepeat = isARepeat
+3
View File
@@ -47,6 +47,9 @@ class Applications {
static func addRunningApplications(_ runningApps: [NSRunningApplication]) { static func addRunningApplications(_ runningApps: [NSRunningApplication]) {
runningApps.forEach { runningApps.forEach {
if $0.bundleIdentifier == "com.apple.dock" {
MissionControl.observe($0.processIdentifier)
}
if isActualApplication($0) { if isActualApplication($0) {
Applications.list.append(Application($0)) Applications.list.append(Application($0))
} }
+2
View File
@@ -10,6 +10,7 @@ class BackgroundWork {
static var mouseEventsThread: BackgroundThreadWithRunLoop! static var mouseEventsThread: BackgroundThreadWithRunLoop!
static var systemPermissionsThread: BackgroundThreadWithRunLoop! static var systemPermissionsThread: BackgroundThreadWithRunLoop!
static var repeatingKeyThread: BackgroundThreadWithRunLoop! static var repeatingKeyThread: BackgroundThreadWithRunLoop!
static var missionControlThread: BackgroundThreadWithRunLoop!
// we cap concurrent tasks to .processorCount to avoid thread explosion on the .global queue // we cap concurrent tasks to .processorCount to avoid thread explosion on the .global queue
static let globalSemaphore = DispatchSemaphore(value: ProcessInfo.processInfo.processorCount) static let globalSemaphore = DispatchSemaphore(value: ProcessInfo.processInfo.processorCount)
@@ -25,6 +26,7 @@ class BackgroundWork {
accessibilityEventsThread = BackgroundThreadWithRunLoop("accessibilityEventsThread", .userInteractive) accessibilityEventsThread = BackgroundThreadWithRunLoop("accessibilityEventsThread", .userInteractive)
mouseEventsThread = BackgroundThreadWithRunLoop("mouseEventsThread", .userInteractive) mouseEventsThread = BackgroundThreadWithRunLoop("mouseEventsThread", .userInteractive)
repeatingKeyThread = BackgroundThreadWithRunLoop("repeatingKeyThread", .userInteractive) repeatingKeyThread = BackgroundThreadWithRunLoop("repeatingKeyThread", .userInteractive)
missionControlThread = BackgroundThreadWithRunLoop("missionControlThread", .userInteractive)
} }
static func startSystemPermissionThread() { static func startSystemPermissionThread() {
+1
View File
@@ -132,6 +132,7 @@ class KeyboardEvents {
@discardableResult @discardableResult
fileprivate func handleEvent(_ id: EventHotKeyID?, _ shortcutState: ShortcutState?, _ keyCode: UInt32?, _ modifiers: UInt32?, _ isARepeat: Bool) -> Bool { fileprivate func handleEvent(_ id: EventHotKeyID?, _ shortcutState: ShortcutState?, _ keyCode: UInt32?, _ modifiers: UInt32?, _ isARepeat: Bool) -> Bool {
logger.d(id, shortcutState, keyCode, modifiers, isARepeat)
var someShortcutTriggered = false var someShortcutTriggered = false
for shortcut in ControlsTab.shortcuts.values { for shortcut in ControlsTab.shortcuts.values {
if shortcut.matches(id, shortcutState, keyCode, modifiers, isARepeat) && shortcut.shouldTrigger() { if shortcut.matches(id, shortcutState, keyCode, modifiers, isARepeat) && shortcut.shouldTrigger() {
+3 -4
View File
@@ -229,7 +229,7 @@ class App: AppCenterApplication, NSApplicationDelegate {
func focusSelectedWindow(_ selectedWindow: Window?) { func focusSelectedWindow(_ selectedWindow: Window?) {
hideUi(true) hideUi(true)
if let window = selectedWindow, !MissionControl.isActive() { if let window = selectedWindow, MissionControl.state() == .inactive {
window.focus() window.focus()
} else { } else {
previewPanel.orderOut(nil) previewPanel.orderOut(nil)
@@ -269,12 +269,11 @@ class App: AppCenterApplication, NSApplicationDelegate {
} }
func showUiOrCycleSelection(_ shortcutIndex: Int) { func showUiOrCycleSelection(_ shortcutIndex: Int) {
logger.i("showUiOrCycleSelection") logger.d(shortcutIndex, self.shortcutIndex, isFirstSummon)
App.app.appIsBeingUsed = true App.app.appIsBeingUsed = true
if isFirstSummon || shortcutIndex != self.shortcutIndex { if isFirstSummon || shortcutIndex != self.shortcutIndex {
logger.i("showUiOrCycleSelection: isFirstSummon")
isFirstSummon = false 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? // TODO: can the CGS call inside detectTabbedWindows introduce latency when WindowServer is busy?
Windows.detectTabbedWindows() Windows.detectTabbedWindows()
// TODO: find a way to update space info when spaces are changed, instead of on every trigger // TODO: find a way to update space info when spaces are changed, instead of on every trigger