diff --git a/Sources/AppBundle/model/Json.swift b/Sources/AppBundle/model/Json.swift index 27fc9a23..5c99337d 100644 --- a/Sources/AppBundle/model/Json.swift +++ b/Sources/AppBundle/model/Json.swift @@ -1,5 +1,5 @@ +import AppKit import Common -import Foundation enum Json: Encodable { // vector @@ -44,4 +44,26 @@ enum Json: Encodable { die("Can't parse \(String(describing: value)) (\(type(of: value))) to JSON") } } + + var rawValue: Any? { + switch self { + case .null: nil + + case .array(let x): x + case .dict(let x): x + + case .bool(let x): x + case .int(let x): x + case .string(let x): x + case .uint32(let x): x + } + } + + var asDictOrDie: [String: Json] { + if case .dict(let dict) = self { + dict + } else { + dieT("\(self) is not a dict") + } + } } diff --git a/Sources/AppBundle/tree/MacApp.swift b/Sources/AppBundle/tree/MacApp.swift index 5f2cf8ee..1195386e 100644 --- a/Sources/AppBundle/tree/MacApp.swift +++ b/Sources/AppBundle/tree/MacApp.swift @@ -81,7 +81,7 @@ final class MacApp: AbstractApp { setFrameJobs.removeValue(forKey: windowId)?.cancel() _ = withWindowAsync(windowId) { [windows] window, job in guard let closeButton = window.get(Ax.closeButtonAttr) else { return } - if AXUIElementPerformAction(closeButton, kAXPressAction as CFString) == .success { + if AXUIElementPerformAction(closeButton.cast, kAXPressAction as CFString) == .success { windows.threadGuarded.removeValue(forKey: windowId) } } @@ -187,7 +187,7 @@ final class MacApp: AbstractApp { @MainActor // todo swift is stupid func isDialogHeuristic(_ windowId: UInt32) async throws -> Bool { try await withWindow(windowId) { [nsApp] window, job in - window.isDialogHeuristic(nsApp) + window.isDialogHeuristic(appBundleId: nsApp.bundleIdentifier) } == true } diff --git a/Sources/AppBundle/util/AxUiElementMock.swift b/Sources/AppBundle/util/AxUiElementMock.swift new file mode 100644 index 00000000..f8814022 --- /dev/null +++ b/Sources/AppBundle/util/AxUiElementMock.swift @@ -0,0 +1,12 @@ +import AppKit +import Common + +/// Alternative name: AttrAddressibleStorage +protocol AxUiElementMock { + func get(_ attr: Attr) -> Attr.T? + func containingWindowId() -> CGWindowID? +} + +extension AxUiElementMock { + var cast: AXUIElement { self as! AXUIElement } +} diff --git a/Sources/AppBundle/util/AxUiElementEx.swift b/Sources/AppBundle/util/AxUiElementMockEx.swift similarity index 94% rename from Sources/AppBundle/util/AxUiElementEx.swift rename to Sources/AppBundle/util/AxUiElementMockEx.swift index a4373211..ca5861c8 100644 --- a/Sources/AppBundle/util/AxUiElementEx.swift +++ b/Sources/AppBundle/util/AxUiElementMockEx.swift @@ -1,9 +1,8 @@ import AppKit -extension AXUIElement { +extension AxUiElementMock { // 'isDialogHeuristic' function name is referenced in the guide - func isDialogHeuristic(_ app: NSRunningApplication) -> Bool { - let id = app.bundleIdentifier + func isDialogHeuristic(appBundleId id: String?) -> Bool { // Note: a lot of windows don't have title on startup. So please don't rely on the title // Don't tile: @@ -58,7 +57,7 @@ extension AXUIElement { /// Why do we need to filter out non-windows? /// - "floating by default" workflow /// - It's annoying that the focus command treats these popups as floating windows - func isWindowHeuristic(axApp: AXUIElement, appBundleId: String?) -> Bool { + func isWindowHeuristic(axApp: AxUiElementMock, appBundleId: String?) -> Bool { // Just don't do anything with "Ghostty Quick Terminal" windows. // Its position and size are managed by the Ghostty itself // https://github.com/nikitabobko/AeroSpace/issues/103 @@ -96,7 +95,7 @@ extension AXUIElement { get(Ax.subroleAttr) == kAXStandardWindowSubrole } - private func isWindowHeuristicOld(axApp: AXUIElement, appBundleId: String?) -> Bool { // 0.18.3 hotfix + private func isWindowHeuristicOld(axApp: AxUiElementMock, appBundleId: String?) -> Bool { // 0.18.3 hotfix lazy var subrole = get(Ax.subroleAttr) lazy var title = get(Ax.titleAttr) ?? "" @@ -133,7 +132,7 @@ private extension String { } } -private func isFullscreenable(_ axWindow: AXUIElement) -> Bool { +private func isFullscreenable(_ axWindow: AxUiElementMock) -> Bool { if let fullscreenButton = axWindow.get(Ax.fullscreenButtonAttr) { return fullscreenButton.get(Ax.enabledAttr) == true } diff --git a/Sources/AppBundle/util/accessibility.swift b/Sources/AppBundle/util/accessibility.swift index d35b1a37..47687d4b 100644 --- a/Sources/AppBundle/util/accessibility.swift +++ b/Sources/AppBundle/util/accessibility.swift @@ -269,22 +269,23 @@ enum Ax { // key: kAXMainWindowAttribute, // getter: tryGetWindow //) - static let closeButtonAttr = ReadableAttrImpl( + static let closeButtonAttr = ReadableAttrImpl( key: kAXCloseButtonAttribute, - getter: { ($0 as! AXUIElement) } + getter: { castToAxUiElementMock($0) } ) // Note! fullscreen is not the same as "zoom" (green plus) - static let fullscreenButtonAttr = ReadableAttrImpl( + static let fullscreenButtonAttr = ReadableAttrImpl( key: kAXFullScreenButtonAttribute, - getter: { ($0 as! AXUIElement) } + getter: { castToAxUiElementMock($0) } ) - static let zoomButtonAttr = ReadableAttrImpl( + // green plus + static let zoomButtonAttr = ReadableAttrImpl( key: kAXZoomButtonAttribute, - getter: { ($0 as! AXUIElement) } + getter: { castToAxUiElementMock($0) } ) - static let minimizeButtonAttr = ReadableAttrImpl( + static let minimizeButtonAttr = ReadableAttrImpl( key: kAXMinimizeButtonAttribute, - getter: { ($0 as! AXUIElement) } + getter: { castToAxUiElementMock($0) } ) //static let growAreaAttr = ReadableAttrImpl( // key: kAXGrowAreaAttribute, @@ -292,6 +293,14 @@ enum Ax { //) } +private func castToAxUiElementMock(_ a: AnyObject) -> AxUiElementMock { + if let dict = a as? [String: Json] { // Convert from _SwiftDeferredNSDictionary + dict as? AxUiElementMock ?? dieT("Cannot cast \(type(of: a)) to AxUiElementMock") + } else { + a as? AxUiElementMock ?? dieT("Cannot cast \(type(of: a)) to AxUiElementMock") + } +} + typealias WindowIdAndAxUiElement = (windowId: UInt32, ax: AXUIElement) private func windowOrNil(_ any: Any?) -> WindowIdAndAxUiElement? { @@ -306,7 +315,7 @@ private func windowOrNil(_ any: Any?) -> WindowIdAndAxUiElement? { } } -extension AXUIElement { +extension AXUIElement: AxUiElementMock { func get(_ attr: Attr) -> Attr.T? { let state = signposter.beginInterval(#function, "attr: \(attr.key) axTaskLocalAppThreadToken: \(axTaskLocalAppThreadToken?.idForDebug)") defer { signposter.endInterval(#function, state) } diff --git a/Sources/AppBundleTests/AxWindowKindTest.swift b/Sources/AppBundleTests/AxWindowKindTest.swift new file mode 100644 index 00000000..9ff568bb --- /dev/null +++ b/Sources/AppBundleTests/AxWindowKindTest.swift @@ -0,0 +1,39 @@ +@testable import AppBundle +import Common +import XCTest + +final class AxWindowKindTest: XCTestCase { + func test() throws { + for file in try FileManager.default.contentsOfDirectory(at: projectRoot.appending(path: "./axDumps"), includingPropertiesForKeys: nil) { + let rawJson = try JSONSerialization.jsonObject(with: Data.init(contentsOf: file), options: [.json5Allowed]) as! [String: Any] + let json = Json.newOrDie(rawJson).asDictOrDie + let app = json["Aero.AXApp"]!.asDictOrDie + let appBundleId = rawJson["Aero.App.appBundleId"] as? String + assertEquals( + json.isWindowHeuristic(axApp: app, appBundleId: appBundleId), + rawJson["Aero.isWindowHeuristic"] as? Bool ?? dieT(), + additionalMsg: "isWindowHeuristic doesn't match for \(file)", + ) + assertEquals( + json.isDialogHeuristic(appBundleId: appBundleId), + rawJson["Aero.isDialogHeuristic"] as? Bool ?? dieT(), + additionalMsg: "isDialogHeuristic doesn't match for \(file)", + ) + } + } +} + +extension [String: Json]: AxUiElementMock { + public func get(_ attr: Attr) -> Attr.T? where Attr: ReadableAttr { + self[attr.key]?.rawValue.flatMap { attr.getter($0 as AnyObject) } + } + + public func containingWindowId() -> CGWindowID? { + let windowId = self["Aero.axWindowId"]?.rawValue ?? dieT() + if let windowId = windowId as? Int { + return UInt32.init(windowId) + } else { + return windowId as? UInt32 ?? dieT() + } + } +} diff --git a/Sources/AppBundleTests/assert.swift b/Sources/AppBundleTests/assert.swift index 967598b3..03037549 100644 --- a/Sources/AppBundleTests/assert.swift +++ b/Sources/AppBundleTests/assert.swift @@ -24,9 +24,9 @@ func assertNotNil(_ actual: Any?, file: String = #file, line: Int = #line) { } } -func assertEquals(_ actual: T, _ expected: T, file: String = #file, line: Int = #line) where T: Equatable { +func assertEquals(_ actual: T, _ expected: T, additionalMsg: String? = nil, file: String = #file, line: Int = #line) where T: Equatable { if actual != expected { - failExpectedActual(expected, actual, file: file, line: line) + failExpectedActual(expected, actual, additionalMsg: additionalMsg, file: file, line: line) } } @@ -49,11 +49,12 @@ func assertFail(_ actual: Result, _ expected: F? = nil, file: St } } -private func failExpectedActual(_ expected: Any, _ actual: Any, file: String = #file, line: Int = #line) { +private func failExpectedActual(_ expected: Any, _ actual: Any, additionalMsg: String? = nil, file: String = #file, line: Int = #line) { + let additionalMsg = additionalMsg.map { "\n Additional Message:\n \($0)" } ?? "" XCTFail( """ - \(file):\(line): Assertion failed + \(file):\(line): Assertion failed\(additionalMsg) Expected: \(expected) Actual: diff --git a/Sources/AppBundleTests/testUtil.swift b/Sources/AppBundleTests/testUtil.swift index 6c21347e..3560cf68 100644 --- a/Sources/AppBundleTests/testUtil.swift +++ b/Sources/AppBundleTests/testUtil.swift @@ -4,7 +4,7 @@ import Foundation import HotKey import XCTest -let projectRoot: URL = { +let projectRoot: URL = { // todo make it work in Xcode var url = URL(filePath: #file) while !FileManager.default.fileExists(atPath: url.appending(component: ".git").path) { url.deleteLastPathComponent() diff --git a/axDumps/alacritty_0_15_1_main_window.json5 b/axDumps/alacritty_0_15_1_main_window.json5 new file mode 100644 index 00000000..c4d06537 --- /dev/null +++ b/axDumps/alacritty_0_15_1_main_window.json5 @@ -0,0 +1,95 @@ +{ + "AXActivationPoint" : " {value = x:10.000000 y:58.000000 type = kAXValueCGPointType}", + "AXCancelButton" : null, + "AXCloseButton" : { + "AXEnabled" : 1, + "AXParent" : "AXUIElement(AxWindowId=197, title=\"ta ~\", role=\"AXWindow\", subrole=\"AXStandardWindow\")", + "AXRole" : "AXButton", + "AXSubrole" : "AXCloseButton", + "AXTitle" : null, + "AXTopLevelUIElement" : "AXUIElement(AxWindowId=197, title=\"ta ~\", role=\"AXWindow\", subrole=\"AXStandardWindow\")", + "AXWindow" : "AXUIElement(AxWindowId=197, title=\"ta ~\", role=\"AXWindow\", subrole=\"AXStandardWindow\")", + "Aero.AxIgnored" : "AXFrame, AXSize, AXFocused, AXHelp, AXPosition, AXRoleDescription, AXEdited" + }, + "AXDefaultButton" : null, + "AXDocument" : null, + "AXFocused" : 1, + "AXFrame" : " {value = x:0.000000 y:44.000000 w:1800.000000 h:1124.000000 type = kAXValueCGRectType}", + "AXFullScreen" : 0, + "AXFullScreenButton" : { + "AXEnabled" : 1, + "AXParent" : "AXUIElement(AxWindowId=197, title=\"ta ~\", role=\"AXWindow\", subrole=\"AXStandardWindow\")", + "AXRole" : "AXButton", + "AXSubrole" : "AXFullScreenButton", + "AXTitle" : null, + "AXTopLevelUIElement" : "AXUIElement(AxWindowId=197, title=\"ta ~\", role=\"AXWindow\", subrole=\"AXStandardWindow\")", + "AXWindow" : "AXUIElement(AxWindowId=197, title=\"ta ~\", role=\"AXWindow\", subrole=\"AXStandardWindow\")", + "Aero.AxIgnored" : "AXFrame, AXSize, AXFocused, AXChildren, AXHelp, AXPosition, AXRoleDescription" + }, + "AXGrowArea" : null, + "AXMain" : 1, + "AXMinimizeButton" : { + "AXEnabled" : 1, + "AXParent" : "AXUIElement(AxWindowId=197, title=\"ta ~\", role=\"AXWindow\", subrole=\"AXStandardWindow\")", + "AXRole" : "AXButton", + "AXSubrole" : "AXMinimizeButton", + "AXTitle" : null, + "AXTopLevelUIElement" : "AXUIElement(AxWindowId=197, title=\"ta ~\", role=\"AXWindow\", subrole=\"AXStandardWindow\")", + "AXWindow" : "AXUIElement(AxWindowId=197, title=\"ta ~\", role=\"AXWindow\", subrole=\"AXStandardWindow\")", + "Aero.AxIgnored" : "AXFrame, AXSize, AXFocused, AXHelp, AXPosition, AXRoleDescription" + }, + "AXMinimized" : 0, + "AXModal" : 0, + "AXParent" : " {pid=1835}", + "AXPosition" : " {value = x:0.000000 y:44.000000 type = kAXValueCGPointType}", + "AXProxy" : null, + "AXRole" : "AXWindow", + "AXSections" : [ + + ], + "AXSize" : " {value = w:1800.000000 h:1124.000000 type = kAXValueCGSizeType}", + "AXSubrole" : "AXStandardWindow", + "AXTitle" : "ta ~", + "AXTitleUIElement" : null, + "AXToolbarButton" : null, + "AXZoomButton" : { + "AXEnabled" : 1, + "AXParent" : "AXUIElement(AxWindowId=197, title=\"ta ~\", role=\"AXWindow\", subrole=\"AXStandardWindow\")", + "AXRole" : "AXButton", + "AXSubrole" : "AXFullScreenButton", + "AXTitle" : null, + "AXTopLevelUIElement" : "AXUIElement(AxWindowId=197, title=\"ta ~\", role=\"AXWindow\", subrole=\"AXStandardWindow\")", + "AXWindow" : "AXUIElement(AxWindowId=197, title=\"ta ~\", role=\"AXWindow\", subrole=\"AXStandardWindow\")", + "Aero.AxIgnored" : "AXFrame, AXSize, AXFocused, AXChildren, AXHelp, AXPosition, AXRoleDescription" + }, + "Aero.AXApp" : { + "AXExtrasMenuBar" : null, + "AXFocusedUIElement" : "AXUIElement(AxWindowId=197, title=\"ta ~\", role=\"AXWindow\", subrole=\"AXStandardWindow\")", + "AXFocusedWindow" : "AXUIElement(AxWindowId=197, title=\"ta ~\", role=\"AXWindow\", subrole=\"AXStandardWindow\")", + "AXFrame" : null, + "AXFrontmost" : 1, + "AXFunctionRowTopLevelElements" : [ + + ], + "AXMainWindow" : "AXUIElement(AxWindowId=197, title=\"ta ~\", role=\"AXWindow\", subrole=\"AXStandardWindow\")", + "AXMenuBar" : " {pid=1835}", + "AXPosition" : null, + "AXRole" : "AXApplication", + "AXSize" : null, + "AXTitle" : "Alacritty", + "AXWindows" : [ + "AXUIElement(AxWindowId=197, title=\"ta ~\", role=\"AXWindow\", subrole=\"AXStandardWindow\")" + ], + "Aero.AxIgnored" : "AXChildren, AXChildrenInNavigationOrder, AXEnhancedUserInterface, AXPreferredLanguage, AXRoleDescription, AXHidden" + }, + "Aero.App.appBundleId" : "org.alacritty", + "Aero.App.nsApp.activationPolicy" : "regular", + "Aero.App.version" : "1", + "Aero.App.versionShort" : "0.15.1", + "Aero.AxIgnored" : "AXChildrenInNavigationOrder, AXChildren, AXRoleDescription", + "Aero.axWindowId" : "197", + "Aero.isDialogHeuristic" : false, + "Aero.isWindowHeuristic" : true, + "Aero.treeNodeParent" : "AppBundle.TilingContainer", + "Aero.workspace" : "M" +} diff --git a/axDumps/choose_1_5_0.json5 b/axDumps/choose_1_5_0.json5 new file mode 100644 index 00000000..185e9871 --- /dev/null +++ b/axDumps/choose_1_5_0.json5 @@ -0,0 +1,60 @@ +{ + "AXActivationPoint" : " {value = x:510.000000 y:243.000000 type = kAXValueCGPointType}", + "AXCancelButton" : null, + "AXCloseButton" : null, + "AXDefaultButton" : null, + "AXDocument" : null, + "AXFocused" : 0, + "AXFrame" : " {value = x:500.000000 y:229.000000 w:800.000000 h:387.000000 type = kAXValueCGRectType}", + "AXFullScreen" : 0, + "AXFullScreenButton" : null, + "AXGrowArea" : null, + "AXMain" : 1, + "AXMinimizeButton" : null, + "AXMinimized" : 0, + "AXModal" : 0, + "AXParent" : " {pid=14046}", + "AXPosition" : " {value = x:500.000000 y:229.000000 type = kAXValueCGPointType}", + "AXProxy" : null, + "AXRole" : "AXWindow", + "AXSections" : [ + "{\n SectionDescription = Content;\n SectionObject = \" {pid=14046}\";\n SectionUniqueID = AXContent;\n}", + "{\n SectionObject = \" {pid=14046}\";\n SectionUniqueID = AXContainer;\n}" + ], + "AXSize" : " {value = w:800.000000 h:387.000000 type = kAXValueCGSizeType}", + "AXSubrole" : "AXStandardWindow", + "AXTitle" : "", + "AXTitleUIElement" : "AXUIElement(AxWindowId=836, title=nil, role=\"AXStaticText\", subrole=nil)", + "AXToolbarButton" : null, + "AXZoomButton" : null, + "Aero.AXApp" : { + "AXExtrasMenuBar" : null, + "AXFocusedUIElement" : "AXUIElement(AxWindowId=836, title=nil, role=\"AXTextField\", subrole=nil)", + "AXFocusedWindow" : "AXUIElement(AxWindowId=836, title=\"\", role=\"AXWindow\", subrole=\"AXStandardWindow\")", + "AXFrame" : null, + "AXFrontmost" : 1, + "AXFunctionRowTopLevelElements" : [ + + ], + "AXMainWindow" : "AXUIElement(AxWindowId=836, title=\"\", role=\"AXWindow\", subrole=\"AXStandardWindow\")", + "AXMenuBar" : " {pid=14046}", + "AXPosition" : null, + "AXRole" : "AXApplication", + "AXSize" : null, + "AXTitle" : "choose", + "AXWindows" : [ + "AXUIElement(AxWindowId=836, title=\"\", role=\"AXWindow\", subrole=\"AXStandardWindow\")" + ], + "Aero.AxIgnored" : "AXChildren, AXChildrenInNavigationOrder, AXEnhancedUserInterface, AXPreferredLanguage, AXRoleDescription, AXHidden" + }, + "Aero.App.appBundleId" : null, + "Aero.App.nsApp.activationPolicy" : "accessory", + "Aero.App.version" : null, // choose -v => 1.5.0 + "Aero.App.versionShort" : null, + "Aero.AxIgnored" : "AXChildrenInNavigationOrder, AXChildren, AXRoleDescription", + "Aero.axWindowId" : 836, + "Aero.isDialogHeuristic" : true, + "Aero.isWindowHeuristic" : true, + "Aero.treeNodeParent" : "AppBundle.Workspace", + "Aero.workspace" : "M" +}