Skip to content

Commit

Permalink
fix: various issues with the app freezing or being slowly
Browse files Browse the repository at this point in the history
  • Loading branch information
lwouis committed Jan 7, 2025
1 parent 8b52dc8 commit 121515c
Show file tree
Hide file tree
Showing 10 changed files with 80 additions and 81 deletions.
12 changes: 7 additions & 5 deletions src/logic/Application.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand All @@ -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()
Expand All @@ -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)
}
}
}
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/logic/Applications.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ class Applications {
Windows.cycleFocusedWindowIndex(-windowsOnTheLeftOfFocusedWindow)
}
if !existingWindowstoRemove.isEmpty {
App.app.refreshOpenUi([])
App.app.refreshOpenUi([], .refreshUiAfterExternalEvent)
}
}
}
Expand Down
43 changes: 16 additions & 27 deletions src/logic/BackgroundWork.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
}

Expand Down Expand Up @@ -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)
}
}
29 changes: 14 additions & 15 deletions src/logic/Window.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand All @@ -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)
Expand All @@ -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)
}
Expand All @@ -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)
}
Expand All @@ -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)
Expand Down
23 changes: 7 additions & 16 deletions src/logic/Windows.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -303,38 +302,30 @@ 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) {
eligibleWindows.append(window)
}
}
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() {
Expand Down
22 changes: 13 additions & 9 deletions src/logic/events/AccessibilityEvents.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Expand All @@ -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)
}
}
}
}
Expand All @@ -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)
}
}
}
Expand Down Expand Up @@ -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)
}
}
}
Expand Down Expand Up @@ -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()
}
Expand All @@ -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)
}
}
}
Expand All @@ -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)
}
}
}
Expand All @@ -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)
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/logic/events/ScreensEvents.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/logic/events/SpacesEvents.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading

0 comments on commit 121515c

Please sign in to comment.