fix: focusing windows could fail after focusing a frozen app (#4520)
also generally reworked and improved background work handling
This commit is contained in:
1 parent
3f18951368
commit
591ce5274f
12 files changed
+245
-141
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user