Skip to content

Commit

Permalink
feat: improve window focusing action
Browse files Browse the repository at this point in the history
  • Loading branch information
lwouis committed Feb 15, 2025
1 parent e98dff3 commit 8dd63c7
Show file tree
Hide file tree
Showing 3 changed files with 183 additions and 24 deletions.
3 changes: 2 additions & 1 deletion src/api-wrappers/AXUIElement.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ extension AXUIElement {
// we add 5s to make sure to not do an extra retry
AXUIElementSetMessagingTimeout(AXUIElementCreateSystemWide(), globalTimeoutInSeconds + 5)
}

static var windowResizedOrMovedMap = DebounceMap()
static var windowTitleChangedMap = DebounceMap()

Expand Down Expand Up @@ -423,4 +424,4 @@ enum AxError: Error {
/// it starts at 0 for each app, and increments over time, for each new UI element
/// this means that long-lived apps (e.g. Finder) may have high IDs
/// we don't know how high it can go, and if it wraps around
typealias AXUIElementID = UInt
typealias AXUIElementID = UInt64
31 changes: 11 additions & 20 deletions src/logic/Window.swift
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ class Window {
var psn = ProcessSerialNumber()
GetProcessForPID(self.application.pid, &psn)
_SLPSSetFrontProcessWithOptions(&psn, self.cgWindowId!, SLPSMode.userGenerated.rawValue)
self.makeKeyWindow(psn)
self.makeKeyWindow(&psn)
self.axUiElement!.focusWindow()
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(50)) {
Windows.previewFocusedWindowIfNeeded()
Expand All @@ -195,25 +195,16 @@ class Window {
}

/// The following function was ported from https://github.com/Hammerspoon/hammerspoon/issues/370#issuecomment-545545468
func makeKeyWindow(_ psn: ProcessSerialNumber) -> Void {
var psn_ = psn
var bytes1 = [UInt8](repeating: 0, count: 0xf8)
bytes1[0x04] = 0xF8
bytes1[0x08] = 0x01
bytes1[0x3a] = 0x10
var bytes2 = [UInt8](repeating: 0, count: 0xf8)
bytes2[0x04] = 0xF8
bytes2[0x08] = 0x02
bytes2[0x3a] = 0x10
memcpy(&bytes1[0x3c], &cgWindowId, MemoryLayout<UInt32>.size)
memset(&bytes1[0x20], 0xFF, 0x10)
memcpy(&bytes2[0x3c], &cgWindowId, MemoryLayout<UInt32>.size)
memset(&bytes2[0x20], 0xFF, 0x10)
[bytes1, bytes2].forEach { bytes in
_ = bytes.withUnsafeBufferPointer { pointer in
SLPSPostEventRecordTo(&psn_, &UnsafeMutablePointer(mutating: pointer.baseAddress)!.pointee)
}
}
func makeKeyWindow(_ psn: inout ProcessSerialNumber) -> Void {
var bytes = [UInt8](repeating: 0, count: 0xf8)
bytes[0x04] = 0xf8
bytes[0x3a] = 0x10
memcpy(&bytes[0x3c], &cgWindowId, MemoryLayout<UInt32>.size)
memset(&bytes[0x20], 0xff, 0x10)
bytes[0x08] = 0x01
SLPSPostEventRecordTo(&psn, &bytes)
bytes[0x08] = 0x02
SLPSPostEventRecordTo(&psn, &bytes)
}

// 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
Expand Down
173 changes: 170 additions & 3 deletions unit-tests/AXUIElementTests.swift
Original file line number Diff line number Diff line change
@@ -1,20 +1,142 @@
//import XCTest
//
//final class ImageScalingTests: XCTestCase {
// /// 20ms
// func testWindowsByBruteForce() throws {
// /// average: 0.021
// func testWindowsByBruteForce1000() throws {
// measure(options: options) {
// let _ = windowsByBruteForce(41414)
// let _ = windowsByBruteForce(721)
// }
// }
//
// /// average: 0.042
// func testWindowsByBruteForceData() throws {
// measure(options: options) {
// let _ = windowsByBruteForceData(721)
// }
// }
//
// /// average: 0.040
// /// average: 0.019 without CFDataCreate
// func testWindowsByBruteForceUint8() throws {
// measure(options: options) {
// var pid = pid_t(721)
// let _ = windowsByBruteForceUint8(&pid)
// }
// }
//
// func testRangeToBruteForce() {
// // all windows is empty
// XCTAssertEqual(Windows.rangeToBruteForce([], []), nil)
// XCTAssertEqual(Windows.rangeToBruteForce([], [2, 3]), nil)
// // all windows are known
// XCTAssertEqual(Windows.rangeToBruteForce([2, 3, 4, 5], [2, 3, 4, 5]), nil)
// // no known window helps us narrow the range
// XCTAssertEqual(Windows.rangeToBruteForce([2, 3, 4, 5], []), [nil, nil])
// XCTAssertEqual(Windows.rangeToBruteForce([2, 3, 4, 5], [3]), [nil, nil])
// XCTAssertEqual(Windows.rangeToBruteForce([2, 3, 4, 5], [3, 4]), [nil, nil])
// // some known windows are not in allWindows
// XCTAssertEqual(Windows.rangeToBruteForce([2, 3, 4, 5], [1]), [nil, nil])
// XCTAssertEqual(Windows.rangeToBruteForce([2, 3, 4, 5], [6]), [nil, nil])
// XCTAssertEqual(Windows.rangeToBruteForce([2, 3, 4, 5], [1, 2, 3]), [nil, nil])
// XCTAssertEqual(Windows.rangeToBruteForce([2, 3, 4, 5], [1, 2, 3, 4, 5, 6]), [nil, nil])
// XCTAssertEqual(Windows.rangeToBruteForce([2, 3, 4, 5], [2, 3, 4, 5, 6]), [nil, nil])
// XCTAssertEqual(Windows.rangeToBruteForce([2, 3, 4, 5], [1, 2, 3, 4, 5]), [nil, nil])
// // only start is narrowed
// XCTAssertEqual(Windows.rangeToBruteForce([2, 3, 4, 5], [2]), [2, nil])
// XCTAssertEqual(Windows.rangeToBruteForce([2, 3, 4, 5], [2, 4]), [2, nil])
// XCTAssertEqual(Windows.rangeToBruteForce([2, 3, 4, 5], [2, 3]), [3, nil])
// XCTAssertEqual(Windows.rangeToBruteForce([2, 3, 4, 5], [2, 3, 4]), [4, nil])
// // only end is narrowed
// XCTAssertEqual(Windows.rangeToBruteForce([2, 3, 4, 5], [5]), [nil, 5])
// XCTAssertEqual(Windows.rangeToBruteForce([2, 3, 4, 5], [3, 5]), [nil, 5])
// XCTAssertEqual(Windows.rangeToBruteForce([2, 3, 4, 5], [4, 5]), [nil, 4])
// XCTAssertEqual(Windows.rangeToBruteForce([2, 3, 4, 5], [3, 4, 5]), [nil, 3])
// // start and end are narrowed
// XCTAssertEqual(Windows.rangeToBruteForce([2, 3, 4, 5], [2, 5]), [2, 5])
// XCTAssertEqual(Windows.rangeToBruteForce([2, 3, 4, 5], [2, 3, 5]), [3, 5])
// XCTAssertEqual(Windows.rangeToBruteForce([2, 3, 4, 5], [2, 4, 5]), [2, 4])
// }
//
// private var options: XCTMeasureOptions = {
// let o = XCTMeasureOptions()
// o.iterationCount = 10
// return o
// }()
//}
//
//class Windows {
//// static func CGWindowIDRangeToAXUIElementIDRange(_ range: [CGWindowID?]) -> Range<AXUIElementID> {
////// print(range)
//// var start = AXUIElementID(0)
//// var end = AXUIElementID(1000)
//// if let startCGWindowID = range[0],
//// let startAXUIElementID = (Windows.list.first { $0.cgWindowId == startCGWindowID }!.axUiElement.id()) {
//// start = startAXUIElementID
//// }
//// if let endCGWindowID = range[1],
//// let endAXUIElementID = (Windows.list.first { $0.cgWindowId == endCGWindowID }!.axUiElement.id()) {
//// end = endAXUIElementID
//// } else {
//// end = start + 1000
//// }
//// let range2 = start..<end
////// print(range2)
//// return range2
//// }
//
// static func rangeToBruteForce(_ allWindowIds: [CGWindowID], _ knownWindowIds: [CGWindowID]) -> [CGWindowID?]? {
// if allWindowIds.isEmpty {
// return nil
// }
// if knownWindowIds.isEmpty {
// return [nil, nil]
// }
// let allSorted = allWindowIds.sorted()
// let knownSorted = knownWindowIds.sorted()
// if knownSorted.first! < allSorted.first! || knownSorted.last! > allSorted.last! {
// Logger.error("we track some windows, yet they are not returned by CGWindowListCopyWindowInfo")
// return [nil, nil]
// }
// if allSorted.count == knownSorted.count {
// var same = true
// for i in 0..<allSorted.count {
// if allSorted[i] != knownSorted[i] {
// same = false
// }
// }
// if same {
// return nil
// }
// }
// var startIsShared = knownSorted.first! == allSorted.first!
// let endIsShared = knownSorted.last! == allSorted.last!
// if !startIsShared && !endIsShared {
// return [nil, nil]
// }
// var start: CGWindowID? = nil
// var end: CGWindowID? = endIsShared ? allSorted.last! : nil
// for windowId in allSorted {
// if startIsShared {
// if knownSorted.contains(windowId) {
// start = windowId
// } else {
// startIsShared = false
// }
// }
// if endIsShared {
// if knownSorted.contains(windowId) {
// if windowId < end! {
// end = windowId
// }
// } else {
// end = allSorted.last!
// }
// }
// }
// return [start, end]
// }
//}
//
//func windowsByBruteForce(_ pid: pid_t) -> [AXUIElement] {
// // we use this to call _AXUIElementCreateWithRemoteToken; we reuse the object for performance
// // tests showed that this remoteToken is 20 bytes: 4 + 4 + 4 + 8; the order of bytes matters
Expand All @@ -35,6 +157,51 @@
// return axWindows
//}
//
//func windowsByBruteForceData(_ pid: pid_t) -> [AXUIElement] {
// // we use this to call _AXUIElementCreateWithRemoteToken; we reuse the object for performance
// // tests showed that this remoteToken is 20 bytes: 4 + 4 + 4 + 8; the order of bytes matters
// var remoteToken = Data(count: 20)
// remoteToken.replaceSubrange(0..<4, with: withUnsafeBytes(of: pid) { Data($0) })
// remoteToken.replaceSubrange(4..<8, with: withUnsafeBytes(of: Int32(0)) { Data($0) })
// remoteToken.replaceSubrange(8..<12, with: withUnsafeBytes(of: Int32(0x636f636f)) { Data($0) })
// let axWindows = [AXUIElement]()
// // we iterate to 1000 as a tradeoff between performance, and missing windows of long-lived processes
// for axUiElementId: AXUIElementID in 0..<100000 {
// remoteToken.replaceSubrange(12..<20, with: withUnsafeBytes(of: axUiElementId) { Data($0) })
//// if let axUiElement = _AXUIElementCreateWithRemoteToken(remoteToken as CFData)?.takeRetainedValue(),
//// let subrole = try? axUiElement.subrole(),
//// [kAXStandardWindowSubrole, kAXDialogSubrole].contains(subrole) {
//// axWindows.append(axUiElement)
//// }
// }
// return axWindows
//}
//
//func windowsByBruteForceUint8(_ pid: inout pid_t) -> [AXUIElement] {
// // we use this to call _AXUIElementCreateWithRemoteToken; we reuse the object for performance
// // tests showed that this remoteToken is 20 bytes: 4 + 4 + 4 + 8; the order of bytes matters
// var tid = Int32(0x636f636f)
// var remoteToken = [UInt8](repeating: 0, count: 20)
// var dataCursor = 0
// memcpy(&remoteToken[dataCursor], &pid, MemoryLayout<UInt32>.size)
// dataCursor += MemoryLayout<UInt32>.size
// memset(&remoteToken[dataCursor], 0, MemoryLayout<UInt32>.size)
// dataCursor += MemoryLayout<UInt32>.size
// memcpy(&remoteToken[dataCursor], &tid, MemoryLayout<Int32>.size)
// dataCursor += MemoryLayout<Int32>.size
// let axWindows = [AXUIElement]()
// // we iterate to 1000 as a tradeoff between performance, and missing windows of long-lived processes
// for var axUiElementId: AXUIElementID in 0..<100000 {
// memcpy(&remoteToken[dataCursor], &axUiElementId, MemoryLayout<AXUIElementID>.size);
// let _ = CFDataCreate(kCFAllocatorDefault, remoteToken, remoteToken.count)
//// let axUiElement = _AXUIElementCreateWithRemoteToken(cfdata)?.takeRetainedValue(),
//// let subrole = try? axUiElement.subrole(),
//// [kAXStandardWindowSubrole, kAXDialogSubrole].contains(subrole) {
//// axWindows.append(axUiElement)
// }
// return axWindows
//}
//
//typealias AXUIElementID = UInt
//
//enum AxError: Error {
Expand Down

0 comments on commit 8dd63c7

Please sign in to comment.