fix: rework all multi-threading to handle complex scenarios
BREAKING CHANGE: this rework should fix all sorts of issues when OS events happen in parallel: new windows, new apps, user shortcuts, etc. Here are example of use-cases that should work great now, without, and very quickly: * AltTab is open and an app/window is launched/quit * A window is minimized/deminimized, and while the animation is playing, the user invokes AltTab * An app starts and takes a long time to boot (e.g. Gimp) * An app becomes unresponsive, yet AltTab is unaffected and remains interactive while still processing the state of the window while its parent app finally stops being frozen closes #348, closes #157, closes #342, closes #93
This commit is contained in:
1 parent
8d833f5163
commit
d144476691
13 files changed
+454
-289
No files matched your search
@@ -34,7 +34,7 @@
|
||||
D04BA34AC850A273AB288B1E /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BA3B51D05213404938366 /* Localizable.strings */; };
|
||||
D04BA3744F48116DF4252B19 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BA02355EB28D639F854DF /* Localizable.strings */; };
|
||||
D04BA3C24F4F644EA91DE38C /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BA717693DA18CB74BAED1 /* Localizable.strings */; };
|
||||
D04BA3CF766857381519B892 /* DispatchQueues.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAB74451B79FE18B8BEDF /* DispatchQueues.swift */; };
|
||||
D04BA3CF766857381519B892 /* BackgroundWork.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAB74451B79FE18B8BEDF /* BackgroundWork.swift */; };
|
||||
D04BA40CC1415DA69CCE5D89 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BA17FC84640580894400E /* InfoPlist.strings */; };
|
||||
D04BA4575B13F1A148C108E2 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BA459B8804ABFBDA50663 /* InfoPlist.strings */; };
|
||||
D04BA48B00B4211A465C7337 /* DebugProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BACABD048E62EBE4576CC /* DebugProfile.swift */; };
|
||||
@@ -216,7 +216,7 @@
|
||||
D04BAB51808B6118EB00DFC7 /* mstile-150x150.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "mstile-150x150.png"; sourceTree = "<group>"; };
|
||||
D04BAB6652494D7575057E86 /* 14 windows - 3 lines.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "14 windows - 3 lines.jpg"; sourceTree = "<group>"; };
|
||||
D04BAB703998DAD0EC9A6F4A /* ko */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = ko; path = Localizable.strings; sourceTree = "<group>"; };
|
||||
D04BAB74451B79FE18B8BEDF /* DispatchQueues.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DispatchQueues.swift; sourceTree = "<group>"; };
|
||||
D04BAB74451B79FE18B8BEDF /* BackgroundWork.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BackgroundWork.swift; sourceTree = "<group>"; };
|
||||
D04BAB7714DEDEA0A53AC3ED /* main.scss */ = {isa = PBXFileReference; lastKnownFileType = file.scss; path = main.scss; sourceTree = "<group>"; };
|
||||
D04BAB7AC7316FA7117B071E /* pt-BR */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = Localizable.strings; sourceTree = "<group>"; };
|
||||
D04BAB8A94DA69A6B5008AE5 /* Pipfile */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = Pipfile; sourceTree = "<group>"; };
|
||||
@@ -319,7 +319,7 @@
|
||||
D04BA015A45DE7AFDC9794FE /* Window.swift */,
|
||||
D04BA10777505D8A67ABD186 /* Application.swift */,
|
||||
D04BA282BB16C1554595A968 /* Applications.swift */,
|
||||
D04BAB74451B79FE18B8BEDF /* DispatchQueues.swift */,
|
||||
D04BAB74451B79FE18B8BEDF /* BackgroundWork.swift */,
|
||||
D04BACABD048E62EBE4576CC /* DebugProfile.swift */,
|
||||
D04BAC8857A527C2E15D6598 /* events */,
|
||||
);
|
||||
@@ -1013,7 +1013,7 @@
|
||||
D04BA6187A91A847844B6ABB /* Window.swift in Sources */,
|
||||
D04BA737008AA2CD4E230A21 /* Application.swift in Sources */,
|
||||
D04BA2A6FF9DDDC5A1A68E36 /* Applications.swift in Sources */,
|
||||
D04BA3CF766857381519B892 /* DispatchQueues.swift in Sources */,
|
||||
D04BA3CF766857381519B892 /* BackgroundWork.swift in Sources */,
|
||||
D04BA48B00B4211A465C7337 /* DebugProfile.swift in Sources */,
|
||||
D04BAB68B7B8D1B548BC3AD5 /* App.swift in Sources */,
|
||||
D04BAB048DE698E013577C51 /* ThumbnailsPanel.swift in Sources */,
|
||||
|
||||
+114
-125
@@ -1,146 +1,135 @@
|
||||
import Cocoa
|
||||
|
||||
extension AXUIElement {
|
||||
static let globalTimeoutInSeconds = Float(120)
|
||||
|
||||
// default timeout for AX calls is 6s. We increase it in order to avoid retrying every 6s, thus saving resources
|
||||
static func setGlobalTimeout() {
|
||||
// we add 5s to make sure to not do an extra retry
|
||||
AXUIElementSetMessagingTimeout(AXUIElementCreateSystemWide(), globalTimeoutInSeconds + 5)
|
||||
}
|
||||
|
||||
static let normalLevel = CGWindowLevelForKey(.normalWindow)
|
||||
|
||||
func cgWindowId() -> CGWindowID {
|
||||
func axCallWhichCanThrow<T>(_ result: AXError, _ successValue: inout T) throws -> T? {
|
||||
switch result {
|
||||
case .success: return successValue
|
||||
// .cannotComplete can happen if the app is unresponsive; we throw in that case to retry until the call succeeds
|
||||
case .cannotComplete: throw AxError.runtimeError
|
||||
// for other errors it's pointless to retry
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
func cgWindowId() throws -> CGWindowID? {
|
||||
var id = CGWindowID(0)
|
||||
_AXUIElementGetWindow(self, &id)
|
||||
return id
|
||||
return try axCallWhichCanThrow(_AXUIElementGetWindow(self, &id), &id)
|
||||
}
|
||||
|
||||
func pid() -> pid_t {
|
||||
func pid() throws -> pid_t? {
|
||||
var pid = pid_t(0)
|
||||
AXUIElementGetPid(self, &pid)
|
||||
return pid
|
||||
return try axCallWhichCanThrow(AXUIElementGetPid(self, &pid), &pid)
|
||||
}
|
||||
|
||||
func isActualWindow(_ bundleIdentifier: String?) -> Bool {
|
||||
// Some non-windows have cgWindowId == 0 (e.g. windows of apps starting at login with the checkbox "Hidden" checked)
|
||||
// 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"
|
||||
// CGWindowLevel == .normalWindow helps filter out iStats Pro and other top-level pop-overs
|
||||
let subrole_ = subrole()
|
||||
return cgWindowId() != 0 && subrole_ != nil &&
|
||||
(["AXStandardWindow", "AXDialog"].contains(subrole_) ||
|
||||
// All Steam windows have subrole = AXUnknown
|
||||
// some dropdown menus are not desirable; they have title == "", or sometimes role == nil when switching between menus quickly
|
||||
(bundleIdentifier == "com.valvesoftware.steam" && title() != "" && role() != nil)) &&
|
||||
// don't show floating windows
|
||||
isOnNormalLevel()
|
||||
}
|
||||
|
||||
func isOnNormalLevel() -> Bool {
|
||||
let level: CGWindowLevel = cgWindowId().level()
|
||||
return level == AXUIElement.normalLevel
|
||||
}
|
||||
|
||||
func position() -> CGPoint? {
|
||||
return value(kAXPositionAttribute, CGPoint.zero, .cgPoint)
|
||||
}
|
||||
|
||||
func title() -> String? {
|
||||
return attribute(kAXTitleAttribute, String.self)
|
||||
}
|
||||
|
||||
func windows() -> [AXUIElement]? {
|
||||
return attribute(kAXWindowsAttribute, [AXUIElement].self)
|
||||
}
|
||||
|
||||
func isMinimized() -> Bool {
|
||||
return attribute(kAXMinimizedAttribute, Bool.self) == true
|
||||
}
|
||||
|
||||
func isHidden() -> Bool {
|
||||
return attribute(kAXHiddenAttribute, Bool.self) == true
|
||||
}
|
||||
|
||||
func isFullScreen() -> Bool {
|
||||
return attribute(kAXFullscreenAttribute, Bool.self) == true
|
||||
}
|
||||
|
||||
func focusedWindow() -> AXUIElement? {
|
||||
return attribute(kAXFocusedWindowAttribute, AXUIElement.self)
|
||||
}
|
||||
|
||||
func role() -> String? {
|
||||
return attribute(kAXRoleAttribute, String.self)
|
||||
}
|
||||
|
||||
func subrole() -> String? {
|
||||
return attribute(kAXSubroleAttribute, String.self)
|
||||
}
|
||||
|
||||
func closeButton() -> AXUIElement {
|
||||
return attribute(kAXCloseButtonAttribute, AXUIElement.self)!
|
||||
}
|
||||
|
||||
func closeWindow() {
|
||||
if isFullScreen() {
|
||||
AXUIElementSetAttributeValue(self, kAXFullscreenAttribute as CFString, false as CFTypeRef)
|
||||
}
|
||||
AXUIElementPerformAction(closeButton(), kAXPressAction as CFString)
|
||||
}
|
||||
|
||||
func minDeminWindow() {
|
||||
if isFullScreen() {
|
||||
AXUIElementSetAttributeValue(self, kAXFullscreenAttribute as CFString, false as CFTypeRef)
|
||||
// minimizing is ignored if sent immediatly; we wait for the de-fullscreen animation to be over
|
||||
DispatchQueues.accessibilityCommands.asyncAfter(deadline: .now() + .milliseconds(1000)) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
AXUIElementSetAttributeValue(self, kAXMinimizedAttribute as CFString, true as CFTypeRef)
|
||||
}
|
||||
} else {
|
||||
AXUIElementSetAttributeValue(self, kAXMinimizedAttribute as CFString, !isMinimized() as CFTypeRef)
|
||||
}
|
||||
}
|
||||
|
||||
func focusWindow() {
|
||||
AXUIElementPerformAction(self, kAXRaiseAction as CFString)
|
||||
}
|
||||
|
||||
func subscribeWithRetry(_ axObserver: AXObserver, _ notification: String, _ pointer: UnsafeMutableRawPointer?, _ callback: (() -> Void)? = nil, _ runningApplication: NSRunningApplication? = nil, _ wid: CGWindowID? = nil, _ startTime: DispatchTime = DispatchTime.now()) {
|
||||
DispatchQueue.global(qos: .userInteractive).async { [weak self] () -> () in
|
||||
guard let self = self else { return }
|
||||
// some apps return .isFinishedLaunching = true but will return .cannotComplete when we try to subscribe to them
|
||||
// this happens for example when apps launch and have heavy loading to do (e.g. Gimp).
|
||||
// we have no way to know if they are one day going to be letting us subscribe, so we timeout after 2 min
|
||||
let timePassedInSeconds = Double(DispatchTime.now().uptimeNanoseconds - startTime.uptimeNanoseconds) / 1_000_000_000
|
||||
if timePassedInSeconds > 120 { return }
|
||||
let result = AXObserverAddNotification(axObserver, self, notification as CFString, pointer)
|
||||
self.handleSubscriptionAttempt(result, axObserver, notification, pointer, callback, runningApplication, wid, startTime)
|
||||
}
|
||||
}
|
||||
|
||||
func handleSubscriptionAttempt(_ result: AXError, _ axObserver: AXObserver, _ notification: String, _ pointer: UnsafeMutableRawPointer?, _ callback: (() -> Void)?, _ runningApplication: NSRunningApplication?, _ wid: CGWindowID?, _ startTime: DispatchTime) -> Void {
|
||||
if result == .success || result == .notificationAlreadyRegistered {
|
||||
DispatchQueue.main.async { () -> () in
|
||||
callback?()
|
||||
}
|
||||
} else if result != .notificationUnsupported && result != .notImplemented {
|
||||
DispatchQueue.global(qos: .userInteractive).asyncAfter(deadline: .now() + .milliseconds(10), execute: { [weak self] in
|
||||
self?.subscribeWithRetry(axObserver, notification, pointer, callback, runningApplication, wid, startTime)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private func attribute<T>(_ key: String, _ type: T.Type) -> T? {
|
||||
func attribute<T>(_ key: String, _ type: T.Type) throws -> T? {
|
||||
var value: AnyObject?
|
||||
let result = AXUIElementCopyAttributeValue(self, key as CFString, &value)
|
||||
if result == .success, let value = value as? T {
|
||||
return value
|
||||
}
|
||||
return nil
|
||||
return try axCallWhichCanThrow(AXUIElementCopyAttributeValue(self, key as CFString, &value), &value) as? T
|
||||
}
|
||||
|
||||
private func value<T>(_ key: String, _ target: T, _ type: AXValueType) -> T? {
|
||||
if let a = attribute(key, AXValue.self) {
|
||||
private func value<T>(_ key: String, _ target: T, _ type: AXValueType) throws -> T? {
|
||||
if let a = try attribute(key, AXValue.self) {
|
||||
var value = target
|
||||
AXValueGetValue(a, type, &value)
|
||||
return value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isActualWindow(_ bundleIdentifier: String?) throws -> Bool {
|
||||
// Some non-windows have cgWindowId == 0 (e.g. windows of apps starting at login with the checkbox "Hidden" checked)
|
||||
// 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"
|
||||
// CGWindowLevel == .normalWindow helps filter out iStats Pro and other top-level pop-overs
|
||||
let wid = try cgWindowId()
|
||||
return try wid != nil && wid != 0 &&
|
||||
// don't show floating windows
|
||||
isOnNormalLevel(wid!) &&
|
||||
(["AXStandardWindow", "AXDialog"].contains(subrole()) ||
|
||||
// All Steam windows have subrole = AXUnknown
|
||||
// some dropdown menus are not desirable; they have title == "", or sometimes role == nil when switching between menus quickly
|
||||
(bundleIdentifier == "com.valvesoftware.steam" && title() != "" && role() != nil))
|
||||
}
|
||||
|
||||
func isOnNormalLevel(_ wid: CGWindowID) -> Bool {
|
||||
let level: CGWindowLevel = wid.level()
|
||||
return level == AXUIElement.normalLevel
|
||||
}
|
||||
|
||||
func position() throws -> CGPoint? {
|
||||
return try value(kAXPositionAttribute, CGPoint.zero, .cgPoint)
|
||||
}
|
||||
|
||||
func title() throws -> String? {
|
||||
return try attribute(kAXTitleAttribute, String.self)
|
||||
}
|
||||
|
||||
func parent() throws -> AXUIElement? {
|
||||
return try attribute(kAXParentAttribute, AXUIElement.self)
|
||||
}
|
||||
|
||||
func windows() throws -> [AXUIElement]? {
|
||||
return try attribute(kAXWindowsAttribute, [AXUIElement].self)
|
||||
}
|
||||
|
||||
func isMinimized() throws -> Bool {
|
||||
return try attribute(kAXMinimizedAttribute, Bool.self) == true
|
||||
}
|
||||
|
||||
func isFullscreen() throws -> Bool {
|
||||
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 focusWindow() {
|
||||
performAction(kAXRaiseAction)
|
||||
}
|
||||
|
||||
func subscribeToNotification(_ axObserver: AXObserver, _ notification: String, _ callback: (() -> Void)? = nil, _ runningApplication: NSRunningApplication? = nil, _ wid: CGWindowID? = nil, _ startTime: DispatchTime = DispatchTime.now()) throws {
|
||||
let result = AXObserverAddNotification(axObserver, self, notification as CFString, nil)
|
||||
if result == .success || result == .notificationAlreadyRegistered {
|
||||
callback?()
|
||||
} else if result != .notificationUnsupported && result != .notImplemented {
|
||||
throw AxError.runtimeError
|
||||
}
|
||||
}
|
||||
|
||||
func setAttribute(_ key: String, _ value: Any) {
|
||||
AXUIElementSetAttributeValue(self, key as CFString, value as CFTypeRef)
|
||||
}
|
||||
|
||||
func performAction(_ action: String) {
|
||||
AXUIElementPerformAction(self, action as CFString)
|
||||
}
|
||||
}
|
||||
|
||||
enum AxError: Error {
|
||||
case runtimeError
|
||||
}
|
||||
@@ -7,19 +7,11 @@ extension Collection {
|
||||
}
|
||||
}
|
||||
|
||||
// removing an objc KVO observer if there is none throws an exception
|
||||
extension NSObject {
|
||||
func safeRemoveObserver(_ observer: NSObject, _ key: String) {
|
||||
guard observationInfo != nil else { return }
|
||||
removeObserver(observer, forKeyPath: key)
|
||||
}
|
||||
}
|
||||
|
||||
extension Array where Element == Window {
|
||||
func firstIndexThatMatches(_ element: AXUIElement) -> Self.Index? {
|
||||
func firstIndexThatMatches(_ element: AXUIElement, _ wid: CGWindowID?) -> Self.Index? {
|
||||
// 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
|
||||
return firstIndex(where: { $0.axUiElement == element || ($0.cgWindowId != -1 && $0.cgWindowId == element.cgWindowId()) })
|
||||
return firstIndex { $0.axUiElement == element || ($0.cgWindowId != -1 && $0.cgWindowId == wid) }
|
||||
}
|
||||
|
||||
mutating func insertAndScaleRecycledPool(_ elements: [Element], at i: Int) {
|
||||
@@ -50,9 +42,11 @@ extension Array {
|
||||
func forEachAsync(fn: @escaping (Element) -> Void) {
|
||||
let group = DispatchGroup()
|
||||
for element in self {
|
||||
group.enter()
|
||||
DispatchQueue.global(qos: .userInteractive).async(group: group) {
|
||||
BackgroundWork.globalSemaphore.wait()
|
||||
BackgroundWork.uiDisplayQueue.async(group: group) {
|
||||
group.enter()
|
||||
fn(element)
|
||||
BackgroundWork.globalSemaphore.signal()
|
||||
group.leave()
|
||||
}
|
||||
}
|
||||
|
||||
+43
-21
@@ -35,8 +35,8 @@ class Application: NSObject {
|
||||
]
|
||||
}
|
||||
|
||||
func removeObserver() {
|
||||
runningApplication.safeRemoveObserver(self, "isFinishedLaunching")
|
||||
deinit {
|
||||
debugPrint("Deinit app", runningApplication.bundleIdentifier ?? "nil")
|
||||
}
|
||||
|
||||
func addAndObserveWindows() {
|
||||
@@ -49,18 +49,38 @@ class Application: NSObject {
|
||||
}
|
||||
|
||||
func observeNewWindows() {
|
||||
if runningApplication.isFinishedLaunching && runningApplication.activationPolicy != .prohibited,
|
||||
let windows = (axUiElement!.windows()?.filter { $0.isActualWindow(runningApplication.bundleIdentifier) }) {
|
||||
// bug in macOS: sometimes the OS returns multiple duplicate windows (e.g. Mail.app starting at login)
|
||||
let actualWindows = Array(Set(windows.filter { Windows.list.firstIndexThatMatches($0) == nil }))
|
||||
if actualWindows.count > 0 {
|
||||
addWindows(actualWindows)
|
||||
}
|
||||
if runningApplication.isFinishedLaunching && runningApplication.activationPolicy != .prohibited {
|
||||
retryUntilTimeout({ [weak self] in
|
||||
guard let self = self else { return }
|
||||
if let windows_ = try self.axUiElement!.windows(), windows_.count > 0 {
|
||||
// bug in macOS: sometimes the OS returns multiple duplicate windows (e.g. Mail.app starting at login)
|
||||
let windows = try Array(Set(windows_)).map {
|
||||
(
|
||||
$0,
|
||||
try $0.isActualWindow(self.runningApplication.bundleIdentifier),
|
||||
try $0.cgWindowId(),
|
||||
try $0.title(),
|
||||
try $0.isFullscreen(),
|
||||
try $0.isMinimized(),
|
||||
try $0.position()
|
||||
)
|
||||
}
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.addWindows(windows)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private func addWindows(_ axWindows: [AXUIElement]) {
|
||||
let windows = axWindows.map { Window($0, self) }
|
||||
private func addWindows(_ axWindows: [(AXUIElement, Bool, CGWindowID?, String?, Bool, Bool, CGPoint?)]) {
|
||||
let windows: [Window] = axWindows.compactMap { (axUiElement, isActualWindow, wid, axTitle, isFullscreen, isMinimized, position) in
|
||||
if let wid = wid, isActualWindow && Windows.list.firstIndexThatMatches(axUiElement, wid) == nil {
|
||||
return Window(axUiElement, self, wid, axTitle, isFullscreen, isMinimized, position)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Windows.list.insertAndScaleRecycledPool(windows, at: 0)
|
||||
if App.app.appIsBeingUsed {
|
||||
Windows.cycleFocusedWindowIndex(windows.count)
|
||||
@@ -70,18 +90,20 @@ class Application: NSObject {
|
||||
|
||||
private func observeEvents() {
|
||||
guard let axObserver = axObserver else { return }
|
||||
let selfPointer = UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque())
|
||||
for notification in Application.notifications(runningApplication) {
|
||||
axUiElement!.subscribeWithRetry(axObserver, notification, selfPointer, { [weak self] in
|
||||
// some apps have `isFinishedLaunching == true` but are actually not finished, and will return .cannotComplete
|
||||
// we consider them ready when the first subscription succeeds, and list their windows again at that point
|
||||
retryUntilTimeout({ [weak self] in
|
||||
guard let self = self else { return }
|
||||
if !self.isReallyFinishedLaunching {
|
||||
self.isReallyFinishedLaunching = true
|
||||
self.observeNewWindows()
|
||||
}
|
||||
}, runningApplication)
|
||||
try self.axUiElement!.subscribeToNotification(axObserver, notification, { [weak self] in
|
||||
guard let self = self else { return }
|
||||
// some apps have `isFinishedLaunching == true` but are actually not finished, and will return .cannotComplete
|
||||
// we consider them ready when the first subscription succeeds, and list their windows again at that point
|
||||
if !self.isReallyFinishedLaunching {
|
||||
self.isReallyFinishedLaunching = true
|
||||
self.observeNewWindows()
|
||||
}
|
||||
}, self.runningApplication)
|
||||
})
|
||||
}
|
||||
CFRunLoopAddSource(CFRunLoopGetCurrent(), AXObserverGetRunLoopSource(axObserver), .defaultMode)
|
||||
CFRunLoopAddSource(BackgroundWork.accessibilityEventsThread.runLoop, AXObserverGetRunLoopSource(axObserver), .defaultMode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import Foundation
|
||||
|
||||
// queues and dedicated threads to observe background events such as keyboard inputs, or accessibility events
|
||||
class BackgroundWork {
|
||||
static let uiDisplayQueue = DispatchQueue.globalConcurrent("mainQueueConcurrentWork", .userInteractive)
|
||||
static let accessibilityCommandsQueue = DispatchQueue.globalConcurrent("accessibilityCommands", .userInteractive)
|
||||
static let accessibilityEventsThread = BackgroundThreadWithRunLoop("accessibilityEvents")
|
||||
static let keyboardEventsThread = BackgroundThreadWithRunLoop("keyboardEvents")
|
||||
|
||||
// we cap concurrent tasks to .processorCount to avoid thread explosion on the .global queue
|
||||
static let globalSemaphore = DispatchSemaphore(value: ProcessInfo.processInfo.processorCount)
|
||||
|
||||
// swift static variables are lazy; we artificially force the threads to init
|
||||
static func start() {
|
||||
_ = accessibilityEventsThread
|
||||
_ = keyboardEventsThread
|
||||
}
|
||||
}
|
||||
|
||||
extension DispatchQueue {
|
||||
static func globalConcurrent(_ label: String, _ qos: DispatchQoS) -> DispatchQueue {
|
||||
return DispatchQueue(label: label, target: .global(qos: qos.qosClass))
|
||||
}
|
||||
|
||||
func asyncWithCap(_ deadline: DispatchTime? = nil, _ fn: @escaping () -> Void) {
|
||||
let block = {
|
||||
fn()
|
||||
BackgroundWork.globalSemaphore.signal()
|
||||
}
|
||||
BackgroundWork.globalSemaphore.wait()
|
||||
if let deadline = deadline {
|
||||
asyncAfter(deadline: deadline, execute: block)
|
||||
} else {
|
||||
async(execute: block)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class BackgroundThreadWithRunLoop {
|
||||
var thread: Thread?
|
||||
var runLoop: CFRunLoop?
|
||||
|
||||
init(_ name: String) {
|
||||
thread = Thread {
|
||||
self.runLoop = CFRunLoopGetCurrent()
|
||||
while !self.thread!.isCancelled {
|
||||
CFRunLoopRun()
|
||||
}
|
||||
}
|
||||
thread!.name = name
|
||||
thread!.start()
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
// OS events should be observed on background threads
|
||||
class DispatchQueues {
|
||||
static let accessibilityCommands = DispatchQueue(label: "accessibilityCommands", qos: .userInteractive, attributes: .concurrent)
|
||||
static let keyboardEvents = DispatchQueue(label: "keyboardEvents", qos: .userInteractive)
|
||||
}
|
||||
@@ -23,7 +23,8 @@ class Screen {
|
||||
|
||||
static func uuid(_ screen: NSScreen) -> ScreenUuid? {
|
||||
let screenNumber = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as! UInt32
|
||||
let screenUuid = CGDisplayCreateUUIDFromDisplayID(screenNumber).takeRetainedValue()
|
||||
// this api from apple lies in its signature; the return value can be nil; we cast to remove the warning
|
||||
let screenUuid = CGDisplayCreateUUIDFromDisplayID(screenNumber).takeRetainedValue() as CFUUID?
|
||||
if screenUuid != nil,
|
||||
let uuid = CFUUIDCreateString(nil, screenUuid) {
|
||||
return uuid
|
||||
|
||||
@@ -22,7 +22,6 @@ class Spaces {
|
||||
if let mainScreen = NSScreen.main,
|
||||
let uuid = Screen.uuid(mainScreen) {
|
||||
currentSpaceId = CGSManagedDisplayGetCurrentSpace(cgsMainConnectionId, uuid)
|
||||
debugPrint("currentSpaceId", currentSpaceId)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+47
-21
@@ -2,7 +2,7 @@ import Cocoa
|
||||
|
||||
class Window {
|
||||
var cgWindowId: CGWindowID
|
||||
var title: String = ""
|
||||
var title: String!
|
||||
var thumbnail: NSImage?
|
||||
var icon: NSImage?
|
||||
var shouldShowTheUser = true
|
||||
@@ -11,6 +11,7 @@ class Window {
|
||||
var isFullscreen: Bool
|
||||
var isMinimized: Bool
|
||||
var isOnAllSpaces: Bool
|
||||
var position: CGPoint?
|
||||
var spaceId: CGSSpaceID
|
||||
var spaceIndex: SpaceIndex
|
||||
var axUiElement: AXUIElement
|
||||
@@ -24,33 +25,42 @@ class Window {
|
||||
kAXWindowMiniaturizedNotification,
|
||||
kAXWindowDeminiaturizedNotification,
|
||||
kAXWindowResizedNotification,
|
||||
kAXWindowMovedNotification,
|
||||
]
|
||||
|
||||
init(_ axUiElement: AXUIElement, _ application: Application) {
|
||||
init(_ axUiElement: AXUIElement, _ application: Application, _ wid: CGWindowID, _ axTitle: String?, _ isFullscreen: Bool, _ isMinimized: Bool, _ position: CGPoint?) {
|
||||
// TODO: make a efficient batched AXUIElementCopyMultipleAttributeValues call once for each window, and store the values
|
||||
self.axUiElement = axUiElement
|
||||
self.application = application
|
||||
self.cgWindowId = axUiElement.cgWindowId()
|
||||
self.cgWindowId = wid
|
||||
self.spaceId = Spaces.currentSpaceId
|
||||
self.spaceIndex = Spaces.currentSpaceIndex
|
||||
self.icon = application.runningApplication.icon
|
||||
self.isHidden = application.runningApplication.isHidden
|
||||
self.isFullscreen = axUiElement.isFullScreen()
|
||||
self.isMinimized = axUiElement.isMinimized()
|
||||
self.isFullscreen = isFullscreen
|
||||
self.isMinimized = isMinimized
|
||||
self.isOnAllSpaces = false
|
||||
self.title = bestEffortTitle()
|
||||
self.isTabbed = getIsTabbed()
|
||||
debugPrint("Adding window", cgWindowId, title, application.runningApplication.bundleIdentifier ?? "nil", Spaces.currentSpaceId, Spaces.currentSpaceIndex)
|
||||
self.position = position
|
||||
self.title = bestEffortTitle(axTitle)
|
||||
self.isTabbed = false
|
||||
debugPrint("Adding window", cgWindowId, title ?? "nil", application.runningApplication.bundleIdentifier ?? "nil")
|
||||
observeEvents()
|
||||
}
|
||||
|
||||
deinit {
|
||||
debugPrint("Deinit window", title ?? "nil", application.runningApplication.bundleIdentifier ?? "nil")
|
||||
}
|
||||
|
||||
private func observeEvents() {
|
||||
AXObserverCreate(application.runningApplication.processIdentifier, axObserverCallback, &axObserver)
|
||||
guard let axObserver = axObserver else { return }
|
||||
for notification in Window.notifications {
|
||||
axUiElement.subscribeWithRetry(axObserver, notification, nil, nil, nil, cgWindowId)
|
||||
retryUntilTimeout({ [weak self] in
|
||||
guard let self = self else { return }
|
||||
try self.axUiElement.subscribeToNotification(axObserver, notification, nil, nil, self.cgWindowId)
|
||||
})
|
||||
}
|
||||
CFRunLoopAddSource(CFRunLoopGetCurrent(), AXObserverGetRunLoopSource(axObserver), .defaultMode)
|
||||
CFRunLoopAddSource(BackgroundWork.accessibilityEventsThread.runLoop, AXObserverGetRunLoopSource(axObserver), .defaultMode)
|
||||
}
|
||||
|
||||
func refreshThumbnail() {
|
||||
@@ -58,33 +68,49 @@ class Window {
|
||||
thumbnail = NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height))
|
||||
}
|
||||
|
||||
func getIsTabbed() -> Bool {
|
||||
func getIsTabbed(_ currentWindows: [AXUIElement]?) -> Bool {
|
||||
// we can only detect tabs for windows on the current space, as AXUIElement.windows() only reports current space windows
|
||||
// also, windows that start in fullscreen will have the wrong spaceID at that point in time, so we check if they are fullscreen too
|
||||
return spaceId == Spaces.currentSpaceId && !isFullscreen &&
|
||||
application.axUiElement!.windows()?.first { $0 == axUiElement } == nil
|
||||
currentWindows?.first { $0 == axUiElement } == nil
|
||||
}
|
||||
|
||||
func close() {
|
||||
DispatchQueues.accessibilityCommands.async { [weak self] in
|
||||
self?.axUiElement.closeWindow()
|
||||
BackgroundWork.accessibilityCommandsQueue.asyncWithCap { [weak self] in
|
||||
guard let self = self else { return }
|
||||
if self.isFullscreen {
|
||||
self.axUiElement.setAttribute(kAXFullscreenAttribute, false)
|
||||
}
|
||||
if let closeButton_ = try? self.axUiElement.closeButton() {
|
||||
closeButton_.performAction(kAXPressAction)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func minDemin() {
|
||||
DispatchQueues.accessibilityCommands.async { [weak self] in
|
||||
self?.axUiElement.minDeminWindow()
|
||||
BackgroundWork.accessibilityCommandsQueue.asyncWithCap { [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() + .milliseconds(1000)) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.axUiElement.setAttribute(kAXMinimizedAttribute, true)
|
||||
}
|
||||
} else {
|
||||
self.axUiElement.setAttribute(kAXMinimizedAttribute, !self.isMinimized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func quitApp() {
|
||||
DispatchQueues.accessibilityCommands.async { [weak self] in
|
||||
BackgroundWork.accessibilityCommandsQueue.asyncWithCap { [weak self] in
|
||||
self?.application.runningApplication.terminate()
|
||||
}
|
||||
}
|
||||
|
||||
func hideShowApp() {
|
||||
DispatchQueues.accessibilityCommands.async { [weak self] in
|
||||
BackgroundWork.accessibilityCommandsQueue.asyncWithCap { [weak self] in
|
||||
guard let self = self else { return }
|
||||
if self.application.runningApplication.isHidden {
|
||||
self.application.runningApplication.unhide()
|
||||
@@ -98,7 +124,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
|
||||
DispatchQueues.accessibilityCommands.async { [weak self] in
|
||||
BackgroundWork.accessibilityCommandsQueue.asyncWithCap { [weak self] in
|
||||
guard let self = self else { return }
|
||||
var elementConnection = UInt32(0)
|
||||
CGSGetWindowOwner(cgsMainConnectionId, self.cgWindowId, &elementConnection)
|
||||
@@ -130,8 +156,8 @@ class Window {
|
||||
}
|
||||
|
||||
// for some windows (e.g. Slack), the AX API doesn't return a title; we try CG API; finally we resort to the app name
|
||||
func bestEffortTitle() -> String {
|
||||
if let axTitle = axUiElement.title(), !axTitle.isEmpty {
|
||||
func bestEffortTitle(_ axTitle: String?) -> String {
|
||||
if let axTitle = axTitle, !axTitle.isEmpty {
|
||||
return axTitle
|
||||
}
|
||||
if let cgTitle = cgWindowId.title(), !cgTitle.isEmpty {
|
||||
|
||||
@@ -107,7 +107,7 @@ class Windows {
|
||||
}
|
||||
|
||||
static func isOnScreen(_ window: Window, _ screen: NSScreen) -> Bool {
|
||||
if let position = window.axUiElement.position() {
|
||||
if let position = window.position {
|
||||
var screenFrameInQuartzCoordinates = screen.frame
|
||||
screenFrameInQuartzCoordinates.origin.y = NSMaxY(NSScreen.screens[0].frame) - NSMaxY(screen.frame)
|
||||
return screenFrameInQuartzCoordinates.contains(position)
|
||||
|
||||
@@ -1,101 +1,192 @@
|
||||
import Cocoa
|
||||
|
||||
func axObserverCallback(observer: AXObserver, element: AXUIElement, notificationName: CFString, applicationPointer: UnsafeMutableRawPointer?) -> Void {
|
||||
func axObserverCallback(observer: AXObserver, element: AXUIElement, notificationName: CFString, _: UnsafeMutableRawPointer?) -> Void {
|
||||
let type = notificationName as String
|
||||
debugPrint("Accessibility event", type, element.title() ?? "nil")
|
||||
switch type {
|
||||
case kAXApplicationActivatedNotification: applicationActivated(element)
|
||||
case kAXApplicationHiddenNotification,
|
||||
kAXApplicationShownNotification: applicationHiddenOrShown(element, type)
|
||||
case kAXWindowCreatedNotification: windowCreated(element, applicationPointer)
|
||||
case kAXMainWindowChangedNotification: focusedWindowChanged(element, applicationPointer)
|
||||
case kAXUIElementDestroyedNotification: windowDestroyed(element)
|
||||
case kAXWindowMiniaturizedNotification,
|
||||
kAXWindowDeminiaturizedNotification: windowMiniaturizedOrDeminiaturized(element, type)
|
||||
case kAXTitleChangedNotification: windowTitleChanged(element)
|
||||
case kAXWindowResizedNotification: windowResized(element)
|
||||
case kAXFocusedUIElementChangedNotification: focusedUiElementChanged(element, applicationPointer)
|
||||
default: return
|
||||
}
|
||||
retryUntilTimeout({ try handleEvent(type, element) })
|
||||
}
|
||||
|
||||
private func focusedUiElementChanged(_ element: AXUIElement, _ applicationPointer: UnsafeMutableRawPointer?) {
|
||||
let application = Unmanaged<Application>.fromOpaque(applicationPointer!).takeUnretainedValue()
|
||||
Windows.list.forEach {
|
||||
if $0.application == application {
|
||||
// this event is the only opportunity we have to check if a window became a tab, or a tab became a window
|
||||
$0.isTabbed = $0.getIsTabbed()
|
||||
func retryUntilTimeout(_ fn: @escaping () throws -> Void, _ startTime: DispatchTime = DispatchTime.now()) {
|
||||
DispatchQueue.global(qos: .userInteractive).async {
|
||||
do {
|
||||
try fn()
|
||||
} catch {
|
||||
let timePassedInSeconds = Double(DispatchTime.now().uptimeNanoseconds - startTime.uptimeNanoseconds) / 1_000_000_000
|
||||
if timePassedInSeconds < Double(AXUIElement.globalTimeoutInSeconds) {
|
||||
DispatchQueue.global(qos: .userInteractive).asyncAfter(deadline: .now() + .milliseconds(10)) {
|
||||
retryUntilTimeout(fn, startTime)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func applicationActivated(_ element: AXUIElement) {
|
||||
guard let appFocusedWindow = element.focusedWindow(),
|
||||
let existingIndex = Windows.list.firstIndexThatMatches(appFocusedWindow) else { return }
|
||||
Windows.list.insert(Windows.list.remove(at: existingIndex), at: 0)
|
||||
App.app.refreshOpenUi([Windows.list[0], Windows.list[existingIndex]])
|
||||
}
|
||||
|
||||
private func applicationHiddenOrShown(_ element: AXUIElement, _ type: String) {
|
||||
let windows = Windows.list.filter {
|
||||
// for AXUIElement of apps, CFEqual or == don't work; looks like a Cocoa bug
|
||||
$0.application.axUiElement!.pid() == element.pid()
|
||||
func handleEvent(_ type: String, _ element: AXUIElement) throws {
|
||||
debugPrint("Accessibility event", type, element)
|
||||
// events are handled concurrently, thus we check that the app is still running
|
||||
if let pid = try element.pid(),
|
||||
let app = NSRunningApplication(processIdentifier: pid) {
|
||||
switch type {
|
||||
case kAXApplicationActivatedNotification: try applicationActivated(element)
|
||||
case kAXApplicationHiddenNotification,
|
||||
kAXApplicationShownNotification: try applicationHiddenOrShown(element, app, type)
|
||||
case kAXWindowCreatedNotification: try windowCreated(element, app)
|
||||
case kAXMainWindowChangedNotification: try focusedWindowChanged(element, app)
|
||||
case kAXUIElementDestroyedNotification: try windowDestroyed(element)
|
||||
case kAXWindowMiniaturizedNotification,
|
||||
kAXWindowDeminiaturizedNotification: try windowMiniaturizedOrDeminiaturized(element, type)
|
||||
case kAXTitleChangedNotification: try windowTitleChanged(element)
|
||||
case kAXWindowResizedNotification: try windowResized(element)
|
||||
case kAXWindowMovedNotification: try windowMoved(element)
|
||||
case kAXFocusedUIElementChangedNotification: try focusedUiElementChanged(element, app)
|
||||
default: return
|
||||
}
|
||||
}
|
||||
windows.forEach { $0.isHidden = type == kAXApplicationHiddenNotification }
|
||||
App.app.refreshOpenUi(windows)
|
||||
}
|
||||
|
||||
private func windowCreated(_ element: AXUIElement, _ applicationPointer: UnsafeMutableRawPointer?) {
|
||||
let application = Unmanaged<Application>.fromOpaque(applicationPointer!).takeUnretainedValue()
|
||||
guard element.isActualWindow(application.runningApplication.bundleIdentifier) else { return }
|
||||
// a window being un-minimized can trigger kAXWindowCreatedNotification
|
||||
guard Windows.list.firstIndexThatMatches(element) == nil else { return }
|
||||
let window = Window(element, application)
|
||||
Windows.list.insertAndScaleRecycledPool([window], at: 0)
|
||||
Windows.cycleFocusedWindowIndex(1)
|
||||
App.app.refreshOpenUi([window])
|
||||
private func focusedUiElementChanged(_ element: AXUIElement, _ app: NSRunningApplication) throws {
|
||||
let appAxUiElement = AXUIElementCreateApplication(app.processIdentifier)
|
||||
if let currentWindows = try appAxUiElement.windows() {
|
||||
DispatchQueue.main.async {
|
||||
let windows = Windows.list.filter {
|
||||
// for AXUIElement of apps, CFEqual or == don't work; looks like a Cocoa bug
|
||||
let isFromApp = $0.application.runningApplication.processIdentifier == app.processIdentifier
|
||||
if isFromApp {
|
||||
// this event is the only opportunity we have to check if a window became a tab, or a tab became a window
|
||||
let isTabbedNew = $0.getIsTabbed(currentWindows)
|
||||
if $0.isTabbed != isTabbedNew {
|
||||
$0.isTabbed = isTabbedNew
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
App.app.refreshOpenUi(windows)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func focusedWindowChanged(_ element: AXUIElement, _ applicationPointer: UnsafeMutableRawPointer?) {
|
||||
if let existingIndex = Windows.list.firstIndexThatMatches(element) {
|
||||
private func applicationActivated(_ element: AXUIElement) throws {
|
||||
if let appFocusedWindow = try element.focusedWindow(),
|
||||
let wid = try appFocusedWindow.cgWindowId() {
|
||||
DispatchQueue.main.async {
|
||||
guard let existingIndex = Windows.list.firstIndexThatMatches(appFocusedWindow, wid) else { return }
|
||||
Windows.list.insert(Windows.list.remove(at: existingIndex), at: 0)
|
||||
App.app.refreshOpenUi([Windows.list[0], Windows.list[existingIndex]])
|
||||
} else {
|
||||
let application = Unmanaged<Application>.fromOpaque(applicationPointer!).takeUnretainedValue()
|
||||
if element.isActualWindow(application.runningApplication.bundleIdentifier) {
|
||||
Windows.list.insert(Window(element, application), at: 0)
|
||||
App.app.refreshOpenUi([Windows.list[0]])
|
||||
App.app.refreshOpenUi([Windows.list[0], Windows.list[existingIndex]])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func windowDestroyed(_ element: AXUIElement) {
|
||||
guard let existingIndex = Windows.list.firstIndexThatMatches(element) else { return }
|
||||
Windows.list.remove(at: existingIndex)
|
||||
guard Windows.list.count > 0 else { App.app.hideUi(); return }
|
||||
Windows.moveFocusedWindowIndexAfterWindowDestroyedInBackground(existingIndex)
|
||||
App.app.refreshOpenUi()
|
||||
private func applicationHiddenOrShown(_ element: AXUIElement, _ app: NSRunningApplication, _ type: String) throws {
|
||||
DispatchQueue.main.async {
|
||||
let windows = Windows.list.filter {
|
||||
// for AXUIElement of apps, CFEqual or == don't work; looks like a Cocoa bug
|
||||
let isFromApp = $0.application.runningApplication.processIdentifier == app.processIdentifier
|
||||
if isFromApp {
|
||||
$0.isHidden = type == kAXApplicationHiddenNotification
|
||||
}
|
||||
return isFromApp
|
||||
}
|
||||
App.app.refreshOpenUi(windows)
|
||||
}
|
||||
}
|
||||
|
||||
private func windowMiniaturizedOrDeminiaturized(_ element: AXUIElement, _ type: String) {
|
||||
guard let index = Windows.list.firstIndexThatMatches(element) else { return }
|
||||
let window = Windows.list[index]
|
||||
window.isMinimized = type == kAXWindowMiniaturizedNotification
|
||||
App.app.refreshOpenUi([window])
|
||||
private func windowCreated(_ element: AXUIElement, _ app: NSRunningApplication) throws {
|
||||
if let wid = try element.cgWindowId(),
|
||||
try element.isActualWindow(app.bundleIdentifier) {
|
||||
let axTitle = try element.title()
|
||||
let isFullscreen = try element.isFullscreen()
|
||||
let isMinimized = try element.isMinimized()
|
||||
let position = try element.position()
|
||||
DispatchQueue.main.async {
|
||||
// a window being un-minimized can trigger kAXWindowCreatedNotification
|
||||
if Windows.list.firstIndexThatMatches(element, wid) == nil,
|
||||
let app = (Applications.list.first { $0.runningApplication.processIdentifier == app.processIdentifier }) {
|
||||
let window = Window(element, app, wid, axTitle, isFullscreen, isMinimized, position)
|
||||
Windows.list.insertAndScaleRecycledPool([window], at: 0)
|
||||
Windows.cycleFocusedWindowIndex(1)
|
||||
App.app.refreshOpenUi([window])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func windowTitleChanged(_ element: AXUIElement) {
|
||||
guard let index = Windows.list.firstIndexThatMatches(element) else { return }
|
||||
let window = Windows.list[index]
|
||||
guard let newTitle = window.axUiElement.title(),
|
||||
newTitle != window.title else { return }
|
||||
window.title = newTitle
|
||||
App.app.refreshOpenUi([window])
|
||||
private func focusedWindowChanged(_ element: AXUIElement, _ app: NSRunningApplication) throws {
|
||||
if let wid = try element.cgWindowId() {
|
||||
let isActualWindow = try element.isActualWindow(app.bundleIdentifier)
|
||||
let axTitle = try element.title()
|
||||
let isFullscreen = try element.isFullscreen()
|
||||
let isMinimized = try element.isMinimized()
|
||||
let position = try element.position()
|
||||
DispatchQueue.main.async {
|
||||
if let existingIndex = Windows.list.firstIndexThatMatches(element, wid) {
|
||||
Windows.list.insert(Windows.list.remove(at: existingIndex), at: 0)
|
||||
App.app.refreshOpenUi([Windows.list[0], Windows.list[existingIndex]])
|
||||
} else if isActualWindow,
|
||||
let app = (Applications.list.first { $0.runningApplication.processIdentifier == app.processIdentifier }) {
|
||||
Windows.list.insert(Window(element, app, wid, axTitle, isFullscreen, isMinimized, position), at: 0)
|
||||
App.app.refreshOpenUi([Windows.list[0]])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func windowResized(_ element: AXUIElement) {
|
||||
guard let index = Windows.list.firstIndexThatMatches(element) else { return }
|
||||
let window = Windows.list[index]
|
||||
window.isFullscreen = window.axUiElement.isFullScreen()
|
||||
App.app.refreshOpenUi([window])
|
||||
}
|
||||
private func windowDestroyed(_ element: AXUIElement) throws {
|
||||
let wid = try element.cgWindowId()
|
||||
DispatchQueue.main.async {
|
||||
guard let existingIndex = Windows.list.firstIndexThatMatches(element, wid) else { return }
|
||||
Windows.list.remove(at: existingIndex)
|
||||
guard Windows.list.count > 0 else { App.app.hideUi(); return }
|
||||
Windows.moveFocusedWindowIndexAfterWindowDestroyedInBackground(existingIndex)
|
||||
App.app.refreshOpenUi()
|
||||
}
|
||||
}
|
||||
|
||||
private func windowMiniaturizedOrDeminiaturized(_ element: AXUIElement, _ type: String) throws {
|
||||
if let wid = try element.cgWindowId() {
|
||||
DispatchQueue.main.async {
|
||||
guard let index = Windows.list.firstIndexThatMatches(element, wid) else { return }
|
||||
let window = Windows.list[index]
|
||||
window.isMinimized = type == kAXWindowMiniaturizedNotification
|
||||
App.app.refreshOpenUi([window])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func windowTitleChanged(_ element: AXUIElement) throws {
|
||||
if let wid = try element.cgWindowId() {
|
||||
let newTitle = try element.title()
|
||||
DispatchQueue.main.async {
|
||||
guard let index = Windows.list.firstIndexThatMatches(element, wid) else { return }
|
||||
let window = Windows.list[index]
|
||||
guard newTitle != nil && newTitle != window.title else { return }
|
||||
window.title = newTitle!
|
||||
App.app.refreshOpenUi([window])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func windowResized(_ element: AXUIElement) throws {
|
||||
// TODO: only trigger this at the end of the resize, not on every tick
|
||||
// currenly resizing a window will lag AltTab as it triggers too much UI work
|
||||
if let wid = try element.cgWindowId() {
|
||||
let isFullscreen = try element.isFullscreen()
|
||||
DispatchQueue.main.async {
|
||||
guard let index = Windows.list.firstIndexThatMatches(element, wid) else { return }
|
||||
let window = Windows.list[index]
|
||||
window.isFullscreen = isFullscreen
|
||||
App.app.refreshOpenUi([window])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func windowMoved(_ element: AXUIElement) throws {
|
||||
if let wid = try element.cgWindowId() {
|
||||
let position = try element.position()
|
||||
DispatchQueue.main.async {
|
||||
guard let index = Windows.list.firstIndexThatMatches(element, wid) else { return }
|
||||
let window = Windows.list[index]
|
||||
window.position = position
|
||||
App.app.refreshOpenUi([window])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,30 +10,27 @@ class KeyboardEvents {
|
||||
private var eventTap: CFMachPort?
|
||||
|
||||
private func observe_() {
|
||||
DispatchQueues.keyboardEvents.async {
|
||||
let eventMask = [CGEventType.keyDown, CGEventType.keyUp, CGEventType.flagsChanged].reduce(CGEventMask(0), { $0 | (1 << $1.rawValue) })
|
||||
// CGEvent.tapCreate returns null if ensureAccessibilityCheckboxIsChecked() didn't pass
|
||||
eventTap = CGEvent.tapCreate(
|
||||
tap: .cgSessionEventTap,
|
||||
place: .headInsertEventTap,
|
||||
options: .defaultTap,
|
||||
eventsOfInterest: eventMask,
|
||||
callback: keyboardHandler,
|
||||
userInfo: nil)
|
||||
let runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0)
|
||||
CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes)
|
||||
CFRunLoopRun()
|
||||
}
|
||||
let eventMask = [CGEventType.keyDown, CGEventType.keyUp, CGEventType.flagsChanged].reduce(CGEventMask(0), { $0 | (1 << $1.rawValue) })
|
||||
// CGEvent.tapCreate returns null if ensureAccessibilityCheckboxIsChecked() didn't pass
|
||||
eventTap = CGEvent.tapCreate(
|
||||
tap: .cgSessionEventTap,
|
||||
place: .headInsertEventTap,
|
||||
options: .defaultTap,
|
||||
eventsOfInterest: eventMask,
|
||||
callback: keyboardHandler,
|
||||
userInfo: nil)
|
||||
let runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0)
|
||||
CFRunLoopAddSource(BackgroundWork.keyboardEventsThread.runLoop, runLoopSource, .commonModes)
|
||||
}
|
||||
|
||||
private func keyboardHandler(proxy: CGEventTapProxy, type: CGEventType, cgEvent: CGEvent, userInfo: UnsafeMutableRawPointer?) -> Unmanaged<CGEvent>? {
|
||||
if type == .keyDown || type == .keyUp || type == .flagsChanged {
|
||||
if type == .keyDown || type == .keyUp || type == .flagsChanged {
|
||||
if let event_ = NSEvent(cgEvent: cgEvent),
|
||||
// workaround: NSEvent.characters is not safe outside of the main thread; this is not documented by Apple
|
||||
// see https://github.com/Kentzo/ShortcutRecorder/issues/114#issuecomment-606465340
|
||||
// see https://github.com/Kentzo/ShortcutRecorder/issues/114#issuecomment-606465340
|
||||
let event = NSEvent.keyEvent(with: event_.type, location: event_.locationInWindow, modifierFlags: event_.modifierFlags,
|
||||
timestamp: event_.timestamp, windowNumber: event_.windowNumber, context: nil, characters: "",
|
||||
charactersIgnoringModifiers: "", isARepeat: type == .flagsChanged ? false : event_.isARepeat, keyCode: event_.keyCode) {
|
||||
timestamp: event_.timestamp, windowNumber: event_.windowNumber, context: nil, characters: "",
|
||||
charactersIgnoringModifiers: "", isARepeat: type == .flagsChanged ? false : event_.isARepeat, keyCode: event_.keyCode) {
|
||||
let appWasBeingUsed = App.app.appIsBeingUsed
|
||||
App.shortcutMonitor.handle(event, withTarget: nil)
|
||||
if appWasBeingUsed || App.app.appIsBeingUsed {
|
||||
|
||||
+2
-2
@@ -34,8 +34,10 @@ class App: NSApplication, NSApplicationDelegate {
|
||||
#if !DEBUG
|
||||
PFMoveToApplicationsFolderIfNecessary()
|
||||
#endif
|
||||
AXUIElement.setGlobalTimeout()
|
||||
SystemPermissions.ensureAccessibilityCheckboxIsChecked()
|
||||
SystemPermissions.ensureScreenRecordingCheckboxIsChecked()
|
||||
BackgroundWork.start()
|
||||
Preferences.migratePreferences()
|
||||
Preferences.registerDefaults()
|
||||
statusItem = Menubar.make()
|
||||
@@ -52,8 +54,6 @@ class App: NSApplication, NSApplicationDelegate {
|
||||
|
||||
// pre-load some windows so they are faster on first display
|
||||
private func preloadWindows() {
|
||||
preferencesWindowController.show()
|
||||
preferencesWindowController.window!.orderOut(nil)
|
||||
thumbnailsPanel.orderFront(nil)
|
||||
thumbnailsPanel.orderOut(nil)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user