diff --git a/alt-tab-macos.xcodeproj/project.pbxproj b/alt-tab-macos.xcodeproj/project.pbxproj index d299d99c..c29f0376 100644 --- a/alt-tab-macos.xcodeproj/project.pbxproj +++ b/alt-tab-macos.xcodeproj/project.pbxproj @@ -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 = ""; }; BF0C8EE17F3ABD7A6D44BFFE /* pl */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = pl; path = Localizable.strings; sourceTree = ""; }; BF0C8F58B38F29B2662E6C2D /* PreferencesMigrations.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PreferencesMigrations.swift; sourceTree = ""; }; + BF0C8A1E7D3F94B6E8C52A01 /* Throttler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Throttler.swift; sourceTree = ""; }; BF0C8FAF34022E23582797F3 /* ScrollwheelEvents.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScrollwheelEvents.swift; sourceTree = ""; }; BF0C8FB9777EE9BE74EF542B /* zh-hk */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = "zh-hk"; path = Localizable.strings; sourceTree = ""; }; BF0C8FC083A2443F3EAF5DB7 /* preferences-blacklist.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "preferences-blacklist.jpg"; sourceTree = ""; }; @@ -1410,6 +1412,7 @@ BF0C8D57C86AF492D43A576D /* AppearanceTestable.swift */, BF0C8998862E0E04D60CACBC /* MacroPreferences.swift */, BF0C8F58B38F29B2662E6C2D /* PreferencesMigrations.swift */, + BF0C8A1E7D3F94B6E8C52A01 /* Throttler.swift */, ); path = logic; sourceTree = ""; @@ -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 */, diff --git a/src/api-wrappers/AXUIElement.swift b/src/api-wrappers/AXUIElement.swift index 8f46cef6..51c90269 100644 --- a/src/api-wrappers/AXUIElement.swift +++ b/src/api-wrappers/AXUIElement.swift @@ -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() - - 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 { - private var map = [K: V]() - private let lock = NSLock() - - @discardableResult - func withLock(_ block: (inout [K: V]) -> T) -> T { - lock.lock() - defer { lock.unlock() } - return block(&map) - } - } } /// Attributes diff --git a/src/api-wrappers/Logger.swift b/src/api-wrappers/Logger.swift index 5f6c8f29..fa88a7a8 100644 --- a/src/api-wrappers/Logger.swift +++ b/src/api-wrappers/Logger.swift @@ -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() diff --git a/src/logic/Application.swift b/src/logic/Application.swift index 31ea8d2c..516a1fd5 100644 --- a/src/logic/Application.swift +++ b/src/logic/Application.swift @@ -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() diff --git a/src/logic/Applications.swift b/src/logic/Applications.swift index 29e2994d..6112df8e 100644 --- a/src/logic/Applications.swift +++ b/src/logic/Applications.swift @@ -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) + } } } } diff --git a/src/logic/Preferences.swift b/src/logic/Preferences.swift index 4a6f371d..9f2f1770 100644 --- a/src/logic/Preferences.swift +++ b/src/logic/Preferences.swift @@ -295,7 +295,7 @@ class Preferences { } class CachedUserDefaults { - static var cache = AXUIElement.ConcurrentMap() + static var cache = ConcurrentMap() static func removeFromCache(_ key: String) { cache.withLock { $0.removeValue(forKey: key) } diff --git a/src/logic/Throttler.swift b/src/logic/Throttler.swift new file mode 100644 index 00000000..7781c09e --- /dev/null +++ b/src/logic/Throttler.swift @@ -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() + + 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 { + private var map = [K: V]() + private let lock = NSLock() + + @discardableResult + func withLock(_ block: (inout [K: V]) -> T) -> T { + lock.lock() + defer { lock.unlock() } + return block(&map) + } +} diff --git a/src/logic/Window.swift b/src/logic/Window.swift index f84a1c22..3be8e560 100644 --- a/src/logic/Window.swift +++ b/src/logic/Window.swift @@ -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 { diff --git a/src/logic/Windows.swift b/src/logic/Windows.swift index 97b7f7b2..d2f12762 100644 --- a/src/logic/Windows.swift +++ b/src/logic/Windows.swift @@ -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() } } diff --git a/src/logic/events/AccessibilityEvents.swift b/src/logic/events/AccessibilityEvents.swift index a9f745cd..8ea5d798 100644 --- a/src/logic/events/AccessibilityEvents.swift +++ b/src/logic/events/AccessibilityEvents.swift @@ -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) + } } } } diff --git a/src/logic/events/ScreensEvents.swift b/src/logic/events/ScreensEvents.swift index 01674af1..5c1eb28e 100644 --- a/src/logic/events/ScreensEvents.swift +++ b/src/logic/events/ScreensEvents.swift @@ -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 -} diff --git a/src/logic/events/SpacesEvents.swift b/src/logic/events/SpacesEvents.swift index 214c9da8..4b581f4c 100644 --- a/src/logic/events/SpacesEvents.swift +++ b/src/logic/events/SpacesEvents.swift @@ -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)" } } } } diff --git a/src/ui/App.swift b/src/ui/App.swift index dc66c252..274e246e 100644 --- a/src/ui/App.swift +++ b/src/ui/App.swift @@ -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)" }