fix: switcher may show windows in the wrong order (closes #5346)

This commit is contained in:
lwouis committed 2026-03-04 11:45:58 +01:00
1 parent a809ffad61
commit a632e42fbc
13 files changed
+223 -170

No files matched your search

+4
View File
@@ -95,6 +95,7 @@
BF0C8053A58DFB383A3E3351 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C8E237569FA2A8FE20707 /* InfoPlist.strings */; };
BF0C807D26E465F467B066F7 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C849EBA5A547912BD8BB9 /* Localizable.strings */; };
BF0C80C69EFEF93C18539925 /* PreferencesMigrations.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C8F58B38F29B2662E6C2D /* PreferencesMigrations.swift */; };
BF0C8A2F6E4C85A7D9B63B12 /* Throttler.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C8A1E7D3F94B6E8C52A01 /* Throttler.swift */; };
BF0C80FCECC1386EB73B66E5 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C8E9B9EF1485A14AE3316 /* Localizable.strings */; };
BF0C812C068CD751CE9BFB67 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C8D5993267A74D4875C1A /* Localizable.strings */; };
BF0C81480366BAD71BC9A158 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C8ABA423E72218605CA4F /* InfoPlist.strings */; };
@@ -575,6 +576,7 @@
BF0C8EE00994AC6C8323EE12 /* table-of-contents.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; path = "table-of-contents.js"; sourceTree = "<group>"; };
BF0C8EE17F3ABD7A6D44BFFE /* pl */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = pl; path = Localizable.strings; sourceTree = "<group>"; };
BF0C8F58B38F29B2662E6C2D /* PreferencesMigrations.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PreferencesMigrations.swift; sourceTree = "<group>"; };
BF0C8A1E7D3F94B6E8C52A01 /* Throttler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Throttler.swift; sourceTree = "<group>"; };
BF0C8FAF34022E23582797F3 /* ScrollwheelEvents.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScrollwheelEvents.swift; sourceTree = "<group>"; };
BF0C8FB9777EE9BE74EF542B /* zh-hk */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = "zh-hk"; path = Localizable.strings; sourceTree = "<group>"; };
BF0C8FC083A2443F3EAF5DB7 /* preferences-blacklist.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "preferences-blacklist.jpg"; sourceTree = "<group>"; };
@@ -1410,6 +1412,7 @@
BF0C8D57C86AF492D43A576D /* AppearanceTestable.swift */,
BF0C8998862E0E04D60CACBC /* MacroPreferences.swift */,
BF0C8F58B38F29B2662E6C2D /* PreferencesMigrations.swift */,
BF0C8A1E7D3F94B6E8C52A01 /* Throttler.swift */,
);
path = logic;
sourceTree = "<group>";
@@ -2611,6 +2614,7 @@
BF0C8E366BC25171C0A3881C /* ApplicationServices.HIServices.framework.swift in Sources */,
BF0C803A13C409E7F01FBCAB /* SkyLight.framework.swift in Sources */,
BF0C80C69EFEF93C18539925 /* PreferencesMigrations.swift in Sources */,
BF0C8A2F6E4C85A7D9B63B12 /* Throttler.swift in Sources */,
BF0C8973EC078169170F2634 /* CliEvents.swift in Sources */,
BF0C8521DA3078516615C414 /* HelperExtensionsTestable.swift in Sources */,
BF0C8991399A3277A9A02FFA /* LightImageView.swift in Sources */,
+14 -61
View File
@@ -13,13 +13,6 @@ extension AXUIElement {
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)
private static var throttleMap = ConcurrentMap<String, ThrottleState>()
private struct ThrottleState {
let time: UInt64
var tailScheduled: Bool
}
static func setGlobalTimeout() {
AXUIElementSetMessagingTimeout(AXUIElementCreateSystemWide(), globalMessagingTimeoutInSeconds)
}
@@ -33,20 +26,11 @@ extension AXUIElement {
}
/// 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, isWindowDestroyedEvent: Bool = false, 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, isWindowDestroyedEvent: isWindowDestroyedEvent, callType: callType, block: block) }
let queue = (callType == .updateAppWindows ? BackgroundWork.axCallsManualDiscoveryQueue
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 callType == .updateWindowFromAxEvent || callType == .updateWindowFromManualDiscovery {
guard let wid else { Logger.error { (callType, context, file, function, line) }; return }
// we don't throttle windowDestroyedEvent as these are key events to process
if !isWindowDestroyedEvent && throttleOrProceed(key: "\(callType.rawValue)-wid-\(wid)", queue: queue, closure: closure) { return }
}
if callType == .updateApp || callType == .updateAppWindows || callType == .updateAppFocusedWindow {
guard let pid else { Logger.error { (callType, context, file, function, line) }; return }
if throttleOrProceed(key: "\(callType.rawValue)-pid-\(pid)", queue: queue, closure: closure) { return }
}
if let after {
queue.addOperationAfter(deadline: after, block: closure)
} else {
@@ -54,7 +38,7 @@ extension AXUIElement {
}
}
private static func retryAxCallUntilTimeout_(file: String, function: String, line: Int, context: String, after: DispatchTime?, pid: pid_t?, wid: CGWindowID?, retriesQueue: Bool, startTimeInNanoseconds: UInt64, isWindowDestroyedEvent: Bool, callType: AXCallType, block: @escaping () throws -> Void) {
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
@@ -67,36 +51,13 @@ extension AXUIElement {
}
// 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, isWindowDestroyedEvent: isWindowDestroyedEvent, callType: callType, block: block)
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)"
}
private static func throttleOrProceed(key: String, queue: LabeledOperationQueue, closure: @escaping () -> Void) -> Bool {
let windowNs: UInt64 = 1_000_000_000
return throttleMap.withLock { map in
let now = DispatchTime.now().uptimeNanoseconds
if let state = map[key] {
let elapsed = now >= state.time ? (now - state.time) : windowNs // treat “time in future” as throttled
if elapsed < windowNs {
if !state.tailScheduled {
map[key] = ThrottleState(time: state.time, tailScheduled: true)
let remaining = windowNs - elapsed
queue.addOperationAfter(deadline: .now() + .nanoseconds(Int(remaining))) {
throttleMap.withLock { $0[key] = nil }
closure()
}
}
return true
}
}
map[key] = ThrottleState(time: now, tailScheduled: false)
return false
}
}
@discardableResult
func subscribeToNotification(_ axObserver: AXObserver, _ notification: String, _ callback: (() -> Void)? = nil) throws -> Bool {
let result = AXObserverAddNotification(axObserver, self, notification as CFString, nil)
@@ -115,26 +76,18 @@ extension AXUIElement {
case subscribeToAppNotification
case subscribeToWindowNotification
case subscribeToDockNotification
case updateWindowFromAxEvent
case updateAppWindowsFromManualDiscovery
case updateWindowFromManualDiscovery
case updateApp
case updateAppFocusedWindow
case updateAppWindows
case updateDockBadges
case axEventEntrypoint
case entrypointFromAxEvent
case updateWindowFromAxEvent
case updateAppFromAxEvent
case updateAppFocusedWindowFromWindowInit
case updateDockBadgesFromShowingUi
}
final class ConcurrentMap<K: Hashable, V> {
private var map = [K: V]()
private let lock = NSLock()
@discardableResult
func withLock<T>(_ block: (inout [K: V]) -> T) -> T {
lock.lock()
defer { lock.unlock() }
return block(&map)
}
}
}
/// Attributes
+1 -1
View File
@@ -5,7 +5,7 @@ class Logger {
private static let logger = SwiftyBeaver.self
static let flag = "--logs="
static let longDateTimeFormat = "yyyy-MM-dd HH:mm:ss.SSS"
static let shortDateTimeFormat = "HH:mm:ss"
static let shortDateTimeFormat = "HH:mm:ss.SSS"
static func initialize() {
let console = ConsoleDestination()
+1 -31
View File
@@ -142,7 +142,7 @@ class Application: NSObject {
// apps don't always create kAXApplicationActivatedNotification upon launch; we update frontmostPid in case it has changed
Applications.frontmostPid = NSWorkspace.shared.frontmostApplication?.processIdentifier
// apps don't always send kAXWindowCreatedNotification upon launch; we manually check to prevent missing windows
self?.manuallyUpdateWindows()
if let self { Applications.manuallyUpdateWindows(self) }
}
}
}
@@ -150,36 +150,6 @@ class Application: NSObject {
CFRunLoopAddSource(BackgroundWork.accessibilityEventsThread.runLoop, AXObserverGetRunLoopSource(axObserver), .commonModes)
}
func manuallyUpdateWindows() {
AXUIElement.retryAxCallUntilTimeout(context: debugId, pid: pid, callType: .updateAppWindows) { [weak self] in
guard let self, let axUiElement = self.axUiElement else { return }
let axWindows = try axUiElement.allWindows(self.pid)
guard !axWindows.isEmpty else {
DispatchQueue.main.async { [weak self] in
guard let self else { return }
if self.addWindowlessWindowIfNeeded() != nil {
App.refreshOpenUiAfterExternalEvent([])
}
}
// 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 self.runningApplication.isActive && self.runningApplication.activationPolicy == .regular {
throw AxError.runtimeError
}
return
}
for axWindow in axWindows {
guard let wid = try? axWindow.cgWindowId() else { continue }
AXUIElement.retryAxCallUntilTimeout(context: debugId, pid: pid, wid: wid, callType: .updateWindowFromManualDiscovery) { [weak self] in
try self?.manuallyUpdateWindow(axWindow, wid)
}
}
}
}
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()
+61 -15
View File
@@ -4,6 +4,9 @@ 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)
static let badgesThrottler = Throttler(delayInMs: 1000)
static func initialDiscovery() {
addInitialRunningApplications()
@@ -26,7 +29,43 @@ class Applications {
/// this manually queries the system for windows, and keeps our list in-sync with the actual system
static func addMissingWindows() {
for app in list {
app.manuallyUpdateWindows()
manuallyUpdateWindows(app)
}
}
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 {
DispatchQueue.main.async { [weak app] in
guard let app else { return }
if app.addWindowlessWindowIfNeeded() != nil {
App.refreshOpenUiAfterExternalEvent([])
}
}
// 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
}
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)
}
}
}
}
}
}
@@ -77,25 +116,32 @@ class Applications {
// comparing pid here can fail here, as it can be already nil; we use isEqual here to avoid the issue
list.removeAll { $0.runningApplication.isEqual(tApp) }
}
for tApp in terminatingApps {
let pid = tApp.processIdentifier
manualAppUpdatesThrottler.removeEntry(withKey: "\(pid)")
AccessibilityEvents.removeThrottlerEntries(pid: pid)
}
App.refreshOpenUiAfterExternalEvent([])
}
static func refreshBadgesAsync() {
guard App.appIsBeingUsed && !Preferences.hideAppBadges else { return }
AXUIElement.retryAxCallUntilTimeout(callType: .updateDockBadges) {
guard let dockPid = (list.first { $0.bundleIdentifier == "com.apple.dock" }?.pid),
let axDockChildren = try AXUIElementCreateApplication(dockPid).attributes([kAXChildrenAttribute]).children,
let axList = try (axDockChildren.first { try $0.attributes([kAXRoleAttribute]).role == kAXListRole }),
let axListChildren = try axList.attributes([kAXChildrenAttribute]).children else { return }
let axAppDockItemUrlAndLabel: [(URL?, String?)] = try axListChildren.compactMap {
let a = try $0.attributes([kAXSubroleAttribute, kAXIsApplicationRunningAttribute, kAXURLAttribute, kAXStatusLabelAttribute])
guard a.subrole == kAXApplicationDockItemSubrole && (a.appIsRunning ?? false) else { return nil }
return (a.url, a.statusLabel)
}
guard !axAppDockItemUrlAndLabel.isEmpty else { return }
DispatchQueue.main.async {
guard App.appIsBeingUsed && !Preferences.hideAppBadges else { return }
refreshBadges_(axAppDockItemUrlAndLabel)
badgesThrottler.throttleOrProceed {
AXUIElement.retryAxCallUntilTimeout(callType: .updateDockBadgesFromShowingUi) {
guard let dockPid = (list.first { $0.bundleIdentifier == "com.apple.dock" }?.pid),
let axDockChildren = try AXUIElementCreateApplication(dockPid).attributes([kAXChildrenAttribute]).children,
let axList = try (axDockChildren.first { try $0.attributes([kAXRoleAttribute]).role == kAXListRole }),
let axListChildren = try axList.attributes([kAXChildrenAttribute]).children else { return }
let axAppDockItemUrlAndLabel: [(URL?, String?)] = try axListChildren.compactMap {
let a = try $0.attributes([kAXSubroleAttribute, kAXIsApplicationRunningAttribute, kAXURLAttribute, kAXStatusLabelAttribute])
guard a.subrole == kAXApplicationDockItemSubrole && (a.appIsRunning ?? false) else { return nil }
return (a.url, a.statusLabel)
}
guard !axAppDockItemUrlAndLabel.isEmpty else { return }
DispatchQueue.main.async {
guard App.appIsBeingUsed && !Preferences.hideAppBadges else { return }
refreshBadges_(axAppDockItemUrlAndLabel)
}
}
}
}
+1 -1
View File
@@ -295,7 +295,7 @@ class Preferences {
}
class CachedUserDefaults {
static var cache = AXUIElement.ConcurrentMap<String, Any>()
static var cache = ConcurrentMap<String, Any>()
static func removeFromCache(_ key: String) {
cache.withLock { $0.removeValue(forKey: key) }
+102
View File
@@ -0,0 +1,102 @@
import Cocoa
class Throttler {
private let delayInNanoseconds: UInt64
private var lastTimeInNanoseconds = DispatchTime.now().uptimeNanoseconds
private var nextScheduled = false
init(delayInMs: Int) {
self.delayInNanoseconds = UInt64(delayInMs) * 1_000_000
}
func throttleOrProceed(_ block: @escaping () -> Void) {
let now = DispatchTime.now().uptimeNanoseconds
let (elapsed, overflow) = now.subtractingReportingOverflow(lastTimeInNanoseconds)
if !overflow, elapsed >= delayInNanoseconds {
lastTimeInNanoseconds = now
block()
return
}
guard !nextScheduled else { return }
nextScheduled = true
DispatchQueue.main.asyncAfter(deadline: .now() + .nanoseconds(Int(delayInNanoseconds) + 10_000_000)) { [self] in
nextScheduled = false
throttleOrProceed(block)
}
}
}
class ThrottlerWithKey {
private let delayInNanoseconds: UInt64
private let map = ConcurrentMap<String, ThrottleState>()
private struct ThrottleState {
let time: UInt64
var tailScheduled: Bool
}
init(delayInMs: Int) {
self.delayInNanoseconds = UInt64(delayInMs) * 1_000_000
}
func removeEntry(withKey key: String) {
map.withLock { $0[key] = nil }
}
func removeEntries(withSuffix suffix: String) {
map.withLock { map in
for key in map.keys where key.hasSuffix(suffix) {
map[key] = nil
}
}
}
func throttleOrProceed(key: String, queue: LabeledOperationQueue? = nil, _ block: @escaping () -> Void) {
let shouldThrottle = map.withLock { map in
let now = DispatchTime.now().uptimeNanoseconds
if let state = map[key] {
let elapsed = now >= state.time ? (now - state.time) : delayInNanoseconds
if elapsed < delayInNanoseconds {
if !state.tailScheduled {
map[key] = ThrottleState(time: state.time, tailScheduled: true)
let remaining = delayInNanoseconds - elapsed
if let queue {
queue.addOperationAfter(deadline: .now() + .nanoseconds(Int(remaining))) {
self.map.withLock { $0[key] = nil }
block()
}
} else {
let callerQueue = OperationQueue.current?.underlyingQueue ?? DispatchQueue.main
callerQueue.asyncAfter(deadline: .now() + .nanoseconds(Int(remaining))) {
self.map.withLock { $0[key] = nil }
block()
}
}
}
return true
}
}
map[key] = ThrottleState(time: now, tailScheduled: false)
return false
}
if !shouldThrottle {
if let queue {
queue.addOperation(block)
} else {
block()
}
}
}
}
final class ConcurrentMap<K: Hashable, V> {
private var map = [K: V]()
private let lock = NSLock()
@discardableResult
func withLock<T>(_ block: (inout [K: V]) -> T) -> T {
lock.lock()
defer { lock.unlock() }
return block(&map)
}
}
+1 -1
View File
@@ -322,7 +322,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: .updateAppFocusedWindow) { [weak app] in
AXUIElement.retryAxCallUntilTimeout(context: debugId, pid: app.pid, wid: cgWindowId, callType: .updateAppFocusedWindowFromWindowInit) { [weak app] in
guard let app, let focusedWindow = try appAxUiElement.attributes([kAXFocusedWindowAttribute]).focusedWindow else { return }
let focusedWid = try focusedWindow.cgWindowId()
DispatchQueue.main.async {
+6
View File
@@ -531,6 +531,12 @@ class Windows {
w.lastFocusOrder -= howManyToShift
return false
}
for w in windows {
if let wid = w.cgWindowId {
Applications.manualWindowUpdatesThrottler.removeEntry(withKey: "\(wid)")
AccessibilityEvents.removeThrottlerEntries(wid: wid)
}
}
if addWindowlessWindowIfNeeded {
windows.forEach { $0.application.addWindowlessWindowIfNeeded() }
}
+19 -5
View File
@@ -3,10 +3,20 @@ 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: .axEventEntrypoint) {
AXUIElement.retryAxCallUntilTimeout(context: "(type:\(type)", callType: .entrypointFromAxEvent) {
try handleEvent(type, element)
}
}
@@ -15,13 +25,17 @@ class AccessibilityEvents {
let pid = try element.pid()
Logger.debug { "\(type) pid:\(pid)" }
if [kAXApplicationActivatedNotification, kAXApplicationHiddenNotification, kAXApplicationShownNotification].contains(type) {
AXUIElement.retryAxCallUntilTimeout(context: "(pid:\(pid))", pid: pid, callType: .updateApp) {
try handleEventApp(type, pid, element)
throttler.throttleOrProceed(key: "\(type)-pid-\(pid)") {
AXUIElement.retryAxCallUntilTimeout(context: "(pid:\(pid))", pid: pid, callType: .updateAppFromAxEvent) {
try handleEventApp(type, pid, element)
}
}
} else {
let wid = (try? element.cgWindowId()) ?? 0
AXUIElement.retryAxCallUntilTimeout(context: "(pid:\(pid))", pid: pid, wid: wid, isWindowDestroyedEvent: type == kAXUIElementDestroyedNotification, callType: .updateWindowFromAxEvent) {
try handleEventWindow(type, wid, pid, element)
throttler.throttleOrProceed(key: "\(type)-wid-\(wid)") {
AXUIElement.retryAxCallUntilTimeout(context: "(pid:\(pid))", pid: pid, wid: wid, callType: .updateWindowFromAxEvent) {
try handleEventWindow(type, wid, pid, element)
}
}
}
}
+4 -33
View File
@@ -1,7 +1,7 @@
import Cocoa
class ScreensEvents {
static let debouncerScreenAndSpace = Debouncer(.main)
private static let throttler = Throttler(delayInMs: 200)
static func observe() {
NotificationCenter.default.addObserver(self, selector: #selector(handleEvent), name: NSApplication.didChangeScreenParametersNotification, object: nil)
@@ -9,45 +9,16 @@ class ScreensEvents {
@objc private static func handleEvent(_ notification: Notification) {
// screen notifications often arrive in groups (e.g. 2 in a row in a short time)
// we debounce to minimize work
debouncerScreenAndSpace.debounce(.screenEvent) {
throttler.throttleOrProceed {
Logger.debug { notification.name.rawValue }
Spaces.refresh()
Screens.refresh()
// a screen added or removed, or screen resolution change can mess up layout; we reset components
App.resetPreferencesDependentComponents()
}
}
}
final class Debouncer {
private let queue: DispatchQueue
private var workItem: DispatchWorkItem?
private var contextsBlocks = [DebounceContext: () -> Void]()
init(_ queue: DispatchQueue) {
self.queue = queue
}
func debounce(_ context: DebounceContext, _ block: @escaping () -> Void) {
contextsBlocks[context] = block
workItem?.cancel()
let item = DispatchWorkItem(block: {
for block in self.contextsBlocks.values {
block()
}
// ScreensEvents: a screen added or removed can shuffle windows around Spaces; we refresh them
// SpacesEvents: if UI was kept open during Space transition, the Spaces may be obsolete; we refresh them
// a screen added or removed can shuffle windows around Spaces; we refresh them
App.refreshOpenUiAfterExternalEvent(Windows.list)
Logger.info { "screens:\(NSScreen.screens.map { ($0.cachedUuid() ?? "nil" as CFString, $0.frame) })" }
Logger.info { "currentSpace:\(Spaces.currentSpaceIndex) (id:\(Spaces.currentSpaceId)) spaces:\(Spaces.screenSpacesMap)" }
})
workItem = item
queue.asyncAfter(deadline: .now() + humanPerceptionDelay, execute: item)
}
}
}
enum DebounceContext {
case spaceEvent
case screenEvent
}
+7 -1
View File
@@ -1,12 +1,14 @@
import Cocoa
class SpacesEvents {
private static let throttler = Throttler(delayInMs: 200)
static func observe() {
NSWorkspace.shared.notificationCenter.addObserver(self, selector: #selector(handleEvent), name: NSWorkspace.activeSpaceDidChangeNotification, object: nil)
}
@objc private static func handleEvent(_ notification: Notification) {
ScreensEvents.debouncerScreenAndSpace.debounce(.spaceEvent) {
throttler.throttleOrProceed {
Logger.debug { notification.name.rawValue }
// Workaround for Safari full-screen videos
// when full-screening a video, Safari spawns a second full-screen window called "Safari"
@@ -18,6 +20,10 @@ class SpacesEvents {
let focusedWindow = frontmostApp.focusedWindow {
App.checkIfShortcutsShouldBeDisabled(focusedWindow, nil)
}
// if UI was kept open during Space transition, the Spaces may be obsolete; we refresh them
App.refreshOpenUiAfterExternalEvent(Windows.list)
Logger.info { "screens:\(NSScreen.screens.map { ($0.cachedUuid() ?? "nil" as CFString, $0.frame) })" }
Logger.info { "currentSpace:\(Spaces.currentSpaceIndex) (id:\(Spaces.currentSpaceId)) spaces:\(Spaces.screenSpacesMap)" }
}
}
}
+2 -21
View File
@@ -28,8 +28,7 @@ class App: AppCenterApplication {
private static var appCenterDelegate: AppCenterCrash?
// don't queue multiple delayed rebuildUi() calls
private static var delayedDisplayScheduled = 0
private static var lastRefreshTimeInNanoseconds = DispatchTime.now().uptimeNanoseconds
private static var nextRefreshScheduled = false
private static let refreshOpenUiThrottler = Throttler(delayInMs: 200)
override init() {
super.init()
@@ -266,7 +265,7 @@ class App: AppCenterApplication {
static func refreshOpenUiAfterExternalEvent(_ windowsToScreenshot: [Window], windowRemoved: Bool = false) {
Windows.refreshThumbnailsAsync(windowsToScreenshot, .refreshUiAfterExternalEvent, windowRemoved: windowRemoved)
refreshOpenUiWithThrottling {
refreshOpenUiThrottler.throttleOrProceed {
guard appIsBeingUsed else { return }
if !Windows.updatesBeforeShowing() { hideUi(); return }
refreshUi(true)
@@ -287,24 +286,6 @@ class App: AppCenterApplication {
Applications.refreshBadgesAsync()
}
static func refreshOpenUiWithThrottling(_ block: @escaping () -> Void) {
let throttleDelayInMs = 200
let now = DispatchTime.now().uptimeNanoseconds
let (elapsedInNanoseconds, overflow) = now.subtractingReportingOverflow(lastRefreshTimeInNanoseconds)
let timeSinceLastRefreshInMs = overflow ? 0 : Float(elapsedInNanoseconds) / 1_000_000
if timeSinceLastRefreshInMs >= Float(throttleDelayInMs) {
lastRefreshTimeInNanoseconds = now
block()
return
}
guard !nextRefreshScheduled else { return }
nextRefreshScheduled = true
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(throttleDelayInMs + 10)) {
nextRefreshScheduled = false
refreshOpenUiWithThrottling(block)
}
}
static func showUiOrCycleSelection(_ shortcutIndex: Int, _ forceDoNothingOnRelease_: Bool) {
forceDoNothingOnRelease = forceDoNothingOnRelease_
Logger.debug { "isFirstSummon:\(isFirstSummon) shortcutIndex:\(shortcutIndex)" }