diff --git a/src/api-wrappers/CGWindowID.swift b/src/api-wrappers/CGWindowID.swift index 14faf0dcc..8cfd44424 100644 --- a/src/api-wrappers/CGWindowID.swift +++ b/src/api-wrappers/CGWindowID.swift @@ -19,11 +19,63 @@ extension CGWindowID { return CGSCopySpacesForWindows(cgsMainConnectionId, CGSSpaceMask.all.rawValue, [self] as CFArray) as! [CGSSpaceID] } - func screenshot() -> CGImage? { - // CGSHWCaptureWindowList + // fullscreen has multiple windows + // e.g. Notes.app has a toolbar window and a main window + // We need to composite these window images + func fullScreenshot(_ win: Window) -> CGImage? { + var height: CGFloat = 0; + var width: CGFloat = 0; + var imageMap = [(CGWindow, CGImage)]() + var maxWidthWindowId: CGWindowID = 0 + let screen = Spaces.spaceFrameMap.first { $0.0 == win.spaceId }!.1 + var windowsInSpaces = Spaces.windowsInSpaces([win.spaceId]) // The returned windows are sorted from highest to lowest according to the z-index + var windowsToCapture: [CGWindow] = [] + // find current app's window in the fullscreen space + for item in windowsInSpaces { + let cgWin = CGWindowListCopyWindowInfo([.excludeDesktopElements, .optionIncludingWindow], item) as! [CGWindow] + guard cgWin.first!.isNotMenubarOrOthers(), + cgWin.first!.ownerPID() == win.application.runningApplication.processIdentifier, + cgWin.first!.bounds() != nil, + let bounds = CGRect(dictionaryRepresentation: cgWin.first!.bounds()!), bounds.height > 0, bounds.width > 0 else { continue } + windowsToCapture.append(cgWin.first!) + } + // Drawing images from lowest to highest base on the z-index + windowsToCapture = windowsToCapture.reversed() + for item in windowsToCapture { + let bounds = CGRect(dictionaryRepresentation: item.bounds()!) + if width < bounds!.width { + maxWidthWindowId = item.id()! + } + var windowId = item.id()! + let list = CGSHWCaptureWindowList(cgsMainConnectionId, &windowId, 1, [.ignoreGlobalClipShape, .nominalResolution]).takeRetainedValue() as! [CGImage] + imageMap.append((item, list.first!)) + } + let bytesPerRow = imageMap.first { $0.0.id()! == maxWidthWindowId }!.1.bytesPerRow + var context = CGContext.init(data: nil, + width: Int(screen.frame.width), + height: Int(screen.frame.height), + bitsPerComponent: 8, + bytesPerRow: bytesPerRow, + space: imageMap.first!.1.colorSpace!, + bitmapInfo: imageMap.first!.1.bitmapInfo.rawValue) + // composite these window images + for item in imageMap { + let bounds = CGRect(dictionaryRepresentation: item.0.bounds()!) + // Convert the coordinate system, the origin of window is top-left, the image is bottom-left + // so we need to convert y-index + context?.draw(item.1, in: CGRect.init(x: bounds!.origin.x, y: screen.frame.height - bounds!.height - bounds!.origin.y, width: bounds!.width, height: bounds!.height)) + } + return context?.makeImage() + } + + func screenshot(_ win: Window) -> CGImage? { var windowId_ = self - let list = CGSHWCaptureWindowList(cgsMainConnectionId, &windowId_, 1, [.ignoreGlobalClipShape, .nominalResolution]).takeRetainedValue() as! [CGImage] - return list.first + if win.isFullscreen { + return fullScreenshot(win) + } else { + let list = CGSHWCaptureWindowList(cgsMainConnectionId, &windowId_, 1, [.ignoreGlobalClipShape, .nominalResolution]).takeRetainedValue() as! [CGImage] + return list.first + } // // CGWindowListCreateImage // return CGWindowListCreateImage(.null, .optionIncludingWindow, self, [.boundsIgnoreFraming, .bestResolution]) diff --git a/src/api-wrappers/HelperExtensions.swift b/src/api-wrappers/HelperExtensions.swift index eee756913..e03c1b96e 100644 --- a/src/api-wrappers/HelperExtensions.swift +++ b/src/api-wrappers/HelperExtensions.swift @@ -211,3 +211,18 @@ extension String { self = NSFileTypeForHFSTypeCode(fourCharCode).trimmingCharacters(in: CharacterSet(charactersIn: "'")) } } + +fileprivate var notificationKey: Int = 1 + +extension NSRunningApplication { + var notification: Notification.Name? { + get { + return objc_getAssociatedObject(self, ¬ificationKey) as? Notification.Name + } + set { + if let value = newValue { + objc_setAssociatedObject(self, ¬ificationKey, value, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + } +} diff --git a/src/api-wrappers/PrivateApis.swift b/src/api-wrappers/PrivateApis.swift index ec60896b2..b03083d2c 100644 --- a/src/api-wrappers/PrivateApis.swift +++ b/src/api-wrappers/PrivateApis.swift @@ -126,6 +126,11 @@ func CGSManagedDisplayGetCurrentSpace(_ cid: CGSConnectionID, _ displayUuid: CFS @_silgen_name("CGSAddWindowsToSpaces") func CGSAddWindowsToSpaces(_ cid: CGSConnectionID, _ windows: NSArray, _ spaces: NSArray) -> Void +// Move the given windows (CGWindowIDs) to the given space (CGSSpaceID) +// * macOS 10.10+ +@_silgen_name("CGSMoveWindowsToManagedSpace") +func CGSMoveWindowsToManagedSpace(_ cid: CGSConnectionID, _ windows: NSArray, _ space: CGSSpaceID) -> Void + // remove the provided windows from the provided spaces // * macOS 10.10-12.2 @_silgen_name("CGSRemoveWindowsFromSpaces") diff --git a/src/logic/Application.swift b/src/logic/Application.swift index 63b9fe6c4..2a00e4126 100644 --- a/src/logic/Application.swift +++ b/src/logic/Application.swift @@ -86,8 +86,8 @@ class Application: NSObject { func observeNewWindows(_ group: DispatchGroup? = nil) { if runningApplication.isFinishedLaunching && runningApplication.activationPolicy != .prohibited { retryAxCallUntilTimeout(group, 5) { [weak self] in - guard let self = self else { return } - if let axWindows_ = try self.axUiElement!.windows(), axWindows_.count > 0 { + guard let self = self, let axWindows_ = try self.axUiElement!.windows() else { throw AxError.runtimeError } + if axWindows_.count > 0 { // bug in macOS: sometimes the OS returns multiple duplicate windows (e.g. Mail.app starting at login) let axWindows = try Array(Set(axWindows_)).compactMap { if let wid = try $0.cgWindowId() { @@ -116,14 +116,10 @@ class Application: NSObject { let window = self.addWindowslessAppsIfNeeded() App.app.refreshOpenUi(window) } - if group == nil && !self.wasLaunchedBeforeAltTab && ( - // workaround: opening an app while the active app is fullscreen; we wait out the space transition animation - CGSSpaceGetType(cgsMainConnectionId, Spaces.currentSpaceId) == .fullscreen || - // workaround: some apps launch but have no window ready instantly. It's very unlikely an app would launch with no window - // so we retry until timeout, in those rare cases (e.g. Bear.app) - // we only do this for active app, to avoid wasting CPU, with the trade-off of maybe missing some windows - self.runningApplication.isActive - ) { + // workaround: some apps launch but have no window ready instantly. It's very unlikely an app would launch with no window + // so we retry until timeout, in those rare cases (e.g. Bear.app) + // we only do this for active app, to avoid wasting CPU, with the trade-off of maybe missing some windows + if group == nil && self.runningApplication.notification == NSWorkspace.didActivateApplicationNotification { throw AxError.runtimeError } } @@ -146,6 +142,42 @@ class Application: NSObject { return windows } + func getOtherSpaceWindows(_ windowsOnlyOnOtherSpaces: [CGWindowID]) -> [Window] { + var otherSpaceWindows: [Window] = [] + for winId in windowsOnlyOnOtherSpaces { + let cgWinArray = CGWindowListCopyWindowInfo([.excludeDesktopElements, .optionIncludingWindow], winId) as! [CGWindow] + // get current app's windows only on other space + guard runningApplication.processIdentifier == cgWinArray.first!.ownerPID() + && cgWinArray.first!.id() != nil + && cgWinArray.first!.isNotMenubarOrOthers() + && cgWinArray.first!.bounds() != nil + && CGRect(dictionaryRepresentation: cgWinArray.first!.bounds()!)!.width > 100 + && CGRect(dictionaryRepresentation: cgWinArray.first!.bounds()!)!.height > 100 + else { continue } + guard let capture = CGWindowListCreateImage(CGRect.null, .optionIncludingWindow, cgWinArray.first!.id()!, .boundsIgnoreFraming) else { continue } + let win = Window(self, cgWinArray.first!) + Windows.appendAndUpdateFocus(win) + otherSpaceWindows.append(win) + } + return otherSpaceWindows + } + + func addOtherSpaceWindows(_ windowsOnlyOnOtherSpaces: [CGWindowID]) { + if runningApplication.isFinishedLaunching && runningApplication.activationPolicy != .prohibited { + retryAxCallUntilTimeout { [weak self] in + guard let self = self else { return } + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + var windows = self.getOtherSpaceWindows(windowsOnlyOnOtherSpaces) + if let window = self.addWindowslessAppsIfNeeded() { + windows.append(contentsOf: window) + } + App.app.refreshOpenUi(windows) + } + } + } + } + func addWindowslessAppsIfNeeded() -> [Window]? { if !Preferences.hideWindowlessApps && runningApplication.activationPolicy == .regular && diff --git a/src/logic/Applications.swift b/src/logic/Applications.swift index c16e4415c..ed469096d 100644 --- a/src/logic/Applications.swift +++ b/src/logic/Applications.swift @@ -14,10 +14,19 @@ class Applications { _ = group.wait(wallTimeout: .now() + .seconds(2)) } + static func addOtherSpaceWindows(_ windowsOnlyOnOtherSpaces: [CGWindowID]) { + for app in list { + app.wasLaunchedBeforeAltTab = true + guard app.runningApplication.isFinishedLaunching else { continue } + app.addOtherSpaceWindows(windowsOnlyOnOtherSpaces) + } + } + static func initialDiscovery() { addInitialRunningApplications() addInitialRunningApplicationsWindows() WorkspaceEvents.observeRunningApplications() + WorkspaceEvents.registerFrontAppChangeNote() } static func addInitialRunningApplications() { @@ -31,17 +40,18 @@ class Applications { let windowsOnOtherSpaces = Spaces.windowsInSpaces(otherSpaces) let windowsOnlyOnOtherSpaces = Array(Set(windowsOnOtherSpaces).subtracting(windowsOnCurrentSpace)) if windowsOnlyOnOtherSpaces.count > 0 { - // on initial launch, we use private APIs to bring windows from other spaces into the current space, observe them, then remove them from the current space - CGSAddWindowsToSpaces(cgsMainConnectionId, windowsOnlyOnOtherSpaces as NSArray, [Spaces.currentSpaceId]) - Applications.observeNewWindowsBlocking() - CGSRemoveWindowsFromSpaces(cgsMainConnectionId, windowsOnlyOnOtherSpaces as NSArray, [Spaces.currentSpaceId]) + // Currently we add those window in other space without AXUIElement init + // We don't need to get the AXUIElement until we focus these windows. + // when we need to focus these windows, we use the helper window to take us to that space, + // then get the AXUIElement, and finally focus that window. + Applications.addOtherSpaceWindows(windowsOnlyOnOtherSpaces) } } } static func addRunningApplications(_ runningApps: [NSRunningApplication], _ wasLaunchedBeforeAltTab: Bool = false) { runningApps.forEach { - if isActualApplication($0) { + if isActualApplication($0, wasLaunchedBeforeAltTab) { Applications.list.append(Application($0, wasLaunchedBeforeAltTab)) } } @@ -101,10 +111,37 @@ class Applications { } } - private static func isActualApplication(_ app: NSRunningApplication) -> Bool { + private static func isActualApplication(_ app: NSRunningApplication, _ wasLaunchedBeforeAltTab: Bool = false) -> 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(app) || isAndroidEmulator(app)) && !app.processIdentifier.isZombie() + return isAnWindowApplication(app, wasLaunchedBeforeAltTab) && (isNotXpc(app) || isAndroidEmulator(app)) && !app.processIdentifier.isZombie() + } + + private static func isAnWindowApplication(_ app: NSRunningApplication, _ wasLaunchedBeforeAltTab: Bool = false) -> Bool { + if (wasLaunchedBeforeAltTab) { + // For wasLaunchedBeforeAltTab=true, we assume that those apps are all launched, if they are programs with windows. + // Even if it has 0 windows at this point, axUiElement.windows() will not throw an exception. If they are programs without windows, then axUiElement.windows() will throw an exception. + // Here I consider there is an edge case where AltTab is starting up and this program has been loading, then it is possible that axUiElement.windows() will throw an exception. + // I'm not quite sure if this happens, but even if it does, then after restarting this application, AltTab captures its window without any problem. I think this happens rarely. + let allWindows = CGWindow.windows(.optionAll) + guard let winApp = (allWindows.first { app.processIdentifier == $0.ownerPID() + && $0.isNotMenubarOrOthers() + && $0.id() != nil + && $0.bounds() != nil + && CGRect(dictionaryRepresentation: $0.bounds()!)!.width > 0 + && CGRect(dictionaryRepresentation: $0.bounds()!)!.height > 0 + }) else { + return false + } + return true + } else { + // Because we only add the application when we receive the didActivateApplicationNotification. + // So here is actually the handling for the case wasLaunchedBeforeAltTab=false. For applications where wasLaunchedBeforeAltTab=true, the majority of isActive is false. + // The reason for not using axUiElement.windows() here as a way to determine if it is a window application is that + // When we receive the didActivateApplicationNotification notification, the application may still be loading and axUiElement.windows() will throw an exception + // So we use isActive to determine if it is a window application, even if the application is not frontmost, isActive is still true at this time + return true; + } } private static func isNotXpc(_ app: NSRunningApplication) -> Bool { diff --git a/src/logic/HelperWindow.swift b/src/logic/HelperWindow.swift new file mode 100644 index 000000000..be791c0f8 --- /dev/null +++ b/src/logic/HelperWindow.swift @@ -0,0 +1,18 @@ +import Cocoa +/** + * we use this window to help us switch to another space + */ +class HelperWindow: NSWindow { + var canBecomeKey_ = true + override var canBecomeKey: Bool { canBecomeKey_ } + convenience init() { + self.init(contentRect: .zero, styleMask: [.borderless], backing: .buffered, defer: false) + setupWindow() + } + + private func setupWindow() { + isReleasedWhenClosed = false + hidesOnDeactivate = false + title = "Helper Window" + } +} diff --git a/src/logic/Spaces.swift b/src/logic/Spaces.swift index 9b616d852..9e469426b 100644 --- a/src/logic/Spaces.swift +++ b/src/logic/Spaces.swift @@ -6,6 +6,7 @@ class Spaces { static var visibleSpaces = [CGSSpaceID]() static var screenSpacesMap = [ScreenUuid: [CGSSpaceID]]() static var idsAndIndexes = [(CGSSpaceID, SpaceIndex)]() + static var spaceFrameMap = [(CGSSpaceID, NSScreen)]() static func observeSpaceChanges() { NSWorkspace.shared.notificationCenter.addObserver(forName: NSWorkspace.activeSpaceDidChangeNotification, object: nil, queue: nil, using: { _ in @@ -13,7 +14,17 @@ class Spaces { refreshAllIdsAndIndexes() updateCurrentSpace() // if UI was kept open during Space transition, the Spaces may be obsolete; we refresh them - Windows.list.forEachAsync { $0.updatesWindowSpace() } + Windows.list.forEachAsync { + $0.updatesWindowSpace() + // we need to set up AXUIElement for those invisiable window launched before AltTab when space changed + if $0.axUiElement == nil && $0.spaceId == currentSpaceId && !$0.isWindowlessApp { + do { + try $0.getAxUiElementAndObserveEvents() + } catch { + debugPrint("can not setUpMissingInfoForOtherWindows for", $0.application.runningApplication.bundleIdentifier) + } + } + } }) NSWorkspace.shared.notificationCenter.addObserver(forName: NSApplication.didChangeScreenParametersNotification, object: nil, queue: nil, using: { _ in debugPrint("OS event", "didChangeScreenParametersNotification") @@ -47,15 +58,18 @@ class Spaces { idsAndIndexes.removeAll() screenSpacesMap.removeAll() visibleSpaces.removeAll() + spaceFrameMap.removeAll() var spaceIndex = SpaceIndex(1) (CGSCopyManagedDisplaySpaces(cgsMainConnectionId) as! [NSDictionary]).forEach { (screen: NSDictionary) in var display = screen["Display Identifier"] as! ScreenUuid if display as String == "Main", let mainUuid = NSScreen.main?.uuid() { display = mainUuid } + let nsScreen = NSScreen.screens.first { $0.uuid() == display } as! NSScreen (screen["Spaces"] as! [NSDictionary]).forEach { (space: NSDictionary) in let spaceId = space["id64"] as! CGSSpaceID idsAndIndexes.append((spaceId, spaceIndex)) + spaceFrameMap.append((spaceId, nsScreen)) screenSpacesMap[display, default: []].append(spaceId) spaceIndex += 1 } diff --git a/src/logic/Window.swift b/src/logic/Window.swift index 2f99abe6c..5c66d3faf 100644 --- a/src/logic/Window.swift +++ b/src/logic/Window.swift @@ -61,6 +61,44 @@ class Window { debugPrint("Adding app-window", title ?? "nil", application.runningApplication.bundleIdentifier ?? "nil") } + // we init a window when this window is not at current active space + init(_ application: Application, _ cgWindow: CGWindow) { + self.application = application + self.title = bestEffortTitle(cgWindow.title()) + self.cgWindowId = cgWindow.id()! + let bounds = CGRect(dictionaryRepresentation: cgWindow.bounds()!) + self.size = bounds!.size + self.position = bounds!.origin + updatesWindowSpace() + if CGSSpaceGetType(cgsMainConnectionId, self.spaceId) == .fullscreen { + self.isFullscreen = true + } else { + self.isFullscreen = false + } + self.isMinimized = false + if !Preferences.hideThumbnails { + refreshThumbnail() + } + application.removeWindowslessAppWindow() + debugPrint("Adding app-window", title ?? "nil", application.runningApplication.bundleIdentifier ?? "nil") + } + + func getAxUiElementAndObserveEvents() throws { + guard let windows = try self.application.axUiElement?.windows(), windows.count > 0 else { + debugPrint("try to getAxUiElementAndObserveEvents nil", self.application.runningApplication.bundleIdentifier, self.title, self.spaceId) + return + } + for win in windows { + if try cgWindowId == win.cgWindowId() { + self.axUiElement = win + self.isMinimized = try self.axUiElement.isMinimized() + self.observeEvents() + return + } + } + debugPrint("try to getAxUiElementAndObserveEvents failed", self.application.runningApplication.bundleIdentifier, self.title, self.spaceId) + } + deinit { debugPrint("Deinit window", title ?? "nil", application.runningApplication.bundleIdentifier ?? "nil") } @@ -95,13 +133,13 @@ class Window { } func refreshThumbnail() { - guard let cgImage = cgWindowId.screenshot() else { return } + guard let cgImage = cgWindowId.screenshot(self) else { return } thumbnail = NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height)) thumbnailFullSize = thumbnail!.size } func close() { - if isWindowlessApp { return } + if isWindowlessApp || self.axUiElement == nil { return } BackgroundWork.accessibilityCommandsQueue.asyncWithCap { [weak self] in guard let self = self else { return } if self.isFullscreen { @@ -114,7 +152,7 @@ class Window { } func minDemin() { - if isWindowlessApp { return } + if isWindowlessApp || self.axUiElement == nil { return } BackgroundWork.accessibilityCommandsQueue.asyncWithCap { [weak self] in guard let self = self else { return } if self.isFullscreen { @@ -131,7 +169,7 @@ class Window { } func toggleFullscreen() { - if isWindowlessApp { return } + if isWindowlessApp || self.axUiElement == nil { return } BackgroundWork.accessibilityCommandsQueue.asyncWithCap { [weak self] in guard let self = self else { return } self.axUiElement.setAttribute(kAXFullscreenAttribute, !self.isFullscreen) @@ -157,6 +195,11 @@ class Window { } } + func gotoSpace(_ spaceId: CGSSpaceID) { + CGSMoveWindowsToManagedSpace(cgsMainConnectionId, [App.app.helperWindow.windowNumber] as NSArray, spaceId) + App.app.showHelperWindow() + } + func focus() { if isWindowlessApp { if let bundleID = application.runningApplication.bundleIdentifier { @@ -164,6 +207,31 @@ class Window { } else { application.runningApplication.activate(options: .activateIgnoringOtherApps) } + } else if self.axUiElement == nil { + // the window is in other space, we can not get AXUIElement before we go to it's space + // when we want to focus the window, it means we want to go to the space and focus the window + // so we just go to that space by using helperWindow, then we get AXUIElement and focus the window + gotoSpace(self.spaceId) + do { + retryAxCallUntilTimeout { [weak self] in + guard let self = self else { return } + try self.getAxUiElementAndObserveEvents() + if self.axUiElement == nil { + // retry until we get axUiElement + throw AxError.runtimeError + } + BackgroundWork.accessibilityCommandsQueue.asyncWithCap { [weak self] in + guard let self = self else { return } + var psn = ProcessSerialNumber() + GetProcessForPID(self.application.pid, &psn) + _SLPSSetFrontProcessWithOptions(&psn, self.cgWindowId, .userGenerated) + self.makeKeyWindow(psn) + self.axUiElement.focusWindow() + } + } + } catch { + debugPrint("can not setUpMissingInfoForCurrentWindow for", self.application.runningApplication.bundleIdentifier) + } } 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 diff --git a/src/logic/events/AccessibilityEvents.swift b/src/logic/events/AccessibilityEvents.swift index 0f797a446..38898121a 100644 --- a/src/logic/events/AccessibilityEvents.swift +++ b/src/logic/events/AccessibilityEvents.swift @@ -51,6 +51,12 @@ fileprivate func applicationActivated(_ element: AXUIElement, _ pid: pid_t) thro app.focusedWindow = window App.app.checkIfShortcutsShouldBeDisabled(window, app.runningApplication) App.app.refreshOpenUi(window != nil ? [window!] : nil) + guard let win = (Windows.list.first { app.runningApplication.processIdentifier == $0.application.runningApplication.processIdentifier && !$0.isWindowlessApp }) else { + // for edge-case: some app (e.g. Bear.app) is loading and runningApplication.isFinishedLaunching is false (sometimes) when we call observeNewWindows() at first time + // as a result we miss their windows. but we will receive kAXApplicationActivatedNotification notification and we can add it successfully + app.observeNewWindows() + return + } } } } diff --git a/src/logic/events/WorkspaceEvents.swift b/src/logic/events/WorkspaceEvents.swift index 911270d32..574a93700 100644 --- a/src/logic/events/WorkspaceEvents.swift +++ b/src/logic/events/WorkspaceEvents.swift @@ -10,15 +10,57 @@ class WorkspaceEvents { } static func observerCallback(_ application: NSWorkspace, _ change: NSKeyValueObservedChange) { - let workspaceApps = Set(NSWorkspace.shared.runningApplications) - let diff = Array(workspaceApps.symmetricDifference(previousValueOfRunningApps)) - if change.kind == .insertion { - debugPrint("OS event", "apps launched", diff.map { ($0.processIdentifier, $0.bundleIdentifier) }) - Applications.addRunningApplications(diff) - } else if change.kind == .removal { - debugPrint("OS event", "apps quit", diff.map { ($0.processIdentifier, $0.bundleIdentifier) }) - Applications.removeRunningApplications(diff) + } + + static func registerFrontAppChangeNote() { + NSWorkspace.shared.notificationCenter.addObserver(self, selector: #selector(receiveFrontAppChangeNote(_:)), name: NSWorkspace.didActivateApplicationNotification, object: nil) + NSWorkspace.shared.notificationCenter.addObserver(self, selector: #selector(receiveFrontAppLaunchNote(_:)), name: NSWorkspace.didLaunchApplicationNotification, object: nil) + NSWorkspace.shared.notificationCenter.addObserver(self, selector: #selector(receiveFrontAppTerminateNote(_:)), name: NSWorkspace.didTerminateApplicationNotification, object: nil) + } + + // We add apps when we receive a didActivateApplicationNotification notification, not when we receive an apps launched, because any app will have an apps launched notification. + // But we are only interested in apps that have windows. We think that since an app can be activated, it must have a window, and subscribing to its window event makes sense and is likely to work, even if it requires multiple retries to subscribe. + // I'm not very sure if there is an edge case, but so far testing down the line has not revealed it. + // When we receive the didActivateApplicationNotification notification, NSRunningApplication.isActive=true, even if the app is not the frontmost window anymore. + // If we go to add the application when we receive the message of apps launched, at this time NSRunningApplication.isActive may be false, and try axUiElement.windows() may also throw an exception. + // For those background applications, we don't receive notifications of didActivateApplicationNotification until they have their own window. For example, those menu bar applications. + @objc static func receiveFrontAppChangeNote(_ notification: Notification) { + if let application = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication { + debugPrint("OS event", notification.name.rawValue, application.bundleIdentifier, application.processIdentifier) + guard let app = (Applications.list.first { application.processIdentifier == $0.pid }) else { + debugPrint("add running application", application.bundleIdentifier, application.processIdentifier) + application.notification = notification.name + Applications.addRunningApplications([application]) + return + } + guard let win = (Windows.list.first { application.processIdentifier == $0.application.runningApplication.processIdentifier && !$0.isWindowlessApp }) else { + app.hasBeenActiveOnce = true + app.runningApplication.notification = notification.name + app.observeNewWindows() + return + } + } + } + + @objc static func receiveFrontAppLaunchNote(_ notification: Notification) { + if let application = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication { + debugPrint("OS event", notification.name.rawValue, application.bundleIdentifier, application.processIdentifier) + guard let app = (Applications.list.first { application.processIdentifier == $0.pid }) else { + debugPrint("add running application", notification.name.rawValue, application.bundleIdentifier, application.processIdentifier) + application.notification = notification.name + Applications.addRunningApplications([application]) + return + } + } + } + + @objc static func receiveFrontAppTerminateNote(_ notification: Notification) { + if let application = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication { + debugPrint("OS event", notification.name.rawValue, application.bundleIdentifier, application.processIdentifier) + if let app = (Applications.list.first { application.processIdentifier == $0.pid }) { + debugPrint("remove running application", application.bundleIdentifier, application.processIdentifier) + Applications.removeRunningApplications([application]) + } } - previousValueOfRunningApps = workspaceApps } } diff --git a/src/ui/App.swift b/src/ui/App.swift index d03511989..d801b2841 100644 --- a/src/ui/App.swift +++ b/src/ui/App.swift @@ -19,6 +19,7 @@ class App: AppCenterApplication, NSApplicationDelegate { static var app: App! var thumbnailsPanel: ThumbnailsPanel! var preferencesWindow: PreferencesWindow! + var helperWindow: HelperWindow! var feedbackWindow: FeedbackWindow! var isFirstSummon = true var appIsBeingUsed = false @@ -59,6 +60,7 @@ class App: AppCenterApplication, NSApplicationDelegate { Spaces.initialDiscovery() Applications.initialDiscovery() self.preferencesWindow = PreferencesWindow() + self.helperWindow = HelperWindow() self.feedbackWindow = FeedbackWindow() KeyboardEvents.addEventHandlers() MouseEvents.observe() @@ -158,6 +160,11 @@ class App: AppCenterApplication, NSApplicationDelegate { showSecondaryWindow(preferencesWindow) } + // focus the helper window and we can go to that space + @objc func showHelperWindow() { + showSecondaryWindow(helperWindow) + } + func showSecondaryWindow(_ window: NSWindow?) { if let window = window { NSScreen.preferred().repositionPanel(window, .appleCentered)