From 10b2c714ca9c9c8fa4229928204f8794d68112cf Mon Sep 17 00:00:00 2001 From: "louis.pontoise" Date: Fri, 10 Jan 2020 15:01:50 +0900 Subject: [PATCH] fix: quitting apps was not properly removing apps from the list Instead of using the PSN, we now use .equals() to compare 2 NSRunningApplication as recommended by Apple docs --- .../api-wrappers/HelperExtensions.swift | 13 ++++++ alt-tab-macos/logic/Application.swift | 18 ++++----- alt-tab-macos/logic/Applications.swift | 40 ++++++++----------- alt-tab-macos/logic/Window.swift | 10 ++--- alt-tab-macos/logic/Windows.swift | 28 ++++--------- alt-tab-macos/ui/App.swift | 2 +- alt-tab-macos/ui/ThumbnailsPanel.swift | 8 ++-- 7 files changed, 56 insertions(+), 63 deletions(-) diff --git a/alt-tab-macos/api-wrappers/HelperExtensions.swift b/alt-tab-macos/api-wrappers/HelperExtensions.swift index 183530a5..703c804c 100644 --- a/alt-tab-macos/api-wrappers/HelperExtensions.swift +++ b/alt-tab-macos/api-wrappers/HelperExtensions.swift @@ -72,3 +72,16 @@ extension NSObject { removeObserver(observer, forKeyPath: key) } } + +extension Array where Element == Window { + func firstIndexThatMatches(_ element: AXUIElement) -> Self.Index? { + // `CFEqual` is safer than comparing `CGWindowID` because it will succeed even if the window is deallocated + // by the OS, in which case the `CGWindowID` will be `-1` + return firstIndex(where: { CFEqual($0.axUiElement, element) }) + } + + func firstWindowThatMatches(_ element: AXUIElement) -> Window? { + guard let index = firstIndexThatMatches(element) else { return nil } + return self[index] + } +} diff --git a/alt-tab-macos/logic/Application.swift b/alt-tab-macos/logic/Application.swift index 27b1e5e4..9e4fcb61 100644 --- a/alt-tab-macos/logic/Application.swift +++ b/alt-tab-macos/logic/Application.swift @@ -36,7 +36,7 @@ class Application: NSObject { func observeNewWindows() { var newWindows = [AXUIElement]() for window in getActualWindows() { - guard Windows.listRecentlyUsedFirst.firstIndexThatMatches(window) == nil else { continue } + guard Windows.list.firstIndexThatMatches(window) == nil else { continue } newWindows.append(window) } addWindows(newWindows) @@ -53,7 +53,7 @@ class Application: NSObject { } private func addWindows(_ windows: [AXUIElement]) { - Windows.listRecentlyUsedFirst.insert(contentsOf: windows.map { Window($0, self) }, at: 0) + Windows.list.insert(contentsOf: windows.map { Window($0, self) }, at: 0) } private func observeEvents(_ windows: [AXUIElement]) { @@ -77,10 +77,10 @@ func axObserverApplicationCallback(observer: AXObserver, element: AXUIElement, n case kAXApplicationActivatedNotification: guard !app.appIsBeingUsed, let appFocusedWindow = element.focusedWindow(), - let existingIndex = Windows.listRecentlyUsedFirst.firstIndexThatMatches(appFocusedWindow) else { return } - Windows.listRecentlyUsedFirst.insert(Windows.listRecentlyUsedFirst.remove(at: existingIndex), at: 0) + let existingIndex = Windows.list.firstIndexThatMatches(appFocusedWindow) else { return } + Windows.list.insert(Windows.list.remove(at: existingIndex), at: 0) case kAXApplicationHiddenNotification, kAXApplicationShownNotification: - for window in Windows.listRecentlyUsedFirst { + for window in Windows.list { guard window.application.axUiElement!.pid() == element.pid() else { continue } window.isHidden = type == kAXApplicationHiddenNotification } @@ -88,9 +88,9 @@ func axObserverApplicationCallback(observer: AXObserver, element: AXUIElement, n case kAXWindowCreatedNotification: guard element.isActualWindow() else { return } // a window being un-minimized can trigger kAXWindowCreatedNotification - guard Windows.listRecentlyUsedFirst.firstIndexThatMatches(element) == nil else { return } + guard Windows.list.firstIndexThatMatches(element) == nil else { return } let window = Window(element, application) - Windows.listRecentlyUsedFirst.insert(window, at: 0) + Windows.list.insert(window, at: 0) Windows.moveFocusedWindowIndexAfterWindowCreatedInBackground() // TODO: find a better way to get thumbnail of the new window window.refreshThumbnail() @@ -98,8 +98,8 @@ func axObserverApplicationCallback(observer: AXObserver, element: AXUIElement, n case kAXFocusedWindowChangedNotification: guard !app.appIsBeingUsed, element.isActualWindow(), - let existingIndex = Windows.listRecentlyUsedFirst.firstIndexThatMatches(element) else { return } - Windows.listRecentlyUsedFirst.insert(Windows.listRecentlyUsedFirst.remove(at: existingIndex), at: 0) + let existingIndex = Windows.list.firstIndexThatMatches(element) else { return } + Windows.list.insert(Windows.list.remove(at: existingIndex), at: 0) default: return } } diff --git a/alt-tab-macos/logic/Applications.swift b/alt-tab-macos/logic/Applications.swift index a8b51ac4..07aa1363 100644 --- a/alt-tab-macos/logic/Applications.swift +++ b/alt-tab-macos/logic/Applications.swift @@ -2,52 +2,44 @@ import Foundation import Cocoa class Applications { - static var map = [pid_t: Application]() + static var list = [Application]() static var appsObserver = RunningApplicationsObserver() static func addInitialRunningApplications() { addRunningApplications(NSWorkspace.shared.runningApplications) } - static func addRunningApplications(_ runningApps: [NSRunningApplication]) { - for app in filterApplications(runningApps) { - Applications.map[app.processIdentifier] = Application(app) - } - } - static func observeRunningApplications() { NSWorkspace.shared.addObserver(Applications.appsObserver, forKeyPath: "runningApplications", options: [.old, .new], context: nil) } static func reviewRunningApplicationsWindows() { - for app in map.values { + for app in list { guard app.runningApplication.isFinishedLaunching else { continue } app.observeNewWindows() } } - static func removeApplications(_ runningApps: [NSRunningApplication]) { - var someAppsAreAlreadyTerminated = false + static func addRunningApplications(_ runningApps: [NSRunningApplication]) { + for app in filterApplications(runningApps) { + Applications.list.append(Application(app)) + } + } + + static func removeRunningApplications(_ runningApps: [NSRunningApplication]) { for runningApp in runningApps { - guard runningApp.bundleIdentifier != nil else { someAppsAreAlreadyTerminated = true; continue } - guard let app = Applications.map[runningApp.processIdentifier] else { continue } + guard let app = Applications.list.first(where: { $0.runningApplication.isEqual(runningApp) }) else { continue } var windowsToKeep = [Window]() - for window in Windows.listRecentlyUsedFirst { - guard window.application.runningApplication.processIdentifier != runningApp.processIdentifier else { continue } + for window in Windows.list { + guard !window.application.runningApplication.isEqual(runningApp) else { continue } windowsToKeep.append(window) } - Windows.listRecentlyUsedFirst = windowsToKeep + Windows.list = windowsToKeep // some apps never finish launching; the observer leaks for them without this app.removeObserver() - Applications.map.removeValue(forKey: runningApp.processIdentifier) - } - // sometimes removed `runningApps` are already terminated by the time they reach this method so we can't match their pid in `Applications.map` above - // we need to remove them based on their lack of `bundleIdentifier` - if someAppsAreAlreadyTerminated { - Windows.listRecentlyUsedFirst.removeAll(where: { $0.application.runningApplication.bundleIdentifier == nil }) - Applications.map = Applications.map.filter { $0.value.runningApplication.bundleIdentifier != nil } + Applications.list.removeAll(where: { $0.runningApplication.isEqual(runningApp) }) } - guard Windows.listRecentlyUsedFirst.count > 0 else { (App.shared as! App).hideUi(); return } + guard Windows.list.count > 0 else { (App.shared as! App).hideUi(); return } // TODO: implement of more sophisticated way to decide which thumbnail gets focused on app quit Windows.focusedWindowIndex = 1 (App.shared as! App).refreshOpenUi() @@ -71,7 +63,7 @@ class RunningApplicationsObserver: NSObject { case .removal: let apps = change![.oldKey] as! [NSRunningApplication] debugPrint("OS event: apps quit", apps.map { ($0.processIdentifier, $0.bundleIdentifier) }) - Applications.removeApplications(apps) + Applications.removeRunningApplications(apps) default: return } } diff --git a/alt-tab-macos/logic/Window.swift b/alt-tab-macos/logic/Window.swift index 8968be9b..90bfe6a3 100644 --- a/alt-tab-macos/logic/Window.swift +++ b/alt-tab-macos/logic/Window.swift @@ -104,20 +104,20 @@ func axObserverWindowCallback(observer: AXObserver, element: AXUIElement, notifi debugPrint("OS event: " + type, element.title()) switch type { case kAXUIElementDestroyedNotification: - guard let existingIndex = Windows.listRecentlyUsedFirst.firstIndexThatMatches(element) else { return } - Windows.listRecentlyUsedFirst.remove(at: existingIndex) - guard Windows.listRecentlyUsedFirst.count > 0 else { app.hideUi(); return } + guard let existingIndex = Windows.list.firstIndexThatMatches(element) else { return } + Windows.list.remove(at: existingIndex) + guard Windows.list.count > 0 else { app.hideUi(); return } Windows.moveFocusedWindowIndexAfterWindowDestroyedInBackground(existingIndex) app.refreshOpenUi() case kAXWindowMiniaturizedNotification, kAXWindowDeminiaturizedNotification: - guard let window = Windows.listRecentlyUsedFirst.firstWindowThatMatches(element) else { return } + guard let window = Windows.list.firstWindowThatMatches(element) else { return } window.isMinimized = type == kAXWindowMiniaturizedNotification // TODO: find a better way to get thumbnail of the new window (when AltTab is triggered min/demin animation) window.refreshThumbnail() app.refreshOpenUi() case kAXTitleChangedNotification: guard element.isActualWindow(), - let window = Windows.listRecentlyUsedFirst.firstWindowThatMatches(element), + let window = Windows.list.firstWindowThatMatches(element), let newTitle = window.axUiElement.title(), newTitle != window.title else { return } window.title = newTitle diff --git a/alt-tab-macos/logic/Windows.swift b/alt-tab-macos/logic/Windows.swift index e89bb088..97a0b7fb 100644 --- a/alt-tab-macos/logic/Windows.swift +++ b/alt-tab-macos/logic/Windows.swift @@ -2,15 +2,16 @@ import Cocoa import Foundation class Windows { - static var listRecentlyUsedFirst = [Window]() + // order in the array is important: most-recently-used elements are first + static var list = [Window]() static var focusedWindowIndex = Array.Index(0) static func focusedWindow() -> Window? { - return listRecentlyUsedFirst.count > focusedWindowIndex ? listRecentlyUsedFirst[focusedWindowIndex] : nil + return list.count > focusedWindowIndex ? list[focusedWindowIndex] : nil } static func cycleFocusedWindowIndex(_ step: Array.Index) { - focusedWindowIndex = focusedWindowIndex + step < 0 ? listRecentlyUsedFirst.count - 1 : (focusedWindowIndex + step) % listRecentlyUsedFirst.count + focusedWindowIndex = focusedWindowIndex + step < 0 ? list.count - 1 : (focusedWindowIndex + step) % list.count } static func moveFocusedWindowIndexAfterWindowDestroyedInBackground(_ destroyedWindowIndex: Array.Index) { @@ -26,7 +27,7 @@ class Windows { static func updateSpaces() { let spacesMap = Spaces.allIdsAndIndexes() - for window in listRecentlyUsedFirst { + for window in list { guard let spaceId = (CGSCopySpacesForWindows(cgsMainConnectionId, CGSSpaceMask.all.rawValue, [window.cgWindowId] as CFArray) as! [CGSSpaceID]).first else { continue } window.spaceId = spaceId window.spaceIndex = spacesMap.first { $0.0 == spaceId }!.1 @@ -38,7 +39,7 @@ class Windows { for (index, cgWindowId) in Spaces.windowsInSpaces([Spaces.currentSpaceId]).enumerated() { windowLevelMap[cgWindowId] = index } - var sortedTuples = Windows.listRecentlyUsedFirst.map { (windowLevelMap[$0.cgWindowId], $0) } + var sortedTuples = Windows.list.map { (windowLevelMap[$0.cgWindowId], $0) } sortedTuples.sort(by: { if $0.0 == nil { return false @@ -48,25 +49,12 @@ class Windows { } return $0.0! < $1.0! }) - Windows.listRecentlyUsedFirst = sortedTuples.map { $0.1 } + Windows.list = sortedTuples.map { $0.1 } } static func refreshAllThumbnails() { - for window in listRecentlyUsedFirst { + for window in list { window.refreshThumbnail() } } } - -extension Array where Element == Window { - func firstIndexThatMatches(_ element: AXUIElement) -> Self.Index? { - // `CFEqual` is safer than comparing `CGWindowID` because it will succeed even if the window is deallocated - // by the OS, in which case the `CGWindowID` will be `-1` - return firstIndex(where: { CFEqual($0.axUiElement, element) }) - } - - func firstWindowThatMatches(_ element: AXUIElement) -> Window? { - guard let index = firstIndexThatMatches(element) else { return nil } - return self[index] - } -} diff --git a/alt-tab-macos/ui/App.swift b/alt-tab-macos/ui/App.swift index 65c774ed..112cf7de 100644 --- a/alt-tab-macos/ui/App.swift +++ b/alt-tab-macos/ui/App.swift @@ -82,7 +82,7 @@ class App: NSApplication, NSApplicationDelegate, NSWindowDelegate { if isFirstSummon { debugPrint("showUiOrCycleSelection: isFirstSummon") isFirstSummon = false - if Windows.listRecentlyUsedFirst.count == 0 || CGWindow.isMissionControlActive() { + if Windows.list.count == 0 || CGWindow.isMissionControlActive() { appIsBeingUsed = false isFirstSummon = true return diff --git a/alt-tab-macos/ui/ThumbnailsPanel.swift b/alt-tab-macos/ui/ThumbnailsPanel.swift index 843d010e..8c2094d1 100644 --- a/alt-tab-macos/ui/ThumbnailsPanel.swift +++ b/alt-tab-macos/ui/ThumbnailsPanel.swift @@ -62,18 +62,18 @@ class ThumbnailsPanel: NSPanel, NSCollectionViewDataSource, NSCollectionViewDele } func collectionView(_ collectionView: NSCollectionView, numberOfItemsInSection section: Int) -> Int { - return Windows.listRecentlyUsedFirst.count + return Windows.list.count } func collectionView(_ collectionView: NSCollectionView, itemForRepresentedObjectAt indexPath: IndexPath) -> NSCollectionViewItem { let item = collectionView.makeItem(withIdentifier: cellId, for: indexPath) as! Cell - item.updateWithNewContent(Windows.listRecentlyUsedFirst[indexPath.item], app!.focusSelectedWindow, app!.thumbnailsPanel!.highlightCell, currentScreen!) + item.updateWithNewContent(Windows.list[indexPath.item], app!.focusSelectedWindow, app!.thumbnailsPanel!.highlightCell, currentScreen!) return item } func collectionView(_ collectionView: NSCollectionView, layout collectionViewLayout: NSCollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> NSSize { - if indexPath.item < Windows.listRecentlyUsedFirst.count { - let (width, height) = Cell.computeDownscaledSize(Windows.listRecentlyUsedFirst[indexPath.item].thumbnail, currentScreen!) + if indexPath.item < Windows.list.count { + let (width, height) = Cell.computeDownscaledSize(Windows.list[indexPath.item].thumbnail, currentScreen!) return NSSize(width: CGFloat(width) + Preferences.cellPadding * 2, height: CGFloat(height) + max(Preferences.fontHeight!, Preferences.iconSize!) + Preferences.interItemPadding + Preferences.cellPadding * 2) } return .zero