fix: windows would sometimes not update their order (closes #5207)

This commit is contained in:
lwouis committed 2026-01-19 19:35:13 +01:00
1 parent d1fd1fd9d0
commit a3f2a54f20
16 files changed
+862 -966

No files matched your search

+8
View File
@@ -64,6 +64,7 @@
48F3E16224EC0D8800A1C64B /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BABDA79F8EF76E3ACDD5F /* Localizable.strings */; };
5883F0A829A5182C0071DB65 /* TrackpadEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5883F0A729A5182C0071DB65 /* TrackpadEvents.swift */; };
5F04852E2F09AC9700235D4A /* WindowCaptureEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F04852D2F09AC9700235D4A /* WindowCaptureEvents.swift */; };
5F17452E2F1A908D00128EF8 /* WindowDiscriminator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F17452D2F1A907800128EF8 /* WindowDiscriminator.swift */; };
5F21C9E02C6E94240091F72F /* ShortcutsWhenActiveSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F21C9DF2C6E94240091F72F /* ShortcutsWhenActiveSheet.swift */; };
5F21C9E22C6E94410091F72F /* AdditionalControlsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F21C9E12C6E94410091F72F /* AdditionalControlsSheet.swift */; };
5F21C9E42C6E94700091F72F /* CustomizeStyleSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F21C9E32C6E94700091F72F /* CustomizeStyleSheet.swift */; };
@@ -72,6 +73,7 @@
5F4753072CEE5A2E002C6C5E /* AppearanceTestable.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C8D57C86AF492D43A576D /* AppearanceTestable.swift */; };
5F706EC02BA448B1004D542D /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5F706EBE2BA448B1004D542D /* Localizable.strings */; };
5F706EC32BA448D8004D542D /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5F706EC12BA448D8004D542D /* InfoPlist.strings */; };
5F791E4B2F1C65E100B6419C /* ApplicationDiscriminator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F791E4A2F1C65E100B6419C /* ApplicationDiscriminator.swift */; };
5F882F382EC3C63400110A9F /* CustomRecorderControlTestable.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C8DC2A355E29516DD2FAF /* CustomRecorderControlTestable.swift */; };
5F906A342F16A18500B99924 /* Screens.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F906A332F16A18000B99924 /* Screens.swift */; };
5FEFDF2F2F181E120065BAA3 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5FEFDF2D2F181E120065BAA3 /* Localizable.strings */; };
@@ -359,6 +361,7 @@
59DD4BE63D42EEFF1182BA7F /* Pods-alt-tab-macos.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-alt-tab-macos.release.xcconfig"; path = "Target Support Files/Pods-alt-tab-macos/Pods-alt-tab-macos.release.xcconfig"; sourceTree = "<group>"; };
5F04852D2F09AC9700235D4A /* WindowCaptureEvents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowCaptureEvents.swift; sourceTree = "<group>"; };
5F084EAF2EF15E6B00CD212A /* TabbedWindowDetection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabbedWindowDetection.swift; sourceTree = "<group>"; };
5F17452D2F1A907800128EF8 /* WindowDiscriminator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDiscriminator.swift; sourceTree = "<group>"; };
5F21C9DF2C6E94240091F72F /* ShortcutsWhenActiveSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutsWhenActiveSheet.swift; sourceTree = "<group>"; };
5F21C9E12C6E94410091F72F /* AdditionalControlsSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdditionalControlsSheet.swift; sourceTree = "<group>"; };
5F21C9E32C6E94700091F72F /* CustomizeStyleSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomizeStyleSheet.swift; sourceTree = "<group>"; };
@@ -374,6 +377,7 @@
5F2218B02F156BC6005769F9 /* mascot-squirrel.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "mascot-squirrel.png"; sourceTree = "<group>"; };
5F706EBD2BA448B1004D542D /* is */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = is; path = Localizable.strings; sourceTree = "<group>"; };
5F706EC22BA448D8004D542D /* is */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = is; path = InfoPlist.strings; sourceTree = "<group>"; };
5F791E4A2F1C65E100B6419C /* ApplicationDiscriminator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationDiscriminator.swift; sourceTree = "<group>"; };
5F906A332F16A18000B99924 /* Screens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Screens.swift; sourceTree = "<group>"; };
5F9F965C2EAE731200E25498 /* jetbrains.svg */ = {isa = PBXFileReference; lastKnownFileType = text; path = jetbrains.svg; sourceTree = "<group>"; };
5FAE000A2F13B49800FE6246 /* update_readme_and_website.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = update_readme_and_website.sh; sourceTree = "<group>"; };
@@ -1329,6 +1333,8 @@
D04BA0D80B24E72B9B981A1D /* logic */ = {
isa = PBXGroup;
children = (
5F17452D2F1A907800128EF8 /* WindowDiscriminator.swift */,
5F791E4A2F1C65E100B6419C /* ApplicationDiscriminator.swift */,
5F906A332F16A18000B99924 /* Screens.swift */,
D04BACD976030676FD0761D5 /* Windows.swift */,
D04BA9B93823398A542FF7A0 /* Preferences.swift */,
@@ -2455,6 +2461,7 @@
D04BA1B133D53572D7B312C2 /* ThumbnailFontIconView.swift in Sources */,
D04BA1CEC6B9C8945FEC8740 /* ThumbnailView.swift in Sources */,
D04BA14D93726795A6937832 /* LabelAndControl.swift in Sources */,
5F791E4B2F1C65E100B6419C /* ApplicationDiscriminator.swift in Sources */,
D04BA7BE7F3DD24D58ACE942 /* AppearanceTab.swift in Sources */,
D04BAD1BE9DC22C48C53D195 /* AboutTab.swift in Sources */,
D04BA15A1B0C4871EA7CB899 /* GeneralTab.swift in Sources */,
@@ -2465,6 +2472,7 @@
D04BAB4EB890853B5B9B2C61 /* BoldLabel.swift in Sources */,
D04BAABE804F3769CE22BEB6 /* HyperlinkLabel.swift in Sources */,
D04BAD2A7F2E8BF64EE982E9 /* TextArea.swift in Sources */,
5F17452E2F1A908D00128EF8 /* WindowDiscriminator.swift in Sources */,
D04BA11E56383D082D7BE5A5 /* ThumbnailsView.swift in Sources */,
5F21C9E42C6E94700091F72F /* CustomizeStyleSheet.swift in Sources */,
D04BA1621718FF26F8D5E75D /* CustomRecorderControl.swift in Sources */,
+115 -308
View File
@@ -6,6 +6,7 @@ import ApplicationServices.HIServices.AXRoleConstants
import ApplicationServices.HIServices.AXAttributeConstants
import ApplicationServices.HIServices.AXActionConstants
/// common, subscriptions, concurrency
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
@@ -21,14 +22,12 @@ extension AXUIElement {
AXUIElementSetMessagingTimeout(AXUIElementCreateSystemWide(), globalMessagingTimeoutInSeconds)
}
enum AXCallType: Int {
case subscribeToAppNotification
case subscribeToWindowNotification
case subscribeToDockNotification
case updateWindow
case updateAppWindows
case updateDockBadges
case axEventEntrypoint
private func throwIfNotSuccess(_ result: AXError) throws -> Void {
// .cannotComplete can happen if the app is unresponsive
if result == .cannotComplete {
throw AxError.runtimeError
}
// for success or other errors we don't throw
}
/// 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
@@ -99,14 +98,45 @@ extension AXUIElement {
return "Context: \((file as NSString).lastPathComponent):\(line) \(function) \(String(describing: callType)) \(context)"
}
func throwIfNotSuccess(_ result: AXError) throws -> Void {
// .cannotComplete can happen if the app is unresponsive
if result == .cannotComplete {
throw AxError.runtimeError
@discardableResult
func subscribeToNotification(_ axObserver: AXObserver, _ notification: String, _ callback: (() -> Void)? = nil) throws -> Bool {
let result = AXObserverAddNotification(axObserver, self, notification as CFString, nil)
if result == .success || result == .notificationAlreadyRegistered {
return true
}
// for success or other errors we don't throw
if result == .notificationUnsupported || result == .notImplemented {
// subscription will never succeed
return false
}
// temporary issue; subscription may succeed if retried
throw AxError.runtimeError
}
enum AXCallType: Int {
case subscribeToAppNotification
case subscribeToWindowNotification
case subscribeToDockNotification
case updateWindow
case updateAppWindows
case updateDockBadges
case axEventEntrypoint
}
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
extension AXUIElement {
// periphery:ignore
func id() -> AXUIElementID? {
let pointer = UnsafeRawPointer(Unmanaged.passUnretained(self).toOpaque()).advanced(by: 0x20)
@@ -128,94 +158,62 @@ extension AXUIElement {
return pid
}
func attribute<T>(_ key: String, _ _: T.Type) throws -> T? {
var attributeValue: AnyObject?
try throwIfNotSuccess(AXUIElementCopyAttributeValue(self, key as CFString, &attributeValue))
return attributeValue as? T
}
func windowAttributes() throws -> (String?, String?, String?, Bool, Bool)? {
let attributes = [
kAXTitleAttribute,
kAXRoleAttribute,
kAXSubroleAttribute,
kAXMinimizedAttribute,
kAXFullscreenAttribute,
]
func attributes(_ keys: [String]) throws -> AXAttributes {
var values: CFArray?
try throwIfNotSuccess(AXUIElementCopyMultipleAttributeValues(self, attributes as CFArray, [], &values))
if let array = (values as? Array<Any>) {
return (
array[0] as? String,
array[1] as? String,
array[2] as? String,
// if the value is nil, we return false. This avoid returning Bool?; simplifies things
(array[3] as? Bool) ?? false,
// if the value is nil, we return false. This avoid returning Bool?; simplifies things
(array[4] as? Bool) ?? false
)
}
return nil
}
private func value<T>(_ key: String, _ target: T, _ type: AXValueType) throws -> T? {
if let a = try attribute(key, AXValue.self) {
var value = target
_ = withUnsafePointer(to: &value) {
AXValueGetValue(a, type, UnsafeMutableRawPointer(mutating: $0))
// without .stopOnError, AXUIElementCopyMultipleAttributeValues always returns an array. it contains placeholder values.
// This makes it very hard to know what's real. For example, it can return a MainWindow that's not nil, but has its attributes zero'd
try throwIfNotSuccess(AXUIElementCopyMultipleAttributeValues(self, keys as CFArray, [.stopOnError], &values))
let array = values as? [Any] ?? []
var result = AXAttributes()
for (index, key) in keys.enumerated() {
guard index < array.count else { continue }
let value = array[index]
switch key {
case kAXTitleAttribute: result.title = value as? String
case kAXRoleAttribute: result.role = value as? String
case kAXSubroleAttribute: result.subrole = value as? String
case kAXStatusLabelAttribute: result.statusLabel = value as? String
case kAXMinimizedAttribute: result.isMinimized = value as? Bool
case kAXFullscreenAttribute: result.isFullscreen = value as? Bool
case kAXIsApplicationRunningAttribute: result.appIsRunning = value as? Bool
case kAXURLAttribute: result.url = value as? URL
case kAXParentAttribute: result.parent = unsafeDowncast(value as CFTypeRef, to: AXUIElement.self)
case kAXFocusedWindowAttribute: result.focusedWindow = unsafeDowncast(value as CFTypeRef, to: AXUIElement.self)
case kAXMainWindowAttribute: result.mainWindow = unsafeDowncast(value as CFTypeRef, to: AXUIElement.self)
case kAXCloseButtonAttribute: result.closeButton = unsafeDowncast(value as CFTypeRef, to: AXUIElement.self)
case kAXChildrenAttribute: result.children = unsafeDowncast(value as CFTypeRef, to: CFArray.self) as? [AXUIElement]
case kAXWindowsAttribute: result.windows = unsafeDowncast(value as CFTypeRef, to: CFArray.self) as? [AXUIElement]
case kAXPositionAttribute: result.position = unboxAxValue(value, .cgPoint)
case kAXSizeAttribute: result.size = unboxAxValue(value, .cgSize)
default: Logger.error { "key:\(key) value:\(value)" }
}
return value
}
return nil
return result
}
func title() throws -> String? {
return try attribute(kAXTitleAttribute, String.self)
func unboxAxValue<T>(_ attributeValue: Any, _ axType: AXValueType, as _: T.Type = T.self) -> T? {
let axValue = unsafeDowncast(attributeValue as CFTypeRef, to: AXValue.self)
let result = UnsafeMutablePointer<T>.allocate(capacity: 1)
defer { result.deallocate() }
guard AXValueGetValue(axValue, axType, result) else {
return nil
}
return result.pointee
}
// periphery:ignore
func parent() throws -> AXUIElement? {
return try attribute(kAXParentAttribute, AXUIElement.self)
}
func children() throws -> [AXUIElement]? {
return try attribute(kAXChildrenAttribute, [AXUIElement].self)
}
func isMinimized() throws -> Bool {
// if the AX call doesn't return, we return false. This avoid returning Bool?; simplifies things
return try attribute(kAXMinimizedAttribute, Bool.self) == true
}
func isFullscreen() throws -> Bool {
// if the AX call doesn't return, we return false. This avoid returning Bool?; simplifies things
return try attribute(kAXFullscreenAttribute, Bool.self) == true
}
func focusedWindow() throws -> AXUIElement? {
return try attribute(kAXFocusedWindowAttribute, AXUIElement.self)
}
func role() throws -> String? {
return try attribute(kAXRoleAttribute, String.self)
}
func subrole() throws -> String? {
return try attribute(kAXSubroleAttribute, String.self)
}
func closeButton() throws -> AXUIElement? {
return try attribute(kAXCloseButtonAttribute, AXUIElement.self)
}
func appIsRunning() throws -> Bool? {
return try attribute(kAXIsApplicationRunningAttribute, Bool.self)
/// we combine both the normal approach and brute-force to get all possible windows
/// with only normal approach: we miss other-Spaces windows
/// with only brute-force approach: we miss windows when the app launches (e.g. launch Note.app: first window is not found by brute-force)
func allWindows(_ pid: pid_t) throws -> [AXUIElement] {
let aWindows = try windows()
let bWindows = Self.windowsByBruteForce(pid)
return Array(Set(aWindows + bWindows))
}
/// doesn't return windows on other Spaces
/// use windowsByBruteForce if you want those
func windows() throws -> [AXUIElement] {
let windows = try attribute(kAXWindowsAttribute, [AXUIElement].self)
private func windows() throws -> [AXUIElement] {
let windows = try attributes([kAXWindowsAttribute]).windows
if let windows,
!windows.isEmpty {
// bug in macOS: sometimes the OS returns multiple duplicate windows (e.g. Mail.app starting at login)
@@ -227,23 +225,6 @@ extension AXUIElement {
return []
}
func position() throws -> CGPoint? {
return try value(kAXPositionAttribute, CGPoint.zero, .cgPoint)
}
func size() throws -> CGSize? {
return try value(kAXSizeAttribute, CGSize.zero, .cgSize)
}
/// we combine both the normal approach and brute-force to get all possible windows
/// with only normal approach: we miss other-Spaces windows
/// with only brute-force approach: we miss windows when the app launches (e.g. launch Note.app: first window is not found by brute-force)
func allWindows(_ pid: pid_t) throws -> [AXUIElement] {
let aWindows = try windows()
let bWindows = AXUIElement.windowsByBruteForce(pid)
return Array(Set(aWindows + bWindows))
}
/// brute-force getting the windows of a process by iterating over AXUIElementID one by one
private static func windowsByBruteForce(_ pid: pid_t) -> [AXUIElement] {
// we use this to call _AXUIElementCreateWithRemoteToken; we reuse the object for performance
@@ -259,7 +240,7 @@ extension AXUIElement {
for axUiElementId: AXUIElementID in 0..<1000 {
remoteToken.replaceSubrange(12..<20, with: withUnsafeBytes(of: axUiElementId) { Data($0) })
if let axUiElement = _AXUIElementCreateWithRemoteToken(remoteToken as CFData)?.takeRetainedValue(),
let subrole = try? axUiElement.subrole(),
let subrole = try? axUiElement.attributes([kAXSubroleAttribute]).subrole,
[kAXStandardWindowSubrole, kAXDialogSubrole].contains(subrole) {
axWindows.append(axUiElement)
}
@@ -269,177 +250,10 @@ extension AXUIElement {
}
return axWindows
}
}
static func isActualWindow(_ app: Application, _ wid: CGWindowID, _ level: CGWindowLevel, _ title: String?, _ subrole: String?, _ role: String?, _ size: CGSize?) -> Bool {
// Some non-windows have title: nil (e.g. some OS elements)
// Some non-windows have subrole: nil (e.g. some OS elements), "AXUnknown" (e.g. Bartender), "AXSystemDialog" (e.g. Intellij tooltips)
// Minimized windows or windows of a hidden app have subrole "AXDialog"
// Activity Monitor main window subrole is "AXDialog" for a brief moment at launch; it then becomes "AXStandardWindow"
// Some non-windows have cgWindowId == 0 (e.g. windows of apps starting at login with the checkbox "Hidden" checked)
return wid != 0
// Finder's file copy dialogs are wide but < 100 height (see https://github.com/lwouis/alt-tab-macos/issues/1466)
// Sonoma introduced a bug: a caps-lock & language indicators shows as a small window.
// We try to hide it by filtering out tiny windows
&& size != nil && (size!.width > 100 && size!.height > 50) && (
(
books(app) ||
keynote(app) ||
preview(app, subrole) ||
iina(app) ||
openFlStudio(app, title) ||
crossoverWindow(app, role, subrole, level) ||
isAlwaysOnTopScrcpy(app, level, role, subrole)
) || (
(
[kAXStandardWindowSubrole, kAXDialogSubrole].contains(subrole) ||
openBoard(app) ||
adobeAudition(app, subrole) ||
adobeAfterEffects(app, subrole) ||
steam(app, title, role) ||
worldOfWarcraft(app, role) ||
battleNetBootstrapper(app, role) ||
firefox(app, role, size) ||
vlcFullscreenVideo(app, role) ||
sanGuoShaAirWD(app) ||
dvdFab(app) ||
drBetotte(app) ||
androidEmulator(app, title) ||
autocad(app, subrole)
) && (
mustHaveIfJetbrainApp(app, title, subrole, size!) &&
mustHaveIfSteam(app, title, role) &&
mustHaveIfFusion360(app, title, role) &&
mustHaveIfColorSlurp(app, subrole)
)
)
)
}
private static func mustHaveIfFusion360(_ app: Application, _ title: String?, _ role: String?) -> Bool {
// filter out Autodesk Fusion side panels "Browser" and "Comments" with subrole AXDialog but with no title
return app.bundleIdentifier != "com.autodesk.fusion360" || (title != nil && title != "")
}
private static func mustHaveIfJetbrainApp(_ app: Application, _ title: String?, _ subrole: String?, _ size: NSSize) -> Bool {
// jetbrain apps sometimes generate non-windows that pass all checks in isActualWindow
// they have no title, so we can filter them out based on that
// we also hide windows too small
return app.bundleIdentifier?.range(of: "^com\\.(jetbrains\\.|google\\.android\\.studio).*?$", options: .regularExpression) == nil || (
(subrole == kAXStandardWindowSubrole || (title != nil && title != "")) &&
size.width > 100 && size.height > 100
)
}
private static func mustHaveIfColorSlurp(_ app: Application, _ subrole: String?) -> Bool {
return app.bundleIdentifier != "com.IdeaPunch.ColorSlurp" || subrole == kAXStandardWindowSubrole
}
private static func iina(_ app: Application) -> Bool {
// IINA.app can have videos float (level == 2 instead of 0)
// there is also complex animations during which we may or may not consider the window not a window
return app.bundleIdentifier == "com.colliderli.iina"
}
private static func keynote(_ app: Application) -> Bool {
// apple Keynote has a fake fullscreen window when in presentation mode
// it covers the screen with a AXUnknown window instead of using standard fullscreen mode
return app.bundleIdentifier == "com.apple.iWork.Keynote"
}
private static func preview(_ app: Application, _ subrole: String?) -> Bool {
// when opening multiple documents at once with apple Preview,
// one of the window will have level == 1 for some reason
return app.bundleIdentifier == "com.apple.Preview" && [kAXStandardWindowSubrole, kAXDialogSubrole].contains(subrole)
}
private static func openFlStudio(_ app: Application, _ title: String?) -> Bool {
// OpenBoard is a ported app which doesn't use standard macOS windows
return app.bundleIdentifier == "com.image-line.flstudio" && (title != nil && title != "")
}
private static func openBoard(_ app: Application) -> Bool {
// OpenBoard is a ported app which doesn't use standard macOS windows
return app.bundleIdentifier == "org.oe-f.OpenBoard"
}
private static func adobeAudition(_ app: Application, _ subrole: String?) -> Bool {
return app.bundleIdentifier == "com.adobe.Audition" && subrole == kAXFloatingWindowSubrole
}
private static func adobeAfterEffects(_ app: Application, _ subrole: String?) -> Bool {
return app.bundleIdentifier == "com.adobe.AfterEffects" && subrole == kAXFloatingWindowSubrole
}
private static func books(_ app: Application) -> Bool {
// Books.app has animations on window creation. This means windows are originally created with subrole == AXUnknown or isOnNormalLevel == false
return app.bundleIdentifier == "com.apple.iBooksX"
}
private static func worldOfWarcraft(_ app: Application, _ role: String?) -> Bool {
return app.bundleIdentifier == "com.blizzard.worldofwarcraft" && role == kAXWindowRole
}
private static func battleNetBootstrapper(_ app: Application, _ role: String?) -> Bool {
// Battlenet bootstrapper windows have subrole == AXUnknown
return app.bundleIdentifier == "net.battle.bootstrapper" && role == kAXWindowRole
}
private static func drBetotte(_ app: Application) -> Bool {
return app.bundleIdentifier == "com.ssworks.drbetotte"
}
private static func dvdFab(_ app: Application) -> Bool {
return app.bundleIdentifier == "com.goland.dvdfab.macos"
}
private static func sanGuoShaAirWD(_ app: Application) -> Bool {
return app.bundleIdentifier == "SanGuoShaAirWD"
}
private static func steam(_ app: Application, _ title: String?, _ role: String?) -> Bool {
// All Steam windows have subrole == AXUnknown
// some dropdown menus are not desirable; they have title == "", or sometimes role == nil when switching between menus quickly
return app.bundleIdentifier == "com.valvesoftware.steam" && (title != nil && title != "" && role != nil)
}
private static func mustHaveIfSteam(_ app: Application, _ title: String?, _ role: String?) -> Bool {
// All Steam windows have subrole == AXUnknown
// some dropdown menus are not desirable; they have title == "", or sometimes role == nil when switching between menus quickly
return app.bundleIdentifier != "com.valvesoftware.steam" || (title != nil && title != "" && role != nil)
}
private static func firefox(_ app: Application, _ role: String?, _ size: CGSize?) -> Bool {
// Firefox fullscreen video have subrole == AXUnknown if fullscreen'ed when the base window is not fullscreen
// Firefox tooltips are implemented as windows with subrole == AXUnknown
return (app.bundleIdentifier?.hasPrefix("org.mozilla.firefox") ?? false) && role == kAXWindowRole && size?.height != nil && size!.height > 400
}
private static func vlcFullscreenVideo(_ app: Application, _ role: String?) -> Bool {
// VLC fullscreen video have subrole == AXUnknown if fullscreen'ed
return (app.bundleIdentifier?.hasPrefix("org.videolan.vlc") ?? false) && role == kAXWindowRole
}
private static func androidEmulator(_ app: Application, _ title: String?) -> Bool {
// android emulator small vertical menu is a "window" with empty title; we exclude it
return title != "" && Applications.isAndroidEmulator(app.bundleIdentifier, app.pid)
}
private static func crossoverWindow(_ app: Application, _ role: String?, _ subrole: String?, _ level: CGWindowLevel) -> Bool {
return app.bundleIdentifier == nil && role == kAXWindowRole && subrole == kAXUnknownSubrole && level == CGWindow.normalLevel
&& (app.localizedName == "wine64-preloader" || app.executableURL?.absoluteString.contains("/winetemp-") ?? false)
}
private static func isAlwaysOnTopScrcpy(_ app: Application, _ level: CGWindowLevel, _ role: String?, _ subrole: String?) -> Bool {
// scrcpy presents as a floating window when "Always on top" is enabled, so it doesn't get picked up normally.
// It also doesn't have a bundle ID, so we need to match using the localized name, which should always be the same.
return app.localizedName == "scrcpy" && level == CGWindow.floatingWindow && role == kAXWindowRole && subrole == kAXStandardWindowSubrole
}
private static func autocad(_ app: Application, _ subrole: String?) -> Bool {
// AutoCAD uses the undocumented "AXDocumentWindow" subrole
return (app.bundleIdentifier?.hasPrefix("com.autodesk.AutoCAD") ?? false) && subrole == kAXDocumentWindowSubrole
}
/// Actions
extension AXUIElement {
func focusWindow() throws {
try performAction(kAXRaiseAction)
}
@@ -451,24 +265,6 @@ extension AXUIElement {
func performAction(_ action: String) throws {
try throwIfNotSuccess(AXUIElementPerformAction(self, action as CFString))
}
@discardableResult
func subscribeToNotification(_ axObserver: AXObserver, _ notification: String, _ callback: (() -> Void)? = nil) throws -> Bool {
let result = AXObserverAddNotification(axObserver, self, notification as CFString, nil)
if result == .success || result == .notificationAlreadyRegistered {
return true
}
if result == .notificationUnsupported || result == .notImplemented {
// subscription will never succeed
return false
}
// temporary issue; subscription may succeed if retried
throw AxError.runtimeError
}
}
enum AxError: Error {
case runtimeError
}
/// tests have shown that this ID has a range going from 0 to probably UInt.MAX
@@ -477,19 +273,30 @@ enum AxError: Error {
/// we don't know how high it can go, and if it wraps around
typealias AXUIElementID = UInt64
enum AxError: Error {
case runtimeError
}
struct AXAttributes {
var title: String?
var role: String?
var subrole: String?
var isMinimized: Bool?
var isFullscreen: Bool?
var parent: AXUIElement?
var children: [AXUIElement]?
var focusedWindow: AXUIElement?
var mainWindow: AXUIElement?
var closeButton: AXUIElement?
var appIsRunning: Bool?
var url: URL?
var statusLabel: String?
var windows: [AXUIElement]?
var position: CGPoint?
var size: CGSize?
}
enum DebounceType: Int {
case windowResizedOrMoved
case windowTitleChanged
}
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)
}
}
+47 -74
View File
@@ -81,7 +81,7 @@ class Application: NSObject {
runningApplication.observe(\.activationPolicy, options: [.new]) { [weak self] _, _ in
guard let self else { return }
if self.runningApplication.activationPolicy != .regular {
self.removeWindowslessAppWindow()
self.removeWindowlessAppWindow()
}
self.observeEventsIfEligible()
},
@@ -92,12 +92,6 @@ class Application: NSObject {
Logger.debug { self.debugId() }
}
func removeWindowslessAppWindow() {
if let windowlessAppWindow = (Windows.list.firstIndex { $0.isWindowlessApp == true && $0.application.pid == pid }) {
Windows.list.remove(at: windowlessAppWindow)
App.app.refreshOpenUi([], .refreshUiAfterExternalEvent)
}
}
func observeEventsIfEligible() {
if runningApplication.activationPolicy != .prohibited && !isReallyFinishedLaunching {
@@ -111,36 +105,36 @@ class Application: NSObject {
}
}
func manuallyUpdateWindows() {
AXUIElement.retryAxCallUntilTimeout(context: debugId(), pid: pid, callType: .updateAppWindows) { [weak self] in
guard let self, let axUiElement = self.axUiElement else { return }
var atLeastOneActualWindow = false
let axWindows = try axUiElement.allWindows(self.pid)
for axWindow in axWindows {
let wid = try axWindow.cgWindowId()
if let (title, role, subrole, isMinimized, isFullscreen) = try axWindow.windowAttributes() {
let size = try axWindow.size()
let level = wid.level()
if AXUIElement.isActualWindow(self, wid, level, title, subrole, role, size) {
let position = try axWindow.position()
atLeastOneActualWindow = true
DispatchQueue.main.async { [weak self] in
guard let self else { return }
if let window = (Windows.list.first { $0.isEqualRobust(axWindow, wid) }) {
window.title = window.bestEffortTitle(title)
window.size = size
window.isFullscreen = isFullscreen
window.isMinimized = isMinimized
window.position = position
} else {
let window = self.addWindow(axWindow, wid, title, isFullscreen, isMinimized, position, size)
App.app.refreshOpenUi([window], .refreshUiAfterExternalEvent)
}
private func observeEvents() {
guard let axObserver else { return }
AXUIElement.retryAxCallUntilTimeout(context: debugId(), pid: pid, callType: .subscribeToAppNotification) { [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())" }
if !self.isReallyFinishedLaunching {
// some apps have `isFinishedLaunching == true` but are actually not finished, and will return .cannotComplete
// we consider them ready when the first subscription succeeds
// 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
try self?.axUiElement!.subscribeToNotification(axObserver, notification)
}
}
DispatchQueue.main.async { [weak self] in
self?.manuallyUpdateWindows()
}
}
}
if (!atLeastOneActualWindow) {
}
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 {
@@ -155,18 +149,31 @@ class Application: NSObject {
if self.runningApplication.isActive && self.runningApplication.activationPolicy == .regular {
throw AxError.runtimeError
}
return
}
for axWindow in axWindows {
let wid = try axWindow.cgWindowId()
guard wid != 0 else { continue } // some bogus "windows" have wid 0
try AccessibilityEvents.handleEventWindow(kAXWindowCreatedNotification, wid, pid, axWindow)
}
}
}
@discardableResult
func addWindowlessWindowIfNeeded() -> Window? {
if runningApplication.activationPolicy == .regular && !runningApplication.isTerminated
&& (Windows.list.firstIndex { $0.application.pid == pid }) == nil {
let window = Window(self)
Windows.appendAndUpdateFocus(window)
return window
}
return nil
guard runningApplication.activationPolicy == .regular && !runningApplication.isTerminated
&& !(Windows.list.contains { $0.application.pid == pid }) else { return nil }
let window = Window(self)
Windows.appendWindow(window)
focusedWindow = nil
App.app.refreshOpenUi([], .refreshUiAfterExternalEvent)
return window
}
func removeWindowlessAppWindow() {
guard let windowlessAppWindow = (Windows.list.first { $0.isWindowlessApp == true && $0.application.pid == pid }) else { return }
Windows.removeWindows([windowlessAppWindow], false)
App.app.refreshOpenUi([], .refreshUiAfterExternalEvent)
}
func hideOrShow() {
@@ -194,38 +201,4 @@ class Application: NSObject {
alreadyRequestedToQuit = true
}
}
private func addWindow(_ axUiElement: AXUIElement, _ wid: CGWindowID, _ axTitle: String?, _ isFullscreen: Bool, _ isMinimized: Bool, _ position: CGPoint?, _ size: CGSize?) -> Window {
let window = Window(axUiElement, self, wid, axTitle, isFullscreen, isMinimized, position, size)
Windows.appendAndUpdateFocus(window)
if App.app.appIsBeingUsed {
Windows.cycleFocusedWindowIndex(1)
}
return window
}
private func observeEvents() {
guard let axObserver else { return }
AXUIElement.retryAxCallUntilTimeout(context: debugId(), pid: pid, callType: .subscribeToAppNotification) { [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())" }
if !self.isReallyFinishedLaunching {
// some apps have `isFinishedLaunching == true` but are actually not finished, and will return .cannotComplete
// we consider them ready when the first subscription succeeds
// 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
try self?.axUiElement!.subscribeToNotification(axObserver, notification)
}
}
DispatchQueue.main.async { [weak self] in
self?.manuallyUpdateWindows()
}
}
}
}
CFRunLoopAddSource(BackgroundWork.accessibilityEventsThread.runLoop, AXObserverGetRunLoopSource(axObserver), .commonModes)
}
}
+30
View File
@@ -0,0 +1,30 @@
class ApplicationDiscriminator {
static func isActualApplication(_ processIdentifier: pid_t, _ bundleIdentifier: String?) -> Bool {
// an app can start with .activationPolicy == .prohibited, then transition to != .prohibited later
// an app can be both activationPolicy == .accessory and XPC (e.g. com.apple.dock.etci)
return (isNotXpc(processIdentifier) || isPasswords(bundleIdentifier) || isAndroidEmulator(bundleIdentifier, processIdentifier)) && !processIdentifier.isZombie()
}
private static func isNotXpc(_ processIdentifier: pid_t) -> Bool {
// these private APIs are more reliable than Bundle.init? as it can return nil (e.g. for com.apple.dock.etci)
var psn = ProcessSerialNumber()
GetProcessForPID(processIdentifier, &psn)
var info = ProcessInfoRec()
GetProcessInformation(&psn, &info)
return String(info.processType) != "XPC!"
}
private static func isPasswords(_ bundleIdentifier: String?) -> Bool {
return bundleIdentifier == "com.apple.Passwords"
}
static func isAndroidEmulator(_ bundleIdentifier: String?, _ processIdentifier: pid_t) -> Bool {
// NSRunningApplication provides no way to identify the emulator; we pattern match on its KERN_PROCARGS
if bundleIdentifier == nil,
let executablePath = Sysctl.run([CTL_KERN, KERN_PROCARGS, processIdentifier]) {
// example path: ~/Library/Android/sdk/emulator/qemu/darwin-x86_64/qemu-system-x86_64
return executablePath.range(of: "qemu-system[^/]*$", options: .regularExpression, range: nil, locale: nil) != nil
}
return false
}
}
+31 -63
View File
@@ -47,10 +47,10 @@ class Applications {
let believedAlive = Set(wIds)
let confirmedAlive = Set(existingWids)
let zombies = believedAlive.subtracting(confirmedAlive)
for (index, window) in Windows.list.enumerated().reversed() {
for window in Windows.list.reversed() {
if let wid = window.cgWindowId, zombies.contains(wid) {
Logger.debug { window.debugId() }
Windows.removeWindow(index, window.application.pid)
Windows.removeWindows([window], true)
}
}
}
@@ -63,8 +63,8 @@ class Applications {
DockEvents.observe(processIdentifier)
}
// com.apple.universalcontrol always fails subscribeToNotification. We blacklist it to save resources on everyone's machines
if bundleIdentifier != "com.apple.universalcontrol" && isActualApplication(processIdentifier, bundleIdentifier) {
Applications.list.append(Application($0))
if bundleIdentifier != "com.apple.universalcontrol" {
findOrCreate(processIdentifier)
}
}
}
@@ -73,49 +73,38 @@ class Applications {
let existingAppsToRemove = list.filter { app in terminatingApps.contains { tApp in app.runningApplication.isEqual(tApp) } }
let existingWindowstoRemove = Windows.list.filter { window in terminatingApps.contains { tApp in window.application.runningApplication.isEqual(tApp) } }
if existingAppsToRemove.isEmpty && existingWindowstoRemove.isEmpty { return }
var windowsOnTheLeftOfFocusedWindow = 0
for tApp in terminatingApps {
for (index, window) in Windows.list.enumerated() {
if window.application.runningApplication.isEqual(tApp)
&& index < Windows.focusedWindowIndex && window.shouldShowTheUser {
windowsOnTheLeftOfFocusedWindow += 1
}
}
Windows.removeWindows(Windows.list.filter { $0.application.runningApplication.isEqual(tApp) }, false)
// comparing pid here can fail here, as it can be already nil; we use isEqual here to avoid the issue
Applications.list.removeAll { $0.runningApplication.isEqual(tApp) }
Windows.list.removeAll { $0.application.runningApplication.isEqual(tApp) }
}
if Windows.list.count == 0 {
App.app.hideUi()
} else {
if windowsOnTheLeftOfFocusedWindow > 0 {
Windows.cycleFocusedWindowIndex(-windowsOnTheLeftOfFocusedWindow)
}
if !existingWindowstoRemove.isEmpty {
App.app.refreshOpenUi([], .refreshUiAfterExternalEvent)
}
list.removeAll { $0.runningApplication.isEqual(tApp) }
}
App.app.refreshOpenUi([], .refreshUiAfterExternalEvent)
}
static func refreshBadgesAsync() {
if !App.app.appIsBeingUsed || Preferences.hideAppBadges { return }
guard App.app.appIsBeingUsed && !Preferences.hideAppBadges else { return }
AXUIElement.retryAxCallUntilTimeout(callType: .updateDockBadges) {
if let dockPid = (list.first { $0.bundleIdentifier == "com.apple.dock" }?.pid),
let axList = (try AXUIElementCreateApplication(dockPid).children()?.first { try $0.role() == kAXListRole }),
let axAppDockItem = (try axList.children()?.filter { try $0.subrole() == kAXApplicationDockItemSubrole && ($0.appIsRunning() ?? false) }) {
let axAppDockItemUrlAndLabel = try axAppDockItem.map { try ($0.attribute(kAXURLAttribute, URL.self), $0.attribute(kAXStatusLabelAttribute, String.self)) }
DispatchQueue.main.async {
refreshBadges_(axAppDockItemUrlAndLabel)
}
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.app.appIsBeingUsed && !Preferences.hideAppBadges else { return }
refreshBadges_(axAppDockItemUrlAndLabel)
}
}
}
static func refreshBadges_(_ items: [(URL?, String?)]) {
Windows.list.enumerated().forEach { (i, window) in
if !App.app.appIsBeingUsed { return }
let view = ThumbnailsView.recycledViews[i]
if let app = Applications.find(window.application.pid) {
if let app = findOrCreate(window.application.pid) {
if app.runningApplication.activationPolicy == .regular,
let matchingItem = (items.first { $0.0 == app.bundleURL }),
let label = matchingItem.1 {
@@ -129,37 +118,16 @@ class Applications {
}
}
private static func isActualApplication(_ processIdentifier: pid_t, _ bundleIdentifier: String?) -> Bool {
// an app can start with .activationPolicy == .prohibited, then transition to != .prohibited later
// an app can be both activationPolicy == .accessory and XPC (e.g. com.apple.dock.etci)
return (isNotXpc(processIdentifier) || isPasswords(bundleIdentifier) || isAndroidEmulator(bundleIdentifier, processIdentifier)) && !processIdentifier.isZombie()
}
private static func isNotXpc(_ processIdentifier: pid_t) -> Bool {
// these private APIs are more reliable than Bundle.init? as it can return nil (e.g. for com.apple.dock.etci)
var psn = ProcessSerialNumber()
GetProcessForPID(processIdentifier, &psn)
var info = ProcessInfoRec()
GetProcessInformation(&psn, &info)
return String(info.processType) != "XPC!"
}
private static func isPasswords(_ bundleIdentifier: String?) -> Bool {
return bundleIdentifier == "com.apple.Passwords"
}
static func isAndroidEmulator(_ bundleIdentifier: String?, _ processIdentifier: pid_t) -> Bool {
// NSRunningApplication provides no way to identify the emulator; we pattern match on its KERN_PROCARGS
if bundleIdentifier == nil,
let executablePath = Sysctl.run([CTL_KERN, KERN_PROCARGS, processIdentifier]) {
// example path: ~/Library/Android/sdk/emulator/qemu/darwin-x86_64/qemu-system-x86_64
return executablePath.range(of: "qemu-system[^/]*$", options: .regularExpression, range: nil, locale: nil) != nil
@discardableResult
static func findOrCreate(_ pid: pid_t) -> Application? {
if let app = (list.first { $0.pid == pid }) {
return app
}
return false
}
static func find(_ pid: pid_t?) -> Application? {
return list.first { $0.pid == pid }
guard let runningApp = NSRunningApplication(processIdentifier: pid) else { return nil }
guard ApplicationDiscriminator.isActualApplication(pid, runningApp.bundleIdentifier) else { return nil }
let app = Application(runningApp)
list.append(app)
return app
}
static func updateAppIcons() {
+1 -1
View File
@@ -123,7 +123,7 @@ class Preferences {
// periphery:ignore
static var startAtLogin: Bool { CachedUserDefaults.bool("startAtLogin") }
static var blacklist: [BlacklistEntry] { CachedUserDefaults.json("blacklist", [BlacklistEntry].self) }
static var previewFocusedWindow: Bool { CachedUserDefaults.bool("previewFocusedWindow") }
static var previewSelectedWindow: Bool { CachedUserDefaults.bool("previewFocusedWindow") }
static var screenRecordingPermissionSkipped: Bool { CachedUserDefaults.bool("screenRecordingPermissionSkipped") }
// macro values
+3 -2
View File
@@ -34,8 +34,9 @@ extension NSScreen {
/// * if NSScreen.screensHaveSeparateSpaces == false, and key window is on another screen than screens[0], it still returns screens[0]
/// we find the screen with the key window ourselves manually
static func active() -> NSScreen? {
guard let app = Applications.find(NSWorkspace.shared.frontmostApplication?.processIdentifier) else { return nil }
guard let focusedWindow = app.focusedWindow else { return NSScreen.withActiveMenubar() }
guard let frontmostPid = NSWorkspace.shared.frontmostApplication?.processIdentifier,
let frontmostApp = Applications.findOrCreate(frontmostPid) else { return nil }
guard let focusedWindow = frontmostApp.focusedWindow else { return NSScreen.withActiveMenubar() }
// on the very first summon, this window may not have its spaces updated, which may land the wrong active screen
focusedWindow.updateSpacesAndScreen()
guard let screenId = focusedWindow.screenId else { return nil }
+31 -36
View File
@@ -11,6 +11,7 @@ class Window {
]
private static var globalCreationCounter = Int.zero
var id: String!
var cgWindowId: CGWindowID?
var lastFocusOrder = Int.zero
var creationOrder = Int.zero
@@ -39,28 +40,26 @@ class Window {
return "\(application.debugId()) (wid:\(cgWindowId) title:\(title))"
}
init(_ axUiElement: AXUIElement, _ application: Application, _ wid: CGWindowID, _ axTitle: String?, _ isFullscreen: Bool, _ isMinimized: Bool, _ position: CGPoint?, _ size: CGSize?) {
init(_ axUiElement: AXUIElement, _ application: Application, _ wid: CGWindowID, _ title: String?, _ isFullscreen: Bool?, _ isMinimized: Bool?, _ position: CGPoint?, _ size: CGSize?) {
id = "\(wid)"
self.axUiElement = axUiElement
self.application = application
cgWindowId = wid
self.updateSpacesAndScreen()
self.isFullscreen = isFullscreen
self.isMinimized = isMinimized
self.position = position
self.size = size
title = bestEffortTitle(axTitle)
updateFromAxAttributes(title, size, position, isFullscreen, isMinimized)
Window.globalCreationCounter += 1
creationOrder = Window.globalCreationCounter
application.removeWindowslessAppWindow()
application.removeWindowlessAppWindow()
// the app may have timed out trying to subscribe to app notifications
// It may be responsive now since it has a window; we attempt again
application.observeEventsIfEligible()
checkIfFocused(application, wid)
checkIfFocused()
Logger.debug { self.debugId() }
observeEvents()
}
init(_ application: Application) {
id = "\(application.pid)"
self.application = application
title = bestEffortTitle(nil)
Window.globalCreationCounter += 1
@@ -72,6 +71,14 @@ class Window {
Logger.debug { self.debugId() }
}
func updateFromAxAttributes(_ title: String?, _ size: CGSize?, _ position: CGPoint?, _ isFullscreen: Bool?, _ isMinimized: Bool?) {
self.title = bestEffortTitle(title)
self.size = size
self.position = position
self.isFullscreen = isFullscreen ?? false
self.isMinimized = isMinimized ?? false
}
func isEqualRobust(_ otherWindowAxUiElement: AXUIElement, _ otherWindowWid: CGWindowID?) -> Bool {
// the window can be deallocated by the OS, in which case its `CGWindowID` will be `-1`
// we check for equality both on the AXUIElement, and the CGWindowID, in order to catch all scenarios
@@ -133,12 +140,12 @@ class Window {
// minimizing is ignored if sent immediatly; we wait for the de-fullscreen animation to be over
BackgroundWork.accessibilityCommandsQueue.addOperationAfter(deadline: .now() + .seconds(1)) { [weak self] in
guard let self else { return }
if let closeButton_ = try? self.axUiElement!.closeButton() {
if let closeButton_ = try? self.axUiElement!.attributes([kAXCloseButtonAttribute]).closeButton {
try? closeButton_.performAction(kAXPressAction)
}
}
} else {
if let closeButton_ = try? self.axUiElement!.closeButton() {
if let closeButton_ = try? self.axUiElement!.attributes([kAXCloseButtonAttribute]).closeButton {
try? closeButton_.performAction(kAXPressAction)
}
}
@@ -192,7 +199,7 @@ class Window {
if let altTabWindow = altTabWindow() {
App.shared.activate(ignoringOtherApps: true)
altTabWindow.makeKeyAndOrderFront(nil)
Windows.previewFocusedWindowIfNeeded()
Windows.previewSelectedWindowIfNeeded()
} else if isWindowlessApp || cgWindowId == nil || Preferences.onlyShowApplications() {
if let bundleUrl = application.bundleURL, isWindowlessApp {
if (try? NSWorkspace.shared.launchApplication(at: bundleUrl, configuration: [:])) == nil {
@@ -201,7 +208,7 @@ class Window {
} else {
application.runningApplication.activate(options: .activateAllWindows)
}
Windows.previewFocusedWindowIfNeeded()
Windows.previewSelectedWindowIfNeeded()
} else {
// macOS bug: when switching to a System Preferences window in another space, it switches to that space,
// but quickly switches back to another window in that space
@@ -214,7 +221,7 @@ class Window {
self.makeKeyWindow(&psn)
try? self.axUiElement!.focusWindow()
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(50)) {
Windows.previewFocusedWindowIfNeeded()
Windows.previewSelectedWindowIfNeeded()
}
}
}
@@ -292,23 +299,11 @@ class Window {
// Determines if this window is the main application window
func isAppMainWindow() -> Bool {
if let element = application.axUiElement {
var mainWindow: AnyObject?
if AXUIElementCopyAttributeValue(element, kAXMainWindowAttribute as CFString, &mainWindow) == .success {
if let mainWin = mainWindow as! AXUIElement? {
do {
let w1 = try mainWin.cgWindowId()
let w2 = try axUiElement!.cgWindowId()
if w1 == w2 {
return true
}
} catch {
return false
}
}
}
}
return false
// AX calls done on main thread. They can block thus freeze the UI
// TODO: find a better approach
guard let appAxUiElement = application.axUiElement,
let mainWindow = try? appAxUiElement.attributes([kAXMainWindowAttribute]).mainWindow else { return false }
return (try? mainWindow.cgWindowId()) == cgWindowId
}
private func altTabWindow() -> NSWindow? {
@@ -320,12 +315,12 @@ class Window {
/// some apps will not trigger AXApplicationActivated, where we usually update application.focusedWindow
/// workaround: we check and possibly do it here
private func checkIfFocused(_ application: Application, _ wid: CGWindowID) {
AXUIElement.retryAxCallUntilTimeout(context: debugId(), pid: application.pid, callType: .updateWindow) {
let focusedWid = try application.axUiElement?.focusedWindow()?.cgWindowId()
if wid == focusedWid {
application.focusedWindow = self
}
private func checkIfFocused() {
AXUIElement.retryAxCallUntilTimeout(context: debugId(), pid: application.pid, callType: .updateWindow) { [weak self] in
guard let self,
let focusedWindow = try self.application.axUiElement?.attributes([kAXFocusedWindowAttribute]).focusedWindow,
let window = try (Windows.list.first { $0.isEqualRobust(focusedWindow, (try focusedWindow.cgWindowId())) }) else { return }
self.application.focusedWindow = window
}
}
}
+172
View File
@@ -0,0 +1,172 @@
class WindowDiscriminator {
static func isActualWindow(_ app: Application, _ wid: CGWindowID, _ level: CGWindowLevel, _ title: String?, _ subrole: String?, _ role: String?, _ size: CGSize?) -> Bool {
// Some non-windows have title: nil (e.g. some OS elements)
// Some non-windows have subrole: nil (e.g. some OS elements), "AXUnknown" (e.g. Bartender), "AXSystemDialog" (e.g. Intellij tooltips)
// Minimized windows or windows of a hidden app have subrole "AXDialog"
// Activity Monitor main window subrole is "AXDialog" for a brief moment at launch; it then becomes "AXStandardWindow"
// Some non-windows have cgWindowId == 0 (e.g. windows of apps starting at login with the checkbox "Hidden" checked)
// Logger.error { (app.bundleIdentifier, level, title, subrole, role, size) }
return wid != 0
// Finder's file copy dialogs are wide but < 100 height (see https://github.com/lwouis/alt-tab-macos/issues/1466)
// Sonoma introduced a bug: a caps-lock & language indicators shows as a small window.
// We try to hide it by filtering out tiny windows
&& size != nil && (size!.width > 100 && size!.height > 50) && (
(
books(app) ||
keynote(app) ||
preview(app, subrole) ||
iina(app) ||
openFlStudio(app, title) ||
crossoverWindow(app, role, subrole, level) ||
isAlwaysOnTopScrcpy(app, level, role, subrole)
) || (
(
[kAXStandardWindowSubrole, kAXDialogSubrole].contains(subrole) ||
openBoard(app) ||
adobeAudition(app, subrole) ||
adobeAfterEffects(app, subrole) ||
steam(app, title, role) ||
worldOfWarcraft(app, role) ||
battleNetBootstrapper(app, role) ||
firefox(app, role, size) ||
vlcFullscreenVideo(app, role) ||
sanGuoShaAirWD(app) ||
dvdFab(app) ||
drBetotte(app) ||
androidEmulator(app, title) ||
autocad(app, subrole)
) && (
mustHaveIfJetbrainApp(app, title, subrole, size!) &&
mustHaveIfSteam(app, title, role) &&
mustHaveIfFusion360(app, title, role) &&
mustHaveIfColorSlurp(app, subrole)
)
)
)
}
private static func mustHaveIfFusion360(_ app: Application, _ title: String?, _ role: String?) -> Bool {
// filter out Autodesk Fusion side panels "Browser" and "Comments" with subrole AXDialog but with no title
return app.bundleIdentifier != "com.autodesk.fusion360" || (title != nil && title != "")
}
private static func mustHaveIfJetbrainApp(_ app: Application, _ title: String?, _ subrole: String?, _ size: NSSize) -> Bool {
// jetbrain apps sometimes generate non-windows that pass all checks in isActualWindow
// they have no title, so we can filter them out based on that
// we also hide windows too small
return app.bundleIdentifier?.range(of: "^com\\.(jetbrains\\.|google\\.android\\.studio).*?$", options: .regularExpression) == nil || (
(subrole == kAXStandardWindowSubrole || (title != nil && title != "")) &&
size.width > 100 && size.height > 100
)
}
private static func mustHaveIfColorSlurp(_ app: Application, _ subrole: String?) -> Bool {
return app.bundleIdentifier != "com.IdeaPunch.ColorSlurp" || subrole == kAXStandardWindowSubrole
}
private static func iina(_ app: Application) -> Bool {
// IINA.app can have videos float (level == 2 instead of 0)
// there is also complex animations during which we may or may not consider the window not a window
return app.bundleIdentifier == "com.colliderli.iina"
}
private static func keynote(_ app: Application) -> Bool {
// apple Keynote has a fake fullscreen window when in presentation mode
// it covers the screen with a AXUnknown window instead of using standard fullscreen mode
return app.bundleIdentifier == "com.apple.iWork.Keynote"
}
private static func preview(_ app: Application, _ subrole: String?) -> Bool {
// when opening multiple documents at once with apple Preview,
// one of the window will have level == 1 for some reason
return app.bundleIdentifier == "com.apple.Preview" && [kAXStandardWindowSubrole, kAXDialogSubrole].contains(subrole)
}
private static func openFlStudio(_ app: Application, _ title: String?) -> Bool {
// OpenBoard is a ported app which doesn't use standard macOS windows
return app.bundleIdentifier == "com.image-line.flstudio" && (title != nil && title != "")
}
private static func openBoard(_ app: Application) -> Bool {
// OpenBoard is a ported app which doesn't use standard macOS windows
return app.bundleIdentifier == "org.oe-f.OpenBoard"
}
private static func adobeAudition(_ app: Application, _ subrole: String?) -> Bool {
return app.bundleIdentifier == "com.adobe.Audition" && subrole == kAXFloatingWindowSubrole
}
private static func adobeAfterEffects(_ app: Application, _ subrole: String?) -> Bool {
return app.bundleIdentifier == "com.adobe.AfterEffects" && subrole == kAXFloatingWindowSubrole
}
private static func books(_ app: Application) -> Bool {
// Books.app has animations on window creation. This means windows are originally created with subrole == AXUnknown or isOnNormalLevel == false
return app.bundleIdentifier == "com.apple.iBooksX"
}
private static func worldOfWarcraft(_ app: Application, _ role: String?) -> Bool {
return app.bundleIdentifier == "com.blizzard.worldofwarcraft" && role == kAXWindowRole
}
private static func battleNetBootstrapper(_ app: Application, _ role: String?) -> Bool {
// Battlenet bootstrapper windows have subrole == AXUnknown
return app.bundleIdentifier == "net.battle.bootstrapper" && role == kAXWindowRole
}
private static func drBetotte(_ app: Application) -> Bool {
return app.bundleIdentifier == "com.ssworks.drbetotte"
}
private static func dvdFab(_ app: Application) -> Bool {
return app.bundleIdentifier == "com.goland.dvdfab.macos"
}
private static func sanGuoShaAirWD(_ app: Application) -> Bool {
return app.bundleIdentifier == "SanGuoShaAirWD"
}
private static func steam(_ app: Application, _ title: String?, _ role: String?) -> Bool {
// All Steam windows have subrole == AXUnknown
// some dropdown menus are not desirable; they have title == "", or sometimes role == nil when switching between menus quickly
return app.bundleIdentifier == "com.valvesoftware.steam" && (title != nil && title != "" && role != nil)
}
private static func mustHaveIfSteam(_ app: Application, _ title: String?, _ role: String?) -> Bool {
// All Steam windows have subrole == AXUnknown
// some dropdown menus are not desirable; they have title == "", or sometimes role == nil when switching between menus quickly
return app.bundleIdentifier != "com.valvesoftware.steam" || (title != nil && title != "" && role != nil)
}
private static func firefox(_ app: Application, _ role: String?, _ size: CGSize?) -> Bool {
// Firefox fullscreen video have subrole == AXUnknown if fullscreen'ed when the base window is not fullscreen
// Firefox tooltips are implemented as windows with subrole == AXUnknown
return (app.bundleIdentifier?.hasPrefix("org.mozilla.firefox") ?? false) && role == kAXWindowRole && size?.height != nil && size!.height > 400
}
private static func vlcFullscreenVideo(_ app: Application, _ role: String?) -> Bool {
// VLC fullscreen video have subrole == AXUnknown if fullscreen'ed
return (app.bundleIdentifier?.hasPrefix("org.videolan.vlc") ?? false) && role == kAXWindowRole
}
private static func androidEmulator(_ app: Application, _ title: String?) -> Bool {
// android emulator small vertical menu is a "window" with empty title; we exclude it
return title != "" && ApplicationDiscriminator.isAndroidEmulator(app.bundleIdentifier, app.pid)
}
private static func crossoverWindow(_ app: Application, _ role: String?, _ subrole: String?, _ level: CGWindowLevel) -> Bool {
return app.bundleIdentifier == nil && role == kAXWindowRole && subrole == kAXUnknownSubrole && level == CGWindow.normalLevel
&& (app.localizedName == "wine64-preloader" || app.executableURL?.absoluteString.contains("/winetemp-") ?? false)
}
private static func isAlwaysOnTopScrcpy(_ app: Application, _ level: CGWindowLevel, _ role: String?, _ subrole: String?) -> Bool {
// scrcpy presents as a floating window when "Always on top" is enabled, so it doesn't get picked up normally.
// It also doesn't have a bundle ID, so we need to match using the localized name, which should always be the same.
return app.localizedName == "scrcpy" && level == CGWindow.floatingWindow && role == kAXWindowRole && subrole == kAXStandardWindowSubrole
}
private static func autocad(_ app: Application, _ subrole: String?) -> Bool {
// AutoCAD uses the undocumented "AXDocumentWindow" subrole
return (app.bundleIdentifier?.hasPrefix("com.autodesk.AutoCAD") ?? false) && subrole == kAXDocumentWindowSubrole
}
}
+301 -299
View File
@@ -2,10 +2,272 @@ import Cocoa
class Windows {
static var list = [Window]()
static var focusedWindowIndex = Int(0)
static var selectedWindowIndex = Int(0)
static var selectedWindowTarget: String?
static var hoveredWindowIndex: Int?
private static var lastWindowActivityType = WindowActivityType.none
static func updateIsFullscreenOnCurrentSpace() {
let windowsOnCurrentSpace = list.filter { !$0.isWindowlessApp }
for window in windowsOnCurrentSpace {
AXUIElement.retryAxCallUntilTimeout(context: window.debugId(), after: .now() + humanPerceptionDelay, callType: .updateWindow) { [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!)
}
}
}
private static func compareByAppNameThenWindowTitle(_ w1: Window, _ w2: Window) -> ComparisonResult {
let order = w1.application.localizedName.localizedStandardCompare(w2.application.localizedName)
if order == .orderedSame {
return w1.title.localizedStandardCompare(w2.title)
}
return order
}
static func voiceOverWindow(_ windowIndex: Int = selectedWindowIndex) {
guard App.app.appIsBeingUsed && App.app.thumbnailsPanel.isKeyWindow else { return }
// it seems that sometimes makeFirstResponder is called before the view is visible
// and it creates a delay in showing the main window; calling it with some delay seems to work around this
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) {
let window = ThumbnailsView.recycledViews[windowIndex]
if window.window_ != nil && window.window != nil {
App.app.thumbnailsPanel.makeFirstResponder(window)
}
}
}
static func previewSelectedWindowIfNeeded() {
if App.app.appIsBeingUsed && ScreenRecordingPermission.status == .granted
&& Preferences.previewSelectedWindow && !Preferences.onlyShowApplications()
&& App.app.thumbnailsPanel.isKeyWindow,
let window = selectedWindow(),
let id = window.cgWindowId,
let thumbnail = window.thumbnail,
let position = window.position,
let size = window.size {
App.app.previewPanel.show(id, thumbnail, position, size)
} else {
App.app.previewPanel.orderOut(nil)
}
}
/// tabs detection is a flaky work-around the lack of public API to observe OS tabs
/// see: https://github.com/lwouis/alt-tab-macos/issues/1540
private static func detectTabbedWindows(_ window: Window, _ cgsWindowIds: [CGWindowID], _ visibleCgsWindowIds: [CGWindowID]) {
if let cgWindowId = window.cgWindowId {
if window.isMinimized || window.isHidden {
if #available(macOS 13.0, *) {
// not exact after window merging
window.isTabbed = !cgsWindowIds.contains(cgWindowId)
} else {
// not known
window.isTabbed = false
}
} else {
window.isTabbed = !visibleCgsWindowIds.contains(cgWindowId)
}
}
}
static func updatesBeforeShowing() -> Bool {
if list.count == 0 || MissionControl.state() == .showAllWindows || MissionControl.state() == .showFrontWindows { return false }
// TODO: find a way to update space info when spaces are changed, instead of on every trigger
// workaround: when Preferences > Mission Control > "Displays have separate Spaces" is unchecked,
// switching between displays doesn't trigger .activeSpaceDidChangeNotification; we get the latest manually
Spaces.refresh()
let spaceIdsAndIndexes = Spaces.idsAndIndexes.map { $0.0 }
lazy var cgsWindowIds = Spaces.windowsInSpaces(spaceIdsAndIndexes)
lazy var visibleCgsWindowIds = Spaces.windowsInSpaces(spaceIdsAndIndexes, false)
for window in list {
detectTabbedWindows(window, cgsWindowIds, visibleCgsWindowIds)
window.updateSpacesAndScreen()
refreshIfWindowShouldBeShownToTheUser(window)
}
refreshWhichWindowsToShowTheUser()
sort()
if (!list.contains { $0.shouldShowTheUser }) { return false }
return true
}
// dispatch screenshot requests off the main-thread, then wait for completion
static func refreshThumbnailsAsync(_ windows: [Window], _ source: RefreshCausedBy, windowRemoved: Bool = false) {
guard (!windows.isEmpty || windowRemoved) && ScreenRecordingPermission.status == .granted
&& !Preferences.onlyShowApplications()
&& (!Appearance.hideThumbnails || Preferences.previewSelectedWindow) else { return }
var eligibleWindows = [Window]()
for window in windows {
if !window.isWindowlessApp, let cgWindowId = window.cgWindowId, cgWindowId != CGWindowID(bitPattern: -1) {
eligibleWindows.append(window)
}
}
guard (!eligibleWindows.isEmpty || windowRemoved) else { return }
if #available(macOS 14.0, *),
// mitigate macOS 15 bugs with ScreenCapture Kit (see https://github.com/lwouis/alt-tab-macos/issues/5190)
ProcessInfo.processInfo.operatingSystemVersion.majorVersion != 15 {
WindowCaptureScreenshots.oneTimeScreenshots(eligibleWindows, source)
} else {
WindowCaptureScreenshotsPrivateApi.oneTimeScreenshots(eligibleWindows, source)
}
}
static func refreshWhichWindowsToShowTheUser() {
if Preferences.onlyShowApplications() {
// Group windows by application and select the optimal main window
let windowsGroupedByApp = Dictionary(grouping: list) { $0.application.pid }
windowsGroupedByApp.forEach { (app, windows) in
if windows.count > 1, let mainWindow = findMainWindow(windows) {
windows.forEach { window in
if window.cgWindowId != mainWindow.cgWindowId {
window.shouldShowTheUser = false
}
}
}
}
}
}
private static func refreshIfWindowShouldBeShownToTheUser(_ window: Window) {
window.shouldShowTheUser =
!(window.application.bundleIdentifier.flatMap { id in
Preferences.blacklist.contains {
id.hasPrefix($0.bundleIdentifier) &&
($0.hide == .always || (window.isWindowlessApp && $0.hide != .none))
}
} ?? false) &&
!(Preferences.appsToShow[App.app.shortcutIndex] == .active && window.application.pid != NSWorkspace.shared.frontmostApplication?.processIdentifier) &&
!(Preferences.appsToShow[App.app.shortcutIndex] == .nonActive && window.application.pid == NSWorkspace.shared.frontmostApplication?.processIdentifier) &&
!(!(Preferences.showHiddenWindows[App.app.shortcutIndex] != .hide) && window.isHidden) &&
((Preferences.showWindowlessApps[App.app.shortcutIndex] != .hide && window.isWindowlessApp) ||
!window.isWindowlessApp &&
!(!(Preferences.showFullscreenWindows[App.app.shortcutIndex] != .hide) && window.isFullscreen) &&
!(!(Preferences.showMinimizedWindows[App.app.shortcutIndex] != .hide) && window.isMinimized) &&
!(Preferences.spacesToShow[App.app.shortcutIndex] == .visible && !Spaces.visibleSpaces.contains { visibleSpace in window.spaceIds.contains { $0 == visibleSpace } }) &&
!(Preferences.screensToShow[App.app.shortcutIndex] == .showingAltTab && !window.isOnScreen(NSScreen.preferred)) &&
(Preferences.showTabsAsWindows || !window.isTabbed))
}
/// Selects the most appropriate main window from a given list of windows.
///
/// The selection criteria are as follows:
/// 1. Prefer the focused window if it exists.
/// 2. Prefer the main window of the application if the focused window is not found.
///
/// - Parameter windows: An array of `Window` objects to select from.
/// - Returns: The most appropriate `Window` object based on the selection criteria, or `nil` if the array is empty.
static func findMainWindow(_ windows: [Window]) -> Window? {
let sortedWindows = windows.sorted { (window1, window2) -> Bool in
// Prefer the focus window
if window1.application.focusedWindow?.cgWindowId == window1.cgWindowId {
return true
} else if window2.application.focusedWindow?.cgWindowId == window2.cgWindowId {
return false
}
// Prefer the main window
if window1.isAppMainWindow() && !window2.isAppMainWindow() {
return true
} else if !window1.isAppMainWindow() && window2.isAppMainWindow() {
return false
}
return true
}
return sortedWindows.first { $0.shouldShowTheUser }
}
/// selectedWindowIndex methods
//////////////////////////////
static func selectedWindow() -> Window? {
return list.count > selectedWindowIndex ? list[selectedWindowIndex] : nil
}
static func setInitialSelectedAndHoveredWindowIndex() {
let oldIndex = selectedWindowIndex
selectedWindowIndex = 0
selectedWindowTarget = nil
ThumbnailsView.highlight(oldIndex)
if let oldIndex = hoveredWindowIndex {
hoveredWindowIndex = nil
ThumbnailsView.highlight(oldIndex)
}
if let frontmostPid = NSWorkspace.shared.frontmostApplication?.processIdentifier,
let frontmostApp = Applications.findOrCreate(frontmostPid),
(frontmostApp.focusedWindow == nil || Preferences.windowOrder[App.app.shortcutIndex] != .recentlyFocused),
let lastFocusedOrderWindowIndex = getLastFocusedOrderWindowIndex() {
updateSelectedAndHoveredWindowIndex(lastFocusedOrderWindowIndex)
} else {
cycleSelectedWindowIndex(1)
if selectedWindowIndex == 0 {
updateSelectedAndHoveredWindowIndex(0)
}
}
}
static func updateSelectedWindow() {
guard let index = (list.firstIndex { $0.id == selectedWindowTarget }) else {
setInitialSelectedAndHoveredWindowIndex()
return
}
updateSelectedAndHoveredWindowIndex(index)
}
static func updateSelectedAndHoveredWindowIndex(_ newIndex: Int, _ fromMouse: Bool = false) {
var index: Int?
if fromMouse && (newIndex != hoveredWindowIndex || lastWindowActivityType == .focus) {
let oldIndex = hoveredWindowIndex
hoveredWindowIndex = newIndex
if let oldIndex {
ThumbnailsView.highlight(oldIndex)
}
index = hoveredWindowIndex
lastWindowActivityType = .hover
}
if (!fromMouse || Preferences.mouseHoverEnabled)
&& (newIndex != selectedWindowIndex || lastWindowActivityType == .hover) {
let oldIndex = selectedWindowIndex
selectedWindowIndex = newIndex
selectedWindowTarget = list[newIndex].id
ThumbnailsView.highlight(oldIndex)
previewSelectedWindowIfNeeded()
index = selectedWindowIndex
lastWindowActivityType = .focus
}
guard let index else { return }
ThumbnailsView.highlight(index)
let focusedView = ThumbnailsView.recycledViews[index]
App.app.thumbnailsPanel.thumbnailsView.scrollView.contentView.scrollToVisible(focusedView.frame)
voiceOverWindow(index)
}
static func cycleSelectedWindowIndex(_ step: Int, allowWrap: Bool = true) {
guard App.app.appIsBeingUsed else { return }
let nextIndex = selectedWindowIndexAfterCycling(step)
// don't wrap-around at the end, if key-repeat
if (((step > 0 && nextIndex < selectedWindowIndex) || (step < 0 && nextIndex > selectedWindowIndex)) &&
(!allowWrap || ATShortcut.lastEventIsARepeat || !KeyRepeatTimer.timerIsSuspended))
// don't cycle to another row, if !allowWrap
|| (!allowWrap && list[nextIndex].rowIndex != list[selectedWindowIndex].rowIndex) {
return
}
updateSelectedAndHoveredWindowIndex(nextIndex)
}
static func selectedWindowIndexAfterCycling(_ step: Int) -> Int {
if list.count == 0 { return 0 }
var iterations = 0
var targetIndex = selectedWindowIndex
repeat {
let next = (targetIndex + step) % list.count
targetIndex = next < 0 ? list.count + next : next
iterations += 1
} while !list[targetIndex].shouldShowTheUser && iterations <= list.count
return targetIndex
}
/// lastFocusOrder methods
//////////////////////////////
/// Updates windows "lastFocusOrder" to ensure unique values based on window z-order.
/// Windows are ordered by their position in Spaces.windowsInSpaces() results,
/// with topmost windows first.
@@ -15,14 +277,14 @@ class Windows {
windowLevelMap[cgWindowId] = index
}
list = list
.sorted { w1, w2 in
(windowLevelMap[w1.cgWindowId] ?? .max) < (windowLevelMap[w2.cgWindowId] ?? .max)
}
.enumerated()
.map { (index, window) -> Window in
window.lastFocusOrder = index
return window
}
.sorted { w1, w2 in
(windowLevelMap[w1.cgWindowId] ?? .max) < (windowLevelMap[w2.cgWindowId] ?? .max)
}
.enumerated()
.map { (index, window) -> Window in
window.lastFocusOrder = index
return window
}
}
/// reordered list based on preferences, keeping the original index
@@ -71,48 +333,10 @@ class Windows {
}
}
static func updateIsFullscreenOnCurrentSpace() {
let windowsOnCurrentSpace = Windows.list.filter { !$0.isWindowlessApp }
for window in windowsOnCurrentSpace {
AXUIElement.retryAxCallUntilTimeout(context: window.debugId(), after: .now() + humanPerceptionDelay, callType: .updateWindow) { [weak window] in
guard let window else { return }
try AccessibilityEvents.updateWindowSizeAndPositionAndFullscreen(window.axUiElement!, window.cgWindowId!, window)
}
}
}
private static func compareByAppNameThenWindowTitle(_ w1: Window, _ w2: Window) -> ComparisonResult {
let order = w1.application.localizedName.localizedStandardCompare(w2.application.localizedName)
if order == .orderedSame {
return w1.title.localizedStandardCompare(w2.title)
}
return order
}
static func setInitialFocusedAndHoveredWindowIndex() {
let oldIndex = focusedWindowIndex
focusedWindowIndex = 0
ThumbnailsView.highlight(oldIndex)
if let oldIndex = hoveredWindowIndex {
hoveredWindowIndex = nil
ThumbnailsView.highlight(oldIndex)
}
if let app = Applications.find(NSWorkspace.shared.frontmostApplication?.processIdentifier),
(app.focusedWindow == nil || Preferences.windowOrder[App.app.shortcutIndex] != .recentlyFocused),
let lastFocusedWindowIndex = getLastFocusedWindowIndex() {
updateFocusedAndHoveredWindowIndex(lastFocusedWindowIndex)
} else {
cycleFocusedWindowIndex(1)
if focusedWindowIndex == 0 {
updateFocusedAndHoveredWindowIndex(0)
}
}
}
static func getLastFocusedWindowIndex() -> Int? {
static func getLastFocusedOrderWindowIndex() -> Int? {
var index: Int? = nil
var lastFocusOrderMin = Int.max
Windows.list.enumerated().forEach {
list.enumerated().forEach {
if !$0.element.isWindowlessApp && $0.element.lastFocusOrder < lastFocusOrderMin {
lastFocusOrderMin = $0.element.lastFocusOrder
index = $0.offset
@@ -121,45 +345,7 @@ class Windows {
return index
}
static func appendAndUpdateFocus(_ window: Window) {
list.forEach {
$0.lastFocusOrder += 1
}
list.append(window)
if list.count > ThumbnailsView.recycledViews.count {
ThumbnailsView.recycledViews.append(ThumbnailView())
}
}
static func removeWindow(_ index: Int, _ pid: pid_t) {
let window = Windows.list[index]
removeAndUpdateFocus(window)
if window.application.addWindowlessWindowIfNeeded() != nil {
Applications.find(pid)?.focusedWindow = nil
}
if Windows.list.count > 0 {
moveFocusedWindowIndexAfterWindowDestroyedInBackground(index)
App.app.refreshOpenUi([], .refreshUiAfterExternalEvent, windowRemoved: true)
} else {
App.app.hideUi()
}
}
private static func removeAndUpdateFocus(_ window: Window) {
let removedWindowOldFocusOrder = window.lastFocusOrder
list.removeAll {
if $0.lastFocusOrder == removedWindowOldFocusOrder {
return true
}
if $0.lastFocusOrder > removedWindowOldFocusOrder {
$0.lastFocusOrder -= 1
}
return false
}
}
static func updateLastFocus(_ otherWindowAxUiElement: AXUIElement, _ otherWindowWid: CGWindowID) -> [Window]? {
guard let focusedWindow = (list.first { $0.isEqualRobust(otherWindowAxUiElement, otherWindowWid) }) else { return nil }
static func updateLastFocusOrder(_ focusedWindow: Window) -> [Window]? {
// no need to update the list is the window is already lastFocusOrder 0
guard focusedWindow.lastFocusOrder != 0 && list.count > 1, let previousFocus = (list.first { $0.lastFocusOrder == 0 }) else { return [focusedWindow] }
// 2 windows have recently changed: the one which got focused, and the one who just lost focus
@@ -175,226 +361,42 @@ class Windows {
return windowsToRefresh
}
static func updateFocusedAndHoveredWindowIndex(_ newIndex: Int, _ fromMouse: Bool = false) {
var index: Int?
if fromMouse && (newIndex != hoveredWindowIndex || lastWindowActivityType == .focus) {
let oldIndex = hoveredWindowIndex
hoveredWindowIndex = newIndex
if let oldIndex {
ThumbnailsView.highlight(oldIndex)
}
index = hoveredWindowIndex
lastWindowActivityType = .hover
static func findOrCreate(_ windowAxUiElement: AXUIElement, _ wid: CGWindowID, _ app: Application, _ level: CGWindowLevel, _ title: String?, _ subrole: String?, _ role: String?, _ size: CGSize?, _ position: CGPoint?, _ isFullscreen: Bool?, _ isMinimized: Bool?) -> Window? {
if let window = (list.first { $0.isEqualRobust(windowAxUiElement, wid) }) {
// on any window event, we take the opportunity to refresh all window attributes
window.updateFromAxAttributes(title, size, position, isFullscreen, isMinimized)
return window
}
if (!fromMouse || Preferences.mouseHoverEnabled)
&& (newIndex != focusedWindowIndex || lastWindowActivityType == .hover) {
let oldIndex = focusedWindowIndex
focusedWindowIndex = newIndex
ThumbnailsView.highlight(oldIndex)
previewFocusedWindowIfNeeded()
index = focusedWindowIndex
lastWindowActivityType = .focus
}
guard let index else { return }
ThumbnailsView.highlight(index)
let focusedView = ThumbnailsView.recycledViews[index]
App.app.thumbnailsPanel.thumbnailsView.scrollView.contentView.scrollToVisible(focusedView.frame)
voiceOverWindow(index)
guard WindowDiscriminator.isActualWindow(app, wid, level, title, subrole, role, size) else { return nil }
let window = Window(windowAxUiElement, app, wid, title, isFullscreen, isMinimized, position, size)
appendWindow(window)
return window
}
static func previewFocusedWindowIfNeeded() {
if App.app.appIsBeingUsed && ScreenRecordingPermission.status == .granted
&& Preferences.previewFocusedWindow && !Preferences.onlyShowApplications()
&& App.app.thumbnailsPanel.isKeyWindow,
let window = focusedWindow(),
let id = window.cgWindowId,
let thumbnail = window.thumbnail,
let position = window.position,
let size = window.size {
App.app.previewPanel.show(id, thumbnail, position, size)
} else {
App.app.previewPanel.orderOut(nil)
static func appendWindow(_ window: Window) {
list.forEach {
$0.lastFocusOrder += 1
}
list.append(window)
if list.count > ThumbnailsView.recycledViews.count {
ThumbnailsView.recycledViews.append(ThumbnailView())
}
}
static func voiceOverWindow(_ windowIndex: Int = focusedWindowIndex) {
guard App.app.appIsBeingUsed && App.app.thumbnailsPanel.isKeyWindow else { return }
// it seems that sometimes makeFirstResponder is called before the view is visible
// and it creates a delay in showing the main window; calling it with some delay seems to work around this
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) {
let window = ThumbnailsView.recycledViews[windowIndex]
if window.window_ != nil && window.window != nil {
App.app.thumbnailsPanel.makeFirstResponder(window)
}
}
}
static func focusedWindow() -> Window? {
return list.count > focusedWindowIndex ? list[focusedWindowIndex] : nil
}
static func cycleFocusedWindowIndex(_ step: Int, allowWrap: Bool = true) {
let nextIndex = windowIndexAfterCycling(step)
// don't wrap-around at the end, if key-repeat
if (((step > 0 && nextIndex < focusedWindowIndex) || (step < 0 && nextIndex > focusedWindowIndex)) &&
(!allowWrap || ATShortcut.lastEventIsARepeat || !KeyRepeatTimer.timerIsSuspended))
// don't cycle to another row, if !allowWrap
|| (!allowWrap && list[nextIndex].rowIndex != list[focusedWindowIndex].rowIndex) {
return
}
updateFocusedAndHoveredWindowIndex(nextIndex)
}
static func windowIndexAfterCycling(_ step: Int) -> Int {
if list.count == 0 { return 0 }
var iterations = 0
var targetIndex = focusedWindowIndex
repeat {
let next = (targetIndex + step) % list.count
targetIndex = next < 0 ? list.count + next : next
iterations += 1
} while !list[targetIndex].shouldShowTheUser && iterations <= list.count
return targetIndex
}
private static func moveFocusedWindowIndexAfterWindowDestroyedInBackground(_ index: Int) {
if index < focusedWindowIndex {
cycleFocusedWindowIndex(-1)
}
}
static func updateFocusedWindowIndex() {
if let focusedWindow = focusedWindow() {
if !focusedWindow.shouldShowTheUser {
cycleFocusedWindowIndex(windowIndexAfterCycling(1) > focusedWindowIndex ? 1 : -1)
} else {
previewFocusedWindowIfNeeded()
}
} else {
cycleFocusedWindowIndex(-1)
}
}
/// tabs detection is a flaky work-around the lack of public API to observe OS tabs
/// see: https://github.com/lwouis/alt-tab-macos/issues/1540
private static func detectTabbedWindows(_ window: Window, _ cgsWindowIds: [CGWindowID], _ visibleCgsWindowIds: [CGWindowID]) {
if let cgWindowId = window.cgWindowId {
if window.isMinimized || window.isHidden {
if #available(macOS 13.0, *) {
// not exact after window merging
window.isTabbed = !cgsWindowIds.contains(cgWindowId)
} else {
// not known
window.isTabbed = false
}
} else {
window.isTabbed = !visibleCgsWindowIds.contains(cgWindowId)
}
}
}
static func updatesBeforeShowing() -> Bool {
if list.count == 0 || MissionControl.state() == .showAllWindows || MissionControl.state() == .showFrontWindows { return false }
// TODO: find a way to update space info when spaces are changed, instead of on every trigger
// workaround: when Preferences > Mission Control > "Displays have separate Spaces" is unchecked,
// switching between displays doesn't trigger .activeSpaceDidChangeNotification; we get the latest manually
Spaces.refresh()
let spaceIdsAndIndexes = Spaces.idsAndIndexes.map { $0.0 }
lazy var cgsWindowIds = Spaces.windowsInSpaces(spaceIdsAndIndexes)
lazy var visibleCgsWindowIds = Spaces.windowsInSpaces(spaceIdsAndIndexes, false)
for window in list {
detectTabbedWindows(window, cgsWindowIds, visibleCgsWindowIds)
window.updateSpacesAndScreen()
refreshIfWindowShouldBeShownToTheUser(window)
}
refreshWhichWindowsToShowTheUser()
sort()
if (!list.contains { $0.shouldShowTheUser }) { return false }
return true
}
// dispatch screenshot requests off the main-thread, then wait for completion
static func refreshThumbnailsAsync(_ windows: [Window], _ source: RefreshCausedBy, windowRemoved: Bool = false) {
guard (!windows.isEmpty || windowRemoved) && ScreenRecordingPermission.status == .granted
&& !Preferences.onlyShowApplications()
&& (!Appearance.hideThumbnails || Preferences.previewFocusedWindow) else { return }
var eligibleWindows = [Window]()
for window in windows {
if !window.isWindowlessApp, let cgWindowId = window.cgWindowId, cgWindowId != CGWindowID(bitPattern: -1) {
eligibleWindows.append(window)
}
}
guard (!eligibleWindows.isEmpty || windowRemoved) else { return }
if #available(macOS 14.0, *),
// mitigate macOS 15 bugs with ScreenCapture Kit (see https://github.com/lwouis/alt-tab-macos/issues/5190)
ProcessInfo.processInfo.operatingSystemVersion.majorVersion != 15 {
WindowCaptureScreenshots.oneTimeScreenshots(eligibleWindows, source)
} else {
WindowCaptureScreenshotsPrivateApi.oneTimeScreenshots(eligibleWindows, source)
}
}
static func refreshWhichWindowsToShowTheUser() {
if Preferences.onlyShowApplications() {
// Group windows by application and select the optimal main window
let windowsGroupedByApp = Dictionary(grouping: list) { $0.application.pid }
windowsGroupedByApp.forEach { (app, windows) in
if windows.count > 1, let mainWindow = selectMainWindow(windows) {
windows.forEach { window in
if window.cgWindowId != mainWindow.cgWindowId {
window.shouldShowTheUser = false
}
}
}
}
}
}
private static func refreshIfWindowShouldBeShownToTheUser(_ window: Window) {
window.shouldShowTheUser =
!(window.application.bundleIdentifier.flatMap { id in
Preferences.blacklist.contains {
id.hasPrefix($0.bundleIdentifier) &&
($0.hide == .always || (window.isWindowlessApp && $0.hide != .none))
}
} ?? false) &&
!(Preferences.appsToShow[App.app.shortcutIndex] == .active && window.application.pid != NSWorkspace.shared.frontmostApplication?.processIdentifier) &&
!(Preferences.appsToShow[App.app.shortcutIndex] == .nonActive && window.application.pid == NSWorkspace.shared.frontmostApplication?.processIdentifier) &&
!(!(Preferences.showHiddenWindows[App.app.shortcutIndex] != .hide) && window.isHidden) &&
((Preferences.showWindowlessApps[App.app.shortcutIndex] != .hide && window.isWindowlessApp) ||
!window.isWindowlessApp &&
!(!(Preferences.showFullscreenWindows[App.app.shortcutIndex] != .hide) && window.isFullscreen) &&
!(!(Preferences.showMinimizedWindows[App.app.shortcutIndex] != .hide) && window.isMinimized) &&
!(Preferences.spacesToShow[App.app.shortcutIndex] == .visible && !Spaces.visibleSpaces.contains { visibleSpace in window.spaceIds.contains { $0 == visibleSpace } }) &&
!(Preferences.screensToShow[App.app.shortcutIndex] == .showingAltTab && !window.isOnScreen(NSScreen.preferred)) &&
(Preferences.showTabsAsWindows || !window.isTabbed))
}
/// Selects the most appropriate main window from a given list of windows.
///
/// The selection criteria are as follows:
/// 1. Prefer the focused window if it exists.
/// 2. Prefer the main window of the application if the focused window is not found.
///
/// - Parameter windows: An array of `Window` objects to select from.
/// - Returns: The most appropriate `Window` object based on the selection criteria, or `nil` if the array is empty.
static func selectMainWindow(_ windows: [Window]) -> Window? {
let sortedWindows = windows.sorted { (window1, window2) -> Bool in
// Prefer the focus window
if window1.application.focusedWindow?.cgWindowId == window1.cgWindowId {
static func removeWindows(_ windows: [Window], _ addWindowlessWindowIfNeeded: Bool) {
let toRemove = windows.map { $0.lastFocusOrder }
list.removeAll { w in
if toRemove.contains(w.lastFocusOrder) {
return true
} else if window2.application.focusedWindow?.cgWindowId == window2.cgWindowId {
return false
}
// Prefer the main window
if window1.isAppMainWindow() && !window2.isAppMainWindow() {
return true
} else if !window1.isAppMainWindow() && window2.isAppMainWindow() {
return false
}
return true
let howManyToShift = toRemove.reduce(0) { $1 < w.lastFocusOrder ? $0 + 1 : $0 }
w.lastFocusOrder -= howManyToShift
return false
}
return sortedWindows.first { $0.shouldShowTheUser }
if addWindowlessWindowIfNeeded {
windows.forEach { $0.application.addWindowlessWindowIfNeeded() }
}
App.app.refreshOpenUi([], .refreshUiAfterExternalEvent, windowRemoved: true)
}
}
+87 -147
View File
@@ -6,186 +6,126 @@ class AccessibilityEvents {
static let axObserverCallback: AXObserverCallback = { _, element, notificationName, _ in
let type = notificationName as String
Logger.debug { type }
AXUIElement.retryAxCallUntilTimeout(context: "(type:\(type)", callType: .axEventEntrypoint) { try handleEvent(type, element) }
}
static func updateWindowSizeAndPositionAndFullscreen(_ element: AXUIElement, _ wid: CGWindowID, _ window: Window?) throws {
if let (title, _, _, isMinimized, isFullscreen) = try element.windowAttributes() {
let size = try element.size()
let position = try element.position()
DispatchQueue.main.async { [weak window] in
if let window = (window != nil ? window : (Windows.list.first { $0.isEqualRobust(element, wid) })) {
window.title = window.bestEffortTitle(title)
window.size = size
window.position = position
window.isMinimized = isMinimized
window.updateSpacesAndScreen()
if window.isFullscreen != isFullscreen {
window.isFullscreen = isFullscreen
App.app.checkIfShortcutsShouldBeDisabled(window, nil)
}
App.app.refreshOpenUi([window], .refreshUiAfterExternalEvent)
}
}
AXUIElement.retryAxCallUntilTimeout(context: "(type:\(type)", callType: .axEventEntrypoint) {
try handleEvent(type, element)
}
}
private static func handleEvent(_ type: String, _ element: AXUIElement) throws {
// events are handled concurrently, thus we check that the app is still running
let pid = try element.pid()
if try pid != ProcessInfo.processInfo.processIdentifier || (element.subrole() != kAXUnknownSubrole) {
Logger.info { "\(type) (pid:\(pid) title:\(try? element.title()))" }
switch type {
case kAXApplicationActivatedNotification: try applicationActivated(element, pid)
case kAXApplicationHiddenNotification,
kAXApplicationShownNotification: try applicationHiddenOrShown(pid, type)
case kAXWindowCreatedNotification: try windowCreated(element, pid)
case kAXMainWindowChangedNotification,
kAXFocusedWindowChangedNotification: try focusedWindowChanged(element, pid)
case kAXUIElementDestroyedNotification: try windowDestroyed(element, pid)
case kAXWindowMiniaturizedNotification,
kAXWindowDeminiaturizedNotification: try windowMiniaturizedOrDeminiaturized(element, type)
case kAXTitleChangedNotification: try windowTitleChanged(element, pid)
case kAXWindowResizedNotification,
kAXWindowMovedNotification: try windowResizedOrMoved(element, pid)
default: return
}
Logger.debug { "\(type) pid:\(pid)" }
if [kAXApplicationActivatedNotification, kAXApplicationHiddenNotification, kAXApplicationShownNotification].contains(type) {
try handleEventApp(type, pid, element)
} else {
try handleEventWindowWithDebounceIfNecessary(type, pid, element)
}
}
private static func applicationActivated(_ element: AXUIElement, _ pid: pid_t) throws {
let appFocusedWindow = try element.focusedWindow()
let wid = try appFocusedWindow?.cgWindowId()
private static func handleEventApp(_ type: String, _ pid: pid_t, _ element: AXUIElement) throws {
let appFocusedWindow = try element.attributes([kAXFocusedWindowAttribute]).focusedWindow
DispatchQueue.main.async {
if let app = Applications.find(pid) {
if app.hasBeenActiveOnce != true {
app.hasBeenActiveOnce = true
}
let window = (appFocusedWindow != nil && wid != nil) ? Windows.updateLastFocus(appFocusedWindow!, wid!)?.first : nil
app.focusedWindow = window
App.app.checkIfShortcutsShouldBeDisabled(window, app.runningApplication)
App.app.refreshOpenUi(window != nil ? [window!] : [], .refreshUiAfterExternalEvent)
guard let app = Applications.findOrCreate(pid) else { return }
Logger.info { "\(type) app:\(app.debugId())" }
if type == kAXApplicationActivatedNotification {
applicationActivated(app, pid, type, appFocusedWindow)
} else if type == kAXApplicationHiddenNotification || type == kAXApplicationShownNotification {
applicationHiddenOrShown(app, pid, type)
}
}
}
private static func applicationHiddenOrShown(_ pid: pid_t, _ type: String) throws {
DispatchQueue.main.async {
if let app = Applications.find(pid) {
app.isHidden = type == kAXApplicationHiddenNotification
let windows = Windows.list.filter {
// for AXUIElement of apps, CFEqual or == don't work; looks like a Cocoa bug
return $0.application.pid == pid
}
// if we process the "shown" event too fast, the window won't be listed by CGSCopyWindowsWithOptionsAndTags
// it will thus be detected as isTabbed. We add a delay to work around this scenario
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(200)) {
App.app.refreshOpenUi(windows, .refreshUiAfterExternalEvent)
}
private static func applicationActivated(_ app: Application, _ pid: pid_t, _ type: String, _ appFocusedWindow: AXUIElement?) {
if app.hasBeenActiveOnce != true {
app.hasBeenActiveOnce = true
}
if let appFocusedWindow {
// if there is a focusedWindow, we reuse existing code to process it as if it was a kAXFocusedWindowChangedNotification
AXUIElement.retryAxCallUntilTimeout(context: "(type:\(type)", callType: .axEventEntrypoint) {
try handleEventWindowWithDebounceIfNecessary(kAXFocusedWindowChangedNotification, pid, appFocusedWindow)
}
} else if let windowless = (Windows.list.first { $0.isWindowlessApp && $0.application.pid == pid }) {
if let windows = Windows.updateLastFocusOrder(windowless) {
App.app.refreshOpenUi(windows, .refreshUiAfterExternalEvent)
}
}
}
private static func windowCreated(_ element: AXUIElement, _ pid: pid_t) throws {
private static func applicationHiddenOrShown(_ app: Application, _ pid: pid_t, _ type: String) {
app.isHidden = type == kAXApplicationHiddenNotification
let windows = Windows.list.filter {
// for AXUIElement of apps, CFEqual or == don't work; looks like a Cocoa bug
return $0.application.pid == pid
}
// if we process the "shown" event too fast, the window won't be listed by CGSCopyWindowsWithOptionsAndTags
// it will thus be detected as isTabbed. We add a delay to work around this scenario
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(200)) {
App.app.refreshOpenUi(windows, .refreshUiAfterExternalEvent)
}
}
private static func handleEventWindowWithDebounceIfNecessary(_ type: String, _ pid: pid_t, _ element: AXUIElement) throws {
let wid = try element.cgWindowId()
if let (title, role, subrole, isMinimized, isFullscreen) = try element.windowAttributes() {
let position = try element.position()
let size = try element.size()
let level = wid.level()
DispatchQueue.main.async {
if let app = Applications.find(pid), NSRunningApplication(processIdentifier: pid) != nil {
if (!Windows.list.contains { $0.isEqualRobust(element, wid) }) &&
AXUIElement.isActualWindow(app, wid, level, title, subrole, role, size) {
let window = Window(element, app, wid, title, isFullscreen, isMinimized, position, size)
Windows.appendAndUpdateFocus(window)
Windows.cycleFocusedWindowIndex(1)
App.app.refreshOpenUi([window], .refreshUiAfterExternalEvent)
}
}
}
}
}
private static func focusedWindowChanged(_ element: AXUIElement, _ pid: pid_t) throws {
let wid = try element.cgWindowId()
if let runningApp = NSRunningApplication(processIdentifier: pid) {
// photoshop will focus a window *after* you focus another app
// we check that a focused window happens within an active app
if runningApp.isActive {
DispatchQueue.main.async {
guard let app = Applications.find(pid) else { return }
// if the window is shown by alt-tab, we mark it as focused for this app
// this avoids issues with dialogs, quicklook, etc (see scenarios from #1044 and #2003)
if let w = (Windows.list.first { $0.isEqualRobust(element, wid) }) {
app.focusedWindow = w
}
if let windows = Windows.updateLastFocus(element, wid) {
App.app.refreshOpenUi(windows, .refreshUiAfterExternalEvent)
} else {
AXUIElement.retryAxCallUntilTimeout(context: "(wid:\(wid) pid:\(pid))", pid: pid, callType: .updateWindow) {
if let (title, role, subrole, isMinimized, isFullscreen) = try element.windowAttributes() {
let position = try element.position()
let size = try element.size()
let level = wid.level()
DispatchQueue.main.async {
if (!Windows.list.contains { $0.isEqualRobust(element, wid) }),
AXUIElement.isActualWindow(app, wid, level, title, subrole, role, size) {
let window = Window(element, app, wid, title, isFullscreen, isMinimized, position, size)
Windows.appendAndUpdateFocus(window)
App.app.refreshOpenUi([window], .refreshUiAfterExternalEvent)
}
}
}
}
}
}
guard wid != 0 || type == kAXUIElementDestroyedNotification else { return } // some bogus "windows" have wid 0
if type == kAXTitleChangedNotification || type == kAXWindowResizedNotification || type == kAXWindowMovedNotification {
AXUIElement.retryAxCallUntilTimeout(context: "(wid:\(wid) pid:\(pid))", debounceType: (type == kAXTitleChangedNotification ? .windowTitleChanged : .windowResizedOrMoved), pid: pid, wid: wid, callType: .updateWindow) {
try handleEventWindow(type, wid, pid, element)
}
} else {
DispatchQueue.main.async {
Applications.find(pid)?.focusedWindow = nil
}
try handleEventWindow(type, wid, pid, element)
}
}
private static func windowDestroyed(_ element: AXUIElement, _ pid: pid_t) throws {
let wid = try element.cgWindowId()
static func handleEventWindow(_ type: String, _ wid: CGWindowID, _ pid: pid_t, _ element: AXUIElement) throws {
guard wid != App.app.thumbnailsPanel.windowNumber else { return } // don't process events for the thumbnails panel
let level = wid.level()
let a = try element.attributes([kAXTitleAttribute, kAXSubroleAttribute, kAXRoleAttribute, kAXSizeAttribute, kAXPositionAttribute, kAXFullscreenAttribute, kAXMinimizedAttribute])
DispatchQueue.main.async {
if let index = (Windows.list.firstIndex { $0.isEqualRobust(element, wid) }) {
Windows.removeWindow(index, pid)
guard let app = Applications.findOrCreate(pid) else { return }
Logger.debug { "\(type) wid:\(wid) app:\(app.debugId())" }
if type == kAXUIElementDestroyedNotification {
windowDestroyed(element, pid, wid)
return
}
}
}
private static func windowMiniaturizedOrDeminiaturized(_ element: AXUIElement, _ type: String) throws {
let wid = try element.cgWindowId()
DispatchQueue.main.async {
if let window = (Windows.list.first { $0.isEqualRobust(element, wid) }) {
window.isMinimized = type == kAXWindowMiniaturizedNotification
guard let window = Windows.findOrCreate(element, wid, app, level, a.title, a.subrole, a.role, a.size, a.position, a.isFullscreen, a.isMinimized) else {
// we don't know this window, but it got focused, so let's update app.focusedWindow with nil
if type == kAXFocusedWindowChangedNotification {
app.focusedWindow = nil
}
return
}
Logger.debug { "\(type) win:\(window.debugId())" }
if type == kAXMainWindowChangedNotification || type == kAXFocusedWindowChangedNotification {
focusedWindowChanged(window)
} else if type == kAXWindowResizedNotification || type == kAXWindowMovedNotification {
windowResizedOrMoved(window)
} else {
App.app.refreshOpenUi([window], .refreshUiAfterExternalEvent)
}
}
}
private static func windowTitleChanged(_ element: AXUIElement, _ pid: pid_t) throws {
let wid = try element.cgWindowId()
AXUIElement.retryAxCallUntilTimeout(context: "(wid:\(wid) pid:\(pid))", debounceType: .windowTitleChanged, pid: pid, wid: wid, callType: .updateWindow) {
if let (title, _, _, isMinimized, isFullscreen) = try element.windowAttributes() {
DispatchQueue.main.async {
if let window = (Windows.list.first { $0.isEqualRobust(element, wid) }), title != window.title {
window.title = window.bestEffortTitle(title)
window.isMinimized = isMinimized
window.isFullscreen = isFullscreen
App.app.refreshOpenUi([window], .refreshUiAfterExternalEvent)
}
}
}
private static func windowDestroyed(_ windowAxUiElement: AXUIElement, _ pid: pid_t, _ wid: CGWindowID) {
if let window = (Windows.list.first { $0.isEqualRobust(windowAxUiElement, wid) }) {
Windows.removeWindows([window], true)
}
}
private static func windowResizedOrMoved(_ element: AXUIElement, _ pid: pid_t) throws {
let wid = try element.cgWindowId()
AXUIElement.retryAxCallUntilTimeout(context: "(wid:\(wid) pid:\(pid))", debounceType: .windowResizedOrMoved, pid: pid, wid: wid, callType: .updateWindow) {
try updateWindowSizeAndPositionAndFullscreen(element, wid, nil)
private static func focusedWindowChanged(_ window: Window) {
// photoshop will focus a window *after* you focus another app
// we check that a focused window happens within an active app
guard window.application.runningApplication.isActive else { return }
// if the window is shown by alt-tab, we mark it as focused for this app
// this avoids issues with dialogs, quicklook, etc (see scenarios from #1044 and #2003)
window.application.focusedWindow = window
App.app.checkIfShortcutsShouldBeDisabled(window, window.application.runningApplication)
if let windows = Windows.updateLastFocusOrder(window) {
App.app.refreshOpenUi(windows, .refreshUiAfterExternalEvent)
}
}
private static func windowResizedOrMoved(_ window: Window) {
window.updateSpacesAndScreen()
App.app.checkIfShortcutsShouldBeDisabled(window, nil)
App.app.refreshOpenUi([window], .refreshUiAfterExternalEvent)
}
}
+1 -1
View File
@@ -258,7 +258,7 @@ extension SCStreamConfiguration {
// window.size shows logical size. It doesn't change when the scaleFactor changes. We need to correct for this as we need to capture more and less pixels depending on DPI
let originalSize = NSSize(width: size.width * scaleFactor, height: size.height * scaleFactor)
guard originalSize.width > 0, originalSize.height > 0 else { return }
if Preferences.previewFocusedWindow {
if Preferences.previewSelectedWindow {
width = Int(originalSize.width)
height = Int(originalSize.height)
} else {
+12 -12
View File
@@ -95,30 +95,30 @@ class App: AppCenterApplication {
}
func closeSelectedWindow() {
Windows.focusedWindow()?.close()
Windows.selectedWindow()?.close()
}
func minDeminSelectedWindow() {
Windows.focusedWindow()?.minDemin()
Windows.selectedWindow()?.minDemin()
}
func toggleFullscreenSelectedWindow() {
Windows.focusedWindow()?.toggleFullscreen()
Windows.selectedWindow()?.toggleFullscreen()
}
func quitSelectedApp() {
Windows.focusedWindow()?.application.quit()
Windows.selectedWindow()?.application.quit()
}
func hideShowSelectedApp() {
Windows.focusedWindow()?.application.hideOrShow()
Windows.selectedWindow()?.application.hideOrShow()
}
func focusTarget() {
guard appIsBeingUsed else { return } // already hidden
let focusedWindow = Windows.focusedWindow()
Logger.info { focusedWindow?.debugId() }
focusSelectedWindow(focusedWindow)
let selectedWindow = Windows.selectedWindow()
Logger.info { selectedWindow?.debugId() }
focusSelectedWindow(selectedWindow)
}
@objc func checkForUpdatesNow(_ sender: NSMenuItem) {
@@ -169,7 +169,7 @@ class App: AppCenterApplication {
if direction == .up || direction == .down {
thumbnailsPanel.thumbnailsView.navigateUpOrDown(direction, allowWrap: allowWrap)
} else {
Windows.cycleFocusedWindowIndex(direction.step(), allowWrap: allowWrap)
Windows.cycleSelectedWindowIndex(direction.step(), allowWrap: allowWrap)
}
}
@@ -206,13 +206,13 @@ class App: AppCenterApplication {
if !Windows.updatesBeforeShowing() { hideUi(); return }
}
guard appIsBeingUsed else { return }
Windows.updateFocusedWindowIndex()
Windows.updateSelectedWindow()
guard appIsBeingUsed else { return }
thumbnailsPanel.updateContents()
guard appIsBeingUsed else { return }
Windows.voiceOverWindow() // at this point ThumbnailViews are assigned to the window, and ready
guard appIsBeingUsed else { return }
Windows.previewFocusedWindowIfNeeded()
Windows.previewSelectedWindowIfNeeded()
guard appIsBeingUsed else { return }
Applications.refreshBadgesAsync()
}
@@ -230,7 +230,7 @@ class App: AppCenterApplication {
isFirstSummon = false
self.shortcutIndex = shortcutIndex
if !Windows.updatesBeforeShowing() { hideUi(); return }
Windows.setInitialFocusedAndHoveredWindowIndex()
Windows.setInitialSelectedAndHoveredWindowIndex()
if Preferences.windowDisplayDelay == DispatchTimeInterval.milliseconds(0) {
buildUiAndShowPanel()
} else {
+4 -4
View File
@@ -62,7 +62,7 @@ class ThumbnailView: FlippedView {
dragAndDropTimer?.invalidate()
dragAndDropTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: false, block: { _ in
// the user can tab to focus the next thumbnail, while still dragging. We don't want to perform drag then
if Windows.focusedWindowIndex == Windows.hoveredWindowIndex {
if Windows.selectedWindowIndex == Windows.hoveredWindowIndex {
self.mouseUpCallback()
}
})
@@ -130,7 +130,7 @@ class ThumbnailView: FlippedView {
}
func drawHighlight() {
let isFocused = indexInRecycledViews == Windows.focusedWindowIndex
let isFocused = indexInRecycledViews == Windows.selectedWindowIndex
let isHovered = indexInRecycledViews == Windows.hoveredWindowIndex
setBackground(isFocused: isFocused, isHovered: isHovered)
setBorder(isFocused: isFocused, isHovered: isHovered)
@@ -252,7 +252,7 @@ class ThumbnailView: FlippedView {
}
private func updateAppIconsLabel(isFocused: Bool, isHovered: Bool) {
let focusedView = ThumbnailsView.recycledViews[Windows.focusedWindowIndex]
let focusedView = ThumbnailsView.recycledViews[Windows.selectedWindowIndex]
var hoveredView: ThumbnailView? = nil
if Windows.hoveredWindowIndex != nil {
hoveredView = ThumbnailsView.recycledViews[Windows.hoveredWindowIndex!]
@@ -366,7 +366,7 @@ class ThumbnailView: FlippedView {
windowControlIcons.forEach { $0.window_ = element }
showOrHideWindowControls(isShowingWindowControls)
mouseUpCallback = { () -> Void in App.app.focusSelectedWindow(element) }
mouseMovedCallback = { () -> Void in Windows.updateFocusedAndHoveredWindowIndex(index, true) }
mouseMovedCallback = { () -> Void in Windows.updateSelectedAndHoveredWindowIndex(index, true) }
}
private func updateSizes(_ newHeight: CGFloat) {
+5 -5
View File
@@ -50,7 +50,7 @@ class ThumbnailsView {
func nextRow(_ direction: Direction, allowWrap: Bool = true) -> [ThumbnailView]? {
let step = direction == .down ? 1 : -1
if let currentRow = Windows.focusedWindow()?.rowIndex {
if let currentRow = Windows.selectedWindow()?.rowIndex {
var nextRow = currentRow + step
if nextRow >= rows.count {
if allowWrap {
@@ -75,8 +75,8 @@ class ThumbnailsView {
}
func navigateUpOrDown(_ direction: Direction, allowWrap: Bool = true) {
guard Windows.focusedWindowIndex < ThumbnailsView.recycledViews.count else { return }
let focusedViewFrame = ThumbnailsView.recycledViews[Windows.focusedWindowIndex].frame
guard Windows.selectedWindowIndex < ThumbnailsView.recycledViews.count else { return }
let focusedViewFrame = ThumbnailsView.recycledViews[Windows.selectedWindowIndex].frame
let originCenter = NSMidX(focusedViewFrame)
guard let targetRow = nextRow(direction, allowWrap: allowWrap), !targetRow.isEmpty else { return }
let leftSide = originCenter < NSMidX(contentView.frame)
@@ -89,7 +89,7 @@ class ThumbnailsView {
return leadingSide ? NSMinX($0.frame) < originCenter : NSMaxX($0.frame) > originCenter
}) ?? iterable.last else { return }
guard let targetIndex = ThumbnailsView.recycledViews.firstIndex(of: targetView) else { return }
Windows.updateFocusedAndHoveredWindowIndex(targetIndex)
Windows.updateSelectedAndHoveredWindowIndex(targetIndex)
}
func updateItemsAndLayout() {
@@ -227,7 +227,7 @@ class ThumbnailsView {
}
private func highlightStartView() {
ThumbnailsView.highlight(Windows.focusedWindowIndex)
ThumbnailsView.highlight(Windows.selectedWindowIndex)
if let hoveredWindowIndex = Windows.hoveredWindowIndex {
ThumbnailsView.highlight(hoveredWindowIndex)
}
@@ -210,28 +210,28 @@ class ShowHideIllustratedView {
}
}))
showHideRows.append(showTabsAsWindows)
var previewFocusedWindow = ShowHideRowInfo()
previewFocusedWindow.rowId = "previewFocusedWindow"
previewFocusedWindow.uncheckedImage = "hide_preview_focused_window"
previewFocusedWindow.checkedImage = "show_preview_focused_window"
previewFocusedWindow.supportedStyles = [.thumbnails, .appIcons, .titles]
previewFocusedWindow.leftViews = [TableGroupView.makeText(NSLocalizedString("Preview selected window", comment: ""))]
previewFocusedWindow.subTitle = NSLocalizedString("Preview the selected window.", comment: "")
previewFocusedWindow.rightViews.append(LabelAndControl.makeInfoButton(onMouseEntered: { event, view in
if ShowHideIllustratedView.isDisabledOnApplications(previewFocusedWindow) {
var previewSelectedWindow = ShowHideRowInfo()
previewSelectedWindow.rowId = "previewFocusedWindow"
previewSelectedWindow.uncheckedImage = "hide_preview_focused_window"
previewSelectedWindow.checkedImage = "show_preview_focused_window"
previewSelectedWindow.supportedStyles = [.thumbnails, .appIcons, .titles]
previewSelectedWindow.leftViews = [TableGroupView.makeText(NSLocalizedString("Preview selected window", comment: ""))]
previewSelectedWindow.subTitle = NSLocalizedString("Preview the selected window.", comment: "")
previewSelectedWindow.rightViews.append(LabelAndControl.makeInfoButton(onMouseEntered: { event, view in
if ShowHideIllustratedView.isDisabledOnApplications(previewSelectedWindow) {
Popover.shared.show(event: event, positioningView: view, message: featureUnavailable)
} else {
Popover.shared.show(event: event, positioningView: view, message: previewFocusedWindow.subTitle!)
Popover.shared.show(event: event, positioningView: view, message: previewSelectedWindow.subTitle!)
}
}, onMouseExited: { event, view in
Popover.shared.hide()
}))
previewFocusedWindow.rightViews.append(LabelAndControl.makeSwitch(previewFocusedWindow.rowId, extraAction: { sender in
if !ShowHideIllustratedView.isDisabledOnApplications(previewFocusedWindow) {
self.onCheckboxClicked(sender: sender, rowId: previewFocusedWindow.rowId)
previewSelectedWindow.rightViews.append(LabelAndControl.makeSwitch(previewSelectedWindow.rowId, extraAction: { sender in
if !ShowHideIllustratedView.isDisabledOnApplications(previewSelectedWindow) {
self.onCheckboxClicked(sender: sender, rowId: previewSelectedWindow.rowId)
}
}))
showHideRows.append(previewFocusedWindow)
showHideRows.append(previewSelectedWindow)
}
static func isDisabledOnApplications(_ row: ShowHideRowInfo) -> Bool {