mirror of
https://github.com/lwouis/alt-tab-macos.git
synced 2026-05-24 11:20:36 +00:00
feat: improved detection of windows and apps
* find more windows and apps (e.g. apps that take 30s to launch) * keep windows and apps more up-to-date * more efficient, uses fewer resources
This commit is contained in:
@@ -14,3 +14,4 @@ codesign.conf
|
||||
codesign.crt
|
||||
codesign.key
|
||||
codesign.p12
|
||||
/.claude/worktrees/
|
||||
|
||||
@@ -170,6 +170,7 @@
|
||||
BF0C89C49CFBAAB4646BB8CB /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C82A8848224E76B880A83 /* InfoPlist.strings */; };
|
||||
BF0C89F0632E0A2EC52D9168 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C87749F1A64E2D3305181 /* InfoPlist.strings */; };
|
||||
BF0C8A2F6E4C85A7D9B63B12 /* Throttler.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C8A1E7D3F94B6E8C52A01 /* Throttler.swift */; };
|
||||
AA0C8A100000000000000001 /* AXCallScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0C8A000000000000000001 /* AXCallScheduler.swift */; };
|
||||
BF0C8A3F32B2177AF407DC7E /* DockEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C870C14C20C936BA4AF40 /* DockEvents.swift */; };
|
||||
BF0C8A408B8E1C662E457A99 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C86C535CC75FA07FAA09B /* InfoPlist.strings */; };
|
||||
BF0C8A49891371E4027E0641 /* MacroPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C8998862E0E04D60CACBC /* MacroPreferences.swift */; };
|
||||
@@ -528,6 +529,7 @@
|
||||
BF0C89D7F23D82A3FF442E0B /* build_app.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = build_app.sh; sourceTree = "<group>"; };
|
||||
BF0C89F6EA16DD7322626E20 /* FUNDING.yml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.yaml; path = FUNDING.yml; sourceTree = "<group>"; };
|
||||
BF0C8A1E7D3F94B6E8C52A01 /* Throttler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Throttler.swift; sourceTree = "<group>"; };
|
||||
AA0C8A000000000000000001 /* AXCallScheduler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AXCallScheduler.swift; sourceTree = "<group>"; };
|
||||
BF0C8A261AAA42191426A587 /* run_tests.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = run_tests.sh; sourceTree = "<group>"; };
|
||||
BF0C8A3DAEA5B956593AF97A /* kn */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = kn; path = InfoPlist.strings; sourceTree = "<group>"; };
|
||||
BF0C8A479D2B7FB80531F037 /* PopupButtonLikeSystemSettings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PopupButtonLikeSystemSettings.swift; sourceTree = "<group>"; };
|
||||
@@ -1433,6 +1435,7 @@
|
||||
BF0C8998862E0E04D60CACBC /* MacroPreferences.swift */,
|
||||
BF0C8F58B38F29B2662E6C2D /* PreferencesMigrations.swift */,
|
||||
BF0C8A1E7D3F94B6E8C52A01 /* Throttler.swift */,
|
||||
AA0C8A000000000000000001 /* AXCallScheduler.swift */,
|
||||
);
|
||||
path = logic;
|
||||
sourceTree = "<group>";
|
||||
@@ -2643,6 +2646,7 @@
|
||||
BF0C803A13C409E7F01FBCAB /* SkyLight.framework.swift in Sources */,
|
||||
BF0C80C69EFEF93C18539925 /* PreferencesMigrations.swift in Sources */,
|
||||
BF0C8A2F6E4C85A7D9B63B12 /* Throttler.swift in Sources */,
|
||||
AA0C8A100000000000000001 /* AXCallScheduler.swift in Sources */,
|
||||
BF0C8973EC078169170F2634 /* CliEvents.swift in Sources */,
|
||||
BF0C8521DA3078516615C414 /* HelperExtensionsTestable.swift in Sources */,
|
||||
BF0C8991399A3277A9A02FFA /* LightImageView.swift in Sources */,
|
||||
|
||||
@@ -6,13 +6,11 @@ import ApplicationServices.HIServices.AXRoleConstants
|
||||
import ApplicationServices.HIServices.AXAttributeConstants
|
||||
import ApplicationServices.HIServices.AXActionConstants
|
||||
|
||||
/// common, subscriptions, concurrency
|
||||
/// common, subscriptions
|
||||
extension AXUIElement {
|
||||
// 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
|
||||
private static let globalMessagingTimeoutInSeconds = Float(1)
|
||||
// if an app times out our AX calls, we retry for 6s then give up
|
||||
private static let axCallsRetriesQueueTimeoutInSeconds = Float(6)
|
||||
static func setGlobalTimeout() {
|
||||
AXUIElementSetMessagingTimeout(AXUIElementCreateSystemWide(), globalMessagingTimeoutInSeconds)
|
||||
}
|
||||
@@ -25,39 +23,6 @@ extension AXUIElement {
|
||||
// for success or other errors we don't throw
|
||||
}
|
||||
|
||||
/// 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(file: String = #file, function: String = #function, line: Int = #line, context: String = "", after: DispatchTime? = nil, pid: pid_t? = nil, wid: CGWindowID? = nil, retriesQueue: Bool = false, startTimeInNanoseconds: UInt64 = DispatchTime.now().uptimeNanoseconds, callType: AXCallType, block: @escaping () throws -> Void) {
|
||||
let closure = { retryAxCallUntilTimeout_(file: file, function: function, line: line, context: context, after: after, pid: pid, wid: wid, retriesQueue: retriesQueue, startTimeInNanoseconds: startTimeInNanoseconds, callType: callType, block: block) }
|
||||
let queue = (callType == .updateAppWindowsFromManualDiscovery ? BackgroundWork.axCallsManualDiscoveryQueue
|
||||
: (retriesQueue ? BackgroundWork.axCallsRetriesQueue
|
||||
: BackgroundWork.axCallsFirstAttemptQueue))!
|
||||
if let after {
|
||||
queue.addOperationAfter(deadline: after, block: closure)
|
||||
} else {
|
||||
queue.addOperation(closure)
|
||||
}
|
||||
}
|
||||
|
||||
private static func retryAxCallUntilTimeout_(file: String, function: String, line: Int, context: String, after: DispatchTime?, pid: pid_t?, wid: CGWindowID?, retriesQueue: Bool, startTimeInNanoseconds: UInt64, callType: AXCallType, block: @escaping () throws -> Void) {
|
||||
// attempt the AX call
|
||||
if (try? block()) != nil {
|
||||
return
|
||||
}
|
||||
// give up?
|
||||
let timePassedInSeconds = Float(DispatchTime.now().uptimeNanoseconds - startTimeInNanoseconds) / 1_000_000_000
|
||||
if timePassedInSeconds >= axCallsRetriesQueueTimeoutInSeconds {
|
||||
Logger.info { "AX call failed for more than \(Int(axCallsRetriesQueueTimeoutInSeconds))s. Giving up on it. \(logFromContext(file, function, line, context, callType))" }
|
||||
return
|
||||
}
|
||||
// retry
|
||||
Logger.debug { "(pid:\(pid) wid:\(wid)) \(logFromContext(file, function, line, context, callType))" }
|
||||
retryAxCallUntilTimeout(file: file, function: function, line: line, context: context, after: .now() + humanPerceptionDelay, pid: pid, wid: wid, retriesQueue: true, startTimeInNanoseconds: startTimeInNanoseconds, callType: callType, block: block)
|
||||
}
|
||||
|
||||
private static func logFromContext(_ file: String, _ function: String, _ line: Int, _ context: String, _ callType: AXCallType) -> String {
|
||||
return "Context: \((file as NSString).lastPathComponent):\(line) \(function) \(String(describing: callType)) \(context)"
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func subscribeToNotification(_ axObserver: AXObserver, _ notification: String, _ callback: (() -> Void)? = nil) throws -> Bool {
|
||||
let result = AXObserverAddNotification(axObserver, self, notification as CFString, nil)
|
||||
@@ -71,23 +36,6 @@ extension AXUIElement {
|
||||
// temporary issue; subscription may succeed if retried
|
||||
throw AxError.runtimeError
|
||||
}
|
||||
|
||||
enum AXCallType: Int {
|
||||
case subscribeToAppNotification
|
||||
case subscribeToWindowNotification
|
||||
case subscribeToDockNotification
|
||||
|
||||
case updateAppWindowsFromManualDiscovery
|
||||
case updateWindowFromManualDiscovery
|
||||
|
||||
case entrypointFromAxEvent
|
||||
case updateWindowFromAxEvent
|
||||
case updateAppFromAxEvent
|
||||
|
||||
case updateAppFocusedWindowFromWindowInit
|
||||
case updateDockBadgesFromShowingUi
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Attributes
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import Foundation
|
||||
|
||||
class AXCallScheduler {
|
||||
static let shared = AXCallScheduler()
|
||||
|
||||
let fastQueue: LabeledOperationQueue
|
||||
let retryQueue: LabeledOperationQueue
|
||||
|
||||
private let lock = NSLock()
|
||||
private var keyStates = [String: KeyState]()
|
||||
private var unresponsivePids = Set<pid_t>()
|
||||
|
||||
private static let throttleDelayNs: UInt64 = 200_000_000
|
||||
private static let giveUpAfterSeconds: Float = 60.0
|
||||
private static let backoffStepsNs: [UInt64] = [200_000_000, 1_000_000_000, 2_000_000_000, 5_000_000_000]
|
||||
|
||||
private enum Phase {
|
||||
case idle
|
||||
case throttled
|
||||
case executing
|
||||
case retrying
|
||||
}
|
||||
|
||||
private struct KeyState {
|
||||
var phase: Phase = .idle
|
||||
var lastExecutionTime: UInt64 = 0
|
||||
var retryStartTime: UInt64 = 0
|
||||
var retryCount = 0
|
||||
var pendingBlock: (() throws -> Void)?
|
||||
var pendingPid: pid_t?
|
||||
var pendingContext: String?
|
||||
var cancelRetries = false
|
||||
}
|
||||
|
||||
private init() {
|
||||
fastQueue = LabeledOperationQueue("axCallsFast", .userInteractive, 16)
|
||||
retryQueue = LabeledOperationQueue("axCallsRetry", .userInteractive, 8)
|
||||
}
|
||||
|
||||
func schedule(key: String, file: String = #file, function: String = #function, line: Int = #line, context: String = "", pid: pid_t? = nil, block: @escaping () throws -> Void) {
|
||||
lock.lock()
|
||||
var state = keyStates[key] ?? KeyState()
|
||||
switch state.phase {
|
||||
case .idle:
|
||||
let now = DispatchTime.now().uptimeNanoseconds
|
||||
let elapsed = now >= state.lastExecutionTime ? (now - state.lastExecutionTime) : Self.throttleDelayNs
|
||||
if elapsed >= Self.throttleDelayNs {
|
||||
state.phase = .executing
|
||||
keyStates[key] = state
|
||||
lock.unlock()
|
||||
submitToQueue(key: key, pid: pid, file: file, function: function, line: line, context: context, block: block)
|
||||
} else {
|
||||
state.phase = .throttled
|
||||
state.pendingBlock = block
|
||||
state.pendingPid = pid
|
||||
state.pendingContext = context
|
||||
keyStates[key] = state
|
||||
let remaining = Self.throttleDelayNs - elapsed
|
||||
lock.unlock()
|
||||
let queue = queueForPid(pid)
|
||||
queue.addOperationAfter(deadline: .now() + .nanoseconds(Int(remaining))) { [self] in
|
||||
fireThrottled(key: key, file: file, function: function, line: line)
|
||||
}
|
||||
}
|
||||
case .throttled:
|
||||
state.pendingBlock = block
|
||||
state.pendingPid = pid
|
||||
state.pendingContext = context
|
||||
keyStates[key] = state
|
||||
lock.unlock()
|
||||
case .executing:
|
||||
state.pendingBlock = block
|
||||
state.pendingPid = pid
|
||||
state.pendingContext = context
|
||||
keyStates[key] = state
|
||||
lock.unlock()
|
||||
case .retrying:
|
||||
state.pendingBlock = block
|
||||
state.pendingPid = pid
|
||||
state.pendingContext = context
|
||||
state.cancelRetries = true
|
||||
keyStates[key] = state
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func submit(_ block: @escaping () -> Void) {
|
||||
fastQueue.addOperation(block)
|
||||
}
|
||||
|
||||
func removeEntry(key: String) {
|
||||
lock.lock()
|
||||
keyStates[key] = nil
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func removeUnresponsivePid(_ pid: pid_t) {
|
||||
lock.lock()
|
||||
unresponsivePids.remove(pid)
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
private func queueForPid(_ pid: pid_t?) -> LabeledOperationQueue {
|
||||
if let pid, unresponsivePids.contains(pid) {
|
||||
return retryQueue
|
||||
}
|
||||
return fastQueue
|
||||
}
|
||||
|
||||
private func fireThrottled(key: String, file: String, function: String, line: Int) {
|
||||
lock.lock()
|
||||
guard var state = keyStates[key], state.phase == .throttled, let block = state.pendingBlock else {
|
||||
lock.unlock()
|
||||
return
|
||||
}
|
||||
let pid = state.pendingPid
|
||||
let context = state.pendingContext ?? ""
|
||||
state.pendingBlock = nil
|
||||
state.pendingPid = nil
|
||||
state.pendingContext = nil
|
||||
state.phase = .executing
|
||||
keyStates[key] = state
|
||||
lock.unlock()
|
||||
attemptBlock(key: key, pid: pid, file: file, function: function, line: line, context: context, retryStartTime: DispatchTime.now().uptimeNanoseconds, block: block)
|
||||
}
|
||||
|
||||
private func submitToQueue(key: String, pid: pid_t?, file: String, function: String, line: Int, context: String, block: @escaping () throws -> Void) {
|
||||
let queue = queueForPid(pid)
|
||||
queue.addOperation { [self] in
|
||||
attemptBlock(key: key, pid: pid, file: file, function: function, line: line, context: context, retryStartTime: DispatchTime.now().uptimeNanoseconds, block: block)
|
||||
}
|
||||
}
|
||||
|
||||
private func attemptBlock(key: String, pid: pid_t?, file: String, function: String, line: Int, context: String, retryStartTime: UInt64, block: @escaping () throws -> Void) {
|
||||
// check if cancelled by a newer request
|
||||
lock.lock()
|
||||
if let state = keyStates[key], state.cancelRetries {
|
||||
lock.unlock()
|
||||
drainPending(key: key, file: file, function: function, line: line)
|
||||
return
|
||||
}
|
||||
lock.unlock()
|
||||
|
||||
if (try? block()) != nil {
|
||||
// success
|
||||
if let pid {
|
||||
lock.lock()
|
||||
unresponsivePids.remove(pid)
|
||||
lock.unlock()
|
||||
}
|
||||
onComplete(key: key, file: file, function: function, line: line)
|
||||
return
|
||||
}
|
||||
|
||||
// failure
|
||||
if let pid {
|
||||
lock.lock()
|
||||
unresponsivePids.insert(pid)
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
let elapsed = Float(DispatchTime.now().uptimeNanoseconds - retryStartTime) / 1_000_000_000
|
||||
if elapsed >= Self.giveUpAfterSeconds {
|
||||
Logger.info { "AX call timed out after \(Int(Self.giveUpAfterSeconds))s. \(Self.logContext(file, function, line, context))" }
|
||||
if let pid {
|
||||
lock.lock()
|
||||
unresponsivePids.remove(pid)
|
||||
lock.unlock()
|
||||
}
|
||||
onComplete(key: key, file: file, function: function, line: line)
|
||||
return
|
||||
}
|
||||
|
||||
// schedule retry with backoff: 200ms, 1s, 2s, 5s, 5s, ...
|
||||
let delayNs: UInt64
|
||||
lock.lock()
|
||||
if var state = keyStates[key] {
|
||||
state.phase = .retrying
|
||||
let step = min(state.retryCount, Self.backoffStepsNs.count - 1)
|
||||
delayNs = Self.backoffStepsNs[step]
|
||||
state.retryCount += 1
|
||||
keyStates[key] = state
|
||||
} else {
|
||||
delayNs = Self.backoffStepsNs[0]
|
||||
}
|
||||
lock.unlock()
|
||||
|
||||
Logger.debug { "Retrying AX call in \(delayNs / 1_000_000)ms. \(Self.logContext(file, function, line, context))" }
|
||||
retryQueue.addOperationAfter(deadline: .now() + .nanoseconds(Int(delayNs))) { [self] in
|
||||
attemptBlock(key: key, pid: pid, file: file, function: function, line: line, context: context, retryStartTime: retryStartTime, block: block)
|
||||
}
|
||||
}
|
||||
|
||||
private func onComplete(key: String, file: String, function: String, line: Int) {
|
||||
lock.lock()
|
||||
if var state = keyStates[key] {
|
||||
state.lastExecutionTime = DispatchTime.now().uptimeNanoseconds
|
||||
state.phase = .idle
|
||||
state.cancelRetries = false
|
||||
state.retryCount = 0
|
||||
keyStates[key] = state
|
||||
}
|
||||
lock.unlock()
|
||||
drainPending(key: key, file: file, function: function, line: line)
|
||||
}
|
||||
|
||||
private func drainPending(key: String, file: String, function: String, line: Int) {
|
||||
lock.lock()
|
||||
guard var state = keyStates[key], let block = state.pendingBlock else {
|
||||
if var state = keyStates[key] {
|
||||
state.cancelRetries = false
|
||||
state.phase = .idle
|
||||
keyStates[key] = state
|
||||
}
|
||||
lock.unlock()
|
||||
return
|
||||
}
|
||||
let pid = state.pendingPid
|
||||
let context = state.pendingContext ?? ""
|
||||
state.pendingBlock = nil
|
||||
state.pendingPid = nil
|
||||
state.pendingContext = nil
|
||||
state.cancelRetries = false
|
||||
|
||||
// apply throttle check before executing pending block
|
||||
let now = DispatchTime.now().uptimeNanoseconds
|
||||
let elapsed = now >= state.lastExecutionTime ? (now - state.lastExecutionTime) : Self.throttleDelayNs
|
||||
if elapsed >= Self.throttleDelayNs {
|
||||
state.phase = .executing
|
||||
keyStates[key] = state
|
||||
lock.unlock()
|
||||
let queue = queueForPid(pid)
|
||||
queue.addOperation { [self] in
|
||||
attemptBlock(key: key, pid: pid, file: file, function: function, line: line, context: context, retryStartTime: DispatchTime.now().uptimeNanoseconds, block: block)
|
||||
}
|
||||
} else {
|
||||
state.phase = .throttled
|
||||
state.pendingBlock = block
|
||||
state.pendingPid = pid
|
||||
state.pendingContext = context
|
||||
keyStates[key] = state
|
||||
let remaining = Self.throttleDelayNs - elapsed
|
||||
lock.unlock()
|
||||
let queue = queueForPid(pid)
|
||||
queue.addOperationAfter(deadline: .now() + .nanoseconds(Int(remaining))) { [self] in
|
||||
fireThrottled(key: key, file: file, function: function, line: line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func logContext(_ file: String, _ function: String, _ line: Int, _ context: String) -> String {
|
||||
"\((file as NSString).lastPathComponent):\(line) \(function) \(context)"
|
||||
}
|
||||
}
|
||||
@@ -128,7 +128,7 @@ class Application: NSObject {
|
||||
|
||||
private func observeEvents() {
|
||||
guard let axObserver else { return }
|
||||
AXUIElement.retryAxCallUntilTimeout(context: debugId, pid: pid, callType: .subscribeToAppNotification) { [weak self] in
|
||||
AXCallScheduler.shared.schedule(key: "sub-app-\(pid)", context: debugId, pid: pid) { [weak self] in
|
||||
guard let self, !self.isReallyFinishedLaunching else { return }
|
||||
if try self.axUiElement!.subscribeToNotification(axObserver, Application.notifications.first!) {
|
||||
Logger.debug { "Subscribed to app: \(self.debugId)" }
|
||||
@@ -138,7 +138,7 @@ class Application: NSObject {
|
||||
// windows opened before that point won't send a notification, so check those windows manually here
|
||||
self.isReallyFinishedLaunching = true
|
||||
for notification in Application.notifications.dropFirst() {
|
||||
AXUIElement.retryAxCallUntilTimeout(context: self.debugId, pid: self.pid, callType: .subscribeToAppNotification) { [weak self] in
|
||||
AXCallScheduler.shared.schedule(key: "sub-app-\(self.pid)-\(notification)", context: self.debugId, pid: self.pid) { [weak self] in
|
||||
try self?.axUiElement!.subscribeToNotification(axObserver, notification)
|
||||
}
|
||||
}
|
||||
@@ -158,29 +158,6 @@ class Application: NSObject {
|
||||
CFRunLoopAddSource(BackgroundWork.accessibilityEventsThread.runLoop, AXObserverGetRunLoopSource(axObserver), .commonModes)
|
||||
}
|
||||
|
||||
func manuallyUpdateWindow(_ axWindow: AXUIElement, _ wid: CGWindowID) throws {
|
||||
guard wid != 0 && wid != TilesPanel.shared.windowNumber else { return } // some bogus "windows" have wid 0
|
||||
let level = wid.level()
|
||||
// if we query .children on ourselves, AppKit calls layout directly from our thread instead of IPC; we avoid this
|
||||
let isSelf = pid == ProcessInfo.processInfo.processIdentifier
|
||||
let keys = [kAXTitleAttribute, kAXSubroleAttribute, kAXRoleAttribute, kAXSizeAttribute, kAXPositionAttribute, kAXFullscreenAttribute, kAXMinimizedAttribute] + (isSelf ? [] : [kAXChildrenAttribute])
|
||||
let a = try axWindow.attributes(keys)
|
||||
let tabSiblingTitles = isSelf ? nil : TabGroup.extractTabTitles(a.children)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
let findOrCreate = Windows.findOrCreate(axWindow, wid, self, level, a.title, a.subrole, a.role, a.size, a.position, a.isFullscreen, a.isMinimized)
|
||||
guard let window = findOrCreate.0 else { return }
|
||||
var tabStateChanged = false
|
||||
if tabSiblingTitles != nil || window.tabbedSiblingWids != nil {
|
||||
tabStateChanged = TabGroup.updateState(window, tabSiblingTitles)
|
||||
}
|
||||
if findOrCreate.1 || (tabStateChanged && App.appIsBeingUsed) {
|
||||
if findOrCreate.1 { Logger.info { "manuallyUpdateWindows found a new window:\(window.debugId)" } }
|
||||
App.refreshOpenUiAfterExternalEvent([window])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func addWindowlessWindowIfNeeded() -> Window? {
|
||||
guard runningApplication.activationPolicy == .regular && !runningApplication.isTerminated
|
||||
|
||||
@@ -4,8 +4,12 @@ import ApplicationServices
|
||||
class Applications {
|
||||
static var list = [Application]()
|
||||
static var frontmostPid = NSWorkspace.shared.frontmostApplication?.processIdentifier
|
||||
static let manualAppUpdatesThrottler = ThrottlerWithKey(delayInMs: 1000)
|
||||
static let manualWindowUpdatesThrottler = ThrottlerWithKey(delayInMs: 1000)
|
||||
// Layer 0: global throttle on manuallyRefreshAllWindows (panel show full-sync)
|
||||
static let manualRefreshThrottler = Throttler(delayInMs: 1000)
|
||||
// Layer 1 (AX IPC throttle + retry + concurrency) is handled by AXCallScheduler.shared
|
||||
// Layer 2: throttle mutations to Applications.list / Windows.list on main thread
|
||||
static let appListUpdateThrottler = ThrottlerWithKey(delayInMs: 200)
|
||||
static let windowListUpdateThrottler = ThrottlerWithKey(delayInMs: 200)
|
||||
static let badgesThrottler = Throttler(delayInMs: 1000)
|
||||
|
||||
static func initialDiscovery() {
|
||||
@@ -18,8 +22,11 @@ class Applications {
|
||||
}
|
||||
|
||||
static func manuallyRefreshAllWindows() {
|
||||
removeZombieWindows()
|
||||
addMissingWindows()
|
||||
manualRefreshThrottler.throttleOrProceed {
|
||||
removeZombieWindows()
|
||||
addMissingWindows()
|
||||
reviewExistingWindows()
|
||||
}
|
||||
}
|
||||
|
||||
/// we may not receive a window-created event in some cases:
|
||||
@@ -34,55 +41,92 @@ class Applications {
|
||||
}
|
||||
|
||||
static func manuallyUpdateWindows(_ app: Application) {
|
||||
manualAppUpdatesThrottler.throttleOrProceed(key: "\(app.pid)") { [weak app] in
|
||||
guard let app else { return }
|
||||
AXUIElement.retryAxCallUntilTimeout(context: app.debugId, pid: app.pid, callType: .updateAppWindowsFromManualDiscovery) { [weak app] in
|
||||
guard let app, let axUiElement = app.axUiElement else { return }
|
||||
let axWindows = try axUiElement.allWindows(app.pid)
|
||||
guard !axWindows.isEmpty else {
|
||||
// workaround: some apps launch but take a while to create their window(s)
|
||||
// initial windows don't trigger a windowCreated notification, so we won't get notified
|
||||
// it's very unlikely an app would launch with no initial window
|
||||
// so we retry until timeout, in those rare cases (e.g. Bear.app)
|
||||
// we only do this for regular, active app, to avoid wasting CPU, with the trade-off of maybe missing some windows
|
||||
if app.runningApplication.isActive && app.runningApplication.activationPolicy == .regular {
|
||||
throw AxError.runtimeError
|
||||
}
|
||||
return
|
||||
AXCallScheduler.shared.schedule(key: "pid-\(app.pid)", context: app.debugId, pid: app.pid) { [weak app] in
|
||||
guard let app, let axUiElement = app.axUiElement else { return }
|
||||
let axWindows = try axUiElement.allWindows(app.pid)
|
||||
guard !axWindows.isEmpty else {
|
||||
// workaround: some apps launch but take a while to create their window(s)
|
||||
// initial windows don't trigger a windowCreated notification, so we won't get notified
|
||||
// it's very unlikely an app would launch with no initial window
|
||||
// so we retry until timeout, in those rare cases (e.g. Bear.app)
|
||||
// we only do this for regular, active app, to avoid wasting CPU, with the trade-off of maybe missing some windows
|
||||
if app.runningApplication.isActive && app.runningApplication.activationPolicy == .regular {
|
||||
throw AxError.runtimeError
|
||||
}
|
||||
for axWindow in axWindows {
|
||||
guard let wid = try? axWindow.cgWindowId(), wid != 0 else { continue }
|
||||
manualWindowUpdatesThrottler.throttleOrProceed(key: "\(wid)") { [weak app] in
|
||||
guard let app else { return }
|
||||
AXUIElement.retryAxCallUntilTimeout(context: app.debugId, pid: app.pid, wid: wid, callType: .updateWindowFromManualDiscovery) { [weak app] in
|
||||
try app?.manuallyUpdateWindow(axWindow, wid)
|
||||
}
|
||||
return
|
||||
}
|
||||
for axWindow in axWindows {
|
||||
guard let wid = try? axWindow.cgWindowId(), wid != 0 else { continue }
|
||||
updateWindowAttributes(axWindow, wid, app)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Unified window attribute fetch + main-thread update. Used by both manual sync and reviewExistingWindows.
|
||||
static func updateWindowAttributes(_ axWindow: AXUIElement, _ wid: CGWindowID, _ app: Application) {
|
||||
AXCallScheduler.shared.schedule(key: "wid-\(wid)", context: app.debugId, pid: app.pid) { [weak app] in
|
||||
guard let app else { return }
|
||||
guard wid != 0 && wid != TilesPanel.shared.windowNumber else { return }
|
||||
let level = wid.level()
|
||||
let isSelf = app.pid == ProcessInfo.processInfo.processIdentifier
|
||||
let keys = [kAXTitleAttribute, kAXSubroleAttribute, kAXRoleAttribute, kAXSizeAttribute, kAXPositionAttribute, kAXFullscreenAttribute, kAXMinimizedAttribute] + (isSelf ? [] : [kAXChildrenAttribute])
|
||||
let a = try axWindow.attributes(keys)
|
||||
let tabSiblingTitles = isSelf ? nil : TabGroup.extractTabTitles(a.children)
|
||||
DispatchQueue.main.async { [weak app] in
|
||||
guard let app else { return }
|
||||
windowListUpdateThrottler.throttleOrProceed(key: "\(wid)") {
|
||||
let findOrCreate = Windows.findOrCreate(axWindow, wid, app, level, a.title, a.subrole, a.role, a.size, a.position, a.isFullscreen, a.isMinimized)
|
||||
guard let window = findOrCreate.0 else { return }
|
||||
var tabStateChanged = false
|
||||
if tabSiblingTitles != nil || window.tabbedSiblingWids != nil {
|
||||
tabStateChanged = TabGroup.updateState(window, tabSiblingTitles)
|
||||
}
|
||||
if findOrCreate.1 || (tabStateChanged && App.appIsBeingUsed) {
|
||||
if findOrCreate.1 { Logger.info { "manuallyUpdateWindows found a new window:\(window.debugId)" } }
|
||||
App.refreshOpenUiAfterExternalEvent([window])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// refreshes AX attributes for all known windows, in case notifications were incomplete
|
||||
static func reviewExistingWindows() {
|
||||
for window in Windows.list {
|
||||
guard !window.isWindowlessApp,
|
||||
let axUiElement = window.axUiElement,
|
||||
let wid = window.cgWindowId else { continue }
|
||||
updateWindowAttributes(axUiElement, wid, window.application)
|
||||
}
|
||||
}
|
||||
|
||||
/// we may not receive a window-destroyed event in some cases:
|
||||
/// * Sequoia bug: https://github.com/lwouis/alt-tab-macos/issues/3589
|
||||
/// * Logic Pro bug: https://github.com/lwouis/alt-tab-macos/issues/4924
|
||||
/// this acts as a garbage-collector for windows, to keep our list in-sync with the actual system
|
||||
static func removeZombieWindows() {
|
||||
// snapshot wids on main thread where Windows.list is safe to read
|
||||
let wIds = Windows.list.compactMap { $0.cgWindowId }
|
||||
guard !wIds.isEmpty else { return }
|
||||
let rawIds: CFArray = wIds.map { UnsafeRawPointer(bitPattern: UInt($0)) }.withUnsafeBufferPointer {
|
||||
CFArrayCreate(nil, UnsafeMutablePointer(mutating: $0.baseAddress), $0.count, nil)
|
||||
}
|
||||
let descriptions = CGWindowListCreateDescriptionFromArray(rawIds) as? [[CFString: Any]]
|
||||
let existingWids = descriptions?.compactMap { $0[kCGWindowNumber] } as? [CGWindowID]
|
||||
guard let existingWids else { return }
|
||||
let believedAlive = Set(wIds)
|
||||
let confirmedAlive = Set(existingWids)
|
||||
let zombies = believedAlive.subtracting(confirmedAlive)
|
||||
for window in Windows.list.reversed() {
|
||||
if let wid = window.cgWindowId, zombies.contains(wid) {
|
||||
Logger.debug { window.debugId }
|
||||
Windows.removeWindows([window], true)
|
||||
// CGWindowListCreateDescriptionFromArray is a synchronous WindowServer IPC call; run it off main thread
|
||||
AXCallScheduler.shared.submit {
|
||||
let rawIds: CFArray = wIds.map { UnsafeRawPointer(bitPattern: UInt($0)) }.withUnsafeBufferPointer {
|
||||
CFArrayCreate(nil, UnsafeMutablePointer(mutating: $0.baseAddress), $0.count, nil)
|
||||
}
|
||||
let descriptions = CGWindowListCreateDescriptionFromArray(rawIds) as? [[CFString: Any]]
|
||||
let existingWids = descriptions?.compactMap { $0[kCGWindowNumber] } as? [CGWindowID]
|
||||
guard let existingWids else { return }
|
||||
let believedAlive = Set(wIds)
|
||||
let confirmedAlive = Set(existingWids)
|
||||
let zombies = believedAlive.subtracting(confirmedAlive)
|
||||
guard !zombies.isEmpty else { return }
|
||||
DispatchQueue.main.async {
|
||||
for window in Windows.list.reversed() {
|
||||
if let wid = window.cgWindowId, zombies.contains(wid) {
|
||||
Logger.debug { window.debugId }
|
||||
Windows.removeWindows([window], true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -112,8 +156,9 @@ class Applications {
|
||||
}
|
||||
for tApp in terminatingApps {
|
||||
let pid = tApp.processIdentifier
|
||||
manualAppUpdatesThrottler.removeEntry(withKey: "\(pid)")
|
||||
AccessibilityEvents.removeThrottlerEntries(pid: pid)
|
||||
AXCallScheduler.shared.removeEntry(key: "pid-\(pid)")
|
||||
AXCallScheduler.shared.removeUnresponsivePid(pid)
|
||||
appListUpdateThrottler.removeEntry(withKey: "\(pid)")
|
||||
}
|
||||
App.refreshOpenUiAfterExternalEvent([])
|
||||
}
|
||||
@@ -121,8 +166,9 @@ class Applications {
|
||||
static func refreshBadgesAsync() {
|
||||
guard App.appIsBeingUsed && !Preferences.hideAppBadges else { return }
|
||||
badgesThrottler.throttleOrProceed {
|
||||
AXUIElement.retryAxCallUntilTimeout(callType: .updateDockBadgesFromShowingUi) {
|
||||
guard let dockPid = (list.first { $0.bundleIdentifier == "com.apple.dock" }?.pid),
|
||||
let dockPid = list.first { $0.bundleIdentifier == "com.apple.dock" }?.pid
|
||||
AXCallScheduler.shared.schedule(key: "badges", context: "badges", pid: dockPid) {
|
||||
guard let dockPid,
|
||||
let axDockChildren = try AXUIElementCreateApplication(dockPid).attributes([kAXChildrenAttribute]).children,
|
||||
let axListAttrs = (axDockChildren.lazy.compactMap { try? $0.attributes([kAXRoleAttribute, kAXChildrenAttribute]) }.first { $0.role == kAXListRole }),
|
||||
let axListChildren = axListAttrs.children else { return }
|
||||
|
||||
@@ -13,9 +13,6 @@ class BackgroundWork {
|
||||
static var repeatingKeyQueue: LabeledOperationQueue!
|
||||
static var screenshotsQueue: LabeledOperationQueue!
|
||||
static var accessibilityCommandsQueue: LabeledOperationQueue!
|
||||
static var axCallsFirstAttemptQueue: LabeledOperationQueue!
|
||||
static var axCallsRetriesQueue: LabeledOperationQueue!
|
||||
static var axCallsManualDiscoveryQueue: LabeledOperationQueue!
|
||||
static var crashReportsQueue: LabeledOperationQueue!
|
||||
static var permissionsCheckOnTimerQueue: LabeledOperationQueue!
|
||||
static var permissionsSystemCallsQueue: LabeledOperationQueue!
|
||||
@@ -33,16 +30,9 @@ class BackgroundWork {
|
||||
}
|
||||
|
||||
static func start() {
|
||||
|
||||
// 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("axCommands", .userInteractive, 4)
|
||||
// calls to the AX APIs can block for a long time (e.g. if an app is unresponsive)
|
||||
// We first try the AX calls on axCallsFirstAttemptQueue. If we get a timeout, we move to axCallsRetriesQueue and retry there for a while
|
||||
axCallsFirstAttemptQueue = LabeledOperationQueue("axCallsFirst", .userInteractive, 8)
|
||||
axCallsRetriesQueue = LabeledOperationQueue("axCallsRetry", .userInteractive, 8)
|
||||
// we separate calls to manuallyUpdateWindows since those can be very heavy and numerous
|
||||
axCallsManualDiscoveryQueue = LabeledOperationQueue("axCallsManual", .userInteractive, 8)
|
||||
// we time key repeat on a background queue. We handle their consequence on the main-thread
|
||||
repeatingKeyQueue = LabeledOperationQueue("repeatingKey", .userInteractive, 1)
|
||||
// we observe app and windows notifications. They arrive on this thread, and are handled off the main thread initially
|
||||
@@ -72,7 +62,7 @@ class BackgroundWork {
|
||||
// useful during development to inspect how many threads are used by AltTab
|
||||
private static func logThreadsAndQueuesOnRepeat() {
|
||||
// if Logger.decideLevel() == .debug {
|
||||
debugMenu = DebugMenu([screenshotsQueue, accessibilityCommandsQueue, axCallsFirstAttemptQueue, axCallsRetriesQueue, axCallsManualDiscoveryQueue])
|
||||
debugMenu = DebugMenu([screenshotsQueue, accessibilityCommandsQueue, AXCallScheduler.shared.fastQueue, AXCallScheduler.shared.retryQueue])
|
||||
debugMenu.orderFront(nil)
|
||||
debugMenu.start()
|
||||
// Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
|
||||
@@ -83,7 +73,7 @@ class BackgroundWork {
|
||||
}
|
||||
|
||||
private static func logQueues() -> Void {
|
||||
let queues = [screenshotsQueue, accessibilityCommandsQueue, axCallsFirstAttemptQueue, axCallsRetriesQueue, crashReportsQueue].compactMap { $0 }
|
||||
let queues = [screenshotsQueue, accessibilityCommandsQueue, AXCallScheduler.shared.fastQueue, AXCallScheduler.shared.retryQueue, crashReportsQueue].compactMap { $0 }
|
||||
var map = [String:Int]()
|
||||
for queue in queues {
|
||||
map[queue.underlyingQueue!.label] = queue.operations.reduce(0) { $1.isExecuting ? $0 + 1 : $0 }
|
||||
|
||||
@@ -97,12 +97,12 @@ class Window {
|
||||
private func observeEvents() {
|
||||
AXObserverCreate(application.pid, AccessibilityEvents.axObserverCallback, &axObserver)
|
||||
guard let axObserver else { return }
|
||||
AXUIElement.retryAxCallUntilTimeout(context: debugId, pid: application.pid, wid: cgWindowId, callType: .subscribeToWindowNotification) { [weak self] in
|
||||
AXCallScheduler.shared.schedule(key: "sub-win-\(cgWindowId)", context: debugId, pid: application.pid) { [weak self] in
|
||||
guard let self else { return }
|
||||
if try self.axUiElement!.subscribeToNotification(axObserver, Window.notifications.first!) {
|
||||
Logger.debug { "Subscribed to window: \(self.debugId)" }
|
||||
for notification in Window.notifications.dropFirst() {
|
||||
AXUIElement.retryAxCallUntilTimeout(context: self.debugId, pid: self.application.pid, wid: cgWindowId, callType: .subscribeToWindowNotification) { [weak self] in
|
||||
AXCallScheduler.shared.schedule(key: "sub-win-\(cgWindowId)-\(notification)", context: self.debugId, pid: self.application.pid) { [weak self] in
|
||||
try self?.axUiElement!.subscribeToNotification(axObserver, notification)
|
||||
}
|
||||
}
|
||||
@@ -331,7 +331,7 @@ class Window {
|
||||
private func checkIfFocused() {
|
||||
let app = application
|
||||
guard let appAxUiElement = app.axUiElement else { return }
|
||||
AXUIElement.retryAxCallUntilTimeout(context: debugId, pid: app.pid, wid: cgWindowId, callType: .updateAppFocusedWindowFromWindowInit) { [weak app] in
|
||||
AXCallScheduler.shared.schedule(key: "wid-\(cgWindowId)-focus", context: debugId, pid: app.pid) { [weak app] in
|
||||
guard let app, let focusedWindow = try appAxUiElement.attributes([kAXFocusedWindowAttribute]).focusedWindow else { return }
|
||||
let focusedWid = try focusedWindow.cgWindowId()
|
||||
DispatchQueue.main.async {
|
||||
|
||||
@@ -42,10 +42,11 @@ class Windows {
|
||||
static func updateIsFullscreenOnCurrentSpace() {
|
||||
let windowsOnCurrentSpace = list.filter { !$0.isWindowlessApp }
|
||||
for window in windowsOnCurrentSpace {
|
||||
AXUIElement.retryAxCallUntilTimeout(context: window.debugId, after: .now() + humanPerceptionDelay, wid: window.cgWindowId, callType: .updateWindowFromAxEvent) { [weak window] in
|
||||
guard let wid = window.cgWindowId, let axUiElement = window.axUiElement else { continue }
|
||||
AXCallScheduler.shared.schedule(key: "wid-\(wid)", context: window.debugId, pid: window.application.pid) { [weak window] in
|
||||
guard let window else { return }
|
||||
// we reuse existing code, to update .isFullscreen, as if there was a kAXWindowResizedNotification
|
||||
try AccessibilityEvents.handleEventWindow(kAXWindowResizedNotification, window.cgWindowId!, window.application.pid, window.axUiElement!)
|
||||
try AccessibilityEvents.handleEventWindow(kAXWindowResizedNotification, wid, window.application.pid, axUiElement)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -514,8 +515,8 @@ class Windows {
|
||||
}
|
||||
for w in windows {
|
||||
if let wid = w.cgWindowId {
|
||||
Applications.manualWindowUpdatesThrottler.removeEntry(withKey: "\(wid)")
|
||||
AccessibilityEvents.removeThrottlerEntries(wid: wid)
|
||||
AXCallScheduler.shared.removeEntry(key: "wid-\(wid)")
|
||||
Applications.windowListUpdateThrottler.removeEntry(withKey: "\(wid)")
|
||||
}
|
||||
// when a tabbed window is removed, update its former siblings' tab group
|
||||
if let siblingWids = w.tabbedSiblingWids {
|
||||
|
||||
@@ -3,21 +3,12 @@ import ApplicationServices.HIServices.AXUIElement
|
||||
import ApplicationServices.HIServices.AXNotificationConstants
|
||||
|
||||
class AccessibilityEvents {
|
||||
private static let throttler = ThrottlerWithKey(delayInMs: 200)
|
||||
|
||||
static func removeThrottlerEntries(wid: CGWindowID) {
|
||||
throttler.removeEntries(withSuffix: "-wid-\(wid)")
|
||||
}
|
||||
|
||||
static func removeThrottlerEntries(pid: pid_t) {
|
||||
throttler.removeEntries(withSuffix: "-pid-\(pid)")
|
||||
}
|
||||
|
||||
static let axObserverCallback: AXObserverCallback = { _, element, notificationName, _ in
|
||||
let type = notificationName as String
|
||||
Logger.debug { type }
|
||||
AXUIElement.retryAxCallUntilTimeout(context: "(type:\(type)", callType: .entrypointFromAxEvent) {
|
||||
try handleEvent(type, element)
|
||||
AXCallScheduler.shared.submit {
|
||||
do { try handleEvent(type, element) }
|
||||
catch { Logger.debug { "handleEvent threw for \(type): stale element" } }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,17 +16,22 @@ class AccessibilityEvents {
|
||||
let pid = try element.pid()
|
||||
Logger.debug { "\(type) pid:\(pid)" }
|
||||
if [kAXApplicationActivatedNotification, kAXApplicationHiddenNotification, kAXApplicationShownNotification].contains(type) {
|
||||
throttler.throttleOrProceed(key: "\(type)-pid-\(pid)") {
|
||||
AXUIElement.retryAxCallUntilTimeout(context: "(pid:\(pid))", pid: pid, callType: .updateAppFromAxEvent) {
|
||||
try handleEventApp(type, pid, element)
|
||||
}
|
||||
AXCallScheduler.shared.schedule(key: "pid-\(pid)", context: "(pid:\(pid))", pid: pid) {
|
||||
try handleEventApp(type, pid, element)
|
||||
}
|
||||
} else {
|
||||
let wid = (try? element.cgWindowId()) ?? 0
|
||||
throttler.throttleOrProceed(key: "\(type)-wid-\(wid)") {
|
||||
AXUIElement.retryAxCallUntilTimeout(context: "(pid:\(pid))", pid: pid, wid: wid, callType: .updateWindowFromAxEvent) {
|
||||
try handleEventWindow(type, wid, pid, element)
|
||||
guard wid != 0 || type == kAXUIElementDestroyedNotification,
|
||||
wid != TilesPanel.shared.windowNumber else { return }
|
||||
if type == kAXUIElementDestroyedNotification {
|
||||
DispatchQueue.main.async {
|
||||
Logger.info { "\(type) wid:\(wid) pid:\(pid)" }
|
||||
windowDestroyed(element, pid, wid)
|
||||
}
|
||||
return
|
||||
}
|
||||
AXCallScheduler.shared.schedule(key: "wid-\(wid)", context: "(pid:\(pid) wid:\(wid))", pid: pid) {
|
||||
try handleEventWindow(type, wid, pid, element)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,12 +40,14 @@ class AccessibilityEvents {
|
||||
let appFocusedWindow = try element.attributes([kAXFocusedWindowAttribute]).focusedWindow
|
||||
let wid = try appFocusedWindow?.cgWindowId()
|
||||
DispatchQueue.main.async {
|
||||
guard let app = Applications.findOrCreate(pid, false) else { return }
|
||||
Logger.info { "\(type) app:\(app.debugId)" }
|
||||
if type == kAXApplicationActivatedNotification {
|
||||
applicationActivated(app, pid, type, appFocusedWindow, wid)
|
||||
} else if type == kAXApplicationHiddenNotification || type == kAXApplicationShownNotification {
|
||||
applicationHiddenOrShown(app, pid, type)
|
||||
Applications.appListUpdateThrottler.throttleOrProceed(key: "\(pid)") {
|
||||
guard let app = Applications.findOrCreate(pid, false) else { return }
|
||||
Logger.info { "\(type) app:\(app.debugId)" }
|
||||
if type == kAXApplicationActivatedNotification {
|
||||
applicationActivated(app, pid, type, appFocusedWindow, wid)
|
||||
} else if type == kAXApplicationHiddenNotification || type == kAXApplicationShownNotification {
|
||||
applicationHiddenOrShown(app, pid, type)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,7 +59,7 @@ class AccessibilityEvents {
|
||||
}
|
||||
if let appFocusedWindow, let wid {
|
||||
// if there is a focusedWindow, we reuse existing code to process it as if it was a kAXFocusedWindowChangedNotification
|
||||
AXUIElement.retryAxCallUntilTimeout(context: "\(type) \(app.debugId))", pid: pid, wid: wid, callType: .updateWindowFromAxEvent) {
|
||||
AXCallScheduler.shared.schedule(key: "wid-\(wid)", context: "\(type) \(app.debugId))", pid: pid) {
|
||||
try handleEventWindow(kAXFocusedWindowChangedNotification, wid, pid, appFocusedWindow)
|
||||
}
|
||||
} else {
|
||||
@@ -87,15 +85,6 @@ class AccessibilityEvents {
|
||||
}
|
||||
|
||||
static func handleEventWindow(_ type: String, _ wid: CGWindowID, _ pid: pid_t, _ element: AXUIElement) throws {
|
||||
guard wid != 0 || type == kAXUIElementDestroyedNotification,
|
||||
wid != TilesPanel.shared.windowNumber else { return } // don't process events for the thumbnails panel
|
||||
if type == kAXUIElementDestroyedNotification {
|
||||
DispatchQueue.main.async {
|
||||
Logger.info { "\(type) wid:\(wid) pid:\(pid)" }
|
||||
windowDestroyed(element, pid, wid)
|
||||
}
|
||||
return
|
||||
}
|
||||
let level = wid.level()
|
||||
// if we query .children on ourselves, AppKit calls layout directly from our thread instead of IPC; we avoid this
|
||||
let isSelf = pid == ProcessInfo.processInfo.processIdentifier
|
||||
@@ -103,30 +92,32 @@ class AccessibilityEvents {
|
||||
let a = try element.attributes(keys)
|
||||
let tabSiblingTitles = isSelf ? nil : TabGroup.extractTabTitles(a.children)
|
||||
DispatchQueue.main.async {
|
||||
guard let app = Applications.findOrCreate(pid, false) else { return }
|
||||
Logger.info { "\(type) wid:\(wid) app:\(app.debugId)" }
|
||||
let findOrCreate = Windows.findOrCreate(element, wid, app, level, a.title, a.subrole, a.role, a.size, a.position, a.isFullscreen, a.isMinimized)
|
||||
guard let window = findOrCreate.0 else {
|
||||
// we don't know this window, but it got focused, so let's update app.focusedWindow with nil
|
||||
if type == kAXFocusedWindowChangedNotification && a.role != kAXSheetRole {
|
||||
app.focusedWindow = nil
|
||||
Applications.windowListUpdateThrottler.throttleOrProceed(key: "\(wid)") {
|
||||
guard let app = Applications.findOrCreate(pid, false) else { return }
|
||||
Logger.info { "\(type) wid:\(wid) app:\(app.debugId)" }
|
||||
let findOrCreate = Windows.findOrCreate(element, wid, app, level, a.title, a.subrole, a.role, a.size, a.position, a.isFullscreen, a.isMinimized)
|
||||
guard let window = findOrCreate.0 else {
|
||||
// we don't know this window, but it got focused, so let's update app.focusedWindow with nil
|
||||
if type == kAXFocusedWindowChangedNotification && a.role != kAXSheetRole {
|
||||
app.focusedWindow = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
Logger.debug { "\(type) win:\(window.debugId)" }
|
||||
var tabStateChanged = false
|
||||
if tabSiblingTitles != nil || window.tabbedSiblingWids != nil {
|
||||
tabStateChanged = TabGroup.updateState(window, tabSiblingTitles)
|
||||
}
|
||||
if findOrCreate.1 || (tabStateChanged && App.appIsBeingUsed) {
|
||||
App.refreshOpenUiAfterExternalEvent([window])
|
||||
}
|
||||
if type == kAXMainWindowChangedNotification || type == kAXFocusedWindowChangedNotification {
|
||||
focusedWindowChanged(window)
|
||||
} else if type == kAXWindowResizedNotification || type == kAXWindowMovedNotification {
|
||||
windowResizedOrMoved(window)
|
||||
} else if !findOrCreate.1 {
|
||||
App.refreshOpenUiAfterExternalEvent([window])
|
||||
}
|
||||
return
|
||||
}
|
||||
Logger.debug { "\(type) win:\(window.debugId)" }
|
||||
var tabStateChanged = false
|
||||
if tabSiblingTitles != nil || window.tabbedSiblingWids != nil {
|
||||
tabStateChanged = TabGroup.updateState(window, tabSiblingTitles)
|
||||
}
|
||||
if findOrCreate.1 || (tabStateChanged && App.appIsBeingUsed) {
|
||||
App.refreshOpenUiAfterExternalEvent([window])
|
||||
}
|
||||
if type == kAXMainWindowChangedNotification || type == kAXFocusedWindowChangedNotification {
|
||||
focusedWindowChanged(window)
|
||||
} else if type == kAXWindowResizedNotification || type == kAXWindowMovedNotification {
|
||||
windowResizedOrMoved(window)
|
||||
} else if !findOrCreate.1 {
|
||||
App.refreshOpenUiAfterExternalEvent([window])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ class DockEvents {
|
||||
AXObserverCreate(dockPid, handleEvent, &axObserver)
|
||||
// are we sure we always get a non-nil axObserver?
|
||||
for notification in MissionControlState.allCases {
|
||||
AXUIElement.retryAxCallUntilTimeout(callType: .subscribeToDockNotification) {
|
||||
AXCallScheduler.shared.schedule(key: "sub-dock-\(notification.rawValue)", context: "dock", pid: dockPid) {
|
||||
if try axUiElement!.subscribeToNotification(axObserver!, notification.rawValue, nil) {
|
||||
if notification == MissionControlState.showDesktop {
|
||||
Logger.debug { "Subscribed to Dock" }
|
||||
|
||||
Reference in New Issue
Block a user