fix: focusing windows could fail after focusing a frozen app (#4520)

also generally reworked and improved background work handling
This commit is contained in:
lwouis committed 2025-08-29 10:00:43 +02:00
1 parent 3f18951368
commit 591ce5274f
12 files changed
+245 -141

No files matched your search

+89 -66
View File
@@ -7,73 +7,75 @@ import ApplicationServices.HIServices.AXAttributeConstants
import ApplicationServices.HIServices.AXActionConstants
extension AXUIElement {
static let globalTimeoutInSeconds = Float(120)
// default timeout for AX calls is 6s
// we reduce to 1s to avoid AX calls blocking threads, thus too many threads getting created to make the next AX calls
static let globalMessagingTimeoutInSeconds = Float(1)
// we retry for 2 minutes at most
static let retryAXCallQuickQueueTimeoutInSeconds = Float(6)
static let retryAXCallSlowQueueTimeoutInSeconds = Float(120)
// 250ms is similar to human delay in processing changes on screen
// See https://humanbenchmark.com/tests/reactiontime
static let retryDelayInMilliseconds = 250
static let retryDelayInMilliseconds = DispatchTimeInterval.milliseconds(250)
// default timeout for AX calls is 6s. We increase it in order to avoid retrying every 6s, thus saving resources
static func setGlobalTimeout() {
// we add 5s to make sure to not do an extra retry
AXUIElementSetMessagingTimeout(AXUIElementCreateSystemWide(), globalTimeoutInSeconds + 5)
AXUIElementSetMessagingTimeout(AXUIElementCreateSystemWide(), globalMessagingTimeoutInSeconds)
}
static var windowResizedOrMovedMap = DebounceMap()
static var windowTitleChangedMap = DebounceMap()
typealias DebounceMap = [CGWindowID: DispatchWorkItem]
enum DebounceType {
case windowResizedOrMoved
case windowTitleChanged
}
private static var debounceMap = DebounceMap()
/// if the window server is busy, it may not reply to AX calls. We retry right before the call times-out and returns a bogus value
static func retryAxCallUntilTimeout(after: DispatchTime = .now(), timeoutInSeconds: Double = Double(AXUIElement.globalTimeoutInSeconds), execute: @escaping () throws -> Void) {
BackgroundWork.axCallsQueue.asyncAfter(deadline: after) {
retryAxCallUntilTimeout_(timeoutInSeconds: timeoutInSeconds, after: after, execute: execute)
}
}
static func retryAxCallUntilTimeoutDebounced(_ debounceType: DebounceType, _ wid: CGWindowID, execute: @escaping () throws -> Void) {
let workItem = DispatchWorkItem {
retryAxCallUntilTimeout_(timeoutInSeconds: Double(globalTimeoutInSeconds), execute: execute)
}
// use .barrier to avoid accessing the DebounceMaps concurrently
BackgroundWork.axCallsQueue.async(flags: .barrier) {
switch debounceType {
case .windowResizedOrMoved:
windowResizedOrMovedMap[wid]?.cancel()
windowResizedOrMovedMap[wid] = workItem
case .windowTitleChanged:
windowTitleChangedMap[wid]?.cancel()
windowTitleChangedMap[wid] = workItem
static func retryAxCallUntilTimeout(file: String = #file, function: String = #function, line: Int = #line, context: String = "", after: DispatchTime? = nil, debounceType: DebounceType? = nil, wid: CGWindowID? = nil, slowQueue: Bool = false, startTimeInNanoseconds: UInt64 = DispatchTime.now().uptimeNanoseconds, block: @escaping () throws -> Void) {
let closure = { retryAxCallUntilTimeout_(file: file, function: function, line: line, context: context, after: after, debounceType: debounceType, wid: wid, slowQueue: slowQueue, startTimeInNanoseconds: startTimeInNanoseconds, block: block) }
let queue = slowQueue ? BackgroundWork.axCallsSlowQueue : BackgroundWork.axCallsQuickQueue
if let after {
queue!.addOperationAfter(deadline: after, block: closure)
} else if let debounceType, let wid {
let debounceMapKey = "\(debounceType.rawValue)\(wid)"
let workItem = DispatchWorkItem {
closure()
debounceMap.withLock { $0[debounceMapKey] = nil }
}
}
BackgroundWork.axCallsQueue.asyncAfter(deadline: .now() + .milliseconds(retryDelayInMilliseconds), execute: workItem)
}
private static func retryAxCallUntilTimeout_(timeoutInSeconds: Double, after: DispatchTime = DispatchTime.now(), execute: @escaping () throws -> Void) {
do {
try execute()
} catch {
let timePassedInSeconds = Double(DispatchTime.now().uptimeNanoseconds - after.uptimeNanoseconds) / 1_000_000_000
if timePassedInSeconds < timeoutInSeconds {
BackgroundWork.axCallsQueue.asyncAfter(deadline: .now() + .milliseconds(retryDelayInMilliseconds)) {
retryAxCallUntilTimeout_(timeoutInSeconds: timeoutInSeconds, after: after, execute: execute)
}
debounceMap.withLock {
$0[debounceMapKey]?.cancel()
$0[debounceMapKey] = workItem
}
queue!.addOperationAfter(deadline: .now() + retryDelayInMilliseconds) {
workItem.perform()
}
} else {
queue!.addOperation(closure)
}
}
func axCallWhichCanThrow<T>(_ result: AXError, _ successValue: inout T) throws -> T? {
switch result {
case .success: return successValue
// .cannotComplete can happen if the app is unresponsive; we throw in that case to retry until the call succeeds
case .cannotComplete: throw AxError.runtimeError
// for other errors it's pointless to retry
default: return nil
private static func retryAxCallUntilTimeout_(file: String, function: String, line: Int, context: String, after: DispatchTime?, debounceType: DebounceType?, wid: CGWindowID?, slowQueue: Bool, startTimeInNanoseconds: UInt64, block: @escaping () throws -> Void) {
if (try? block()) != nil {
return
}
let timePassedInSeconds = Float(DispatchTime.now().uptimeNanoseconds - startTimeInNanoseconds) / 1_000_000_000
if timePassedInSeconds >= retryAXCallSlowQueueTimeoutInSeconds {
Logger.info("AX call failed for more than \(Int(retryAXCallSlowQueueTimeoutInSeconds))s. Giving up on it", slowQueue, logFromContext(file, function, line, context))
return
}
if timePassedInSeconds >= retryAXCallQuickQueueTimeoutInSeconds {
Logger.info("AX call failed for more than \(Int(retryAXCallQuickQueueTimeoutInSeconds))s. Retrying on to the slow queue in 1s", slowQueue, logFromContext(file, function, line, context))
retryAxCallUntilTimeout(file: file, function: function, line: line, context: context, after: .now() + .seconds(1), debounceType: debounceType, wid: wid, slowQueue: true, startTimeInNanoseconds: startTimeInNanoseconds, block: block)
return
}
Logger.info("AX call failed. Retrying on the quick queue in \(retryDelayInMilliseconds.toMilliseconds)ms", slowQueue, logFromContext(file, function, line, context))
retryAxCallUntilTimeout(file: file, function: function, line: line, context: context, after: .now() + retryDelayInMilliseconds, debounceType: debounceType, wid: wid, slowQueue: false, startTimeInNanoseconds: startTimeInNanoseconds, block: block)
}
private static func logFromContext(_ file: String, _ function: String, _ line: Int, _ context: String) -> String {
return "Context: \((file as NSString).lastPathComponent):\(line) \(function) \(context)"
}
func throwIfNotSuccess(_ result: AXError) throws -> Void {
// .cannotComplete can happen if the app is unresponsive
if result == .cannotComplete {
throw AxError.runtimeError
}
// for success or other errors we don't throw
}
// periphery:ignore
@@ -85,19 +87,22 @@ extension AXUIElement {
return bytePtr?.withMemoryRebound(to: AXUIElementID.self, capacity: 1) { $0.pointee }
}
func cgWindowId() throws -> CGWindowID? {
func cgWindowId() throws -> CGWindowID {
var id = CGWindowID(0)
return try axCallWhichCanThrow(_AXUIElementGetWindow(self, &id), &id)
try throwIfNotSuccess(_AXUIElementGetWindow(self, &id))
return id
}
func pid() throws -> pid_t? {
func pid() throws -> pid_t {
var pid = pid_t(0)
return try axCallWhichCanThrow(AXUIElementGetPid(self, &pid), &pid)
try throwIfNotSuccess(AXUIElementGetPid(self, &pid))
return pid
}
func attribute<T>(_ key: String, _ _: T.Type) throws -> T? {
var value: AnyObject?
return try axCallWhichCanThrow(AXUIElementCopyAttributeValue(self, key as CFString, &value), &value) as? T
var attributeValue: AnyObject?
try throwIfNotSuccess(AXUIElementCopyAttributeValue(self, key as CFString, &attributeValue))
return attributeValue as? T
}
func windowAttributes() throws -> (String?, String?, String?, Bool, Bool)? {
@@ -109,7 +114,8 @@ extension AXUIElement {
kAXFullscreenAttribute,
]
var values: CFArray?
if let array = ((try axCallWhichCanThrow(AXUIElementCopyMultipleAttributeValues(self, attributes as CFArray, [], &values), &values)) as? Array<Any>) {
try throwIfNotSuccess(AXUIElementCopyMultipleAttributeValues(self, attributes as CFArray, [], &values))
if let array = (values as? Array<Any>) {
return (
array[0] as? String,
array[1] as? String,
@@ -401,16 +407,16 @@ extension AXUIElement {
return (app.bundleIdentifier?.hasPrefix("com.autodesk.AutoCAD") ?? false) && subrole == kAXDocumentWindowSubrole
}
func focusWindow() {
performAction(kAXRaiseAction)
func focusWindow() throws {
try performAction(kAXRaiseAction)
}
func setAttribute(_ key: String, _ value: Any) {
AXUIElementSetAttributeValue(self, key as CFString, value as CFTypeRef)
func setAttribute(_ key: String, _ value: Any) throws {
try throwIfNotSuccess(AXUIElementSetAttributeValue(self, key as CFString, value as CFTypeRef))
}
func performAction(_ action: String) {
AXUIElementPerformAction(self, action as CFString)
func performAction(_ action: String) throws {
try throwIfNotSuccess(AXUIElementPerformAction(self, action as CFString))
}
func subscribeToNotification(_ axObserver: AXObserver, _ notification: String, _ callback: (() -> Void)? = nil) throws {
@@ -432,3 +438,20 @@ enum AxError: Error {
/// this means that long-lived apps (e.g. Finder) may have high IDs
/// we don't know how high it can go, and if it wraps around
typealias AXUIElementID = UInt64
enum DebounceType: Int {
case windowResizedOrMoved = 0
case windowTitleChanged = 1
}
class DebounceMap {
typealias DebounceMapType = [String: DispatchWorkItem]
private var map: DebounceMapType = [:]
private let lock = NSLock()
func withLock<T>(_ block: (inout DebounceMapType) -> T) -> T {
lock.lock()
defer { lock.unlock() }
return block(&map)
}
}
+12
View File
@@ -312,3 +312,15 @@ extension Optional {
if let self { return self } else { throw Error.unexpectedNil }
}
}
extension DispatchTimeInterval {
var toMilliseconds: Int {
switch self {
case .seconds(let s): return s / 1000
case .milliseconds(let ms): return ms
case .microseconds(let us): return us * 1000
case .nanoseconds(let ns): return ns * 1_000_000
default: return .max
}
}
}
+1 -1
View File
@@ -12,7 +12,7 @@ class IOKitPrototype {
kIOHIDDeviceUsageKey: kHIDUsage_GD_Keyboard
] as CFDictionary)
IOHIDManagerRegisterInputValueCallback(manager, keyboardEventHandler, nil)
IOHIDManagerScheduleWithRunLoop(manager, BackgroundWork.keyboardEventsThread.runLoop!, CFRunLoopMode.commonModes.rawValue)
IOHIDManagerScheduleWithRunLoop(manager, BackgroundWork.keyboardAndTrackpadEventsThread.runLoop!, CFRunLoopMode.commonModes.rawValue)
IOHIDManagerOpen(manager, IOOptionBits(kIOHIDOptionsTypeNone))
}
}
+1 -1
View File
@@ -29,7 +29,7 @@ class AppCenterCrash: NSObject {
initNecessaryFacilities()
let shouldSend = checkIfShouldSend()
BackgroundWork.startCrashReportsQueue()
BackgroundWork.crashReportsQueue.async {
BackgroundWork.crashReportsQueue.addOperation {
AppCenter.networkRequestsAllowed = shouldSend
Crashes.notify(with: shouldSend ? .send : .dontSend)
AppCenter.networkRequestsAllowed = false
+4 -4
View File
@@ -68,13 +68,13 @@ class Application: NSObject {
}
func manuallyUpdateWindows() {
AXUIElement.retryAxCallUntilTimeout(timeoutInSeconds: 5) { [weak self] in
AXUIElement.retryAxCallUntilTimeout(context: "\(bundleIdentifier ?? bundleURL?.absoluteString ?? localizedName ?? String(describing: pid))") { [weak self] in
guard let self else { return }
var atLeastOneActualWindow = false
guard let axWindows = try self.axUiElement?.allWindows(self.pid) else { return }
for axWindow in axWindows {
if let wid = try axWindow.cgWindowId(),
let (title, role, subrole, isMinimized, isFullscreen) = try axWindow.windowAttributes() {
let wid = try axWindow.cgWindowId()
if let (title, role, subrole, isMinimized, isFullscreen) = try axWindow.windowAttributes() {
let size = try axWindow.size()
let level = wid.level()
if AXUIElement.isActualWindow(self, wid, level, title, subrole, role, size) {
@@ -170,7 +170,7 @@ class Application: NSObject {
kAXApplicationHiddenNotification,
kAXApplicationShownNotification,
] {
AXUIElement.retryAxCallUntilTimeout { [weak self] in
AXUIElement.retryAxCallUntilTimeout(context: "\(bundleIdentifier ?? bundleURL?.absoluteString ?? localizedName ?? String(describing: pid))") { [weak self] in
guard let self else { return }
try self.axUiElement!.subscribeToNotification(axObserver, notification, {
DispatchQueue.main.async { [weak self] in
+2 -1
View File
@@ -20,7 +20,8 @@ class Applications {
if bundleIdentifier == "com.apple.dock" {
DockEvents.observe(processIdentifier)
}
if isActualApplication(processIdentifier, bundleIdentifier) {
// com.apple.universalcontrol always fails subscribeToNotification. We blacklist it to save resources on everyone's machines
if bundleIdentifier != "com.apple.universalcontrol" && isActualApplication(processIdentifier, bundleIdentifier) {
Applications.list.append(Application($0))
}
}
+94 -23
View File
@@ -2,44 +2,51 @@ import Foundation
// queues and dedicated threads to observe background events such as keyboard inputs, or accessibility events
class BackgroundWork {
static var screenshotsQueue: DispatchQueue!
static var accessibilityCommandsQueue: DispatchQueue!
static var axCallsQueue: DispatchQueue!
static var crashReportsQueue: DispatchQueue!
// we use an OperationQueue tied to the global DispatchQueue to call blocking APIs in parallel
static var screenshotsQueue: LabeledOperationQueue!
static var accessibilityCommandsQueue: LabeledOperationQueue!
static var axCallsQuickQueue: LabeledOperationQueue!
static var axCallsSlowQueue: LabeledOperationQueue!
static var crashReportsQueue: LabeledOperationQueue!
// we use Threads to observe events in sequence
static var accessibilityEventsThread: BackgroundThreadWithRunLoop!
static var keyboardEventsThread: BackgroundThreadWithRunLoop!
static var keyboardAndTrackpadEventsThread: BackgroundThreadWithRunLoop!
static var systemPermissionsThread: BackgroundThreadWithRunLoop!
static var repeatingKeyThread: BackgroundThreadWithRunLoop!
static var missionControlThread: BackgroundThreadWithRunLoop!
static var cliEventsThread: BackgroundThreadWithRunLoop!
// swift static variables are lazy; we artificially force the threads to init
private static var totalPotentialThreadCount = 0
static func start() {
// screenshots are taken on a serial DispatchQueue. They used to be taken on the .global() concurrent queue.
// it could hand the app the screenshot OS calls are slow. It would hang or crash with this error:
// it could hang the app since the screenshot OS calls are slow. It would hang or crash with this error:
// >Processes reached dispatch thread soft limit (64)
screenshotsQueue = DispatchQueue.queue("screenshotsQueue", .userInteractive, false)
// calls to act on windows (e.g. AXUIElementSetAttributeValue, AXUIElementPerformAction) are done off the main thread
accessibilityCommandsQueue = DispatchQueue.queue("accessibilityCommandsQueue", .userInteractive, false)
screenshotsQueue = LabeledOperationQueue("screenshotsQueue", .userInteractive, 8)
// calls to focus/close/minimize/etc windows
// They are tried once and if they timeout we don't retry. The OS seems to still execute them even if the call timed out
accessibilityCommandsQueue = LabeledOperationQueue("accessibilityCommandsQueue", .userInteractive, 4)
// calls to the AX APIs can block for a long time (e.g. if an app is unresponsive)
// We can't use a serial queue. We use the global concurrent queue
axCallsQueue = DispatchQueue.queue("axCallsQueue", .userInteractive, true)
// We first try the AX calls on the Quick queue. If we get a timeout, we move to the Slow queue and retry for a while there
axCallsQuickQueue = LabeledOperationQueue("axCallsQuickQueue", .userInteractive, 8)
axCallsSlowQueue = LabeledOperationQueue("axCallsSlowQueue", .userInteractive, 8)
// we observe app and windows notifications. They arrive on this thread, and are handled off the main thread initially
accessibilityEventsThread = BackgroundThreadWithRunLoop("accessibilityEventsThread", .userInteractive)
// we listen to as any keyboard events as possible on a background thread, as it's more available/reliable than the main thread
keyboardEventsThread = BackgroundThreadWithRunLoop("keyboardEventsThread", .userInteractive)
keyboardAndTrackpadEventsThread = BackgroundThreadWithRunLoop("keyboardAndTrackpadEventsThread", .userInteractive)
// we time key repeat on a background thread for precision. We handle their consequence on the main-thread
repeatingKeyThread = BackgroundThreadWithRunLoop("repeatingKeyThread", .userInteractive)
// we main Mission Control state on a background thread. We protect reads from main-thread with an NSLock
missionControlThread = BackgroundThreadWithRunLoop("missionControlThread", .userInteractive)
// we listen to CLI commands (CFMessagePort events)
cliEventsThread = BackgroundThreadWithRunLoop("cliEventsThread", .userInteractive)
// logThreadsAndQueuesOnRepeat()
}
static func startCrashReportsQueue() {
if crashReportsQueue == nil {
// crash reports can be sent off the main thread
crashReportsQueue = DispatchQueue.queue("crashReportsQueue", .utility, false)
crashReportsQueue = LabeledOperationQueue("crashReportsQueue", .utility, 1)
}
}
@@ -48,12 +55,65 @@ class BackgroundWork {
systemPermissionsThread = BackgroundThreadWithRunLoop("systemPermissionsThread", .utility)
}
static func addPotentialThreadCount(_ additionalCount: Int) {
totalPotentialThreadCount += additionalCount
// a macos process has a soft limit of 64 threads. We need to be careful to don't spawn too many threads through DispatchQueues
assert(totalPotentialThreadCount <= 36)
}
// useful during development to inspect how many threads are used by AltTab
private static func logThreadsAndQueuesOnRepeat() {
Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
logThreads()
logQueues()
}
}
private static func logQueues() -> Void {
let queues = [screenshotsQueue, accessibilityCommandsQueue, axCallsQuickQueue, axCallsSlowQueue, crashReportsQueue].compactMap { $0 }
var map = [String:Int]()
for queue in queues {
map[queue.underlyingQueue!.label] = queue.operations.reduce(0) { $1.isExecuting ? $0 + 1 : $0 }
}
let prettyPrintMap = map.sorted { $0.key < $1.key }.map { "\($0.key): \($0.value)" }.joined(separator: ", ")
debugPrint(prettyPrintMap)
}
private static func logThreads() -> Void {
var count: mach_msg_type_number_t = 0
var threadList: thread_act_array_t?
let kr = task_threads(mach_task_self_, &threadList, &count)
guard kr == KERN_SUCCESS, let threads = threadList else { return }
var namedThreads = [String]()
var unnamedThreadsCount = 0
for i in 0..<Int(count) {
let thread = threads[i]
if let pth = pthread_from_mach_thread_np(thread) {
var nameBuffer = [CChar](repeating: 0, count: 64)
if pthread_getname_np(pth, &nameBuffer, nameBuffer.count) == 0 {
let name = String(cString: nameBuffer)
if !name.isEmpty {
namedThreads.append(name)
} else {
unnamedThreadsCount += 1
}
}
}
}
vm_deallocate(mach_task_self_,
vm_address_t(bitPattern: threads),
vm_size_t(count) * vm_size_t(MemoryLayout<thread_t>.size))
Logger.info(namedThreads.count, "Named threads:", namedThreads.sorted())
Logger.info(unnamedThreadsCount, "Unnamed threads (e.g. from GCD queues)")
}
class BackgroundThreadWithRunLoop: Thread {
var runLoop: CFRunLoop?
// Thread.start() is async; we use a semaphore to make the init() sync
private let threadStartSemaphore = DispatchSemaphore(value: 0)
init(_ name: String, _ qos: DispatchQoS) {
addPotentialThreadCount(1)
super.init()
self.name = name
qualityOfService = qos.toQualityOfService()
@@ -79,16 +139,27 @@ class BackgroundWork {
CFRunLoopAddSource(runLoop, source, .commonModes)
}
}
}
extension DispatchQueue {
static func queue(_ label: String, _ qos: DispatchQoS, _ globalParallel: Bool) -> DispatchQueue {
if globalParallel {
// label is not reflected in Instruments because the target is .global
// if we want to see our custom labels, we need our private queue.
// However, we want to be efficient and use the OS thread pool, so we use .global
return DispatchQueue(label: label, attributes: [.concurrent], target: .global(qos: qos.qosClass))
class LabeledOperationQueue: OperationQueue {
private let strongUnderlyingQueue: DispatchQueue
init(_ label: String, _ qos: DispatchQoS, _ maxConcurrentOperationCount: Int) {
strongUnderlyingQueue = DispatchQueue(label: label, attributes: [.concurrent])
super.init()
self.maxConcurrentOperationCount = maxConcurrentOperationCount
addPotentialThreadCount(maxConcurrentOperationCount)
self.underlyingQueue = strongUnderlyingQueue
}
override func addOperation(_ block: @escaping () -> ()) {
Logger.debug("task", strongUnderlyingQueue.label)
super.addOperation(block)
}
func addOperationAfter(deadline: DispatchTime, block: @escaping @convention(block) () -> Void) {
strongUnderlyingQueue.asyncAfter(deadline: deadline) { [weak self] in
self?.addOperation(block)
}
}
return DispatchQueue(label: label, qos: qos)
}
}
+14 -14
View File
@@ -67,7 +67,7 @@ class Window {
/// some apps will not trigger AXApplicationActivated, where we usually update application.focusedWindow
/// workaround: we check and possibly do it here
func checkIfFocused(_ application: Application, _ wid: CGWindowID) {
AXUIElement.retryAxCallUntilTimeout {
AXUIElement.retryAxCallUntilTimeout(context: "app:\(application.bundleIdentifier ?? application.bundleURL?.absoluteString ?? application.localizedName ?? String(application.pid)) wid:\(wid) title:\(title ?? "")") {
let focusedWid = try application.axUiElement?.focusedWindow()?.cgWindowId()
if wid == focusedWid {
application.focusedWindow = self
@@ -85,7 +85,7 @@ class Window {
AXObserverCreate(application.pid, axObserverCallback, &axObserver)
guard let axObserver else { return }
for notification in Window.notifications {
AXUIElement.retryAxCallUntilTimeout { [weak self] in
AXUIElement.retryAxCallUntilTimeout(context: "app:\(application.bundleIdentifier ?? application.bundleURL?.absoluteString ?? application.localizedName ?? String(application.pid)) wid:\(String(describing: cgWindowId)) title:\(title ?? "")") { [weak self] in
guard let self else { return }
try self.axUiElement!.subscribeToNotification(axObserver, notification, nil)
}
@@ -115,13 +115,13 @@ class Window {
NSSound.beep()
return
}
BackgroundWork.accessibilityCommandsQueue.async { [weak self] in
BackgroundWork.accessibilityCommandsQueue.addOperation { [weak self] in
guard let self else { return }
if self.isFullscreen {
self.axUiElement!.setAttribute(kAXFullscreenAttribute, false)
try? self.axUiElement!.setAttribute(kAXFullscreenAttribute, false)
}
if let closeButton_ = try? self.axUiElement!.closeButton() {
closeButton_.performAction(kAXPressAction)
try? closeButton_.performAction(kAXPressAction)
}
}
}
@@ -135,17 +135,17 @@ class Window {
NSSound.beep()
return
}
BackgroundWork.accessibilityCommandsQueue.async { [weak self] in
BackgroundWork.accessibilityCommandsQueue.addOperation { [weak self] in
guard let self else { return }
if self.isFullscreen {
self.axUiElement!.setAttribute(kAXFullscreenAttribute, false)
try? self.axUiElement!.setAttribute(kAXFullscreenAttribute, false)
// minimizing is ignored if sent immediatly; we wait for the de-fullscreen animation to be over
BackgroundWork.accessibilityCommandsQueue.asyncAfter(deadline: .now() + .seconds(1)) { [weak self] in
BackgroundWork.accessibilityCommandsQueue.addOperationAfter(deadline: .now() + .seconds(1)) { [weak self] in
guard let self else { return }
self.axUiElement!.setAttribute(kAXMinimizedAttribute, true)
try? self.axUiElement!.setAttribute(kAXMinimizedAttribute, true)
}
} else {
self.axUiElement!.setAttribute(kAXMinimizedAttribute, !self.isMinimized)
try? self.axUiElement!.setAttribute(kAXMinimizedAttribute, !self.isMinimized)
}
}
}
@@ -155,9 +155,9 @@ class Window {
NSSound.beep()
return
}
BackgroundWork.accessibilityCommandsQueue.async { [weak self] in
BackgroundWork.accessibilityCommandsQueue.addOperation { [weak self] in
guard let self else { return }
self.axUiElement!.setAttribute(kAXFullscreenAttribute, !self.isFullscreen)
try? self.axUiElement!.setAttribute(kAXFullscreenAttribute, !self.isFullscreen)
}
}
@@ -180,13 +180,13 @@ class Window {
// macOS bug: when switching to a System Preferences window in another space, it switches to that space,
// but quickly switches back to another window in that space
// You can reproduce this buggy behaviour by clicking on the dock icon, proving it's an OS bug
BackgroundWork.accessibilityCommandsQueue.async { [weak self] in
BackgroundWork.accessibilityCommandsQueue.addOperation { [weak self] in
guard let self else { return }
var psn = ProcessSerialNumber()
GetProcessForPID(self.application.pid, &psn)
_SLPSSetFrontProcessWithOptions(&psn, self.cgWindowId!, SLPSMode.userGenerated.rawValue)
self.makeKeyWindow(&psn)
self.axUiElement!.focusWindow()
try? self.axUiElement!.focusWindow()
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(50)) {
Windows.previewFocusedWindowIfNeeded()
}
+2 -2
View File
@@ -74,7 +74,7 @@ class Windows {
static func updateIsFullscreenOnCurrentSpace() {
let windowsOnCurrentSpace = Windows.list.filter { !$0.isWindowlessApp }
for window in windowsOnCurrentSpace {
AXUIElement.retryAxCallUntilTimeout(after: .now() + .milliseconds(AXUIElement.retryDelayInMilliseconds)) { [weak window] in
AXUIElement.retryAxCallUntilTimeout(context: "title:\(window.title ?? "") wid:\(String(describing: window.cgWindowId))", after: .now() + AXUIElement.retryDelayInMilliseconds) { [weak window] in
guard let window else { return }
try updateWindowSizeAndPositionAndFullscreen(window.axUiElement!, window.cgWindowId!, window)
}
@@ -327,7 +327,7 @@ class Windows {
private static func screenshotEligibleWindowsAndRefreshUi(_ eligibleWindows: [Window], _ source: RefreshCausedBy) {
for window in eligibleWindows {
BackgroundWork.screenshotsQueue.async { [weak window] in
BackgroundWork.screenshotsQueue.addOperation { [weak window] in
if source == .refreshOnlyThumbnailsAfterShowUi && !App.app.appIsBeingUsed { return }
if let wid = window?.cgWindowId, let cgImage = wid.screenshot() {
if source == .refreshOnlyThumbnailsAfterShowUi && !App.app.appIsBeingUsed { return }
+24 -27
View File
@@ -10,8 +10,8 @@ let axObserverCallback: AXObserverCallback = { _, element, notificationName, _ i
fileprivate func handleEvent(_ type: String, _ element: AXUIElement) throws {
// events are handled concurrently, thus we check that the app is still running
if let pid = try element.pid(),
try pid != ProcessInfo.processInfo.processIdentifier || (element.subrole() != kAXUnknownSubrole) {
let pid = try element.pid()
if try pid != ProcessInfo.processInfo.processIdentifier || (element.subrole() != kAXUnknownSubrole) {
Logger.info(type, pid, try element.title() ?? "nil")
switch type {
case kAXApplicationActivatedNotification: try applicationActivated(element, pid)
@@ -65,8 +65,8 @@ fileprivate func applicationHiddenOrShown(_ pid: pid_t, _ type: String) throws {
}
fileprivate func windowCreated(_ element: AXUIElement, _ pid: pid_t) throws {
if let wid = try element.cgWindowId(),
let (title, role, subrole, isMinimized, isFullscreen) = try element.windowAttributes() {
let wid = try element.cgWindowId()
if let (title, role, subrole, isMinimized, isFullscreen) = try element.windowAttributes() {
let position = try element.position()
let size = try element.size()
let level = wid.level()
@@ -85,8 +85,8 @@ fileprivate func windowCreated(_ element: AXUIElement, _ pid: pid_t) throws {
}
fileprivate func focusedWindowChanged(_ element: AXUIElement, _ pid: pid_t) throws {
if let wid = try element.cgWindowId(),
let runningApp = NSRunningApplication(processIdentifier: pid) {
let wid = try element.cgWindowId()
if let runningApp = NSRunningApplication(processIdentifier: pid) {
// photoshop will focus a window *after* you focus another app
// we check that a focused window happens within an active app
if runningApp.isActive {
@@ -100,7 +100,7 @@ fileprivate func focusedWindowChanged(_ element: AXUIElement, _ pid: pid_t) thro
if let windows = Windows.updateLastFocus(element, wid) {
App.app.refreshOpenUi(windows, .refreshUiAfterExternalEvent)
} else {
AXUIElement.retryAxCallUntilTimeout {
AXUIElement.retryAxCallUntilTimeout(context: "wid:\(wid) pid:\(pid)") {
if let (title, role, subrole, isMinimized, isFullscreen) = try element.windowAttributes() {
let position = try element.position()
let size = try element.size()
@@ -145,27 +145,25 @@ fileprivate func windowDestroyed(_ element: AXUIElement, _ pid: pid_t) throws {
}
fileprivate func windowMiniaturizedOrDeminiaturized(_ element: AXUIElement, _ type: String) throws {
if let wid = try element.cgWindowId() {
DispatchQueue.main.async {
if let window = (Windows.list.first { $0.isEqualRobust(element, wid) }) {
window.isMinimized = type == kAXWindowMiniaturizedNotification
App.app.refreshOpenUi([window], .refreshUiAfterExternalEvent)
}
let wid = try element.cgWindowId()
DispatchQueue.main.async {
if let window = (Windows.list.first { $0.isEqualRobust(element, wid) }) {
window.isMinimized = type == kAXWindowMiniaturizedNotification
App.app.refreshOpenUi([window], .refreshUiAfterExternalEvent)
}
}
}
fileprivate func windowTitleChanged(_ element: AXUIElement) throws {
if let wid = try element.cgWindowId() {
AXUIElement.retryAxCallUntilTimeoutDebounced(.windowTitleChanged, wid) {
if let (title, _, _, isMinimized, isFullscreen) = try element.windowAttributes() {
DispatchQueue.main.async {
if let window = (Windows.list.first { $0.isEqualRobust(element, wid) }), title != window.title {
window.title = window.bestEffortTitle(title)
window.isMinimized = isMinimized
window.isFullscreen = isFullscreen
App.app.refreshOpenUi([window], .refreshUiAfterExternalEvent)
}
let wid = try element.cgWindowId()
AXUIElement.retryAxCallUntilTimeout(context: "\(wid)", debounceType: .windowTitleChanged, wid: wid) {
if let (title, _, _, isMinimized, isFullscreen) = try element.windowAttributes() {
DispatchQueue.main.async {
if let window = (Windows.list.first { $0.isEqualRobust(element, wid) }), title != window.title {
window.title = window.bestEffortTitle(title)
window.isMinimized = isMinimized
window.isFullscreen = isFullscreen
App.app.refreshOpenUi([window], .refreshUiAfterExternalEvent)
}
}
}
@@ -173,10 +171,9 @@ fileprivate func windowTitleChanged(_ element: AXUIElement) throws {
}
fileprivate func windowResizedOrMoved(_ element: AXUIElement) throws {
if let wid = try element.cgWindowId() {
AXUIElement.retryAxCallUntilTimeoutDebounced(.windowResizedOrMoved, wid) {
try updateWindowSizeAndPositionAndFullscreen(element, wid, nil)
}
let wid = try element.cgWindowId()
AXUIElement.retryAxCallUntilTimeout(context: "\(wid)", debounceType: .windowResizedOrMoved, wid: wid) {
try updateWindowSizeAndPositionAndFullscreen(element, wid, nil)
}
}
+1 -1
View File
@@ -81,7 +81,7 @@ class KeyboardEvents {
userInfo: nil)
if let eventTap {
let runLoopSource = CFMachPortCreateRunLoopSource(nil, eventTap, 0)
CFRunLoopAddSource(BackgroundWork.keyboardEventsThread.runLoop, runLoopSource, .commonModes)
CFRunLoopAddSource(BackgroundWork.keyboardAndTrackpadEventsThread.runLoop, runLoopSource, .commonModes)
} else {
App.app.restart()
}
+1 -1
View File
@@ -29,7 +29,7 @@ private func observe_() {
userInfo: nil)
if let eventTap {
let runLoopSource = CFMachPortCreateRunLoopSource(nil, eventTap, 0)
CFRunLoopAddSource(BackgroundWork.keyboardEventsThread.runLoop, runLoopSource, .commonModes)
CFRunLoopAddSource(BackgroundWork.keyboardAndTrackpadEventsThread.runLoop, runLoopSource, .commonModes)
} else {
App.app.restart()
}