diff --git a/src/logic/Application.swift b/src/logic/Application.swift index dec09ab7..41282e76 100644 --- a/src/logic/Application.swift +++ b/src/logic/Application.swift @@ -54,7 +54,7 @@ class Application: NSObject { func removeWindowslessAppWindow() { if let windowlessAppWindow = (Windows.list.firstIndex { $0.isWindowlessApp == true && $0.application.pid == pid }) { Windows.list.remove(at: windowlessAppWindow) - App.app.refreshOpenUi([]) + App.app.refreshOpenUi([], .refreshUiAfterExternalEvent) } } @@ -72,9 +72,11 @@ class Application: NSObject { retryAxCallUntilTimeout(group, 5) { [weak self] in guard let self = self else { return } var atLeastOneActualWindow = false - if let axWindows_ = try self.axUiElement!.windows(), axWindows_.count > 0 { + if let axWindows_ = try self.axUiElement!.windows(), !axWindows_.isEmpty { // bug in macOS: sometimes the OS returns multiple duplicate windows (e.g. Mail.app starting at login) - try Array(Set(axWindows_)).forEach { axWindow in + let uniqueWindows = Array(Set(axWindows_)) + if uniqueWindows.isEmpty { return } + for axWindow in uniqueWindows { if let wid = try axWindow.cgWindowId() { let title = try axWindow.title() let subrole = try axWindow.subrole() @@ -96,7 +98,7 @@ class Application: NSObject { window.position = position } else { let window = self.addWindow(axWindow, wid, title, isFullscreen, isMinimized, position, size) - App.app.refreshOpenUi([window]) + App.app.refreshOpenUi([window], .refreshUiAfterExternalEvent) } } } @@ -107,7 +109,7 @@ class Application: NSObject { DispatchQueue.main.async { [weak self] in guard let self = self else { return } if self.addWindowlessWindowIfNeeded() != nil { - App.app.refreshOpenUi([]) + App.app.refreshOpenUi([], .refreshUiAfterExternalEvent) } } // workaround: some apps launch but take a while to create their window(s) diff --git a/src/logic/Applications.swift b/src/logic/Applications.swift index 2bbccb75..8332c8ea 100644 --- a/src/logic/Applications.swift +++ b/src/logic/Applications.swift @@ -83,7 +83,7 @@ class Applications { Windows.cycleFocusedWindowIndex(-windowsOnTheLeftOfFocusedWindow) } if !existingWindowstoRemove.isEmpty { - App.app.refreshOpenUi([]) + App.app.refreshOpenUi([], .refreshUiAfterExternalEvent) } } } diff --git a/src/logic/BackgroundWork.swift b/src/logic/BackgroundWork.swift index dfd92e25..3b223f33 100644 --- a/src/logic/BackgroundWork.swift +++ b/src/logic/BackgroundWork.swift @@ -12,16 +12,18 @@ class BackgroundWork { static var repeatingKeyThread: BackgroundThreadWithRunLoop! static var missionControlThread: BackgroundThreadWithRunLoop! static var cliEventsThread: BackgroundThreadWithRunLoop! - static let screenshotsDispatchGroup = DispatchGroup() // swift static variables are lazy; we artificially force the threads to init static func start() { - // screenshots are taken off the main thread, concurrently - screenshotsQueue = DispatchQueue.globalConcurrent("screenshotsQueue", .userInteractive) + // screenshots are taken on a serial DispatchQueue. They used to be taken on the .global() concurrent queue. + // it could hand the app the screenshot OS calls are slow. It would hang or crash with this error: + // >Processes reached dispatch thread soft limit (64) + screenshotsQueue = DispatchQueue.queue("screenshotsQueue", .userInteractive, false) // calls to act on windows (e.g. AXUIElementSetAttributeValue, AXUIElementPerformAction) are done off the main thread - accessibilityCommandsQueue = DispatchQueue.globalConcurrent("accessibilityCommandsQueue", .userInteractive) - // calls to the AX APIs are blocking. We dispatch those on a globalConcurrent queue - axCallsQueue = DispatchQueue.globalConcurrent("axCallsQueue", .userInteractive) + accessibilityCommandsQueue = DispatchQueue.queue("accessibilityCommandsQueue", .userInteractive, false) + // calls to the AX APIs can block for a long time (e.g. if an app is unresponsive) + // We can't use a serial queue. We use the global concurrent queue + axCallsQueue = DispatchQueue.queue("axCallsQueue", .userInteractive, true) // we observe app and windows notifications. They arrive on this thread, and are handled off the main thread initially accessibilityEventsThread = BackgroundThreadWithRunLoop("accessibilityEventsThread", .userInteractive) // we listen to as any keyboard events as possible on a background thread, as it's more available/reliable than the main thread @@ -37,7 +39,7 @@ class BackgroundWork { static func startCrashReportsQueue() { if crashReportsQueue == nil { // crash reports can be sent off the main thread - crashReportsQueue = DispatchQueue.globalConcurrent("crashReportsQueue", .utility) + crashReportsQueue = DispatchQueue.queue("crashReportsQueue", .utility, false) } } @@ -79,27 +81,14 @@ class BackgroundWork { } } -// we cap concurrent tasks to .processorCount to avoid thread explosion on the .global queue -let backgroundWorkGlobalSemaphore = DispatchSemaphore(value: ProcessInfo.processInfo.processorCount) - extension DispatchQueue { - static func globalConcurrent(_ label: String, _ qos: DispatchQoS) -> DispatchQueue { - // label is not reflected in Instruments because the target is .global - // if we want to see our custom labels, we need our private queue. - // However, we want to be efficient and use the OS thread pool, so we use .global - DispatchQueue(label: label, attributes: .concurrent, target: .global(qos: qos.qosClass)) - } - - func asyncWithCap(_ deadline: DispatchTime? = nil, _ fn: @escaping () -> Void) { - let block = { - fn() - backgroundWorkGlobalSemaphore.signal() - } - backgroundWorkGlobalSemaphore.wait() - if let deadline = deadline { - asyncAfter(deadline: deadline, execute: block) - } else { - async(execute: block) + static func queue(_ label: String, _ qos: DispatchQoS, _ globalParallel: Bool) -> DispatchQueue { + if globalParallel { + // label is not reflected in Instruments because the target is .global + // if we want to see our custom labels, we need our private queue. + // However, we want to be efficient and use the OS thread pool, so we use .global + return DispatchQueue(label: label, attributes: [.concurrent], target: .global(qos: qos.qosClass)) } + return DispatchQueue(label: label, qos: qos) } } diff --git a/src/logic/Window.swift b/src/logic/Window.swift index 2eb67435..a0e08a24 100644 --- a/src/logic/Window.swift +++ b/src/logic/Window.swift @@ -97,18 +97,17 @@ class Window { CFRunLoopAddSource(BackgroundWork.accessibilityEventsThread.runLoop, AXObserverGetRunLoopSource(axObserver), .commonModes) } - func refreshThumbnail(_ screenshot: NSImage?) { + func refreshThumbnail(_ screenshot: NSImage) { thumbnail = screenshot - thumbnailFullSize = screenshot?.size - if App.app.appIsBeingUsed && shouldShowTheUser { - if let index = (Windows.list.firstIndex { $0.cgWindowId == cgWindowId }) { - let view = ThumbnailsView.recycledViews[index] - if !view.thumbnail.isHidden { - view.thumbnail.image = thumbnail?.copyToSeparateContexts() - let thumbnailSize = ThumbnailView.thumbnailSize(thumbnail, false) - view.thumbnail.setSize(thumbnailSize) - } + thumbnailFullSize = screenshot.size + if !App.app.appIsBeingUsed || !shouldShowTheUser { return } + if let view = (ThumbnailsView.recycledViews.first { $0.window_?.cgWindowId == cgWindowId }) { + if !view.thumbnail.isHidden { + view.thumbnail.image = thumbnail?.copyToSeparateContexts() + let thumbnailSize = ThumbnailView.thumbnailSize(thumbnail, false) + view.thumbnail.setSize(thumbnailSize) } + App.app.previewPanel.updateImageIfShowing(cgWindowId, screenshot, screenshot.size) } } @@ -121,7 +120,7 @@ class Window { NSSound.beep() return } - BackgroundWork.accessibilityCommandsQueue.asyncWithCap { [weak self] in + BackgroundWork.accessibilityCommandsQueue.async { [weak self] in guard let self = self else { return } if self.isFullscreen { self.axUiElement.setAttribute(kAXFullscreenAttribute, false) @@ -141,12 +140,12 @@ class Window { NSSound.beep() return } - BackgroundWork.accessibilityCommandsQueue.asyncWithCap { [weak self] in + BackgroundWork.accessibilityCommandsQueue.async { [weak self] in guard let self = self else { return } if self.isFullscreen { self.axUiElement.setAttribute(kAXFullscreenAttribute, false) // minimizing is ignored if sent immediatly; we wait for the de-fullscreen animation to be over - BackgroundWork.accessibilityCommandsQueue.asyncWithCap(.now() + .seconds(1)) { [weak self] in + BackgroundWork.accessibilityCommandsQueue.asyncAfter(deadline: .now() + .seconds(1)) { [weak self] in guard let self = self else { return } self.axUiElement.setAttribute(kAXMinimizedAttribute, true) } @@ -161,7 +160,7 @@ class Window { NSSound.beep() return } - BackgroundWork.accessibilityCommandsQueue.asyncWithCap { [weak self] in + BackgroundWork.accessibilityCommandsQueue.async { [weak self] in guard let self = self else { return } self.axUiElement.setAttribute(kAXFullscreenAttribute, !self.isFullscreen) } @@ -186,7 +185,7 @@ class Window { // 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 // You can reproduce this buggy behaviour by clicking on the dock icon, proving it's an OS bug - BackgroundWork.accessibilityCommandsQueue.asyncWithCap { [weak self] in + BackgroundWork.accessibilityCommandsQueue.async { [weak self] in guard let self = self else { return } var psn = ProcessSerialNumber() GetProcessForPID(self.application.pid, &psn) diff --git a/src/logic/Windows.swift b/src/logic/Windows.swift index 368a7c0d..19439ae4 100644 --- a/src/logic/Windows.swift +++ b/src/logic/Windows.swift @@ -273,7 +273,6 @@ class Windows { lazy var cgsWindowIds = Spaces.windowsInSpaces(spaceIdsAndIndexes) lazy var visibleCgsWindowIds = Spaces.windowsInSpaces(spaceIdsAndIndexes, false) for window in list { - // TODO: can the CGS call inside detectTabbedWindows introduce latency when WindowServer is busy? detectTabbedWindows(window, cgsWindowIds, visibleCgsWindowIds) updatesWindowSpace(window) refreshIfWindowShouldBeShownToTheUser(window) @@ -303,7 +302,7 @@ class Windows { } // dispatch screenshot requests off the main-thread, then wait for completion - static func refreshThumbnails(_ windows: [Window], _ onlyUpdateScreenshots: Bool) { + static func refreshThumbnails(_ windows: [Window], _ source: RefreshCausedBy) { var eligibleWindows = [Window]() for window in windows { if !window.isWindowlessApp, let cgWindowId = window.cgWindowId, cgWindowId != CGWindowID(bitPattern: -1) { @@ -311,30 +310,22 @@ class Windows { } } if eligibleWindows.isEmpty { return } - screenshotEligibleWindowsAndRefreshUi(eligibleWindows, onlyUpdateScreenshots) + screenshotEligibleWindowsAndRefreshUi(eligibleWindows, source) } - private static func screenshotEligibleWindowsAndRefreshUi(_ eligibleWindows: [Window], _ onlyUpdateScreenshots: Bool) { - eligibleWindows.forEach { _ in BackgroundWork.screenshotsDispatchGroup.enter() } + private static func screenshotEligibleWindowsAndRefreshUi(_ eligibleWindows: [Window], _ source: RefreshCausedBy) { for window in eligibleWindows { BackgroundWork.screenshotsQueue.async { [weak window] in - backgroundWorkGlobalSemaphore.wait() - defer { - backgroundWorkGlobalSemaphore.signal() - BackgroundWork.screenshotsDispatchGroup.leave() - } - if let cgImage = window?.cgWindowId?.screenshot() { + if source == .refreshOnlyThumbnailsAfterShowUi && !App.app.appIsBeingUsed { return } + if let wid = window?.cgWindowId, let cgImage = wid.screenshot() { + if source == .refreshOnlyThumbnailsAfterShowUi && !App.app.appIsBeingUsed { return } DispatchQueue.main.async { [weak window] in + if source == .refreshOnlyThumbnailsAfterShowUi && !App.app.appIsBeingUsed { return } window?.refreshThumbnail(NSImage.fromCgImage(cgImage)) } } } } - if !onlyUpdateScreenshots { - BackgroundWork.screenshotsDispatchGroup.notify(queue: DispatchQueue.main) { - App.app.refreshOpenUi([]) - } - } } static func refreshWhichWindowsToShowTheUser() { diff --git a/src/logic/events/AccessibilityEvents.swift b/src/logic/events/AccessibilityEvents.swift index 7b5617e4..32b89fdb 100644 --- a/src/logic/events/AccessibilityEvents.swift +++ b/src/logic/events/AccessibilityEvents.swift @@ -42,7 +42,7 @@ fileprivate func applicationActivated(_ element: AXUIElement, _ pid: pid_t) thro 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!] : []) + App.app.refreshOpenUi(window != nil ? [window!] : [], .refreshUiAfterExternalEvent) } } } @@ -55,7 +55,11 @@ fileprivate func applicationHiddenOrShown(_ pid: pid_t, _ type: String) throws { // for AXUIElement of apps, CFEqual or == don't work; looks like a Cocoa bug return $0.application.pid == pid } - App.app.refreshOpenUi(windows) + // 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) + } } } } @@ -77,7 +81,7 @@ fileprivate func windowCreated(_ element: AXUIElement, _ pid: pid_t) throws { let window = Window(element, app, wid, axTitle, isFullscreen, isMinimized, position, size) Windows.appendAndUpdateFocus(window) Windows.cycleFocusedWindowIndex(1) - App.app.refreshOpenUi([window]) + App.app.refreshOpenUi([window], .refreshUiAfterExternalEvent) } } } @@ -106,11 +110,11 @@ fileprivate func focusedWindowChanged(_ element: AXUIElement, _ pid: pid_t) thro app.focusedWindow = w } if let windows = Windows.updateLastFocus(element, wid) { - App.app.refreshOpenUi(windows) + App.app.refreshOpenUi(windows, .refreshUiAfterExternalEvent) } else if AXUIElement.isActualWindow(app, wid, level, axTitle, subrole, role, size) { let window = Window(element, app, wid, axTitle, isFullscreen, isMinimized, position, size) Windows.appendAndUpdateFocus(window) - App.app.refreshOpenUi([window]) + App.app.refreshOpenUi([window], .refreshUiAfterExternalEvent) } } } @@ -142,7 +146,7 @@ fileprivate func windowDestroyed(_ element: AXUIElement, _ pid: pid_t) throws { } if Windows.list.count > 0 { Windows.moveFocusedWindowIndexAfterWindowDestroyedInBackground(index) - App.app.refreshOpenUi([]) + App.app.refreshOpenUi([], .refreshUiAfterExternalEvent) } else { App.app.hideUi() } @@ -155,7 +159,7 @@ fileprivate func windowMiniaturizedOrDeminiaturized(_ element: AXUIElement, _ ty DispatchQueue.main.async { if let window = (Windows.list.first { $0.isEqualRobust(element, wid) }) { window.isMinimized = type == kAXWindowMiniaturizedNotification - App.app.refreshOpenUi([window]) + App.app.refreshOpenUi([window], .refreshUiAfterExternalEvent) } } } @@ -167,7 +171,7 @@ fileprivate func windowTitleChanged(_ element: AXUIElement) throws { DispatchQueue.main.async { if let window = (Windows.list.first { $0.isEqualRobust(element, wid) }), newTitle != window.title { window.title = window.bestEffortTitle(newTitle) - App.app.refreshOpenUi([window]) + App.app.refreshOpenUi([window], .refreshUiAfterExternalEvent) } } } @@ -188,7 +192,7 @@ fileprivate func windowResizedOrMoved(_ element: AXUIElement) throws { window.isFullscreen = isFullscreen App.app.checkIfShortcutsShouldBeDisabled(window, nil) } - App.app.refreshOpenUi([window]) + App.app.refreshOpenUi([window], .refreshUiAfterExternalEvent) } } } diff --git a/src/logic/events/ScreensEvents.swift b/src/logic/events/ScreensEvents.swift index d8405e06..47559e2e 100644 --- a/src/logic/events/ScreensEvents.swift +++ b/src/logic/events/ScreensEvents.swift @@ -8,7 +8,7 @@ class ScreensEvents { @objc private static func handleEvent(_ notification: Notification) { Logger.debug(notification.name.rawValue) // a screen added or removed can shuffle windows around Spaces; we refresh them - App.app.refreshOpenUi(Windows.list) + App.app.refreshOpenUi(Windows.list, .refreshUiAfterExternalEvent) Logger.info("screens", NSScreen.screens.map { ($0.uuid() ?? "nil" as CFString, $0.frame) }) Logger.info("spaces", Spaces.screenSpacesMap) Logger.info("current space", Spaces.currentSpaceIndex, Spaces.currentSpaceId) diff --git a/src/logic/events/SpacesEvents.swift b/src/logic/events/SpacesEvents.swift index c172e040..e74adc5b 100644 --- a/src/logic/events/SpacesEvents.swift +++ b/src/logic/events/SpacesEvents.swift @@ -8,7 +8,7 @@ class SpacesEvents { @objc private static func handleEvent(_ notification: Notification) { Logger.debug(notification.name.rawValue) // if UI was kept open during Space transition, the Spaces may be obsolete; we refresh them - App.app.refreshOpenUi(Windows.list) + App.app.refreshOpenUi(Windows.list, .refreshUiAfterExternalEvent) Logger.info("current space", Spaces.currentSpaceIndex, Spaces.currentSpaceId) // from macos 12.2 beta onwards, we can't get other-space windows; grabbing windows when switching spaces mitigates the issue // also, updating windows on Space transition works around an issue with Safari where its fullscreen windows spawn not in fullscreen. diff --git a/src/ui/App.swift b/src/ui/App.swift index df8a7174..8b5a9d00 100644 --- a/src/ui/App.swift +++ b/src/ui/App.swift @@ -197,15 +197,15 @@ class App: AppCenterApplication { CGWarpMouseCursorPosition(point) } - func refreshOpenUi(_ windowsToScreenshot: [Window], skipUpdatesBeforeShowing: Bool = false, onlyUpdateScreenshots: Bool = false) { + func refreshOpenUi(_ windowsToScreenshot: [Window], _ source: RefreshCausedBy) { if !windowsToScreenshot.isEmpty && SystemPermissions.screenRecordingPermission == .granted && !Preferences.onlyShowApplications() && (!Appearance.hideThumbnails || Preferences.previewFocusedWindow) { - Windows.refreshThumbnails(windowsToScreenshot, onlyUpdateScreenshots) - return + Windows.refreshThumbnails(windowsToScreenshot, source) + if source == .refreshOnlyThumbnailsAfterShowUi { return } } guard appIsBeingUsed else { return } - if !skipUpdatesBeforeShowing { + if source == .refreshUiAfterExternalEvent { if !Windows.updatesBeforeShowing() { hideUi(); return } } guard appIsBeingUsed else { return } @@ -262,10 +262,10 @@ class App: AppCenterApplication { thumbnailsPanel.makeKeyAndOrderFront(nil) // workaround: without this, switching between 2 screens make thumbnailPanel invisible KeyRepeatTimer.toggleRepeatingKeyNextWindow() guard appIsBeingUsed else { return } - refreshOpenUi([], skipUpdatesBeforeShowing: true) // first display with the data we have + refreshOpenUi([], .showUi) guard appIsBeingUsed else { return } thumbnailsPanel.show() - refreshOpenUi(Windows.list, skipUpdatesBeforeShowing: true, onlyUpdateScreenshots: true) // second refresh with fresh thumbnails + refreshOpenUi(Windows.list, .refreshOnlyThumbnailsAfterShowUi) } func checkIfShortcutsShouldBeDisabled(_ activeWindow: Window?, _ activeApp: NSRunningApplication?) { @@ -338,3 +338,10 @@ extension App: NSApplicationDelegate { setNativeCommandTabEnabled(true) } } + +enum RefreshCausedBy { + case showUi + case refreshOnlyThumbnailsAfterShowUi + case refreshUiAfterThumbnailsHaveBeenRefreshed + case refreshUiAfterExternalEvent +} \ No newline at end of file diff --git a/src/ui/main-window/PreviewPanel.swift b/src/ui/main-window/PreviewPanel.swift index fc5589e1..8d41fa42 100644 --- a/src/ui/main-window/PreviewPanel.swift +++ b/src/ui/main-window/PreviewPanel.swift @@ -27,6 +27,13 @@ class PreviewPanel: NSPanel { setAccessibilitySubrole(.unknown) } + func updateImageIfShowing(_ id: CGWindowID?, _ preview: NSImage, _ size: CGSize) { + if id == currentId && previewView.image != preview { + previewView.image = preview.copyToSeparateContexts() + previewView.image!.size = size + } + } + func show(_ id: CGWindowID, _ preview: NSImage, _ position: CGPoint, _ size: CGSize) { if previewView.image != preview { previewView.image = preview.copyToSeparateContexts()