fix: switcher may show windows in the wrong order (closes #5346)
This commit is contained in:
1 parent
a809ffad61
commit
a632e42fbc
13 files changed
+223
-170
No files matched your search
@@ -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 */,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)" }
|
||||
|
||||
Reference in new issue
Block a user