-
-
Notifications
You must be signed in to change notification settings - Fork 330
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
refactor: complete rework of the internals
closes #93 closes #24 closes #117 BREAKING CHANGE: Instead of asking the OS about the state of the whole system on trigger (what we do today; hard to do fast), or asking the state of the whole system on a timer (what HyperSwitch does today; inaccurate) - instead of one of 2 approaches, v3 observes the Accessibility events such as "an app was launched", "a window was closed". This means we build a cache as we receive these events in the background, and when the user trigger the app, we can show accurate state of the windows instantly. Of course there is no free lunch, so this approach has its own issues. However from my work on it from the past week, I'm very optimistic! The thing I'm the most excited about actually is not the perf (because on my machine even v2 is instant; I have a recent macbook and no 4k displays), but the fact that we will finally have the thumbnails in order of recently-used to least-recently-used, instead of the order of their stack (z-index) on the desktop. It's a big difference! There are many more limitations that are no longer applying also with this approach. More context: #45 (comment)
- Loading branch information
Showing
22 changed files
with
620 additions
and
355 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,100 +1,68 @@ | ||
import Cocoa | ||
import Foundation | ||
|
||
// This list of keys is not exhaustive; it contains only the values used by this app | ||
// full public list: ApplicationServices.HIServices.AXAttributeConstants.swift | ||
// Note that the String value is transformed by the getters (e.g. kAXWindowsAttribute -> AXWindows) | ||
enum AXAttributeKey: String { | ||
case windows = "AXWindows" | ||
case minimized = "AXMinimized" | ||
case focusedWindow = "AXFocusedWindow" | ||
case subrole = "AXSubrole" | ||
} | ||
|
||
extension AXUIElement { | ||
func value<T>(_ key: AXAttributeKey, _ target: T, _ type: AXValueType) -> T? { | ||
if let a = attribute(key, AXValue.self) { | ||
var value = target | ||
AXValueGetValue(a, type, &value) | ||
return value | ||
} | ||
return nil | ||
} | ||
|
||
func attribute<T>(_ key: AXAttributeKey, _ type: T.Type) -> T? { | ||
var value: AnyObject? | ||
let result = AXUIElementCopyAttributeValue(self, key.rawValue as CFString, &value) | ||
if result == .success, let value = value as? T { | ||
return value | ||
} | ||
return nil | ||
} | ||
|
||
func cgId() -> CGWindowID { | ||
func cgWindowId() -> CGWindowID { | ||
var id = CGWindowID(0) | ||
_AXUIElementGetWindow(self, &id) | ||
return id | ||
} | ||
|
||
func focusedWindow() -> AXUIElement? { | ||
return attribute(.focusedWindow, AXUIElement.self) | ||
func pid() -> pid_t { | ||
var pid = pid_t(0) | ||
AXUIElementGetPid(self, &pid) | ||
return pid | ||
} | ||
|
||
func isActualWindow() -> Bool { | ||
let subrole = self.attribute(.subrole, String.self) | ||
return subrole != nil && subrole != "AXUnknown" | ||
func isActualWindow(_ isAppHidden: Bool = false) -> Bool { | ||
// TODO: should we displays windows that disappear when invoking Expose? (e.g. Outlook meeting reminder window) (see https://stackoverflow.com/a/49723037/2249756) | ||
// TODO: TotalFinder and XtraFinder double-window hacks (see #84) | ||
// TODO: should we display menubar windows? (e.g. iStats Pro dropdown menu) | ||
// Some non-windows have subrole: nil (e.g. some OS elements), "AXUnknown" (e.g. Bartender), "AXSystemDialog" (e.g. Intellij tooltips) | ||
// Some non-windows have title: nil (e.g. some OS elements) | ||
// Minimized windows or windows of a hidden app have subrole "AXDialog" | ||
return title() != nil && (subrole() == "AXStandardWindow" || isMinimized() || isAppHidden) | ||
} | ||
|
||
func windows() -> [AXUIElement]? { | ||
return attribute(.windows, [AXUIElement].self) | ||
func title() -> String? { | ||
return attribute(kAXTitleAttribute, String.self) | ||
} | ||
|
||
func window(_ id: CGWindowID) -> AXUIElement? { | ||
return windows()?.first(where: { return id == $0.cgId() }) | ||
func windows() -> [AXUIElement]? { | ||
return attribute(kAXWindowsAttribute, [AXUIElement].self) | ||
} | ||
|
||
func isMinimized() -> Bool { | ||
return attribute(.minimized, Bool.self) == true | ||
return attribute(kAXMinimizedAttribute, Bool.self) == true | ||
} | ||
|
||
func focus(_ id: CGWindowID) { | ||
// implementation notes: the following sequence of actions repeats some calls. This is necessary for | ||
// minimized windows on other spaces, and focuses windows faster (e.g. the Security & Privacy 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 | ||
var elementConnection = UInt32(0) | ||
CGSGetWindowOwner(cgsMainConnectionId, id, &elementConnection) | ||
var psn = ProcessSerialNumber() | ||
CGSGetConnectionPSN(elementConnection, &psn) | ||
AXUIElementPerformAction(self, kAXRaiseAction as CFString) | ||
makeKeyWindow(psn, id) | ||
_SLPSSetFrontProcessWithOptions(&psn, id, .userGenerated) | ||
makeKeyWindow(psn, id) | ||
AXUIElementPerformAction(self, kAXRaiseAction as CFString) | ||
func isHidden() -> Bool { | ||
return attribute(kAXHiddenAttribute, Bool.self) == true | ||
} | ||
|
||
// The following function was ported from https://github.com/Hammerspoon/hammerspoon/issues/370#issuecomment-545545468 | ||
func makeKeyWindow(_ psn: ProcessSerialNumber, _ wid: CGWindowID) -> Void { | ||
var wid_ = wid | ||
var psn_ = psn | ||
|
||
var bytes1 = [UInt8](repeating: 0, count: 0xf8) | ||
bytes1[0x04] = 0xF8 | ||
bytes1[0x08] = 0x01 | ||
bytes1[0x3a] = 0x10 | ||
func focusedWindow() -> AXUIElement? { | ||
return attribute(kAXFocusedWindowAttribute, AXUIElement.self) | ||
} | ||
|
||
var bytes2 = [UInt8](repeating: 0, count: 0xf8) | ||
bytes2[0x04] = 0xF8 | ||
bytes2[0x08] = 0x02 | ||
bytes2[0x3a] = 0x10 | ||
func subrole() -> String? { | ||
return attribute(kAXSubroleAttribute, String.self) | ||
} | ||
|
||
memcpy(&bytes1[0x3c], &wid_, MemoryLayout<UInt32>.size) | ||
memset(&bytes1[0x20], 0xFF, 0x10) | ||
memcpy(&bytes2[0x3c], &wid_, MemoryLayout<UInt32>.size) | ||
memset(&bytes2[0x20], 0xFF, 0x10) | ||
private func attribute<T>(_ key: String, _ type: T.Type) -> T? { | ||
var value: AnyObject? | ||
let result = AXUIElementCopyAttributeValue(self, key as CFString, &value) | ||
if result == .success, let value = value as? T { | ||
return value | ||
} | ||
return nil | ||
} | ||
|
||
SLPSPostEventRecordTo(&psn_, &(UnsafeMutablePointer(mutating: UnsafePointer<UInt8>(bytes1)).pointee)) | ||
SLPSPostEventRecordTo(&psn_, &(UnsafeMutablePointer(mutating: UnsafePointer<UInt8>(bytes2)).pointee)) | ||
private func value<T>(_ key: String, _ target: T, _ type: AXValueType) -> T? { | ||
if let a = attribute(key, AXValue.self) { | ||
var value = target | ||
AXValueGetValue(a, type, &value) | ||
return value | ||
} | ||
return nil | ||
} | ||
} |
Oops, something went wrong.