fix: windows would sometimes not update their order (closes #5207)
This commit is contained in:
1 parent
d1fd1fd9d0
commit
a3f2a54f20
16 files changed
+862
-966
No files matched your search
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user