From 2496820c4db3e87bc5d620ff5baf7e9d0fbac7ce Mon Sep 17 00:00:00 2001 From: Purav Manot Date: Wed, 10 Dec 2025 23:27:48 +0530 Subject: [PATCH 01/14] Refactor --- Sources/AccessibilityControl/Element.swift | 9 +++ Sources/WindowControl/Dock.swift | 8 +-- .../NSRunningApplication++.swift | 57 +++++++++++++++++++ 3 files changed, 70 insertions(+), 4 deletions(-) create mode 100644 Sources/WindowControl/NSRunningApplication++.swift diff --git a/Sources/AccessibilityControl/Element.swift b/Sources/AccessibilityControl/Element.swift index 6d84220..72db2cb 100644 --- a/Sources/AccessibilityControl/Element.swift +++ b/Sources/AccessibilityControl/Element.swift @@ -125,3 +125,12 @@ extension Accessibility.Element { return .init(raw: id) } } + + +// MARK: - Conformances + +extension Accessibility.Element: Equatable { + public static func == (lhs: Accessibility.Element, rhs: Accessibility.Element) -> Bool { + CFEqual(lhs.raw, rhs.raw) + } +} diff --git a/Sources/WindowControl/Dock.swift b/Sources/WindowControl/Dock.swift index 505a8b0..97b8c6a 100644 --- a/Sources/WindowControl/Dock.swift +++ b/Sources/WindowControl/Dock.swift @@ -11,10 +11,10 @@ public enum Dock { static let bundleID = "com.apple.dock" public static var pid: pid_t? { - self.getApp()?.processIdentifier + self.runningApplication()?.processIdentifier } - public static func getApp() -> NSRunningApplication? { + public static func runningApplication() -> NSRunningApplication? { NSRunningApplication.runningApplications(withBundleIdentifier: Dock.bundleID).first } @@ -29,7 +29,7 @@ public enum Dock { private func onDockTerminate() { debugLog("dock terminated") try? retry(withTimeout: 5, interval: 0.1) { - guard Dock.getApp() != nil else { throw ErrorMessage("Dock not running") } // we wait for a max of 5s for dock to relaunch + guard Dock.runningApplication() != nil else { throw ErrorMessage("Dock not running") } // we wait for a max of 5s for dock to relaunch self.onExit() try? self.observe() } @@ -37,7 +37,7 @@ public enum Dock { private func observe() throws { try retry(withTimeout: 5, interval: 0.1) { - guard Dock.getApp() != nil, let pid = Dock.pid else { throw ErrorMessage("Dock not running") } + guard Dock.runningApplication() != nil, let pid = Dock.pid else { throw ErrorMessage("Dock not running") } debugLog("observing dock exit with pid=\(pid)") try Process.monitorExit(pid: pid, self.onDockTerminate) } diff --git a/Sources/WindowControl/NSRunningApplication++.swift b/Sources/WindowControl/NSRunningApplication++.swift new file mode 100644 index 0000000..2271132 --- /dev/null +++ b/Sources/WindowControl/NSRunningApplication++.swift @@ -0,0 +1,57 @@ +import Foundation +import AppKit + +private let kBackgroundLaunchKey = "_kLSOpenOptionBackgroundLaunchKey" +private let kLaunchIsUserActionKey = "_kLSOpenOptionLaunchIsUserActionKey" + +extension NSWorkspace.OpenConfiguration { + private struct PrivateSelectors { + static let getAdditionalOptions = Selector(("_additionalLSOpenOptions")) + static let setAdditionalOptions = Selector(("_setAdditionalLSOpenOptions:")) + } + + private var additionalOptions: [String: Any] { + get { + guard responds(to: PrivateSelectors.getAdditionalOptions), + let result = perform(PrivateSelectors.getAdditionalOptions)?.takeUnretainedValue() as? [String: Any] else { + return [:] + } + return result + } + set { + guard responds(to: PrivateSelectors.setAdditionalOptions) else { return } + perform(PrivateSelectors.setAdditionalOptions, with: newValue) + } + } + + private func withAdditionalOptions(_ mutate: (inout [String: Any]) -> Void) { + var opts = additionalOptions + mutate(&opts) + additionalOptions = opts + } + + /// Wraps `_kLSOpenOptionBackgroundLaunchKey` + var launchesInBackground: Bool { + get { + (additionalOptions[kBackgroundLaunchKey] as? Bool) ?? false + } + set { + withAdditionalOptions { options in + options[kBackgroundLaunchKey] = newValue + } + } + } + + /// Wraps `_kLSOpenOptionLaunchIsUserActionKey` + var launchIsUserAction: Bool { + get { + (additionalOptions[kLaunchIsUserActionKey] as? Bool) ?? false + } + set { + withAdditionalOptions { options in + options[kLaunchIsUserActionKey] = newValue + } + } + } +} + From cf24ae9fca98cee91089fbb3c0169dd999829478 Mon Sep 17 00:00:00 2001 From: Purav Manot Date: Sat, 13 Dec 2025 01:34:47 +0530 Subject: [PATCH 02/14] make `Token` conform to Cancellable --- Sources/AccessibilityControl/Observer.swift | 33 ++++++++++++++++----- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/Sources/AccessibilityControl/Observer.swift b/Sources/AccessibilityControl/Observer.swift index 8c0a36e..f9068a1 100644 --- a/Sources/AccessibilityControl/Observer.swift +++ b/Sources/AccessibilityControl/Observer.swift @@ -1,4 +1,5 @@ import Foundation +import Combine import ApplicationServices private func observerCallback( @@ -24,12 +25,21 @@ extension Accessibility { } public final class Observer { - public final class Token { - private let remove: () -> Void + public final class Token: Cancellable { + private var removeAction: (() -> Void)? + fileprivate init(remove: @escaping () -> Void) { - self.remove = remove + self.removeAction = remove + } + + public func cancel() { + self.removeAction?() + self.removeAction = nil + } + + deinit { + cancel() } - deinit { remove() } } public typealias Callback = (_ info: [AnyHashable: Any]) -> Void @@ -38,7 +48,7 @@ extension Accessibility { // no need to retain the entire observer so long as the individual // tokens are retained - public init(pid: pid_t, on runLoop: RunLoop = .current) throws { + public init(pid: pid_t, on runLoop: RunLoop = .main) throws { var raw: AXObserver? try check(AXObserverCreateWithInfoCallback(pid, observerCallback, &raw)) guard let raw = raw else { @@ -80,15 +90,24 @@ extension Accessibility { } extension Accessibility.Element { - // the token must be retained public func observe( _ notification: Accessibility.Notification, - on runLoop: RunLoop = .current, + on runLoop: RunLoop = .main, callback: @escaping Accessibility.Observer.Callback ) throws -> Accessibility.Observer.Token { try Accessibility.Observer(pid: pid(), on: runLoop) .observe(notification, for: self, callback: callback) } + public func publisher( + for notification: Accessibility.Notification, + on runLoop: RunLoop = .main, + callback: @escaping Accessibility.Observer.Callback + ) throws -> AnyCancellable { + let token = try Accessibility.Observer(pid: pid(), on: runLoop) + .observe(notification, for: self, callback: callback) + + return AnyCancellable(token) + } } From 2b8aa018aa1761c75161a24bb0d9894eccf01854 Mon Sep 17 00:00:00 2001 From: Purav Manot Date: Sun, 21 Dec 2025 17:46:47 +0530 Subject: [PATCH 03/14] refactor --- Sources/AccessibilityControl/Accessibility.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/AccessibilityControl/Accessibility.swift b/Sources/AccessibilityControl/Accessibility.swift index 3f3fd73..670a285 100644 --- a/Sources/AccessibilityControl/Accessibility.swift +++ b/Sources/AccessibilityControl/Accessibility.swift @@ -105,7 +105,9 @@ public enum Accessibility { public typealias MutableAttributeName = MutableAttribute.Name public typealias ParameterizedAttributeName = ParameterizedAttribute.Name - init() {} + init() { + + } } public static func isTrusted(shouldPrompt: Bool = false) -> Bool { From 55ca837978923e2d83869fff7992f410b6329322 Mon Sep 17 00:00:00 2001 From: Purav Manot Date: Wed, 24 Dec 2025 21:26:24 +0530 Subject: [PATCH 04/14] expose accessibility window through `NSRunningApplication` --- .../NSRunningApplication+Accessibility.swift | 13 +++++++++++++ Sources/WindowControl/NSRunningApplication++.swift | 1 - 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 Sources/AccessibilityControl/NSRunningApplication+Accessibility.swift diff --git a/Sources/AccessibilityControl/NSRunningApplication+Accessibility.swift b/Sources/AccessibilityControl/NSRunningApplication+Accessibility.swift new file mode 100644 index 0000000..298a72a --- /dev/null +++ b/Sources/AccessibilityControl/NSRunningApplication+Accessibility.swift @@ -0,0 +1,13 @@ +import AppKit +import WindowControl +import Cocoa + +extension NSRunningApplication { + public var accessibilityElement: Accessibility.Element { + .init(pid: self.processIdentifier) + } + + public var accessibilityWindow: WindowControl.Window? { + try? self.accessibilityElement.window() + } +} diff --git a/Sources/WindowControl/NSRunningApplication++.swift b/Sources/WindowControl/NSRunningApplication++.swift index 2271132..d206b83 100644 --- a/Sources/WindowControl/NSRunningApplication++.swift +++ b/Sources/WindowControl/NSRunningApplication++.swift @@ -54,4 +54,3 @@ extension NSWorkspace.OpenConfiguration { } } } - From 1cdaeb2b804a5bf911affdacdb4b44e63f84b48e Mon Sep 17 00:00:00 2001 From: Purav Manot Date: Fri, 26 Dec 2025 00:11:44 +0530 Subject: [PATCH 05/14] fix access level --- .../NSRunningApplication+Accessibility.swift | 6 +++--- Sources/WindowControl/NSRunningApplication++.swift | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Sources/AccessibilityControl/NSRunningApplication+Accessibility.swift b/Sources/AccessibilityControl/NSRunningApplication+Accessibility.swift index 298a72a..295ba70 100644 --- a/Sources/AccessibilityControl/NSRunningApplication+Accessibility.swift +++ b/Sources/AccessibilityControl/NSRunningApplication+Accessibility.swift @@ -3,11 +3,11 @@ import WindowControl import Cocoa extension NSRunningApplication { - public var accessibilityElement: Accessibility.Element { + public var _accessibilityElement: Accessibility.Element { .init(pid: self.processIdentifier) } - public var accessibilityWindow: WindowControl.Window? { - try? self.accessibilityElement.window() + public var _accessibilityWindow: WindowControl.Window? { + try? self._accessibilityElement.window() } } diff --git a/Sources/WindowControl/NSRunningApplication++.swift b/Sources/WindowControl/NSRunningApplication++.swift index d206b83..9ec8353 100644 --- a/Sources/WindowControl/NSRunningApplication++.swift +++ b/Sources/WindowControl/NSRunningApplication++.swift @@ -31,7 +31,7 @@ extension NSWorkspace.OpenConfiguration { } /// Wraps `_kLSOpenOptionBackgroundLaunchKey` - var launchesInBackground: Bool { + public var launchesInBackground: Bool { get { (additionalOptions[kBackgroundLaunchKey] as? Bool) ?? false } @@ -43,7 +43,7 @@ extension NSWorkspace.OpenConfiguration { } /// Wraps `_kLSOpenOptionLaunchIsUserActionKey` - var launchIsUserAction: Bool { + public var launchIsUserAction: Bool { get { (additionalOptions[kLaunchIsUserActionKey] as? Bool) ?? false } From e6d053d25536e47369d2ba0e25ddce97b0538c68 Mon Sep 17 00:00:00 2001 From: Purav Manot Date: Sat, 27 Dec 2025 17:26:45 +0530 Subject: [PATCH 06/14] remove unnecessary `throws` --- Sources/WindowControl/Space.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/WindowControl/Space.swift b/Sources/WindowControl/Space.swift index e38da8a..8e1ae3a 100644 --- a/Sources/WindowControl/Space.swift +++ b/Sources/WindowControl/Space.swift @@ -93,7 +93,7 @@ public class Space: Hashable { destroyWhenDone: Bool = true, display: Display = .main, connection: GraphicsConnection = .main - ) throws { + ) { isUnknownKind = kind == .unknown var values: [String: Any] = [ // "wsid": 1234 as CFNumber, // Compat ID, can be used with SLSMoveWorkspaceWindowList(conn, {windowID}, 1, wsid) From e387f953335320f18f0e785cd3c2815cb4ae66a7 Mon Sep 17 00:00:00 2001 From: Purav Manot Date: Sun, 28 Dec 2025 01:43:58 +0530 Subject: [PATCH 07/14] cleanup --- .../NSRunningApplication++.swift | 56 ------------------- 1 file changed, 56 deletions(-) delete mode 100644 Sources/WindowControl/NSRunningApplication++.swift diff --git a/Sources/WindowControl/NSRunningApplication++.swift b/Sources/WindowControl/NSRunningApplication++.swift deleted file mode 100644 index 9ec8353..0000000 --- a/Sources/WindowControl/NSRunningApplication++.swift +++ /dev/null @@ -1,56 +0,0 @@ -import Foundation -import AppKit - -private let kBackgroundLaunchKey = "_kLSOpenOptionBackgroundLaunchKey" -private let kLaunchIsUserActionKey = "_kLSOpenOptionLaunchIsUserActionKey" - -extension NSWorkspace.OpenConfiguration { - private struct PrivateSelectors { - static let getAdditionalOptions = Selector(("_additionalLSOpenOptions")) - static let setAdditionalOptions = Selector(("_setAdditionalLSOpenOptions:")) - } - - private var additionalOptions: [String: Any] { - get { - guard responds(to: PrivateSelectors.getAdditionalOptions), - let result = perform(PrivateSelectors.getAdditionalOptions)?.takeUnretainedValue() as? [String: Any] else { - return [:] - } - return result - } - set { - guard responds(to: PrivateSelectors.setAdditionalOptions) else { return } - perform(PrivateSelectors.setAdditionalOptions, with: newValue) - } - } - - private func withAdditionalOptions(_ mutate: (inout [String: Any]) -> Void) { - var opts = additionalOptions - mutate(&opts) - additionalOptions = opts - } - - /// Wraps `_kLSOpenOptionBackgroundLaunchKey` - public var launchesInBackground: Bool { - get { - (additionalOptions[kBackgroundLaunchKey] as? Bool) ?? false - } - set { - withAdditionalOptions { options in - options[kBackgroundLaunchKey] = newValue - } - } - } - - /// Wraps `_kLSOpenOptionLaunchIsUserActionKey` - public var launchIsUserAction: Bool { - get { - (additionalOptions[kLaunchIsUserActionKey] as? Bool) ?? false - } - set { - withAdditionalOptions { options in - options[kLaunchIsUserActionKey] = newValue - } - } - } -} From 58e4746137fcfd608a8fff2ad492af94b7b334f6 Mon Sep 17 00:00:00 2001 From: Purav Manot Date: Wed, 21 Jan 2026 05:23:07 +0530 Subject: [PATCH 08/14] add CLI tool + cleanup --- .gitignore | 2 + Package.resolved | 16 + Package.swift | 14 + Sources/AccessibilityControl/Observer.swift | 35 +- Sources/axdump/main.swift | 1396 +++++++++++++++++++ 5 files changed, 1456 insertions(+), 7 deletions(-) create mode 100644 Package.resolved create mode 100644 Sources/axdump/main.swift diff --git a/.gitignore b/.gitignore index a7ac797..67a9e32 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ DerivedData/ # SwiftLint Remote Config Cache .swiftlint/RemoteConfigCache +.claude +MEGAREADME.md diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..e840c32 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,16 @@ +{ + "object": { + "pins": [ + { + "package": "swift-argument-parser", + "repositoryURL": "https://github.com/apple/swift-argument-parser.git", + "state": { + "branch": null, + "revision": "c5d11a805e765f52ba34ec7284bd4fcd6ba68615", + "version": "1.7.0" + } + } + ] + }, + "version": 1 +} diff --git a/Package.swift b/Package.swift index 23eada2..80940e7 100644 --- a/Package.swift +++ b/Package.swift @@ -10,6 +10,13 @@ let package = Package( name: "BetterSwiftAX", targets: ["AccessibilityControl"] ), + .executable( + name: "axdump", + targets: ["axdump"] + ), + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.2.0"), ], targets: [ .target( @@ -26,5 +33,12 @@ let package = Package( name: "AccessibilityControl", dependencies: ["CAccessibilityControl", "WindowControl"] ), + .target( + name: "axdump", + dependencies: [ + "AccessibilityControl", + .product(name: "ArgumentParser", package: "swift-argument-parser"), + ] + ), ] ) diff --git a/Sources/AccessibilityControl/Observer.swift b/Sources/AccessibilityControl/Observer.swift index f9068a1..8ad3acd 100644 --- a/Sources/AccessibilityControl/Observer.swift +++ b/Sources/AccessibilityControl/Observer.swift @@ -1,24 +1,29 @@ -import Foundation -import Combine +import AppKit import ApplicationServices +import Combine +import Foundation private func observerCallback( observer _: AXObserver, - element _: AXUIElement, + element: AXUIElement, notification _: CFString, info: CFDictionary, context: UnsafeMutableRawPointer? ) { guard let context = context else { return } + var dict = info as? [AnyHashable: Any] ?? [:] + // Include the element that triggered the notification + dict["AXUIElement"] = Accessibility.Element(raw: element) Unmanaged> .fromOpaque(context) .takeUnretainedValue() - .value(info as? [AnyHashable: Any] ?? [:]) + .value(dict) } extension Accessibility { public struct Notification: AccessibilityPhantomName { public let value: String + public init(_ value: String) { self.value = value } @@ -67,9 +72,19 @@ extension Accessibility { _ notification: Notification, for element: Element, callback: @escaping Callback + ) throws -> Token { + let notification = try NSAccessibility.Notification(from: notification) + + return try observe(notification, for: element, callback: callback) + } + + public func observe( + _ notification: NSAccessibility.Notification, + for element: Element, + callback: @escaping Callback ) throws -> Token { let callback = Box(callback) - let cfNotif = notification.value as CFString + let cfNotif = notification.rawValue as CFString try check( AXObserverAddNotification( raw, @@ -89,10 +104,16 @@ extension Accessibility { } } +extension NSAccessibility.Notification { + public init(from accessibilityNotification: Accessibility.Notification) { + self.init(rawValue: accessibilityNotification.value) + } +} + extension Accessibility.Element { // the token must be retained public func observe( - _ notification: Accessibility.Notification, + _ notification: NSAccessibility.Notification, on runLoop: RunLoop = .main, callback: @escaping Accessibility.Observer.Callback ) throws -> Accessibility.Observer.Token { @@ -101,7 +122,7 @@ extension Accessibility.Element { } public func publisher( - for notification: Accessibility.Notification, + for notification: NSAccessibility.Notification, on runLoop: RunLoop = .main, callback: @escaping Accessibility.Observer.Callback ) throws -> AnyCancellable { diff --git a/Sources/axdump/main.swift b/Sources/axdump/main.swift new file mode 100644 index 0000000..0425611 --- /dev/null +++ b/Sources/axdump/main.swift @@ -0,0 +1,1396 @@ +import Foundation +import AccessibilityControl +import AppKit +import ArgumentParser + +// MARK: - Attribute Fields + +struct AttributeFields: OptionSet { + let rawValue: Int + + static let role = AttributeFields(rawValue: 1 << 0) + static let roleDescription = AttributeFields(rawValue: 1 << 1) + static let title = AttributeFields(rawValue: 1 << 2) + static let identifier = AttributeFields(rawValue: 1 << 3) + static let value = AttributeFields(rawValue: 1 << 4) + static let description = AttributeFields(rawValue: 1 << 5) + static let enabled = AttributeFields(rawValue: 1 << 6) + static let focused = AttributeFields(rawValue: 1 << 7) + static let position = AttributeFields(rawValue: 1 << 8) + static let size = AttributeFields(rawValue: 1 << 9) + static let frame = AttributeFields(rawValue: 1 << 10) + static let help = AttributeFields(rawValue: 1 << 11) + static let subrole = AttributeFields(rawValue: 1 << 12) + + static let minimal: AttributeFields = [.role, .title, .identifier] + static let standard: AttributeFields = [.role, .roleDescription, .title, .identifier, .value, .description] + static let all: AttributeFields = [ + .role, .roleDescription, .title, .identifier, .value, + .description, .enabled, .focused, .position, .size, .frame, .help, .subrole + ] + + static func parse(_ string: String) -> AttributeFields { + var fields: AttributeFields = [] + for name in string.lowercased().split(separator: ",") { + switch name.trimmingCharacters(in: .whitespaces) { + case "role": fields.insert(.role) + case "roledescription", "role-description": fields.insert(.roleDescription) + case "title": fields.insert(.title) + case "identifier", "id": fields.insert(.identifier) + case "value": fields.insert(.value) + case "description", "desc": fields.insert(.description) + case "enabled": fields.insert(.enabled) + case "focused": fields.insert(.focused) + case "position", "pos": fields.insert(.position) + case "size": fields.insert(.size) + case "frame": fields.insert(.frame) + case "help": fields.insert(.help) + case "subrole": fields.insert(.subrole) + case "minimal": fields.formUnion(.minimal) + case "standard": fields.formUnion(.standard) + case "all": fields.formUnion(.all) + default: break + } + } + return fields.isEmpty ? .standard : fields + } +} + +// MARK: - Element Printer + +struct ElementPrinter { + let fields: AttributeFields + let verbosity: Int + + func formatElement(_ element: Accessibility.Element, indent: Int = 0) -> String { + let prefix = String(repeating: " ", count: indent) + var lines: [String] = [] + + var info: [String] = [] + + if fields.contains(.role) { + if let role: String = try? element.attribute(.init("AXRole"))() { + info.append("role=\(role)") + } + } + + if fields.contains(.subrole) { + if let subrole: String = try? element.attribute(.init("AXSubrole"))() { + info.append("subrole=\(subrole)") + } + } + + if fields.contains(.roleDescription) { + if let roleDesc: String = try? element.attribute(.init("AXRoleDescription"))() { + info.append("roleDesc=\"\(roleDesc)\"") + } + } + + if fields.contains(.title) { + if let title: String = try? element.attribute(.init("AXTitle"))() { + let truncated = title.count > 50 ? String(title.prefix(50)) + "..." : title + info.append("title=\"\(truncated)\"") + } + } + + if fields.contains(.identifier) { + if let id: String = try? element.attribute(.init("AXIdentifier"))() { + info.append("id=\"\(id)\"") + } + } + + if fields.contains(.description) { + if let desc: String = try? element.attribute(.init("AXDescription"))() { + let truncated = desc.count > 50 ? String(desc.prefix(50)) + "..." : desc + info.append("desc=\"\(truncated)\"") + } + } + + if fields.contains(.value) { + if let value: Any = try? element.attribute(.init("AXValue"))() { + let strValue = String(describing: value) + let truncated = strValue.count > 50 ? String(strValue.prefix(50)) + "..." : strValue + info.append("value=\"\(truncated)\"") + } + } + + if fields.contains(.enabled) { + if let enabled: Bool = try? element.attribute(.init("AXEnabled"))() { + info.append("enabled=\(enabled)") + } + } + + if fields.contains(.focused) { + if let focused: Bool = try? element.attribute(.init("AXFocused"))() { + info.append("focused=\(focused)") + } + } + + if fields.contains(.position) { + if let pos: CGPoint = try? element.attribute(.init("AXPosition"))() { + info.append("pos=(\(Int(pos.x)),\(Int(pos.y)))") + } + } + + if fields.contains(.size) { + if let size: CGSize = try? element.attribute(.init("AXSize"))() { + info.append("size=(\(Int(size.width))x\(Int(size.height)))") + } + } + + if fields.contains(.frame) { + if let frame: CGRect = try? element.attribute(.init("AXFrame"))() { + info.append("frame=(\(Int(frame.origin.x)),\(Int(frame.origin.y)) \(Int(frame.width))x\(Int(frame.height)))") + } + } + + if fields.contains(.help) { + if let help: String = try? element.attribute(.init("AXHelp"))() { + let truncated = help.count > 50 ? String(help.prefix(50)) + "..." : help + info.append("help=\"\(truncated)\"") + } + } + + let infoStr = info.isEmpty ? "(no attributes)" : info.joined(separator: " ") + lines.append("\(prefix)\(infoStr)") + + if verbosity >= 2 { + if let actions = try? element.supportedActions(), !actions.isEmpty { + let actionNames = actions.map { $0.name.value.replacingOccurrences(of: "AX", with: "") } + lines.append("\(prefix) actions: \(actionNames.joined(separator: ", "))") + } + } + + return lines.joined(separator: "\n") + } + + func printTree(_ element: Accessibility.Element, maxDepth: Int, currentDepth: Int = 0) { + print(formatElement(element, indent: currentDepth)) + + guard currentDepth < maxDepth else { return } + + let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = element.attribute(.init("AXChildren")) + guard let children: [Accessibility.Element] = try? childrenAttr() else { return } + + for child in children { + printTree(child, maxDepth: maxDepth, currentDepth: currentDepth + 1) + } + } +} + +// MARK: - Commands + +struct AXDump: ParsableCommand { + static var configuration = CommandConfiguration( + commandName: "axdump", + abstract: "Dump accessibility tree information for running applications", + discussion: """ + A command-line tool for exploring and debugging macOS accessibility trees. + Requires accessibility permissions (System Preferences > Security & Privacy > Privacy > Accessibility). + + EXAMPLES: + axdump list List running applications with PIDs + axdump dump 710 -d 2 Dump Finder's tree (2 levels deep) + axdump inspect 710 -p 0.0 Inspect first grandchild element + axdump observe 710 -n all -v Watch all notifications + + WORKFLOW: + 1. Use 'list' to find the PID of the target application + 2. Use 'dump' to explore the element hierarchy + 3. Use 'inspect' to read full attribute values or navigate to specific elements + 4. Use 'observe' to monitor real-time accessibility events + """, + subcommands: [List.self, Dump.self, Query.self, Inspect.self, Observe.self], + defaultSubcommand: List.self + ) +} + +extension AXDump { + struct List: ParsableCommand { + static var configuration = CommandConfiguration( + abstract: "List running applications with accessibility elements", + discussion: """ + Lists all running applications that can be inspected via accessibility APIs. + By default, only shows regular (foreground) applications. + + EXAMPLES: + axdump list List foreground apps with PIDs + axdump list -a Include background/menu bar apps + axdump list -v Show window count and app title + axdump list -av Verbose listing of all apps + """ + ) + + @Flag(name: .shortAndLong, help: "Show all applications (including background)") + var all: Bool = false + + @Flag(name: .shortAndLong, help: "Show detailed information") + var verbose: Bool = false + + func run() throws { + guard Accessibility.isTrusted(shouldPrompt: true) else { + print("Error: Accessibility permissions required") + print("Please grant permissions in System Preferences > Security & Privacy > Privacy > Accessibility") + throw ExitCode.failure + } + + let apps = NSWorkspace.shared.runningApplications + let filteredApps = all ? apps : apps.filter { $0.activationPolicy == .regular } + + let sortedApps = filteredApps.sorted { ($0.localizedName ?? "") < ($1.localizedName ?? "") } + + print("Running Applications:") + print(String(repeating: "-", count: 60)) + + for app in sortedApps { + let name = app.localizedName ?? "Unknown" + let pid = app.processIdentifier + let bundleID = app.bundleIdentifier ?? "N/A" + + if verbose { + print("\(String(format: "%6d", pid)) \(name)") + print(" Bundle: \(bundleID)") + + let element = Accessibility.Element(pid: pid) + let windowsAttr: Accessibility.Attribute<[Accessibility.Element]> = element.attribute(.init("AXWindows")) + if let windowCount = try? windowsAttr.count() { + print(" Windows: \(windowCount)") + } + if let title: String = try? element.attribute(.init("AXTitle"))() { + print(" Title: \(title)") + } + print() + } else { + print("\(String(format: "%6d", pid)) \(name) (\(bundleID))") + } + } + } + } +} + +extension AXDump { + struct Dump: ParsableCommand { + static var configuration = CommandConfiguration( + abstract: "Dump accessibility tree for an application", + discussion: """ + Recursively dumps the accessibility element hierarchy starting from the + application root or focused window. Output is indented to show nesting. + + FIELD PRESETS: + minimal - role, title, identifier + standard - role, roleDescription, title, identifier, value, description + all - all available fields + + INDIVIDUAL FIELDS: + role, subrole, roleDescription (or role-description), title, + identifier (or id), value, description (or desc), enabled, + focused, position (or pos), size, frame, help + + EXAMPLES: + axdump dump 710 Dump with default settings + axdump dump 710 -d 5 Dump 5 levels deep + axdump dump 710 -f minimal Only show role, title, id + axdump dump 710 -f role,title,value Custom field selection + axdump dump 710 -w Start from focused window + axdump dump 710 -v 2 Verbose (includes actions) + """ + ) + + @Argument(help: "Process ID of the application") + var pid: Int32 + + @Option(name: .shortAndLong, help: "Maximum depth to traverse (default: 3)") + var depth: Int = 3 + + @Option(name: .shortAndLong, help: "Verbosity level: 0=minimal, 1=normal, 2=detailed") + var verbosity: Int = 1 + + @Option(name: [.customShort("f"), .long], help: "Fields to display (comma-separated): role,title,identifier,value,description,enabled,focused,position,size,frame,help,subrole,roleDescription. Presets: minimal,standard,all") + var fields: String = "standard" + + @Flag(name: .shortAndLong, help: "Start from focused window instead of application root") + var window: Bool = false + + func run() throws { + guard Accessibility.isTrusted(shouldPrompt: true) else { + print("Error: Accessibility permissions required") + throw ExitCode.failure + } + + let appElement = Accessibility.Element(pid: pid) + + let rootElement: Accessibility.Element + if window { + guard let focusedWindow: Accessibility.Element = try? appElement.attribute(.init("AXFocusedWindow"))() else { + print("Error: Could not get focused window for PID \(pid)") + throw ExitCode.failure + } + rootElement = focusedWindow + } else { + rootElement = appElement + } + + let attributeFields = AttributeFields.parse(fields) + let printer = ElementPrinter(fields: attributeFields, verbosity: verbosity) + + if let appName: String = try? appElement.attribute(.init("AXTitle"))() { + print("Accessibility Tree for: \(appName) (PID: \(pid))") + } else { + print("Accessibility Tree for PID: \(pid)") + } + print(String(repeating: "=", count: 60)) + + printer.printTree(rootElement, maxDepth: depth) + } + } +} + +extension AXDump { + struct Query: ParsableCommand { + static var configuration = CommandConfiguration( + abstract: "Query specific element relationships", + discussion: """ + Query relationships between accessibility elements like parent, children, + siblings, or list all attributes of an element. + + RELATIONS: + children - Direct child elements + parent - Parent element + siblings - Sibling elements (same parent) + windows - Application windows + focused - Focused window and UI element + all-attributes - All attributes with truncated values (aliases: attrs, attributes) + + EXAMPLES: + axdump query 710 -r windows List all windows + axdump query 710 -r children Show app's direct children + axdump query 710 -r children -F Children of focused element + axdump query 710 -r siblings -F Siblings of focused element + axdump query 710 -r all-attributes List all attributes (truncated) + axdump query 710 -r focused Show focused window and element + """ + ) + + @Argument(help: "Process ID of the application") + var pid: Int32 + + @Option(name: [.customShort("r"), .long], help: "Relationship to query: children, parent, siblings, windows, focused, all-attributes") + var relation: String = "children" + + @Option(name: [.customShort("f"), .long], help: "Fields to display") + var fields: String = "standard" + + @Option(name: .shortAndLong, help: "Verbosity level") + var verbosity: Int = 1 + + @Flag(name: [.customShort("F"), .long], help: "Query from focused element instead of application root") + var focused: Bool = false + + func run() throws { + guard Accessibility.isTrusted(shouldPrompt: true) else { + print("Error: Accessibility permissions required") + throw ExitCode.failure + } + + let appElement = Accessibility.Element(pid: pid) + + let targetElement: Accessibility.Element + if focused { + guard let focusedElement: Accessibility.Element = try? appElement.attribute(.init("AXFocusedUIElement"))() else { + print("Error: Could not get focused element for PID \(pid)") + throw ExitCode.failure + } + targetElement = focusedElement + } else { + targetElement = appElement + } + + let attributeFields = AttributeFields.parse(fields) + let printer = ElementPrinter(fields: attributeFields, verbosity: verbosity) + + switch relation.lowercased() { + case "children": + queryChildren(of: targetElement, printer: printer) + + case "parent": + queryParent(of: targetElement, printer: printer) + + case "siblings": + querySiblings(of: targetElement, printer: printer) + + case "windows": + queryWindows(of: appElement, printer: printer) + + case "focused": + queryFocused(of: appElement, printer: printer) + + case "all-attributes", "attrs", "attributes": + queryAllAttributes(of: targetElement) + + default: + print("Unknown relation: \(relation)") + print("Valid options: children, parent, siblings, windows, focused, all-attributes") + throw ExitCode.failure + } + } + + private func queryChildren(of element: Accessibility.Element, printer: ElementPrinter) { + print("Children:") + print(String(repeating: "-", count: 40)) + + let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = element.attribute(.init("AXChildren")) + guard let children: [Accessibility.Element] = try? childrenAttr() else { + print("(no children or unable to read)") + return + } + + print("Count: \(children.count)") + print() + + for (index, child) in children.enumerated() { + print("[\(index)] \(printer.formatElement(child))") + } + } + + private func queryParent(of element: Accessibility.Element, printer: ElementPrinter) { + print("Parent:") + print(String(repeating: "-", count: 40)) + + guard let parent: Accessibility.Element = try? element.attribute(.init("AXParent"))() else { + print("(no parent or unable to read)") + return + } + + print(printer.formatElement(parent)) + } + + private func querySiblings(of element: Accessibility.Element, printer: ElementPrinter) { + print("Siblings:") + print(String(repeating: "-", count: 40)) + + guard let parent: Accessibility.Element = try? element.attribute(.init("AXParent"))() else { + print("(no parent - cannot determine siblings)") + return + } + + let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = parent.attribute(.init("AXChildren")) + guard let siblings: [Accessibility.Element] = try? childrenAttr() else { + print("(unable to read parent's children)") + return + } + + let filteredSiblings = siblings.filter { $0 != element } + print("Count: \(filteredSiblings.count)") + print() + + for (index, sibling) in filteredSiblings.enumerated() { + print("[\(index)] \(printer.formatElement(sibling))") + } + } + + private func queryWindows(of element: Accessibility.Element, printer: ElementPrinter) { + print("Windows:") + print(String(repeating: "-", count: 40)) + + let windowsAttr: Accessibility.Attribute<[Accessibility.Element]> = element.attribute(.init("AXWindows")) + guard let windows: [Accessibility.Element] = try? windowsAttr() else { + print("(no windows or unable to read)") + return + } + + print("Count: \(windows.count)") + print() + + for (index, window) in windows.enumerated() { + print("[\(index)] \(printer.formatElement(window))") + } + } + + private func queryFocused(of element: Accessibility.Element, printer: ElementPrinter) { + print("Focused Elements:") + print(String(repeating: "-", count: 40)) + + if let focusedWindow: Accessibility.Element = try? element.attribute(.init("AXFocusedWindow"))() { + print("Focused Window:") + print(" \(printer.formatElement(focusedWindow))") + print() + } + + if let focusedElement: Accessibility.Element = try? element.attribute(.init("AXFocusedUIElement"))() { + print("Focused UI Element:") + print(" \(printer.formatElement(focusedElement))") + } + } + + private func queryAllAttributes(of element: Accessibility.Element) { + print("All Attributes:") + print(String(repeating: "-", count: 40)) + + guard let attributes = try? element.supportedAttributes() else { + print("(unable to read attributes)") + return + } + + for attr in attributes.sorted(by: { $0.name.value < $1.name.value }) { + let name = attr.name.value + if let value: Any = try? attr() { + let strValue = String(describing: value) + let truncated = strValue.count > 80 ? String(strValue.prefix(80)) + "..." : strValue + print("\(name): \(truncated)") + } else { + print("\(name): (unable to read)") + } + } + + print() + print("Parameterized Attributes:") + print(String(repeating: "-", count: 40)) + + if let paramAttrs = try? element.supportedParameterizedAttributes() { + for attr in paramAttrs.sorted(by: { $0.name.value < $1.name.value }) { + print(attr.name.value) + } + } + + print() + print("Actions:") + print(String(repeating: "-", count: 40)) + + if let actions = try? element.supportedActions() { + for action in actions.sorted(by: { $0.name.value < $1.name.value }) { + print("\(action.name.value): \(action.description)") + } + } + } + } +} + +extension AXDump { + struct Inspect: ParsableCommand { + static var configuration = CommandConfiguration( + abstract: "Inspect specific attributes or elements in full detail", + discussion: """ + Read attribute values in full (without truncation) and navigate to specific + elements in the hierarchy using child indices. + + NAVIGATION: + Use -c (--child) for single-level navigation or -p (--path) for multi-level. + Path format: dot-separated indices, e.g., "0.3.1" means: + - First child of root (index 0) + - Fourth child of that (index 3) + - Second child of that (index 1) + + ATTRIBUTES: + Use -a to specify attributes to read. Can omit 'AX' prefix. + Use -a list to see all available attributes for an element. + + EXAMPLES: + axdump inspect 710 Show all attributes (full values) + axdump inspect 710 -a list List available attributes + axdump inspect 710 -a AXValue Read AXValue in full + axdump inspect 710 -a Value,Title Read multiple (AX prefix optional) + axdump inspect 710 -c 0 Inspect first child + axdump inspect 710 -p 0.2.1 Navigate to nested element + axdump inspect 710 -w -a AXChildren From focused window + axdump inspect 710 -F -p 0 First child of focused element + axdump inspect 710 -j Output as JSON + axdump inspect 710 -l 500 Truncate values at 500 chars + """ + ) + + @Argument(help: "Process ID of the application") + var pid: Int32 + + @Option(name: [.customShort("p"), .long], help: "Path to element as dot-separated child indices (e.g., '0.3.1' for first child, then 4th child, then 2nd child)") + var path: String? + + @Option(name: [.customShort("a"), .long], help: "Specific attribute(s) to read in full (comma-separated, e.g., 'AXValue,AXTitle'). Use 'list' to show available attributes.") + var attributes: String? + + @Option(name: [.customShort("c"), .long], help: "Index of child element to inspect (shorthand for --path)") + var child: Int? + + @Flag(name: [.customShort("F"), .long], help: "Start from focused element") + var focused: Bool = false + + @Flag(name: .shortAndLong, help: "Start from focused window") + var window: Bool = false + + @Option(name: [.customShort("l"), .long], help: "Maximum output length per attribute (0 for unlimited)") + var maxLength: Int = 0 + + @Flag(name: [.customShort("j"), .long], help: "Output as JSON") + var json: Bool = false + + func run() throws { + guard Accessibility.isTrusted(shouldPrompt: true) else { + print("Error: Accessibility permissions required") + throw ExitCode.failure + } + + let appElement = Accessibility.Element(pid: pid) + + // Determine starting element + var targetElement: Accessibility.Element = appElement + + if focused { + guard let focusedElement: Accessibility.Element = try? appElement.attribute(.init("AXFocusedUIElement"))() else { + print("Error: Could not get focused element for PID \(pid)") + throw ExitCode.failure + } + targetElement = focusedElement + } else if window { + guard let focusedWindow: Accessibility.Element = try? appElement.attribute(.init("AXFocusedWindow"))() else { + print("Error: Could not get focused window for PID \(pid)") + throw ExitCode.failure + } + targetElement = focusedWindow + } + + // Navigate to child if specified + if let childIndex = child { + targetElement = try navigateToChild(from: targetElement, index: childIndex) + } + + // Navigate via path if specified + if let pathString = path { + targetElement = try navigateToPath(from: targetElement, path: pathString) + } + + // Show element info + printElementHeader(targetElement) + + // Handle attribute inspection + if let attrString = attributes { + if attrString.lowercased() == "list" { + listAttributes(of: targetElement) + } else { + let attrNames = attrString.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) } + inspectAttributes(of: targetElement, names: attrNames) + } + } else { + // Default: show all attributes with full values + inspectAllAttributes(of: targetElement) + } + } + + private func navigateToChild(from element: Accessibility.Element, index: Int) throws -> Accessibility.Element { + let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = element.attribute(.init("AXChildren")) + guard let children: [Accessibility.Element] = try? childrenAttr() else { + throw ValidationError("Element has no children") + } + guard index >= 0 && index < children.count else { + throw ValidationError("Child index \(index) out of range (0..<\(children.count))") + } + return children[index] + } + + private func navigateToPath(from element: Accessibility.Element, path: String) throws -> Accessibility.Element { + var current = element + let indices = path.split(separator: ".").compactMap { Int($0) } + + for (step, index) in indices.enumerated() { + let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = current.attribute(.init("AXChildren")) + guard let children: [Accessibility.Element] = try? childrenAttr() else { + throw ValidationError("Element at step \(step) has no children") + } + guard index >= 0 && index < children.count else { + throw ValidationError("Child index \(index) at step \(step) out of range (0..<\(children.count))") + } + current = children[index] + } + + return current + } + + private func printElementHeader(_ element: Accessibility.Element) { + print("Element Info:") + print(String(repeating: "=", count: 60)) + + if let role: String = try? element.attribute(.init("AXRole"))() { + print("Role: \(role)") + } + if let title: String = try? element.attribute(.init("AXTitle"))() { + print("Title: \(title)") + } + if let id: String = try? element.attribute(.init("AXIdentifier"))() { + print("Identifier: \(id)") + } + + // Show child count for navigation hints + let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = element.attribute(.init("AXChildren")) + if let count = try? childrenAttr.count() { + print("Children: \(count)") + } + + print(String(repeating: "-", count: 60)) + print() + } + + private func listAttributes(of element: Accessibility.Element) { + print("Available Attributes:") + print(String(repeating: "-", count: 40)) + + guard let attributes = try? element.supportedAttributes() else { + print("(unable to read attributes)") + return + } + + for attr in attributes.sorted(by: { $0.name.value < $1.name.value }) { + let name = attr.name.value + let settable = (try? attr.isSettable()) ?? false + let settableStr = settable ? " [settable]" : "" + print(" \(name)\(settableStr)") + } + + print() + print("Parameterized Attributes:") + print(String(repeating: "-", count: 40)) + + if let paramAttrs = try? element.supportedParameterizedAttributes() { + for attr in paramAttrs.sorted(by: { $0.name.value < $1.name.value }) { + print(" \(attr.name.value)") + } + } + } + + private func inspectAttributes(of element: Accessibility.Element, names: [String]) { + if json { + var result: [String: Any] = [:] + for name in names { + let attrName = name.hasPrefix("AX") ? name : "AX\(name)" + if let value: Any = try? element.attribute(.init(attrName))() { + result[attrName] = formatValueForJSON(value) + } else { + result[attrName] = NSNull() + } + } + if let jsonData = try? JSONSerialization.data(withJSONObject: result, options: [.prettyPrinted, .sortedKeys]), + let jsonString = String(data: jsonData, encoding: .utf8) { + print(jsonString) + } + return + } + + for name in names { + let attrName = name.hasPrefix("AX") ? name : "AX\(name)" + print("\(attrName):") + print(String(repeating: "-", count: 40)) + + if let value: Any = try? element.attribute(.init(attrName))() { + let strValue = formatValue(value) + if maxLength > 0 && strValue.count > maxLength { + print(String(strValue.prefix(maxLength))) + print("... (truncated, total length: \(strValue.count))") + } else { + print(strValue) + } + } else { + print("(unable to read or no value)") + } + print() + } + } + + private func inspectAllAttributes(of element: Accessibility.Element) { + guard let attributes = try? element.supportedAttributes() else { + print("(unable to read attributes)") + return + } + + if json { + var result: [String: Any] = [:] + for attr in attributes { + if let value: Any = try? attr() { + result[attr.name.value] = formatValueForJSON(value) + } + } + if let jsonData = try? JSONSerialization.data(withJSONObject: result, options: [.prettyPrinted, .sortedKeys]), + let jsonString = String(data: jsonData, encoding: .utf8) { + print(jsonString) + } + return + } + + print("All Attributes (full values):") + print(String(repeating: "-", count: 40)) + + for attr in attributes.sorted(by: { $0.name.value < $1.name.value }) { + let name = attr.name.value + + if let value: Any = try? attr() { + let strValue = formatValue(value) + if maxLength > 0 && strValue.count > maxLength { + print("\(name): \(String(strValue.prefix(maxLength)))... (truncated)") + } else if strValue.contains("\n") || strValue.count > 80 { + print("\(name):") + print(strValue.split(separator: "\n", omittingEmptySubsequences: false) + .map { " \($0)" } + .joined(separator: "\n")) + } else { + print("\(name): \(strValue)") + } + } else { + print("\(name): (unable to read)") + } + } + } + + private func formatValue(_ value: Any) -> String { + switch value { + case let element as Accessibility.Element: + var parts: [String] = ["") + return parts.joined(separator: " ") + + case let elements as [Accessibility.Element]: + var lines: [String] = ["[\(elements.count) elements]"] + for (index, element) in elements.enumerated() { + var parts: [String] = [" [\(index)]"] + if let role: String = try? element.attribute(.init("AXRole"))() { + parts.append("role=\(role)") + } + if let title: String = try? element.attribute(.init("AXTitle"))() { + parts.append("title=\"\(title)\"") + } + if let id: String = try? element.attribute(.init("AXIdentifier"))() { + parts.append("id=\"\(id)\"") + } + lines.append(parts.joined(separator: " ")) + } + return lines.joined(separator: "\n") + + case let structValue as Accessibility.Struct: + switch structValue { + case .point(let point): + return "(\(point.x), \(point.y))" + case .size(let size): + return "\(size.width) x \(size.height)" + case .rect(let rect): + return "origin=(\(rect.origin.x), \(rect.origin.y)) size=(\(rect.width) x \(rect.height))" + case .range(let range): + return "\(range.lowerBound)..<\(range.upperBound)" + case .error(let error): + return "Error: \(error)" + } + + case let point as CGPoint: + return "(\(point.x), \(point.y))" + + case let size as CGSize: + return "\(size.width) x \(size.height)" + + case let rect as CGRect: + return "origin=(\(rect.origin.x), \(rect.origin.y)) size=(\(rect.width) x \(rect.height))" + + case let array as [Any]: + return array.map { formatValue($0) }.joined(separator: ", ") + + case let dict as [String: Any]: + return dict.map { "\($0.key): \(formatValue($0.value))" }.joined(separator: ", ") + + default: + return String(describing: value) + } + } + + private func formatValueForJSON(_ value: Any) -> Any { + switch value { + case let element as Accessibility.Element: + var dict: [String: Any] = ["_type": "element"] + if let role: String = try? element.attribute(.init("AXRole"))() { + dict["role"] = role + } + if let title: String = try? element.attribute(.init("AXTitle"))() { + dict["title"] = title + } + if let id: String = try? element.attribute(.init("AXIdentifier"))() { + dict["identifier"] = id + } + return dict + + case let elements as [Accessibility.Element]: + return elements.map { formatValueForJSON($0) } + + case let structValue as Accessibility.Struct: + switch structValue { + case .point(let point): + return ["x": point.x, "y": point.y] + case .size(let size): + return ["width": size.width, "height": size.height] + case .rect(let rect): + return ["x": rect.origin.x, "y": rect.origin.y, "width": rect.width, "height": rect.height] + case .range(let range): + return ["start": range.lowerBound, "end": range.upperBound] + case .error(let error): + return ["error": String(describing: error)] + } + + case let point as CGPoint: + return ["x": point.x, "y": point.y] + + case let size as CGSize: + return ["width": size.width, "height": size.height] + + case let rect as CGRect: + return [ + "x": rect.origin.x, + "y": rect.origin.y, + "width": rect.width, + "height": rect.height + ] + + case let array as [Any]: + return array.map { formatValueForJSON($0) } + + case let dict as [String: Any]: + return dict.mapValues { formatValueForJSON($0) } + + case let str as String: + return str + + case let num as NSNumber: + return num + + case let bool as Bool: + return bool + + default: + return String(describing: value) + } + } + } +} + +extension AXDump { + struct Observe: ParsableCommand { + static var configuration = CommandConfiguration( + abstract: "Observe accessibility notifications for an application", + discussion: """ + Monitor accessibility notifications in real-time. Each notification is printed + with a timestamp. Press Ctrl+C to stop observing. + + COMMON NOTIFICATIONS: + AXValueChanged - Element value changed + AXFocusedUIElementChanged - Focus moved to different element + AXFocusedWindowChanged - Different window got focus + AXSelectedTextChanged - Text selection changed + AXSelectedChildrenChanged - Child selection changed + AXWindowCreated/Moved/Resized - Window events + AXMenuOpened/Closed - Menu events + AXApplicationActivated - App became frontmost + + Use -n list to see all common notifications. + + EXAMPLES: + axdump observe 710 Observe focus changes (default) + axdump observe 710 -n list List available notifications + axdump observe 710 -n AXValueChanged Observe value changes + axdump observe 710 -n ValueChanged,Focused Multiple (AX prefix optional) + axdump observe 710 -n all Observe all notifications + axdump observe 710 -n all -v Verbose (show element details) + axdump observe 710 -w -n AXWindowMoved Observe from focused window + axdump observe 710 -n all -j JSON output + """ + ) + + @Argument(help: "Process ID of the application") + var pid: Int32 + + @Option(name: [.customShort("n"), .long], help: "Notification(s) to observe (comma-separated, e.g., 'AXValueChanged,AXFocusedUIElementChanged'). Use 'list' to show common notifications, 'all' to observe all available.") + var notifications: String = "AXFocusedUIElementChanged" + + @Option(name: [.customShort("p"), .long], help: "Path to element to observe (dot-separated child indices)") + var path: String? + + @Flag(name: [.customShort("F"), .long], help: "Observe focused element") + var focused: Bool = false + + @Flag(name: .shortAndLong, help: "Observe focused window") + var window: Bool = false + + @Flag(name: [.customShort("j"), .long], help: "Output as JSON") + var json: Bool = false + + @Flag(name: [.customShort("v"), .long], help: "Verbose output (show element details)") + var verbose: Bool = false + + @Flag(name: .long, help: "Disable colored output") + var noColor: Bool = false + + // ANSI color codes + private enum Color: String { + case reset = "\u{001B}[0m" + case dim = "\u{001B}[2m" + case bold = "\u{001B}[1m" + case red = "\u{001B}[31m" + case green = "\u{001B}[32m" + case yellow = "\u{001B}[33m" + case blue = "\u{001B}[34m" + case magenta = "\u{001B}[35m" + case cyan = "\u{001B}[36m" + case white = "\u{001B}[37m" + case brightRed = "\u{001B}[91m" + case brightGreen = "\u{001B}[92m" + case brightYellow = "\u{001B}[93m" + case brightBlue = "\u{001B}[94m" + case brightMagenta = "\u{001B}[95m" + case brightCyan = "\u{001B}[96m" + } + + private func colorForNotification(_ name: String) -> Color { + switch name { + case "AXValueChanged", "AXSelectedTextChanged": + return .green + case "AXFocusedUIElementChanged", "AXFocusedWindowChanged": + return .cyan + case "AXLayoutChanged", "AXResized", "AXMoved": + return .yellow + case "AXWindowCreated", "AXWindowMoved", "AXWindowResized": + return .blue + case "AXApplicationActivated", "AXApplicationDeactivated": + return .magenta + case "AXMenuOpened", "AXMenuClosed", "AXMenuItemSelected": + return .brightMagenta + case "AXUIElementDestroyed": + return .red + case "AXCreated": + return .brightGreen + case "AXTitleChanged": + return .brightCyan + default: + return .white + } + } + + // Common notifications + static let commonNotifications = [ + "AXValueChanged", + "AXUIElementDestroyed", + "AXSelectedTextChanged", + "AXSelectedChildrenChanged", + "AXFocusedUIElementChanged", + "AXFocusedWindowChanged", + "AXApplicationActivated", + "AXApplicationDeactivated", + "AXWindowCreated", + "AXWindowMoved", + "AXWindowResized", + "AXWindowMiniaturized", + "AXWindowDeminiaturized", + "AXDrawerCreated", + "AXSheetCreated", + "AXMenuOpened", + "AXMenuClosed", + "AXMenuItemSelected", + "AXTitleChanged", + "AXResized", + "AXMoved", + "AXCreated", + "AXLayoutChanged", + "AXSelectedCellsChanged", + "AXUnitsChanged", + "AXSelectedColumnsChanged", + "AXSelectedRowsChanged", + "AXRowCountChanged", + "AXRowExpanded", + "AXRowCollapsed", + ] + + func run() throws { + guard Accessibility.isTrusted(shouldPrompt: true) else { + print("Error: Accessibility permissions required") + throw ExitCode.failure + } + + // Handle 'list' option + if notifications.lowercased() == "list" { + print("Common Accessibility Notifications:") + print(String(repeating: "-", count: 40)) + for notification in Self.commonNotifications { + print(" \(notification)") + } + return + } + + let appElement = Accessibility.Element(pid: pid) + + // Determine target element + var targetElement: Accessibility.Element = appElement + + if focused { + guard let focusedElement: Accessibility.Element = try? appElement.attribute(.init("AXFocusedUIElement"))() else { + print("Error: Could not get focused element for PID \(pid)") + throw ExitCode.failure + } + targetElement = focusedElement + } else if window { + guard let focusedWindow: Accessibility.Element = try? appElement.attribute(.init("AXFocusedWindow"))() else { + print("Error: Could not get focused window for PID \(pid)") + throw ExitCode.failure + } + targetElement = focusedWindow + } + + // Navigate via path if specified + if let pathString = path { + targetElement = try navigateToPath(from: targetElement, path: pathString) + } + + // Print element info + printElementInfo(targetElement) + + // Determine which notifications to observe + let notificationNames: [String] + if notifications.lowercased() == "all" { + notificationNames = Self.commonNotifications + } else { + notificationNames = notifications.split(separator: ",") + .map { String($0).trimmingCharacters(in: .whitespaces) } + .map { $0.hasPrefix("AX") ? $0 : "AX\($0)" } + } + + print("Observing notifications: \(notificationNames.joined(separator: ", "))") + print("Press Ctrl+C to stop") + print(String(repeating: "=", count: 60)) + print() + + // Create observer + let observer = try Accessibility.Observer(pid: pid, on: .main) + + // Store tokens to keep observations alive + var tokens: [Accessibility.Observer.Token] = [] + + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "HH:mm:ss.SSS" + + for notificationName in notificationNames { + do { + let token = try observer.observe( + .init(notificationName), + for: targetElement + ) { [self] info in + let timestamp = dateFormatter.string(from: Date()) + + if json { + var output: [String: Any] = [ + "timestamp": timestamp, + "notification": notificationName + ] + + if let element = info["AXUIElement"] as? Accessibility.Element { + output["element"] = formatElementForJSON(element) + let pathInfo = computeElementPath(element, appElement: appElement) + output["path"] = pathInfo.path + output["chain"] = pathInfo.chain + } + + if let jsonData = try? JSONSerialization.data(withJSONObject: output, options: [.sortedKeys]), + let jsonString = String(data: jsonData, encoding: .utf8) { + print(jsonString) + } + } else { + let useColor = !noColor + let c = { (color: Color) -> String in useColor ? color.rawValue : "" } + let notifColor = colorForNotification(notificationName) + + var line = "\(c(.dim))[\(timestamp)]\(c(.reset)) " + line += "\(c(notifColor))\(notificationName)\(c(.reset))" + + if let element = info["AXUIElement"] as? Accessibility.Element { + let pathInfo = computeElementPath(element, appElement: appElement) + line += " \(c(.dim))@\(c(.reset)) \(c(.blue))\(pathInfo.path)\(c(.reset))" + if verbose { + line += "\n \(c(.dim))chain:\(c(.reset)) \(c(.magenta))\(pathInfo.chain)\(c(.reset))" + line += "\n \(c(.dim))element:\(c(.reset)) \(formatElementColored(element, useColor: useColor))" + } + } else { + line += " \(c(.dim))(no element)\(c(.reset))" + } + + print(line) + } + + // Flush output immediately + fflush(stdout) + } + tokens.append(token) + } catch { + if verbose { + print("Warning: Could not observe \(notificationName): \(error)") + } + } + } + + if tokens.isEmpty { + print("Error: Could not register for any notifications") + throw ExitCode.failure + } + + print("Successfully registered for \(tokens.count) notification(s)") + print() + + // Keep running + RunLoop.main.run() + } + + private func navigateToPath(from element: Accessibility.Element, path: String) throws -> Accessibility.Element { + var current = element + let indices = path.split(separator: ".").compactMap { Int($0) } + + for (step, index) in indices.enumerated() { + let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = current.attribute(.init("AXChildren")) + guard let children: [Accessibility.Element] = try? childrenAttr() else { + throw ValidationError("Element at step \(step) has no children") + } + guard index >= 0 && index < children.count else { + throw ValidationError("Child index \(index) at step \(step) out of range (0..<\(children.count))") + } + current = children[index] + } + + return current + } + + private func printElementInfo(_ element: Accessibility.Element) { + print("Observing Element:") + print(String(repeating: "-", count: 40)) + + if let role: String = try? element.attribute(.init("AXRole"))() { + print("Role: \(role)") + } + if let title: String = try? element.attribute(.init("AXTitle"))() { + print("Title: \(title)") + } + if let id: String = try? element.attribute(.init("AXIdentifier"))() { + print("Identifier: \(id)") + } + + print() + } + + private func formatElement(_ element: Accessibility.Element) -> String { + formatElementColored(element, useColor: false) + } + + private func formatElementColored(_ element: Accessibility.Element, useColor: Bool) -> String { + let c = { (color: Color) -> String in useColor ? color.rawValue : "" } + var parts: [String] = [] + + if let role: String = try? element.attribute(.init("AXRole"))() { + parts.append("\(c(.cyan))role\(c(.reset))=\(c(.white))\(role)\(c(.reset))") + } + if let title: String = try? element.attribute(.init("AXTitle"))() { + let truncated = title.count > 30 ? String(title.prefix(30)) + "..." : title + parts.append("\(c(.yellow))title\(c(.reset))=\"\(c(.white))\(truncated)\(c(.reset))\"") + } + if let id: String = try? element.attribute(.init("AXIdentifier"))() { + parts.append("\(c(.green))id\(c(.reset))=\"\(c(.white))\(id)\(c(.reset))\"") + } + if let value: Any = try? element.attribute(.init("AXValue"))() { + let strValue = String(describing: value) + let truncated = strValue.count > 30 ? String(strValue.prefix(30)) + "..." : strValue + parts.append("\(c(.magenta))value\(c(.reset))=\"\(c(.white))\(truncated)\(c(.reset))\"") + } + + return parts.isEmpty ? "(element)" : parts.joined(separator: " ") + } + + /// Compute the path from the application root to the given element + /// Returns a tuple of (indexPath, chainDescription) + /// indexPath is like "0.2.1" and chainDescription shows the hierarchy with roles/ids + private func computeElementPath(_ element: Accessibility.Element, appElement: Accessibility.Element) -> (path: String, chain: String) { + // Walk up the hierarchy collecting ancestors + var ancestors: [Accessibility.Element] = [] + var current = element + + while true { + ancestors.append(current) + guard let parent: Accessibility.Element = try? current.attribute(.init("AXParent"))() else { + break + } + // Stop if we've reached the application element + if parent == appElement { + break + } + current = parent + } + + // Reverse to get root-to-element order (excluding app element itself) + ancestors.reverse() + + // Now compute indices by finding each element's index in its parent's children + var indices: [Int] = [] + var chainParts: [String] = [] + + // Start from appElement and find indices + var parentForIndex = appElement + for ancestor in ancestors { + // Get children of parent + let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = parentForIndex.attribute(.init("AXChildren")) + if let children: [Accessibility.Element] = try? childrenAttr() { + if let index = children.firstIndex(of: ancestor) { + indices.append(index) + } else { + indices.append(-1) // Unknown index + } + } else { + indices.append(-1) + } + + // Build chain description for this element + var desc = "" + if let role: String = try? ancestor.attribute(.init("AXRole"))() { + desc = role.replacingOccurrences(of: "AX", with: "") + } + if let id: String = try? ancestor.attribute(.init("AXIdentifier"))() { + desc += "[\(id)]" + } else if let title: String = try? ancestor.attribute(.init("AXTitle"))() { + let truncated = title.count > 20 ? String(title.prefix(20)) + "..." : title + desc += "[\"\(truncated)\"]" + } + if desc.isEmpty { + desc = "?" + } + chainParts.append(desc) + + parentForIndex = ancestor + } + + let pathString = indices.map { $0 >= 0 ? String($0) : "?" }.joined(separator: ".") + let chainString = chainParts.joined(separator: " > ") + + return (pathString, chainString) + } + + private func formatElementForJSON(_ element: Accessibility.Element) -> [String: Any] { + var dict: [String: Any] = [:] + + if let role: String = try? element.attribute(.init("AXRole"))() { + dict["role"] = role + } + if let title: String = try? element.attribute(.init("AXTitle"))() { + dict["title"] = title + } + if let id: String = try? element.attribute(.init("AXIdentifier"))() { + dict["identifier"] = id + } + if let value: Any = try? element.attribute(.init("AXValue"))() { + dict["value"] = String(describing: value) + } + + return dict + } + } +} + +AXDump.main() From 1125e8c827d68c19b80edde946090d716a057f0c Mon Sep 17 00:00:00 2001 From: Purav Manot Date: Sat, 24 Jan 2026 04:44:52 +0530 Subject: [PATCH 09/14] update axdump --- Package.swift | 6 +- Sources/AccessibilityControl/Observer.swift | 8 +- Sources/axdump/AXDump.swift | 67 + Sources/axdump/Commands/ActionCommand.swift | 299 ++++ Sources/axdump/Commands/CompareCommand.swift | 296 ++++ Sources/axdump/Commands/DumpCommand.swift | 194 +++ Sources/axdump/Commands/FindCommand.swift | 383 +++++ Sources/axdump/Commands/InspectCommand.swift | 249 +++ Sources/axdump/Commands/KeyCommand.swift | 391 +++++ Sources/axdump/Commands/ListCommand.swift | 91 ++ Sources/axdump/Commands/MenuCommand.swift | 413 +++++ Sources/axdump/Commands/ObserveCommand.swift | 265 ++++ Sources/axdump/Commands/QueryCommand.swift | 232 +++ .../axdump/Commands/ScreenshotCommand.swift | 334 ++++ Sources/axdump/Commands/SetCommand.swift | 185 +++ Sources/axdump/Commands/WatchCommand.swift | 280 ++++ .../axdump/Utilities/AttributeFields.swift | 118 ++ Sources/axdump/Utilities/Constants.swift | 319 ++++ Sources/axdump/Utilities/ElementFilter.swift | 229 +++ Sources/axdump/Utilities/ElementPrinter.swift | 412 +++++ Sources/axdump/Utilities/TreePrinter.swift | 274 ++++ Sources/axdump/main.swift | 1396 ----------------- 22 files changed, 5039 insertions(+), 1402 deletions(-) create mode 100644 Sources/axdump/AXDump.swift create mode 100644 Sources/axdump/Commands/ActionCommand.swift create mode 100644 Sources/axdump/Commands/CompareCommand.swift create mode 100644 Sources/axdump/Commands/DumpCommand.swift create mode 100644 Sources/axdump/Commands/FindCommand.swift create mode 100644 Sources/axdump/Commands/InspectCommand.swift create mode 100644 Sources/axdump/Commands/KeyCommand.swift create mode 100644 Sources/axdump/Commands/ListCommand.swift create mode 100644 Sources/axdump/Commands/MenuCommand.swift create mode 100644 Sources/axdump/Commands/ObserveCommand.swift create mode 100644 Sources/axdump/Commands/QueryCommand.swift create mode 100644 Sources/axdump/Commands/ScreenshotCommand.swift create mode 100644 Sources/axdump/Commands/SetCommand.swift create mode 100644 Sources/axdump/Commands/WatchCommand.swift create mode 100644 Sources/axdump/Utilities/AttributeFields.swift create mode 100644 Sources/axdump/Utilities/Constants.swift create mode 100644 Sources/axdump/Utilities/ElementFilter.swift create mode 100644 Sources/axdump/Utilities/ElementPrinter.swift create mode 100644 Sources/axdump/Utilities/TreePrinter.swift delete mode 100644 Sources/axdump/main.swift diff --git a/Package.swift b/Package.swift index 80940e7..a6d3fa0 100644 --- a/Package.swift +++ b/Package.swift @@ -1,10 +1,10 @@ -// swift-tools-version:5.3 +// swift-tools-version:5.11 import PackageDescription let package = Package( name: "BetterSwiftAX", - platforms: [.macOS(.v10_15)], + platforms: [.macOS(.v13)], products: [ .library( name: "BetterSwiftAX", @@ -33,7 +33,7 @@ let package = Package( name: "AccessibilityControl", dependencies: ["CAccessibilityControl", "WindowControl"] ), - .target( + .executableTarget( name: "axdump", dependencies: [ "AccessibilityControl", diff --git a/Sources/AccessibilityControl/Observer.swift b/Sources/AccessibilityControl/Observer.swift index 8ad3acd..90cf7df 100644 --- a/Sources/AccessibilityControl/Observer.swift +++ b/Sources/AccessibilityControl/Observer.swift @@ -73,9 +73,11 @@ extension Accessibility { for element: Element, callback: @escaping Callback ) throws -> Token { - let notification = try NSAccessibility.Notification(from: notification) - - return try observe(notification, for: element, callback: callback) + return try observe( + NSAccessibility.Notification(from: notification), + for: element, + callback: callback + ) } public func observe( diff --git a/Sources/axdump/AXDump.swift b/Sources/axdump/AXDump.swift new file mode 100644 index 0000000..dd3863f --- /dev/null +++ b/Sources/axdump/AXDump.swift @@ -0,0 +1,67 @@ +import Foundation +import ArgumentParser +import AccessibilityControl +import WindowControl +import AppKit +import CoreGraphics + +// MARK: - Main Command + +@main +struct AXDump: AsyncParsableCommand { + static var configuration = CommandConfiguration( + commandName: "axdump", + abstract: "Dump accessibility tree information for running applications", + discussion: """ + A command-line tool for exploring and debugging macOS accessibility trees. + Requires accessibility permissions (System Preferences > Security & Privacy > Privacy > Accessibility). + + QUICK START: + axdump watch Live explore elements under cursor + axdump find 710 "Save" --click Find and click "Save" button + axdump find 710 --role TextField --type "hello" + axdump menu 710 "File > Save" -x Execute menu item + + EXAMPLES: + axdump list List running applications with PIDs + axdump find 710 "OK" -c Find "OK" button and click it + axdump find 710 --role Button Find all buttons + axdump watch 710 --path Watch with element paths + axdump dump 710 -d 2 Dump tree (2 levels deep) + axdump menu 710 -m "Edit" -x Explore Edit menu + axdump key 710 "cmd+c" Send keyboard shortcut + + WORKFLOW: + 1. Use 'watch' to explore UI and find elements interactively + 2. Use 'find' to locate and act on elements by text/role + 3. Use 'menu' to explore and execute menu items + 4. Use 'dump' for detailed tree exploration + 5. Use 'key' for keyboard shortcuts + + REFERENCE: + axdump list --list-roles Show all known accessibility roles + axdump list --list-subroles Show all known subroles + axdump list --list-actions Show all known actions + + For more help on a specific command: + axdump --help + """, + subcommands: [ + List.self, + Find.self, + Watch.self, + Dump.self, + Query.self, + Inspect.self, + Observe.self, + Screenshot.self, + Action.self, + Set.self, + Key.self, + Menu.self, + Compare.self + ], + defaultSubcommand: List.self + ) +} + diff --git a/Sources/axdump/Commands/ActionCommand.swift b/Sources/axdump/Commands/ActionCommand.swift new file mode 100644 index 0000000..53e906c --- /dev/null +++ b/Sources/axdump/Commands/ActionCommand.swift @@ -0,0 +1,299 @@ +import Foundation +import ArgumentParser +import AccessibilityControl + +extension AXDump { + struct Action: ParsableCommand { + static var configuration = CommandConfiguration( + abstract: "Perform an action on an accessibility element", + discussion: """ + Execute accessibility actions on elements. Navigate to the target element + using path notation or focus options. + + \(AXActions.helpText()) + + CUSTOM ACTIONS: + Some elements expose custom actions (e.g., tapback reactions in Messages). + Use --custom (-C) to perform these by name. Use --list to see available + custom actions for an element. + + EXAMPLES: + axdump action 710 -a Press -p 0.1.2 Press element at path + axdump action 710 -a Press -F Press focused element + axdump action 710 -a Raise -w Raise focused window + axdump action 710 -a Increment -p 0.3 Increment slider + axdump action 710 -a ShowMenu -F Show context menu + axdump action 710 --list -p 0.1 List actions for element + axdump action 710 --list-actions Show all known actions + axdump action 710 -C "Heart" -p 0.0.0.0.0.0.11.0 Perform custom action + axdump action 710 -C "Thumbs up" -p 0.1.2 Perform tapback reaction + """ + ) + + @Argument(help: "Process ID of the application") + var pid: Int32 + + @Option(name: [.customShort("a"), .long], help: "Standard AX action to perform (can omit 'AX' prefix)") + var action: String? + + @Option(name: [.customShort("C"), .long], help: "Custom action to perform by name (e.g., 'Heart', 'Thumbs up')") + var custom: String? + + @Option(name: [.customShort("p"), .long], help: "Path to element (dot-separated child indices)") + var path: String? + + @Option(name: [.customShort("c"), .long], help: "Index of child element (shorthand for single-level path)") + var child: Int? + + @Flag(name: [.customShort("F"), .long], help: "Target the focused element") + var focused: Bool = false + + @Flag(name: .shortAndLong, help: "Target the focused window") + var window: Bool = false + + @Flag(name: .long, help: "List available actions for the target element") + var list: Bool = false + + @Flag(name: .long, help: "List all known accessibility actions") + var listActions: Bool = false + + @Flag(name: .shortAndLong, help: "Verbose output") + var verbose: Bool = false + + func run() throws { + // Handle global list + if listActions { + print(AXActions.fullHelpText()) + return + } + + guard Accessibility.isTrusted(shouldPrompt: true) else { + print("Error: Accessibility permissions required") + throw ExitCode.failure + } + + let appElement = Accessibility.Element(pid: pid) + + // Determine target element + var targetElement: Accessibility.Element = appElement + + if focused { + guard let focusedElement: Accessibility.Element = try? appElement.attribute(.init("AXFocusedUIElement"))() else { + print("Error: Could not get focused element for PID \(pid)") + throw ExitCode.failure + } + targetElement = focusedElement + } else if window { + guard let focusedWindow: Accessibility.Element = try? appElement.attribute(.init("AXFocusedWindow"))() else { + print("Error: Could not get focused window for PID \(pid)") + throw ExitCode.failure + } + targetElement = focusedWindow + } + + // Navigate to child if specified + if let childIndex = child { + targetElement = try navigateToChild(from: targetElement, index: childIndex) + } + + // Navigate via path if specified + if let pathString = path { + targetElement = try navigateToPath(from: targetElement, path: pathString) + } + + // Print element info + if verbose { + printElementInfo(targetElement) + } + + // Handle list + if list { + listActionsForElement(targetElement) + return + } + + // Handle custom action + if let customActionName = custom { + try performCustomAction(customActionName, on: targetElement) + return + } + + // Require action + guard let actionName = action else { + print("Error: No action specified. Use -a , -C , or --list to see available actions.") + throw ExitCode.failure + } + + // Perform standard AX action + let fullActionName = actionName.hasPrefix("AX") ? actionName : "AX\(actionName)" + + do { + let axAction = targetElement.action(.init(fullActionName)) + try axAction() + + print("Action performed: \(fullActionName)") + + if verbose { + // Show element state after action + print() + print("Element state after action:") + printElementInfo(targetElement) + } + } catch { + print("Error: Failed to perform action '\(fullActionName)': \(error)") + throw ExitCode.failure + } + } + + private func performCustomAction(_ name: String, on element: Accessibility.Element) throws { + // Get all actions and find matching custom action + guard let actions = try? element.supportedActions() else { + print("Error: Could not read actions for element") + throw ExitCode.failure + } + + // Find custom action matching the name + // Custom actions have format: "Name:...\nTarget:...\nSelector:..." + var matchingAction: Accessibility.Action? + for action in actions { + let actionName = action.name.value + if actionName.hasPrefix("Name:") { + // Parse custom action name + let parsed = parseCustomAction(actionName) + if parsed.name.lowercased() == name.lowercased() { + matchingAction = action + break + } + } + } + + guard let action = matchingAction else { + print("Error: Custom action '\(name)' not found") + print() + print("Available custom actions:") + for action in actions { + let actionName = action.name.value + if actionName.hasPrefix("Name:") { + let parsed = parseCustomAction(actionName) + print(" - \(parsed.name)") + } + } + throw ExitCode.failure + } + + // Perform the action + do { + try action() + print("Custom action performed: \(name)") + + if verbose { + print() + print("Element state after action:") + printElementInfo(element) + } + } catch { + print("Error: Failed to perform custom action '\(name)': \(error)") + throw ExitCode.failure + } + } + + private func parseCustomAction(_ raw: String) -> (name: String, target: String?, selector: String?) { + var name = "" + var target: String? + var selector: String? + + for line in raw.split(separator: "\n", omittingEmptySubsequences: false) { + let lineStr = String(line) + if lineStr.hasPrefix("Name:") { + name = String(lineStr.dropFirst(5)) + } else if lineStr.hasPrefix("Target:") { + target = String(lineStr.dropFirst(7)) + } else if lineStr.hasPrefix("Selector:") { + selector = String(lineStr.dropFirst(9)) + } + } + + return (name, target, selector) + } + + private func printElementInfo(_ element: Accessibility.Element) { + print("Target Element:") + print(String(repeating: "-", count: 40)) + + if let role: String = try? element.attribute(AXAttribute.role)() { + print(" Role: \(role)") + } + if let subrole: String = try? element.attribute(AXAttribute.subrole)() { + print(" Subrole: \(subrole)") + } + if let title: String = try? element.attribute(AXAttribute.title)() { + print(" Title: \(title)") + } + if let id: String = try? element.attribute(AXAttribute.identifier)() { + print(" Identifier: \(id)") + } + if let value: Any = try? element.attribute(AXAttribute.value)() { + let strValue = String(describing: value) + let truncated = strValue.count > 50 ? String(strValue.prefix(50)) + "..." : strValue + print(" Value: \(truncated)") + } + if let enabled: Bool = try? element.attribute(AXAttribute.enabled)() { + print(" Enabled: \(enabled)") + } + if let focused: Bool = try? element.attribute(AXAttribute.focused)() { + print(" Focused: \(focused)") + } + + print() + } + + private func listActionsForElement(_ element: Accessibility.Element) { + guard let actions = try? element.supportedActions() else { + print("(unable to read actions)") + return + } + + if actions.isEmpty { + print("(no actions available)") + return + } + + // Separate standard and custom actions + var standardActions: [Accessibility.Action] = [] + var customActions: [(name: String, action: Accessibility.Action)] = [] + + for action in actions { + let name = action.name.value + if name.hasPrefix("Name:") { + let parsed = parseCustomAction(name) + customActions.append((parsed.name, action)) + } else { + standardActions.append(action) + } + } + + // Print standard actions + if !standardActions.isEmpty { + print("Standard Actions:") + print(String(repeating: "-", count: 40)) + for action in standardActions.sorted(by: { $0.name.value < $1.name.value }) { + let name = action.name.value + let shortName = name.replacingOccurrences(of: "AX", with: "") + let knownDesc = AXActions.all[name] + let desc = knownDesc ?? action.description + print(" \(shortName.padding(toLength: 20, withPad: " ", startingAt: 0)) \(desc)") + } + } + + // Print custom actions + if !customActions.isEmpty { + if !standardActions.isEmpty { print() } + print("Custom Actions (use -C \"name\"):") + print(String(repeating: "-", count: 40)) + for (name, _) in customActions.sorted(by: { $0.name < $1.name }) { + print(" \(name)") + } + } + } + } +} diff --git a/Sources/axdump/Commands/CompareCommand.swift b/Sources/axdump/Commands/CompareCommand.swift new file mode 100644 index 0000000..b3f8376 --- /dev/null +++ b/Sources/axdump/Commands/CompareCommand.swift @@ -0,0 +1,296 @@ +import Foundation +import ArgumentParser +import AccessibilityControl +import WindowControl +import CoreGraphics +import AppKit + +extension AXDump { + struct Compare: ParsableCommand { + static var configuration = CommandConfiguration( + abstract: "Screenshot before and after an action", + discussion: """ + Captures a screenshot of an element, performs an action, then captures + another screenshot. Useful for understanding how actions affect elements. + + OUTPUT: + Creates two files: _before.png and _after.png + Default names are based on the action and element path. + + EXAMPLES: + axdump compare 710 -a Press -p 0.1.2 Press and compare + axdump compare 710 -a Press -F -o toggle Named output + axdump compare 710 -a Increment -p 0.3 -d 500 Wait 500ms between + axdump compare 710 -a ShowMenu -F --no-window Capture element only + """ + ) + + @Argument(help: "Process ID of the application") + var pid: Int32 + + @Option(name: [.customShort("a"), .long], help: "Action to perform (can omit 'AX' prefix)") + var action: String + + @Option(name: [.customShort("p"), .long], help: "Path to element (dot-separated child indices)") + var path: String? + + @Option(name: [.customShort("c"), .long], help: "Index of child element") + var child: Int? + + @Flag(name: [.customShort("F"), .long], help: "Target the focused element") + var focused: Bool = false + + @Flag(name: .shortAndLong, help: "Target the focused window") + var window: Bool = false + + @Option(name: [.customShort("o"), .long], help: "Output file prefix (default: _)") + var output: String? + + @Option(name: [.customShort("d"), .long], help: "Delay in milliseconds between action and after screenshot (default: 100)") + var delay: Int = 100 + + @Flag(name: .long, help: "Only capture the element frame, not the whole window") + var noWindow: Bool = false + + @Option(name: [.customShort("i"), .long], help: "Window index to capture (default: focused window)") + var windowIndex: Int? + + @Flag(name: .long, help: "Include window shadow") + var shadow: Bool = false + + @Option(name: .long, help: "Bounding box color: red, green, blue, yellow, orange, cyan, magenta") + var boxColor: String = "red" + + func run() throws { + guard Accessibility.isTrusted(shouldPrompt: true) else { + print("Error: Accessibility permissions required") + throw ExitCode.failure + } + + let appElement = Accessibility.Element(pid: pid) + + // Determine target element for action + var targetElement: Accessibility.Element = appElement + + if focused { + guard let focusedElement: Accessibility.Element = try? appElement.attribute(.init("AXFocusedUIElement"))() else { + print("Error: Could not get focused element for PID \(pid)") + throw ExitCode.failure + } + targetElement = focusedElement + } else if window { + guard let focusedWindow: Accessibility.Element = try? appElement.attribute(.init("AXFocusedWindow"))() else { + print("Error: Could not get focused window for PID \(pid)") + throw ExitCode.failure + } + targetElement = focusedWindow + } + + if let childIndex = child { + targetElement = try navigateToChild(from: targetElement, index: childIndex) + } + + if let pathString = path { + targetElement = try navigateToPath(from: targetElement, path: pathString) + } + + // Get the window element for screenshots + let windowElement: Accessibility.Element + if let index = windowIndex { + let windowsAttr: Accessibility.Attribute<[Accessibility.Element]> = appElement.attribute(.init("AXWindows")) + guard let windows: [Accessibility.Element] = try? windowsAttr() else { + print("Error: Could not get windows for PID \(pid)") + throw ExitCode.failure + } + guard index >= 0 && index < windows.count else { + print("Error: Window index \(index) out of range") + throw ExitCode.failure + } + windowElement = windows[index] + } else { + guard let focusedWindow: Accessibility.Element = try? appElement.attribute(.init("AXFocusedWindow"))() else { + print("Error: Could not get focused window for PID \(pid)") + throw ExitCode.failure + } + windowElement = focusedWindow + } + + // Get window ID + let cgWindow: Window + do { + cgWindow = try windowElement.window() + } catch { + print("Error: Could not get window ID: \(error)") + throw ExitCode.failure + } + + // Determine output prefix + let actionName = action.hasPrefix("AX") ? action : "AX\(action)" + let shortAction = action.replacingOccurrences(of: "AX", with: "") + let outputPrefix = output ?? "\(shortAction.lowercased())_\(path ?? "focused")" + + // Print info + print("Compare Action: \(actionName)") + print("Target Element:") + printElementInfo(targetElement) + + // Capture before screenshot + print("Capturing 'before' screenshot...") + let beforeImage = try captureWindow(cgWindow, element: targetElement, windowElement: windowElement, highlight: true) + let beforePath = "\(outputPrefix)_before.png" + try saveImage(beforeImage, to: beforePath) + print(" Saved: \(beforePath)") + + // Perform action + print("Performing action: \(actionName)...") + let axAction = targetElement.action(.init(actionName)) + try axAction() + print(" Action completed") + + // Wait for UI to update + if delay > 0 { + print(" Waiting \(delay)ms...") + Thread.sleep(forTimeInterval: Double(delay) / 1000.0) + } + + // Capture after screenshot + print("Capturing 'after' screenshot...") + let afterImage = try captureWindow(cgWindow, element: targetElement, windowElement: windowElement, highlight: true) + let afterPath = "\(outputPrefix)_after.png" + try saveImage(afterImage, to: afterPath) + print(" Saved: \(afterPath)") + + // Print summary + print() + print("Comparison complete:") + print(" Before: \(beforePath)") + print(" After: \(afterPath)") + + // Print element state change + print() + print("Element state after action:") + printElementInfo(targetElement) + } + + private func printElementInfo(_ element: Accessibility.Element) { + if let role: String = try? element.attribute(AXAttribute.role)() { + print(" Role: \(role)") + } + if let title: String = try? element.attribute(AXAttribute.title)() { + print(" Title: \(title)") + } + if let id: String = try? element.attribute(AXAttribute.identifier)() { + print(" Identifier: \(id)") + } + if let value: Any = try? element.attribute(AXAttribute.value)() { + let strValue = String(describing: value) + let truncated = strValue.count > 50 ? String(strValue.prefix(50)) + "..." : strValue + print(" Value: \(truncated)") + } + if let enabled: Bool = try? element.attribute(AXAttribute.enabled)() { + print(" Enabled: \(enabled)") + } + } + + private func captureWindow(_ window: Window, element: Accessibility.Element, windowElement: Accessibility.Element, highlight: Bool) throws -> CGImage { + var imageOptions: CGWindowImageOption = [.boundsIgnoreFraming] + if shadow { + imageOptions = [] + } + + guard let cgImage = CGWindowListCreateImage( + .null, + .optionIncludingWindow, + window.raw, + imageOptions + ) else { + throw CompareError.captureFailure + } + + guard highlight else { + return cgImage + } + + // Draw bounding box around target element + guard let windowFrame = getElementFrame(windowElement), + let elementFrame = getElementFrame(element) else { + return cgImage + } + + let width = cgImage.width + let height = cgImage.height + + guard let colorSpace = cgImage.colorSpace ?? CGColorSpace(name: CGColorSpace.sRGB), + let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: 0, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { + return cgImage + } + + context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height)) + + let boxCGColor = parseColor(boxColor) + context.setStrokeColor(boxCGColor) + context.setLineWidth(3.0) + + let scaleX = CGFloat(width) / windowFrame.width + let scaleY = CGFloat(height) / windowFrame.height + + let relativeX = elementFrame.origin.x - windowFrame.origin.x + let relativeY = elementFrame.origin.y - windowFrame.origin.y + + let imageX = relativeX * scaleX + let imageY = relativeY * scaleY + let imageWidth = elementFrame.width * scaleX + let imageHeight = elementFrame.height * scaleY + + let flippedY = CGFloat(height) - imageY - imageHeight + + let rect = CGRect(x: imageX, y: flippedY, width: imageWidth, height: imageHeight) + context.stroke(rect) + + return context.makeImage() ?? cgImage + } + + private func getElementFrame(_ element: Accessibility.Element) -> CGRect? { + if let frame = try? element.attribute(AXAttribute.frame)() { + return frame + } + if let pos = try? element.attribute(AXAttribute.position)(), + let size = try? element.attribute(AXAttribute.size)() { + return CGRect(origin: pos, size: size) + } + return nil + } + + private func parseColor(_ name: String) -> CGColor { + switch name.lowercased() { + case "red": return CGColor(red: 1, green: 0, blue: 0, alpha: 1) + case "green": return CGColor(red: 0, green: 1, blue: 0, alpha: 1) + case "blue": return CGColor(red: 0, green: 0, blue: 1, alpha: 1) + case "yellow": return CGColor(red: 1, green: 1, blue: 0, alpha: 1) + case "orange": return CGColor(red: 1, green: 0.5, blue: 0, alpha: 1) + case "cyan": return CGColor(red: 0, green: 1, blue: 1, alpha: 1) + case "magenta": return CGColor(red: 1, green: 0, blue: 1, alpha: 1) + default: return CGColor(red: 1, green: 0, blue: 0, alpha: 1) + } + } + } +} + +enum CompareError: Error, CustomStringConvertible { + case captureFailure + + var description: String { + switch self { + case .captureFailure: + return "Failed to capture window image" + } + } +} diff --git a/Sources/axdump/Commands/DumpCommand.swift b/Sources/axdump/Commands/DumpCommand.swift new file mode 100644 index 0000000..1ffbaf3 --- /dev/null +++ b/Sources/axdump/Commands/DumpCommand.swift @@ -0,0 +1,194 @@ +import Foundation +import ArgumentParser +import AccessibilityControl +import AppKit + +extension AXDump { + struct Dump: ParsableCommand { + static var configuration = CommandConfiguration( + abstract: "Dump accessibility tree for an application", + discussion: """ + Recursively dumps the accessibility element hierarchy starting from the + application root or focused window. Output is rendered as an ASCII tree. + + \(AttributeFields.helpText) + + \(ElementFilter.helpText) + + \(AXRoles.helpText()) + + EXAMPLES: + axdump dump 710 Dump with default settings + axdump dump 710 -d 5 Dump 5 levels deep + axdump dump 710 -f minimal Only show role, title, id + axdump dump 710 -f role,title,value Custom field selection + axdump dump 710 -w Start from focused window + axdump dump 710 --role Button Filter to only buttons + axdump dump 710 --has identifier Only elements with identifier + axdump dump 710 --role "Text.*" -d 10 Regex pattern for roles + """ + ) + + @Argument(help: "Process ID of the application") + var pid: Int32 + + @Option(name: .shortAndLong, help: "Maximum depth to traverse (default: 3)") + var depth: Int = 3 + + @Option(name: .shortAndLong, help: "Verbosity level: 0=minimal, 1=normal, 2=detailed") + var verbosity: Int = 1 + + @Option(name: [.customShort("f"), .long], help: "Fields to display (see FIELD OPTIONS above)") + var fields: String = "standard" + + @Flag(name: .shortAndLong, help: "Start from focused window instead of application root") + var window: Bool = false + + @Flag(name: .long, help: "Disable colored output") + var noColor: Bool = false + + @Flag(name: .long, help: "Output as JSON") + var json: Bool = false + + // Filtering options + @Option(name: .long, help: "Filter by role (regex pattern, e.g., 'Button|Text')") + var role: String? + + @Option(name: .long, help: "Filter by subrole (regex pattern)") + var subrole: String? + + @Option(name: .long, help: "Filter by title (regex pattern)") + var title: String? + + @Option(name: .long, help: "Filter by identifier (regex pattern)") + var id: String? + + @Option(name: .long, parsing: .upToNextOption, help: "Only show elements where these fields are not nil") + var has: [String] = [] + + @Option(name: .long, parsing: .upToNextOption, help: "Only show elements where these fields are nil") + var without: [String] = [] + + @Flag(name: .long, help: "Make pattern matching case-sensitive") + var caseSensitive: Bool = false + + func run() throws { + guard Accessibility.isTrusted(shouldPrompt: true) else { + print("Error: Accessibility permissions required") + throw ExitCode.failure + } + + let appElement = Accessibility.Element(pid: pid) + + let rootElement: Accessibility.Element + if window { + guard let focusedWindow: Accessibility.Element = try? appElement.attribute(.init("AXFocusedWindow"))() else { + print("Error: Could not get focused window for PID \(pid)") + throw ExitCode.failure + } + rootElement = focusedWindow + } else { + rootElement = appElement + } + + // Build filter + let filter: ElementFilter? + do { + filter = try ElementFilter( + rolePattern: role, + subrolePattern: subrole, + titlePattern: title, + identifierPattern: id, + requiredFields: has, + excludedFields: without, + caseSensitive: caseSensitive + ) + } catch { + print("Error: Invalid regex pattern: \(error)") + throw ExitCode.failure + } + + let attributeFields = AttributeFields.parse(fields) + + // Print header + if let appName: String = try? appElement.attribute(.init("AXTitle"))() { + print("Accessibility Tree for: \(appName) (PID: \(pid))") + } else { + print("Accessibility Tree for PID: \(pid)") + } + print(String(repeating: "=", count: 60)) + + if let f = filter, f.isActive { + print("Filters active: \(describeFilter(f))") + print(String(repeating: "-", count: 60)) + } + print() + + if json { + let jsonTree = buildJSONTree(rootElement, depth: depth, filter: filter) + if let jsonData = try? JSONSerialization.data(withJSONObject: jsonTree, options: [.prettyPrinted, .sortedKeys]), + let jsonString = String(data: jsonData, encoding: .utf8) { + print(jsonString) + } + } else { + let printer = TreePrinter( + fields: attributeFields, + filter: filter?.isActive == true ? filter : nil, + maxDepth: depth, + showActions: verbosity >= 2, + useColor: !noColor + ) + printer.printTree(rootElement) + } + } + + private func describeFilter(_ filter: ElementFilter) -> String { + var parts: [String] = [] + if filter.rolePattern != nil { parts.append("role") } + if filter.subrolePattern != nil { parts.append("subrole") } + if filter.titlePattern != nil { parts.append("title") } + if filter.identifierPattern != nil { parts.append("id") } + if !filter.requiredFields.isEmpty { parts.append("has:\(filter.requiredFields.joined(separator: ","))") } + if !filter.excludedFields.isEmpty { parts.append("without:\(filter.excludedFields.joined(separator: ","))") } + return parts.joined(separator: ", ") + } + + private func buildJSONTree(_ element: Accessibility.Element, depth: Int, filter: ElementFilter?) -> [String: Any] { + let printer = ElementPrinter() + var node = printer.formatElementForJSON(element) + + if depth > 0 { + let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = element.attribute(.init("AXChildren")) + if let children: [Accessibility.Element] = try? childrenAttr() { + var childNodes: [[String: Any]] = [] + for child in children { + let passes = filter?.matches(child) ?? true + if passes || childHasMatchingDescendant(child, filter: filter, depth: depth - 1) { + childNodes.append(buildJSONTree(child, depth: depth - 1, filter: filter)) + } + } + if !childNodes.isEmpty { + node["children"] = childNodes + } + } + } + + return node + } + + private func childHasMatchingDescendant(_ element: Accessibility.Element, filter: ElementFilter?, depth: Int) -> Bool { + guard let filter = filter, depth > 0 else { return false } + if filter.matches(element) { return true } + + let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = element.attribute(.init("AXChildren")) + guard let children: [Accessibility.Element] = try? childrenAttr() else { return false } + + for child in children { + if childHasMatchingDescendant(child, filter: filter, depth: depth - 1) { + return true + } + } + return false + } + } +} diff --git a/Sources/axdump/Commands/FindCommand.swift b/Sources/axdump/Commands/FindCommand.swift new file mode 100644 index 0000000..24e9634 --- /dev/null +++ b/Sources/axdump/Commands/FindCommand.swift @@ -0,0 +1,383 @@ +import Foundation +import ArgumentParser +import AccessibilityControl +import AppKit + +extension AXDump { + struct Find: ParsableCommand { + static var configuration = CommandConfiguration( + abstract: "Find elements and optionally act on them", + discussion: """ + Smart element finder with built-in actions. Finds elements by text content, + role, identifier, or combinations thereof. + + SELECTORS: + "text" Find element containing "text" (title, value, or description) + --role Button "OK" Find Button containing "OK" + --id searchField Find element with identifier "searchField" + --role TextField Find any TextField + + ACTIONS (performed on first match): + --click, -c Click/press the element + --focus, -f Focus the element + --type "text" Set the element's value + --read, -r Print the element's value + --custom, -C "name" Perform a custom action (e.g., tapback reactions) + + EXAMPLES: + axdump find 710 "Save" Find "Save" button/text + axdump find 710 "Save" --click Find and click "Save" + axdump find 710 --role Button "Cancel" Find Button with "Cancel" + axdump find 710 --role TextField --focus Focus first text field + axdump find 710 --id searchField --type "query" + axdump find 710 "File name" --read Read value near "File name" + axdump find 710 --role MenuItem "Copy" -c Execute Copy menu item + axdump find 710 --all "Button" Find ALL matching elements + axdump find 710 "hello" --id Sticker -C "Heart" React with Heart + axdump find 710 "hello" --id Sticker -C "👍" React with emoji + """ + ) + + @Argument(help: "Process ID of the application") + var pid: Int32 + + @Argument(help: "Text to search for (in title, value, description, or identifier)") + var text: String? + + @Option(name: .long, help: "Filter by role (Button, TextField, MenuItem, etc.)") + var role: String? + + @Option(name: .long, help: "Filter by identifier") + var id: String? + + @Option(name: .long, help: "Filter by subrole") + var subrole: String? + + @Flag(name: [.customShort("c"), .long], help: "Click/press the found element") + var click: Bool = false + + @Flag(name: [.customShort("f"), .long], help: "Focus the found element") + var focus: Bool = false + + @Option(name: [.customShort("t"), .long], help: "Type/set this value into the element") + var type: String? + + @Flag(name: [.customShort("r"), .long], help: "Read and print the element's value") + var read: Bool = false + + @Option(name: [.customShort("C"), .long], help: "Perform a custom action by name (e.g., 'Heart', 'Thumbs up')") + var custom: String? + + @Flag(name: .long, help: "Find ALL matching elements (not just first)") + var all: Bool = false + + @Option(name: [.customShort("n"), .long], help: "Select the Nth match (1-based, default: 1)") + var nth: Int = 1 + + @Flag(name: [.customShort("v"), .long], help: "Verbose output") + var verbose: Bool = false + + @Option(name: [.customShort("d"), .long], help: "Maximum search depth (default: 10)") + var depth: Int = 10 + + @Flag(name: .shortAndLong, help: "Start search from focused window") + var window: Bool = false + + func run() throws { + guard Accessibility.isTrusted(shouldPrompt: true) else { + print("Error: Accessibility permissions required") + throw ExitCode.failure + } + + guard text != nil || role != nil || id != nil || subrole != nil else { + print("Error: Specify search text, --role, --id, or --subrole") + throw ExitCode.failure + } + + let appElement = Accessibility.Element(pid: pid) + + let rootElement: Accessibility.Element + if window { + guard let focusedWindow: Accessibility.Element = try? appElement.attribute(.init("AXFocusedWindow"))() else { + print("Error: Could not get focused window") + throw ExitCode.failure + } + rootElement = focusedWindow + } else { + rootElement = appElement + } + + // Search for matching elements + var matches: [(element: Accessibility.Element, path: String, info: String)] = [] + searchElements(root: rootElement, path: "", depth: 0, matches: &matches) + + if matches.isEmpty { + print("No matching elements found") + throw ExitCode.failure + } + + if all { + // Print all matches + print("Found \(matches.count) match(es):\n") + for (index, match) in matches.enumerated() { + print("[\(index + 1)] \(match.info)") + if verbose { + print(" path: \(match.path)") + } + } + return + } + + // Select the Nth match + guard nth >= 1 && nth <= matches.count else { + print("Error: Match #\(nth) not found (only \(matches.count) match(es))") + throw ExitCode.failure + } + + let selected = matches[nth - 1] + let element = selected.element + + print("Found: \(selected.info)") + if verbose { + print(" path: \(selected.path)") + printElementDetails(element) + } + + // Perform actions + var actionPerformed = false + + if focus { + let focusAttr = element.mutableAttribute(.init("AXFocused")) as Accessibility.MutableAttribute + if (try? focusAttr.isSettable()) == true { + try? focusAttr(assign: true) + print("→ Focused") + actionPerformed = true + } else { + print("→ Cannot focus this element") + } + } + + if let typeValue = type { + let valueAttr = element.mutableAttribute(.init("AXValue")) as Accessibility.MutableAttribute + if (try? valueAttr.isSettable()) == true { + try valueAttr(assign: typeValue) + print("→ Set value: \(typeValue)") + actionPerformed = true + } else { + print("→ Cannot set value on this element") + } + } + + if click { + let pressAction = element.action(.init("AXPress")) + do { + try pressAction() + print("→ Clicked") + actionPerformed = true + } catch { + // Try AXPick for menu items + let pickAction = element.action(.init("AXPick")) + do { + try pickAction() + print("→ Picked") + actionPerformed = true + } catch { + print("→ Cannot click this element (no AXPress or AXPick action)") + } + } + } + + if read { + if let value: Any = try? element.attribute(.init("AXValue"))() { + print("→ Value: \(value)") + } else if let title: String = try? element.attribute(AXAttribute.title)() { + print("→ Title: \(title)") + } else { + print("→ No readable value") + } + actionPerformed = true + } + + if let customActionName = custom { + try performCustomAction(customActionName, on: element) + actionPerformed = true + } + + if !actionPerformed && matches.count > 1 { + print("\n\(matches.count) total matches. Use --all to see all, or -n to select.") + } + } + + private func performCustomAction(_ name: String, on element: Accessibility.Element) throws { + guard let actions = try? element.supportedActions() else { + print("→ Cannot read actions for element") + throw ExitCode.failure + } + + // Find custom action matching the name + // Custom actions have format: "Name:...\nTarget:...\nSelector:..." + var matchingAction: Accessibility.Action? + for action in actions { + let actionName = action.name.value + if actionName.hasPrefix("Name:") { + let parsed = parseCustomAction(actionName) + if parsed.lowercased() == name.lowercased() { + matchingAction = action + break + } + } + } + + guard let action = matchingAction else { + print("→ Custom action '\(name)' not found") + print() + print("Available custom actions:") + for action in actions { + let actionName = action.name.value + if actionName.hasPrefix("Name:") { + let parsed = parseCustomAction(actionName) + print(" - \(parsed)") + } + } + throw ExitCode.failure + } + + try action() + print("→ Custom action: \(name)") + } + + private func parseCustomAction(_ raw: String) -> String { + for line in raw.split(separator: "\n", omittingEmptySubsequences: false) { + let lineStr = String(line) + if lineStr.hasPrefix("Name:") { + return String(lineStr.dropFirst(5)) + } + } + return raw + } + + private func searchElements( + root: Accessibility.Element, + path: String, + depth: Int, + matches: inout [(element: Accessibility.Element, path: String, info: String)] + ) { + guard depth <= self.depth else { return } + + // Check if this element matches + if elementMatches(root) { + let info = formatElementInfo(root) + matches.append((root, path.isEmpty ? "root" : path, info)) + } + + // Recurse into children + let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = root.attribute(.init("AXChildren")) + guard let children = try? childrenAttr() else { return } + + for (index, child) in children.enumerated() { + let childPath = path.isEmpty ? "\(index)" : "\(path).\(index)" + searchElements(root: child, path: childPath, depth: depth + 1, matches: &matches) + } + } + + private func elementMatches(_ element: Accessibility.Element) -> Bool { + // Check role filter + if let roleFilter = role { + guard let elementRole: String = try? element.attribute(AXAttribute.role)() else { + return false + } + let normalizedFilter = roleFilter.hasPrefix("AX") ? roleFilter : "AX\(roleFilter)" + if elementRole.lowercased() != normalizedFilter.lowercased() { + return false + } + } + + // Check subrole filter + if let subroleFilter = subrole { + guard let elementSubrole: String = try? element.attribute(AXAttribute.subrole)() else { + return false + } + let normalizedFilter = subroleFilter.hasPrefix("AX") ? subroleFilter : "AX\(subroleFilter)" + if elementSubrole.lowercased() != normalizedFilter.lowercased() { + return false + } + } + + // Check identifier filter + if let idFilter = id { + guard let elementId: String = try? element.attribute(AXAttribute.identifier)() else { + return false + } + if !elementId.localizedCaseInsensitiveContains(idFilter) { + return false + } + } + + // Check text filter (matches title, value, description, or identifier) + if let textFilter = text { + let searchText = textFilter.lowercased() + + let title = (try? element.attribute(AXAttribute.title)())?.lowercased() ?? "" + let value = (try? element.attribute(AXAttribute.value)()).map { String(describing: $0).lowercased() } ?? "" + let desc = (try? element.attribute(AXAttribute.description)())?.lowercased() ?? "" + let identifier = (try? element.attribute(AXAttribute.identifier)())?.lowercased() ?? "" + let help = (try? element.attribute(AXAttribute.help)())?.lowercased() ?? "" + + let matchesText = title.contains(searchText) || + value.contains(searchText) || + desc.contains(searchText) || + identifier.contains(searchText) || + help.contains(searchText) + + if !matchesText { + return false + } + } + + return true + } + + private func formatElementInfo(_ element: Accessibility.Element) -> String { + var parts: [String] = [] + + if let role: String = try? element.attribute(AXAttribute.role)() { + parts.append(role.replacingOccurrences(of: "AX", with: "")) + } + + if let title: String = try? element.attribute(AXAttribute.title)(), !title.isEmpty { + let truncated = title.count > 40 ? String(title.prefix(40)) + "..." : title + parts.append("\"\(truncated)\"") + } + + if let id: String = try? element.attribute(AXAttribute.identifier)() { + parts.append("#\(id)") + } + + if let value: Any = try? element.attribute(AXAttribute.value)() { + let strValue = String(describing: value) + if !strValue.isEmpty && strValue != parts.last { + let truncated = strValue.count > 30 ? String(strValue.prefix(30)) + "..." : strValue + parts.append("=\(truncated)") + } + } + + return parts.joined(separator: " ") + } + + private func printElementDetails(_ element: Accessibility.Element) { + if let enabled: Bool = try? element.attribute(AXAttribute.enabled)() { + print(" enabled: \(enabled)") + } + if let focused: Bool = try? element.attribute(AXAttribute.focused)() { + print(" focused: \(focused)") + } + if let frame = try? element.attribute(AXAttribute.frame)() { + print(" frame: (\(Int(frame.origin.x)),\(Int(frame.origin.y))) \(Int(frame.width))x\(Int(frame.height))") + } + if let actions = try? element.supportedActions(), !actions.isEmpty { + let names = actions.map { $0.name.value.replacingOccurrences(of: "AX", with: "") } + print(" actions: \(names.joined(separator: ", "))") + } + } + } +} diff --git a/Sources/axdump/Commands/InspectCommand.swift b/Sources/axdump/Commands/InspectCommand.swift new file mode 100644 index 0000000..7265b32 --- /dev/null +++ b/Sources/axdump/Commands/InspectCommand.swift @@ -0,0 +1,249 @@ +import Foundation +import ArgumentParser +import AccessibilityControl +import CoreGraphics + +extension AXDump { + struct Inspect: ParsableCommand { + static var configuration = CommandConfiguration( + abstract: "Inspect specific attributes or elements in full detail", + discussion: """ + Read attribute values in full (without truncation) and navigate to specific + elements in the hierarchy using child indices. + + NAVIGATION: + Use -c (--child) for single-level navigation or -p (--path) for multi-level. + Path format: dot-separated indices, e.g., "0.3.1" means: + - First child of root (index 0) + - Fourth child of that (index 3) + - Second child of that (index 1) + + ATTRIBUTES: + Use -a to specify attributes to read. Can omit 'AX' prefix. + Use -a list to see all available attributes for an element. + + EXAMPLES: + axdump inspect 710 Show all attributes (full values) + axdump inspect 710 -a list List available attributes + axdump inspect 710 -a AXValue Read AXValue in full + axdump inspect 710 -a Value,Title Read multiple (AX prefix optional) + axdump inspect 710 -c 0 Inspect first child + axdump inspect 710 -p 0.2.1 Navigate to nested element + axdump inspect 710 -w -a AXChildren From focused window + axdump inspect 710 -F -p 0 First child of focused element + axdump inspect 710 -j Output as JSON + axdump inspect 710 -l 500 Truncate values at 500 chars + """ + ) + + @Argument(help: "Process ID of the application") + var pid: Int32 + + @Option(name: [.customShort("p"), .long], help: "Path to element as dot-separated child indices (e.g., '0.3.1')") + var path: String? + + @Option(name: [.customShort("a"), .long], help: "Specific attribute(s) to read in full (comma-separated). Use 'list' to show available.") + var attributes: String? + + @Option(name: [.customShort("c"), .long], help: "Index of child element to inspect (shorthand for --path)") + var child: Int? + + @Flag(name: [.customShort("F"), .long], help: "Start from focused element") + var focused: Bool = false + + @Flag(name: .shortAndLong, help: "Start from focused window") + var window: Bool = false + + @Option(name: [.customShort("l"), .long], help: "Maximum output length per attribute (0 for unlimited)") + var maxLength: Int = 0 + + @Flag(name: [.customShort("j"), .long], help: "Output as JSON") + var json: Bool = false + + func run() throws { + guard Accessibility.isTrusted(shouldPrompt: true) else { + print("Error: Accessibility permissions required") + throw ExitCode.failure + } + + let appElement = Accessibility.Element(pid: pid) + + // Determine starting element + var targetElement: Accessibility.Element = appElement + + if focused { + guard let focusedElement: Accessibility.Element = try? appElement.attribute(.init("AXFocusedUIElement"))() else { + print("Error: Could not get focused element for PID \(pid)") + throw ExitCode.failure + } + targetElement = focusedElement + } else if window { + guard let focusedWindow: Accessibility.Element = try? appElement.attribute(.init("AXFocusedWindow"))() else { + print("Error: Could not get focused window for PID \(pid)") + throw ExitCode.failure + } + targetElement = focusedWindow + } + + // Navigate to child if specified + if let childIndex = child { + targetElement = try navigateToChild(from: targetElement, index: childIndex) + } + + // Navigate via path if specified + if let pathString = path { + targetElement = try navigateToPath(from: targetElement, path: pathString) + } + + // Show element info + printElementHeader(targetElement) + + let printer = ElementPrinter(maxLength: maxLength) + + // Handle attribute inspection + if let attrString = attributes { + if attrString.lowercased() == "list" { + listAttributes(of: targetElement) + } else { + let attrNames = attrString.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) } + inspectAttributes(of: targetElement, names: attrNames, printer: printer) + } + } else { + // Default: show all attributes with full values + inspectAllAttributes(of: targetElement, printer: printer) + } + } + + private func printElementHeader(_ element: Accessibility.Element) { + print("Element Info:") + print(String(repeating: "=", count: 60)) + + if let role: String = try? element.attribute(AXAttribute.role)() { + print("Role: \(role)") + } + if let title: String = try? element.attribute(AXAttribute.title)() { + print("Title: \(title)") + } + if let id: String = try? element.attribute(AXAttribute.identifier)() { + print("Identifier: \(id)") + } + + let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = element.attribute(.init("AXChildren")) + if let count = try? childrenAttr.count() { + print("Children: \(count)") + } + + print(String(repeating: "-", count: 60)) + print() + } + + private func listAttributes(of element: Accessibility.Element) { + print("Available Attributes:") + print(String(repeating: "-", count: 40)) + + guard let attributes = try? element.supportedAttributes() else { + print("(unable to read attributes)") + return + } + + for attr in attributes.sorted(by: { $0.name.value < $1.name.value }) { + let name = attr.name.value + let settable = (try? attr.isSettable()) ?? false + let settableStr = settable ? " [settable]" : "" + print(" \(name)\(settableStr)") + } + + print() + print("Parameterized Attributes:") + print(String(repeating: "-", count: 40)) + + if let paramAttrs = try? element.supportedParameterizedAttributes() { + for attr in paramAttrs.sorted(by: { $0.name.value < $1.name.value }) { + print(" \(attr.name.value)") + } + } + } + + private func inspectAttributes(of element: Accessibility.Element, names: [String], printer: ElementPrinter) { + if json { + var result: [String: Any] = [:] + for name in names { + let attrName = name.hasPrefix("AX") ? name : "AX\(name)" + if let value: Any = try? element.attribute(.init(attrName))() { + result[attrName] = printer.formatValueForJSON(value) + } else { + result[attrName] = NSNull() + } + } + if let jsonData = try? JSONSerialization.data(withJSONObject: result, options: [.prettyPrinted, .sortedKeys]), + let jsonString = String(data: jsonData, encoding: .utf8) { + print(jsonString) + } + return + } + + for name in names { + let attrName = name.hasPrefix("AX") ? name : "AX\(name)" + print("\(attrName):") + print(String(repeating: "-", count: 40)) + + if let value: Any = try? element.attribute(.init(attrName))() { + let strValue = printer.formatValue(value) + if maxLength > 0 && strValue.count > maxLength { + print(String(strValue.prefix(maxLength))) + print("... (truncated, total length: \(strValue.count))") + } else { + print(strValue) + } + } else { + print("(unable to read or no value)") + } + print() + } + } + + private func inspectAllAttributes(of element: Accessibility.Element, printer: ElementPrinter) { + guard let attributes = try? element.supportedAttributes() else { + print("(unable to read attributes)") + return + } + + if json { + var result: [String: Any] = [:] + for attr in attributes { + if let value: Any = try? attr() { + result[attr.name.value] = printer.formatValueForJSON(value) + } + } + if let jsonData = try? JSONSerialization.data(withJSONObject: result, options: [.prettyPrinted, .sortedKeys]), + let jsonString = String(data: jsonData, encoding: .utf8) { + print(jsonString) + } + return + } + + print("All Attributes (full values):") + print(String(repeating: "-", count: 40)) + + for attr in attributes.sorted(by: { $0.name.value < $1.name.value }) { + let name = attr.name.value + + if let value: Any = try? attr() { + let strValue = printer.formatValue(value) + if maxLength > 0 && strValue.count > maxLength { + print("\(name): \(String(strValue.prefix(maxLength)))... (truncated)") + } else if strValue.contains("\n") || strValue.count > 80 { + print("\(name):") + print(strValue.split(separator: "\n", omittingEmptySubsequences: false) + .map { " \($0)" } + .joined(separator: "\n")) + } else { + print("\(name): \(strValue)") + } + } else { + print("\(name): (unable to read)") + } + } + } + } +} diff --git a/Sources/axdump/Commands/KeyCommand.swift b/Sources/axdump/Commands/KeyCommand.swift new file mode 100644 index 0000000..3401ef9 --- /dev/null +++ b/Sources/axdump/Commands/KeyCommand.swift @@ -0,0 +1,391 @@ +import Foundation +import ArgumentParser +import CoreGraphics +import AppKit +import Carbon +import Combine + +// MARK: - OS Version Detection + +private let isSequoiaOrUp: Bool = { + if #available(macOS 15, *) { + return true + } + return false +}() + +// MARK: - Keyboard Layout Mapping + +/// Maps characters to key codes based on the current keyboard layout +/// This is necessary because key codes are physical positions, not characters +private final class KeyMap { + private static let keyCodeRange: Range = 0..<127 + + static let shared = KeyMap() + private init() {} + + private var cached: (String, [UTF16.CodeUnit: UInt16])? + + private func makeMap(source: TISInputSource) -> [UTF16.CodeUnit: UInt16] { + var dict: [UTF16.CodeUnit: UInt16] = [:] + guard let layoutDataRaw = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) else { return [:] } + let layoutData = Unmanaged.fromOpaque(layoutDataRaw).takeUnretainedValue() as Data + layoutData.withUnsafeBytes { buf in + guard let base = buf.baseAddress else { return } + let layout = base.assumingMemoryBound(to: UCKeyboardLayout.self) + for keyCode in Self.keyCodeRange { + var deadKeyState: UInt32 = 0 + var length = 0 + var char: UTF16.CodeUnit = 0 + let err = UCKeyTranslate( + layout, + keyCode, + UInt16(kUCKeyActionDisplay), + 0, // modifierKeyState + UInt32(LMGetKbdType()), + OptionBits(kUCKeyTranslateNoDeadKeysBit), + &deadKeyState, + 1, + &length, + &char + ) + guard err == noErr else { continue } + dict[char] = keyCode + } + } + return dict + } + + subscript(key: Character) -> UInt16? { + guard let utf16 = key.utf16.first else { return nil } + let source = TISCopyCurrentKeyboardLayoutInputSource().takeRetainedValue() + guard let rawID = TISGetInputSourceProperty(source, kTISPropertyInputSourceID) else { return nil } + let id = Unmanaged.fromOpaque(rawID).takeUnretainedValue() as String + let map: [UTF16.CodeUnit: UInt16] + if let _map = cached, _map.0 == id { + map = _map.1 + } else { + map = makeMap(source: source) + cached = (id, map) + } + return map[utf16] + } +} + +// MARK: - Shared Event Source + +private let sharedEventSource = CGEventSource(stateID: .hidSystemState) + +// MARK: - App Activation Helper + +private extension NSRunningApplication { + /// Activates the application and waits for it to become active using KVO via Combine + func activateAndWait(timeoutSeconds: TimeInterval = 2.0) async throws { + // If already active, return immediately + if isActive { return } + + activate(options: [.activateIgnoringOtherApps]) + + // Use Combine publisher with continuation to wait for activation + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + var cancellable: AnyCancellable? + + cancellable = self.publisher(for: \.isActive) + .filter { $0 } + .setFailureType(to: KeyError.self) + .timeout(.seconds(timeoutSeconds), scheduler: DispatchQueue.main, customError: { .activationTimeout }) + .first() + .sink( + receiveCompletion: { completion in + switch completion { + case .finished: + break + case .failure(let error): + continuation.resume(throwing: error) + } + cancellable?.cancel() + }, + receiveValue: { _ in + continuation.resume() + cancellable?.cancel() + } + ) + } + + // Small additional delay to ensure app is ready to receive events + try await Task.sleep(for: .milliseconds(50)) + } +} + +extension AXDump { + struct Key: AsyncParsableCommand { + static var configuration = CommandConfiguration( + abstract: "Send keyboard input to an application", + discussion: """ + Send key presses and keyboard shortcuts to an application. + The application must be frontmost to receive key events. + + KEY NAMES: + Letters/Numbers: a-z, 0-9 + Special keys: enter, return, tab, space, escape, delete, backspace + Arrow keys: up, down, left, right + Function keys: f1-f12 + Navigation: home, end, pageup, pagedown + + MODIFIERS (combine with +): + cmd, command - Command key (⌘) + ctrl, control - Control key (⌃) + opt, option, alt - Option key (⌥) + shift - Shift key (⇧) + + EXAMPLES: + axdump key 710 enter Press Enter + axdump key 710 "cmd+c" Copy (⌘C) + axdump key 710 "cmd+v" Paste (⌘V) + axdump key 710 "cmd+shift+s" Save As (⌘⇧S) + axdump key 710 "cmd+a" "cmd+c" Select All then Copy + axdump key 710 tab tab enter Tab twice then Enter + axdump key 710 --type "Hello World" Type text + axdump key 710 escape Press Escape + """ + ) + + @Argument(help: "Process ID of the application") + var pid: Int32 + + @Argument(parsing: .remaining, help: "Key(s) to press (e.g., 'enter', 'cmd+c', 'cmd+shift+s')") + var keys: [String] = [] + + @Option(name: .long, help: "Type a string of text character by character") + var type: String? + + @Option(name: [.customShort("d"), .long], help: "Delay between key presses in milliseconds (default: 50)") + var delay: Int = 50 + + @Flag(name: .shortAndLong, help: "Activate the application before sending keys") + var activate: Bool = false + + @Flag(name: .long, help: "List all known key names") + var listKeys: Bool = false + + func run() async throws { + if listKeys { + printKeyList() + return + } + + guard !keys.isEmpty || type != nil else { + print("Error: No keys specified. Use positional arguments or --type") + throw ExitCode.failure + } + + // Find the application + guard let app = NSRunningApplication.runningApplications(withBundleIdentifier: "").first(where: { $0.processIdentifier == pid }) ?? + NSWorkspace.shared.runningApplications.first(where: { $0.processIdentifier == pid }) else { + print("Error: Could not find application with PID \(pid)") + throw ExitCode.failure + } + + // Activate if requested + if activate { + do { + try await app.activateAndWait() + } catch KeyError.activationTimeout { + print("Warning: Application may not have activated in time") + } + } + + // Type string if specified + if let text = type { + for char in text { + try sendCharacter(char) + try await Task.sleep(for: .milliseconds(delay)) + } + print("Typed: \(text)") + } + + // Send key presses + for keySpec in keys { + let (keyCode, modifiers) = try parseKeySpec(keySpec) + try await sendKey(keyCode: keyCode, modifiers: modifiers) + print("Pressed: \(keySpec)") + + if delay > 0 && keySpec != keys.last { + try await Task.sleep(for: .milliseconds(delay)) + } + } + } + + private func parseKeySpec(_ spec: String) throws -> (CGKeyCode, CGEventFlags) { + let parts = spec.lowercased().split(separator: "+").map { String($0).trimmingCharacters(in: .whitespaces) } + + var modifiers: CGEventFlags = [] + var keyName: String = "" + + for part in parts { + switch part { + case "cmd", "command": + modifiers.insert(.maskCommand) + case "ctrl", "control": + modifiers.insert(.maskControl) + case "opt", "option", "alt": + modifiers.insert(.maskAlternate) + case "shift": + modifiers.insert(.maskShift) + default: + keyName = part + } + } + + guard let keyCode = keyCodeFor(keyName) else { + throw KeyError.unknownKey(keyName) + } + + return (keyCode, modifiers) + } + + private func keyCodeFor(_ name: String) -> CGKeyCode? { + // Special keys with fixed key codes (these are physical keys, not characters) + let specialKeyMap: [String: CGKeyCode] = [ + // Special keys + "return": 36, "enter": 36, + "tab": 48, + "space": 49, + "delete": 51, "backspace": 51, + "escape": 53, "esc": 53, + "forwarddelete": 117, + + // Function keys + "f1": 122, "f2": 120, "f3": 99, "f4": 118, "f5": 96, "f6": 97, + "f7": 98, "f8": 100, "f9": 101, "f10": 109, "f11": 103, "f12": 111, + + // Arrow keys + "left": 123, "right": 124, "down": 125, "up": 126, + + // Navigation + "home": 115, "end": 119, "pageup": 116, "pagedown": 121, + + // Keypad + "kp0": 82, "kp1": 83, "kp2": 84, "kp3": 85, "kp4": 86, + "kp5": 87, "kp6": 88, "kp7": 89, "kp8": 91, "kp9": 92, + "kp.": 65, "kp*": 67, "kp+": 69, "kp/": 75, "kp-": 78, + "kpenter": 76, "kp=": 81, + ] + + // Check special keys first + if let keyCode = specialKeyMap[name] { + return keyCode + } + + // For single characters, use the keyboard layout-aware KeyMap + // This properly handles non-QWERTY layouts + if name.count == 1, let char = name.first { + if let keyCode = KeyMap.shared[char] { + return CGKeyCode(keyCode) + } + } + + return nil + } + + private func sendKey(keyCode: CGKeyCode, modifiers: CGEventFlags) async throws { + guard let keyDown = CGEvent(keyboardEventSource: sharedEventSource, virtualKey: keyCode, keyDown: true), + let keyUp = CGEvent(keyboardEventSource: sharedEventSource, virtualKey: keyCode, keyDown: false) else { + throw KeyError.eventCreationFailure + } + + keyDown.flags = modifiers + keyUp.flags = modifiers + + // Post to specific process instead of global tap + keyDown.postToPid(pid) + + // Small delay between key down and key up + try await Task.sleep(for: .milliseconds(10)) + + keyUp.postToPid(pid) + + // Sequoia workaround: post an extra event with cleared flags after key up + if isSequoiaOrUp { + keyUp.flags = [] + keyUp.postToPid(pid) + } + } + + private func sendCharacter(_ char: Character) throws { + guard let keyDown = CGEvent(keyboardEventSource: sharedEventSource, virtualKey: 0, keyDown: true), + let keyUp = CGEvent(keyboardEventSource: sharedEventSource, virtualKey: 0, keyDown: false) else { + throw KeyError.eventCreationFailure + } + + var unicodeChar = Array(String(char).utf16) + keyDown.keyboardSetUnicodeString(stringLength: unicodeChar.count, unicodeString: &unicodeChar) + + // Post to specific process instead of global tap + keyDown.postToPid(pid) + keyUp.postToPid(pid) + + // Sequoia workaround + if isSequoiaOrUp { + keyUp.flags = [] + keyUp.postToPid(pid) + } + } + + private func printKeyList() { + print(""" + AVAILABLE KEYS: + + Letters: a-z + Numbers: 0-9 + + Special Keys: + enter, return - Return/Enter key + tab - Tab key + space - Space bar + delete, backspace - Delete/Backspace + escape, esc - Escape key + forwarddelete - Forward Delete + + Arrow Keys: + up, down, left, right + + Navigation: + home, end, pageup, pagedown + + Function Keys: + f1, f2, f3, f4, f5, f6, f7, f8, f9, f10, f11, f12 + + Modifiers (combine with +): + cmd, command - Command key (⌘) + ctrl, control - Control key (⌃) + opt, option, alt - Option key (⌥) + shift - Shift key (⇧) + + Examples: + enter - Press Enter + cmd+c - Copy + cmd+v - Paste + cmd+shift+s - Save As + ctrl+alt+delete - Ctrl+Alt+Delete + """) + } + } +} + +enum KeyError: Error, CustomStringConvertible { + case unknownKey(String) + case eventCreationFailure + case activationTimeout + + var description: String { + switch self { + case .unknownKey(let key): + return "Unknown key: '\(key)'. Use --list-keys to see available keys." + case .eventCreationFailure: + return "Failed to create key event" + case .activationTimeout: + return "Timed out waiting for application to activate" + } + } +} diff --git a/Sources/axdump/Commands/ListCommand.swift b/Sources/axdump/Commands/ListCommand.swift new file mode 100644 index 0000000..1a7f892 --- /dev/null +++ b/Sources/axdump/Commands/ListCommand.swift @@ -0,0 +1,91 @@ +import Foundation +import ArgumentParser +import AccessibilityControl +import AppKit + +extension AXDump { + struct List: ParsableCommand { + static var configuration = CommandConfiguration( + abstract: "List running applications with accessibility elements", + discussion: """ + Lists all running applications that can be inspected via accessibility APIs. + By default, only shows regular (foreground) applications. + + EXAMPLES: + axdump list List foreground apps with PIDs + axdump list -a Include background/menu bar apps + axdump list -v Show window count and app title + axdump list -av Verbose listing of all apps + axdump list --list-roles Show all known accessibility roles + """ + ) + + @Flag(name: .shortAndLong, help: "Show all applications (including background)") + var all: Bool = false + + @Flag(name: .shortAndLong, help: "Show detailed information") + var verbose: Bool = false + + @Flag(name: .long, help: "List all known accessibility roles") + var listRoles: Bool = false + + @Flag(name: .long, help: "List all known accessibility subroles") + var listSubroles: Bool = false + + @Flag(name: .long, help: "List all known accessibility actions") + var listActions: Bool = false + + func run() throws { + // Handle reference listings + if listRoles { + print(AXRoles.fullHelpText()) + return + } + if listSubroles { + print(AXSubroles.fullHelpText()) + return + } + if listActions { + print(AXActions.fullHelpText()) + return + } + + guard Accessibility.isTrusted(shouldPrompt: true) else { + print("Error: Accessibility permissions required") + print("Please grant permissions in System Preferences > Security & Privacy > Privacy > Accessibility") + throw ExitCode.failure + } + + let apps = NSWorkspace.shared.runningApplications + let filteredApps = all ? apps : apps.filter { $0.activationPolicy == .regular } + + let sortedApps = filteredApps.sorted { ($0.localizedName ?? "") < ($1.localizedName ?? "") } + + print("Running Applications:") + print(String(repeating: "-", count: 60)) + + for app in sortedApps { + let name = app.localizedName ?? "Unknown" + let pid = app.processIdentifier + let bundleID = app.bundleIdentifier ?? "N/A" + + if verbose { + print("\(String(format: "%6d", pid)) \(name)") + print(" Bundle: \(bundleID)") + + let element = Accessibility.Element(pid: pid) + let windowsAttr: Accessibility.Attribute<[Accessibility.Element]> = element.attribute(.init("AXWindows")) + if let windowCount = try? windowsAttr.count() { + print(" Windows: \(windowCount)") + } + if let title: String = try? element.attribute(.init("AXTitle"))() { + print(" Title: \(title)") + } + print() + } else { + print("\(String(format: "%6d", pid)) \(name) (\(bundleID))") + } + } + } + } +} diff --git a/Sources/axdump/Commands/MenuCommand.swift b/Sources/axdump/Commands/MenuCommand.swift new file mode 100644 index 0000000..4688c7f --- /dev/null +++ b/Sources/axdump/Commands/MenuCommand.swift @@ -0,0 +1,413 @@ +import Foundation +import ArgumentParser +import AccessibilityControl +import AppKit + +extension AXDump { + struct Menu: ParsableCommand { + static var configuration = CommandConfiguration( + abstract: "Explore and activate menu bar items", + discussion: """ + List menu bar items, explore menu hierarchies, and trigger menu actions + via accessibility APIs. This works even when the app isn't frontmost. + + MENU PATHS: + Menu paths use '>' to separate menu levels. + Examples: "File", "File > New", "Edit > Find > Find..." + + EXAMPLES: + axdump menu 710 List top-level menus + axdump menu 710 -m "File" Show File menu items + axdump menu 710 -m "File > New" Show New submenu + axdump menu 710 -m "Edit > Copy" -x Execute Edit > Copy + axdump menu 710 -m "File > Save" -x Execute File > Save + axdump menu 710 --search "paste" Search all menus for "paste" + axdump menu 710 -m "View" --tree Show full menu tree + """ + ) + + @Argument(help: "Process ID of the application") + var pid: Int32 + + @Option(name: [.customShort("m"), .long], help: "Menu path (e.g., 'File', 'File > Save', 'Edit > Find > Find...')") + var menu: String? + + @Flag(name: [.customShort("x"), .long], help: "Execute/activate the menu item") + var execute: Bool = false + + @Option(name: .long, help: "Search all menus for items matching this pattern (case-insensitive)") + var search: String? + + @Flag(name: .long, help: "Show full menu tree (can be slow for large menus)") + var tree: Bool = false + + @Flag(name: .shortAndLong, help: "Verbose output") + var verbose: Bool = false + + @Flag(name: .long, help: "Disable colored output") + var noColor: Bool = false + + func run() throws { + guard Accessibility.isTrusted(shouldPrompt: true) else { + print("Error: Accessibility permissions required") + throw ExitCode.failure + } + + let appElement = Accessibility.Element(pid: pid) + + // Get the menu bar + guard let menuBar = getMenuBar(appElement) else { + print("Error: Could not find menu bar for PID \(pid)") + throw ExitCode.failure + } + + // Search mode + if let searchPattern = search { + try searchMenus(menuBar: menuBar, pattern: searchPattern) + return + } + + // If no menu specified, list top-level menus + guard let menuPath = menu else { + try listTopLevelMenus(menuBar: menuBar) + return + } + + // Parse menu path and navigate + let pathComponents = menuPath.split(separator: ">").map { String($0).trimmingCharacters(in: .whitespaces) } + + guard let targetItem = try navigateToMenuItem(menuBar: menuBar, path: pathComponents) else { + print("Error: Could not find menu item '\(menuPath)'") + throw ExitCode.failure + } + + if execute { + // Execute the menu item + try executeMenuItem(targetItem, path: menuPath) + } else if tree { + // Show full tree + try showMenuTree(targetItem, indent: 0) + } else { + // Show children of this menu/item + try showMenuContents(targetItem, path: menuPath) + } + } + + // MARK: - Menu Bar Access + + private func getMenuBar(_ appElement: Accessibility.Element) -> Accessibility.Element? { + let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = appElement.attribute(.init("AXChildren")) + guard let children = try? childrenAttr() else { return nil } + + for child in children { + if let role: String = try? child.attribute(AXAttribute.role)(), + role == "AXMenuBar" { + return child + } + } + return nil + } + + // MARK: - List Menus + + private func listTopLevelMenus(menuBar: Accessibility.Element) throws { + let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = menuBar.attribute(.init("AXChildren")) + guard let menuBarItems = try? childrenAttr() else { + print("Could not read menu bar items") + return + } + + print("Menu Bar Items:") + print(String(repeating: "-", count: 50)) + + for (index, item) in menuBarItems.enumerated() { + let title = (try? item.attribute(AXAttribute.title)()) ?? "(untitled)" + let enabled = (try? item.attribute(AXAttribute.enabled)()) ?? true + + var line = "[\(index)] \(title)" + if !enabled { + line += " (disabled)" + } + print(line) + } + + print() + print("Use -m \"\" to explore a menu") + } + + // MARK: - Navigate to Menu Item + + private func navigateToMenuItem(menuBar: Accessibility.Element, path: [String]) throws -> Accessibility.Element? { + var current: Accessibility.Element = menuBar + + for (level, name) in path.enumerated() { + // Get children of current element + let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = current.attribute(.init("AXChildren")) + guard let children = try? childrenAttr() else { + if verbose { + print("Warning: No children at level \(level)") + } + return nil + } + + // Find matching child + var found: Accessibility.Element? + for child in children { + let childTitle = (try? child.attribute(AXAttribute.title)()) ?? "" + if childTitle.lowercased() == name.lowercased() || + childTitle.lowercased().hasPrefix(name.lowercased()) { + found = child + break + } + } + + guard let nextElement = found else { + if verbose { + print("Warning: Could not find '\(name)' at level \(level)") + print("Available items:") + for child in children { + let childTitle = (try? child.attribute(AXAttribute.title)()) ?? "(untitled)" + print(" - \(childTitle)") + } + } + return nil + } + + // If this is a menu bar item or menu item with children, we need to get its menu + let role = (try? nextElement.attribute(AXAttribute.role)()) ?? "" + + if role == "AXMenuBarItem" || role == "AXMenuItem" { + // Check if it has a submenu + let subChildrenAttr: Accessibility.Attribute<[Accessibility.Element]> = nextElement.attribute(.init("AXChildren")) + if let subChildren = try? subChildrenAttr(), !subChildren.isEmpty { + // Get the submenu (first child that's a menu) + for subChild in subChildren { + let subRole = (try? subChild.attribute(AXAttribute.role)()) ?? "" + if subRole == "AXMenu" { + current = subChild + break + } + } + } else { + // This is a leaf item + current = nextElement + } + } else { + current = nextElement + } + + // If this is the last item in the path, return the menu item itself (not the submenu) + if level == path.count - 1 { + return nextElement + } + } + + return current + } + + // MARK: - Show Menu Contents + + private func showMenuContents(_ element: Accessibility.Element, path: String) throws { + let role = (try? element.attribute(AXAttribute.role)()) ?? "" + + // If it's a menu item, get its submenu + var menuElement = element + if role == "AXMenuBarItem" || role == "AXMenuItem" { + let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = element.attribute(.init("AXChildren")) + if let children = try? childrenAttr() { + for child in children { + let childRole = (try? child.attribute(AXAttribute.role)()) ?? "" + if childRole == "AXMenu" { + menuElement = child + break + } + } + } + } + + let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = menuElement.attribute(.init("AXChildren")) + guard let items = try? childrenAttr() else { + print("Menu '\(path)' has no items") + return + } + + print("Menu: \(path)") + print(String(repeating: "-", count: 50)) + + for (index, item) in items.enumerated() { + let itemRole = (try? item.attribute(AXAttribute.role)()) ?? "" + + // Skip menu itself + if itemRole == "AXMenu" { continue } + + let title = (try? item.attribute(AXAttribute.title)()) ?? "" + let enabled = (try? item.attribute(AXAttribute.enabled)()) ?? true + let shortcutAttr: Accessibility.Attribute = item.attribute(.init("AXMenuItemCmdChar")) + let shortcut = (try? shortcutAttr()) ?? "" + let modifiersAttr: Accessibility.Attribute = item.attribute(.init("AXMenuItemCmdModifiers")) + let modifiers = (try? modifiersAttr()) ?? 0 + + // Check if has submenu + let subChildrenAttr: Accessibility.Attribute<[Accessibility.Element]> = item.attribute(.init("AXChildren")) + let hasSubmenu = (try? subChildrenAttr())?.contains { (try? $0.attribute(AXAttribute.role)()) == "AXMenu" } ?? false + + // Format line + var line = "" + + if title.isEmpty { + line = "[\(index)] ─────────────────" // Separator + } else { + line = "[\(index)] \(title)" + + if hasSubmenu { + line += " ▶" + } + + if !shortcut.isEmpty { + let modStr = formatModifiers(modifiers) + line += " (\(modStr)\(shortcut))" + } + + if !enabled { + line += " [disabled]" + } + } + + print(line) + } + + print() + print("Use -m \"\(path) > \" to explore submenus") + print("Use -m \"\(path) > \" -x to execute an action") + } + + // MARK: - Execute Menu Item + + private func executeMenuItem(_ item: Accessibility.Element, path: String) throws { + let title = (try? item.attribute(AXAttribute.title)()) ?? path + let enabled = (try? item.attribute(AXAttribute.enabled)()) ?? true + + guard enabled else { + print("Error: Menu item '\(title)' is disabled") + throw ExitCode.failure + } + + // Perform the press action + let action = item.action(.init("AXPress")) + try action() + + print("Executed: \(path)") + + if verbose { + print(" Title: \(title)") + } + } + + // MARK: - Show Menu Tree + + private func showMenuTree(_ element: Accessibility.Element, indent: Int) throws { + let prefix = String(repeating: " ", count: indent) + let role = (try? element.attribute(AXAttribute.role)()) ?? "" + let title = (try? element.attribute(AXAttribute.title)()) ?? "" + + if !title.isEmpty && role != "AXMenu" { + let enabled = (try? element.attribute(AXAttribute.enabled)()) ?? true + var line = "\(prefix)\(title)" + if !enabled { + line += " [disabled]" + } + print(line) + } + + let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = element.attribute(.init("AXChildren")) + guard let children = try? childrenAttr() else { return } + + for child in children { + try showMenuTree(child, indent: indent + 1) + } + } + + // MARK: - Search Menus + + private func searchMenus(menuBar: Accessibility.Element, pattern: String) throws { + print("Searching for '\(pattern)'...") + print(String(repeating: "-", count: 50)) + + var results: [(path: String, title: String, shortcut: String)] = [] + try searchMenuRecursive(menuBar, pattern: pattern.lowercased(), currentPath: "", results: &results) + + if results.isEmpty { + print("No menu items found matching '\(pattern)'") + } else { + print("Found \(results.count) item(s):\n") + for result in results { + var line = result.path + if !result.shortcut.isEmpty { + line += " (\(result.shortcut))" + } + print(line) + } + } + } + + private func searchMenuRecursive( + _ element: Accessibility.Element, + pattern: String, + currentPath: String, + results: inout [(path: String, title: String, shortcut: String)] + ) throws { + let role = (try? element.attribute(AXAttribute.role)()) ?? "" + let title = (try? element.attribute(AXAttribute.title)()) ?? "" + + let newPath: String + if title.isEmpty || role == "AXMenuBar" || role == "AXMenu" { + newPath = currentPath + } else if currentPath.isEmpty { + newPath = title + } else { + newPath = "\(currentPath) > \(title)" + } + + // Check if this item matches + if !title.isEmpty && title.lowercased().contains(pattern) { + let shortcutAttr: Accessibility.Attribute = element.attribute(.init("AXMenuItemCmdChar")) + let shortcut = (try? shortcutAttr()) ?? "" + let modifiersAttr: Accessibility.Attribute = element.attribute(.init("AXMenuItemCmdModifiers")) + let modifiers = (try? modifiersAttr()) ?? 0 + let fullShortcut = shortcut.isEmpty ? "" : "\(formatModifiers(modifiers))\(shortcut)" + results.append((path: newPath, title: title, shortcut: fullShortcut)) + } + + // Recurse into children + let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = element.attribute(.init("AXChildren")) + guard let children = try? childrenAttr() else { return } + + for child in children { + try searchMenuRecursive(child, pattern: pattern, currentPath: newPath, results: &results) + } + } + + // MARK: - Helpers + + private func formatModifiers(_ modifiers: Int) -> String { + var result = "" + // macOS modifier flags: 1=Shift, 2=Option, 4=Control, 8=Command (but stored inversely in some cases) + // Actually the AXMenuItemCmdModifiers uses different encoding + // 0 = Command only, 1 = Command+Shift, 2 = Command+Option, etc. + + // Simplified: just show ⌘ for now since most shortcuts use Command + if modifiers == 0 { + result = "⌘" + } else if modifiers & 1 != 0 { + result = "⌘⇧" + } else if modifiers & 2 != 0 { + result = "⌘⌥" + } else if modifiers & 4 != 0 { + result = "⌘⌃" + } else { + result = "⌘" + } + return result + } + } +} diff --git a/Sources/axdump/Commands/ObserveCommand.swift b/Sources/axdump/Commands/ObserveCommand.swift new file mode 100644 index 0000000..a1593c4 --- /dev/null +++ b/Sources/axdump/Commands/ObserveCommand.swift @@ -0,0 +1,265 @@ +import Foundation +import ArgumentParser +import AccessibilityControl + +extension AXDump { + struct Observe: ParsableCommand { + static var configuration = CommandConfiguration( + abstract: "Observe accessibility notifications for an application", + discussion: """ + Monitor accessibility notifications in real-time. Each notification is printed + with a timestamp. Press Ctrl+C to stop observing. + + COMMON NOTIFICATIONS: + AXValueChanged - Element value changed + AXFocusedUIElementChanged - Focus moved to different element + AXFocusedWindowChanged - Different window got focus + AXSelectedTextChanged - Text selection changed + AXSelectedChildrenChanged - Child selection changed + AXWindowCreated/Moved/Resized - Window events + AXMenuOpened/Closed - Menu events + AXApplicationActivated - App became frontmost + + Use -n list to see all common notifications. + + EXAMPLES: + axdump observe 710 Observe focus changes (default) + axdump observe 710 -n list List available notifications + axdump observe 710 -n AXValueChanged Observe value changes + axdump observe 710 -n ValueChanged,Focused Multiple (AX prefix optional) + axdump observe 710 -n all Observe all notifications + axdump observe 710 -n all -v Verbose (show element details) + axdump observe 710 -w -n AXWindowMoved Observe from focused window + axdump observe 710 -n all -j JSON output + """ + ) + + @Argument(help: "Process ID of the application") + var pid: Int32 + + @Option(name: [.customShort("n"), .long], help: "Notification(s) to observe (comma-separated). Use 'list' to show, 'all' for all.") + var notifications: String = "AXFocusedUIElementChanged" + + @Option(name: [.customShort("p"), .long], help: "Path to element to observe (dot-separated child indices)") + var path: String? + + @Flag(name: [.customShort("F"), .long], help: "Observe focused element") + var focused: Bool = false + + @Flag(name: .shortAndLong, help: "Observe focused window") + var window: Bool = false + + @Flag(name: [.customShort("j"), .long], help: "Output as JSON") + var json: Bool = false + + @Flag(name: [.customShort("v"), .long], help: "Verbose output (show element details)") + var verbose: Bool = false + + @Flag(name: .long, help: "Disable colored output") + var noColor: Bool = false + + func run() throws { + guard Accessibility.isTrusted(shouldPrompt: true) else { + print("Error: Accessibility permissions required") + throw ExitCode.failure + } + + // Handle 'list' option + if notifications.lowercased() == "list" { + print("Common Accessibility Notifications:") + print(String(repeating: "-", count: 40)) + for notification in AXNotifications.all { + print(" \(notification)") + } + return + } + + let appElement = Accessibility.Element(pid: pid) + + // Determine target element + var targetElement: Accessibility.Element = appElement + + if focused { + guard let focusedElement: Accessibility.Element = try? appElement.attribute(.init("AXFocusedUIElement"))() else { + print("Error: Could not get focused element for PID \(pid)") + throw ExitCode.failure + } + targetElement = focusedElement + } else if window { + guard let focusedWindow: Accessibility.Element = try? appElement.attribute(.init("AXFocusedWindow"))() else { + print("Error: Could not get focused window for PID \(pid)") + throw ExitCode.failure + } + targetElement = focusedWindow + } + + // Navigate via path if specified + if let pathString = path { + targetElement = try navigateToPath(from: targetElement, path: pathString) + } + + // Print element info + printElementInfo(targetElement) + + // Determine which notifications to observe + let notificationNames: [String] + if notifications.lowercased() == "all" { + notificationNames = AXNotifications.all + } else { + notificationNames = notifications.split(separator: ",") + .map { String($0).trimmingCharacters(in: .whitespaces) } + .map { $0.hasPrefix("AX") ? $0 : "AX\($0)" } + } + + print("Observing notifications: \(notificationNames.joined(separator: ", "))") + print("Press Ctrl+C to stop") + print(String(repeating: "=", count: 60)) + print() + + // Create observer + let observer = try Accessibility.Observer(pid: pid, on: .main) + + // Store tokens to keep observations alive + var tokens: [Accessibility.Observer.Token] = [] + + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "HH:mm:ss.SSS" + + let useColor = !noColor + + for notificationName in notificationNames { + do { + let token = try observer.observe( + .init(notificationName), + for: targetElement + ) { [self] info in + let timestamp = dateFormatter.string(from: Date()) + + if json { + var output: [String: Any] = [ + "timestamp": timestamp, + "notification": notificationName + ] + + if let element = info["AXUIElement"] as? Accessibility.Element { + let printer = ElementPrinter() + output["element"] = printer.formatElementForJSON(element) + let pathInfo = computeElementPath(element, appElement: appElement) + output["path"] = pathInfo.path + output["chain"] = pathInfo.chain + } + + if let jsonData = try? JSONSerialization.data(withJSONObject: output, options: [.sortedKeys]), + let jsonString = String(data: jsonData, encoding: .utf8) { + print(jsonString) + } + } else { + let notifColor = colorForNotification(notificationName) + + var line = Color.dim.wrap("[\(timestamp)]", enabled: useColor) + " " + line += notifColor.wrap(notificationName, enabled: useColor) + + if let element = info["AXUIElement"] as? Accessibility.Element { + let pathInfo = computeElementPath(element, appElement: appElement) + line += " " + Color.dim.wrap("@", enabled: useColor) + " " + line += Color.blue.wrap(pathInfo.path, enabled: useColor) + if verbose { + line += "\n " + Color.dim.wrap("chain:", enabled: useColor) + " " + line += Color.magenta.wrap(pathInfo.chain, enabled: useColor) + line += "\n " + Color.dim.wrap("element:", enabled: useColor) + " " + line += formatElementColored(element, useColor: useColor) + } + } else { + line += " " + Color.dim.wrap("(no element)", enabled: useColor) + } + + print(line) + } + + fflush(stdout) + } + tokens.append(token) + } catch { + if verbose { + print("Warning: Could not observe \(notificationName): \(error)") + } + } + } + + if tokens.isEmpty { + print("Error: Could not register for any notifications") + throw ExitCode.failure + } + + print("Successfully registered for \(tokens.count) notification(s)") + print() + + // Keep running + RunLoop.main.run() + } + + private func printElementInfo(_ element: Accessibility.Element) { + print("Observing Element:") + print(String(repeating: "-", count: 40)) + + if let role: String = try? element.attribute(AXAttribute.role)() { + print("Role: \(role)") + } + if let title: String = try? element.attribute(AXAttribute.title)() { + print("Title: \(title)") + } + if let id: String = try? element.attribute(AXAttribute.identifier)() { + print("Identifier: \(id)") + } + + print() + } + + private func colorForNotification(_ name: String) -> Color { + switch name { + case "AXValueChanged", "AXSelectedTextChanged": + return .green + case "AXFocusedUIElementChanged", "AXFocusedWindowChanged": + return .cyan + case "AXLayoutChanged", "AXResized", "AXMoved": + return .yellow + case "AXWindowCreated", "AXWindowMoved", "AXWindowResized": + return .blue + case "AXApplicationActivated", "AXApplicationDeactivated": + return .magenta + case "AXMenuOpened", "AXMenuClosed", "AXMenuItemSelected": + return .brightMagenta + case "AXUIElementDestroyed": + return .red + case "AXCreated": + return .brightGreen + case "AXTitleChanged": + return .brightCyan + default: + return .white + } + } + + private func formatElementColored(_ element: Accessibility.Element, useColor: Bool) -> String { + var parts: [String] = [] + + if let role: String = try? element.attribute(AXAttribute.role)() { + parts.append(Color.cyan.wrap("role", enabled: useColor) + "=" + Color.white.wrap(role, enabled: useColor)) + } + if let title: String = try? element.attribute(AXAttribute.title)() { + let truncated = title.count > 30 ? String(title.prefix(30)) + "..." : title + parts.append(Color.yellow.wrap("title", enabled: useColor) + "=\"" + Color.white.wrap(truncated, enabled: useColor) + "\"") + } + if let id: String = try? element.attribute(AXAttribute.identifier)() { + parts.append(Color.green.wrap("id", enabled: useColor) + "=\"" + Color.white.wrap(id, enabled: useColor) + "\"") + } + if let value: Any = try? element.attribute(AXAttribute.value)() { + let strValue = String(describing: value) + let truncated = strValue.count > 30 ? String(strValue.prefix(30)) + "..." : strValue + parts.append(Color.magenta.wrap("value", enabled: useColor) + "=\"" + Color.white.wrap(truncated, enabled: useColor) + "\"") + } + + return parts.isEmpty ? "(element)" : parts.joined(separator: " ") + } + } +} diff --git a/Sources/axdump/Commands/QueryCommand.swift b/Sources/axdump/Commands/QueryCommand.swift new file mode 100644 index 0000000..6e3cb83 --- /dev/null +++ b/Sources/axdump/Commands/QueryCommand.swift @@ -0,0 +1,232 @@ +import Foundation +import ArgumentParser +import AccessibilityControl + +extension AXDump { + struct Query: ParsableCommand { + static var configuration = CommandConfiguration( + abstract: "Query specific element relationships", + discussion: """ + Query relationships between accessibility elements like parent, children, + siblings, or list all attributes of an element. + + RELATIONS: + children - Direct child elements + parent - Parent element + siblings - Sibling elements (same parent) + windows - Application windows + focused - Focused window and UI element + all-attributes - All attributes with truncated values (aliases: attrs, attributes) + + EXAMPLES: + axdump query 710 -r windows List all windows + axdump query 710 -r children Show app's direct children + axdump query 710 -r children -F Children of focused element + axdump query 710 -r siblings -F Siblings of focused element + axdump query 710 -r all-attributes List all attributes (truncated) + axdump query 710 -r focused Show focused window and element + """ + ) + + @Argument(help: "Process ID of the application") + var pid: Int32 + + @Option(name: [.customShort("r"), .long], help: "Relationship to query: children, parent, siblings, windows, focused, all-attributes") + var relation: String = "children" + + @Option(name: [.customShort("f"), .long], help: "Fields to display") + var fields: String = "standard" + + @Option(name: .shortAndLong, help: "Verbosity level") + var verbosity: Int = 1 + + @Flag(name: [.customShort("F"), .long], help: "Query from focused element instead of application root") + var focused: Bool = false + + @Option(name: [.customShort("p"), .long], help: "Path to element (dot-separated child indices)") + var path: String? + + @Flag(name: .long, help: "Disable colored output") + var noColor: Bool = false + + func run() throws { + guard Accessibility.isTrusted(shouldPrompt: true) else { + print("Error: Accessibility permissions required") + throw ExitCode.failure + } + + let appElement = Accessibility.Element(pid: pid) + + var targetElement: Accessibility.Element = appElement + + if focused { + guard let focusedElement: Accessibility.Element = try? appElement.attribute(.init("AXFocusedUIElement"))() else { + print("Error: Could not get focused element for PID \(pid)") + throw ExitCode.failure + } + targetElement = focusedElement + } + + if let pathString = path { + targetElement = try navigateToPath(from: targetElement, path: pathString) + } + + let attributeFields = AttributeFields.parse(fields) + let printer = ElementPrinter(fields: attributeFields, verbosity: verbosity) + + switch relation.lowercased() { + case "children": + queryChildren(of: targetElement, printer: printer) + + case "parent": + queryParent(of: targetElement, printer: printer) + + case "siblings": + querySiblings(of: targetElement, printer: printer) + + case "windows": + queryWindows(of: appElement, printer: printer) + + case "focused": + queryFocused(of: appElement, printer: printer) + + case "all-attributes", "attrs", "attributes": + queryAllAttributes(of: targetElement) + + default: + print("Unknown relation: \(relation)") + print("Valid options: children, parent, siblings, windows, focused, all-attributes") + throw ExitCode.failure + } + } + + private func queryChildren(of element: Accessibility.Element, printer: ElementPrinter) { + print("Children:") + print(String(repeating: "-", count: 40)) + + let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = element.attribute(.init("AXChildren")) + guard let children: [Accessibility.Element] = try? childrenAttr() else { + print("(no children or unable to read)") + return + } + + print("Count: \(children.count)") + print() + + for (index, child) in children.enumerated() { + print("[\(index)] \(printer.formatElement(child))") + } + } + + private func queryParent(of element: Accessibility.Element, printer: ElementPrinter) { + print("Parent:") + print(String(repeating: "-", count: 40)) + + guard let parent: Accessibility.Element = try? element.attribute(.init("AXParent"))() else { + print("(no parent or unable to read)") + return + } + + print(printer.formatElement(parent)) + } + + private func querySiblings(of element: Accessibility.Element, printer: ElementPrinter) { + print("Siblings:") + print(String(repeating: "-", count: 40)) + + guard let parent: Accessibility.Element = try? element.attribute(.init("AXParent"))() else { + print("(no parent - cannot determine siblings)") + return + } + + let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = parent.attribute(.init("AXChildren")) + guard let siblings: [Accessibility.Element] = try? childrenAttr() else { + print("(unable to read parent's children)") + return + } + + let filteredSiblings = siblings.filter { $0 != element } + print("Count: \(filteredSiblings.count)") + print() + + for (index, sibling) in filteredSiblings.enumerated() { + print("[\(index)] \(printer.formatElement(sibling))") + } + } + + private func queryWindows(of element: Accessibility.Element, printer: ElementPrinter) { + print("Windows:") + print(String(repeating: "-", count: 40)) + + let windowsAttr: Accessibility.Attribute<[Accessibility.Element]> = element.attribute(.init("AXWindows")) + guard let windows: [Accessibility.Element] = try? windowsAttr() else { + print("(no windows or unable to read)") + return + } + + print("Count: \(windows.count)") + print() + + for (index, window) in windows.enumerated() { + print("[\(index)] \(printer.formatElement(window))") + } + } + + private func queryFocused(of element: Accessibility.Element, printer: ElementPrinter) { + print("Focused Elements:") + print(String(repeating: "-", count: 40)) + + if let focusedWindow: Accessibility.Element = try? element.attribute(.init("AXFocusedWindow"))() { + print("Focused Window:") + print(" \(printer.formatElement(focusedWindow))") + print() + } + + if let focusedElement: Accessibility.Element = try? element.attribute(.init("AXFocusedUIElement"))() { + print("Focused UI Element:") + print(" \(printer.formatElement(focusedElement))") + } + } + + private func queryAllAttributes(of element: Accessibility.Element) { + print("All Attributes:") + print(String(repeating: "-", count: 40)) + + guard let attributes = try? element.supportedAttributes() else { + print("(unable to read attributes)") + return + } + + for attr in attributes.sorted(by: { $0.name.value < $1.name.value }) { + let name = attr.name.value + if let value: Any = try? attr() { + let strValue = String(describing: value) + let truncated = strValue.count > 80 ? String(strValue.prefix(80)) + "..." : strValue + print("\(name): \(truncated)") + } else { + print("\(name): (unable to read)") + } + } + + print() + print("Parameterized Attributes:") + print(String(repeating: "-", count: 40)) + + if let paramAttrs = try? element.supportedParameterizedAttributes() { + for attr in paramAttrs.sorted(by: { $0.name.value < $1.name.value }) { + print(attr.name.value) + } + } + + print() + print("Actions:") + print(String(repeating: "-", count: 40)) + + if let actions = try? element.supportedActions() { + for action in actions.sorted(by: { $0.name.value < $1.name.value }) { + print("\(action.name.value): \(action.description)") + } + } + } + } +} diff --git a/Sources/axdump/Commands/ScreenshotCommand.swift b/Sources/axdump/Commands/ScreenshotCommand.swift new file mode 100644 index 0000000..880f5a6 --- /dev/null +++ b/Sources/axdump/Commands/ScreenshotCommand.swift @@ -0,0 +1,334 @@ +import Foundation +import ArgumentParser +import AccessibilityControl +import WindowControl +import CoreGraphics +import AppKit +import ImageIO + +extension AXDump { + struct Screenshot: ParsableCommand { + static var configuration = CommandConfiguration( + abstract: "Capture a screenshot of an application window", + discussion: """ + Captures a window and saves it as a PNG file. Optionally draws bounding boxes + around specified accessibility elements. + + EXAMPLES: + axdump screenshot 710 Screenshot focused window + axdump screenshot 710 -o ~/Desktop/win.png Custom output path + axdump screenshot 710 -i 0 Screenshot first window + axdump screenshot 710 --list List available windows + axdump screenshot 710 -b 0.1.2 Draw box around element at path + axdump screenshot 710 -b 0.1.2 -b 0.2.0 Multiple bounding boxes + axdump screenshot 710 --shadow Include window shadow + """ + ) + + @Argument(help: "Process ID of the application") + var pid: Int32 + + @Option(name: [.customShort("o"), .long], help: "Output file path (default: window_.png)") + var output: String? + + @Option(name: [.customShort("i"), .long], help: "Window index to capture (default: focused window)") + var windowIndex: Int? + + @Flag(name: .long, help: "List available windows for the application") + var list: Bool = false + + @Flag(name: .long, help: "Include window shadow in screenshot") + var shadow: Bool = false + + @Option(name: [.customShort("b"), .long], parsing: .upToNextOption, help: "Element path(s) to draw bounding boxes around") + var boundingBox: [String] = [] + + @Option(name: .long, help: "Bounding box color: red, green, blue, yellow, orange, cyan, magenta, white") + var boxColor: String = "red" + + @Option(name: .long, help: "Bounding box line width (default: 2.0)") + var boxWidth: Double = 2.0 + + func run() throws { + guard Accessibility.isTrusted(shouldPrompt: true) else { + print("Error: Accessibility permissions required") + throw ExitCode.failure + } + + let appElement = Accessibility.Element(pid: pid) + + if list { + try listWindows(appElement) + return + } + + // Get the window element + let windowElement: Accessibility.Element + if let index = windowIndex { + let windowsAttr: Accessibility.Attribute<[Accessibility.Element]> = appElement.attribute(.init("AXWindows")) + guard let windows: [Accessibility.Element] = try? windowsAttr() else { + print("Error: Could not get windows for PID \(pid)") + throw ExitCode.failure + } + guard index >= 0 && index < windows.count else { + print("Error: Window index \(index) out of range (0..<\(windows.count))") + throw ExitCode.failure + } + windowElement = windows[index] + } else { + guard let focusedWindow: Accessibility.Element = try? appElement.attribute(.init("AXFocusedWindow"))() else { + print("Error: Could not get focused window for PID \(pid)") + print("Tip: Use --list to see available windows, then -i to select one") + throw ExitCode.failure + } + windowElement = focusedWindow + } + + // Get window ID + let window: Window + do { + window = try windowElement.window() + } catch { + print("Error: Could not get window ID: \(error)") + throw ExitCode.failure + } + + // Get window bounds for bounding box calculations + let windowFrame: CGRect = getElementFrame(windowElement) ?? .zero + + // Capture the window + var imageOptions: CGWindowImageOption = [.boundsIgnoreFraming] + if shadow { + imageOptions = [] + } + + guard let cgImage = CGWindowListCreateImage( + .null, + .optionIncludingWindow, + window.raw, + imageOptions + ) else { + print("Error: Failed to capture window image") + throw ExitCode.failure + } + + // Draw bounding boxes if requested + let finalImage: CGImage + if !boundingBox.isEmpty { + finalImage = try drawBoundingBoxes( + on: cgImage, + windowElement: windowElement, + windowFrame: windowFrame, + paths: boundingBox + ) + } else { + finalImage = cgImage + } + + // Determine output path + let outputPath: String + if let userPath = output { + outputPath = (userPath as NSString).expandingTildeInPath + } else { + outputPath = "window_\(window.raw).png" + } + + // Save as PNG + try saveImage(finalImage, to: outputPath) + + print("Screenshot saved to: \(outputPath)") + print("Window ID: \(window.raw)") + print("Image size: \(finalImage.width) x \(finalImage.height)") + + if !boundingBox.isEmpty { + print("Bounding boxes drawn: \(boundingBox.count)") + } + } + + private func listWindows(_ appElement: Accessibility.Element) throws { + let windowsAttr: Accessibility.Attribute<[Accessibility.Element]> = appElement.attribute(.init("AXWindows")) + guard let windows: [Accessibility.Element] = try? windowsAttr() else { + print("Error: Could not get windows for PID \(pid)") + throw ExitCode.failure + } + + if let appName: String = try? appElement.attribute(.init("AXTitle"))() { + print("Windows for: \(appName) (PID: \(pid))") + } else { + print("Windows for PID: \(pid)") + } + print(String(repeating: "-", count: 60)) + + if windows.isEmpty { + print("No windows found") + return + } + + let focusedWindow: Accessibility.Element? = try? appElement.attribute(.init("AXFocusedWindow"))() + + for (index, window) in windows.enumerated() { + var info: [String] = ["[\(index)]"] + + let isFocused = focusedWindow != nil && window == focusedWindow + if isFocused { + info.append("*") + } + + if let title: String = try? window.attribute(.init("AXTitle"))() { + info.append("title=\"\(title)\"") + } + + if let frame = getElementFrame(window) { + info.append("frame=(\(Int(frame.origin.x)),\(Int(frame.origin.y)) \(Int(frame.width))x\(Int(frame.height)))") + } + + if let windowID = try? window.window() { + info.append("id=\(windowID.raw)") + } + + print(info.joined(separator: " ")) + } + + print() + print("* = focused window") + print("Use -i to capture a specific window") + } + + private func getElementFrame(_ element: Accessibility.Element) -> CGRect? { + if let frame = try? element.attribute(AXAttribute.frame)() { + return frame + } + + if let pos = try? element.attribute(AXAttribute.position)(), + let size = try? element.attribute(AXAttribute.size)() { + return CGRect(origin: pos, size: size) + } + + return nil + } + + private func drawBoundingBoxes( + on image: CGImage, + windowElement: Accessibility.Element, + windowFrame: CGRect, + paths: [String] + ) throws -> CGImage { + let width = image.width + let height = image.height + + guard let colorSpace = image.colorSpace ?? CGColorSpace(name: CGColorSpace.sRGB), + let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: 0, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { + print("Warning: Could not create graphics context for bounding boxes") + return image + } + + context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) + + let boxCGColor = parseColor(boxColor) + context.setStrokeColor(boxCGColor) + context.setLineWidth(CGFloat(boxWidth)) + + let scaleX = CGFloat(width) / windowFrame.width + let scaleY = CGFloat(height) / windowFrame.height + + for path in paths { + do { + let element = try navigateToPath(from: windowElement, path: path) + + guard let elementFrame = getElementFrame(element) else { + print("Warning: Could not get frame for element at path '\(path)'") + continue + } + + let relativeX = elementFrame.origin.x - windowFrame.origin.x + let relativeY = elementFrame.origin.y - windowFrame.origin.y + + let imageX = relativeX * scaleX + let imageY = relativeY * scaleY + let imageWidth = elementFrame.width * scaleX + let imageHeight = elementFrame.height * scaleY + + let flippedY = CGFloat(height) - imageY - imageHeight + + let rect = CGRect(x: imageX, y: flippedY, width: imageWidth, height: imageHeight) + context.stroke(rect) + + if let role: String = try? element.attribute(.init("AXRole"))() { + var desc = " Box at '\(path)': \(role)" + if let title: String = try? element.attribute(.init("AXTitle"))() { + desc += " title=\"\(title)\"" + } + desc += " frame=(\(Int(elementFrame.origin.x)),\(Int(elementFrame.origin.y)) \(Int(elementFrame.width))x\(Int(elementFrame.height)))" + print(desc) + } + + } catch { + print("Warning: Could not find element at path '\(path)': \(error)") + } + } + + guard let finalImage = context.makeImage() else { + print("Warning: Could not create final image with bounding boxes") + return image + } + + return finalImage + } + + private func parseColor(_ name: String) -> CGColor { + switch name.lowercased() { + case "red": return CGColor(red: 1, green: 0, blue: 0, alpha: 1) + case "green": return CGColor(red: 0, green: 1, blue: 0, alpha: 1) + case "blue": return CGColor(red: 0, green: 0, blue: 1, alpha: 1) + case "yellow": return CGColor(red: 1, green: 1, blue: 0, alpha: 1) + case "orange": return CGColor(red: 1, green: 0.5, blue: 0, alpha: 1) + case "cyan": return CGColor(red: 0, green: 1, blue: 1, alpha: 1) + case "magenta": return CGColor(red: 1, green: 0, blue: 1, alpha: 1) + case "white": return CGColor(red: 1, green: 1, blue: 1, alpha: 1) + default: return CGColor(red: 1, green: 0, blue: 0, alpha: 1) + } + } + } +} + +// MARK: - Image Saving Helper + +func saveImage(_ image: CGImage, to path: String) throws { + let url = URL(fileURLWithPath: path) + guard let destination = CGImageDestinationCreateWithURL( + url as CFURL, + "public.png" as CFString, + 1, + nil + ) else { + throw ImageError.cannotCreateDestination(path) + } + + CGImageDestinationAddImage(destination, image, nil) + + guard CGImageDestinationFinalize(destination) else { + throw ImageError.cannotWrite(path) + } +} + +enum ImageError: Error, CustomStringConvertible { + case cannotCreateDestination(String) + case cannotWrite(String) + + var description: String { + switch self { + case .cannotCreateDestination(let path): + return "Could not create image destination at \(path)" + case .cannotWrite(let path): + return "Failed to write image to \(path)" + } + } +} diff --git a/Sources/axdump/Commands/SetCommand.swift b/Sources/axdump/Commands/SetCommand.swift new file mode 100644 index 0000000..950ca28 --- /dev/null +++ b/Sources/axdump/Commands/SetCommand.swift @@ -0,0 +1,185 @@ +import Foundation +import ArgumentParser +import AccessibilityControl + +extension AXDump { + struct Set: ParsableCommand { + static var configuration = CommandConfiguration( + abstract: "Set an attribute value on an accessibility element", + discussion: """ + Set mutable attribute values on elements. Navigate to the target element + using path notation or focus options. + + COMMON SETTABLE ATTRIBUTES: + AXValue - Element's value (text fields, sliders, etc.) + AXFocused - Whether element has focus (true/false) + + VALUE TYPES: + - Strings: Just provide the text + - Booleans: true, false, yes, no, 1, 0 + - Numbers: Integer or decimal values + + EXAMPLES: + axdump set 710 -a Value -v "Hello" -p 0.1.2 Set text value + axdump set 710 -a Focused -v true -p 0.1 Focus element + axdump set 710 -a Value -v 50 -p 0.3 Set slider to 50 + axdump set 710 -a Value -v "search term" -F Set focused element's value + """ + ) + + @Argument(help: "Process ID of the application") + var pid: Int32 + + @Option(name: [.customShort("a"), .long], help: "Attribute to set (can omit 'AX' prefix)") + var attribute: String + + @Option(name: [.customShort("v"), .long], help: "Value to set") + var value: String + + @Option(name: [.customShort("p"), .long], help: "Path to element (dot-separated child indices)") + var path: String? + + @Option(name: [.customShort("c"), .long], help: "Index of child element") + var child: Int? + + @Flag(name: [.customShort("F"), .long], help: "Target the focused element") + var focused: Bool = false + + @Flag(name: .shortAndLong, help: "Target the focused window") + var window: Bool = false + + @Flag(name: .long, help: "Verbose output") + var verbose: Bool = false + + func run() throws { + guard Accessibility.isTrusted(shouldPrompt: true) else { + print("Error: Accessibility permissions required") + throw ExitCode.failure + } + + let appElement = Accessibility.Element(pid: pid) + + // Determine target element + var targetElement: Accessibility.Element = appElement + + if focused { + guard let focusedElement: Accessibility.Element = try? appElement.attribute(.init("AXFocusedUIElement"))() else { + print("Error: Could not get focused element for PID \(pid)") + throw ExitCode.failure + } + targetElement = focusedElement + } else if window { + guard let focusedWindow: Accessibility.Element = try? appElement.attribute(.init("AXFocusedWindow"))() else { + print("Error: Could not get focused window for PID \(pid)") + throw ExitCode.failure + } + targetElement = focusedWindow + } + + if let childIndex = child { + targetElement = try navigateToChild(from: targetElement, index: childIndex) + } + + if let pathString = path { + targetElement = try navigateToPath(from: targetElement, path: pathString) + } + + // Normalize attribute name + let attrName = attribute.hasPrefix("AX") ? attribute : "AX\(attribute)" + + // Print element info if verbose + if verbose { + printElementInfo(targetElement) + } + + // Check if attribute is settable + let attr = targetElement.attribute(.init(attrName)) as Accessibility.Attribute + guard (try? attr.isSettable()) == true else { + print("Error: Attribute '\(attrName)' is not settable on this element") + throw ExitCode.failure + } + + // Get current value for comparison + let oldValue: Any? = try? attr() + + // Parse and set the value based on attribute type + do { + try setValue(value, forAttribute: attrName, on: targetElement) + + print("Set \(attrName) = \(value)") + + if verbose { + if let old = oldValue { + print(" Previous value: \(String(describing: old))") + } + // Read back the new value + if let newValue: Any = try? attr() { + print(" New value: \(String(describing: newValue))") + } + } + } catch { + print("Error: Failed to set \(attrName): \(error)") + throw ExitCode.failure + } + } + + private func setValue(_ valueStr: String, forAttribute attrName: String, on element: Accessibility.Element) throws { + // Try to determine the appropriate type based on the attribute name and value + switch attrName { + case "AXFocused", "AXEnabled", "AXSelected", "AXDisclosed", "AXExpanded": + // Boolean attributes + let boolValue = parseBool(valueStr) + let mutableAttr = element.mutableAttribute(.init(attrName)) as Accessibility.MutableAttribute + try mutableAttr(assign: boolValue) + + case "AXValue": + // Value can be string, number, or bool depending on element + // Try number first, then bool, then string + if let intVal = Int(valueStr) { + let mutableAttr = element.mutableAttribute(.init(attrName)) as Accessibility.MutableAttribute + try mutableAttr(assign: intVal) + } else if let doubleVal = Double(valueStr) { + let mutableAttr = element.mutableAttribute(.init(attrName)) as Accessibility.MutableAttribute + try mutableAttr(assign: doubleVal) + } else if valueStr.lowercased() == "true" || valueStr.lowercased() == "false" { + let mutableAttr = element.mutableAttribute(.init(attrName)) as Accessibility.MutableAttribute + try mutableAttr(assign: parseBool(valueStr)) + } else { + // Default to string + let mutableAttr = element.mutableAttribute(.init(attrName)) as Accessibility.MutableAttribute + try mutableAttr(assign: valueStr) + } + + default: + // Default: try as string + let mutableAttr = element.mutableAttribute(.init(attrName)) as Accessibility.MutableAttribute + try mutableAttr(assign: valueStr) + } + } + + private func parseBool(_ value: String) -> Bool { + switch value.lowercased() { + case "true", "yes", "1", "on": return true + case "false", "no", "0", "off": return false + default: return !value.isEmpty + } + } + + private func printElementInfo(_ element: Accessibility.Element) { + print("Target Element:") + print(String(repeating: "-", count: 40)) + + if let role: String = try? element.attribute(AXAttribute.role)() { + print(" Role: \(role)") + } + if let title: String = try? element.attribute(AXAttribute.title)() { + print(" Title: \(title)") + } + if let id: String = try? element.attribute(AXAttribute.identifier)() { + print(" Identifier: \(id)") + } + + print() + } + } +} diff --git a/Sources/axdump/Commands/WatchCommand.swift b/Sources/axdump/Commands/WatchCommand.swift new file mode 100644 index 0000000..015e1c1 --- /dev/null +++ b/Sources/axdump/Commands/WatchCommand.swift @@ -0,0 +1,280 @@ +import Foundation +import ArgumentParser +import AccessibilityControl +import AppKit +import CoreGraphics + +extension AXDump { + struct Watch: ParsableCommand { + static var configuration = CommandConfiguration( + abstract: "Live watch element under mouse cursor", + discussion: """ + Continuously displays information about the accessibility element under + the mouse cursor. Useful for exploring UI and finding elements. + + Press Ctrl+C to stop. + + EXAMPLES: + axdump watch Watch any app + axdump watch 710 Watch only Finder (PID 710) + axdump watch --click Also show click path + axdump watch --actions Show available actions + axdump watch --path Show element path for automation + """ + ) + + @Argument(help: "Optional: Only watch elements in this PID") + var pid: Int32? + + @Flag(name: .long, help: "Show available actions on the element") + var actions: Bool = false + + @Flag(name: .long, help: "Show element path (for use with other commands)") + var path: Bool = false + + @Flag(name: .long, help: "Show full attribute list") + var full: Bool = false + + @Option(name: [.customShort("i"), .long], help: "Update interval in milliseconds (default: 100)") + var interval: Int = 100 + + @Flag(name: .long, help: "Disable colored output") + var noColor: Bool = false + + func run() throws { + guard Accessibility.isTrusted(shouldPrompt: true) else { + print("Error: Accessibility permissions required") + throw ExitCode.failure + } + + let useColor = !noColor + print(Color.cyan.wrap("Watching accessibility elements under cursor...", enabled: useColor)) + print(Color.dim.wrap("Press Ctrl+C to stop\n", enabled: useColor)) + + var lastElement: Accessibility.Element? + var lastInfo = "" + + // Set up signal handler for clean exit + signal(SIGINT) { _ in + print("\n\nStopped watching.") + Darwin.exit(0) + } + + while true { + let mouseLocation = NSEvent.mouseLocation + + // Convert to screen coordinates (flip Y) + let screenHeight = NSScreen.main?.frame.height ?? 0 + let point = CGPoint(x: mouseLocation.x, y: screenHeight - mouseLocation.y) + + // Hit test to find element at point + let systemWide = Accessibility.Element.systemWide + guard let element: Accessibility.Element = try? systemWide.hitTest(x: Float(point.x), y: Float(point.y)) else { + Thread.sleep(forTimeInterval: Double(interval) / 1000.0) + continue + } + + // If filtering by PID, check it + if let filterPid = pid { + guard let elementPid = try? element.pid(), elementPid == filterPid else { + Thread.sleep(forTimeInterval: Double(interval) / 1000.0) + continue + } + } + + // Check if element changed + let currentInfo = formatElementInfo(element, useColor: useColor) + if currentInfo != lastInfo || element != lastElement { + lastElement = element + lastInfo = currentInfo + + // Clear previous output and print new info + print("\u{001B}[2J\u{001B}[H", terminator: "") // Clear screen + print(Color.cyan.wrap("═══ Element Under Cursor ═══", enabled: useColor)) + print() + print(currentInfo) + + if path { + printElementPath(element, useColor: useColor) + } + + if actions { + printActions(element, useColor: useColor) + } + + if full { + printFullAttributes(element, useColor: useColor) + } + + print() + print(Color.dim.wrap("Mouse: (\(Int(point.x)), \(Int(point.y)))", enabled: useColor)) + print(Color.dim.wrap("Press Ctrl+C to stop", enabled: useColor)) + } + + Thread.sleep(forTimeInterval: Double(interval) / 1000.0) + } + } + + private func formatElementInfo(_ element: Accessibility.Element, useColor: Bool) -> String { + var lines: [String] = [] + + // App info + if let elementPid = try? element.pid() { + let apps = NSWorkspace.shared.runningApplications.filter { $0.processIdentifier == elementPid } + if let app = apps.first { + let appName = app.localizedName ?? "Unknown" + lines.append(Color.yellow.wrap("App:", enabled: useColor) + " \(appName) (PID: \(elementPid))") + } + } + + // Role + if let role: String = try? element.attribute(AXAttribute.role)() { + let shortRole = role.replacingOccurrences(of: "AX", with: "") + var roleLine = Color.green.wrap("Role:", enabled: useColor) + " \(shortRole)" + + if let subrole: String = try? element.attribute(AXAttribute.subrole)() { + let shortSubrole = subrole.replacingOccurrences(of: "AX", with: "") + roleLine += " [\(shortSubrole)]" + } + lines.append(roleLine) + } + + // Title + if let title: String = try? element.attribute(AXAttribute.title)(), !title.isEmpty { + lines.append(Color.green.wrap("Title:", enabled: useColor) + " \"\(title)\"") + } + + // Identifier + if let id: String = try? element.attribute(AXAttribute.identifier)() { + lines.append(Color.green.wrap("ID:", enabled: useColor) + " \(id)") + } + + // Value + if let value: Any = try? element.attribute(AXAttribute.value)() { + let strValue = String(describing: value) + let truncated = strValue.count > 50 ? String(strValue.prefix(50)) + "..." : strValue + lines.append(Color.green.wrap("Value:", enabled: useColor) + " \(truncated)") + } + + // Description + if let desc: String = try? element.attribute(AXAttribute.description)(), !desc.isEmpty { + lines.append(Color.green.wrap("Desc:", enabled: useColor) + " \"\(desc)\"") + } + + // Enabled/Focused + let enabled = (try? element.attribute(AXAttribute.enabled)()) ?? true + let focused = (try? element.attribute(AXAttribute.focused)()) ?? false + var stateLine = Color.green.wrap("State:", enabled: useColor) + stateLine += enabled ? " enabled" : Color.red.wrap(" disabled", enabled: useColor) + if focused { + stateLine += Color.brightGreen.wrap(" [focused]", enabled: useColor) + } + lines.append(stateLine) + + // Frame + if let frame = try? element.attribute(AXAttribute.frame)() { + lines.append(Color.green.wrap("Frame:", enabled: useColor) + + " (\(Int(frame.origin.x)), \(Int(frame.origin.y))) \(Int(frame.width))×\(Int(frame.height))") + } + + return lines.joined(separator: "\n") + } + + private func printElementPath(_ element: Accessibility.Element, useColor: Bool) { + print() + print(Color.cyan.wrap("─── Path ───", enabled: useColor)) + + // Try to compute path + guard let elementPid = try? element.pid() else { return } + let appElement = Accessibility.Element(pid: elementPid) + + var ancestors: [(element: Accessibility.Element, index: Int)] = [] + var current = element + + // Walk up to root + while let parent: Accessibility.Element = try? current.attribute(.init("AXParent"))() { + // Find index of current in parent's children + let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = parent.attribute(.init("AXChildren")) + var index = -1 + if let children = try? childrenAttr() { + index = children.firstIndex(of: current) ?? -1 + } + ancestors.append((current, index)) + + if parent == appElement { + break + } + current = parent + } + + ancestors.reverse() + + // Build path string + let pathIndices = ancestors.map { $0.index >= 0 ? String($0.index) : "?" }.joined(separator: ".") + print(Color.yellow.wrap("Path:", enabled: useColor) + " \(pathIndices)") + + // Build chain description + var chain: [String] = [] + for (elem, _) in ancestors { + var desc = "" + if let role: String = try? elem.attribute(AXAttribute.role)() { + desc = role.replacingOccurrences(of: "AX", with: "") + } + if let title: String = try? elem.attribute(AXAttribute.title)(), !title.isEmpty { + let short = title.count > 15 ? String(title.prefix(15)) + "..." : title + desc += "[\"\(short)\"]" + } else if let id: String = try? elem.attribute(AXAttribute.identifier)() { + desc += "[#\(id)]" + } + chain.append(desc) + } + print(Color.dim.wrap("Chain:", enabled: useColor) + " " + chain.joined(separator: " > ")) + + // Print command hint + print() + print(Color.dim.wrap("Use with:", enabled: useColor)) + print(" axdump inspect \(elementPid) -p \(pathIndices)") + print(" axdump action \(elementPid) -a Press -p \(pathIndices)") + } + + private func printActions(_ element: Accessibility.Element, useColor: Bool) { + print() + print(Color.cyan.wrap("─── Actions ───", enabled: useColor)) + + guard let actions = try? element.supportedActions() else { + print(Color.dim.wrap("(none)", enabled: useColor)) + return + } + + if actions.isEmpty { + print(Color.dim.wrap("(none)", enabled: useColor)) + return + } + + for action in actions { + let name = action.name.value.replacingOccurrences(of: "AX", with: "") + let desc = AXActions.all[action.name.value] ?? action.description + print(" \(Color.yellow.wrap(name, enabled: useColor)): \(desc)") + } + } + + private func printFullAttributes(_ element: Accessibility.Element, useColor: Bool) { + print() + print(Color.cyan.wrap("─── All Attributes ───", enabled: useColor)) + + guard let attrs = try? element.supportedAttributes() else { + print(Color.dim.wrap("(unable to read)", enabled: useColor)) + return + } + + for attr in attrs.sorted(by: { $0.name.value < $1.name.value }) { + let name = attr.name.value + if let value: Any = try? attr() { + let strValue = String(describing: value) + let truncated = strValue.count > 40 ? String(strValue.prefix(40)) + "..." : strValue + print(" \(name): \(truncated)") + } + } + } + } +} diff --git a/Sources/axdump/Utilities/AttributeFields.swift b/Sources/axdump/Utilities/AttributeFields.swift new file mode 100644 index 0000000..d11e0a1 --- /dev/null +++ b/Sources/axdump/Utilities/AttributeFields.swift @@ -0,0 +1,118 @@ +import Foundation +import AccessibilityControl +import CoreGraphics + +// MARK: - Attribute Names + +/// Common accessibility attribute names with type information +enum AXAttribute { + static let frame: Accessibility.Attribute.Name = .init("AXFrame") + static let position: Accessibility.Attribute.Name = .init("AXPosition") + static let size: Accessibility.Attribute.Name = .init("AXSize") + static let role: Accessibility.Attribute.Name = .init("AXRole") + static let subrole: Accessibility.Attribute.Name = .init("AXSubrole") + static let roleDescription: Accessibility.Attribute.Name = .init("AXRoleDescription") + static let title: Accessibility.Attribute.Name = .init("AXTitle") + static let identifier: Accessibility.Attribute.Name = .init("AXIdentifier") + static let description: Accessibility.Attribute.Name = .init("AXDescription") + static let help: Accessibility.Attribute.Name = .init("AXHelp") + static let value: Accessibility.Attribute.Name = .init("AXValue") + static let enabled: Accessibility.Attribute.Name = .init("AXEnabled") + static let focused: Accessibility.Attribute.Name = .init("AXFocused") + static let children: Accessibility.Attribute<[Accessibility.Element]>.Name = .init("AXChildren") + static let parent: Accessibility.Attribute.Name = .init("AXParent") + static let windows: Accessibility.Attribute<[Accessibility.Element]>.Name = .init("AXWindows") + static let focusedWindow: Accessibility.Attribute.Name = .init("AXFocusedWindow") + static let focusedUIElement: Accessibility.Attribute.Name = .init("AXFocusedUIElement") + static let mainWindow: Accessibility.Attribute.Name = .init("AXMainWindow") +} + +// MARK: - Attribute Field Selection + +/// Option set for selecting which attribute fields to display +struct AttributeFields: OptionSet { + let rawValue: Int + + static let role = AttributeFields(rawValue: 1 << 0) + static let roleDescription = AttributeFields(rawValue: 1 << 1) + static let title = AttributeFields(rawValue: 1 << 2) + static let identifier = AttributeFields(rawValue: 1 << 3) + static let value = AttributeFields(rawValue: 1 << 4) + static let description = AttributeFields(rawValue: 1 << 5) + static let enabled = AttributeFields(rawValue: 1 << 6) + static let focused = AttributeFields(rawValue: 1 << 7) + static let position = AttributeFields(rawValue: 1 << 8) + static let size = AttributeFields(rawValue: 1 << 9) + static let frame = AttributeFields(rawValue: 1 << 10) + static let help = AttributeFields(rawValue: 1 << 11) + static let subrole = AttributeFields(rawValue: 1 << 12) + static let childCount = AttributeFields(rawValue: 1 << 13) + static let actions = AttributeFields(rawValue: 1 << 14) + + // Presets + static let minimal: AttributeFields = [.role, .title, .identifier] + static let standard: AttributeFields = [.role, .roleDescription, .title, .identifier, .value, .description] + static let full: AttributeFields = [ + .role, .roleDescription, .title, .identifier, .value, + .description, .enabled, .focused, .position, .size, .frame, .help, .subrole + ] + static let all: AttributeFields = [ + .role, .roleDescription, .title, .identifier, .value, + .description, .enabled, .focused, .position, .size, .frame, .help, .subrole, + .childCount, .actions + ] + + /// Parse a comma-separated field specification string + static func parse(_ string: String) -> AttributeFields { + var fields: AttributeFields = [] + for name in string.lowercased().split(separator: ",") { + switch name.trimmingCharacters(in: .whitespaces) { + case "role": fields.insert(.role) + case "roledescription", "role-description", "roledesc": fields.insert(.roleDescription) + case "title": fields.insert(.title) + case "identifier", "id": fields.insert(.identifier) + case "value", "val": fields.insert(.value) + case "description", "desc": fields.insert(.description) + case "enabled": fields.insert(.enabled) + case "focused": fields.insert(.focused) + case "position", "pos": fields.insert(.position) + case "size": fields.insert(.size) + case "frame": fields.insert(.frame) + case "help": fields.insert(.help) + case "subrole": fields.insert(.subrole) + case "children", "childcount", "child-count": fields.insert(.childCount) + case "actions": fields.insert(.actions) + // Presets + case "minimal": fields.formUnion(.minimal) + case "standard": fields.formUnion(.standard) + case "full": fields.formUnion(.full) + case "all": fields.formUnion(.all) + default: break + } + } + return fields.isEmpty ? .standard : fields + } + + /// Help text describing available field options + static var helpText: String { + """ + FIELD OPTIONS: + Presets: + minimal - role, title, identifier + standard - role, roleDescription, title, identifier, value, description (default) + full - all basic fields + all - all fields including childCount and actions + + Individual fields (comma-separated): + role, subrole, roleDescription (or roledesc), title, + identifier (or id), value (or val), description (or desc), + enabled, focused, position (or pos), size, frame, help, + childCount (or children), actions + + Examples: + -f minimal + -f role,title,value + -f standard,actions + """ + } +} diff --git a/Sources/axdump/Utilities/Constants.swift b/Sources/axdump/Utilities/Constants.swift new file mode 100644 index 0000000..bae9be1 --- /dev/null +++ b/Sources/axdump/Utilities/Constants.swift @@ -0,0 +1,319 @@ +import Foundation + +// MARK: - Common Accessibility Roles + +/// Standard macOS accessibility roles (AXRole values) +enum AXRoles { + /// All known roles for reference and help text + static let all: [String: String] = [ + // Core UI Elements + "AXApplication": "Application root element", + "AXWindow": "Window container", + "AXSheet": "Sheet dialog", + "AXDrawer": "Drawer panel", + "AXDialog": "Dialog window", + + // Buttons & Controls + "AXButton": "Push button", + "AXRadioButton": "Radio button (single selection)", + "AXCheckBox": "Checkbox (toggle)", + "AXPopUpButton": "Pop-up button (dropdown)", + "AXMenuButton": "Menu button", + "AXDisclosureTriangle": "Disclosure triangle (expand/collapse)", + "AXIncrementor": "Stepper control", + "AXSlider": "Slider control", + "AXColorWell": "Color picker well", + + // Text Elements + "AXStaticText": "Static text label", + "AXTextField": "Text input field", + "AXTextArea": "Multi-line text area", + "AXSecureTextField": "Password field", + "AXSearchField": "Search input field", + "AXComboBox": "Combo box (text + dropdown)", + + // Menus + "AXMenuBar": "Application menu bar", + "AXMenu": "Menu container", + "AXMenuItem": "Menu item", + "AXMenuBarItem": "Menu bar item", + + // Lists & Tables + "AXList": "List container", + "AXTable": "Table with rows/columns", + "AXOutline": "Outline (hierarchical list)", + "AXBrowser": "Column browser (like Finder)", + "AXRow": "Table/list row", + "AXColumn": "Table column", + "AXCell": "Table cell", + + // Groups & Containers + "AXGroup": "Generic grouping element", + "AXScrollArea": "Scrollable area", + "AXSplitGroup": "Split view container", + "AXSplitter": "Split view divider", + "AXTabGroup": "Tab container", + "AXToolbar": "Toolbar container", + "AXLayoutArea": "Layout area", + "AXLayoutItem": "Layout item", + "AXMatte": "Matte (background)", + "AXRulerMarker": "Ruler marker", + + // Media & Images + "AXImage": "Image element", + "AXValueIndicator": "Value indicator (progress)", + "AXProgressIndicator": "Progress bar", + "AXBusyIndicator": "Busy/loading indicator", + "AXRelevanceIndicator": "Relevance indicator", + "AXLevelIndicator": "Level indicator", + + // Special Elements + "AXLink": "Hyperlink", + "AXHelpTag": "Help tooltip", + "AXScrollBar": "Scroll bar", + "AXHandle": "Resize handle", + "AXGrowArea": "Window grow area", + "AXRuler": "Ruler", + "AXGrid": "Grid layout", + "AXWebArea": "Web content area", + + // System UI + "AXDockItem": "Dock item", + "AXSystemWide": "System-wide element", + ] + + /// Most commonly used roles + static let common: [String] = [ + "AXButton", "AXStaticText", "AXTextField", "AXTextArea", + "AXCheckBox", "AXRadioButton", "AXPopUpButton", "AXSlider", + "AXWindow", "AXGroup", "AXScrollArea", "AXTable", "AXList", + "AXRow", "AXCell", "AXImage", "AXLink", "AXMenu", "AXMenuItem", + "AXToolbar", "AXTabGroup", "AXWebArea" + ] +} + +// MARK: - Common Accessibility Subroles + +/// Standard macOS accessibility subroles (AXSubrole values) +enum AXSubroles { + /// All known subroles for reference and help text + static let all: [String: String] = [ + // Window Subroles + "AXStandardWindow": "Standard window", + "AXDialog": "Dialog window", + "AXSystemDialog": "System dialog", + "AXFloatingWindow": "Floating window", + "AXFullScreenWindow": "Full screen window", + + // Button Subroles + "AXCloseButton": "Window close button", + "AXMinimizeButton": "Window minimize button", + "AXZoomButton": "Window zoom button", + "AXFullScreenButton": "Full screen button", + "AXToolbarButton": "Toolbar button", + "AXSecureTextField": "Secure text field", + "AXSearchField": "Search field", + + // Table Subroles + "AXSortButton": "Sort button (table header)", + "AXTableRow": "Table row", + "AXOutlineRow": "Outline row", + + // Text Subroles + "AXTextAttachment": "Text attachment", + "AXTextLink": "Text link", + + // Menu Subroles + "AXMenuBarItem": "Menu bar item", + "AXApplicationDockItem": "Application dock item", + "AXDocumentDockItem": "Document dock item", + "AXFolderDockItem": "Folder dock item", + "AXMinimizedWindowDockItem": "Minimized window dock item", + "AXURLDockItem": "URL dock item", + "AXDockExtraDockItem": "Dock extra item", + "AXTrashDockItem": "Trash dock item", + "AXSeparatorDockItem": "Separator dock item", + "AXProcessSwitcherList": "Process switcher list", + + // Content Subroles + "AXContentList": "Content list", + "AXDefinitionList": "Definition list", + "AXDescriptionList": "Description list", + + // Decorator Subroles + "AXDecrementArrow": "Decrement arrow", + "AXIncrementArrow": "Increment arrow", + "AXDecrementPage": "Decrement page", + "AXIncrementPage": "Increment page", + + // Timeline Subroles + "AXTimeline": "Timeline", + "AXRatingIndicator": "Rating indicator", + + // Accessibility Subroles + "AXUnknown": "Unknown subrole", + "AXToggle": "Toggle button", + "AXSwitch": "Switch control", + ] + + /// Most commonly used subroles + static let common: [String] = [ + "AXCloseButton", "AXMinimizeButton", "AXZoomButton", + "AXFullScreenButton", "AXToolbarButton", "AXSearchField", + "AXTableRow", "AXOutlineRow", "AXTextLink", "AXToggle", "AXSwitch" + ] +} + +// MARK: - Common Accessibility Actions + +/// Standard macOS accessibility actions (AXAction values) +enum AXActions { + /// All known actions for reference and help text + static let all: [String: String] = [ + // Primary Actions + "AXPress": "Activate/click the element (buttons, links)", + "AXIncrement": "Increase value (sliders, steppers)", + "AXDecrement": "Decrease value (sliders, steppers)", + "AXConfirm": "Confirm/submit (dialogs, forms)", + "AXCancel": "Cancel operation", + "AXPick": "Pick/select item (menus, pickers)", + + // Window Actions + "AXRaise": "Bring window to front", + + // Menu Actions + "AXShowMenu": "Show context/popup menu", + + // UI Actions + "AXShowAlternateUI": "Show alternate UI", + "AXShowDefaultUI": "Show default UI", + + // Scroll Actions + "AXScrollLeftByPage": "Scroll left by page", + "AXScrollRightByPage": "Scroll right by page", + "AXScrollUpByPage": "Scroll up by page", + "AXScrollDownByPage": "Scroll down by page", + + // Deletion Actions + "AXDelete": "Delete element or content", + ] + + /// Most commonly used actions + static let common: [String] = [ + "AXPress", "AXIncrement", "AXDecrement", "AXConfirm", + "AXCancel", "AXPick", "AXRaise", "AXShowMenu" + ] +} + +// MARK: - Common Accessibility Notifications + +/// Standard macOS accessibility notifications +enum AXNotifications { + /// All known notifications for reference + static let all: [String] = [ + "AXValueChanged", + "AXUIElementDestroyed", + "AXSelectedTextChanged", + "AXSelectedChildrenChanged", + "AXFocusedUIElementChanged", + "AXFocusedWindowChanged", + "AXApplicationActivated", + "AXApplicationDeactivated", + "AXWindowCreated", + "AXWindowMoved", + "AXWindowResized", + "AXWindowMiniaturized", + "AXWindowDeminiaturized", + "AXDrawerCreated", + "AXSheetCreated", + "AXMenuOpened", + "AXMenuClosed", + "AXMenuItemSelected", + "AXTitleChanged", + "AXResized", + "AXMoved", + "AXCreated", + "AXLayoutChanged", + "AXSelectedCellsChanged", + "AXUnitsChanged", + "AXSelectedColumnsChanged", + "AXSelectedRowsChanged", + "AXRowCountChanged", + "AXRowExpanded", + "AXRowCollapsed", + ] +} + +// MARK: - Help Text Generators + +extension AXRoles { + static func helpText() -> String { + var lines: [String] = ["COMMON ROLES:"] + for role in common { + if let desc = all[role] { + let shortRole = role.replacingOccurrences(of: "AX", with: "") + lines.append(" \(shortRole.padding(toLength: 20, withPad: " ", startingAt: 0)) \(desc)") + } + } + lines.append("") + lines.append("Use --list-roles for all \(all.count) known roles") + return lines.joined(separator: "\n") + } + + static func fullHelpText() -> String { + var lines: [String] = ["ALL KNOWN ROLES:"] + for (role, desc) in all.sorted(by: { $0.key < $1.key }) { + let shortRole = role.replacingOccurrences(of: "AX", with: "") + lines.append(" \(shortRole.padding(toLength: 25, withPad: " ", startingAt: 0)) \(desc)") + } + return lines.joined(separator: "\n") + } +} + +extension AXSubroles { + static func helpText() -> String { + var lines: [String] = ["COMMON SUBROLES:"] + for subrole in common { + if let desc = all[subrole] { + let shortSubrole = subrole.replacingOccurrences(of: "AX", with: "") + lines.append(" \(shortSubrole.padding(toLength: 20, withPad: " ", startingAt: 0)) \(desc)") + } + } + lines.append("") + lines.append("Use --list-subroles for all \(all.count) known subroles") + return lines.joined(separator: "\n") + } + + static func fullHelpText() -> String { + var lines: [String] = ["ALL KNOWN SUBROLES:"] + for (subrole, desc) in all.sorted(by: { $0.key < $1.key }) { + let shortSubrole = subrole.replacingOccurrences(of: "AX", with: "") + lines.append(" \(shortSubrole.padding(toLength: 25, withPad: " ", startingAt: 0)) \(desc)") + } + return lines.joined(separator: "\n") + } +} + +extension AXActions { + static func helpText() -> String { + var lines: [String] = ["COMMON ACTIONS:"] + for action in common { + if let desc = all[action] { + let shortAction = action.replacingOccurrences(of: "AX", with: "") + lines.append(" \(shortAction.padding(toLength: 15, withPad: " ", startingAt: 0)) \(desc)") + } + } + lines.append("") + lines.append("Use --list-actions for all \(all.count) known actions") + return lines.joined(separator: "\n") + } + + static func fullHelpText() -> String { + var lines: [String] = ["ALL KNOWN ACTIONS:"] + for (action, desc) in all.sorted(by: { $0.key < $1.key }) { + let shortAction = action.replacingOccurrences(of: "AX", with: "") + lines.append(" \(shortAction.padding(toLength: 20, withPad: " ", startingAt: 0)) \(desc)") + } + return lines.joined(separator: "\n") + } +} diff --git a/Sources/axdump/Utilities/ElementFilter.swift b/Sources/axdump/Utilities/ElementFilter.swift new file mode 100644 index 0000000..ddfbf62 --- /dev/null +++ b/Sources/axdump/Utilities/ElementFilter.swift @@ -0,0 +1,229 @@ +import Foundation +import AccessibilityControl + +// MARK: - Element Filter + +/// Filter for selecting accessibility elements based on criteria +struct ElementFilter { + /// Role pattern to match (regex supported) + var rolePattern: NSRegularExpression? + + /// Subrole pattern to match (regex supported) + var subrolePattern: NSRegularExpression? + + /// Title pattern to match (regex supported) + var titlePattern: NSRegularExpression? + + /// Identifier pattern to match (regex supported) + var identifierPattern: NSRegularExpression? + + /// Required fields that must not be nil + var requiredFields: Set + + /// Fields that must be nil + var excludedFields: Set + + /// Whether filtering is case sensitive + var caseSensitive: Bool + + init( + rolePattern: String? = nil, + subrolePattern: String? = nil, + titlePattern: String? = nil, + identifierPattern: String? = nil, + requiredFields: [String] = [], + excludedFields: [String] = [], + caseSensitive: Bool = false + ) throws { + let options: NSRegularExpression.Options = caseSensitive ? [] : [.caseInsensitive] + + if let pattern = rolePattern { + self.rolePattern = try NSRegularExpression(pattern: pattern, options: options) + } + if let pattern = subrolePattern { + self.subrolePattern = try NSRegularExpression(pattern: pattern, options: options) + } + if let pattern = titlePattern { + self.titlePattern = try NSRegularExpression(pattern: pattern, options: options) + } + if let pattern = identifierPattern { + self.identifierPattern = try NSRegularExpression(pattern: pattern, options: options) + } + + self.requiredFields = Set(requiredFields.map { normalizeFieldName($0) }) + self.excludedFields = Set(excludedFields.map { normalizeFieldName($0) }) + self.caseSensitive = caseSensitive + } + + /// Check if an element matches this filter + func matches(_ element: Accessibility.Element) -> Bool { + // Check role pattern + if let pattern = rolePattern { + guard let role: String = try? element.attribute(AXAttribute.role)() else { + return false + } + if !matchesPattern(pattern, in: role) { + return false + } + } + + // Check subrole pattern + if let pattern = subrolePattern { + guard let subrole: String = try? element.attribute(AXAttribute.subrole)() else { + return false + } + if !matchesPattern(pattern, in: subrole) { + return false + } + } + + // Check title pattern + if let pattern = titlePattern { + guard let title: String = try? element.attribute(AXAttribute.title)() else { + return false + } + if !matchesPattern(pattern, in: title) { + return false + } + } + + // Check identifier pattern + if let pattern = identifierPattern { + guard let id: String = try? element.attribute(AXAttribute.identifier)() else { + return false + } + if !matchesPattern(pattern, in: id) { + return false + } + } + + // Check required fields (must not be nil) + for field in requiredFields { + if !hasValue(element, forField: field) { + return false + } + } + + // Check excluded fields (must be nil) + for field in excludedFields { + if hasValue(element, forField: field) { + return false + } + } + + return true + } + + /// Check if element has a non-nil value for a field + private func hasValue(_ element: Accessibility.Element, forField field: String) -> Bool { + let attrName = field.hasPrefix("AX") ? field : "AX\(field)" + + switch attrName { + case "AXRole": + return (try? element.attribute(AXAttribute.role)()) != nil + case "AXSubrole": + return (try? element.attribute(AXAttribute.subrole)()) != nil + case "AXTitle": + return (try? element.attribute(AXAttribute.title)()) != nil + case "AXIdentifier": + return (try? element.attribute(AXAttribute.identifier)()) != nil + case "AXDescription": + return (try? element.attribute(AXAttribute.description)()) != nil + case "AXValue": + return (try? element.attribute(AXAttribute.value)()) != nil + case "AXHelp": + return (try? element.attribute(AXAttribute.help)()) != nil + case "AXRoleDescription": + return (try? element.attribute(AXAttribute.roleDescription)()) != nil + case "AXEnabled": + return (try? element.attribute(AXAttribute.enabled)()) != nil + case "AXFocused": + return (try? element.attribute(AXAttribute.focused)()) != nil + case "AXPosition": + return (try? element.attribute(AXAttribute.position)()) != nil + case "AXSize": + return (try? element.attribute(AXAttribute.size)()) != nil + case "AXFrame": + return (try? element.attribute(AXAttribute.frame)()) != nil + case "AXChildren": + let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = element.attribute(.init("AXChildren")) + return (try? childrenAttr.count()) ?? 0 > 0 + default: + // Try generic attribute read + if let _: Any = try? element.attribute(.init(attrName))() { + return true + } + return false + } + } + + private func matchesPattern(_ pattern: NSRegularExpression, in string: String) -> Bool { + let range = NSRange(string.startIndex..., in: string) + return pattern.firstMatch(in: string, options: [], range: range) != nil + } + + /// Check if any filtering is active + var isActive: Bool { + rolePattern != nil || + subrolePattern != nil || + titlePattern != nil || + identifierPattern != nil || + !requiredFields.isEmpty || + !excludedFields.isEmpty + } +} + +// MARK: - Field Name Normalization + +/// Normalize a field name to its canonical form +func normalizeFieldName(_ name: String) -> String { + let lowercased = name.lowercased().trimmingCharacters(in: .whitespaces) + switch lowercased { + case "role": return "AXRole" + case "subrole": return "AXSubrole" + case "title": return "AXTitle" + case "id", "identifier": return "AXIdentifier" + case "desc", "description": return "AXDescription" + case "value", "val": return "AXValue" + case "help": return "AXHelp" + case "roledesc", "roledescription", "role-description": return "AXRoleDescription" + case "enabled": return "AXEnabled" + case "focused": return "AXFocused" + case "pos", "position": return "AXPosition" + case "size": return "AXSize" + case "frame": return "AXFrame" + case "children": return "AXChildren" + case "parent": return "AXParent" + case "windows": return "AXWindows" + default: + // Assume it's already an AX name or add prefix + return name.hasPrefix("AX") ? name : "AX\(name.capitalized)" + } +} + +// MARK: - Filter Help + +extension ElementFilter { + static var helpText: String { + """ + FILTERING OPTIONS: + --role PATTERN Filter by role (regex, e.g., 'Button|Text') + --subrole PATTERN Filter by subrole (regex) + --title PATTERN Filter by title (regex) + --id PATTERN Filter by identifier (regex) + --has FIELD,... Only show elements where FIELD is not nil + --without FIELD,... Only show elements where FIELD is nil + --case-sensitive Make pattern matching case-sensitive + + FIELD NAMES for --has/--without: + role, subrole, title, identifier (or id), description (or desc), + value, help, enabled, focused, position, size, frame, children + + EXAMPLES: + axdump dump 710 --role "Button" + axdump dump 710 --role "Text.*" --has title + axdump dump 710 --has identifier --without value + axdump dump 710 --title "Save|Cancel" --case-sensitive + """ + } +} diff --git a/Sources/axdump/Utilities/ElementPrinter.swift b/Sources/axdump/Utilities/ElementPrinter.swift new file mode 100644 index 0000000..9b5d2e2 --- /dev/null +++ b/Sources/axdump/Utilities/ElementPrinter.swift @@ -0,0 +1,412 @@ +import Foundation +import AccessibilityControl +import CoreGraphics + +// MARK: - Element Printer + +/// Formats accessibility elements for display +struct ElementPrinter { + let fields: AttributeFields + let verbosity: Int + let useColor: Bool + let maxLength: Int + + init( + fields: AttributeFields = .standard, + verbosity: Int = 1, + useColor: Bool = true, + maxLength: Int = 0 + ) { + self.fields = fields + self.verbosity = verbosity + self.useColor = useColor + self.maxLength = maxLength + } + + // MARK: - Single Element Formatting + + /// Format an element as a single line + func formatElement(_ element: Accessibility.Element, indent: Int = 0) -> String { + let prefix = String(repeating: " ", count: indent) + var info: [String] = [] + + if fields.contains(.role) { + if let role = try? element.attribute(AXAttribute.role)() { + info.append("role=\(role)") + } + } + + if fields.contains(.subrole) { + if let subrole = try? element.attribute(AXAttribute.subrole)() { + info.append("subrole=\(subrole)") + } + } + + if fields.contains(.roleDescription) { + if let roleDesc = try? element.attribute(AXAttribute.roleDescription)() { + info.append("roleDesc=\"\(roleDesc)\"") + } + } + + if fields.contains(.title) { + if let title = try? element.attribute(AXAttribute.title)() { + let truncated = truncate(title, to: 50) + info.append("title=\"\(truncated)\"") + } + } + + if fields.contains(.identifier) { + if let id = try? element.attribute(AXAttribute.identifier)() { + info.append("id=\"\(id)\"") + } + } + + if fields.contains(.description) { + if let desc = try? element.attribute(AXAttribute.description)() { + let truncated = truncate(desc, to: 50) + info.append("desc=\"\(truncated)\"") + } + } + + if fields.contains(.value) { + if let value = try? element.attribute(AXAttribute.value)() { + let strValue = String(describing: value) + let truncated = truncate(strValue, to: 50) + info.append("value=\"\(truncated)\"") + } + } + + if fields.contains(.enabled) { + if let enabled = try? element.attribute(AXAttribute.enabled)() { + info.append("enabled=\(enabled)") + } + } + + if fields.contains(.focused) { + if let focused = try? element.attribute(AXAttribute.focused)() { + info.append("focused=\(focused)") + } + } + + if fields.contains(.position) { + if let pos = try? element.attribute(AXAttribute.position)() { + info.append("pos=(\(Int(pos.x)),\(Int(pos.y)))") + } + } + + if fields.contains(.size) { + if let size = try? element.attribute(AXAttribute.size)() { + info.append("size=(\(Int(size.width))x\(Int(size.height)))") + } + } + + if fields.contains(.frame) { + if let frame = try? element.attribute(AXAttribute.frame)() { + info.append("frame=(\(Int(frame.origin.x)),\(Int(frame.origin.y)) \(Int(frame.width))x\(Int(frame.height)))") + } + } + + if fields.contains(.help) { + if let help = try? element.attribute(AXAttribute.help)() { + let truncated = truncate(help, to: 50) + info.append("help=\"\(truncated)\"") + } + } + + if fields.contains(.childCount) { + let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = element.attribute(.init("AXChildren")) + if let count = try? childrenAttr.count() { + info.append("children=\(count)") + } + } + + let infoStr = info.isEmpty ? "(no attributes)" : info.joined(separator: " ") + var result = "\(prefix)\(infoStr)" + + if verbosity >= 2 || fields.contains(.actions) { + if let actions = try? element.supportedActions(), !actions.isEmpty { + let actionNames = actions.map { $0.name.value.replacingOccurrences(of: "AX", with: "") } + result += "\n\(prefix) actions: \(actionNames.joined(separator: ", "))" + } + } + + return result + } + + /// Format element for JSON output + func formatElementForJSON(_ element: Accessibility.Element) -> [String: Any] { + var dict: [String: Any] = [:] + + if let role: String = try? element.attribute(AXAttribute.role)() { + dict["role"] = role + } + if let subrole: String = try? element.attribute(AXAttribute.subrole)() { + dict["subrole"] = subrole + } + if let title: String = try? element.attribute(AXAttribute.title)() { + dict["title"] = title + } + if let id: String = try? element.attribute(AXAttribute.identifier)() { + dict["identifier"] = id + } + if let desc: String = try? element.attribute(AXAttribute.description)() { + dict["description"] = desc + } + if let value = try? element.attribute(AXAttribute.value)() { + dict["value"] = formatValueForJSON(value) + } + if let enabled: Bool = try? element.attribute(AXAttribute.enabled)() { + dict["enabled"] = enabled + } + if let focused: Bool = try? element.attribute(AXAttribute.focused)() { + dict["focused"] = focused + } + if let frame = try? element.attribute(AXAttribute.frame)() { + dict["frame"] = [ + "x": frame.origin.x, + "y": frame.origin.y, + "width": frame.width, + "height": frame.height + ] + } + + return dict + } + + // MARK: - Value Formatting + + /// Format a value for display + func formatValue(_ value: Any) -> String { + switch value { + case let element as Accessibility.Element: + var parts: [String] = ["") + return parts.joined(separator: " ") + + case let elements as [Accessibility.Element]: + var lines: [String] = ["[\(elements.count) elements]"] + for (index, element) in elements.enumerated() { + var parts: [String] = [" [\(index)]"] + if let role: String = try? element.attribute(AXAttribute.role)() { + parts.append("role=\(role)") + } + if let title: String = try? element.attribute(AXAttribute.title)() { + parts.append("title=\"\(title)\"") + } + if let id: String = try? element.attribute(AXAttribute.identifier)() { + parts.append("id=\"\(id)\"") + } + lines.append(parts.joined(separator: " ")) + } + return lines.joined(separator: "\n") + + case let structValue as Accessibility.Struct: + switch structValue { + case .point(let point): + return "(\(point.x), \(point.y))" + case .size(let size): + return "\(size.width) x \(size.height)" + case .rect(let rect): + return "origin=(\(rect.origin.x), \(rect.origin.y)) size=(\(rect.width) x \(rect.height))" + case .range(let range): + return "\(range.lowerBound)..<\(range.upperBound)" + case .error(let error): + return "Error: \(error)" + } + + case let point as CGPoint: + return "(\(point.x), \(point.y))" + + case let size as CGSize: + return "\(size.width) x \(size.height)" + + case let rect as CGRect: + return "origin=(\(rect.origin.x), \(rect.origin.y)) size=(\(rect.width) x \(rect.height))" + + case let array as [Any]: + return array.map { formatValue($0) }.joined(separator: ", ") + + case let dict as [String: Any]: + return dict.map { "\($0.key): \(formatValue($0.value))" }.joined(separator: ", ") + + default: + let str = String(describing: value) + return maxLength > 0 && str.count > maxLength ? String(str.prefix(maxLength)) + "..." : str + } + } + + /// Format a value for JSON serialization + func formatValueForJSON(_ value: Any) -> Any { + switch value { + case let element as Accessibility.Element: + return formatElementForJSON(element) + + case let elements as [Accessibility.Element]: + return elements.map { formatElementForJSON($0) } + + case let structValue as Accessibility.Struct: + switch structValue { + case .point(let point): + return ["x": point.x, "y": point.y] + case .size(let size): + return ["width": size.width, "height": size.height] + case .rect(let rect): + return ["x": rect.origin.x, "y": rect.origin.y, "width": rect.width, "height": rect.height] + case .range(let range): + return ["start": range.lowerBound, "end": range.upperBound] + case .error(let error): + return ["error": String(describing: error)] + } + + case let point as CGPoint: + return ["x": point.x, "y": point.y] + + case let size as CGSize: + return ["width": size.width, "height": size.height] + + case let rect as CGRect: + return ["x": rect.origin.x, "y": rect.origin.y, "width": rect.width, "height": rect.height] + + case let array as [Any]: + return array.map { formatValueForJSON($0) } + + case let dict as [String: Any]: + return dict.mapValues { formatValueForJSON($0) } + + case let str as String: + return str + + case let num as NSNumber: + return num + + case let bool as Bool: + return bool + + default: + return String(describing: value) + } + } + + // MARK: - Helpers + + private func truncate(_ string: String, to maxLength: Int) -> String { + let limit = self.maxLength > 0 ? min(maxLength, self.maxLength) : maxLength + return string.count > limit ? String(string.prefix(limit)) + "..." : string + } +} + +// MARK: - Element Path Computation + +/// Compute the path from the application root to a given element +func computeElementPath(_ element: Accessibility.Element, appElement: Accessibility.Element) -> (path: String, chain: String) { + var ancestors: [Accessibility.Element] = [] + var current = element + + while true { + ancestors.append(current) + guard let parent: Accessibility.Element = try? current.attribute(.init("AXParent"))() else { + break + } + if parent == appElement { + break + } + current = parent + } + + ancestors.reverse() + + var indices: [Int] = [] + var chainParts: [String] = [] + + var parentForIndex = appElement + for ancestor in ancestors { + let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = parentForIndex.attribute(.init("AXChildren")) + if let children: [Accessibility.Element] = try? childrenAttr() { + if let index = children.firstIndex(of: ancestor) { + indices.append(index) + } else { + indices.append(-1) + } + } else { + indices.append(-1) + } + + var desc = "" + if let role: String = try? ancestor.attribute(AXAttribute.role)() { + desc = role.replacingOccurrences(of: "AX", with: "") + } + if let id: String = try? ancestor.attribute(AXAttribute.identifier)() { + desc += "[\(id)]" + } else if let title: String = try? ancestor.attribute(AXAttribute.title)() { + let truncated = title.count > 20 ? String(title.prefix(20)) + "..." : title + desc += "[\"\(truncated)\"]" + } + if desc.isEmpty { + desc = "?" + } + chainParts.append(desc) + + parentForIndex = ancestor + } + + let pathString = indices.map { $0 >= 0 ? String($0) : "?" }.joined(separator: ".") + let chainString = chainParts.joined(separator: " > ") + + return (pathString, chainString) +} + +// MARK: - Element Navigation + +/// Navigate to an element via dot-separated child indices +func navigateToPath(from element: Accessibility.Element, path: String) throws -> Accessibility.Element { + var current = element + let indices = path.split(separator: ".").compactMap { Int($0) } + + for (step, index) in indices.enumerated() { + let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = current.attribute(.init("AXChildren")) + guard let children: [Accessibility.Element] = try? childrenAttr() else { + throw NavigationError.noChildren(step: step) + } + guard index >= 0 && index < children.count else { + throw NavigationError.indexOutOfRange(index: index, step: step, count: children.count) + } + current = children[index] + } + + return current +} + +/// Navigate to a single child by index +func navigateToChild(from element: Accessibility.Element, index: Int) throws -> Accessibility.Element { + let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = element.attribute(.init("AXChildren")) + guard let children: [Accessibility.Element] = try? childrenAttr() else { + throw NavigationError.noChildren(step: 0) + } + guard index >= 0 && index < children.count else { + throw NavigationError.indexOutOfRange(index: index, step: 0, count: children.count) + } + return children[index] +} + +enum NavigationError: Error, CustomStringConvertible { + case noChildren(step: Int) + case indexOutOfRange(index: Int, step: Int, count: Int) + + var description: String { + switch self { + case .noChildren(let step): + return "Element at step \(step) has no children" + case .indexOutOfRange(let index, let step, let count): + return "Child index \(index) at step \(step) out of range (0..<\(count))" + } + } +} diff --git a/Sources/axdump/Utilities/TreePrinter.swift b/Sources/axdump/Utilities/TreePrinter.swift new file mode 100644 index 0000000..76fbed5 --- /dev/null +++ b/Sources/axdump/Utilities/TreePrinter.swift @@ -0,0 +1,274 @@ +import Foundation +import AccessibilityControl + +// MARK: - ASCII Tree Printer + +/// Prints accessibility elements in an ASCII tree format +struct TreePrinter { + let fields: AttributeFields + let filter: ElementFilter? + let maxDepth: Int + let showActions: Bool + let useColor: Bool + + // Tree drawing characters + private let branch = "├── " + private let lastBranch = "└── " + private let vertical = "│ " + private let space = " " + + init( + fields: AttributeFields = .standard, + filter: ElementFilter? = nil, + maxDepth: Int = 3, + showActions: Bool = false, + useColor: Bool = true + ) { + self.fields = fields + self.filter = filter + self.maxDepth = maxDepth + self.showActions = showActions + self.useColor = useColor + } + + // MARK: - Public API + + /// Print the tree starting from a root element + func printTree(_ root: Accessibility.Element) { + printNode(root, prefix: "", isLast: true, depth: 0) + } + + /// Print the tree and return as a string + func treeString(_ root: Accessibility.Element) -> String { + var output = "" + printNode(root, prefix: "", isLast: true, depth: 0, output: &output) + return output + } + + // MARK: - Private Implementation + + private func printNode( + _ element: Accessibility.Element, + prefix: String, + isLast: Bool, + depth: Int, + output: inout String + ) { + // Check filter + let passesFilter = filter?.matches(element) ?? true + + // Get children for recursion (needed even if this node is filtered) + let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = element.attribute(.init("AXChildren")) + let children: [Accessibility.Element] = (try? childrenAttr()) ?? [] + + // Get children that pass filter (or all if no filter) + let matchingChildren: [Accessibility.Element] + if let filter = filter { + matchingChildren = children.filter { childPassesFilterOrHasMatchingDescendant($0, filter: filter, depth: depth + 1) } + } else { + matchingChildren = children + } + + // Only print this node if it passes filter + if passesFilter { + let nodeText = formatElement(element) + let connector = depth == 0 ? "" : (isLast ? lastBranch : branch) + output += "\(prefix)\(connector)\(nodeText)\n" + + // Print actions if requested + if showActions || fields.contains(.actions) { + if let actions = try? element.supportedActions(), !actions.isEmpty { + let actionPrefix = depth == 0 ? "" : (isLast ? space : vertical) + let actionNames = actions.map { $0.name.value.replacingOccurrences(of: "AX", with: "") } + let actionsStr = Color.dim.wrap("actions: ", enabled: useColor) + + Color.yellow.wrap(actionNames.joined(separator: ", "), enabled: useColor) + output += "\(prefix)\(actionPrefix)\(space)\(actionsStr)\n" + } + } + } + + // Recurse into children + guard depth < maxDepth else { return } + + let childPrefix: String + if depth == 0 { + childPrefix = "" + } else if passesFilter { + childPrefix = prefix + (isLast ? space : vertical) + } else { + childPrefix = prefix + } + + for (index, child) in matchingChildren.enumerated() { + let isLastChild = index == matchingChildren.count - 1 + printNode(child, prefix: childPrefix, isLast: isLastChild, depth: depth + 1, output: &output) + } + } + + private func printNode( + _ element: Accessibility.Element, + prefix: String, + isLast: Bool, + depth: Int + ) { + var output = "" + printNode(element, prefix: prefix, isLast: isLast, depth: depth, output: &output) + print(output, terminator: "") + } + + /// Check if an element or any of its descendants passes the filter + private func childPassesFilterOrHasMatchingDescendant(_ element: Accessibility.Element, filter: ElementFilter, depth: Int) -> Bool { + if filter.matches(element) { + return true + } + + guard depth < maxDepth else { return false } + + let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = element.attribute(.init("AXChildren")) + guard let children: [Accessibility.Element] = try? childrenAttr() else { return false } + + for child in children { + if childPassesFilterOrHasMatchingDescendant(child, filter: filter, depth: depth + 1) { + return true + } + } + + return false + } + + // MARK: - Element Formatting + + private func formatElement(_ element: Accessibility.Element) -> String { + var parts: [String] = [] + + // Role (always first, with color) + if fields.contains(.role) { + if let role = try? element.attribute(AXAttribute.role)() { + let shortRole = role.replacingOccurrences(of: "AX", with: "") + parts.append(Color.cyan.wrap(shortRole, enabled: useColor)) + } + } + + // Subrole + if fields.contains(.subrole) { + if let subrole = try? element.attribute(AXAttribute.subrole)() { + let shortSubrole = subrole.replacingOccurrences(of: "AX", with: "") + parts.append(Color.blue.wrap("[\(shortSubrole)]", enabled: useColor)) + } + } + + // Title + if fields.contains(.title) { + if let title = try? element.attribute(AXAttribute.title)() { + let truncated = title.count > 40 ? String(title.prefix(40)) + "..." : title + parts.append(Color.yellow.wrap("\"\(truncated)\"", enabled: useColor)) + } + } + + // Identifier + if fields.contains(.identifier) { + if let id = try? element.attribute(AXAttribute.identifier)() { + parts.append(Color.green.wrap("#\(id)", enabled: useColor)) + } + } + + // Role description + if fields.contains(.roleDescription) { + if let roleDesc = try? element.attribute(AXAttribute.roleDescription)() { + parts.append(Color.dim.wrap("(\(roleDesc))", enabled: useColor)) + } + } + + // Value + if fields.contains(.value) { + if let value = try? element.attribute(AXAttribute.value)() { + let strValue = String(describing: value) + let truncated = strValue.count > 30 ? String(strValue.prefix(30)) + "..." : strValue + parts.append(Color.magenta.wrap("=\(truncated)", enabled: useColor)) + } + } + + // Description + if fields.contains(.description) { + if let desc = try? element.attribute(AXAttribute.description)() { + let truncated = desc.count > 30 ? String(desc.prefix(30)) + "..." : desc + parts.append(Color.dim.wrap("desc:\"\(truncated)\"", enabled: useColor)) + } + } + + // Enabled/Focused + if fields.contains(.enabled) { + if let enabled = try? element.attribute(AXAttribute.enabled)(), !enabled { + parts.append(Color.red.wrap("[disabled]", enabled: useColor)) + } + } + + if fields.contains(.focused) { + if let focused = try? element.attribute(AXAttribute.focused)(), focused { + parts.append(Color.brightGreen.wrap("[focused]", enabled: useColor)) + } + } + + // Position/Size/Frame + if fields.contains(.frame) { + if let frame = try? element.attribute(AXAttribute.frame)() { + parts.append(Color.dim.wrap("[\(Int(frame.origin.x)),\(Int(frame.origin.y)) \(Int(frame.width))x\(Int(frame.height))]", enabled: useColor)) + } + } else { + if fields.contains(.position) { + if let pos = try? element.attribute(AXAttribute.position)() { + parts.append(Color.dim.wrap("@(\(Int(pos.x)),\(Int(pos.y)))", enabled: useColor)) + } + } + if fields.contains(.size) { + if let size = try? element.attribute(AXAttribute.size)() { + parts.append(Color.dim.wrap("\(Int(size.width))x\(Int(size.height))", enabled: useColor)) + } + } + } + + // Help + if fields.contains(.help) { + if let help = try? element.attribute(AXAttribute.help)() { + let truncated = help.count > 30 ? String(help.prefix(30)) + "..." : help + parts.append(Color.dim.wrap("help:\"\(truncated)\"", enabled: useColor)) + } + } + + // Child count + if fields.contains(.childCount) { + let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = element.attribute(.init("AXChildren")) + if let count = try? childrenAttr.count(), count > 0 { + parts.append(Color.dim.wrap("(\(count) children)", enabled: useColor)) + } + } + + return parts.isEmpty ? Color.dim.wrap("(empty)", enabled: useColor) : parts.joined(separator: " ") + } +} + +// MARK: - ANSI Color Support + +enum Color: String { + case reset = "\u{001B}[0m" + case dim = "\u{001B}[2m" + case bold = "\u{001B}[1m" + case red = "\u{001B}[31m" + case green = "\u{001B}[32m" + case yellow = "\u{001B}[33m" + case blue = "\u{001B}[34m" + case magenta = "\u{001B}[35m" + case cyan = "\u{001B}[36m" + case white = "\u{001B}[37m" + case brightRed = "\u{001B}[91m" + case brightGreen = "\u{001B}[92m" + case brightYellow = "\u{001B}[93m" + case brightBlue = "\u{001B}[94m" + case brightMagenta = "\u{001B}[95m" + case brightCyan = "\u{001B}[96m" + + func wrap(_ text: String, enabled: Bool) -> String { + guard enabled else { return text } + return "\(rawValue)\(text)\(Color.reset.rawValue)" + } +} diff --git a/Sources/axdump/main.swift b/Sources/axdump/main.swift deleted file mode 100644 index 0425611..0000000 --- a/Sources/axdump/main.swift +++ /dev/null @@ -1,1396 +0,0 @@ -import Foundation -import AccessibilityControl -import AppKit -import ArgumentParser - -// MARK: - Attribute Fields - -struct AttributeFields: OptionSet { - let rawValue: Int - - static let role = AttributeFields(rawValue: 1 << 0) - static let roleDescription = AttributeFields(rawValue: 1 << 1) - static let title = AttributeFields(rawValue: 1 << 2) - static let identifier = AttributeFields(rawValue: 1 << 3) - static let value = AttributeFields(rawValue: 1 << 4) - static let description = AttributeFields(rawValue: 1 << 5) - static let enabled = AttributeFields(rawValue: 1 << 6) - static let focused = AttributeFields(rawValue: 1 << 7) - static let position = AttributeFields(rawValue: 1 << 8) - static let size = AttributeFields(rawValue: 1 << 9) - static let frame = AttributeFields(rawValue: 1 << 10) - static let help = AttributeFields(rawValue: 1 << 11) - static let subrole = AttributeFields(rawValue: 1 << 12) - - static let minimal: AttributeFields = [.role, .title, .identifier] - static let standard: AttributeFields = [.role, .roleDescription, .title, .identifier, .value, .description] - static let all: AttributeFields = [ - .role, .roleDescription, .title, .identifier, .value, - .description, .enabled, .focused, .position, .size, .frame, .help, .subrole - ] - - static func parse(_ string: String) -> AttributeFields { - var fields: AttributeFields = [] - for name in string.lowercased().split(separator: ",") { - switch name.trimmingCharacters(in: .whitespaces) { - case "role": fields.insert(.role) - case "roledescription", "role-description": fields.insert(.roleDescription) - case "title": fields.insert(.title) - case "identifier", "id": fields.insert(.identifier) - case "value": fields.insert(.value) - case "description", "desc": fields.insert(.description) - case "enabled": fields.insert(.enabled) - case "focused": fields.insert(.focused) - case "position", "pos": fields.insert(.position) - case "size": fields.insert(.size) - case "frame": fields.insert(.frame) - case "help": fields.insert(.help) - case "subrole": fields.insert(.subrole) - case "minimal": fields.formUnion(.minimal) - case "standard": fields.formUnion(.standard) - case "all": fields.formUnion(.all) - default: break - } - } - return fields.isEmpty ? .standard : fields - } -} - -// MARK: - Element Printer - -struct ElementPrinter { - let fields: AttributeFields - let verbosity: Int - - func formatElement(_ element: Accessibility.Element, indent: Int = 0) -> String { - let prefix = String(repeating: " ", count: indent) - var lines: [String] = [] - - var info: [String] = [] - - if fields.contains(.role) { - if let role: String = try? element.attribute(.init("AXRole"))() { - info.append("role=\(role)") - } - } - - if fields.contains(.subrole) { - if let subrole: String = try? element.attribute(.init("AXSubrole"))() { - info.append("subrole=\(subrole)") - } - } - - if fields.contains(.roleDescription) { - if let roleDesc: String = try? element.attribute(.init("AXRoleDescription"))() { - info.append("roleDesc=\"\(roleDesc)\"") - } - } - - if fields.contains(.title) { - if let title: String = try? element.attribute(.init("AXTitle"))() { - let truncated = title.count > 50 ? String(title.prefix(50)) + "..." : title - info.append("title=\"\(truncated)\"") - } - } - - if fields.contains(.identifier) { - if let id: String = try? element.attribute(.init("AXIdentifier"))() { - info.append("id=\"\(id)\"") - } - } - - if fields.contains(.description) { - if let desc: String = try? element.attribute(.init("AXDescription"))() { - let truncated = desc.count > 50 ? String(desc.prefix(50)) + "..." : desc - info.append("desc=\"\(truncated)\"") - } - } - - if fields.contains(.value) { - if let value: Any = try? element.attribute(.init("AXValue"))() { - let strValue = String(describing: value) - let truncated = strValue.count > 50 ? String(strValue.prefix(50)) + "..." : strValue - info.append("value=\"\(truncated)\"") - } - } - - if fields.contains(.enabled) { - if let enabled: Bool = try? element.attribute(.init("AXEnabled"))() { - info.append("enabled=\(enabled)") - } - } - - if fields.contains(.focused) { - if let focused: Bool = try? element.attribute(.init("AXFocused"))() { - info.append("focused=\(focused)") - } - } - - if fields.contains(.position) { - if let pos: CGPoint = try? element.attribute(.init("AXPosition"))() { - info.append("pos=(\(Int(pos.x)),\(Int(pos.y)))") - } - } - - if fields.contains(.size) { - if let size: CGSize = try? element.attribute(.init("AXSize"))() { - info.append("size=(\(Int(size.width))x\(Int(size.height)))") - } - } - - if fields.contains(.frame) { - if let frame: CGRect = try? element.attribute(.init("AXFrame"))() { - info.append("frame=(\(Int(frame.origin.x)),\(Int(frame.origin.y)) \(Int(frame.width))x\(Int(frame.height)))") - } - } - - if fields.contains(.help) { - if let help: String = try? element.attribute(.init("AXHelp"))() { - let truncated = help.count > 50 ? String(help.prefix(50)) + "..." : help - info.append("help=\"\(truncated)\"") - } - } - - let infoStr = info.isEmpty ? "(no attributes)" : info.joined(separator: " ") - lines.append("\(prefix)\(infoStr)") - - if verbosity >= 2 { - if let actions = try? element.supportedActions(), !actions.isEmpty { - let actionNames = actions.map { $0.name.value.replacingOccurrences(of: "AX", with: "") } - lines.append("\(prefix) actions: \(actionNames.joined(separator: ", "))") - } - } - - return lines.joined(separator: "\n") - } - - func printTree(_ element: Accessibility.Element, maxDepth: Int, currentDepth: Int = 0) { - print(formatElement(element, indent: currentDepth)) - - guard currentDepth < maxDepth else { return } - - let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = element.attribute(.init("AXChildren")) - guard let children: [Accessibility.Element] = try? childrenAttr() else { return } - - for child in children { - printTree(child, maxDepth: maxDepth, currentDepth: currentDepth + 1) - } - } -} - -// MARK: - Commands - -struct AXDump: ParsableCommand { - static var configuration = CommandConfiguration( - commandName: "axdump", - abstract: "Dump accessibility tree information for running applications", - discussion: """ - A command-line tool for exploring and debugging macOS accessibility trees. - Requires accessibility permissions (System Preferences > Security & Privacy > Privacy > Accessibility). - - EXAMPLES: - axdump list List running applications with PIDs - axdump dump 710 -d 2 Dump Finder's tree (2 levels deep) - axdump inspect 710 -p 0.0 Inspect first grandchild element - axdump observe 710 -n all -v Watch all notifications - - WORKFLOW: - 1. Use 'list' to find the PID of the target application - 2. Use 'dump' to explore the element hierarchy - 3. Use 'inspect' to read full attribute values or navigate to specific elements - 4. Use 'observe' to monitor real-time accessibility events - """, - subcommands: [List.self, Dump.self, Query.self, Inspect.self, Observe.self], - defaultSubcommand: List.self - ) -} - -extension AXDump { - struct List: ParsableCommand { - static var configuration = CommandConfiguration( - abstract: "List running applications with accessibility elements", - discussion: """ - Lists all running applications that can be inspected via accessibility APIs. - By default, only shows regular (foreground) applications. - - EXAMPLES: - axdump list List foreground apps with PIDs - axdump list -a Include background/menu bar apps - axdump list -v Show window count and app title - axdump list -av Verbose listing of all apps - """ - ) - - @Flag(name: .shortAndLong, help: "Show all applications (including background)") - var all: Bool = false - - @Flag(name: .shortAndLong, help: "Show detailed information") - var verbose: Bool = false - - func run() throws { - guard Accessibility.isTrusted(shouldPrompt: true) else { - print("Error: Accessibility permissions required") - print("Please grant permissions in System Preferences > Security & Privacy > Privacy > Accessibility") - throw ExitCode.failure - } - - let apps = NSWorkspace.shared.runningApplications - let filteredApps = all ? apps : apps.filter { $0.activationPolicy == .regular } - - let sortedApps = filteredApps.sorted { ($0.localizedName ?? "") < ($1.localizedName ?? "") } - - print("Running Applications:") - print(String(repeating: "-", count: 60)) - - for app in sortedApps { - let name = app.localizedName ?? "Unknown" - let pid = app.processIdentifier - let bundleID = app.bundleIdentifier ?? "N/A" - - if verbose { - print("\(String(format: "%6d", pid)) \(name)") - print(" Bundle: \(bundleID)") - - let element = Accessibility.Element(pid: pid) - let windowsAttr: Accessibility.Attribute<[Accessibility.Element]> = element.attribute(.init("AXWindows")) - if let windowCount = try? windowsAttr.count() { - print(" Windows: \(windowCount)") - } - if let title: String = try? element.attribute(.init("AXTitle"))() { - print(" Title: \(title)") - } - print() - } else { - print("\(String(format: "%6d", pid)) \(name) (\(bundleID))") - } - } - } - } -} - -extension AXDump { - struct Dump: ParsableCommand { - static var configuration = CommandConfiguration( - abstract: "Dump accessibility tree for an application", - discussion: """ - Recursively dumps the accessibility element hierarchy starting from the - application root or focused window. Output is indented to show nesting. - - FIELD PRESETS: - minimal - role, title, identifier - standard - role, roleDescription, title, identifier, value, description - all - all available fields - - INDIVIDUAL FIELDS: - role, subrole, roleDescription (or role-description), title, - identifier (or id), value, description (or desc), enabled, - focused, position (or pos), size, frame, help - - EXAMPLES: - axdump dump 710 Dump with default settings - axdump dump 710 -d 5 Dump 5 levels deep - axdump dump 710 -f minimal Only show role, title, id - axdump dump 710 -f role,title,value Custom field selection - axdump dump 710 -w Start from focused window - axdump dump 710 -v 2 Verbose (includes actions) - """ - ) - - @Argument(help: "Process ID of the application") - var pid: Int32 - - @Option(name: .shortAndLong, help: "Maximum depth to traverse (default: 3)") - var depth: Int = 3 - - @Option(name: .shortAndLong, help: "Verbosity level: 0=minimal, 1=normal, 2=detailed") - var verbosity: Int = 1 - - @Option(name: [.customShort("f"), .long], help: "Fields to display (comma-separated): role,title,identifier,value,description,enabled,focused,position,size,frame,help,subrole,roleDescription. Presets: minimal,standard,all") - var fields: String = "standard" - - @Flag(name: .shortAndLong, help: "Start from focused window instead of application root") - var window: Bool = false - - func run() throws { - guard Accessibility.isTrusted(shouldPrompt: true) else { - print("Error: Accessibility permissions required") - throw ExitCode.failure - } - - let appElement = Accessibility.Element(pid: pid) - - let rootElement: Accessibility.Element - if window { - guard let focusedWindow: Accessibility.Element = try? appElement.attribute(.init("AXFocusedWindow"))() else { - print("Error: Could not get focused window for PID \(pid)") - throw ExitCode.failure - } - rootElement = focusedWindow - } else { - rootElement = appElement - } - - let attributeFields = AttributeFields.parse(fields) - let printer = ElementPrinter(fields: attributeFields, verbosity: verbosity) - - if let appName: String = try? appElement.attribute(.init("AXTitle"))() { - print("Accessibility Tree for: \(appName) (PID: \(pid))") - } else { - print("Accessibility Tree for PID: \(pid)") - } - print(String(repeating: "=", count: 60)) - - printer.printTree(rootElement, maxDepth: depth) - } - } -} - -extension AXDump { - struct Query: ParsableCommand { - static var configuration = CommandConfiguration( - abstract: "Query specific element relationships", - discussion: """ - Query relationships between accessibility elements like parent, children, - siblings, or list all attributes of an element. - - RELATIONS: - children - Direct child elements - parent - Parent element - siblings - Sibling elements (same parent) - windows - Application windows - focused - Focused window and UI element - all-attributes - All attributes with truncated values (aliases: attrs, attributes) - - EXAMPLES: - axdump query 710 -r windows List all windows - axdump query 710 -r children Show app's direct children - axdump query 710 -r children -F Children of focused element - axdump query 710 -r siblings -F Siblings of focused element - axdump query 710 -r all-attributes List all attributes (truncated) - axdump query 710 -r focused Show focused window and element - """ - ) - - @Argument(help: "Process ID of the application") - var pid: Int32 - - @Option(name: [.customShort("r"), .long], help: "Relationship to query: children, parent, siblings, windows, focused, all-attributes") - var relation: String = "children" - - @Option(name: [.customShort("f"), .long], help: "Fields to display") - var fields: String = "standard" - - @Option(name: .shortAndLong, help: "Verbosity level") - var verbosity: Int = 1 - - @Flag(name: [.customShort("F"), .long], help: "Query from focused element instead of application root") - var focused: Bool = false - - func run() throws { - guard Accessibility.isTrusted(shouldPrompt: true) else { - print("Error: Accessibility permissions required") - throw ExitCode.failure - } - - let appElement = Accessibility.Element(pid: pid) - - let targetElement: Accessibility.Element - if focused { - guard let focusedElement: Accessibility.Element = try? appElement.attribute(.init("AXFocusedUIElement"))() else { - print("Error: Could not get focused element for PID \(pid)") - throw ExitCode.failure - } - targetElement = focusedElement - } else { - targetElement = appElement - } - - let attributeFields = AttributeFields.parse(fields) - let printer = ElementPrinter(fields: attributeFields, verbosity: verbosity) - - switch relation.lowercased() { - case "children": - queryChildren(of: targetElement, printer: printer) - - case "parent": - queryParent(of: targetElement, printer: printer) - - case "siblings": - querySiblings(of: targetElement, printer: printer) - - case "windows": - queryWindows(of: appElement, printer: printer) - - case "focused": - queryFocused(of: appElement, printer: printer) - - case "all-attributes", "attrs", "attributes": - queryAllAttributes(of: targetElement) - - default: - print("Unknown relation: \(relation)") - print("Valid options: children, parent, siblings, windows, focused, all-attributes") - throw ExitCode.failure - } - } - - private func queryChildren(of element: Accessibility.Element, printer: ElementPrinter) { - print("Children:") - print(String(repeating: "-", count: 40)) - - let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = element.attribute(.init("AXChildren")) - guard let children: [Accessibility.Element] = try? childrenAttr() else { - print("(no children or unable to read)") - return - } - - print("Count: \(children.count)") - print() - - for (index, child) in children.enumerated() { - print("[\(index)] \(printer.formatElement(child))") - } - } - - private func queryParent(of element: Accessibility.Element, printer: ElementPrinter) { - print("Parent:") - print(String(repeating: "-", count: 40)) - - guard let parent: Accessibility.Element = try? element.attribute(.init("AXParent"))() else { - print("(no parent or unable to read)") - return - } - - print(printer.formatElement(parent)) - } - - private func querySiblings(of element: Accessibility.Element, printer: ElementPrinter) { - print("Siblings:") - print(String(repeating: "-", count: 40)) - - guard let parent: Accessibility.Element = try? element.attribute(.init("AXParent"))() else { - print("(no parent - cannot determine siblings)") - return - } - - let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = parent.attribute(.init("AXChildren")) - guard let siblings: [Accessibility.Element] = try? childrenAttr() else { - print("(unable to read parent's children)") - return - } - - let filteredSiblings = siblings.filter { $0 != element } - print("Count: \(filteredSiblings.count)") - print() - - for (index, sibling) in filteredSiblings.enumerated() { - print("[\(index)] \(printer.formatElement(sibling))") - } - } - - private func queryWindows(of element: Accessibility.Element, printer: ElementPrinter) { - print("Windows:") - print(String(repeating: "-", count: 40)) - - let windowsAttr: Accessibility.Attribute<[Accessibility.Element]> = element.attribute(.init("AXWindows")) - guard let windows: [Accessibility.Element] = try? windowsAttr() else { - print("(no windows or unable to read)") - return - } - - print("Count: \(windows.count)") - print() - - for (index, window) in windows.enumerated() { - print("[\(index)] \(printer.formatElement(window))") - } - } - - private func queryFocused(of element: Accessibility.Element, printer: ElementPrinter) { - print("Focused Elements:") - print(String(repeating: "-", count: 40)) - - if let focusedWindow: Accessibility.Element = try? element.attribute(.init("AXFocusedWindow"))() { - print("Focused Window:") - print(" \(printer.formatElement(focusedWindow))") - print() - } - - if let focusedElement: Accessibility.Element = try? element.attribute(.init("AXFocusedUIElement"))() { - print("Focused UI Element:") - print(" \(printer.formatElement(focusedElement))") - } - } - - private func queryAllAttributes(of element: Accessibility.Element) { - print("All Attributes:") - print(String(repeating: "-", count: 40)) - - guard let attributes = try? element.supportedAttributes() else { - print("(unable to read attributes)") - return - } - - for attr in attributes.sorted(by: { $0.name.value < $1.name.value }) { - let name = attr.name.value - if let value: Any = try? attr() { - let strValue = String(describing: value) - let truncated = strValue.count > 80 ? String(strValue.prefix(80)) + "..." : strValue - print("\(name): \(truncated)") - } else { - print("\(name): (unable to read)") - } - } - - print() - print("Parameterized Attributes:") - print(String(repeating: "-", count: 40)) - - if let paramAttrs = try? element.supportedParameterizedAttributes() { - for attr in paramAttrs.sorted(by: { $0.name.value < $1.name.value }) { - print(attr.name.value) - } - } - - print() - print("Actions:") - print(String(repeating: "-", count: 40)) - - if let actions = try? element.supportedActions() { - for action in actions.sorted(by: { $0.name.value < $1.name.value }) { - print("\(action.name.value): \(action.description)") - } - } - } - } -} - -extension AXDump { - struct Inspect: ParsableCommand { - static var configuration = CommandConfiguration( - abstract: "Inspect specific attributes or elements in full detail", - discussion: """ - Read attribute values in full (without truncation) and navigate to specific - elements in the hierarchy using child indices. - - NAVIGATION: - Use -c (--child) for single-level navigation or -p (--path) for multi-level. - Path format: dot-separated indices, e.g., "0.3.1" means: - - First child of root (index 0) - - Fourth child of that (index 3) - - Second child of that (index 1) - - ATTRIBUTES: - Use -a to specify attributes to read. Can omit 'AX' prefix. - Use -a list to see all available attributes for an element. - - EXAMPLES: - axdump inspect 710 Show all attributes (full values) - axdump inspect 710 -a list List available attributes - axdump inspect 710 -a AXValue Read AXValue in full - axdump inspect 710 -a Value,Title Read multiple (AX prefix optional) - axdump inspect 710 -c 0 Inspect first child - axdump inspect 710 -p 0.2.1 Navigate to nested element - axdump inspect 710 -w -a AXChildren From focused window - axdump inspect 710 -F -p 0 First child of focused element - axdump inspect 710 -j Output as JSON - axdump inspect 710 -l 500 Truncate values at 500 chars - """ - ) - - @Argument(help: "Process ID of the application") - var pid: Int32 - - @Option(name: [.customShort("p"), .long], help: "Path to element as dot-separated child indices (e.g., '0.3.1' for first child, then 4th child, then 2nd child)") - var path: String? - - @Option(name: [.customShort("a"), .long], help: "Specific attribute(s) to read in full (comma-separated, e.g., 'AXValue,AXTitle'). Use 'list' to show available attributes.") - var attributes: String? - - @Option(name: [.customShort("c"), .long], help: "Index of child element to inspect (shorthand for --path)") - var child: Int? - - @Flag(name: [.customShort("F"), .long], help: "Start from focused element") - var focused: Bool = false - - @Flag(name: .shortAndLong, help: "Start from focused window") - var window: Bool = false - - @Option(name: [.customShort("l"), .long], help: "Maximum output length per attribute (0 for unlimited)") - var maxLength: Int = 0 - - @Flag(name: [.customShort("j"), .long], help: "Output as JSON") - var json: Bool = false - - func run() throws { - guard Accessibility.isTrusted(shouldPrompt: true) else { - print("Error: Accessibility permissions required") - throw ExitCode.failure - } - - let appElement = Accessibility.Element(pid: pid) - - // Determine starting element - var targetElement: Accessibility.Element = appElement - - if focused { - guard let focusedElement: Accessibility.Element = try? appElement.attribute(.init("AXFocusedUIElement"))() else { - print("Error: Could not get focused element for PID \(pid)") - throw ExitCode.failure - } - targetElement = focusedElement - } else if window { - guard let focusedWindow: Accessibility.Element = try? appElement.attribute(.init("AXFocusedWindow"))() else { - print("Error: Could not get focused window for PID \(pid)") - throw ExitCode.failure - } - targetElement = focusedWindow - } - - // Navigate to child if specified - if let childIndex = child { - targetElement = try navigateToChild(from: targetElement, index: childIndex) - } - - // Navigate via path if specified - if let pathString = path { - targetElement = try navigateToPath(from: targetElement, path: pathString) - } - - // Show element info - printElementHeader(targetElement) - - // Handle attribute inspection - if let attrString = attributes { - if attrString.lowercased() == "list" { - listAttributes(of: targetElement) - } else { - let attrNames = attrString.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) } - inspectAttributes(of: targetElement, names: attrNames) - } - } else { - // Default: show all attributes with full values - inspectAllAttributes(of: targetElement) - } - } - - private func navigateToChild(from element: Accessibility.Element, index: Int) throws -> Accessibility.Element { - let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = element.attribute(.init("AXChildren")) - guard let children: [Accessibility.Element] = try? childrenAttr() else { - throw ValidationError("Element has no children") - } - guard index >= 0 && index < children.count else { - throw ValidationError("Child index \(index) out of range (0..<\(children.count))") - } - return children[index] - } - - private func navigateToPath(from element: Accessibility.Element, path: String) throws -> Accessibility.Element { - var current = element - let indices = path.split(separator: ".").compactMap { Int($0) } - - for (step, index) in indices.enumerated() { - let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = current.attribute(.init("AXChildren")) - guard let children: [Accessibility.Element] = try? childrenAttr() else { - throw ValidationError("Element at step \(step) has no children") - } - guard index >= 0 && index < children.count else { - throw ValidationError("Child index \(index) at step \(step) out of range (0..<\(children.count))") - } - current = children[index] - } - - return current - } - - private func printElementHeader(_ element: Accessibility.Element) { - print("Element Info:") - print(String(repeating: "=", count: 60)) - - if let role: String = try? element.attribute(.init("AXRole"))() { - print("Role: \(role)") - } - if let title: String = try? element.attribute(.init("AXTitle"))() { - print("Title: \(title)") - } - if let id: String = try? element.attribute(.init("AXIdentifier"))() { - print("Identifier: \(id)") - } - - // Show child count for navigation hints - let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = element.attribute(.init("AXChildren")) - if let count = try? childrenAttr.count() { - print("Children: \(count)") - } - - print(String(repeating: "-", count: 60)) - print() - } - - private func listAttributes(of element: Accessibility.Element) { - print("Available Attributes:") - print(String(repeating: "-", count: 40)) - - guard let attributes = try? element.supportedAttributes() else { - print("(unable to read attributes)") - return - } - - for attr in attributes.sorted(by: { $0.name.value < $1.name.value }) { - let name = attr.name.value - let settable = (try? attr.isSettable()) ?? false - let settableStr = settable ? " [settable]" : "" - print(" \(name)\(settableStr)") - } - - print() - print("Parameterized Attributes:") - print(String(repeating: "-", count: 40)) - - if let paramAttrs = try? element.supportedParameterizedAttributes() { - for attr in paramAttrs.sorted(by: { $0.name.value < $1.name.value }) { - print(" \(attr.name.value)") - } - } - } - - private func inspectAttributes(of element: Accessibility.Element, names: [String]) { - if json { - var result: [String: Any] = [:] - for name in names { - let attrName = name.hasPrefix("AX") ? name : "AX\(name)" - if let value: Any = try? element.attribute(.init(attrName))() { - result[attrName] = formatValueForJSON(value) - } else { - result[attrName] = NSNull() - } - } - if let jsonData = try? JSONSerialization.data(withJSONObject: result, options: [.prettyPrinted, .sortedKeys]), - let jsonString = String(data: jsonData, encoding: .utf8) { - print(jsonString) - } - return - } - - for name in names { - let attrName = name.hasPrefix("AX") ? name : "AX\(name)" - print("\(attrName):") - print(String(repeating: "-", count: 40)) - - if let value: Any = try? element.attribute(.init(attrName))() { - let strValue = formatValue(value) - if maxLength > 0 && strValue.count > maxLength { - print(String(strValue.prefix(maxLength))) - print("... (truncated, total length: \(strValue.count))") - } else { - print(strValue) - } - } else { - print("(unable to read or no value)") - } - print() - } - } - - private func inspectAllAttributes(of element: Accessibility.Element) { - guard let attributes = try? element.supportedAttributes() else { - print("(unable to read attributes)") - return - } - - if json { - var result: [String: Any] = [:] - for attr in attributes { - if let value: Any = try? attr() { - result[attr.name.value] = formatValueForJSON(value) - } - } - if let jsonData = try? JSONSerialization.data(withJSONObject: result, options: [.prettyPrinted, .sortedKeys]), - let jsonString = String(data: jsonData, encoding: .utf8) { - print(jsonString) - } - return - } - - print("All Attributes (full values):") - print(String(repeating: "-", count: 40)) - - for attr in attributes.sorted(by: { $0.name.value < $1.name.value }) { - let name = attr.name.value - - if let value: Any = try? attr() { - let strValue = formatValue(value) - if maxLength > 0 && strValue.count > maxLength { - print("\(name): \(String(strValue.prefix(maxLength)))... (truncated)") - } else if strValue.contains("\n") || strValue.count > 80 { - print("\(name):") - print(strValue.split(separator: "\n", omittingEmptySubsequences: false) - .map { " \($0)" } - .joined(separator: "\n")) - } else { - print("\(name): \(strValue)") - } - } else { - print("\(name): (unable to read)") - } - } - } - - private func formatValue(_ value: Any) -> String { - switch value { - case let element as Accessibility.Element: - var parts: [String] = ["") - return parts.joined(separator: " ") - - case let elements as [Accessibility.Element]: - var lines: [String] = ["[\(elements.count) elements]"] - for (index, element) in elements.enumerated() { - var parts: [String] = [" [\(index)]"] - if let role: String = try? element.attribute(.init("AXRole"))() { - parts.append("role=\(role)") - } - if let title: String = try? element.attribute(.init("AXTitle"))() { - parts.append("title=\"\(title)\"") - } - if let id: String = try? element.attribute(.init("AXIdentifier"))() { - parts.append("id=\"\(id)\"") - } - lines.append(parts.joined(separator: " ")) - } - return lines.joined(separator: "\n") - - case let structValue as Accessibility.Struct: - switch structValue { - case .point(let point): - return "(\(point.x), \(point.y))" - case .size(let size): - return "\(size.width) x \(size.height)" - case .rect(let rect): - return "origin=(\(rect.origin.x), \(rect.origin.y)) size=(\(rect.width) x \(rect.height))" - case .range(let range): - return "\(range.lowerBound)..<\(range.upperBound)" - case .error(let error): - return "Error: \(error)" - } - - case let point as CGPoint: - return "(\(point.x), \(point.y))" - - case let size as CGSize: - return "\(size.width) x \(size.height)" - - case let rect as CGRect: - return "origin=(\(rect.origin.x), \(rect.origin.y)) size=(\(rect.width) x \(rect.height))" - - case let array as [Any]: - return array.map { formatValue($0) }.joined(separator: ", ") - - case let dict as [String: Any]: - return dict.map { "\($0.key): \(formatValue($0.value))" }.joined(separator: ", ") - - default: - return String(describing: value) - } - } - - private func formatValueForJSON(_ value: Any) -> Any { - switch value { - case let element as Accessibility.Element: - var dict: [String: Any] = ["_type": "element"] - if let role: String = try? element.attribute(.init("AXRole"))() { - dict["role"] = role - } - if let title: String = try? element.attribute(.init("AXTitle"))() { - dict["title"] = title - } - if let id: String = try? element.attribute(.init("AXIdentifier"))() { - dict["identifier"] = id - } - return dict - - case let elements as [Accessibility.Element]: - return elements.map { formatValueForJSON($0) } - - case let structValue as Accessibility.Struct: - switch structValue { - case .point(let point): - return ["x": point.x, "y": point.y] - case .size(let size): - return ["width": size.width, "height": size.height] - case .rect(let rect): - return ["x": rect.origin.x, "y": rect.origin.y, "width": rect.width, "height": rect.height] - case .range(let range): - return ["start": range.lowerBound, "end": range.upperBound] - case .error(let error): - return ["error": String(describing: error)] - } - - case let point as CGPoint: - return ["x": point.x, "y": point.y] - - case let size as CGSize: - return ["width": size.width, "height": size.height] - - case let rect as CGRect: - return [ - "x": rect.origin.x, - "y": rect.origin.y, - "width": rect.width, - "height": rect.height - ] - - case let array as [Any]: - return array.map { formatValueForJSON($0) } - - case let dict as [String: Any]: - return dict.mapValues { formatValueForJSON($0) } - - case let str as String: - return str - - case let num as NSNumber: - return num - - case let bool as Bool: - return bool - - default: - return String(describing: value) - } - } - } -} - -extension AXDump { - struct Observe: ParsableCommand { - static var configuration = CommandConfiguration( - abstract: "Observe accessibility notifications for an application", - discussion: """ - Monitor accessibility notifications in real-time. Each notification is printed - with a timestamp. Press Ctrl+C to stop observing. - - COMMON NOTIFICATIONS: - AXValueChanged - Element value changed - AXFocusedUIElementChanged - Focus moved to different element - AXFocusedWindowChanged - Different window got focus - AXSelectedTextChanged - Text selection changed - AXSelectedChildrenChanged - Child selection changed - AXWindowCreated/Moved/Resized - Window events - AXMenuOpened/Closed - Menu events - AXApplicationActivated - App became frontmost - - Use -n list to see all common notifications. - - EXAMPLES: - axdump observe 710 Observe focus changes (default) - axdump observe 710 -n list List available notifications - axdump observe 710 -n AXValueChanged Observe value changes - axdump observe 710 -n ValueChanged,Focused Multiple (AX prefix optional) - axdump observe 710 -n all Observe all notifications - axdump observe 710 -n all -v Verbose (show element details) - axdump observe 710 -w -n AXWindowMoved Observe from focused window - axdump observe 710 -n all -j JSON output - """ - ) - - @Argument(help: "Process ID of the application") - var pid: Int32 - - @Option(name: [.customShort("n"), .long], help: "Notification(s) to observe (comma-separated, e.g., 'AXValueChanged,AXFocusedUIElementChanged'). Use 'list' to show common notifications, 'all' to observe all available.") - var notifications: String = "AXFocusedUIElementChanged" - - @Option(name: [.customShort("p"), .long], help: "Path to element to observe (dot-separated child indices)") - var path: String? - - @Flag(name: [.customShort("F"), .long], help: "Observe focused element") - var focused: Bool = false - - @Flag(name: .shortAndLong, help: "Observe focused window") - var window: Bool = false - - @Flag(name: [.customShort("j"), .long], help: "Output as JSON") - var json: Bool = false - - @Flag(name: [.customShort("v"), .long], help: "Verbose output (show element details)") - var verbose: Bool = false - - @Flag(name: .long, help: "Disable colored output") - var noColor: Bool = false - - // ANSI color codes - private enum Color: String { - case reset = "\u{001B}[0m" - case dim = "\u{001B}[2m" - case bold = "\u{001B}[1m" - case red = "\u{001B}[31m" - case green = "\u{001B}[32m" - case yellow = "\u{001B}[33m" - case blue = "\u{001B}[34m" - case magenta = "\u{001B}[35m" - case cyan = "\u{001B}[36m" - case white = "\u{001B}[37m" - case brightRed = "\u{001B}[91m" - case brightGreen = "\u{001B}[92m" - case brightYellow = "\u{001B}[93m" - case brightBlue = "\u{001B}[94m" - case brightMagenta = "\u{001B}[95m" - case brightCyan = "\u{001B}[96m" - } - - private func colorForNotification(_ name: String) -> Color { - switch name { - case "AXValueChanged", "AXSelectedTextChanged": - return .green - case "AXFocusedUIElementChanged", "AXFocusedWindowChanged": - return .cyan - case "AXLayoutChanged", "AXResized", "AXMoved": - return .yellow - case "AXWindowCreated", "AXWindowMoved", "AXWindowResized": - return .blue - case "AXApplicationActivated", "AXApplicationDeactivated": - return .magenta - case "AXMenuOpened", "AXMenuClosed", "AXMenuItemSelected": - return .brightMagenta - case "AXUIElementDestroyed": - return .red - case "AXCreated": - return .brightGreen - case "AXTitleChanged": - return .brightCyan - default: - return .white - } - } - - // Common notifications - static let commonNotifications = [ - "AXValueChanged", - "AXUIElementDestroyed", - "AXSelectedTextChanged", - "AXSelectedChildrenChanged", - "AXFocusedUIElementChanged", - "AXFocusedWindowChanged", - "AXApplicationActivated", - "AXApplicationDeactivated", - "AXWindowCreated", - "AXWindowMoved", - "AXWindowResized", - "AXWindowMiniaturized", - "AXWindowDeminiaturized", - "AXDrawerCreated", - "AXSheetCreated", - "AXMenuOpened", - "AXMenuClosed", - "AXMenuItemSelected", - "AXTitleChanged", - "AXResized", - "AXMoved", - "AXCreated", - "AXLayoutChanged", - "AXSelectedCellsChanged", - "AXUnitsChanged", - "AXSelectedColumnsChanged", - "AXSelectedRowsChanged", - "AXRowCountChanged", - "AXRowExpanded", - "AXRowCollapsed", - ] - - func run() throws { - guard Accessibility.isTrusted(shouldPrompt: true) else { - print("Error: Accessibility permissions required") - throw ExitCode.failure - } - - // Handle 'list' option - if notifications.lowercased() == "list" { - print("Common Accessibility Notifications:") - print(String(repeating: "-", count: 40)) - for notification in Self.commonNotifications { - print(" \(notification)") - } - return - } - - let appElement = Accessibility.Element(pid: pid) - - // Determine target element - var targetElement: Accessibility.Element = appElement - - if focused { - guard let focusedElement: Accessibility.Element = try? appElement.attribute(.init("AXFocusedUIElement"))() else { - print("Error: Could not get focused element for PID \(pid)") - throw ExitCode.failure - } - targetElement = focusedElement - } else if window { - guard let focusedWindow: Accessibility.Element = try? appElement.attribute(.init("AXFocusedWindow"))() else { - print("Error: Could not get focused window for PID \(pid)") - throw ExitCode.failure - } - targetElement = focusedWindow - } - - // Navigate via path if specified - if let pathString = path { - targetElement = try navigateToPath(from: targetElement, path: pathString) - } - - // Print element info - printElementInfo(targetElement) - - // Determine which notifications to observe - let notificationNames: [String] - if notifications.lowercased() == "all" { - notificationNames = Self.commonNotifications - } else { - notificationNames = notifications.split(separator: ",") - .map { String($0).trimmingCharacters(in: .whitespaces) } - .map { $0.hasPrefix("AX") ? $0 : "AX\($0)" } - } - - print("Observing notifications: \(notificationNames.joined(separator: ", "))") - print("Press Ctrl+C to stop") - print(String(repeating: "=", count: 60)) - print() - - // Create observer - let observer = try Accessibility.Observer(pid: pid, on: .main) - - // Store tokens to keep observations alive - var tokens: [Accessibility.Observer.Token] = [] - - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "HH:mm:ss.SSS" - - for notificationName in notificationNames { - do { - let token = try observer.observe( - .init(notificationName), - for: targetElement - ) { [self] info in - let timestamp = dateFormatter.string(from: Date()) - - if json { - var output: [String: Any] = [ - "timestamp": timestamp, - "notification": notificationName - ] - - if let element = info["AXUIElement"] as? Accessibility.Element { - output["element"] = formatElementForJSON(element) - let pathInfo = computeElementPath(element, appElement: appElement) - output["path"] = pathInfo.path - output["chain"] = pathInfo.chain - } - - if let jsonData = try? JSONSerialization.data(withJSONObject: output, options: [.sortedKeys]), - let jsonString = String(data: jsonData, encoding: .utf8) { - print(jsonString) - } - } else { - let useColor = !noColor - let c = { (color: Color) -> String in useColor ? color.rawValue : "" } - let notifColor = colorForNotification(notificationName) - - var line = "\(c(.dim))[\(timestamp)]\(c(.reset)) " - line += "\(c(notifColor))\(notificationName)\(c(.reset))" - - if let element = info["AXUIElement"] as? Accessibility.Element { - let pathInfo = computeElementPath(element, appElement: appElement) - line += " \(c(.dim))@\(c(.reset)) \(c(.blue))\(pathInfo.path)\(c(.reset))" - if verbose { - line += "\n \(c(.dim))chain:\(c(.reset)) \(c(.magenta))\(pathInfo.chain)\(c(.reset))" - line += "\n \(c(.dim))element:\(c(.reset)) \(formatElementColored(element, useColor: useColor))" - } - } else { - line += " \(c(.dim))(no element)\(c(.reset))" - } - - print(line) - } - - // Flush output immediately - fflush(stdout) - } - tokens.append(token) - } catch { - if verbose { - print("Warning: Could not observe \(notificationName): \(error)") - } - } - } - - if tokens.isEmpty { - print("Error: Could not register for any notifications") - throw ExitCode.failure - } - - print("Successfully registered for \(tokens.count) notification(s)") - print() - - // Keep running - RunLoop.main.run() - } - - private func navigateToPath(from element: Accessibility.Element, path: String) throws -> Accessibility.Element { - var current = element - let indices = path.split(separator: ".").compactMap { Int($0) } - - for (step, index) in indices.enumerated() { - let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = current.attribute(.init("AXChildren")) - guard let children: [Accessibility.Element] = try? childrenAttr() else { - throw ValidationError("Element at step \(step) has no children") - } - guard index >= 0 && index < children.count else { - throw ValidationError("Child index \(index) at step \(step) out of range (0..<\(children.count))") - } - current = children[index] - } - - return current - } - - private func printElementInfo(_ element: Accessibility.Element) { - print("Observing Element:") - print(String(repeating: "-", count: 40)) - - if let role: String = try? element.attribute(.init("AXRole"))() { - print("Role: \(role)") - } - if let title: String = try? element.attribute(.init("AXTitle"))() { - print("Title: \(title)") - } - if let id: String = try? element.attribute(.init("AXIdentifier"))() { - print("Identifier: \(id)") - } - - print() - } - - private func formatElement(_ element: Accessibility.Element) -> String { - formatElementColored(element, useColor: false) - } - - private func formatElementColored(_ element: Accessibility.Element, useColor: Bool) -> String { - let c = { (color: Color) -> String in useColor ? color.rawValue : "" } - var parts: [String] = [] - - if let role: String = try? element.attribute(.init("AXRole"))() { - parts.append("\(c(.cyan))role\(c(.reset))=\(c(.white))\(role)\(c(.reset))") - } - if let title: String = try? element.attribute(.init("AXTitle"))() { - let truncated = title.count > 30 ? String(title.prefix(30)) + "..." : title - parts.append("\(c(.yellow))title\(c(.reset))=\"\(c(.white))\(truncated)\(c(.reset))\"") - } - if let id: String = try? element.attribute(.init("AXIdentifier"))() { - parts.append("\(c(.green))id\(c(.reset))=\"\(c(.white))\(id)\(c(.reset))\"") - } - if let value: Any = try? element.attribute(.init("AXValue"))() { - let strValue = String(describing: value) - let truncated = strValue.count > 30 ? String(strValue.prefix(30)) + "..." : strValue - parts.append("\(c(.magenta))value\(c(.reset))=\"\(c(.white))\(truncated)\(c(.reset))\"") - } - - return parts.isEmpty ? "(element)" : parts.joined(separator: " ") - } - - /// Compute the path from the application root to the given element - /// Returns a tuple of (indexPath, chainDescription) - /// indexPath is like "0.2.1" and chainDescription shows the hierarchy with roles/ids - private func computeElementPath(_ element: Accessibility.Element, appElement: Accessibility.Element) -> (path: String, chain: String) { - // Walk up the hierarchy collecting ancestors - var ancestors: [Accessibility.Element] = [] - var current = element - - while true { - ancestors.append(current) - guard let parent: Accessibility.Element = try? current.attribute(.init("AXParent"))() else { - break - } - // Stop if we've reached the application element - if parent == appElement { - break - } - current = parent - } - - // Reverse to get root-to-element order (excluding app element itself) - ancestors.reverse() - - // Now compute indices by finding each element's index in its parent's children - var indices: [Int] = [] - var chainParts: [String] = [] - - // Start from appElement and find indices - var parentForIndex = appElement - for ancestor in ancestors { - // Get children of parent - let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = parentForIndex.attribute(.init("AXChildren")) - if let children: [Accessibility.Element] = try? childrenAttr() { - if let index = children.firstIndex(of: ancestor) { - indices.append(index) - } else { - indices.append(-1) // Unknown index - } - } else { - indices.append(-1) - } - - // Build chain description for this element - var desc = "" - if let role: String = try? ancestor.attribute(.init("AXRole"))() { - desc = role.replacingOccurrences(of: "AX", with: "") - } - if let id: String = try? ancestor.attribute(.init("AXIdentifier"))() { - desc += "[\(id)]" - } else if let title: String = try? ancestor.attribute(.init("AXTitle"))() { - let truncated = title.count > 20 ? String(title.prefix(20)) + "..." : title - desc += "[\"\(truncated)\"]" - } - if desc.isEmpty { - desc = "?" - } - chainParts.append(desc) - - parentForIndex = ancestor - } - - let pathString = indices.map { $0 >= 0 ? String($0) : "?" }.joined(separator: ".") - let chainString = chainParts.joined(separator: " > ") - - return (pathString, chainString) - } - - private func formatElementForJSON(_ element: Accessibility.Element) -> [String: Any] { - var dict: [String: Any] = [:] - - if let role: String = try? element.attribute(.init("AXRole"))() { - dict["role"] = role - } - if let title: String = try? element.attribute(.init("AXTitle"))() { - dict["title"] = title - } - if let id: String = try? element.attribute(.init("AXIdentifier"))() { - dict["identifier"] = id - } - if let value: Any = try? element.attribute(.init("AXValue"))() { - dict["value"] = String(describing: value) - } - - return dict - } - } -} - -AXDump.main() From 20511b3b7098c71bc7ee3673598117ae1e2b1c62 Mon Sep 17 00:00:00 2001 From: Purav Manot Date: Fri, 30 Jan 2026 02:46:34 +0530 Subject: [PATCH 10/14] cleanup remove `axdump` --- Sources/axdump/AXDump.swift | 67 --- Sources/axdump/Commands/ActionCommand.swift | 299 ------------- Sources/axdump/Commands/CompareCommand.swift | 296 ------------- Sources/axdump/Commands/DumpCommand.swift | 194 -------- Sources/axdump/Commands/FindCommand.swift | 383 ---------------- Sources/axdump/Commands/InspectCommand.swift | 249 ----------- Sources/axdump/Commands/KeyCommand.swift | 391 ----------------- Sources/axdump/Commands/ListCommand.swift | 91 ---- Sources/axdump/Commands/MenuCommand.swift | 413 ------------------ Sources/axdump/Commands/ObserveCommand.swift | 265 ----------- Sources/axdump/Commands/QueryCommand.swift | 232 ---------- .../axdump/Commands/ScreenshotCommand.swift | 334 -------------- Sources/axdump/Commands/SetCommand.swift | 185 -------- Sources/axdump/Commands/WatchCommand.swift | 280 ------------ .../axdump/Utilities/AttributeFields.swift | 118 ----- Sources/axdump/Utilities/Constants.swift | 319 -------------- Sources/axdump/Utilities/ElementFilter.swift | 229 ---------- Sources/axdump/Utilities/ElementPrinter.swift | 412 ----------------- Sources/axdump/Utilities/TreePrinter.swift | 274 ------------ 19 files changed, 5031 deletions(-) delete mode 100644 Sources/axdump/AXDump.swift delete mode 100644 Sources/axdump/Commands/ActionCommand.swift delete mode 100644 Sources/axdump/Commands/CompareCommand.swift delete mode 100644 Sources/axdump/Commands/DumpCommand.swift delete mode 100644 Sources/axdump/Commands/FindCommand.swift delete mode 100644 Sources/axdump/Commands/InspectCommand.swift delete mode 100644 Sources/axdump/Commands/KeyCommand.swift delete mode 100644 Sources/axdump/Commands/ListCommand.swift delete mode 100644 Sources/axdump/Commands/MenuCommand.swift delete mode 100644 Sources/axdump/Commands/ObserveCommand.swift delete mode 100644 Sources/axdump/Commands/QueryCommand.swift delete mode 100644 Sources/axdump/Commands/ScreenshotCommand.swift delete mode 100644 Sources/axdump/Commands/SetCommand.swift delete mode 100644 Sources/axdump/Commands/WatchCommand.swift delete mode 100644 Sources/axdump/Utilities/AttributeFields.swift delete mode 100644 Sources/axdump/Utilities/Constants.swift delete mode 100644 Sources/axdump/Utilities/ElementFilter.swift delete mode 100644 Sources/axdump/Utilities/ElementPrinter.swift delete mode 100644 Sources/axdump/Utilities/TreePrinter.swift diff --git a/Sources/axdump/AXDump.swift b/Sources/axdump/AXDump.swift deleted file mode 100644 index dd3863f..0000000 --- a/Sources/axdump/AXDump.swift +++ /dev/null @@ -1,67 +0,0 @@ -import Foundation -import ArgumentParser -import AccessibilityControl -import WindowControl -import AppKit -import CoreGraphics - -// MARK: - Main Command - -@main -struct AXDump: AsyncParsableCommand { - static var configuration = CommandConfiguration( - commandName: "axdump", - abstract: "Dump accessibility tree information for running applications", - discussion: """ - A command-line tool for exploring and debugging macOS accessibility trees. - Requires accessibility permissions (System Preferences > Security & Privacy > Privacy > Accessibility). - - QUICK START: - axdump watch Live explore elements under cursor - axdump find 710 "Save" --click Find and click "Save" button - axdump find 710 --role TextField --type "hello" - axdump menu 710 "File > Save" -x Execute menu item - - EXAMPLES: - axdump list List running applications with PIDs - axdump find 710 "OK" -c Find "OK" button and click it - axdump find 710 --role Button Find all buttons - axdump watch 710 --path Watch with element paths - axdump dump 710 -d 2 Dump tree (2 levels deep) - axdump menu 710 -m "Edit" -x Explore Edit menu - axdump key 710 "cmd+c" Send keyboard shortcut - - WORKFLOW: - 1. Use 'watch' to explore UI and find elements interactively - 2. Use 'find' to locate and act on elements by text/role - 3. Use 'menu' to explore and execute menu items - 4. Use 'dump' for detailed tree exploration - 5. Use 'key' for keyboard shortcuts - - REFERENCE: - axdump list --list-roles Show all known accessibility roles - axdump list --list-subroles Show all known subroles - axdump list --list-actions Show all known actions - - For more help on a specific command: - axdump --help - """, - subcommands: [ - List.self, - Find.self, - Watch.self, - Dump.self, - Query.self, - Inspect.self, - Observe.self, - Screenshot.self, - Action.self, - Set.self, - Key.self, - Menu.self, - Compare.self - ], - defaultSubcommand: List.self - ) -} - diff --git a/Sources/axdump/Commands/ActionCommand.swift b/Sources/axdump/Commands/ActionCommand.swift deleted file mode 100644 index 53e906c..0000000 --- a/Sources/axdump/Commands/ActionCommand.swift +++ /dev/null @@ -1,299 +0,0 @@ -import Foundation -import ArgumentParser -import AccessibilityControl - -extension AXDump { - struct Action: ParsableCommand { - static var configuration = CommandConfiguration( - abstract: "Perform an action on an accessibility element", - discussion: """ - Execute accessibility actions on elements. Navigate to the target element - using path notation or focus options. - - \(AXActions.helpText()) - - CUSTOM ACTIONS: - Some elements expose custom actions (e.g., tapback reactions in Messages). - Use --custom (-C) to perform these by name. Use --list to see available - custom actions for an element. - - EXAMPLES: - axdump action 710 -a Press -p 0.1.2 Press element at path - axdump action 710 -a Press -F Press focused element - axdump action 710 -a Raise -w Raise focused window - axdump action 710 -a Increment -p 0.3 Increment slider - axdump action 710 -a ShowMenu -F Show context menu - axdump action 710 --list -p 0.1 List actions for element - axdump action 710 --list-actions Show all known actions - axdump action 710 -C "Heart" -p 0.0.0.0.0.0.11.0 Perform custom action - axdump action 710 -C "Thumbs up" -p 0.1.2 Perform tapback reaction - """ - ) - - @Argument(help: "Process ID of the application") - var pid: Int32 - - @Option(name: [.customShort("a"), .long], help: "Standard AX action to perform (can omit 'AX' prefix)") - var action: String? - - @Option(name: [.customShort("C"), .long], help: "Custom action to perform by name (e.g., 'Heart', 'Thumbs up')") - var custom: String? - - @Option(name: [.customShort("p"), .long], help: "Path to element (dot-separated child indices)") - var path: String? - - @Option(name: [.customShort("c"), .long], help: "Index of child element (shorthand for single-level path)") - var child: Int? - - @Flag(name: [.customShort("F"), .long], help: "Target the focused element") - var focused: Bool = false - - @Flag(name: .shortAndLong, help: "Target the focused window") - var window: Bool = false - - @Flag(name: .long, help: "List available actions for the target element") - var list: Bool = false - - @Flag(name: .long, help: "List all known accessibility actions") - var listActions: Bool = false - - @Flag(name: .shortAndLong, help: "Verbose output") - var verbose: Bool = false - - func run() throws { - // Handle global list - if listActions { - print(AXActions.fullHelpText()) - return - } - - guard Accessibility.isTrusted(shouldPrompt: true) else { - print("Error: Accessibility permissions required") - throw ExitCode.failure - } - - let appElement = Accessibility.Element(pid: pid) - - // Determine target element - var targetElement: Accessibility.Element = appElement - - if focused { - guard let focusedElement: Accessibility.Element = try? appElement.attribute(.init("AXFocusedUIElement"))() else { - print("Error: Could not get focused element for PID \(pid)") - throw ExitCode.failure - } - targetElement = focusedElement - } else if window { - guard let focusedWindow: Accessibility.Element = try? appElement.attribute(.init("AXFocusedWindow"))() else { - print("Error: Could not get focused window for PID \(pid)") - throw ExitCode.failure - } - targetElement = focusedWindow - } - - // Navigate to child if specified - if let childIndex = child { - targetElement = try navigateToChild(from: targetElement, index: childIndex) - } - - // Navigate via path if specified - if let pathString = path { - targetElement = try navigateToPath(from: targetElement, path: pathString) - } - - // Print element info - if verbose { - printElementInfo(targetElement) - } - - // Handle list - if list { - listActionsForElement(targetElement) - return - } - - // Handle custom action - if let customActionName = custom { - try performCustomAction(customActionName, on: targetElement) - return - } - - // Require action - guard let actionName = action else { - print("Error: No action specified. Use -a , -C , or --list to see available actions.") - throw ExitCode.failure - } - - // Perform standard AX action - let fullActionName = actionName.hasPrefix("AX") ? actionName : "AX\(actionName)" - - do { - let axAction = targetElement.action(.init(fullActionName)) - try axAction() - - print("Action performed: \(fullActionName)") - - if verbose { - // Show element state after action - print() - print("Element state after action:") - printElementInfo(targetElement) - } - } catch { - print("Error: Failed to perform action '\(fullActionName)': \(error)") - throw ExitCode.failure - } - } - - private func performCustomAction(_ name: String, on element: Accessibility.Element) throws { - // Get all actions and find matching custom action - guard let actions = try? element.supportedActions() else { - print("Error: Could not read actions for element") - throw ExitCode.failure - } - - // Find custom action matching the name - // Custom actions have format: "Name:...\nTarget:...\nSelector:..." - var matchingAction: Accessibility.Action? - for action in actions { - let actionName = action.name.value - if actionName.hasPrefix("Name:") { - // Parse custom action name - let parsed = parseCustomAction(actionName) - if parsed.name.lowercased() == name.lowercased() { - matchingAction = action - break - } - } - } - - guard let action = matchingAction else { - print("Error: Custom action '\(name)' not found") - print() - print("Available custom actions:") - for action in actions { - let actionName = action.name.value - if actionName.hasPrefix("Name:") { - let parsed = parseCustomAction(actionName) - print(" - \(parsed.name)") - } - } - throw ExitCode.failure - } - - // Perform the action - do { - try action() - print("Custom action performed: \(name)") - - if verbose { - print() - print("Element state after action:") - printElementInfo(element) - } - } catch { - print("Error: Failed to perform custom action '\(name)': \(error)") - throw ExitCode.failure - } - } - - private func parseCustomAction(_ raw: String) -> (name: String, target: String?, selector: String?) { - var name = "" - var target: String? - var selector: String? - - for line in raw.split(separator: "\n", omittingEmptySubsequences: false) { - let lineStr = String(line) - if lineStr.hasPrefix("Name:") { - name = String(lineStr.dropFirst(5)) - } else if lineStr.hasPrefix("Target:") { - target = String(lineStr.dropFirst(7)) - } else if lineStr.hasPrefix("Selector:") { - selector = String(lineStr.dropFirst(9)) - } - } - - return (name, target, selector) - } - - private func printElementInfo(_ element: Accessibility.Element) { - print("Target Element:") - print(String(repeating: "-", count: 40)) - - if let role: String = try? element.attribute(AXAttribute.role)() { - print(" Role: \(role)") - } - if let subrole: String = try? element.attribute(AXAttribute.subrole)() { - print(" Subrole: \(subrole)") - } - if let title: String = try? element.attribute(AXAttribute.title)() { - print(" Title: \(title)") - } - if let id: String = try? element.attribute(AXAttribute.identifier)() { - print(" Identifier: \(id)") - } - if let value: Any = try? element.attribute(AXAttribute.value)() { - let strValue = String(describing: value) - let truncated = strValue.count > 50 ? String(strValue.prefix(50)) + "..." : strValue - print(" Value: \(truncated)") - } - if let enabled: Bool = try? element.attribute(AXAttribute.enabled)() { - print(" Enabled: \(enabled)") - } - if let focused: Bool = try? element.attribute(AXAttribute.focused)() { - print(" Focused: \(focused)") - } - - print() - } - - private func listActionsForElement(_ element: Accessibility.Element) { - guard let actions = try? element.supportedActions() else { - print("(unable to read actions)") - return - } - - if actions.isEmpty { - print("(no actions available)") - return - } - - // Separate standard and custom actions - var standardActions: [Accessibility.Action] = [] - var customActions: [(name: String, action: Accessibility.Action)] = [] - - for action in actions { - let name = action.name.value - if name.hasPrefix("Name:") { - let parsed = parseCustomAction(name) - customActions.append((parsed.name, action)) - } else { - standardActions.append(action) - } - } - - // Print standard actions - if !standardActions.isEmpty { - print("Standard Actions:") - print(String(repeating: "-", count: 40)) - for action in standardActions.sorted(by: { $0.name.value < $1.name.value }) { - let name = action.name.value - let shortName = name.replacingOccurrences(of: "AX", with: "") - let knownDesc = AXActions.all[name] - let desc = knownDesc ?? action.description - print(" \(shortName.padding(toLength: 20, withPad: " ", startingAt: 0)) \(desc)") - } - } - - // Print custom actions - if !customActions.isEmpty { - if !standardActions.isEmpty { print() } - print("Custom Actions (use -C \"name\"):") - print(String(repeating: "-", count: 40)) - for (name, _) in customActions.sorted(by: { $0.name < $1.name }) { - print(" \(name)") - } - } - } - } -} diff --git a/Sources/axdump/Commands/CompareCommand.swift b/Sources/axdump/Commands/CompareCommand.swift deleted file mode 100644 index b3f8376..0000000 --- a/Sources/axdump/Commands/CompareCommand.swift +++ /dev/null @@ -1,296 +0,0 @@ -import Foundation -import ArgumentParser -import AccessibilityControl -import WindowControl -import CoreGraphics -import AppKit - -extension AXDump { - struct Compare: ParsableCommand { - static var configuration = CommandConfiguration( - abstract: "Screenshot before and after an action", - discussion: """ - Captures a screenshot of an element, performs an action, then captures - another screenshot. Useful for understanding how actions affect elements. - - OUTPUT: - Creates two files: _before.png and _after.png - Default names are based on the action and element path. - - EXAMPLES: - axdump compare 710 -a Press -p 0.1.2 Press and compare - axdump compare 710 -a Press -F -o toggle Named output - axdump compare 710 -a Increment -p 0.3 -d 500 Wait 500ms between - axdump compare 710 -a ShowMenu -F --no-window Capture element only - """ - ) - - @Argument(help: "Process ID of the application") - var pid: Int32 - - @Option(name: [.customShort("a"), .long], help: "Action to perform (can omit 'AX' prefix)") - var action: String - - @Option(name: [.customShort("p"), .long], help: "Path to element (dot-separated child indices)") - var path: String? - - @Option(name: [.customShort("c"), .long], help: "Index of child element") - var child: Int? - - @Flag(name: [.customShort("F"), .long], help: "Target the focused element") - var focused: Bool = false - - @Flag(name: .shortAndLong, help: "Target the focused window") - var window: Bool = false - - @Option(name: [.customShort("o"), .long], help: "Output file prefix (default: _)") - var output: String? - - @Option(name: [.customShort("d"), .long], help: "Delay in milliseconds between action and after screenshot (default: 100)") - var delay: Int = 100 - - @Flag(name: .long, help: "Only capture the element frame, not the whole window") - var noWindow: Bool = false - - @Option(name: [.customShort("i"), .long], help: "Window index to capture (default: focused window)") - var windowIndex: Int? - - @Flag(name: .long, help: "Include window shadow") - var shadow: Bool = false - - @Option(name: .long, help: "Bounding box color: red, green, blue, yellow, orange, cyan, magenta") - var boxColor: String = "red" - - func run() throws { - guard Accessibility.isTrusted(shouldPrompt: true) else { - print("Error: Accessibility permissions required") - throw ExitCode.failure - } - - let appElement = Accessibility.Element(pid: pid) - - // Determine target element for action - var targetElement: Accessibility.Element = appElement - - if focused { - guard let focusedElement: Accessibility.Element = try? appElement.attribute(.init("AXFocusedUIElement"))() else { - print("Error: Could not get focused element for PID \(pid)") - throw ExitCode.failure - } - targetElement = focusedElement - } else if window { - guard let focusedWindow: Accessibility.Element = try? appElement.attribute(.init("AXFocusedWindow"))() else { - print("Error: Could not get focused window for PID \(pid)") - throw ExitCode.failure - } - targetElement = focusedWindow - } - - if let childIndex = child { - targetElement = try navigateToChild(from: targetElement, index: childIndex) - } - - if let pathString = path { - targetElement = try navigateToPath(from: targetElement, path: pathString) - } - - // Get the window element for screenshots - let windowElement: Accessibility.Element - if let index = windowIndex { - let windowsAttr: Accessibility.Attribute<[Accessibility.Element]> = appElement.attribute(.init("AXWindows")) - guard let windows: [Accessibility.Element] = try? windowsAttr() else { - print("Error: Could not get windows for PID \(pid)") - throw ExitCode.failure - } - guard index >= 0 && index < windows.count else { - print("Error: Window index \(index) out of range") - throw ExitCode.failure - } - windowElement = windows[index] - } else { - guard let focusedWindow: Accessibility.Element = try? appElement.attribute(.init("AXFocusedWindow"))() else { - print("Error: Could not get focused window for PID \(pid)") - throw ExitCode.failure - } - windowElement = focusedWindow - } - - // Get window ID - let cgWindow: Window - do { - cgWindow = try windowElement.window() - } catch { - print("Error: Could not get window ID: \(error)") - throw ExitCode.failure - } - - // Determine output prefix - let actionName = action.hasPrefix("AX") ? action : "AX\(action)" - let shortAction = action.replacingOccurrences(of: "AX", with: "") - let outputPrefix = output ?? "\(shortAction.lowercased())_\(path ?? "focused")" - - // Print info - print("Compare Action: \(actionName)") - print("Target Element:") - printElementInfo(targetElement) - - // Capture before screenshot - print("Capturing 'before' screenshot...") - let beforeImage = try captureWindow(cgWindow, element: targetElement, windowElement: windowElement, highlight: true) - let beforePath = "\(outputPrefix)_before.png" - try saveImage(beforeImage, to: beforePath) - print(" Saved: \(beforePath)") - - // Perform action - print("Performing action: \(actionName)...") - let axAction = targetElement.action(.init(actionName)) - try axAction() - print(" Action completed") - - // Wait for UI to update - if delay > 0 { - print(" Waiting \(delay)ms...") - Thread.sleep(forTimeInterval: Double(delay) / 1000.0) - } - - // Capture after screenshot - print("Capturing 'after' screenshot...") - let afterImage = try captureWindow(cgWindow, element: targetElement, windowElement: windowElement, highlight: true) - let afterPath = "\(outputPrefix)_after.png" - try saveImage(afterImage, to: afterPath) - print(" Saved: \(afterPath)") - - // Print summary - print() - print("Comparison complete:") - print(" Before: \(beforePath)") - print(" After: \(afterPath)") - - // Print element state change - print() - print("Element state after action:") - printElementInfo(targetElement) - } - - private func printElementInfo(_ element: Accessibility.Element) { - if let role: String = try? element.attribute(AXAttribute.role)() { - print(" Role: \(role)") - } - if let title: String = try? element.attribute(AXAttribute.title)() { - print(" Title: \(title)") - } - if let id: String = try? element.attribute(AXAttribute.identifier)() { - print(" Identifier: \(id)") - } - if let value: Any = try? element.attribute(AXAttribute.value)() { - let strValue = String(describing: value) - let truncated = strValue.count > 50 ? String(strValue.prefix(50)) + "..." : strValue - print(" Value: \(truncated)") - } - if let enabled: Bool = try? element.attribute(AXAttribute.enabled)() { - print(" Enabled: \(enabled)") - } - } - - private func captureWindow(_ window: Window, element: Accessibility.Element, windowElement: Accessibility.Element, highlight: Bool) throws -> CGImage { - var imageOptions: CGWindowImageOption = [.boundsIgnoreFraming] - if shadow { - imageOptions = [] - } - - guard let cgImage = CGWindowListCreateImage( - .null, - .optionIncludingWindow, - window.raw, - imageOptions - ) else { - throw CompareError.captureFailure - } - - guard highlight else { - return cgImage - } - - // Draw bounding box around target element - guard let windowFrame = getElementFrame(windowElement), - let elementFrame = getElementFrame(element) else { - return cgImage - } - - let width = cgImage.width - let height = cgImage.height - - guard let colorSpace = cgImage.colorSpace ?? CGColorSpace(name: CGColorSpace.sRGB), - let context = CGContext( - data: nil, - width: width, - height: height, - bitsPerComponent: 8, - bytesPerRow: 0, - space: colorSpace, - bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue - ) else { - return cgImage - } - - context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height)) - - let boxCGColor = parseColor(boxColor) - context.setStrokeColor(boxCGColor) - context.setLineWidth(3.0) - - let scaleX = CGFloat(width) / windowFrame.width - let scaleY = CGFloat(height) / windowFrame.height - - let relativeX = elementFrame.origin.x - windowFrame.origin.x - let relativeY = elementFrame.origin.y - windowFrame.origin.y - - let imageX = relativeX * scaleX - let imageY = relativeY * scaleY - let imageWidth = elementFrame.width * scaleX - let imageHeight = elementFrame.height * scaleY - - let flippedY = CGFloat(height) - imageY - imageHeight - - let rect = CGRect(x: imageX, y: flippedY, width: imageWidth, height: imageHeight) - context.stroke(rect) - - return context.makeImage() ?? cgImage - } - - private func getElementFrame(_ element: Accessibility.Element) -> CGRect? { - if let frame = try? element.attribute(AXAttribute.frame)() { - return frame - } - if let pos = try? element.attribute(AXAttribute.position)(), - let size = try? element.attribute(AXAttribute.size)() { - return CGRect(origin: pos, size: size) - } - return nil - } - - private func parseColor(_ name: String) -> CGColor { - switch name.lowercased() { - case "red": return CGColor(red: 1, green: 0, blue: 0, alpha: 1) - case "green": return CGColor(red: 0, green: 1, blue: 0, alpha: 1) - case "blue": return CGColor(red: 0, green: 0, blue: 1, alpha: 1) - case "yellow": return CGColor(red: 1, green: 1, blue: 0, alpha: 1) - case "orange": return CGColor(red: 1, green: 0.5, blue: 0, alpha: 1) - case "cyan": return CGColor(red: 0, green: 1, blue: 1, alpha: 1) - case "magenta": return CGColor(red: 1, green: 0, blue: 1, alpha: 1) - default: return CGColor(red: 1, green: 0, blue: 0, alpha: 1) - } - } - } -} - -enum CompareError: Error, CustomStringConvertible { - case captureFailure - - var description: String { - switch self { - case .captureFailure: - return "Failed to capture window image" - } - } -} diff --git a/Sources/axdump/Commands/DumpCommand.swift b/Sources/axdump/Commands/DumpCommand.swift deleted file mode 100644 index 1ffbaf3..0000000 --- a/Sources/axdump/Commands/DumpCommand.swift +++ /dev/null @@ -1,194 +0,0 @@ -import Foundation -import ArgumentParser -import AccessibilityControl -import AppKit - -extension AXDump { - struct Dump: ParsableCommand { - static var configuration = CommandConfiguration( - abstract: "Dump accessibility tree for an application", - discussion: """ - Recursively dumps the accessibility element hierarchy starting from the - application root or focused window. Output is rendered as an ASCII tree. - - \(AttributeFields.helpText) - - \(ElementFilter.helpText) - - \(AXRoles.helpText()) - - EXAMPLES: - axdump dump 710 Dump with default settings - axdump dump 710 -d 5 Dump 5 levels deep - axdump dump 710 -f minimal Only show role, title, id - axdump dump 710 -f role,title,value Custom field selection - axdump dump 710 -w Start from focused window - axdump dump 710 --role Button Filter to only buttons - axdump dump 710 --has identifier Only elements with identifier - axdump dump 710 --role "Text.*" -d 10 Regex pattern for roles - """ - ) - - @Argument(help: "Process ID of the application") - var pid: Int32 - - @Option(name: .shortAndLong, help: "Maximum depth to traverse (default: 3)") - var depth: Int = 3 - - @Option(name: .shortAndLong, help: "Verbosity level: 0=minimal, 1=normal, 2=detailed") - var verbosity: Int = 1 - - @Option(name: [.customShort("f"), .long], help: "Fields to display (see FIELD OPTIONS above)") - var fields: String = "standard" - - @Flag(name: .shortAndLong, help: "Start from focused window instead of application root") - var window: Bool = false - - @Flag(name: .long, help: "Disable colored output") - var noColor: Bool = false - - @Flag(name: .long, help: "Output as JSON") - var json: Bool = false - - // Filtering options - @Option(name: .long, help: "Filter by role (regex pattern, e.g., 'Button|Text')") - var role: String? - - @Option(name: .long, help: "Filter by subrole (regex pattern)") - var subrole: String? - - @Option(name: .long, help: "Filter by title (regex pattern)") - var title: String? - - @Option(name: .long, help: "Filter by identifier (regex pattern)") - var id: String? - - @Option(name: .long, parsing: .upToNextOption, help: "Only show elements where these fields are not nil") - var has: [String] = [] - - @Option(name: .long, parsing: .upToNextOption, help: "Only show elements where these fields are nil") - var without: [String] = [] - - @Flag(name: .long, help: "Make pattern matching case-sensitive") - var caseSensitive: Bool = false - - func run() throws { - guard Accessibility.isTrusted(shouldPrompt: true) else { - print("Error: Accessibility permissions required") - throw ExitCode.failure - } - - let appElement = Accessibility.Element(pid: pid) - - let rootElement: Accessibility.Element - if window { - guard let focusedWindow: Accessibility.Element = try? appElement.attribute(.init("AXFocusedWindow"))() else { - print("Error: Could not get focused window for PID \(pid)") - throw ExitCode.failure - } - rootElement = focusedWindow - } else { - rootElement = appElement - } - - // Build filter - let filter: ElementFilter? - do { - filter = try ElementFilter( - rolePattern: role, - subrolePattern: subrole, - titlePattern: title, - identifierPattern: id, - requiredFields: has, - excludedFields: without, - caseSensitive: caseSensitive - ) - } catch { - print("Error: Invalid regex pattern: \(error)") - throw ExitCode.failure - } - - let attributeFields = AttributeFields.parse(fields) - - // Print header - if let appName: String = try? appElement.attribute(.init("AXTitle"))() { - print("Accessibility Tree for: \(appName) (PID: \(pid))") - } else { - print("Accessibility Tree for PID: \(pid)") - } - print(String(repeating: "=", count: 60)) - - if let f = filter, f.isActive { - print("Filters active: \(describeFilter(f))") - print(String(repeating: "-", count: 60)) - } - print() - - if json { - let jsonTree = buildJSONTree(rootElement, depth: depth, filter: filter) - if let jsonData = try? JSONSerialization.data(withJSONObject: jsonTree, options: [.prettyPrinted, .sortedKeys]), - let jsonString = String(data: jsonData, encoding: .utf8) { - print(jsonString) - } - } else { - let printer = TreePrinter( - fields: attributeFields, - filter: filter?.isActive == true ? filter : nil, - maxDepth: depth, - showActions: verbosity >= 2, - useColor: !noColor - ) - printer.printTree(rootElement) - } - } - - private func describeFilter(_ filter: ElementFilter) -> String { - var parts: [String] = [] - if filter.rolePattern != nil { parts.append("role") } - if filter.subrolePattern != nil { parts.append("subrole") } - if filter.titlePattern != nil { parts.append("title") } - if filter.identifierPattern != nil { parts.append("id") } - if !filter.requiredFields.isEmpty { parts.append("has:\(filter.requiredFields.joined(separator: ","))") } - if !filter.excludedFields.isEmpty { parts.append("without:\(filter.excludedFields.joined(separator: ","))") } - return parts.joined(separator: ", ") - } - - private func buildJSONTree(_ element: Accessibility.Element, depth: Int, filter: ElementFilter?) -> [String: Any] { - let printer = ElementPrinter() - var node = printer.formatElementForJSON(element) - - if depth > 0 { - let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = element.attribute(.init("AXChildren")) - if let children: [Accessibility.Element] = try? childrenAttr() { - var childNodes: [[String: Any]] = [] - for child in children { - let passes = filter?.matches(child) ?? true - if passes || childHasMatchingDescendant(child, filter: filter, depth: depth - 1) { - childNodes.append(buildJSONTree(child, depth: depth - 1, filter: filter)) - } - } - if !childNodes.isEmpty { - node["children"] = childNodes - } - } - } - - return node - } - - private func childHasMatchingDescendant(_ element: Accessibility.Element, filter: ElementFilter?, depth: Int) -> Bool { - guard let filter = filter, depth > 0 else { return false } - if filter.matches(element) { return true } - - let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = element.attribute(.init("AXChildren")) - guard let children: [Accessibility.Element] = try? childrenAttr() else { return false } - - for child in children { - if childHasMatchingDescendant(child, filter: filter, depth: depth - 1) { - return true - } - } - return false - } - } -} diff --git a/Sources/axdump/Commands/FindCommand.swift b/Sources/axdump/Commands/FindCommand.swift deleted file mode 100644 index 24e9634..0000000 --- a/Sources/axdump/Commands/FindCommand.swift +++ /dev/null @@ -1,383 +0,0 @@ -import Foundation -import ArgumentParser -import AccessibilityControl -import AppKit - -extension AXDump { - struct Find: ParsableCommand { - static var configuration = CommandConfiguration( - abstract: "Find elements and optionally act on them", - discussion: """ - Smart element finder with built-in actions. Finds elements by text content, - role, identifier, or combinations thereof. - - SELECTORS: - "text" Find element containing "text" (title, value, or description) - --role Button "OK" Find Button containing "OK" - --id searchField Find element with identifier "searchField" - --role TextField Find any TextField - - ACTIONS (performed on first match): - --click, -c Click/press the element - --focus, -f Focus the element - --type "text" Set the element's value - --read, -r Print the element's value - --custom, -C "name" Perform a custom action (e.g., tapback reactions) - - EXAMPLES: - axdump find 710 "Save" Find "Save" button/text - axdump find 710 "Save" --click Find and click "Save" - axdump find 710 --role Button "Cancel" Find Button with "Cancel" - axdump find 710 --role TextField --focus Focus first text field - axdump find 710 --id searchField --type "query" - axdump find 710 "File name" --read Read value near "File name" - axdump find 710 --role MenuItem "Copy" -c Execute Copy menu item - axdump find 710 --all "Button" Find ALL matching elements - axdump find 710 "hello" --id Sticker -C "Heart" React with Heart - axdump find 710 "hello" --id Sticker -C "👍" React with emoji - """ - ) - - @Argument(help: "Process ID of the application") - var pid: Int32 - - @Argument(help: "Text to search for (in title, value, description, or identifier)") - var text: String? - - @Option(name: .long, help: "Filter by role (Button, TextField, MenuItem, etc.)") - var role: String? - - @Option(name: .long, help: "Filter by identifier") - var id: String? - - @Option(name: .long, help: "Filter by subrole") - var subrole: String? - - @Flag(name: [.customShort("c"), .long], help: "Click/press the found element") - var click: Bool = false - - @Flag(name: [.customShort("f"), .long], help: "Focus the found element") - var focus: Bool = false - - @Option(name: [.customShort("t"), .long], help: "Type/set this value into the element") - var type: String? - - @Flag(name: [.customShort("r"), .long], help: "Read and print the element's value") - var read: Bool = false - - @Option(name: [.customShort("C"), .long], help: "Perform a custom action by name (e.g., 'Heart', 'Thumbs up')") - var custom: String? - - @Flag(name: .long, help: "Find ALL matching elements (not just first)") - var all: Bool = false - - @Option(name: [.customShort("n"), .long], help: "Select the Nth match (1-based, default: 1)") - var nth: Int = 1 - - @Flag(name: [.customShort("v"), .long], help: "Verbose output") - var verbose: Bool = false - - @Option(name: [.customShort("d"), .long], help: "Maximum search depth (default: 10)") - var depth: Int = 10 - - @Flag(name: .shortAndLong, help: "Start search from focused window") - var window: Bool = false - - func run() throws { - guard Accessibility.isTrusted(shouldPrompt: true) else { - print("Error: Accessibility permissions required") - throw ExitCode.failure - } - - guard text != nil || role != nil || id != nil || subrole != nil else { - print("Error: Specify search text, --role, --id, or --subrole") - throw ExitCode.failure - } - - let appElement = Accessibility.Element(pid: pid) - - let rootElement: Accessibility.Element - if window { - guard let focusedWindow: Accessibility.Element = try? appElement.attribute(.init("AXFocusedWindow"))() else { - print("Error: Could not get focused window") - throw ExitCode.failure - } - rootElement = focusedWindow - } else { - rootElement = appElement - } - - // Search for matching elements - var matches: [(element: Accessibility.Element, path: String, info: String)] = [] - searchElements(root: rootElement, path: "", depth: 0, matches: &matches) - - if matches.isEmpty { - print("No matching elements found") - throw ExitCode.failure - } - - if all { - // Print all matches - print("Found \(matches.count) match(es):\n") - for (index, match) in matches.enumerated() { - print("[\(index + 1)] \(match.info)") - if verbose { - print(" path: \(match.path)") - } - } - return - } - - // Select the Nth match - guard nth >= 1 && nth <= matches.count else { - print("Error: Match #\(nth) not found (only \(matches.count) match(es))") - throw ExitCode.failure - } - - let selected = matches[nth - 1] - let element = selected.element - - print("Found: \(selected.info)") - if verbose { - print(" path: \(selected.path)") - printElementDetails(element) - } - - // Perform actions - var actionPerformed = false - - if focus { - let focusAttr = element.mutableAttribute(.init("AXFocused")) as Accessibility.MutableAttribute - if (try? focusAttr.isSettable()) == true { - try? focusAttr(assign: true) - print("→ Focused") - actionPerformed = true - } else { - print("→ Cannot focus this element") - } - } - - if let typeValue = type { - let valueAttr = element.mutableAttribute(.init("AXValue")) as Accessibility.MutableAttribute - if (try? valueAttr.isSettable()) == true { - try valueAttr(assign: typeValue) - print("→ Set value: \(typeValue)") - actionPerformed = true - } else { - print("→ Cannot set value on this element") - } - } - - if click { - let pressAction = element.action(.init("AXPress")) - do { - try pressAction() - print("→ Clicked") - actionPerformed = true - } catch { - // Try AXPick for menu items - let pickAction = element.action(.init("AXPick")) - do { - try pickAction() - print("→ Picked") - actionPerformed = true - } catch { - print("→ Cannot click this element (no AXPress or AXPick action)") - } - } - } - - if read { - if let value: Any = try? element.attribute(.init("AXValue"))() { - print("→ Value: \(value)") - } else if let title: String = try? element.attribute(AXAttribute.title)() { - print("→ Title: \(title)") - } else { - print("→ No readable value") - } - actionPerformed = true - } - - if let customActionName = custom { - try performCustomAction(customActionName, on: element) - actionPerformed = true - } - - if !actionPerformed && matches.count > 1 { - print("\n\(matches.count) total matches. Use --all to see all, or -n to select.") - } - } - - private func performCustomAction(_ name: String, on element: Accessibility.Element) throws { - guard let actions = try? element.supportedActions() else { - print("→ Cannot read actions for element") - throw ExitCode.failure - } - - // Find custom action matching the name - // Custom actions have format: "Name:...\nTarget:...\nSelector:..." - var matchingAction: Accessibility.Action? - for action in actions { - let actionName = action.name.value - if actionName.hasPrefix("Name:") { - let parsed = parseCustomAction(actionName) - if parsed.lowercased() == name.lowercased() { - matchingAction = action - break - } - } - } - - guard let action = matchingAction else { - print("→ Custom action '\(name)' not found") - print() - print("Available custom actions:") - for action in actions { - let actionName = action.name.value - if actionName.hasPrefix("Name:") { - let parsed = parseCustomAction(actionName) - print(" - \(parsed)") - } - } - throw ExitCode.failure - } - - try action() - print("→ Custom action: \(name)") - } - - private func parseCustomAction(_ raw: String) -> String { - for line in raw.split(separator: "\n", omittingEmptySubsequences: false) { - let lineStr = String(line) - if lineStr.hasPrefix("Name:") { - return String(lineStr.dropFirst(5)) - } - } - return raw - } - - private func searchElements( - root: Accessibility.Element, - path: String, - depth: Int, - matches: inout [(element: Accessibility.Element, path: String, info: String)] - ) { - guard depth <= self.depth else { return } - - // Check if this element matches - if elementMatches(root) { - let info = formatElementInfo(root) - matches.append((root, path.isEmpty ? "root" : path, info)) - } - - // Recurse into children - let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = root.attribute(.init("AXChildren")) - guard let children = try? childrenAttr() else { return } - - for (index, child) in children.enumerated() { - let childPath = path.isEmpty ? "\(index)" : "\(path).\(index)" - searchElements(root: child, path: childPath, depth: depth + 1, matches: &matches) - } - } - - private func elementMatches(_ element: Accessibility.Element) -> Bool { - // Check role filter - if let roleFilter = role { - guard let elementRole: String = try? element.attribute(AXAttribute.role)() else { - return false - } - let normalizedFilter = roleFilter.hasPrefix("AX") ? roleFilter : "AX\(roleFilter)" - if elementRole.lowercased() != normalizedFilter.lowercased() { - return false - } - } - - // Check subrole filter - if let subroleFilter = subrole { - guard let elementSubrole: String = try? element.attribute(AXAttribute.subrole)() else { - return false - } - let normalizedFilter = subroleFilter.hasPrefix("AX") ? subroleFilter : "AX\(subroleFilter)" - if elementSubrole.lowercased() != normalizedFilter.lowercased() { - return false - } - } - - // Check identifier filter - if let idFilter = id { - guard let elementId: String = try? element.attribute(AXAttribute.identifier)() else { - return false - } - if !elementId.localizedCaseInsensitiveContains(idFilter) { - return false - } - } - - // Check text filter (matches title, value, description, or identifier) - if let textFilter = text { - let searchText = textFilter.lowercased() - - let title = (try? element.attribute(AXAttribute.title)())?.lowercased() ?? "" - let value = (try? element.attribute(AXAttribute.value)()).map { String(describing: $0).lowercased() } ?? "" - let desc = (try? element.attribute(AXAttribute.description)())?.lowercased() ?? "" - let identifier = (try? element.attribute(AXAttribute.identifier)())?.lowercased() ?? "" - let help = (try? element.attribute(AXAttribute.help)())?.lowercased() ?? "" - - let matchesText = title.contains(searchText) || - value.contains(searchText) || - desc.contains(searchText) || - identifier.contains(searchText) || - help.contains(searchText) - - if !matchesText { - return false - } - } - - return true - } - - private func formatElementInfo(_ element: Accessibility.Element) -> String { - var parts: [String] = [] - - if let role: String = try? element.attribute(AXAttribute.role)() { - parts.append(role.replacingOccurrences(of: "AX", with: "")) - } - - if let title: String = try? element.attribute(AXAttribute.title)(), !title.isEmpty { - let truncated = title.count > 40 ? String(title.prefix(40)) + "..." : title - parts.append("\"\(truncated)\"") - } - - if let id: String = try? element.attribute(AXAttribute.identifier)() { - parts.append("#\(id)") - } - - if let value: Any = try? element.attribute(AXAttribute.value)() { - let strValue = String(describing: value) - if !strValue.isEmpty && strValue != parts.last { - let truncated = strValue.count > 30 ? String(strValue.prefix(30)) + "..." : strValue - parts.append("=\(truncated)") - } - } - - return parts.joined(separator: " ") - } - - private func printElementDetails(_ element: Accessibility.Element) { - if let enabled: Bool = try? element.attribute(AXAttribute.enabled)() { - print(" enabled: \(enabled)") - } - if let focused: Bool = try? element.attribute(AXAttribute.focused)() { - print(" focused: \(focused)") - } - if let frame = try? element.attribute(AXAttribute.frame)() { - print(" frame: (\(Int(frame.origin.x)),\(Int(frame.origin.y))) \(Int(frame.width))x\(Int(frame.height))") - } - if let actions = try? element.supportedActions(), !actions.isEmpty { - let names = actions.map { $0.name.value.replacingOccurrences(of: "AX", with: "") } - print(" actions: \(names.joined(separator: ", "))") - } - } - } -} diff --git a/Sources/axdump/Commands/InspectCommand.swift b/Sources/axdump/Commands/InspectCommand.swift deleted file mode 100644 index 7265b32..0000000 --- a/Sources/axdump/Commands/InspectCommand.swift +++ /dev/null @@ -1,249 +0,0 @@ -import Foundation -import ArgumentParser -import AccessibilityControl -import CoreGraphics - -extension AXDump { - struct Inspect: ParsableCommand { - static var configuration = CommandConfiguration( - abstract: "Inspect specific attributes or elements in full detail", - discussion: """ - Read attribute values in full (without truncation) and navigate to specific - elements in the hierarchy using child indices. - - NAVIGATION: - Use -c (--child) for single-level navigation or -p (--path) for multi-level. - Path format: dot-separated indices, e.g., "0.3.1" means: - - First child of root (index 0) - - Fourth child of that (index 3) - - Second child of that (index 1) - - ATTRIBUTES: - Use -a to specify attributes to read. Can omit 'AX' prefix. - Use -a list to see all available attributes for an element. - - EXAMPLES: - axdump inspect 710 Show all attributes (full values) - axdump inspect 710 -a list List available attributes - axdump inspect 710 -a AXValue Read AXValue in full - axdump inspect 710 -a Value,Title Read multiple (AX prefix optional) - axdump inspect 710 -c 0 Inspect first child - axdump inspect 710 -p 0.2.1 Navigate to nested element - axdump inspect 710 -w -a AXChildren From focused window - axdump inspect 710 -F -p 0 First child of focused element - axdump inspect 710 -j Output as JSON - axdump inspect 710 -l 500 Truncate values at 500 chars - """ - ) - - @Argument(help: "Process ID of the application") - var pid: Int32 - - @Option(name: [.customShort("p"), .long], help: "Path to element as dot-separated child indices (e.g., '0.3.1')") - var path: String? - - @Option(name: [.customShort("a"), .long], help: "Specific attribute(s) to read in full (comma-separated). Use 'list' to show available.") - var attributes: String? - - @Option(name: [.customShort("c"), .long], help: "Index of child element to inspect (shorthand for --path)") - var child: Int? - - @Flag(name: [.customShort("F"), .long], help: "Start from focused element") - var focused: Bool = false - - @Flag(name: .shortAndLong, help: "Start from focused window") - var window: Bool = false - - @Option(name: [.customShort("l"), .long], help: "Maximum output length per attribute (0 for unlimited)") - var maxLength: Int = 0 - - @Flag(name: [.customShort("j"), .long], help: "Output as JSON") - var json: Bool = false - - func run() throws { - guard Accessibility.isTrusted(shouldPrompt: true) else { - print("Error: Accessibility permissions required") - throw ExitCode.failure - } - - let appElement = Accessibility.Element(pid: pid) - - // Determine starting element - var targetElement: Accessibility.Element = appElement - - if focused { - guard let focusedElement: Accessibility.Element = try? appElement.attribute(.init("AXFocusedUIElement"))() else { - print("Error: Could not get focused element for PID \(pid)") - throw ExitCode.failure - } - targetElement = focusedElement - } else if window { - guard let focusedWindow: Accessibility.Element = try? appElement.attribute(.init("AXFocusedWindow"))() else { - print("Error: Could not get focused window for PID \(pid)") - throw ExitCode.failure - } - targetElement = focusedWindow - } - - // Navigate to child if specified - if let childIndex = child { - targetElement = try navigateToChild(from: targetElement, index: childIndex) - } - - // Navigate via path if specified - if let pathString = path { - targetElement = try navigateToPath(from: targetElement, path: pathString) - } - - // Show element info - printElementHeader(targetElement) - - let printer = ElementPrinter(maxLength: maxLength) - - // Handle attribute inspection - if let attrString = attributes { - if attrString.lowercased() == "list" { - listAttributes(of: targetElement) - } else { - let attrNames = attrString.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) } - inspectAttributes(of: targetElement, names: attrNames, printer: printer) - } - } else { - // Default: show all attributes with full values - inspectAllAttributes(of: targetElement, printer: printer) - } - } - - private func printElementHeader(_ element: Accessibility.Element) { - print("Element Info:") - print(String(repeating: "=", count: 60)) - - if let role: String = try? element.attribute(AXAttribute.role)() { - print("Role: \(role)") - } - if let title: String = try? element.attribute(AXAttribute.title)() { - print("Title: \(title)") - } - if let id: String = try? element.attribute(AXAttribute.identifier)() { - print("Identifier: \(id)") - } - - let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = element.attribute(.init("AXChildren")) - if let count = try? childrenAttr.count() { - print("Children: \(count)") - } - - print(String(repeating: "-", count: 60)) - print() - } - - private func listAttributes(of element: Accessibility.Element) { - print("Available Attributes:") - print(String(repeating: "-", count: 40)) - - guard let attributes = try? element.supportedAttributes() else { - print("(unable to read attributes)") - return - } - - for attr in attributes.sorted(by: { $0.name.value < $1.name.value }) { - let name = attr.name.value - let settable = (try? attr.isSettable()) ?? false - let settableStr = settable ? " [settable]" : "" - print(" \(name)\(settableStr)") - } - - print() - print("Parameterized Attributes:") - print(String(repeating: "-", count: 40)) - - if let paramAttrs = try? element.supportedParameterizedAttributes() { - for attr in paramAttrs.sorted(by: { $0.name.value < $1.name.value }) { - print(" \(attr.name.value)") - } - } - } - - private func inspectAttributes(of element: Accessibility.Element, names: [String], printer: ElementPrinter) { - if json { - var result: [String: Any] = [:] - for name in names { - let attrName = name.hasPrefix("AX") ? name : "AX\(name)" - if let value: Any = try? element.attribute(.init(attrName))() { - result[attrName] = printer.formatValueForJSON(value) - } else { - result[attrName] = NSNull() - } - } - if let jsonData = try? JSONSerialization.data(withJSONObject: result, options: [.prettyPrinted, .sortedKeys]), - let jsonString = String(data: jsonData, encoding: .utf8) { - print(jsonString) - } - return - } - - for name in names { - let attrName = name.hasPrefix("AX") ? name : "AX\(name)" - print("\(attrName):") - print(String(repeating: "-", count: 40)) - - if let value: Any = try? element.attribute(.init(attrName))() { - let strValue = printer.formatValue(value) - if maxLength > 0 && strValue.count > maxLength { - print(String(strValue.prefix(maxLength))) - print("... (truncated, total length: \(strValue.count))") - } else { - print(strValue) - } - } else { - print("(unable to read or no value)") - } - print() - } - } - - private func inspectAllAttributes(of element: Accessibility.Element, printer: ElementPrinter) { - guard let attributes = try? element.supportedAttributes() else { - print("(unable to read attributes)") - return - } - - if json { - var result: [String: Any] = [:] - for attr in attributes { - if let value: Any = try? attr() { - result[attr.name.value] = printer.formatValueForJSON(value) - } - } - if let jsonData = try? JSONSerialization.data(withJSONObject: result, options: [.prettyPrinted, .sortedKeys]), - let jsonString = String(data: jsonData, encoding: .utf8) { - print(jsonString) - } - return - } - - print("All Attributes (full values):") - print(String(repeating: "-", count: 40)) - - for attr in attributes.sorted(by: { $0.name.value < $1.name.value }) { - let name = attr.name.value - - if let value: Any = try? attr() { - let strValue = printer.formatValue(value) - if maxLength > 0 && strValue.count > maxLength { - print("\(name): \(String(strValue.prefix(maxLength)))... (truncated)") - } else if strValue.contains("\n") || strValue.count > 80 { - print("\(name):") - print(strValue.split(separator: "\n", omittingEmptySubsequences: false) - .map { " \($0)" } - .joined(separator: "\n")) - } else { - print("\(name): \(strValue)") - } - } else { - print("\(name): (unable to read)") - } - } - } - } -} diff --git a/Sources/axdump/Commands/KeyCommand.swift b/Sources/axdump/Commands/KeyCommand.swift deleted file mode 100644 index 3401ef9..0000000 --- a/Sources/axdump/Commands/KeyCommand.swift +++ /dev/null @@ -1,391 +0,0 @@ -import Foundation -import ArgumentParser -import CoreGraphics -import AppKit -import Carbon -import Combine - -// MARK: - OS Version Detection - -private let isSequoiaOrUp: Bool = { - if #available(macOS 15, *) { - return true - } - return false -}() - -// MARK: - Keyboard Layout Mapping - -/// Maps characters to key codes based on the current keyboard layout -/// This is necessary because key codes are physical positions, not characters -private final class KeyMap { - private static let keyCodeRange: Range = 0..<127 - - static let shared = KeyMap() - private init() {} - - private var cached: (String, [UTF16.CodeUnit: UInt16])? - - private func makeMap(source: TISInputSource) -> [UTF16.CodeUnit: UInt16] { - var dict: [UTF16.CodeUnit: UInt16] = [:] - guard let layoutDataRaw = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) else { return [:] } - let layoutData = Unmanaged.fromOpaque(layoutDataRaw).takeUnretainedValue() as Data - layoutData.withUnsafeBytes { buf in - guard let base = buf.baseAddress else { return } - let layout = base.assumingMemoryBound(to: UCKeyboardLayout.self) - for keyCode in Self.keyCodeRange { - var deadKeyState: UInt32 = 0 - var length = 0 - var char: UTF16.CodeUnit = 0 - let err = UCKeyTranslate( - layout, - keyCode, - UInt16(kUCKeyActionDisplay), - 0, // modifierKeyState - UInt32(LMGetKbdType()), - OptionBits(kUCKeyTranslateNoDeadKeysBit), - &deadKeyState, - 1, - &length, - &char - ) - guard err == noErr else { continue } - dict[char] = keyCode - } - } - return dict - } - - subscript(key: Character) -> UInt16? { - guard let utf16 = key.utf16.first else { return nil } - let source = TISCopyCurrentKeyboardLayoutInputSource().takeRetainedValue() - guard let rawID = TISGetInputSourceProperty(source, kTISPropertyInputSourceID) else { return nil } - let id = Unmanaged.fromOpaque(rawID).takeUnretainedValue() as String - let map: [UTF16.CodeUnit: UInt16] - if let _map = cached, _map.0 == id { - map = _map.1 - } else { - map = makeMap(source: source) - cached = (id, map) - } - return map[utf16] - } -} - -// MARK: - Shared Event Source - -private let sharedEventSource = CGEventSource(stateID: .hidSystemState) - -// MARK: - App Activation Helper - -private extension NSRunningApplication { - /// Activates the application and waits for it to become active using KVO via Combine - func activateAndWait(timeoutSeconds: TimeInterval = 2.0) async throws { - // If already active, return immediately - if isActive { return } - - activate(options: [.activateIgnoringOtherApps]) - - // Use Combine publisher with continuation to wait for activation - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - var cancellable: AnyCancellable? - - cancellable = self.publisher(for: \.isActive) - .filter { $0 } - .setFailureType(to: KeyError.self) - .timeout(.seconds(timeoutSeconds), scheduler: DispatchQueue.main, customError: { .activationTimeout }) - .first() - .sink( - receiveCompletion: { completion in - switch completion { - case .finished: - break - case .failure(let error): - continuation.resume(throwing: error) - } - cancellable?.cancel() - }, - receiveValue: { _ in - continuation.resume() - cancellable?.cancel() - } - ) - } - - // Small additional delay to ensure app is ready to receive events - try await Task.sleep(for: .milliseconds(50)) - } -} - -extension AXDump { - struct Key: AsyncParsableCommand { - static var configuration = CommandConfiguration( - abstract: "Send keyboard input to an application", - discussion: """ - Send key presses and keyboard shortcuts to an application. - The application must be frontmost to receive key events. - - KEY NAMES: - Letters/Numbers: a-z, 0-9 - Special keys: enter, return, tab, space, escape, delete, backspace - Arrow keys: up, down, left, right - Function keys: f1-f12 - Navigation: home, end, pageup, pagedown - - MODIFIERS (combine with +): - cmd, command - Command key (⌘) - ctrl, control - Control key (⌃) - opt, option, alt - Option key (⌥) - shift - Shift key (⇧) - - EXAMPLES: - axdump key 710 enter Press Enter - axdump key 710 "cmd+c" Copy (⌘C) - axdump key 710 "cmd+v" Paste (⌘V) - axdump key 710 "cmd+shift+s" Save As (⌘⇧S) - axdump key 710 "cmd+a" "cmd+c" Select All then Copy - axdump key 710 tab tab enter Tab twice then Enter - axdump key 710 --type "Hello World" Type text - axdump key 710 escape Press Escape - """ - ) - - @Argument(help: "Process ID of the application") - var pid: Int32 - - @Argument(parsing: .remaining, help: "Key(s) to press (e.g., 'enter', 'cmd+c', 'cmd+shift+s')") - var keys: [String] = [] - - @Option(name: .long, help: "Type a string of text character by character") - var type: String? - - @Option(name: [.customShort("d"), .long], help: "Delay between key presses in milliseconds (default: 50)") - var delay: Int = 50 - - @Flag(name: .shortAndLong, help: "Activate the application before sending keys") - var activate: Bool = false - - @Flag(name: .long, help: "List all known key names") - var listKeys: Bool = false - - func run() async throws { - if listKeys { - printKeyList() - return - } - - guard !keys.isEmpty || type != nil else { - print("Error: No keys specified. Use positional arguments or --type") - throw ExitCode.failure - } - - // Find the application - guard let app = NSRunningApplication.runningApplications(withBundleIdentifier: "").first(where: { $0.processIdentifier == pid }) ?? - NSWorkspace.shared.runningApplications.first(where: { $0.processIdentifier == pid }) else { - print("Error: Could not find application with PID \(pid)") - throw ExitCode.failure - } - - // Activate if requested - if activate { - do { - try await app.activateAndWait() - } catch KeyError.activationTimeout { - print("Warning: Application may not have activated in time") - } - } - - // Type string if specified - if let text = type { - for char in text { - try sendCharacter(char) - try await Task.sleep(for: .milliseconds(delay)) - } - print("Typed: \(text)") - } - - // Send key presses - for keySpec in keys { - let (keyCode, modifiers) = try parseKeySpec(keySpec) - try await sendKey(keyCode: keyCode, modifiers: modifiers) - print("Pressed: \(keySpec)") - - if delay > 0 && keySpec != keys.last { - try await Task.sleep(for: .milliseconds(delay)) - } - } - } - - private func parseKeySpec(_ spec: String) throws -> (CGKeyCode, CGEventFlags) { - let parts = spec.lowercased().split(separator: "+").map { String($0).trimmingCharacters(in: .whitespaces) } - - var modifiers: CGEventFlags = [] - var keyName: String = "" - - for part in parts { - switch part { - case "cmd", "command": - modifiers.insert(.maskCommand) - case "ctrl", "control": - modifiers.insert(.maskControl) - case "opt", "option", "alt": - modifiers.insert(.maskAlternate) - case "shift": - modifiers.insert(.maskShift) - default: - keyName = part - } - } - - guard let keyCode = keyCodeFor(keyName) else { - throw KeyError.unknownKey(keyName) - } - - return (keyCode, modifiers) - } - - private func keyCodeFor(_ name: String) -> CGKeyCode? { - // Special keys with fixed key codes (these are physical keys, not characters) - let specialKeyMap: [String: CGKeyCode] = [ - // Special keys - "return": 36, "enter": 36, - "tab": 48, - "space": 49, - "delete": 51, "backspace": 51, - "escape": 53, "esc": 53, - "forwarddelete": 117, - - // Function keys - "f1": 122, "f2": 120, "f3": 99, "f4": 118, "f5": 96, "f6": 97, - "f7": 98, "f8": 100, "f9": 101, "f10": 109, "f11": 103, "f12": 111, - - // Arrow keys - "left": 123, "right": 124, "down": 125, "up": 126, - - // Navigation - "home": 115, "end": 119, "pageup": 116, "pagedown": 121, - - // Keypad - "kp0": 82, "kp1": 83, "kp2": 84, "kp3": 85, "kp4": 86, - "kp5": 87, "kp6": 88, "kp7": 89, "kp8": 91, "kp9": 92, - "kp.": 65, "kp*": 67, "kp+": 69, "kp/": 75, "kp-": 78, - "kpenter": 76, "kp=": 81, - ] - - // Check special keys first - if let keyCode = specialKeyMap[name] { - return keyCode - } - - // For single characters, use the keyboard layout-aware KeyMap - // This properly handles non-QWERTY layouts - if name.count == 1, let char = name.first { - if let keyCode = KeyMap.shared[char] { - return CGKeyCode(keyCode) - } - } - - return nil - } - - private func sendKey(keyCode: CGKeyCode, modifiers: CGEventFlags) async throws { - guard let keyDown = CGEvent(keyboardEventSource: sharedEventSource, virtualKey: keyCode, keyDown: true), - let keyUp = CGEvent(keyboardEventSource: sharedEventSource, virtualKey: keyCode, keyDown: false) else { - throw KeyError.eventCreationFailure - } - - keyDown.flags = modifiers - keyUp.flags = modifiers - - // Post to specific process instead of global tap - keyDown.postToPid(pid) - - // Small delay between key down and key up - try await Task.sleep(for: .milliseconds(10)) - - keyUp.postToPid(pid) - - // Sequoia workaround: post an extra event with cleared flags after key up - if isSequoiaOrUp { - keyUp.flags = [] - keyUp.postToPid(pid) - } - } - - private func sendCharacter(_ char: Character) throws { - guard let keyDown = CGEvent(keyboardEventSource: sharedEventSource, virtualKey: 0, keyDown: true), - let keyUp = CGEvent(keyboardEventSource: sharedEventSource, virtualKey: 0, keyDown: false) else { - throw KeyError.eventCreationFailure - } - - var unicodeChar = Array(String(char).utf16) - keyDown.keyboardSetUnicodeString(stringLength: unicodeChar.count, unicodeString: &unicodeChar) - - // Post to specific process instead of global tap - keyDown.postToPid(pid) - keyUp.postToPid(pid) - - // Sequoia workaround - if isSequoiaOrUp { - keyUp.flags = [] - keyUp.postToPid(pid) - } - } - - private func printKeyList() { - print(""" - AVAILABLE KEYS: - - Letters: a-z - Numbers: 0-9 - - Special Keys: - enter, return - Return/Enter key - tab - Tab key - space - Space bar - delete, backspace - Delete/Backspace - escape, esc - Escape key - forwarddelete - Forward Delete - - Arrow Keys: - up, down, left, right - - Navigation: - home, end, pageup, pagedown - - Function Keys: - f1, f2, f3, f4, f5, f6, f7, f8, f9, f10, f11, f12 - - Modifiers (combine with +): - cmd, command - Command key (⌘) - ctrl, control - Control key (⌃) - opt, option, alt - Option key (⌥) - shift - Shift key (⇧) - - Examples: - enter - Press Enter - cmd+c - Copy - cmd+v - Paste - cmd+shift+s - Save As - ctrl+alt+delete - Ctrl+Alt+Delete - """) - } - } -} - -enum KeyError: Error, CustomStringConvertible { - case unknownKey(String) - case eventCreationFailure - case activationTimeout - - var description: String { - switch self { - case .unknownKey(let key): - return "Unknown key: '\(key)'. Use --list-keys to see available keys." - case .eventCreationFailure: - return "Failed to create key event" - case .activationTimeout: - return "Timed out waiting for application to activate" - } - } -} diff --git a/Sources/axdump/Commands/ListCommand.swift b/Sources/axdump/Commands/ListCommand.swift deleted file mode 100644 index 1a7f892..0000000 --- a/Sources/axdump/Commands/ListCommand.swift +++ /dev/null @@ -1,91 +0,0 @@ -import Foundation -import ArgumentParser -import AccessibilityControl -import AppKit - -extension AXDump { - struct List: ParsableCommand { - static var configuration = CommandConfiguration( - abstract: "List running applications with accessibility elements", - discussion: """ - Lists all running applications that can be inspected via accessibility APIs. - By default, only shows regular (foreground) applications. - - EXAMPLES: - axdump list List foreground apps with PIDs - axdump list -a Include background/menu bar apps - axdump list -v Show window count and app title - axdump list -av Verbose listing of all apps - axdump list --list-roles Show all known accessibility roles - """ - ) - - @Flag(name: .shortAndLong, help: "Show all applications (including background)") - var all: Bool = false - - @Flag(name: .shortAndLong, help: "Show detailed information") - var verbose: Bool = false - - @Flag(name: .long, help: "List all known accessibility roles") - var listRoles: Bool = false - - @Flag(name: .long, help: "List all known accessibility subroles") - var listSubroles: Bool = false - - @Flag(name: .long, help: "List all known accessibility actions") - var listActions: Bool = false - - func run() throws { - // Handle reference listings - if listRoles { - print(AXRoles.fullHelpText()) - return - } - if listSubroles { - print(AXSubroles.fullHelpText()) - return - } - if listActions { - print(AXActions.fullHelpText()) - return - } - - guard Accessibility.isTrusted(shouldPrompt: true) else { - print("Error: Accessibility permissions required") - print("Please grant permissions in System Preferences > Security & Privacy > Privacy > Accessibility") - throw ExitCode.failure - } - - let apps = NSWorkspace.shared.runningApplications - let filteredApps = all ? apps : apps.filter { $0.activationPolicy == .regular } - - let sortedApps = filteredApps.sorted { ($0.localizedName ?? "") < ($1.localizedName ?? "") } - - print("Running Applications:") - print(String(repeating: "-", count: 60)) - - for app in sortedApps { - let name = app.localizedName ?? "Unknown" - let pid = app.processIdentifier - let bundleID = app.bundleIdentifier ?? "N/A" - - if verbose { - print("\(String(format: "%6d", pid)) \(name)") - print(" Bundle: \(bundleID)") - - let element = Accessibility.Element(pid: pid) - let windowsAttr: Accessibility.Attribute<[Accessibility.Element]> = element.attribute(.init("AXWindows")) - if let windowCount = try? windowsAttr.count() { - print(" Windows: \(windowCount)") - } - if let title: String = try? element.attribute(.init("AXTitle"))() { - print(" Title: \(title)") - } - print() - } else { - print("\(String(format: "%6d", pid)) \(name) (\(bundleID))") - } - } - } - } -} diff --git a/Sources/axdump/Commands/MenuCommand.swift b/Sources/axdump/Commands/MenuCommand.swift deleted file mode 100644 index 4688c7f..0000000 --- a/Sources/axdump/Commands/MenuCommand.swift +++ /dev/null @@ -1,413 +0,0 @@ -import Foundation -import ArgumentParser -import AccessibilityControl -import AppKit - -extension AXDump { - struct Menu: ParsableCommand { - static var configuration = CommandConfiguration( - abstract: "Explore and activate menu bar items", - discussion: """ - List menu bar items, explore menu hierarchies, and trigger menu actions - via accessibility APIs. This works even when the app isn't frontmost. - - MENU PATHS: - Menu paths use '>' to separate menu levels. - Examples: "File", "File > New", "Edit > Find > Find..." - - EXAMPLES: - axdump menu 710 List top-level menus - axdump menu 710 -m "File" Show File menu items - axdump menu 710 -m "File > New" Show New submenu - axdump menu 710 -m "Edit > Copy" -x Execute Edit > Copy - axdump menu 710 -m "File > Save" -x Execute File > Save - axdump menu 710 --search "paste" Search all menus for "paste" - axdump menu 710 -m "View" --tree Show full menu tree - """ - ) - - @Argument(help: "Process ID of the application") - var pid: Int32 - - @Option(name: [.customShort("m"), .long], help: "Menu path (e.g., 'File', 'File > Save', 'Edit > Find > Find...')") - var menu: String? - - @Flag(name: [.customShort("x"), .long], help: "Execute/activate the menu item") - var execute: Bool = false - - @Option(name: .long, help: "Search all menus for items matching this pattern (case-insensitive)") - var search: String? - - @Flag(name: .long, help: "Show full menu tree (can be slow for large menus)") - var tree: Bool = false - - @Flag(name: .shortAndLong, help: "Verbose output") - var verbose: Bool = false - - @Flag(name: .long, help: "Disable colored output") - var noColor: Bool = false - - func run() throws { - guard Accessibility.isTrusted(shouldPrompt: true) else { - print("Error: Accessibility permissions required") - throw ExitCode.failure - } - - let appElement = Accessibility.Element(pid: pid) - - // Get the menu bar - guard let menuBar = getMenuBar(appElement) else { - print("Error: Could not find menu bar for PID \(pid)") - throw ExitCode.failure - } - - // Search mode - if let searchPattern = search { - try searchMenus(menuBar: menuBar, pattern: searchPattern) - return - } - - // If no menu specified, list top-level menus - guard let menuPath = menu else { - try listTopLevelMenus(menuBar: menuBar) - return - } - - // Parse menu path and navigate - let pathComponents = menuPath.split(separator: ">").map { String($0).trimmingCharacters(in: .whitespaces) } - - guard let targetItem = try navigateToMenuItem(menuBar: menuBar, path: pathComponents) else { - print("Error: Could not find menu item '\(menuPath)'") - throw ExitCode.failure - } - - if execute { - // Execute the menu item - try executeMenuItem(targetItem, path: menuPath) - } else if tree { - // Show full tree - try showMenuTree(targetItem, indent: 0) - } else { - // Show children of this menu/item - try showMenuContents(targetItem, path: menuPath) - } - } - - // MARK: - Menu Bar Access - - private func getMenuBar(_ appElement: Accessibility.Element) -> Accessibility.Element? { - let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = appElement.attribute(.init("AXChildren")) - guard let children = try? childrenAttr() else { return nil } - - for child in children { - if let role: String = try? child.attribute(AXAttribute.role)(), - role == "AXMenuBar" { - return child - } - } - return nil - } - - // MARK: - List Menus - - private func listTopLevelMenus(menuBar: Accessibility.Element) throws { - let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = menuBar.attribute(.init("AXChildren")) - guard let menuBarItems = try? childrenAttr() else { - print("Could not read menu bar items") - return - } - - print("Menu Bar Items:") - print(String(repeating: "-", count: 50)) - - for (index, item) in menuBarItems.enumerated() { - let title = (try? item.attribute(AXAttribute.title)()) ?? "(untitled)" - let enabled = (try? item.attribute(AXAttribute.enabled)()) ?? true - - var line = "[\(index)] \(title)" - if !enabled { - line += " (disabled)" - } - print(line) - } - - print() - print("Use -m \"\" to explore a menu") - } - - // MARK: - Navigate to Menu Item - - private func navigateToMenuItem(menuBar: Accessibility.Element, path: [String]) throws -> Accessibility.Element? { - var current: Accessibility.Element = menuBar - - for (level, name) in path.enumerated() { - // Get children of current element - let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = current.attribute(.init("AXChildren")) - guard let children = try? childrenAttr() else { - if verbose { - print("Warning: No children at level \(level)") - } - return nil - } - - // Find matching child - var found: Accessibility.Element? - for child in children { - let childTitle = (try? child.attribute(AXAttribute.title)()) ?? "" - if childTitle.lowercased() == name.lowercased() || - childTitle.lowercased().hasPrefix(name.lowercased()) { - found = child - break - } - } - - guard let nextElement = found else { - if verbose { - print("Warning: Could not find '\(name)' at level \(level)") - print("Available items:") - for child in children { - let childTitle = (try? child.attribute(AXAttribute.title)()) ?? "(untitled)" - print(" - \(childTitle)") - } - } - return nil - } - - // If this is a menu bar item or menu item with children, we need to get its menu - let role = (try? nextElement.attribute(AXAttribute.role)()) ?? "" - - if role == "AXMenuBarItem" || role == "AXMenuItem" { - // Check if it has a submenu - let subChildrenAttr: Accessibility.Attribute<[Accessibility.Element]> = nextElement.attribute(.init("AXChildren")) - if let subChildren = try? subChildrenAttr(), !subChildren.isEmpty { - // Get the submenu (first child that's a menu) - for subChild in subChildren { - let subRole = (try? subChild.attribute(AXAttribute.role)()) ?? "" - if subRole == "AXMenu" { - current = subChild - break - } - } - } else { - // This is a leaf item - current = nextElement - } - } else { - current = nextElement - } - - // If this is the last item in the path, return the menu item itself (not the submenu) - if level == path.count - 1 { - return nextElement - } - } - - return current - } - - // MARK: - Show Menu Contents - - private func showMenuContents(_ element: Accessibility.Element, path: String) throws { - let role = (try? element.attribute(AXAttribute.role)()) ?? "" - - // If it's a menu item, get its submenu - var menuElement = element - if role == "AXMenuBarItem" || role == "AXMenuItem" { - let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = element.attribute(.init("AXChildren")) - if let children = try? childrenAttr() { - for child in children { - let childRole = (try? child.attribute(AXAttribute.role)()) ?? "" - if childRole == "AXMenu" { - menuElement = child - break - } - } - } - } - - let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = menuElement.attribute(.init("AXChildren")) - guard let items = try? childrenAttr() else { - print("Menu '\(path)' has no items") - return - } - - print("Menu: \(path)") - print(String(repeating: "-", count: 50)) - - for (index, item) in items.enumerated() { - let itemRole = (try? item.attribute(AXAttribute.role)()) ?? "" - - // Skip menu itself - if itemRole == "AXMenu" { continue } - - let title = (try? item.attribute(AXAttribute.title)()) ?? "" - let enabled = (try? item.attribute(AXAttribute.enabled)()) ?? true - let shortcutAttr: Accessibility.Attribute = item.attribute(.init("AXMenuItemCmdChar")) - let shortcut = (try? shortcutAttr()) ?? "" - let modifiersAttr: Accessibility.Attribute = item.attribute(.init("AXMenuItemCmdModifiers")) - let modifiers = (try? modifiersAttr()) ?? 0 - - // Check if has submenu - let subChildrenAttr: Accessibility.Attribute<[Accessibility.Element]> = item.attribute(.init("AXChildren")) - let hasSubmenu = (try? subChildrenAttr())?.contains { (try? $0.attribute(AXAttribute.role)()) == "AXMenu" } ?? false - - // Format line - var line = "" - - if title.isEmpty { - line = "[\(index)] ─────────────────" // Separator - } else { - line = "[\(index)] \(title)" - - if hasSubmenu { - line += " ▶" - } - - if !shortcut.isEmpty { - let modStr = formatModifiers(modifiers) - line += " (\(modStr)\(shortcut))" - } - - if !enabled { - line += " [disabled]" - } - } - - print(line) - } - - print() - print("Use -m \"\(path) > \" to explore submenus") - print("Use -m \"\(path) > \" -x to execute an action") - } - - // MARK: - Execute Menu Item - - private func executeMenuItem(_ item: Accessibility.Element, path: String) throws { - let title = (try? item.attribute(AXAttribute.title)()) ?? path - let enabled = (try? item.attribute(AXAttribute.enabled)()) ?? true - - guard enabled else { - print("Error: Menu item '\(title)' is disabled") - throw ExitCode.failure - } - - // Perform the press action - let action = item.action(.init("AXPress")) - try action() - - print("Executed: \(path)") - - if verbose { - print(" Title: \(title)") - } - } - - // MARK: - Show Menu Tree - - private func showMenuTree(_ element: Accessibility.Element, indent: Int) throws { - let prefix = String(repeating: " ", count: indent) - let role = (try? element.attribute(AXAttribute.role)()) ?? "" - let title = (try? element.attribute(AXAttribute.title)()) ?? "" - - if !title.isEmpty && role != "AXMenu" { - let enabled = (try? element.attribute(AXAttribute.enabled)()) ?? true - var line = "\(prefix)\(title)" - if !enabled { - line += " [disabled]" - } - print(line) - } - - let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = element.attribute(.init("AXChildren")) - guard let children = try? childrenAttr() else { return } - - for child in children { - try showMenuTree(child, indent: indent + 1) - } - } - - // MARK: - Search Menus - - private func searchMenus(menuBar: Accessibility.Element, pattern: String) throws { - print("Searching for '\(pattern)'...") - print(String(repeating: "-", count: 50)) - - var results: [(path: String, title: String, shortcut: String)] = [] - try searchMenuRecursive(menuBar, pattern: pattern.lowercased(), currentPath: "", results: &results) - - if results.isEmpty { - print("No menu items found matching '\(pattern)'") - } else { - print("Found \(results.count) item(s):\n") - for result in results { - var line = result.path - if !result.shortcut.isEmpty { - line += " (\(result.shortcut))" - } - print(line) - } - } - } - - private func searchMenuRecursive( - _ element: Accessibility.Element, - pattern: String, - currentPath: String, - results: inout [(path: String, title: String, shortcut: String)] - ) throws { - let role = (try? element.attribute(AXAttribute.role)()) ?? "" - let title = (try? element.attribute(AXAttribute.title)()) ?? "" - - let newPath: String - if title.isEmpty || role == "AXMenuBar" || role == "AXMenu" { - newPath = currentPath - } else if currentPath.isEmpty { - newPath = title - } else { - newPath = "\(currentPath) > \(title)" - } - - // Check if this item matches - if !title.isEmpty && title.lowercased().contains(pattern) { - let shortcutAttr: Accessibility.Attribute = element.attribute(.init("AXMenuItemCmdChar")) - let shortcut = (try? shortcutAttr()) ?? "" - let modifiersAttr: Accessibility.Attribute = element.attribute(.init("AXMenuItemCmdModifiers")) - let modifiers = (try? modifiersAttr()) ?? 0 - let fullShortcut = shortcut.isEmpty ? "" : "\(formatModifiers(modifiers))\(shortcut)" - results.append((path: newPath, title: title, shortcut: fullShortcut)) - } - - // Recurse into children - let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = element.attribute(.init("AXChildren")) - guard let children = try? childrenAttr() else { return } - - for child in children { - try searchMenuRecursive(child, pattern: pattern, currentPath: newPath, results: &results) - } - } - - // MARK: - Helpers - - private func formatModifiers(_ modifiers: Int) -> String { - var result = "" - // macOS modifier flags: 1=Shift, 2=Option, 4=Control, 8=Command (but stored inversely in some cases) - // Actually the AXMenuItemCmdModifiers uses different encoding - // 0 = Command only, 1 = Command+Shift, 2 = Command+Option, etc. - - // Simplified: just show ⌘ for now since most shortcuts use Command - if modifiers == 0 { - result = "⌘" - } else if modifiers & 1 != 0 { - result = "⌘⇧" - } else if modifiers & 2 != 0 { - result = "⌘⌥" - } else if modifiers & 4 != 0 { - result = "⌘⌃" - } else { - result = "⌘" - } - return result - } - } -} diff --git a/Sources/axdump/Commands/ObserveCommand.swift b/Sources/axdump/Commands/ObserveCommand.swift deleted file mode 100644 index a1593c4..0000000 --- a/Sources/axdump/Commands/ObserveCommand.swift +++ /dev/null @@ -1,265 +0,0 @@ -import Foundation -import ArgumentParser -import AccessibilityControl - -extension AXDump { - struct Observe: ParsableCommand { - static var configuration = CommandConfiguration( - abstract: "Observe accessibility notifications for an application", - discussion: """ - Monitor accessibility notifications in real-time. Each notification is printed - with a timestamp. Press Ctrl+C to stop observing. - - COMMON NOTIFICATIONS: - AXValueChanged - Element value changed - AXFocusedUIElementChanged - Focus moved to different element - AXFocusedWindowChanged - Different window got focus - AXSelectedTextChanged - Text selection changed - AXSelectedChildrenChanged - Child selection changed - AXWindowCreated/Moved/Resized - Window events - AXMenuOpened/Closed - Menu events - AXApplicationActivated - App became frontmost - - Use -n list to see all common notifications. - - EXAMPLES: - axdump observe 710 Observe focus changes (default) - axdump observe 710 -n list List available notifications - axdump observe 710 -n AXValueChanged Observe value changes - axdump observe 710 -n ValueChanged,Focused Multiple (AX prefix optional) - axdump observe 710 -n all Observe all notifications - axdump observe 710 -n all -v Verbose (show element details) - axdump observe 710 -w -n AXWindowMoved Observe from focused window - axdump observe 710 -n all -j JSON output - """ - ) - - @Argument(help: "Process ID of the application") - var pid: Int32 - - @Option(name: [.customShort("n"), .long], help: "Notification(s) to observe (comma-separated). Use 'list' to show, 'all' for all.") - var notifications: String = "AXFocusedUIElementChanged" - - @Option(name: [.customShort("p"), .long], help: "Path to element to observe (dot-separated child indices)") - var path: String? - - @Flag(name: [.customShort("F"), .long], help: "Observe focused element") - var focused: Bool = false - - @Flag(name: .shortAndLong, help: "Observe focused window") - var window: Bool = false - - @Flag(name: [.customShort("j"), .long], help: "Output as JSON") - var json: Bool = false - - @Flag(name: [.customShort("v"), .long], help: "Verbose output (show element details)") - var verbose: Bool = false - - @Flag(name: .long, help: "Disable colored output") - var noColor: Bool = false - - func run() throws { - guard Accessibility.isTrusted(shouldPrompt: true) else { - print("Error: Accessibility permissions required") - throw ExitCode.failure - } - - // Handle 'list' option - if notifications.lowercased() == "list" { - print("Common Accessibility Notifications:") - print(String(repeating: "-", count: 40)) - for notification in AXNotifications.all { - print(" \(notification)") - } - return - } - - let appElement = Accessibility.Element(pid: pid) - - // Determine target element - var targetElement: Accessibility.Element = appElement - - if focused { - guard let focusedElement: Accessibility.Element = try? appElement.attribute(.init("AXFocusedUIElement"))() else { - print("Error: Could not get focused element for PID \(pid)") - throw ExitCode.failure - } - targetElement = focusedElement - } else if window { - guard let focusedWindow: Accessibility.Element = try? appElement.attribute(.init("AXFocusedWindow"))() else { - print("Error: Could not get focused window for PID \(pid)") - throw ExitCode.failure - } - targetElement = focusedWindow - } - - // Navigate via path if specified - if let pathString = path { - targetElement = try navigateToPath(from: targetElement, path: pathString) - } - - // Print element info - printElementInfo(targetElement) - - // Determine which notifications to observe - let notificationNames: [String] - if notifications.lowercased() == "all" { - notificationNames = AXNotifications.all - } else { - notificationNames = notifications.split(separator: ",") - .map { String($0).trimmingCharacters(in: .whitespaces) } - .map { $0.hasPrefix("AX") ? $0 : "AX\($0)" } - } - - print("Observing notifications: \(notificationNames.joined(separator: ", "))") - print("Press Ctrl+C to stop") - print(String(repeating: "=", count: 60)) - print() - - // Create observer - let observer = try Accessibility.Observer(pid: pid, on: .main) - - // Store tokens to keep observations alive - var tokens: [Accessibility.Observer.Token] = [] - - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "HH:mm:ss.SSS" - - let useColor = !noColor - - for notificationName in notificationNames { - do { - let token = try observer.observe( - .init(notificationName), - for: targetElement - ) { [self] info in - let timestamp = dateFormatter.string(from: Date()) - - if json { - var output: [String: Any] = [ - "timestamp": timestamp, - "notification": notificationName - ] - - if let element = info["AXUIElement"] as? Accessibility.Element { - let printer = ElementPrinter() - output["element"] = printer.formatElementForJSON(element) - let pathInfo = computeElementPath(element, appElement: appElement) - output["path"] = pathInfo.path - output["chain"] = pathInfo.chain - } - - if let jsonData = try? JSONSerialization.data(withJSONObject: output, options: [.sortedKeys]), - let jsonString = String(data: jsonData, encoding: .utf8) { - print(jsonString) - } - } else { - let notifColor = colorForNotification(notificationName) - - var line = Color.dim.wrap("[\(timestamp)]", enabled: useColor) + " " - line += notifColor.wrap(notificationName, enabled: useColor) - - if let element = info["AXUIElement"] as? Accessibility.Element { - let pathInfo = computeElementPath(element, appElement: appElement) - line += " " + Color.dim.wrap("@", enabled: useColor) + " " - line += Color.blue.wrap(pathInfo.path, enabled: useColor) - if verbose { - line += "\n " + Color.dim.wrap("chain:", enabled: useColor) + " " - line += Color.magenta.wrap(pathInfo.chain, enabled: useColor) - line += "\n " + Color.dim.wrap("element:", enabled: useColor) + " " - line += formatElementColored(element, useColor: useColor) - } - } else { - line += " " + Color.dim.wrap("(no element)", enabled: useColor) - } - - print(line) - } - - fflush(stdout) - } - tokens.append(token) - } catch { - if verbose { - print("Warning: Could not observe \(notificationName): \(error)") - } - } - } - - if tokens.isEmpty { - print("Error: Could not register for any notifications") - throw ExitCode.failure - } - - print("Successfully registered for \(tokens.count) notification(s)") - print() - - // Keep running - RunLoop.main.run() - } - - private func printElementInfo(_ element: Accessibility.Element) { - print("Observing Element:") - print(String(repeating: "-", count: 40)) - - if let role: String = try? element.attribute(AXAttribute.role)() { - print("Role: \(role)") - } - if let title: String = try? element.attribute(AXAttribute.title)() { - print("Title: \(title)") - } - if let id: String = try? element.attribute(AXAttribute.identifier)() { - print("Identifier: \(id)") - } - - print() - } - - private func colorForNotification(_ name: String) -> Color { - switch name { - case "AXValueChanged", "AXSelectedTextChanged": - return .green - case "AXFocusedUIElementChanged", "AXFocusedWindowChanged": - return .cyan - case "AXLayoutChanged", "AXResized", "AXMoved": - return .yellow - case "AXWindowCreated", "AXWindowMoved", "AXWindowResized": - return .blue - case "AXApplicationActivated", "AXApplicationDeactivated": - return .magenta - case "AXMenuOpened", "AXMenuClosed", "AXMenuItemSelected": - return .brightMagenta - case "AXUIElementDestroyed": - return .red - case "AXCreated": - return .brightGreen - case "AXTitleChanged": - return .brightCyan - default: - return .white - } - } - - private func formatElementColored(_ element: Accessibility.Element, useColor: Bool) -> String { - var parts: [String] = [] - - if let role: String = try? element.attribute(AXAttribute.role)() { - parts.append(Color.cyan.wrap("role", enabled: useColor) + "=" + Color.white.wrap(role, enabled: useColor)) - } - if let title: String = try? element.attribute(AXAttribute.title)() { - let truncated = title.count > 30 ? String(title.prefix(30)) + "..." : title - parts.append(Color.yellow.wrap("title", enabled: useColor) + "=\"" + Color.white.wrap(truncated, enabled: useColor) + "\"") - } - if let id: String = try? element.attribute(AXAttribute.identifier)() { - parts.append(Color.green.wrap("id", enabled: useColor) + "=\"" + Color.white.wrap(id, enabled: useColor) + "\"") - } - if let value: Any = try? element.attribute(AXAttribute.value)() { - let strValue = String(describing: value) - let truncated = strValue.count > 30 ? String(strValue.prefix(30)) + "..." : strValue - parts.append(Color.magenta.wrap("value", enabled: useColor) + "=\"" + Color.white.wrap(truncated, enabled: useColor) + "\"") - } - - return parts.isEmpty ? "(element)" : parts.joined(separator: " ") - } - } -} diff --git a/Sources/axdump/Commands/QueryCommand.swift b/Sources/axdump/Commands/QueryCommand.swift deleted file mode 100644 index 6e3cb83..0000000 --- a/Sources/axdump/Commands/QueryCommand.swift +++ /dev/null @@ -1,232 +0,0 @@ -import Foundation -import ArgumentParser -import AccessibilityControl - -extension AXDump { - struct Query: ParsableCommand { - static var configuration = CommandConfiguration( - abstract: "Query specific element relationships", - discussion: """ - Query relationships between accessibility elements like parent, children, - siblings, or list all attributes of an element. - - RELATIONS: - children - Direct child elements - parent - Parent element - siblings - Sibling elements (same parent) - windows - Application windows - focused - Focused window and UI element - all-attributes - All attributes with truncated values (aliases: attrs, attributes) - - EXAMPLES: - axdump query 710 -r windows List all windows - axdump query 710 -r children Show app's direct children - axdump query 710 -r children -F Children of focused element - axdump query 710 -r siblings -F Siblings of focused element - axdump query 710 -r all-attributes List all attributes (truncated) - axdump query 710 -r focused Show focused window and element - """ - ) - - @Argument(help: "Process ID of the application") - var pid: Int32 - - @Option(name: [.customShort("r"), .long], help: "Relationship to query: children, parent, siblings, windows, focused, all-attributes") - var relation: String = "children" - - @Option(name: [.customShort("f"), .long], help: "Fields to display") - var fields: String = "standard" - - @Option(name: .shortAndLong, help: "Verbosity level") - var verbosity: Int = 1 - - @Flag(name: [.customShort("F"), .long], help: "Query from focused element instead of application root") - var focused: Bool = false - - @Option(name: [.customShort("p"), .long], help: "Path to element (dot-separated child indices)") - var path: String? - - @Flag(name: .long, help: "Disable colored output") - var noColor: Bool = false - - func run() throws { - guard Accessibility.isTrusted(shouldPrompt: true) else { - print("Error: Accessibility permissions required") - throw ExitCode.failure - } - - let appElement = Accessibility.Element(pid: pid) - - var targetElement: Accessibility.Element = appElement - - if focused { - guard let focusedElement: Accessibility.Element = try? appElement.attribute(.init("AXFocusedUIElement"))() else { - print("Error: Could not get focused element for PID \(pid)") - throw ExitCode.failure - } - targetElement = focusedElement - } - - if let pathString = path { - targetElement = try navigateToPath(from: targetElement, path: pathString) - } - - let attributeFields = AttributeFields.parse(fields) - let printer = ElementPrinter(fields: attributeFields, verbosity: verbosity) - - switch relation.lowercased() { - case "children": - queryChildren(of: targetElement, printer: printer) - - case "parent": - queryParent(of: targetElement, printer: printer) - - case "siblings": - querySiblings(of: targetElement, printer: printer) - - case "windows": - queryWindows(of: appElement, printer: printer) - - case "focused": - queryFocused(of: appElement, printer: printer) - - case "all-attributes", "attrs", "attributes": - queryAllAttributes(of: targetElement) - - default: - print("Unknown relation: \(relation)") - print("Valid options: children, parent, siblings, windows, focused, all-attributes") - throw ExitCode.failure - } - } - - private func queryChildren(of element: Accessibility.Element, printer: ElementPrinter) { - print("Children:") - print(String(repeating: "-", count: 40)) - - let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = element.attribute(.init("AXChildren")) - guard let children: [Accessibility.Element] = try? childrenAttr() else { - print("(no children or unable to read)") - return - } - - print("Count: \(children.count)") - print() - - for (index, child) in children.enumerated() { - print("[\(index)] \(printer.formatElement(child))") - } - } - - private func queryParent(of element: Accessibility.Element, printer: ElementPrinter) { - print("Parent:") - print(String(repeating: "-", count: 40)) - - guard let parent: Accessibility.Element = try? element.attribute(.init("AXParent"))() else { - print("(no parent or unable to read)") - return - } - - print(printer.formatElement(parent)) - } - - private func querySiblings(of element: Accessibility.Element, printer: ElementPrinter) { - print("Siblings:") - print(String(repeating: "-", count: 40)) - - guard let parent: Accessibility.Element = try? element.attribute(.init("AXParent"))() else { - print("(no parent - cannot determine siblings)") - return - } - - let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = parent.attribute(.init("AXChildren")) - guard let siblings: [Accessibility.Element] = try? childrenAttr() else { - print("(unable to read parent's children)") - return - } - - let filteredSiblings = siblings.filter { $0 != element } - print("Count: \(filteredSiblings.count)") - print() - - for (index, sibling) in filteredSiblings.enumerated() { - print("[\(index)] \(printer.formatElement(sibling))") - } - } - - private func queryWindows(of element: Accessibility.Element, printer: ElementPrinter) { - print("Windows:") - print(String(repeating: "-", count: 40)) - - let windowsAttr: Accessibility.Attribute<[Accessibility.Element]> = element.attribute(.init("AXWindows")) - guard let windows: [Accessibility.Element] = try? windowsAttr() else { - print("(no windows or unable to read)") - return - } - - print("Count: \(windows.count)") - print() - - for (index, window) in windows.enumerated() { - print("[\(index)] \(printer.formatElement(window))") - } - } - - private func queryFocused(of element: Accessibility.Element, printer: ElementPrinter) { - print("Focused Elements:") - print(String(repeating: "-", count: 40)) - - if let focusedWindow: Accessibility.Element = try? element.attribute(.init("AXFocusedWindow"))() { - print("Focused Window:") - print(" \(printer.formatElement(focusedWindow))") - print() - } - - if let focusedElement: Accessibility.Element = try? element.attribute(.init("AXFocusedUIElement"))() { - print("Focused UI Element:") - print(" \(printer.formatElement(focusedElement))") - } - } - - private func queryAllAttributes(of element: Accessibility.Element) { - print("All Attributes:") - print(String(repeating: "-", count: 40)) - - guard let attributes = try? element.supportedAttributes() else { - print("(unable to read attributes)") - return - } - - for attr in attributes.sorted(by: { $0.name.value < $1.name.value }) { - let name = attr.name.value - if let value: Any = try? attr() { - let strValue = String(describing: value) - let truncated = strValue.count > 80 ? String(strValue.prefix(80)) + "..." : strValue - print("\(name): \(truncated)") - } else { - print("\(name): (unable to read)") - } - } - - print() - print("Parameterized Attributes:") - print(String(repeating: "-", count: 40)) - - if let paramAttrs = try? element.supportedParameterizedAttributes() { - for attr in paramAttrs.sorted(by: { $0.name.value < $1.name.value }) { - print(attr.name.value) - } - } - - print() - print("Actions:") - print(String(repeating: "-", count: 40)) - - if let actions = try? element.supportedActions() { - for action in actions.sorted(by: { $0.name.value < $1.name.value }) { - print("\(action.name.value): \(action.description)") - } - } - } - } -} diff --git a/Sources/axdump/Commands/ScreenshotCommand.swift b/Sources/axdump/Commands/ScreenshotCommand.swift deleted file mode 100644 index 880f5a6..0000000 --- a/Sources/axdump/Commands/ScreenshotCommand.swift +++ /dev/null @@ -1,334 +0,0 @@ -import Foundation -import ArgumentParser -import AccessibilityControl -import WindowControl -import CoreGraphics -import AppKit -import ImageIO - -extension AXDump { - struct Screenshot: ParsableCommand { - static var configuration = CommandConfiguration( - abstract: "Capture a screenshot of an application window", - discussion: """ - Captures a window and saves it as a PNG file. Optionally draws bounding boxes - around specified accessibility elements. - - EXAMPLES: - axdump screenshot 710 Screenshot focused window - axdump screenshot 710 -o ~/Desktop/win.png Custom output path - axdump screenshot 710 -i 0 Screenshot first window - axdump screenshot 710 --list List available windows - axdump screenshot 710 -b 0.1.2 Draw box around element at path - axdump screenshot 710 -b 0.1.2 -b 0.2.0 Multiple bounding boxes - axdump screenshot 710 --shadow Include window shadow - """ - ) - - @Argument(help: "Process ID of the application") - var pid: Int32 - - @Option(name: [.customShort("o"), .long], help: "Output file path (default: window_.png)") - var output: String? - - @Option(name: [.customShort("i"), .long], help: "Window index to capture (default: focused window)") - var windowIndex: Int? - - @Flag(name: .long, help: "List available windows for the application") - var list: Bool = false - - @Flag(name: .long, help: "Include window shadow in screenshot") - var shadow: Bool = false - - @Option(name: [.customShort("b"), .long], parsing: .upToNextOption, help: "Element path(s) to draw bounding boxes around") - var boundingBox: [String] = [] - - @Option(name: .long, help: "Bounding box color: red, green, blue, yellow, orange, cyan, magenta, white") - var boxColor: String = "red" - - @Option(name: .long, help: "Bounding box line width (default: 2.0)") - var boxWidth: Double = 2.0 - - func run() throws { - guard Accessibility.isTrusted(shouldPrompt: true) else { - print("Error: Accessibility permissions required") - throw ExitCode.failure - } - - let appElement = Accessibility.Element(pid: pid) - - if list { - try listWindows(appElement) - return - } - - // Get the window element - let windowElement: Accessibility.Element - if let index = windowIndex { - let windowsAttr: Accessibility.Attribute<[Accessibility.Element]> = appElement.attribute(.init("AXWindows")) - guard let windows: [Accessibility.Element] = try? windowsAttr() else { - print("Error: Could not get windows for PID \(pid)") - throw ExitCode.failure - } - guard index >= 0 && index < windows.count else { - print("Error: Window index \(index) out of range (0..<\(windows.count))") - throw ExitCode.failure - } - windowElement = windows[index] - } else { - guard let focusedWindow: Accessibility.Element = try? appElement.attribute(.init("AXFocusedWindow"))() else { - print("Error: Could not get focused window for PID \(pid)") - print("Tip: Use --list to see available windows, then -i to select one") - throw ExitCode.failure - } - windowElement = focusedWindow - } - - // Get window ID - let window: Window - do { - window = try windowElement.window() - } catch { - print("Error: Could not get window ID: \(error)") - throw ExitCode.failure - } - - // Get window bounds for bounding box calculations - let windowFrame: CGRect = getElementFrame(windowElement) ?? .zero - - // Capture the window - var imageOptions: CGWindowImageOption = [.boundsIgnoreFraming] - if shadow { - imageOptions = [] - } - - guard let cgImage = CGWindowListCreateImage( - .null, - .optionIncludingWindow, - window.raw, - imageOptions - ) else { - print("Error: Failed to capture window image") - throw ExitCode.failure - } - - // Draw bounding boxes if requested - let finalImage: CGImage - if !boundingBox.isEmpty { - finalImage = try drawBoundingBoxes( - on: cgImage, - windowElement: windowElement, - windowFrame: windowFrame, - paths: boundingBox - ) - } else { - finalImage = cgImage - } - - // Determine output path - let outputPath: String - if let userPath = output { - outputPath = (userPath as NSString).expandingTildeInPath - } else { - outputPath = "window_\(window.raw).png" - } - - // Save as PNG - try saveImage(finalImage, to: outputPath) - - print("Screenshot saved to: \(outputPath)") - print("Window ID: \(window.raw)") - print("Image size: \(finalImage.width) x \(finalImage.height)") - - if !boundingBox.isEmpty { - print("Bounding boxes drawn: \(boundingBox.count)") - } - } - - private func listWindows(_ appElement: Accessibility.Element) throws { - let windowsAttr: Accessibility.Attribute<[Accessibility.Element]> = appElement.attribute(.init("AXWindows")) - guard let windows: [Accessibility.Element] = try? windowsAttr() else { - print("Error: Could not get windows for PID \(pid)") - throw ExitCode.failure - } - - if let appName: String = try? appElement.attribute(.init("AXTitle"))() { - print("Windows for: \(appName) (PID: \(pid))") - } else { - print("Windows for PID: \(pid)") - } - print(String(repeating: "-", count: 60)) - - if windows.isEmpty { - print("No windows found") - return - } - - let focusedWindow: Accessibility.Element? = try? appElement.attribute(.init("AXFocusedWindow"))() - - for (index, window) in windows.enumerated() { - var info: [String] = ["[\(index)]"] - - let isFocused = focusedWindow != nil && window == focusedWindow - if isFocused { - info.append("*") - } - - if let title: String = try? window.attribute(.init("AXTitle"))() { - info.append("title=\"\(title)\"") - } - - if let frame = getElementFrame(window) { - info.append("frame=(\(Int(frame.origin.x)),\(Int(frame.origin.y)) \(Int(frame.width))x\(Int(frame.height)))") - } - - if let windowID = try? window.window() { - info.append("id=\(windowID.raw)") - } - - print(info.joined(separator: " ")) - } - - print() - print("* = focused window") - print("Use -i to capture a specific window") - } - - private func getElementFrame(_ element: Accessibility.Element) -> CGRect? { - if let frame = try? element.attribute(AXAttribute.frame)() { - return frame - } - - if let pos = try? element.attribute(AXAttribute.position)(), - let size = try? element.attribute(AXAttribute.size)() { - return CGRect(origin: pos, size: size) - } - - return nil - } - - private func drawBoundingBoxes( - on image: CGImage, - windowElement: Accessibility.Element, - windowFrame: CGRect, - paths: [String] - ) throws -> CGImage { - let width = image.width - let height = image.height - - guard let colorSpace = image.colorSpace ?? CGColorSpace(name: CGColorSpace.sRGB), - let context = CGContext( - data: nil, - width: width, - height: height, - bitsPerComponent: 8, - bytesPerRow: 0, - space: colorSpace, - bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue - ) else { - print("Warning: Could not create graphics context for bounding boxes") - return image - } - - context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) - - let boxCGColor = parseColor(boxColor) - context.setStrokeColor(boxCGColor) - context.setLineWidth(CGFloat(boxWidth)) - - let scaleX = CGFloat(width) / windowFrame.width - let scaleY = CGFloat(height) / windowFrame.height - - for path in paths { - do { - let element = try navigateToPath(from: windowElement, path: path) - - guard let elementFrame = getElementFrame(element) else { - print("Warning: Could not get frame for element at path '\(path)'") - continue - } - - let relativeX = elementFrame.origin.x - windowFrame.origin.x - let relativeY = elementFrame.origin.y - windowFrame.origin.y - - let imageX = relativeX * scaleX - let imageY = relativeY * scaleY - let imageWidth = elementFrame.width * scaleX - let imageHeight = elementFrame.height * scaleY - - let flippedY = CGFloat(height) - imageY - imageHeight - - let rect = CGRect(x: imageX, y: flippedY, width: imageWidth, height: imageHeight) - context.stroke(rect) - - if let role: String = try? element.attribute(.init("AXRole"))() { - var desc = " Box at '\(path)': \(role)" - if let title: String = try? element.attribute(.init("AXTitle"))() { - desc += " title=\"\(title)\"" - } - desc += " frame=(\(Int(elementFrame.origin.x)),\(Int(elementFrame.origin.y)) \(Int(elementFrame.width))x\(Int(elementFrame.height)))" - print(desc) - } - - } catch { - print("Warning: Could not find element at path '\(path)': \(error)") - } - } - - guard let finalImage = context.makeImage() else { - print("Warning: Could not create final image with bounding boxes") - return image - } - - return finalImage - } - - private func parseColor(_ name: String) -> CGColor { - switch name.lowercased() { - case "red": return CGColor(red: 1, green: 0, blue: 0, alpha: 1) - case "green": return CGColor(red: 0, green: 1, blue: 0, alpha: 1) - case "blue": return CGColor(red: 0, green: 0, blue: 1, alpha: 1) - case "yellow": return CGColor(red: 1, green: 1, blue: 0, alpha: 1) - case "orange": return CGColor(red: 1, green: 0.5, blue: 0, alpha: 1) - case "cyan": return CGColor(red: 0, green: 1, blue: 1, alpha: 1) - case "magenta": return CGColor(red: 1, green: 0, blue: 1, alpha: 1) - case "white": return CGColor(red: 1, green: 1, blue: 1, alpha: 1) - default: return CGColor(red: 1, green: 0, blue: 0, alpha: 1) - } - } - } -} - -// MARK: - Image Saving Helper - -func saveImage(_ image: CGImage, to path: String) throws { - let url = URL(fileURLWithPath: path) - guard let destination = CGImageDestinationCreateWithURL( - url as CFURL, - "public.png" as CFString, - 1, - nil - ) else { - throw ImageError.cannotCreateDestination(path) - } - - CGImageDestinationAddImage(destination, image, nil) - - guard CGImageDestinationFinalize(destination) else { - throw ImageError.cannotWrite(path) - } -} - -enum ImageError: Error, CustomStringConvertible { - case cannotCreateDestination(String) - case cannotWrite(String) - - var description: String { - switch self { - case .cannotCreateDestination(let path): - return "Could not create image destination at \(path)" - case .cannotWrite(let path): - return "Failed to write image to \(path)" - } - } -} diff --git a/Sources/axdump/Commands/SetCommand.swift b/Sources/axdump/Commands/SetCommand.swift deleted file mode 100644 index 950ca28..0000000 --- a/Sources/axdump/Commands/SetCommand.swift +++ /dev/null @@ -1,185 +0,0 @@ -import Foundation -import ArgumentParser -import AccessibilityControl - -extension AXDump { - struct Set: ParsableCommand { - static var configuration = CommandConfiguration( - abstract: "Set an attribute value on an accessibility element", - discussion: """ - Set mutable attribute values on elements. Navigate to the target element - using path notation or focus options. - - COMMON SETTABLE ATTRIBUTES: - AXValue - Element's value (text fields, sliders, etc.) - AXFocused - Whether element has focus (true/false) - - VALUE TYPES: - - Strings: Just provide the text - - Booleans: true, false, yes, no, 1, 0 - - Numbers: Integer or decimal values - - EXAMPLES: - axdump set 710 -a Value -v "Hello" -p 0.1.2 Set text value - axdump set 710 -a Focused -v true -p 0.1 Focus element - axdump set 710 -a Value -v 50 -p 0.3 Set slider to 50 - axdump set 710 -a Value -v "search term" -F Set focused element's value - """ - ) - - @Argument(help: "Process ID of the application") - var pid: Int32 - - @Option(name: [.customShort("a"), .long], help: "Attribute to set (can omit 'AX' prefix)") - var attribute: String - - @Option(name: [.customShort("v"), .long], help: "Value to set") - var value: String - - @Option(name: [.customShort("p"), .long], help: "Path to element (dot-separated child indices)") - var path: String? - - @Option(name: [.customShort("c"), .long], help: "Index of child element") - var child: Int? - - @Flag(name: [.customShort("F"), .long], help: "Target the focused element") - var focused: Bool = false - - @Flag(name: .shortAndLong, help: "Target the focused window") - var window: Bool = false - - @Flag(name: .long, help: "Verbose output") - var verbose: Bool = false - - func run() throws { - guard Accessibility.isTrusted(shouldPrompt: true) else { - print("Error: Accessibility permissions required") - throw ExitCode.failure - } - - let appElement = Accessibility.Element(pid: pid) - - // Determine target element - var targetElement: Accessibility.Element = appElement - - if focused { - guard let focusedElement: Accessibility.Element = try? appElement.attribute(.init("AXFocusedUIElement"))() else { - print("Error: Could not get focused element for PID \(pid)") - throw ExitCode.failure - } - targetElement = focusedElement - } else if window { - guard let focusedWindow: Accessibility.Element = try? appElement.attribute(.init("AXFocusedWindow"))() else { - print("Error: Could not get focused window for PID \(pid)") - throw ExitCode.failure - } - targetElement = focusedWindow - } - - if let childIndex = child { - targetElement = try navigateToChild(from: targetElement, index: childIndex) - } - - if let pathString = path { - targetElement = try navigateToPath(from: targetElement, path: pathString) - } - - // Normalize attribute name - let attrName = attribute.hasPrefix("AX") ? attribute : "AX\(attribute)" - - // Print element info if verbose - if verbose { - printElementInfo(targetElement) - } - - // Check if attribute is settable - let attr = targetElement.attribute(.init(attrName)) as Accessibility.Attribute - guard (try? attr.isSettable()) == true else { - print("Error: Attribute '\(attrName)' is not settable on this element") - throw ExitCode.failure - } - - // Get current value for comparison - let oldValue: Any? = try? attr() - - // Parse and set the value based on attribute type - do { - try setValue(value, forAttribute: attrName, on: targetElement) - - print("Set \(attrName) = \(value)") - - if verbose { - if let old = oldValue { - print(" Previous value: \(String(describing: old))") - } - // Read back the new value - if let newValue: Any = try? attr() { - print(" New value: \(String(describing: newValue))") - } - } - } catch { - print("Error: Failed to set \(attrName): \(error)") - throw ExitCode.failure - } - } - - private func setValue(_ valueStr: String, forAttribute attrName: String, on element: Accessibility.Element) throws { - // Try to determine the appropriate type based on the attribute name and value - switch attrName { - case "AXFocused", "AXEnabled", "AXSelected", "AXDisclosed", "AXExpanded": - // Boolean attributes - let boolValue = parseBool(valueStr) - let mutableAttr = element.mutableAttribute(.init(attrName)) as Accessibility.MutableAttribute - try mutableAttr(assign: boolValue) - - case "AXValue": - // Value can be string, number, or bool depending on element - // Try number first, then bool, then string - if let intVal = Int(valueStr) { - let mutableAttr = element.mutableAttribute(.init(attrName)) as Accessibility.MutableAttribute - try mutableAttr(assign: intVal) - } else if let doubleVal = Double(valueStr) { - let mutableAttr = element.mutableAttribute(.init(attrName)) as Accessibility.MutableAttribute - try mutableAttr(assign: doubleVal) - } else if valueStr.lowercased() == "true" || valueStr.lowercased() == "false" { - let mutableAttr = element.mutableAttribute(.init(attrName)) as Accessibility.MutableAttribute - try mutableAttr(assign: parseBool(valueStr)) - } else { - // Default to string - let mutableAttr = element.mutableAttribute(.init(attrName)) as Accessibility.MutableAttribute - try mutableAttr(assign: valueStr) - } - - default: - // Default: try as string - let mutableAttr = element.mutableAttribute(.init(attrName)) as Accessibility.MutableAttribute - try mutableAttr(assign: valueStr) - } - } - - private func parseBool(_ value: String) -> Bool { - switch value.lowercased() { - case "true", "yes", "1", "on": return true - case "false", "no", "0", "off": return false - default: return !value.isEmpty - } - } - - private func printElementInfo(_ element: Accessibility.Element) { - print("Target Element:") - print(String(repeating: "-", count: 40)) - - if let role: String = try? element.attribute(AXAttribute.role)() { - print(" Role: \(role)") - } - if let title: String = try? element.attribute(AXAttribute.title)() { - print(" Title: \(title)") - } - if let id: String = try? element.attribute(AXAttribute.identifier)() { - print(" Identifier: \(id)") - } - - print() - } - } -} diff --git a/Sources/axdump/Commands/WatchCommand.swift b/Sources/axdump/Commands/WatchCommand.swift deleted file mode 100644 index 015e1c1..0000000 --- a/Sources/axdump/Commands/WatchCommand.swift +++ /dev/null @@ -1,280 +0,0 @@ -import Foundation -import ArgumentParser -import AccessibilityControl -import AppKit -import CoreGraphics - -extension AXDump { - struct Watch: ParsableCommand { - static var configuration = CommandConfiguration( - abstract: "Live watch element under mouse cursor", - discussion: """ - Continuously displays information about the accessibility element under - the mouse cursor. Useful for exploring UI and finding elements. - - Press Ctrl+C to stop. - - EXAMPLES: - axdump watch Watch any app - axdump watch 710 Watch only Finder (PID 710) - axdump watch --click Also show click path - axdump watch --actions Show available actions - axdump watch --path Show element path for automation - """ - ) - - @Argument(help: "Optional: Only watch elements in this PID") - var pid: Int32? - - @Flag(name: .long, help: "Show available actions on the element") - var actions: Bool = false - - @Flag(name: .long, help: "Show element path (for use with other commands)") - var path: Bool = false - - @Flag(name: .long, help: "Show full attribute list") - var full: Bool = false - - @Option(name: [.customShort("i"), .long], help: "Update interval in milliseconds (default: 100)") - var interval: Int = 100 - - @Flag(name: .long, help: "Disable colored output") - var noColor: Bool = false - - func run() throws { - guard Accessibility.isTrusted(shouldPrompt: true) else { - print("Error: Accessibility permissions required") - throw ExitCode.failure - } - - let useColor = !noColor - print(Color.cyan.wrap("Watching accessibility elements under cursor...", enabled: useColor)) - print(Color.dim.wrap("Press Ctrl+C to stop\n", enabled: useColor)) - - var lastElement: Accessibility.Element? - var lastInfo = "" - - // Set up signal handler for clean exit - signal(SIGINT) { _ in - print("\n\nStopped watching.") - Darwin.exit(0) - } - - while true { - let mouseLocation = NSEvent.mouseLocation - - // Convert to screen coordinates (flip Y) - let screenHeight = NSScreen.main?.frame.height ?? 0 - let point = CGPoint(x: mouseLocation.x, y: screenHeight - mouseLocation.y) - - // Hit test to find element at point - let systemWide = Accessibility.Element.systemWide - guard let element: Accessibility.Element = try? systemWide.hitTest(x: Float(point.x), y: Float(point.y)) else { - Thread.sleep(forTimeInterval: Double(interval) / 1000.0) - continue - } - - // If filtering by PID, check it - if let filterPid = pid { - guard let elementPid = try? element.pid(), elementPid == filterPid else { - Thread.sleep(forTimeInterval: Double(interval) / 1000.0) - continue - } - } - - // Check if element changed - let currentInfo = formatElementInfo(element, useColor: useColor) - if currentInfo != lastInfo || element != lastElement { - lastElement = element - lastInfo = currentInfo - - // Clear previous output and print new info - print("\u{001B}[2J\u{001B}[H", terminator: "") // Clear screen - print(Color.cyan.wrap("═══ Element Under Cursor ═══", enabled: useColor)) - print() - print(currentInfo) - - if path { - printElementPath(element, useColor: useColor) - } - - if actions { - printActions(element, useColor: useColor) - } - - if full { - printFullAttributes(element, useColor: useColor) - } - - print() - print(Color.dim.wrap("Mouse: (\(Int(point.x)), \(Int(point.y)))", enabled: useColor)) - print(Color.dim.wrap("Press Ctrl+C to stop", enabled: useColor)) - } - - Thread.sleep(forTimeInterval: Double(interval) / 1000.0) - } - } - - private func formatElementInfo(_ element: Accessibility.Element, useColor: Bool) -> String { - var lines: [String] = [] - - // App info - if let elementPid = try? element.pid() { - let apps = NSWorkspace.shared.runningApplications.filter { $0.processIdentifier == elementPid } - if let app = apps.first { - let appName = app.localizedName ?? "Unknown" - lines.append(Color.yellow.wrap("App:", enabled: useColor) + " \(appName) (PID: \(elementPid))") - } - } - - // Role - if let role: String = try? element.attribute(AXAttribute.role)() { - let shortRole = role.replacingOccurrences(of: "AX", with: "") - var roleLine = Color.green.wrap("Role:", enabled: useColor) + " \(shortRole)" - - if let subrole: String = try? element.attribute(AXAttribute.subrole)() { - let shortSubrole = subrole.replacingOccurrences(of: "AX", with: "") - roleLine += " [\(shortSubrole)]" - } - lines.append(roleLine) - } - - // Title - if let title: String = try? element.attribute(AXAttribute.title)(), !title.isEmpty { - lines.append(Color.green.wrap("Title:", enabled: useColor) + " \"\(title)\"") - } - - // Identifier - if let id: String = try? element.attribute(AXAttribute.identifier)() { - lines.append(Color.green.wrap("ID:", enabled: useColor) + " \(id)") - } - - // Value - if let value: Any = try? element.attribute(AXAttribute.value)() { - let strValue = String(describing: value) - let truncated = strValue.count > 50 ? String(strValue.prefix(50)) + "..." : strValue - lines.append(Color.green.wrap("Value:", enabled: useColor) + " \(truncated)") - } - - // Description - if let desc: String = try? element.attribute(AXAttribute.description)(), !desc.isEmpty { - lines.append(Color.green.wrap("Desc:", enabled: useColor) + " \"\(desc)\"") - } - - // Enabled/Focused - let enabled = (try? element.attribute(AXAttribute.enabled)()) ?? true - let focused = (try? element.attribute(AXAttribute.focused)()) ?? false - var stateLine = Color.green.wrap("State:", enabled: useColor) - stateLine += enabled ? " enabled" : Color.red.wrap(" disabled", enabled: useColor) - if focused { - stateLine += Color.brightGreen.wrap(" [focused]", enabled: useColor) - } - lines.append(stateLine) - - // Frame - if let frame = try? element.attribute(AXAttribute.frame)() { - lines.append(Color.green.wrap("Frame:", enabled: useColor) + - " (\(Int(frame.origin.x)), \(Int(frame.origin.y))) \(Int(frame.width))×\(Int(frame.height))") - } - - return lines.joined(separator: "\n") - } - - private func printElementPath(_ element: Accessibility.Element, useColor: Bool) { - print() - print(Color.cyan.wrap("─── Path ───", enabled: useColor)) - - // Try to compute path - guard let elementPid = try? element.pid() else { return } - let appElement = Accessibility.Element(pid: elementPid) - - var ancestors: [(element: Accessibility.Element, index: Int)] = [] - var current = element - - // Walk up to root - while let parent: Accessibility.Element = try? current.attribute(.init("AXParent"))() { - // Find index of current in parent's children - let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = parent.attribute(.init("AXChildren")) - var index = -1 - if let children = try? childrenAttr() { - index = children.firstIndex(of: current) ?? -1 - } - ancestors.append((current, index)) - - if parent == appElement { - break - } - current = parent - } - - ancestors.reverse() - - // Build path string - let pathIndices = ancestors.map { $0.index >= 0 ? String($0.index) : "?" }.joined(separator: ".") - print(Color.yellow.wrap("Path:", enabled: useColor) + " \(pathIndices)") - - // Build chain description - var chain: [String] = [] - for (elem, _) in ancestors { - var desc = "" - if let role: String = try? elem.attribute(AXAttribute.role)() { - desc = role.replacingOccurrences(of: "AX", with: "") - } - if let title: String = try? elem.attribute(AXAttribute.title)(), !title.isEmpty { - let short = title.count > 15 ? String(title.prefix(15)) + "..." : title - desc += "[\"\(short)\"]" - } else if let id: String = try? elem.attribute(AXAttribute.identifier)() { - desc += "[#\(id)]" - } - chain.append(desc) - } - print(Color.dim.wrap("Chain:", enabled: useColor) + " " + chain.joined(separator: " > ")) - - // Print command hint - print() - print(Color.dim.wrap("Use with:", enabled: useColor)) - print(" axdump inspect \(elementPid) -p \(pathIndices)") - print(" axdump action \(elementPid) -a Press -p \(pathIndices)") - } - - private func printActions(_ element: Accessibility.Element, useColor: Bool) { - print() - print(Color.cyan.wrap("─── Actions ───", enabled: useColor)) - - guard let actions = try? element.supportedActions() else { - print(Color.dim.wrap("(none)", enabled: useColor)) - return - } - - if actions.isEmpty { - print(Color.dim.wrap("(none)", enabled: useColor)) - return - } - - for action in actions { - let name = action.name.value.replacingOccurrences(of: "AX", with: "") - let desc = AXActions.all[action.name.value] ?? action.description - print(" \(Color.yellow.wrap(name, enabled: useColor)): \(desc)") - } - } - - private func printFullAttributes(_ element: Accessibility.Element, useColor: Bool) { - print() - print(Color.cyan.wrap("─── All Attributes ───", enabled: useColor)) - - guard let attrs = try? element.supportedAttributes() else { - print(Color.dim.wrap("(unable to read)", enabled: useColor)) - return - } - - for attr in attrs.sorted(by: { $0.name.value < $1.name.value }) { - let name = attr.name.value - if let value: Any = try? attr() { - let strValue = String(describing: value) - let truncated = strValue.count > 40 ? String(strValue.prefix(40)) + "..." : strValue - print(" \(name): \(truncated)") - } - } - } - } -} diff --git a/Sources/axdump/Utilities/AttributeFields.swift b/Sources/axdump/Utilities/AttributeFields.swift deleted file mode 100644 index d11e0a1..0000000 --- a/Sources/axdump/Utilities/AttributeFields.swift +++ /dev/null @@ -1,118 +0,0 @@ -import Foundation -import AccessibilityControl -import CoreGraphics - -// MARK: - Attribute Names - -/// Common accessibility attribute names with type information -enum AXAttribute { - static let frame: Accessibility.Attribute.Name = .init("AXFrame") - static let position: Accessibility.Attribute.Name = .init("AXPosition") - static let size: Accessibility.Attribute.Name = .init("AXSize") - static let role: Accessibility.Attribute.Name = .init("AXRole") - static let subrole: Accessibility.Attribute.Name = .init("AXSubrole") - static let roleDescription: Accessibility.Attribute.Name = .init("AXRoleDescription") - static let title: Accessibility.Attribute.Name = .init("AXTitle") - static let identifier: Accessibility.Attribute.Name = .init("AXIdentifier") - static let description: Accessibility.Attribute.Name = .init("AXDescription") - static let help: Accessibility.Attribute.Name = .init("AXHelp") - static let value: Accessibility.Attribute.Name = .init("AXValue") - static let enabled: Accessibility.Attribute.Name = .init("AXEnabled") - static let focused: Accessibility.Attribute.Name = .init("AXFocused") - static let children: Accessibility.Attribute<[Accessibility.Element]>.Name = .init("AXChildren") - static let parent: Accessibility.Attribute.Name = .init("AXParent") - static let windows: Accessibility.Attribute<[Accessibility.Element]>.Name = .init("AXWindows") - static let focusedWindow: Accessibility.Attribute.Name = .init("AXFocusedWindow") - static let focusedUIElement: Accessibility.Attribute.Name = .init("AXFocusedUIElement") - static let mainWindow: Accessibility.Attribute.Name = .init("AXMainWindow") -} - -// MARK: - Attribute Field Selection - -/// Option set for selecting which attribute fields to display -struct AttributeFields: OptionSet { - let rawValue: Int - - static let role = AttributeFields(rawValue: 1 << 0) - static let roleDescription = AttributeFields(rawValue: 1 << 1) - static let title = AttributeFields(rawValue: 1 << 2) - static let identifier = AttributeFields(rawValue: 1 << 3) - static let value = AttributeFields(rawValue: 1 << 4) - static let description = AttributeFields(rawValue: 1 << 5) - static let enabled = AttributeFields(rawValue: 1 << 6) - static let focused = AttributeFields(rawValue: 1 << 7) - static let position = AttributeFields(rawValue: 1 << 8) - static let size = AttributeFields(rawValue: 1 << 9) - static let frame = AttributeFields(rawValue: 1 << 10) - static let help = AttributeFields(rawValue: 1 << 11) - static let subrole = AttributeFields(rawValue: 1 << 12) - static let childCount = AttributeFields(rawValue: 1 << 13) - static let actions = AttributeFields(rawValue: 1 << 14) - - // Presets - static let minimal: AttributeFields = [.role, .title, .identifier] - static let standard: AttributeFields = [.role, .roleDescription, .title, .identifier, .value, .description] - static let full: AttributeFields = [ - .role, .roleDescription, .title, .identifier, .value, - .description, .enabled, .focused, .position, .size, .frame, .help, .subrole - ] - static let all: AttributeFields = [ - .role, .roleDescription, .title, .identifier, .value, - .description, .enabled, .focused, .position, .size, .frame, .help, .subrole, - .childCount, .actions - ] - - /// Parse a comma-separated field specification string - static func parse(_ string: String) -> AttributeFields { - var fields: AttributeFields = [] - for name in string.lowercased().split(separator: ",") { - switch name.trimmingCharacters(in: .whitespaces) { - case "role": fields.insert(.role) - case "roledescription", "role-description", "roledesc": fields.insert(.roleDescription) - case "title": fields.insert(.title) - case "identifier", "id": fields.insert(.identifier) - case "value", "val": fields.insert(.value) - case "description", "desc": fields.insert(.description) - case "enabled": fields.insert(.enabled) - case "focused": fields.insert(.focused) - case "position", "pos": fields.insert(.position) - case "size": fields.insert(.size) - case "frame": fields.insert(.frame) - case "help": fields.insert(.help) - case "subrole": fields.insert(.subrole) - case "children", "childcount", "child-count": fields.insert(.childCount) - case "actions": fields.insert(.actions) - // Presets - case "minimal": fields.formUnion(.minimal) - case "standard": fields.formUnion(.standard) - case "full": fields.formUnion(.full) - case "all": fields.formUnion(.all) - default: break - } - } - return fields.isEmpty ? .standard : fields - } - - /// Help text describing available field options - static var helpText: String { - """ - FIELD OPTIONS: - Presets: - minimal - role, title, identifier - standard - role, roleDescription, title, identifier, value, description (default) - full - all basic fields - all - all fields including childCount and actions - - Individual fields (comma-separated): - role, subrole, roleDescription (or roledesc), title, - identifier (or id), value (or val), description (or desc), - enabled, focused, position (or pos), size, frame, help, - childCount (or children), actions - - Examples: - -f minimal - -f role,title,value - -f standard,actions - """ - } -} diff --git a/Sources/axdump/Utilities/Constants.swift b/Sources/axdump/Utilities/Constants.swift deleted file mode 100644 index bae9be1..0000000 --- a/Sources/axdump/Utilities/Constants.swift +++ /dev/null @@ -1,319 +0,0 @@ -import Foundation - -// MARK: - Common Accessibility Roles - -/// Standard macOS accessibility roles (AXRole values) -enum AXRoles { - /// All known roles for reference and help text - static let all: [String: String] = [ - // Core UI Elements - "AXApplication": "Application root element", - "AXWindow": "Window container", - "AXSheet": "Sheet dialog", - "AXDrawer": "Drawer panel", - "AXDialog": "Dialog window", - - // Buttons & Controls - "AXButton": "Push button", - "AXRadioButton": "Radio button (single selection)", - "AXCheckBox": "Checkbox (toggle)", - "AXPopUpButton": "Pop-up button (dropdown)", - "AXMenuButton": "Menu button", - "AXDisclosureTriangle": "Disclosure triangle (expand/collapse)", - "AXIncrementor": "Stepper control", - "AXSlider": "Slider control", - "AXColorWell": "Color picker well", - - // Text Elements - "AXStaticText": "Static text label", - "AXTextField": "Text input field", - "AXTextArea": "Multi-line text area", - "AXSecureTextField": "Password field", - "AXSearchField": "Search input field", - "AXComboBox": "Combo box (text + dropdown)", - - // Menus - "AXMenuBar": "Application menu bar", - "AXMenu": "Menu container", - "AXMenuItem": "Menu item", - "AXMenuBarItem": "Menu bar item", - - // Lists & Tables - "AXList": "List container", - "AXTable": "Table with rows/columns", - "AXOutline": "Outline (hierarchical list)", - "AXBrowser": "Column browser (like Finder)", - "AXRow": "Table/list row", - "AXColumn": "Table column", - "AXCell": "Table cell", - - // Groups & Containers - "AXGroup": "Generic grouping element", - "AXScrollArea": "Scrollable area", - "AXSplitGroup": "Split view container", - "AXSplitter": "Split view divider", - "AXTabGroup": "Tab container", - "AXToolbar": "Toolbar container", - "AXLayoutArea": "Layout area", - "AXLayoutItem": "Layout item", - "AXMatte": "Matte (background)", - "AXRulerMarker": "Ruler marker", - - // Media & Images - "AXImage": "Image element", - "AXValueIndicator": "Value indicator (progress)", - "AXProgressIndicator": "Progress bar", - "AXBusyIndicator": "Busy/loading indicator", - "AXRelevanceIndicator": "Relevance indicator", - "AXLevelIndicator": "Level indicator", - - // Special Elements - "AXLink": "Hyperlink", - "AXHelpTag": "Help tooltip", - "AXScrollBar": "Scroll bar", - "AXHandle": "Resize handle", - "AXGrowArea": "Window grow area", - "AXRuler": "Ruler", - "AXGrid": "Grid layout", - "AXWebArea": "Web content area", - - // System UI - "AXDockItem": "Dock item", - "AXSystemWide": "System-wide element", - ] - - /// Most commonly used roles - static let common: [String] = [ - "AXButton", "AXStaticText", "AXTextField", "AXTextArea", - "AXCheckBox", "AXRadioButton", "AXPopUpButton", "AXSlider", - "AXWindow", "AXGroup", "AXScrollArea", "AXTable", "AXList", - "AXRow", "AXCell", "AXImage", "AXLink", "AXMenu", "AXMenuItem", - "AXToolbar", "AXTabGroup", "AXWebArea" - ] -} - -// MARK: - Common Accessibility Subroles - -/// Standard macOS accessibility subroles (AXSubrole values) -enum AXSubroles { - /// All known subroles for reference and help text - static let all: [String: String] = [ - // Window Subroles - "AXStandardWindow": "Standard window", - "AXDialog": "Dialog window", - "AXSystemDialog": "System dialog", - "AXFloatingWindow": "Floating window", - "AXFullScreenWindow": "Full screen window", - - // Button Subroles - "AXCloseButton": "Window close button", - "AXMinimizeButton": "Window minimize button", - "AXZoomButton": "Window zoom button", - "AXFullScreenButton": "Full screen button", - "AXToolbarButton": "Toolbar button", - "AXSecureTextField": "Secure text field", - "AXSearchField": "Search field", - - // Table Subroles - "AXSortButton": "Sort button (table header)", - "AXTableRow": "Table row", - "AXOutlineRow": "Outline row", - - // Text Subroles - "AXTextAttachment": "Text attachment", - "AXTextLink": "Text link", - - // Menu Subroles - "AXMenuBarItem": "Menu bar item", - "AXApplicationDockItem": "Application dock item", - "AXDocumentDockItem": "Document dock item", - "AXFolderDockItem": "Folder dock item", - "AXMinimizedWindowDockItem": "Minimized window dock item", - "AXURLDockItem": "URL dock item", - "AXDockExtraDockItem": "Dock extra item", - "AXTrashDockItem": "Trash dock item", - "AXSeparatorDockItem": "Separator dock item", - "AXProcessSwitcherList": "Process switcher list", - - // Content Subroles - "AXContentList": "Content list", - "AXDefinitionList": "Definition list", - "AXDescriptionList": "Description list", - - // Decorator Subroles - "AXDecrementArrow": "Decrement arrow", - "AXIncrementArrow": "Increment arrow", - "AXDecrementPage": "Decrement page", - "AXIncrementPage": "Increment page", - - // Timeline Subroles - "AXTimeline": "Timeline", - "AXRatingIndicator": "Rating indicator", - - // Accessibility Subroles - "AXUnknown": "Unknown subrole", - "AXToggle": "Toggle button", - "AXSwitch": "Switch control", - ] - - /// Most commonly used subroles - static let common: [String] = [ - "AXCloseButton", "AXMinimizeButton", "AXZoomButton", - "AXFullScreenButton", "AXToolbarButton", "AXSearchField", - "AXTableRow", "AXOutlineRow", "AXTextLink", "AXToggle", "AXSwitch" - ] -} - -// MARK: - Common Accessibility Actions - -/// Standard macOS accessibility actions (AXAction values) -enum AXActions { - /// All known actions for reference and help text - static let all: [String: String] = [ - // Primary Actions - "AXPress": "Activate/click the element (buttons, links)", - "AXIncrement": "Increase value (sliders, steppers)", - "AXDecrement": "Decrease value (sliders, steppers)", - "AXConfirm": "Confirm/submit (dialogs, forms)", - "AXCancel": "Cancel operation", - "AXPick": "Pick/select item (menus, pickers)", - - // Window Actions - "AXRaise": "Bring window to front", - - // Menu Actions - "AXShowMenu": "Show context/popup menu", - - // UI Actions - "AXShowAlternateUI": "Show alternate UI", - "AXShowDefaultUI": "Show default UI", - - // Scroll Actions - "AXScrollLeftByPage": "Scroll left by page", - "AXScrollRightByPage": "Scroll right by page", - "AXScrollUpByPage": "Scroll up by page", - "AXScrollDownByPage": "Scroll down by page", - - // Deletion Actions - "AXDelete": "Delete element or content", - ] - - /// Most commonly used actions - static let common: [String] = [ - "AXPress", "AXIncrement", "AXDecrement", "AXConfirm", - "AXCancel", "AXPick", "AXRaise", "AXShowMenu" - ] -} - -// MARK: - Common Accessibility Notifications - -/// Standard macOS accessibility notifications -enum AXNotifications { - /// All known notifications for reference - static let all: [String] = [ - "AXValueChanged", - "AXUIElementDestroyed", - "AXSelectedTextChanged", - "AXSelectedChildrenChanged", - "AXFocusedUIElementChanged", - "AXFocusedWindowChanged", - "AXApplicationActivated", - "AXApplicationDeactivated", - "AXWindowCreated", - "AXWindowMoved", - "AXWindowResized", - "AXWindowMiniaturized", - "AXWindowDeminiaturized", - "AXDrawerCreated", - "AXSheetCreated", - "AXMenuOpened", - "AXMenuClosed", - "AXMenuItemSelected", - "AXTitleChanged", - "AXResized", - "AXMoved", - "AXCreated", - "AXLayoutChanged", - "AXSelectedCellsChanged", - "AXUnitsChanged", - "AXSelectedColumnsChanged", - "AXSelectedRowsChanged", - "AXRowCountChanged", - "AXRowExpanded", - "AXRowCollapsed", - ] -} - -// MARK: - Help Text Generators - -extension AXRoles { - static func helpText() -> String { - var lines: [String] = ["COMMON ROLES:"] - for role in common { - if let desc = all[role] { - let shortRole = role.replacingOccurrences(of: "AX", with: "") - lines.append(" \(shortRole.padding(toLength: 20, withPad: " ", startingAt: 0)) \(desc)") - } - } - lines.append("") - lines.append("Use --list-roles for all \(all.count) known roles") - return lines.joined(separator: "\n") - } - - static func fullHelpText() -> String { - var lines: [String] = ["ALL KNOWN ROLES:"] - for (role, desc) in all.sorted(by: { $0.key < $1.key }) { - let shortRole = role.replacingOccurrences(of: "AX", with: "") - lines.append(" \(shortRole.padding(toLength: 25, withPad: " ", startingAt: 0)) \(desc)") - } - return lines.joined(separator: "\n") - } -} - -extension AXSubroles { - static func helpText() -> String { - var lines: [String] = ["COMMON SUBROLES:"] - for subrole in common { - if let desc = all[subrole] { - let shortSubrole = subrole.replacingOccurrences(of: "AX", with: "") - lines.append(" \(shortSubrole.padding(toLength: 20, withPad: " ", startingAt: 0)) \(desc)") - } - } - lines.append("") - lines.append("Use --list-subroles for all \(all.count) known subroles") - return lines.joined(separator: "\n") - } - - static func fullHelpText() -> String { - var lines: [String] = ["ALL KNOWN SUBROLES:"] - for (subrole, desc) in all.sorted(by: { $0.key < $1.key }) { - let shortSubrole = subrole.replacingOccurrences(of: "AX", with: "") - lines.append(" \(shortSubrole.padding(toLength: 25, withPad: " ", startingAt: 0)) \(desc)") - } - return lines.joined(separator: "\n") - } -} - -extension AXActions { - static func helpText() -> String { - var lines: [String] = ["COMMON ACTIONS:"] - for action in common { - if let desc = all[action] { - let shortAction = action.replacingOccurrences(of: "AX", with: "") - lines.append(" \(shortAction.padding(toLength: 15, withPad: " ", startingAt: 0)) \(desc)") - } - } - lines.append("") - lines.append("Use --list-actions for all \(all.count) known actions") - return lines.joined(separator: "\n") - } - - static func fullHelpText() -> String { - var lines: [String] = ["ALL KNOWN ACTIONS:"] - for (action, desc) in all.sorted(by: { $0.key < $1.key }) { - let shortAction = action.replacingOccurrences(of: "AX", with: "") - lines.append(" \(shortAction.padding(toLength: 20, withPad: " ", startingAt: 0)) \(desc)") - } - return lines.joined(separator: "\n") - } -} diff --git a/Sources/axdump/Utilities/ElementFilter.swift b/Sources/axdump/Utilities/ElementFilter.swift deleted file mode 100644 index ddfbf62..0000000 --- a/Sources/axdump/Utilities/ElementFilter.swift +++ /dev/null @@ -1,229 +0,0 @@ -import Foundation -import AccessibilityControl - -// MARK: - Element Filter - -/// Filter for selecting accessibility elements based on criteria -struct ElementFilter { - /// Role pattern to match (regex supported) - var rolePattern: NSRegularExpression? - - /// Subrole pattern to match (regex supported) - var subrolePattern: NSRegularExpression? - - /// Title pattern to match (regex supported) - var titlePattern: NSRegularExpression? - - /// Identifier pattern to match (regex supported) - var identifierPattern: NSRegularExpression? - - /// Required fields that must not be nil - var requiredFields: Set - - /// Fields that must be nil - var excludedFields: Set - - /// Whether filtering is case sensitive - var caseSensitive: Bool - - init( - rolePattern: String? = nil, - subrolePattern: String? = nil, - titlePattern: String? = nil, - identifierPattern: String? = nil, - requiredFields: [String] = [], - excludedFields: [String] = [], - caseSensitive: Bool = false - ) throws { - let options: NSRegularExpression.Options = caseSensitive ? [] : [.caseInsensitive] - - if let pattern = rolePattern { - self.rolePattern = try NSRegularExpression(pattern: pattern, options: options) - } - if let pattern = subrolePattern { - self.subrolePattern = try NSRegularExpression(pattern: pattern, options: options) - } - if let pattern = titlePattern { - self.titlePattern = try NSRegularExpression(pattern: pattern, options: options) - } - if let pattern = identifierPattern { - self.identifierPattern = try NSRegularExpression(pattern: pattern, options: options) - } - - self.requiredFields = Set(requiredFields.map { normalizeFieldName($0) }) - self.excludedFields = Set(excludedFields.map { normalizeFieldName($0) }) - self.caseSensitive = caseSensitive - } - - /// Check if an element matches this filter - func matches(_ element: Accessibility.Element) -> Bool { - // Check role pattern - if let pattern = rolePattern { - guard let role: String = try? element.attribute(AXAttribute.role)() else { - return false - } - if !matchesPattern(pattern, in: role) { - return false - } - } - - // Check subrole pattern - if let pattern = subrolePattern { - guard let subrole: String = try? element.attribute(AXAttribute.subrole)() else { - return false - } - if !matchesPattern(pattern, in: subrole) { - return false - } - } - - // Check title pattern - if let pattern = titlePattern { - guard let title: String = try? element.attribute(AXAttribute.title)() else { - return false - } - if !matchesPattern(pattern, in: title) { - return false - } - } - - // Check identifier pattern - if let pattern = identifierPattern { - guard let id: String = try? element.attribute(AXAttribute.identifier)() else { - return false - } - if !matchesPattern(pattern, in: id) { - return false - } - } - - // Check required fields (must not be nil) - for field in requiredFields { - if !hasValue(element, forField: field) { - return false - } - } - - // Check excluded fields (must be nil) - for field in excludedFields { - if hasValue(element, forField: field) { - return false - } - } - - return true - } - - /// Check if element has a non-nil value for a field - private func hasValue(_ element: Accessibility.Element, forField field: String) -> Bool { - let attrName = field.hasPrefix("AX") ? field : "AX\(field)" - - switch attrName { - case "AXRole": - return (try? element.attribute(AXAttribute.role)()) != nil - case "AXSubrole": - return (try? element.attribute(AXAttribute.subrole)()) != nil - case "AXTitle": - return (try? element.attribute(AXAttribute.title)()) != nil - case "AXIdentifier": - return (try? element.attribute(AXAttribute.identifier)()) != nil - case "AXDescription": - return (try? element.attribute(AXAttribute.description)()) != nil - case "AXValue": - return (try? element.attribute(AXAttribute.value)()) != nil - case "AXHelp": - return (try? element.attribute(AXAttribute.help)()) != nil - case "AXRoleDescription": - return (try? element.attribute(AXAttribute.roleDescription)()) != nil - case "AXEnabled": - return (try? element.attribute(AXAttribute.enabled)()) != nil - case "AXFocused": - return (try? element.attribute(AXAttribute.focused)()) != nil - case "AXPosition": - return (try? element.attribute(AXAttribute.position)()) != nil - case "AXSize": - return (try? element.attribute(AXAttribute.size)()) != nil - case "AXFrame": - return (try? element.attribute(AXAttribute.frame)()) != nil - case "AXChildren": - let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = element.attribute(.init("AXChildren")) - return (try? childrenAttr.count()) ?? 0 > 0 - default: - // Try generic attribute read - if let _: Any = try? element.attribute(.init(attrName))() { - return true - } - return false - } - } - - private func matchesPattern(_ pattern: NSRegularExpression, in string: String) -> Bool { - let range = NSRange(string.startIndex..., in: string) - return pattern.firstMatch(in: string, options: [], range: range) != nil - } - - /// Check if any filtering is active - var isActive: Bool { - rolePattern != nil || - subrolePattern != nil || - titlePattern != nil || - identifierPattern != nil || - !requiredFields.isEmpty || - !excludedFields.isEmpty - } -} - -// MARK: - Field Name Normalization - -/// Normalize a field name to its canonical form -func normalizeFieldName(_ name: String) -> String { - let lowercased = name.lowercased().trimmingCharacters(in: .whitespaces) - switch lowercased { - case "role": return "AXRole" - case "subrole": return "AXSubrole" - case "title": return "AXTitle" - case "id", "identifier": return "AXIdentifier" - case "desc", "description": return "AXDescription" - case "value", "val": return "AXValue" - case "help": return "AXHelp" - case "roledesc", "roledescription", "role-description": return "AXRoleDescription" - case "enabled": return "AXEnabled" - case "focused": return "AXFocused" - case "pos", "position": return "AXPosition" - case "size": return "AXSize" - case "frame": return "AXFrame" - case "children": return "AXChildren" - case "parent": return "AXParent" - case "windows": return "AXWindows" - default: - // Assume it's already an AX name or add prefix - return name.hasPrefix("AX") ? name : "AX\(name.capitalized)" - } -} - -// MARK: - Filter Help - -extension ElementFilter { - static var helpText: String { - """ - FILTERING OPTIONS: - --role PATTERN Filter by role (regex, e.g., 'Button|Text') - --subrole PATTERN Filter by subrole (regex) - --title PATTERN Filter by title (regex) - --id PATTERN Filter by identifier (regex) - --has FIELD,... Only show elements where FIELD is not nil - --without FIELD,... Only show elements where FIELD is nil - --case-sensitive Make pattern matching case-sensitive - - FIELD NAMES for --has/--without: - role, subrole, title, identifier (or id), description (or desc), - value, help, enabled, focused, position, size, frame, children - - EXAMPLES: - axdump dump 710 --role "Button" - axdump dump 710 --role "Text.*" --has title - axdump dump 710 --has identifier --without value - axdump dump 710 --title "Save|Cancel" --case-sensitive - """ - } -} diff --git a/Sources/axdump/Utilities/ElementPrinter.swift b/Sources/axdump/Utilities/ElementPrinter.swift deleted file mode 100644 index 9b5d2e2..0000000 --- a/Sources/axdump/Utilities/ElementPrinter.swift +++ /dev/null @@ -1,412 +0,0 @@ -import Foundation -import AccessibilityControl -import CoreGraphics - -// MARK: - Element Printer - -/// Formats accessibility elements for display -struct ElementPrinter { - let fields: AttributeFields - let verbosity: Int - let useColor: Bool - let maxLength: Int - - init( - fields: AttributeFields = .standard, - verbosity: Int = 1, - useColor: Bool = true, - maxLength: Int = 0 - ) { - self.fields = fields - self.verbosity = verbosity - self.useColor = useColor - self.maxLength = maxLength - } - - // MARK: - Single Element Formatting - - /// Format an element as a single line - func formatElement(_ element: Accessibility.Element, indent: Int = 0) -> String { - let prefix = String(repeating: " ", count: indent) - var info: [String] = [] - - if fields.contains(.role) { - if let role = try? element.attribute(AXAttribute.role)() { - info.append("role=\(role)") - } - } - - if fields.contains(.subrole) { - if let subrole = try? element.attribute(AXAttribute.subrole)() { - info.append("subrole=\(subrole)") - } - } - - if fields.contains(.roleDescription) { - if let roleDesc = try? element.attribute(AXAttribute.roleDescription)() { - info.append("roleDesc=\"\(roleDesc)\"") - } - } - - if fields.contains(.title) { - if let title = try? element.attribute(AXAttribute.title)() { - let truncated = truncate(title, to: 50) - info.append("title=\"\(truncated)\"") - } - } - - if fields.contains(.identifier) { - if let id = try? element.attribute(AXAttribute.identifier)() { - info.append("id=\"\(id)\"") - } - } - - if fields.contains(.description) { - if let desc = try? element.attribute(AXAttribute.description)() { - let truncated = truncate(desc, to: 50) - info.append("desc=\"\(truncated)\"") - } - } - - if fields.contains(.value) { - if let value = try? element.attribute(AXAttribute.value)() { - let strValue = String(describing: value) - let truncated = truncate(strValue, to: 50) - info.append("value=\"\(truncated)\"") - } - } - - if fields.contains(.enabled) { - if let enabled = try? element.attribute(AXAttribute.enabled)() { - info.append("enabled=\(enabled)") - } - } - - if fields.contains(.focused) { - if let focused = try? element.attribute(AXAttribute.focused)() { - info.append("focused=\(focused)") - } - } - - if fields.contains(.position) { - if let pos = try? element.attribute(AXAttribute.position)() { - info.append("pos=(\(Int(pos.x)),\(Int(pos.y)))") - } - } - - if fields.contains(.size) { - if let size = try? element.attribute(AXAttribute.size)() { - info.append("size=(\(Int(size.width))x\(Int(size.height)))") - } - } - - if fields.contains(.frame) { - if let frame = try? element.attribute(AXAttribute.frame)() { - info.append("frame=(\(Int(frame.origin.x)),\(Int(frame.origin.y)) \(Int(frame.width))x\(Int(frame.height)))") - } - } - - if fields.contains(.help) { - if let help = try? element.attribute(AXAttribute.help)() { - let truncated = truncate(help, to: 50) - info.append("help=\"\(truncated)\"") - } - } - - if fields.contains(.childCount) { - let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = element.attribute(.init("AXChildren")) - if let count = try? childrenAttr.count() { - info.append("children=\(count)") - } - } - - let infoStr = info.isEmpty ? "(no attributes)" : info.joined(separator: " ") - var result = "\(prefix)\(infoStr)" - - if verbosity >= 2 || fields.contains(.actions) { - if let actions = try? element.supportedActions(), !actions.isEmpty { - let actionNames = actions.map { $0.name.value.replacingOccurrences(of: "AX", with: "") } - result += "\n\(prefix) actions: \(actionNames.joined(separator: ", "))" - } - } - - return result - } - - /// Format element for JSON output - func formatElementForJSON(_ element: Accessibility.Element) -> [String: Any] { - var dict: [String: Any] = [:] - - if let role: String = try? element.attribute(AXAttribute.role)() { - dict["role"] = role - } - if let subrole: String = try? element.attribute(AXAttribute.subrole)() { - dict["subrole"] = subrole - } - if let title: String = try? element.attribute(AXAttribute.title)() { - dict["title"] = title - } - if let id: String = try? element.attribute(AXAttribute.identifier)() { - dict["identifier"] = id - } - if let desc: String = try? element.attribute(AXAttribute.description)() { - dict["description"] = desc - } - if let value = try? element.attribute(AXAttribute.value)() { - dict["value"] = formatValueForJSON(value) - } - if let enabled: Bool = try? element.attribute(AXAttribute.enabled)() { - dict["enabled"] = enabled - } - if let focused: Bool = try? element.attribute(AXAttribute.focused)() { - dict["focused"] = focused - } - if let frame = try? element.attribute(AXAttribute.frame)() { - dict["frame"] = [ - "x": frame.origin.x, - "y": frame.origin.y, - "width": frame.width, - "height": frame.height - ] - } - - return dict - } - - // MARK: - Value Formatting - - /// Format a value for display - func formatValue(_ value: Any) -> String { - switch value { - case let element as Accessibility.Element: - var parts: [String] = ["") - return parts.joined(separator: " ") - - case let elements as [Accessibility.Element]: - var lines: [String] = ["[\(elements.count) elements]"] - for (index, element) in elements.enumerated() { - var parts: [String] = [" [\(index)]"] - if let role: String = try? element.attribute(AXAttribute.role)() { - parts.append("role=\(role)") - } - if let title: String = try? element.attribute(AXAttribute.title)() { - parts.append("title=\"\(title)\"") - } - if let id: String = try? element.attribute(AXAttribute.identifier)() { - parts.append("id=\"\(id)\"") - } - lines.append(parts.joined(separator: " ")) - } - return lines.joined(separator: "\n") - - case let structValue as Accessibility.Struct: - switch structValue { - case .point(let point): - return "(\(point.x), \(point.y))" - case .size(let size): - return "\(size.width) x \(size.height)" - case .rect(let rect): - return "origin=(\(rect.origin.x), \(rect.origin.y)) size=(\(rect.width) x \(rect.height))" - case .range(let range): - return "\(range.lowerBound)..<\(range.upperBound)" - case .error(let error): - return "Error: \(error)" - } - - case let point as CGPoint: - return "(\(point.x), \(point.y))" - - case let size as CGSize: - return "\(size.width) x \(size.height)" - - case let rect as CGRect: - return "origin=(\(rect.origin.x), \(rect.origin.y)) size=(\(rect.width) x \(rect.height))" - - case let array as [Any]: - return array.map { formatValue($0) }.joined(separator: ", ") - - case let dict as [String: Any]: - return dict.map { "\($0.key): \(formatValue($0.value))" }.joined(separator: ", ") - - default: - let str = String(describing: value) - return maxLength > 0 && str.count > maxLength ? String(str.prefix(maxLength)) + "..." : str - } - } - - /// Format a value for JSON serialization - func formatValueForJSON(_ value: Any) -> Any { - switch value { - case let element as Accessibility.Element: - return formatElementForJSON(element) - - case let elements as [Accessibility.Element]: - return elements.map { formatElementForJSON($0) } - - case let structValue as Accessibility.Struct: - switch structValue { - case .point(let point): - return ["x": point.x, "y": point.y] - case .size(let size): - return ["width": size.width, "height": size.height] - case .rect(let rect): - return ["x": rect.origin.x, "y": rect.origin.y, "width": rect.width, "height": rect.height] - case .range(let range): - return ["start": range.lowerBound, "end": range.upperBound] - case .error(let error): - return ["error": String(describing: error)] - } - - case let point as CGPoint: - return ["x": point.x, "y": point.y] - - case let size as CGSize: - return ["width": size.width, "height": size.height] - - case let rect as CGRect: - return ["x": rect.origin.x, "y": rect.origin.y, "width": rect.width, "height": rect.height] - - case let array as [Any]: - return array.map { formatValueForJSON($0) } - - case let dict as [String: Any]: - return dict.mapValues { formatValueForJSON($0) } - - case let str as String: - return str - - case let num as NSNumber: - return num - - case let bool as Bool: - return bool - - default: - return String(describing: value) - } - } - - // MARK: - Helpers - - private func truncate(_ string: String, to maxLength: Int) -> String { - let limit = self.maxLength > 0 ? min(maxLength, self.maxLength) : maxLength - return string.count > limit ? String(string.prefix(limit)) + "..." : string - } -} - -// MARK: - Element Path Computation - -/// Compute the path from the application root to a given element -func computeElementPath(_ element: Accessibility.Element, appElement: Accessibility.Element) -> (path: String, chain: String) { - var ancestors: [Accessibility.Element] = [] - var current = element - - while true { - ancestors.append(current) - guard let parent: Accessibility.Element = try? current.attribute(.init("AXParent"))() else { - break - } - if parent == appElement { - break - } - current = parent - } - - ancestors.reverse() - - var indices: [Int] = [] - var chainParts: [String] = [] - - var parentForIndex = appElement - for ancestor in ancestors { - let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = parentForIndex.attribute(.init("AXChildren")) - if let children: [Accessibility.Element] = try? childrenAttr() { - if let index = children.firstIndex(of: ancestor) { - indices.append(index) - } else { - indices.append(-1) - } - } else { - indices.append(-1) - } - - var desc = "" - if let role: String = try? ancestor.attribute(AXAttribute.role)() { - desc = role.replacingOccurrences(of: "AX", with: "") - } - if let id: String = try? ancestor.attribute(AXAttribute.identifier)() { - desc += "[\(id)]" - } else if let title: String = try? ancestor.attribute(AXAttribute.title)() { - let truncated = title.count > 20 ? String(title.prefix(20)) + "..." : title - desc += "[\"\(truncated)\"]" - } - if desc.isEmpty { - desc = "?" - } - chainParts.append(desc) - - parentForIndex = ancestor - } - - let pathString = indices.map { $0 >= 0 ? String($0) : "?" }.joined(separator: ".") - let chainString = chainParts.joined(separator: " > ") - - return (pathString, chainString) -} - -// MARK: - Element Navigation - -/// Navigate to an element via dot-separated child indices -func navigateToPath(from element: Accessibility.Element, path: String) throws -> Accessibility.Element { - var current = element - let indices = path.split(separator: ".").compactMap { Int($0) } - - for (step, index) in indices.enumerated() { - let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = current.attribute(.init("AXChildren")) - guard let children: [Accessibility.Element] = try? childrenAttr() else { - throw NavigationError.noChildren(step: step) - } - guard index >= 0 && index < children.count else { - throw NavigationError.indexOutOfRange(index: index, step: step, count: children.count) - } - current = children[index] - } - - return current -} - -/// Navigate to a single child by index -func navigateToChild(from element: Accessibility.Element, index: Int) throws -> Accessibility.Element { - let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = element.attribute(.init("AXChildren")) - guard let children: [Accessibility.Element] = try? childrenAttr() else { - throw NavigationError.noChildren(step: 0) - } - guard index >= 0 && index < children.count else { - throw NavigationError.indexOutOfRange(index: index, step: 0, count: children.count) - } - return children[index] -} - -enum NavigationError: Error, CustomStringConvertible { - case noChildren(step: Int) - case indexOutOfRange(index: Int, step: Int, count: Int) - - var description: String { - switch self { - case .noChildren(let step): - return "Element at step \(step) has no children" - case .indexOutOfRange(let index, let step, let count): - return "Child index \(index) at step \(step) out of range (0..<\(count))" - } - } -} diff --git a/Sources/axdump/Utilities/TreePrinter.swift b/Sources/axdump/Utilities/TreePrinter.swift deleted file mode 100644 index 76fbed5..0000000 --- a/Sources/axdump/Utilities/TreePrinter.swift +++ /dev/null @@ -1,274 +0,0 @@ -import Foundation -import AccessibilityControl - -// MARK: - ASCII Tree Printer - -/// Prints accessibility elements in an ASCII tree format -struct TreePrinter { - let fields: AttributeFields - let filter: ElementFilter? - let maxDepth: Int - let showActions: Bool - let useColor: Bool - - // Tree drawing characters - private let branch = "├── " - private let lastBranch = "└── " - private let vertical = "│ " - private let space = " " - - init( - fields: AttributeFields = .standard, - filter: ElementFilter? = nil, - maxDepth: Int = 3, - showActions: Bool = false, - useColor: Bool = true - ) { - self.fields = fields - self.filter = filter - self.maxDepth = maxDepth - self.showActions = showActions - self.useColor = useColor - } - - // MARK: - Public API - - /// Print the tree starting from a root element - func printTree(_ root: Accessibility.Element) { - printNode(root, prefix: "", isLast: true, depth: 0) - } - - /// Print the tree and return as a string - func treeString(_ root: Accessibility.Element) -> String { - var output = "" - printNode(root, prefix: "", isLast: true, depth: 0, output: &output) - return output - } - - // MARK: - Private Implementation - - private func printNode( - _ element: Accessibility.Element, - prefix: String, - isLast: Bool, - depth: Int, - output: inout String - ) { - // Check filter - let passesFilter = filter?.matches(element) ?? true - - // Get children for recursion (needed even if this node is filtered) - let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = element.attribute(.init("AXChildren")) - let children: [Accessibility.Element] = (try? childrenAttr()) ?? [] - - // Get children that pass filter (or all if no filter) - let matchingChildren: [Accessibility.Element] - if let filter = filter { - matchingChildren = children.filter { childPassesFilterOrHasMatchingDescendant($0, filter: filter, depth: depth + 1) } - } else { - matchingChildren = children - } - - // Only print this node if it passes filter - if passesFilter { - let nodeText = formatElement(element) - let connector = depth == 0 ? "" : (isLast ? lastBranch : branch) - output += "\(prefix)\(connector)\(nodeText)\n" - - // Print actions if requested - if showActions || fields.contains(.actions) { - if let actions = try? element.supportedActions(), !actions.isEmpty { - let actionPrefix = depth == 0 ? "" : (isLast ? space : vertical) - let actionNames = actions.map { $0.name.value.replacingOccurrences(of: "AX", with: "") } - let actionsStr = Color.dim.wrap("actions: ", enabled: useColor) + - Color.yellow.wrap(actionNames.joined(separator: ", "), enabled: useColor) - output += "\(prefix)\(actionPrefix)\(space)\(actionsStr)\n" - } - } - } - - // Recurse into children - guard depth < maxDepth else { return } - - let childPrefix: String - if depth == 0 { - childPrefix = "" - } else if passesFilter { - childPrefix = prefix + (isLast ? space : vertical) - } else { - childPrefix = prefix - } - - for (index, child) in matchingChildren.enumerated() { - let isLastChild = index == matchingChildren.count - 1 - printNode(child, prefix: childPrefix, isLast: isLastChild, depth: depth + 1, output: &output) - } - } - - private func printNode( - _ element: Accessibility.Element, - prefix: String, - isLast: Bool, - depth: Int - ) { - var output = "" - printNode(element, prefix: prefix, isLast: isLast, depth: depth, output: &output) - print(output, terminator: "") - } - - /// Check if an element or any of its descendants passes the filter - private func childPassesFilterOrHasMatchingDescendant(_ element: Accessibility.Element, filter: ElementFilter, depth: Int) -> Bool { - if filter.matches(element) { - return true - } - - guard depth < maxDepth else { return false } - - let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = element.attribute(.init("AXChildren")) - guard let children: [Accessibility.Element] = try? childrenAttr() else { return false } - - for child in children { - if childPassesFilterOrHasMatchingDescendant(child, filter: filter, depth: depth + 1) { - return true - } - } - - return false - } - - // MARK: - Element Formatting - - private func formatElement(_ element: Accessibility.Element) -> String { - var parts: [String] = [] - - // Role (always first, with color) - if fields.contains(.role) { - if let role = try? element.attribute(AXAttribute.role)() { - let shortRole = role.replacingOccurrences(of: "AX", with: "") - parts.append(Color.cyan.wrap(shortRole, enabled: useColor)) - } - } - - // Subrole - if fields.contains(.subrole) { - if let subrole = try? element.attribute(AXAttribute.subrole)() { - let shortSubrole = subrole.replacingOccurrences(of: "AX", with: "") - parts.append(Color.blue.wrap("[\(shortSubrole)]", enabled: useColor)) - } - } - - // Title - if fields.contains(.title) { - if let title = try? element.attribute(AXAttribute.title)() { - let truncated = title.count > 40 ? String(title.prefix(40)) + "..." : title - parts.append(Color.yellow.wrap("\"\(truncated)\"", enabled: useColor)) - } - } - - // Identifier - if fields.contains(.identifier) { - if let id = try? element.attribute(AXAttribute.identifier)() { - parts.append(Color.green.wrap("#\(id)", enabled: useColor)) - } - } - - // Role description - if fields.contains(.roleDescription) { - if let roleDesc = try? element.attribute(AXAttribute.roleDescription)() { - parts.append(Color.dim.wrap("(\(roleDesc))", enabled: useColor)) - } - } - - // Value - if fields.contains(.value) { - if let value = try? element.attribute(AXAttribute.value)() { - let strValue = String(describing: value) - let truncated = strValue.count > 30 ? String(strValue.prefix(30)) + "..." : strValue - parts.append(Color.magenta.wrap("=\(truncated)", enabled: useColor)) - } - } - - // Description - if fields.contains(.description) { - if let desc = try? element.attribute(AXAttribute.description)() { - let truncated = desc.count > 30 ? String(desc.prefix(30)) + "..." : desc - parts.append(Color.dim.wrap("desc:\"\(truncated)\"", enabled: useColor)) - } - } - - // Enabled/Focused - if fields.contains(.enabled) { - if let enabled = try? element.attribute(AXAttribute.enabled)(), !enabled { - parts.append(Color.red.wrap("[disabled]", enabled: useColor)) - } - } - - if fields.contains(.focused) { - if let focused = try? element.attribute(AXAttribute.focused)(), focused { - parts.append(Color.brightGreen.wrap("[focused]", enabled: useColor)) - } - } - - // Position/Size/Frame - if fields.contains(.frame) { - if let frame = try? element.attribute(AXAttribute.frame)() { - parts.append(Color.dim.wrap("[\(Int(frame.origin.x)),\(Int(frame.origin.y)) \(Int(frame.width))x\(Int(frame.height))]", enabled: useColor)) - } - } else { - if fields.contains(.position) { - if let pos = try? element.attribute(AXAttribute.position)() { - parts.append(Color.dim.wrap("@(\(Int(pos.x)),\(Int(pos.y)))", enabled: useColor)) - } - } - if fields.contains(.size) { - if let size = try? element.attribute(AXAttribute.size)() { - parts.append(Color.dim.wrap("\(Int(size.width))x\(Int(size.height))", enabled: useColor)) - } - } - } - - // Help - if fields.contains(.help) { - if let help = try? element.attribute(AXAttribute.help)() { - let truncated = help.count > 30 ? String(help.prefix(30)) + "..." : help - parts.append(Color.dim.wrap("help:\"\(truncated)\"", enabled: useColor)) - } - } - - // Child count - if fields.contains(.childCount) { - let childrenAttr: Accessibility.Attribute<[Accessibility.Element]> = element.attribute(.init("AXChildren")) - if let count = try? childrenAttr.count(), count > 0 { - parts.append(Color.dim.wrap("(\(count) children)", enabled: useColor)) - } - } - - return parts.isEmpty ? Color.dim.wrap("(empty)", enabled: useColor) : parts.joined(separator: " ") - } -} - -// MARK: - ANSI Color Support - -enum Color: String { - case reset = "\u{001B}[0m" - case dim = "\u{001B}[2m" - case bold = "\u{001B}[1m" - case red = "\u{001B}[31m" - case green = "\u{001B}[32m" - case yellow = "\u{001B}[33m" - case blue = "\u{001B}[34m" - case magenta = "\u{001B}[35m" - case cyan = "\u{001B}[36m" - case white = "\u{001B}[37m" - case brightRed = "\u{001B}[91m" - case brightGreen = "\u{001B}[92m" - case brightYellow = "\u{001B}[93m" - case brightBlue = "\u{001B}[94m" - case brightMagenta = "\u{001B}[95m" - case brightCyan = "\u{001B}[96m" - - func wrap(_ text: String, enabled: Bool) -> String { - guard enabled else { return text } - return "\(rawValue)\(text)\(Color.reset.rawValue)" - } -} From e7bd7ab16b714a1166e5bfbc99a991ea2111bbc6 Mon Sep 17 00:00:00 2001 From: Purav Manot Date: Fri, 30 Jan 2026 03:40:36 +0530 Subject: [PATCH 11/14] cleanup remove `axdump` target from Package.swift --- Package.resolved | 25 ++++++++++++------------- Package.swift | 15 ++------------- 2 files changed, 14 insertions(+), 26 deletions(-) diff --git a/Package.resolved b/Package.resolved index e840c32..7e252d4 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,16 +1,15 @@ { - "object": { - "pins": [ - { - "package": "swift-argument-parser", - "repositoryURL": "https://github.com/apple/swift-argument-parser.git", - "state": { - "branch": null, - "revision": "c5d11a805e765f52ba34ec7284bd4fcd6ba68615", - "version": "1.7.0" - } + "originHash" : "532ea61d85af588cbc821303565bb3c649877a17d13a86df1b0238f71cd1480d", + "pins" : [ + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser.git", + "state" : { + "revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615", + "version" : "1.7.0" } - ] - }, - "version": 1 + } + ], + "version" : 3 } diff --git a/Package.swift b/Package.swift index a6d3fa0..f888c10 100644 --- a/Package.swift +++ b/Package.swift @@ -9,11 +9,7 @@ let package = Package( .library( name: "BetterSwiftAX", targets: ["AccessibilityControl"] - ), - .executable( - name: "axdump", - targets: ["axdump"] - ), + ) ], dependencies: [ .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.2.0"), @@ -32,13 +28,6 @@ let package = Package( .target( name: "AccessibilityControl", dependencies: ["CAccessibilityControl", "WindowControl"] - ), - .executableTarget( - name: "axdump", - dependencies: [ - "AccessibilityControl", - .product(name: "ArgumentParser", package: "swift-argument-parser"), - ] - ), + ) ] ) From ca8781fade60db1c3259dce464638e9fd310ded0 Mon Sep 17 00:00:00 2001 From: Purav Manot Date: Thu, 5 Feb 2026 16:30:18 +0530 Subject: [PATCH 12/14] change minimum deployment version to 10.15 --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index f888c10..fa57897 100644 --- a/Package.swift +++ b/Package.swift @@ -4,7 +4,7 @@ import PackageDescription let package = Package( name: "BetterSwiftAX", - platforms: [.macOS(.v13)], + platforms: [.macOS(.v10_15)], products: [ .library( name: "BetterSwiftAX", From e8da38e4236fc4adcbb1a2930c0d4758479691d0 Mon Sep 17 00:00:00 2001 From: Purav Manot Date: Mon, 9 Feb 2026 21:14:56 +0530 Subject: [PATCH 13/14] add utilities and helpers + refactor --- .../Accessibility+Role.swift | 56 ++++ .../Accessibility+Subrole.swift | 34 +++ .../Element+Utilities.swift | 92 +++++++ .../Element+XMLDump.swift | 251 ++++++++++++++++++ .../AccessibilityControl/Names+Standard.swift | 79 ++++++ .../Notification+Standard.swift | 14 + 6 files changed, 526 insertions(+) create mode 100644 Sources/AccessibilityControl/Accessibility+Role.swift create mode 100644 Sources/AccessibilityControl/Accessibility+Subrole.swift create mode 100644 Sources/AccessibilityControl/Element+Utilities.swift create mode 100644 Sources/AccessibilityControl/Element+XMLDump.swift create mode 100644 Sources/AccessibilityControl/Names+Standard.swift create mode 100644 Sources/AccessibilityControl/Notification+Standard.swift diff --git a/Sources/AccessibilityControl/Accessibility+Role.swift b/Sources/AccessibilityControl/Accessibility+Role.swift new file mode 100644 index 0000000..6324efd --- /dev/null +++ b/Sources/AccessibilityControl/Accessibility+Role.swift @@ -0,0 +1,56 @@ +import ApplicationServices + +extension Accessibility { + // https://developer.apple.com/documentation/applicationservices/carbon_accessibility/roles + public enum Role { + public static let application = kAXApplicationRole + public static let systemWide = kAXSystemWideRole + public static let window = kAXWindowRole + public static let sheet = kAXSheetRole + public static let drawer = kAXDrawerRole + public static let growArea = kAXGrowAreaRole + public static let image = kAXImageRole + public static let unknown = kAXUnknownRole + public static let button = kAXButtonRole + public static let radioButton = kAXRadioButtonRole + public static let checkBox = kAXCheckBoxRole + public static let popUpButton = kAXPopUpButtonRole + public static let menuButton = kAXMenuButtonRole + public static let tabGroup = kAXTabGroupRole + public static let table = kAXTableRole + public static let column = kAXColumnRole + public static let row = kAXRowRole + public static let outline = kAXOutlineRole + public static let browser = kAXBrowserRole + public static let scrollArea = kAXScrollAreaRole + public static let scrollBar = kAXScrollBarRole + public static let radioGroup = kAXRadioGroupRole + public static let list = kAXListRole + public static let group = kAXGroupRole + public static let valueIndicator = kAXValueIndicatorRole + public static let comboBox = kAXComboBoxRole + public static let slider = kAXSliderRole + public static let incrementor = kAXIncrementorRole + public static let busyIndicator = kAXBusyIndicatorRole + public static let progressIndicator = kAXProgressIndicatorRole + public static let relevanceIndicator = kAXRelevanceIndicatorRole + public static let toolbar = kAXToolbarRole + public static let disclosureTriangle = kAXDisclosureTriangleRole + public static let textField = kAXTextFieldRole + public static let textArea = kAXTextAreaRole + public static let staticText = kAXStaticTextRole + public static let menuBar = kAXMenuBarRole + public static let menuBarItem = kAXMenuBarItemRole + public static let menu = kAXMenuRole + public static let menuItem = kAXMenuItemRole + public static let splitGroup = kAXSplitGroupRole + public static let splitter = kAXSplitterRole + public static let colorWell = kAXColorWellRole + public static let timeField = kAXTimeFieldRole + public static let dateField = kAXDateFieldRole + public static let helpTag = kAXHelpTagRole + public static let matte = kAXMatteRole + public static let dockItem = kAXDockItemRole + public static let cell = kAXCellRole + } +} diff --git a/Sources/AccessibilityControl/Accessibility+Subrole.swift b/Sources/AccessibilityControl/Accessibility+Subrole.swift new file mode 100644 index 0000000..9770cbb --- /dev/null +++ b/Sources/AccessibilityControl/Accessibility+Subrole.swift @@ -0,0 +1,34 @@ +import ApplicationServices + +extension Accessibility { + public enum Subrole { + public static let `switch` = kAXSwitchSubrole + public static let closeButton = kAXCloseButtonSubrole + public static let minimizeButton = kAXMinimizeButtonSubrole + public static let zoomButton = kAXZoomButtonSubrole + public static let toolbarButton = kAXToolbarButtonSubrole + public static let secureTextField = kAXSecureTextFieldSubrole + public static let tableRow = kAXTableRowSubrole + public static let outlineRow = kAXOutlineRowSubrole + public static let unknown = kAXUnknownSubrole + public static let standardWindow = kAXStandardWindowSubrole + public static let dialog = kAXDialogSubrole + public static let systemDialog = kAXSystemDialogSubrole + public static let floatingWindow = kAXFloatingWindowSubrole + public static let systemFloatingWindow = kAXSystemFloatingWindowSubrole + public static let incrementArrow = kAXIncrementArrowSubrole + public static let decrementArrow = kAXDecrementArrowSubrole + public static let incrementPage = kAXIncrementPageSubrole + public static let decrementPage = kAXDecrementPageSubrole + public static let sortButton = kAXSortButtonSubrole + public static let searchField = kAXSearchFieldSubrole + public static let applicationDockItem = kAXApplicationDockItemSubrole + public static let documentDockItem = kAXDocumentDockItemSubrole + public static let folderDockItem = kAXFolderDockItemSubrole + public static let minimizedWindowDockItem = kAXMinimizedWindowDockItemSubrole + public static let urlDockItem = kAXURLDockItemSubrole + public static let dockExtraDockItem = kAXDockExtraDockItemSubrole + public static let trashDockItem = kAXTrashDockItemSubrole + public static let processSwitcherList = kAXProcessSwitcherListSubrole + } +} diff --git a/Sources/AccessibilityControl/Element+Utilities.swift b/Sources/AccessibilityControl/Element+Utilities.swift new file mode 100644 index 0000000..984632b --- /dev/null +++ b/Sources/AccessibilityControl/Element+Utilities.swift @@ -0,0 +1,92 @@ +import CoreFoundation +import os.log + +private let log = OSLog(subsystem: "com.betterswiftax", category: "accessibility") + +public extension Accessibility.Element { + var isValid: Bool { + (try? pid()) != nil + } + + var isFrameValid: Bool { + (try? self.frame()) != nil + } + + var isInViewport: Bool { + (try? self.frame()) != CGRect.null + } + + // - breadth-first, seems faster than dfs + // - default max complexity to 1,800; if i dump the complexity of the Messages app right now i get ~360. x10 that, should be plenty + // - we can't turn `AXUIElement`s into e.g. `ObjectIdentifier`s and use that to track a set of seen elements and avoid cycles because + // the objects aren't pooled; any given instance of `AXUIElement` in memory is "transient" and another may take its place + func recursiveChildren(maxTraversalComplexity: Int = 3_600) -> AnySequence { + // incremented for every element with children that we discover; not "depth" since it's a running tally + var traversalComplexity = 0 + + return AnySequence(sequence(state: [self] as [Accessibility.Element]) { queue -> Accessibility.Element? in + guard traversalComplexity < maxTraversalComplexity else { + os_log(.error, log: log, "HIT RECURSIVE TRAVERSAL COMPLEXITY LIMIT (%d > %d, queue count: %d), terminating early", traversalComplexity, maxTraversalComplexity, queue.count) + return nil + } + + guard !queue.isEmpty else { + // queue is empty, we're done + return nil + } + + let elt = queue.removeFirst() + + if let children = try? elt.children() { + defer { traversalComplexity += 1 } + queue.append(contentsOf: children) + } + return elt + }) + } + + func recursiveSelectedChildren() -> AnySequence { + AnySequence(sequence(state: [self]) { queue -> Accessibility.Element? in + guard !queue.isEmpty else { return nil } + let elt = queue.removeFirst() + if let selectedChildren = try? elt.selectedChildren() { + queue.append(contentsOf: selectedChildren) + } + return elt + }) + } + + func recursivelyFindChild(withID id: String) -> Accessibility.Element? { + recursiveChildren().lazy.first { + (try? $0.identifier()) == id + } + } + + func setFrame(_ frame: CGRect) throws { + DispatchQueue.concurrentPerform(iterations: 2) { i in + switch i { + case 0: + try? self.position(assign: frame.origin) + case 1: + try? self.size(assign: frame.size) + default: + break + } + } + } + + func closeWindow() throws { + guard let closeButton = try? self.windowCloseButton() else { + throw AccessibilityError(.failure) + } + try closeButton.press() + } +} + +public extension Accessibility.Element { + func firstChild(withRole role: KeyPath) -> Accessibility.Element? { + try? self.children().first { child in + (try? child.role()) == Accessibility.Role.self[keyPath: role] + } + } +} diff --git a/Sources/AccessibilityControl/Element+XMLDump.swift b/Sources/AccessibilityControl/Element+XMLDump.swift new file mode 100644 index 0000000..35379b8 --- /dev/null +++ b/Sources/AccessibilityControl/Element+XMLDump.swift @@ -0,0 +1,251 @@ +import ApplicationServices +import Foundation + +private func printAttribute(to output: inout some TextOutputStream, name: String, value: Any?, omitTrailingSpace: Bool = false) { + guard let value else { return } + let valueEscaped = if let struc = Accessibility.Struct(erased: value as AnyObject) { + // this looks _much nicer_ compared to the default description + "\(struc)".xmlEscaped + } else { + "\(value)".xmlEscaped + } + print("\(name)=\(valueEscaped.quoted)", terminator: omitTrailingSpace ? "" : " ", to: &output) +} + +public struct XMLDumper { + public static let defaultExcludedAttributes: Set = [ + // children are printed specially, so we don't need to print them out as attributes + "AXChildren", "AXChildrenInNavigationOrder", + + // avoid redundantly printing parent elements, which can quickly bloat the result + "AXTopLevelUIElement", "AXMenuItemPrimaryUIElement", "AXParent", "AXWindow", + ] + + static var attributesLikelyToContainPII: Set { + [ + kAXHelpAttribute, + kAXDescriptionAttribute, + kAXTitleAttribute, + + // text entry area values + kAXValueAttribute, + + kAXLabelValueAttribute, + kAXSelectedTextAttribute, + kAXPlaceholderValueAttribute, + kAXFilenameAttribute, + kAXDocumentAttribute, + ] + } + + public var maxDepth: Int? = nil + public var indentation = " " + public var excludedRoles: Set = [] + public var excludedAttributes: Set = XMLDumper.defaultExcludedAttributes + public var intendingToExcludePII = false + public var includeActions = true + public var includeSections = true + public var shallow = false + + public init( + maxDepth: Int? = nil, + indentation: String = " ", + excludedRoles: Set = [], + excludedAttributes: Set = XMLDumper.defaultExcludedAttributes, + intendingToExcludePII: Bool = false, + includeActions: Bool = true, + includeSections: Bool = true, + shallow: Bool = false + ) { + self.maxDepth = maxDepth + self.indentation = indentation + self.excludedRoles = excludedRoles + self.excludedAttributes = excludedAttributes + self.intendingToExcludePII = intendingToExcludePII + self.includeActions = includeActions + self.includeSections = includeSections + self.shallow = shallow + } + + public func dump( + _ element: Accessibility.Element, + to output: inout some TextOutputStream, + depth: Int = 0, + indent: Int = 0, + shallow: Bool? = nil, + preamble: String? = nil, + ) throws { + let shallow = shallow ?? self.shallow + let whitespace = String(repeating: indentation, count: indent) + + if let maxDepth { + guard depth < maxDepth else { + print(whitespace + "⚠️ reached max depth (\(depth) >= \(maxDepth))".asComment, to: &output) + return + } + } + + let role: String + do { + role = try element.role() + } catch { + print(whitespace + "⚠️ couldn't obtain role, skipping \(element): \(String(describing: error))".asComment, to: &output) + return + } + + guard !excludedRoles.contains(role) else { + return + } + + let injectedComment = if let preamble { preamble.asComment + " " } else { "" } + print("\(whitespace)\(injectedComment)<\(role)", terminator: " ", to: &output) + + // keep track of attributes that contain ui elements (as opposed to string/rect/etc.), so we can emit them as child XML elements instead of XML attributes + var attributesPointingToElements = [String: [Accessibility.Element]]() + + if let supportedAttributes = try? element.supportedAttributes() { + let attributes = Dictionary( + supportedAttributes + .filter { !excludedAttributes.contains($0.name.value) } + .map { attribute in (attribute.name.value, try? attribute()) }, + uniquingKeysWith: { first, second in second }, + ) + + let lastNonNilIndex = Array(attributes.values).lastIndex(where: { $0 != nil }) + + for (index, (name, value)) in attributes.sorted(by: { $0.key < $1.key }).enumerated() { + if let array = value as? [Any] { + guard let first = array.first, CFGetTypeID(first as CFTypeRef) == AXUIElementGetTypeID() else { + continue + } + // attribute value is of an array of ui elements + attributesPointingToElements[name, default: []].append(contentsOf: array.map { + Accessibility.Element(raw: $0 as! AXUIElement) + }) + continue + } else if let element = Accessibility.Element(erased: value as CFTypeRef) { + // attribute value is of a singular ui element + attributesPointingToElements[name, default: []].append(element) + continue + } + + printAttribute(to: &output, name: name, value: value, omitTrailingSpace: index == lastNonNilIndex) + } + } + + // closing angle bracket of the opening tag + print("> \(String(describing: element.raw).asComment)", to: &output) + let whitespace2 = String(repeating: indentation, count: indent + 1) + + if !shallow { + for (attributeContainingElementsName, containedElements) in attributesPointingToElements.sorted(by: { $0.key < $1.key }) { + print(whitespace2 + "attribute containing elements".asComment + " <\(attributeContainingElementsName)>", to: &output) + for element in containedElements { + try dump(element, to: &output, depth: depth + 1, indent: indent + 2, shallow: true) + } + print("\(whitespace2)", to: &output) + } + } + + defer { print("\(whitespace)", to: &output) } + + if includeActions, let actions = try? element.supportedActions(), !actions.isEmpty { + for (index, action) in actions.sorted(by: { $0.name.value < $1.name.value }).enumerated() { + print(whitespace2 + "actions[\(index)]".asComment + " + // Target:0x0 + // Selector:(null)" + // + // (yes, including the newlines.) skip these if we are intending to exclude PII + if (intendingToExcludePII && actionName.hasPrefix("AX")) || !intendingToExcludePII { + printAttribute(to: &output, name: "name", value: actionName) + } + + if !excludedAttributes.contains(kAXDescriptionAttribute) { + printAttribute(to: &output, name: "description", value: action.description, omitTrailingSpace: true) + } + print(">", to: &output) + } + } + + guard !shallow else { + // used to avoid following cycles endlessly, since sections can point back up the tree etc. + print(whitespace2 + "...".asComment, to: &output) + return + } + + if includeSections, let sections = try? element.sections() as [[String: Any]] { + for (index, section) in sections.enumerated() { + print(whitespace2 + "[\(index)]".asComment + " ", to: &output) + if let element = Accessibility.Element(axRaw: object) { + try dump(element, to: &output, depth: depth + 1, indent: indent + 2) + } + print("\(whitespace2)", to: &output) + } else { + print(">", to: &output) + } + } + } + + guard let children = try? element.children() else { return } + for (index, child) in children.enumerated() { + try dump(child, to: &output, depth: depth + 1, indent: indent + 1, preamble: "children[\(index)]") + } + } +} + +public extension Accessibility.Element { + func dumpXML( + to output: inout some TextOutputStream, + shallow: Bool = false, + maxDepth: Int? = nil, + excludingPII: Bool = false, + excludingElementsWithRoles excludedRoles: Set = [], + excludingAttributes excludedAttributes: Set = XMLDumper.defaultExcludedAttributes, + includeActions: Bool = true, + includeSections: Bool = true, + ) throws { + var excludedAttributes = excludedAttributes + if excludingPII { + excludedAttributes.formUnion(XMLDumper.attributesLikelyToContainPII) + } + + return try XMLDumper( + maxDepth: maxDepth, + excludedRoles: excludedRoles, + excludedAttributes: excludedAttributes, + intendingToExcludePII: excludingPII, + includeActions: includeActions, + includeSections: includeSections, + shallow: shallow, + ).dump(self, to: &output) + } +} + +// MARK: - + +private extension String { + var xmlEscaped: String { + replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "\"", with: "'") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + } + + var quoted: String { + "\"\(self)\"" + } + + var asComment: String { + "" + } +} diff --git a/Sources/AccessibilityControl/Names+Standard.swift b/Sources/AccessibilityControl/Names+Standard.swift new file mode 100644 index 0000000..3e47cf7 --- /dev/null +++ b/Sources/AccessibilityControl/Names+Standard.swift @@ -0,0 +1,79 @@ +import ApplicationServices + +// refer to AXAttributeConstants.h +// https://gist.github.com/p6p/24fbac5d12891fcfffa2b53761f4343e +// https://developer.apple.com/documentation/applicationservices/axattributeconstants_h/miscellaneous_defines +// https://github.com/tmandry/AXSwift/blob/main/Sources/Constants.swift +public extension Accessibility.Names { + var rows: AttributeName<[Accessibility.Element]> { .init(kAXRowsAttribute) } + var children: AttributeName<[Accessibility.Element]> { .init(kAXChildrenAttribute) } + var selectedChildren: AttributeName<[Accessibility.Element]> { .init(kAXSelectedChildrenAttribute) } + var linkedElements: AttributeName<[Accessibility.Element]> { .init(kAXLinkedUIElementsAttribute) } + // ["SectionObject": {pid=16768}, "SectionUniqueID": 11266711619528580561, "SectionDescription": Messages] + var sections: AttributeName<[[String: CFTypeRef]]> { "AXSections" } + var parent: AttributeName { .init(kAXParentAttribute) } + var valueDescription: AttributeName { .init(kAXValueDescriptionAttribute) } + + var isExpanded: AttributeName { .init(kAXExpandedAttribute) } + var isHidden: AttributeName { .init(kAXHiddenAttribute) } + + // this wont work without the com.apple.private.accessibility.inspection entitlement + // https://stackoverflow.com/questions/45590888/how-to-get-the-objective-c-class-name-corresponding-to-an-axuielement + var className: AttributeName { "AXClassName" } + + var value: MutableAttributeName { .init(kAXValueAttribute) } + var placeholderValue: AttributeName { .init(kAXPlaceholderValueAttribute) } + + var position: MutableAttributeName { .init(kAXPositionAttribute) } + var size: MutableAttributeName { .init(kAXSizeAttribute) } + var frame: AttributeName { "AXFrame" } + + var title: AttributeName { .init(kAXTitleAttribute) } + var titleUIElement: AttributeName { .init(kAXTitleUIElementAttribute) } + var localizedDescription: AttributeName { .init(kAXDescriptionAttribute) } + var identifier: AttributeName { .init(kAXIdentifierAttribute) } + var role: AttributeName { .init(kAXRoleAttribute) } + var subrole: AttributeName { .init(kAXSubroleAttribute) } + var roleDescription: AttributeName { .init(kAXRoleDescriptionAttribute) } + var help: AttributeName { .init(kAXHelpAttribute) } + + var noOfChars: AttributeName { .init(kAXNumberOfCharactersAttribute) } + + var isSelected: MutableAttributeName { .init(kAXSelectedAttribute) } + var isFocused: MutableAttributeName { .init(kAXFocusedAttribute) } + var isEnabled: AttributeName { .init(kAXEnabledAttribute) } + + var selectedRows: AttributeName<[AXUIElement]> { .init(kAXSelectedRowsAttribute) } + var selectedColumns: AttributeName<[AXUIElement]> { .init(kAXSelectedColumnsAttribute) } + var selectedCells: AttributeName<[AXUIElement]> { .init(kAXSelectedCellsAttribute) } + + // https://developer.apple.com/documentation/applicationservices/axactionconstants_h/miscellaneous_defines + var press: ActionName { .init(kAXPressAction) } + var showMenu: ActionName { .init(kAXShowMenuAction) } + var cancel: ActionName { .init(kAXCancelAction) } + var scrollToVisible: ActionName { "AXScrollToVisible" } + + var increment: ActionName { .init(kAXIncrementAction) } + +#if DEBUG + var decrement: ActionName { .init(kAXDecrementAction) } + + var minValue: AttributeName { .init(kAXMinValueAttribute) } + var maxValue: AttributeName { .init(kAXMaxValueAttribute) } +#endif + + // App-specific + var appWindows: AttributeName<[Accessibility.Element]> { .init(kAXWindowsAttribute) } + var appMainWindow: AttributeName { .init(kAXMainWindowAttribute) } + var appFocusedWindow: AttributeName { .init(kAXFocusedWindowAttribute) } + var focusedElement: AttributeName { .init(kAXFocusedUIElementAttribute) } + + // Window-specific + var windowIsMain: MutableAttributeName { .init(kAXMainAttribute) } + var windowIsModal: AttributeName { .init(kAXModalAttribute) } + var windowIsMinimized: MutableAttributeName { .init(kAXMinimizedAttribute) } + var windowIsFullScreen: MutableAttributeName { "AXFullScreen" } + var windowCloseButton: AttributeName { .init(kAXCloseButtonAttribute) } + var windowDefaultButton: AttributeName { .init(kAXDefaultButtonAttribute) } + var windowCancelButton: AttributeName { .init(kAXCancelButtonAttribute) } +} diff --git a/Sources/AccessibilityControl/Notification+Standard.swift b/Sources/AccessibilityControl/Notification+Standard.swift new file mode 100644 index 0000000..cc0407c --- /dev/null +++ b/Sources/AccessibilityControl/Notification+Standard.swift @@ -0,0 +1,14 @@ +import ApplicationServices + +public extension Accessibility.Notification { + static let layoutChanged = Self(kAXLayoutChangedNotification) + static let focusedUIElementChanged = Self(kAXFocusedUIElementChangedNotification) + static let applicationActivated = Self(kAXApplicationActivatedNotification) + static let applicationDeactivated = Self(kAXApplicationDeactivatedNotification) + static let applicationShown = Self(kAXApplicationShownNotification) + static let applicationHidden = Self(kAXApplicationHiddenNotification) + static let windowMoved = Self(kAXWindowMovedNotification) + static let windowResized = Self(kAXWindowResizedNotification) + static let windowCreated = Self(kAXWindowCreatedNotification) + static let titleChanged = Self(kAXTitleChangedNotification) +} From d322e813f0e50268af1faf27b3776254f5051100 Mon Sep 17 00:00:00 2001 From: Purav Manot Date: Tue, 10 Feb 2026 17:33:14 +0530 Subject: [PATCH 14/14] add ax constants generator + regenerate accessibility constants - add GenerateAXConstants plugin and AXConstantsGenerator tool - include rich documentation for constants based on header files --- Package.swift | 19 + Plugins/GenerateAXConstants/plugin.swift | 25 + Sources/AXConstantsGenerator/main.swift | 787 +++++++++++ .../Accessibility+Action.swift | 58 + .../Accessibility+AttributeKey.swift | 1165 +++++++++++++++++ .../Accessibility+Notification.swift | 296 +++++ ...essibility+ParameterizedAttributeKey.swift | 158 +++ .../Accessibility+Role.swift | 122 +- .../Accessibility+Subrole.swift | 86 +- .../Accessibility+Value.swift | 19 + .../Element+Hierarchy.swift | 252 ++++ .../Notification+Standard.swift | 14 - 12 files changed, 2985 insertions(+), 16 deletions(-) create mode 100644 Plugins/GenerateAXConstants/plugin.swift create mode 100644 Sources/AXConstantsGenerator/main.swift create mode 100644 Sources/AccessibilityControl/Accessibility+Action.swift create mode 100644 Sources/AccessibilityControl/Accessibility+AttributeKey.swift create mode 100644 Sources/AccessibilityControl/Accessibility+Notification.swift create mode 100644 Sources/AccessibilityControl/Accessibility+ParameterizedAttributeKey.swift create mode 100644 Sources/AccessibilityControl/Accessibility+Value.swift create mode 100644 Sources/AccessibilityControl/Element+Hierarchy.swift delete mode 100644 Sources/AccessibilityControl/Notification+Standard.swift diff --git a/Package.swift b/Package.swift index fa57897..1f5f7c0 100644 --- a/Package.swift +++ b/Package.swift @@ -28,6 +28,25 @@ let package = Package( .target( name: "AccessibilityControl", dependencies: ["CAccessibilityControl", "WindowControl"] + ), + .executableTarget( + name: "AXConstantsGenerator", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + ] + ), + .plugin( + name: "GenerateAXConstants", + capability: .command( + intent: .custom( + verb: "generate-ax-constants", + description: "Regenerate AX constant Swift files from HIServices headers" + ), + permissions: [ + .writeToPackageDirectory(reason: "Writes generated Swift source files into Sources/AccessibilityControl") + ] + ), + dependencies: ["AXConstantsGenerator"] ) ] ) diff --git a/Plugins/GenerateAXConstants/plugin.swift b/Plugins/GenerateAXConstants/plugin.swift new file mode 100644 index 0000000..76bb7ae --- /dev/null +++ b/Plugins/GenerateAXConstants/plugin.swift @@ -0,0 +1,25 @@ +import Foundation +import PackagePlugin + +@main +struct GenerateAXConstantsPlugin: CommandPlugin { + func performCommand(context: PluginContext, arguments: [String]) async throws { + let generator = try context.tool(named: "AXConstantsGenerator") + + let outputDir = context.package.directory + .appending(subpath: "Sources") + .appending(subpath: "AccessibilityControl") + + let process = Process() + process.executableURL = URL(fileURLWithPath: generator.path.string) + process.arguments = [outputDir.string] + + try process.run() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + Diagnostics.error("AXConstantsGenerator exited with code \(process.terminationStatus)") + return + } + } +} diff --git a/Sources/AXConstantsGenerator/main.swift b/Sources/AXConstantsGenerator/main.swift new file mode 100644 index 0000000..f8e8ed6 --- /dev/null +++ b/Sources/AXConstantsGenerator/main.swift @@ -0,0 +1,787 @@ +import ArgumentParser +import Foundation + +// MARK: - Entry Point + +@main +struct AXConstantsGenerator: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Generate Swift constant definitions from HIServices AX header files." + ) + + @Argument(help: "Output directory for generated Swift files") + var outputDirectory: String + + func run() throws { + let sdkPath = try findSDKPath() + let headersDir = sdkPath + + "/System/Library/Frameworks/ApplicationServices.framework" + + "/Versions/A/Frameworks/HIServices.framework/Versions/A/Headers" + + var allConstants: [Constant] = [] + var commentMap: [String: String] = [:] + var groupMap: [String: String] = [:] + var seen: Set = [] + + for file in headerFiles { + let path = headersDir + "/" + file + let relativeHeaderPath = "System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/HIServices.framework/Versions/A/Headers/\(file)" + guard let content = try? String(contentsOfFile: path, encoding: .utf8) else { + fputs("warning: could not read \(path)\n", stderr) + continue + } + let metadata = parseHeaderMetadata(from: content) + commentMap.merge(metadata.comments) { old, _ in old } + groupMap.merge(metadata.groups) { old, _ in old } + for c in parseConstants(from: content, sourceHeaderPath: relativeHeaderPath) { + if seen.insert(c.define).inserted { + allConstants.append(c) + } + } + } + + var groups: [String: [Constant]] = [:] + for c in allConstants { + groups[c.suffix, default: []].append(c) + } + + let fm = FileManager.default + try fm.createDirectory(atPath: outputDirectory, withIntermediateDirectories: true) + + var totalCount = 0 + for config in outputConfigs { + guard let constants = groups[config.suffix], !constants.isEmpty else { continue } + let content = generateFile(config: config, constants: constants, comments: commentMap, groups: groupMap) + let path = outputDirectory + "/" + config.fileName + try content.write(toFile: path, atomically: true, encoding: .utf8) + totalCount += constants.count + print(" \(config.fileName) — \(constants.count) constants") + } + print("\nGenerated \(totalCount) constants across \(groups.count) files in \(outputDirectory)/") + } +} + +// MARK: - SDK Discovery + +private func findSDKPath() throws -> String { + let process = Process() + let pipe = Pipe() + process.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") + process.arguments = ["--show-sdk-path"] + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + try process.run() + process.waitUntilExit() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + guard let path = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !path.isEmpty else { + throw ValidationError("Could not determine SDK path. Ensure Xcode or Command Line Tools are installed.") + } + return path +} + +// MARK: - Configuration + +private let headerFiles = [ + "AXRoleConstants.h", + "AXAttributeConstants.h", + "AXActionConstants.h", + "AXNotificationConstants.h", + "AXValueConstants.h", + "AXWebConstants.h", +] + +private let swiftKeywords: Set = [ + "associatedtype", "class", "deinit", "enum", "extension", "fileprivate", + "func", "import", "init", "inout", "internal", "let", "open", "operator", + "private", "precedencegroup", "protocol", "public", "rethrows", "static", + "struct", "subscript", "typealias", "var", + "break", "case", "catch", "continue", "default", "defer", "do", "else", + "fallthrough", "for", "guard", "if", "in", "repeat", "return", "switch", + "throw", "where", "while", + "Any", "as", "false", "is", "nil", "self", "Self", "super", + "throws", "true", "try", +] + +private let skipDefines: Set = ["kAXDescription"] + +// MARK: - Output Configuration + +private enum OutputMode { + case caselessEnum(name: String) + case extensionOnType(typeName: String) +} + +private struct OutputConfig { + let suffix: String + let mode: OutputMode + let fileName: String +} + +/// Longest suffix first so "ParameterizedAttribute" matches before "Attribute". +private let outputConfigs: [OutputConfig] = [ + .init(suffix: "ParameterizedAttribute", mode: .caselessEnum(name: "ParameterizedAttributeKey"), fileName: "Accessibility+ParameterizedAttributeKey.swift"), + .init(suffix: "Attribute", mode: .caselessEnum(name: "AttributeKey"), fileName: "Accessibility+AttributeKey.swift"), + .init(suffix: "Subrole", mode: .caselessEnum(name: "Subrole"), fileName: "Accessibility+Subrole.swift"), + .init(suffix: "Role", mode: .caselessEnum(name: "Role"), fileName: "Accessibility+Role.swift"), + .init(suffix: "Notification", mode: .extensionOnType(typeName: "Notification"), fileName: "Accessibility+Notification.swift"), + .init(suffix: "Action", mode: .extensionOnType(typeName: "Action.Name"), fileName: "Accessibility+Action.swift"), + .init(suffix: "Value", mode: .caselessEnum(name: "Value"), fileName: "Accessibility+Value.swift"), +] + +// MARK: - Parsed Constant + +private struct Constant { + let define: String + let suffix: String + let sourceHeaderPath: String + + var baseName: String { + var s = String(define.dropFirst(3)) // strip "kAX" + if s.hasSuffix(suffix) { s = String(s.dropLast(suffix.count)) } + return s + } + + var propertyName: String { + escaped(lowerCamelCase(baseName)) + } +} + +// MARK: - Name Transformation + +/// Converts PascalCase to lowerCamelCase, handling leading acronyms. +/// +/// "Application" → "application" +/// "URLDockItem" → "urlDockItem" +/// "AMPMField" → "ampmField" +/// "UIElementDestroyed" → "uiElementDestroyed" +private func lowerCamelCase(_ name: String) -> String { + guard !name.isEmpty else { return name } + let chars = Array(name) + var upperCount = 0 + for c in chars { + guard c.isUppercase else { break } + upperCount += 1 + } + if upperCount == 0 { return name } + if upperCount >= chars.count { return name.lowercased() } + if upperCount == 1 { + return String(chars[0]).lowercased() + String(chars[1...]) + } + return String(chars[0..<(upperCount - 1)]).lowercased() + + String(chars[(upperCount - 1)...]) +} + +private func escaped(_ name: String) -> String { + swiftKeywords.contains(name) ? "`\(name)`" : name +} + +// MARK: - Constant Parsing + +private let definePattern = try! NSRegularExpression( + pattern: #"^\s*#define\s+(kAX\w+)\s+CFSTR\("[^"]*"\)"#, + options: .anchorsMatchLines +) + +private func parseConstants(from content: String, sourceHeaderPath: String) -> [Constant] { + let ns = content as NSString + let matches = definePattern.matches(in: content, range: NSRange(location: 0, length: ns.length)) + var result: [Constant] = [] + for match in matches { + let define = ns.substring(with: match.range(at: 1)) + guard !skipDefines.contains(define) else { continue } + let stripped = String(define.dropFirst(3)) + for config in outputConfigs { + if stripped.hasSuffix(config.suffix) { + result.append(Constant(define: define, suffix: config.suffix, sourceHeaderPath: sourceHeaderPath)) + break + } + } + } + return result +} + +// MARK: - Comment & Group Extraction + +private struct HeaderMetadata { + var comments: [String: String] + var groups: [String: String] +} + +private struct BlockCommentCandidate { + let lines: [String] + let endLine: Int +} + +private struct DocSection { + let title: String? + let lines: [String] +} + +private struct DefineMatch { + let name: String + let trailingComment: String? +} + +private struct DoxygenTag { + let name: String + let value: String? +} + +private let defineLinePattern = try! NSRegularExpression( + pattern: #"^\s*#define\s+(kAX\w+)\s+CFSTR\("[^"]*"\)(?:\s*//\s*(.*))?\s*$"# +) + +private let doxygenTagPattern = try! NSRegularExpression( + pattern: #"^@([A-Za-z/]+)\b(?:\s+(.*))?$"# +) + +private func parseHeaderMetadata(from content: String) -> HeaderMetadata { + var comments: [String: String] = [:] + var groups: [String: String] = [:] + var quickReferenceGroups: [String: String] = [:] + + let lines = content.components(separatedBy: "\n") + var currentGroup: String? + var inBlock = false + var blockLines: [String] = [] + var lastBlockComment: BlockCommentCandidate? + var lineCommentBuffer: [String] = [] + var lastLineCommentLine = -10_000 + + var inQuickReference = false + var quickReferenceCategory: String? + + for (index, line) in lines.enumerated() { + let trimmed = line.trimmingCharacters(in: .whitespaces) + + if let lastBlock = lastBlockComment, (index - lastBlock.endLine) > 3 { + lastBlockComment = nil + } + + if inBlock { + blockLines.append(line) + updateQuickReferenceState( + trimmedLine: trimmed, + inQuickReference: &inQuickReference, + quickReferenceCategory: &quickReferenceCategory, + quickReferenceGroups: &quickReferenceGroups + ) + if trimmed.contains("*/") { + inBlock = false + let cleaned = cleanBlockComment(lines: blockLines) + if let groupName = extractGroupName(from: cleaned) { + currentGroup = groupName + } + lastBlockComment = BlockCommentCandidate(lines: blockLines, endLine: index) + blockLines = [] + } + continue + } + + if trimmed.hasPrefix("/*") { + inBlock = true + blockLines = [line] + updateQuickReferenceState( + trimmedLine: trimmed, + inQuickReference: &inQuickReference, + quickReferenceCategory: &quickReferenceCategory, + quickReferenceGroups: &quickReferenceGroups + ) + if trimmed.contains("*/") { + inBlock = false + let cleaned = cleanBlockComment(lines: blockLines) + if let groupName = extractGroupName(from: cleaned) { + currentGroup = groupName + } + lastBlockComment = BlockCommentCandidate(lines: blockLines, endLine: index) + blockLines = [] + } + lineCommentBuffer.removeAll(keepingCapacity: true) + continue + } + + if trimmed.hasPrefix("//") { + let text = String(trimmed.dropFirst(2)).trimmingCharacters(in: .whitespaces) + if let sectionName = extractSectionHeadingFromLineComment(text) { + currentGroup = sectionName + lineCommentBuffer.removeAll(keepingCapacity: true) + } else if !text.isEmpty { + lineCommentBuffer.append(text) + lastLineCommentLine = index + } + continue + } + + if let define = parseDefine(from: line) { + if let quickReferenceGroup = quickReferenceGroups[define.name] { + if let currentGroup, isEquivalentGroup(currentGroup, quickReferenceGroup) { + groups[define.name] = groups[define.name] ?? currentGroup + } else { + groups[define.name] = groups[define.name] ?? quickReferenceGroup + } + } else if let currentGroup { + groups[define.name] = groups[define.name] ?? currentGroup + } + + var docParts: [String] = [] + if let lastBlockComment, (index - lastBlockComment.endLine) <= 3 { + if let documentation = extractDocumentation(from: lastBlockComment.lines) { + docParts.append(documentation) + } + } else if !lineCommentBuffer.isEmpty, (index - lastLineCommentLine) <= 2 { + let text = lineCommentBuffer.joined(separator: "\n") + if !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + docParts.append(text) + } + } + + if let trailing = define.trailingComment?.trimmingCharacters(in: .whitespaces), + !trailing.isEmpty { + if docParts.isEmpty { + docParts.append(trailing) + } else { + docParts.append("Inline Note:\n\(trailing)") + } + } + + if !docParts.isEmpty { + comments[define.name] = comments[define.name] ?? docParts.joined(separator: "\n\n") + } + + lineCommentBuffer.removeAll(keepingCapacity: true) + lastBlockComment = nil + continue + } + + if !trimmed.isEmpty { + lineCommentBuffer.removeAll(keepingCapacity: true) + } + } + + return HeaderMetadata(comments: comments, groups: groups) +} + +private func updateQuickReferenceState( + trimmedLine: String, + inQuickReference: inout Bool, + quickReferenceCategory: inout String?, + quickReferenceGroups: inout [String: String] +) { + if trimmedLine.contains("Quick reference:") { + inQuickReference = true + quickReferenceCategory = nil + return + } + + guard inQuickReference else { return } + + if let heading = parseQuickReferenceHeading(from: trimmedLine) { + quickReferenceCategory = heading + } else if let defineName = extractAnyAXDefineName(from: trimmedLine), + let quickReferenceCategory { + quickReferenceGroups[defineName] = quickReferenceCategory + } + + if trimmedLine.contains("*/") { + inQuickReference = false + quickReferenceCategory = nil + } +} + +private func parseQuickReferenceHeading(from line: String) -> String? { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard trimmed.hasPrefix("//") else { return nil } + let text = String(trimmed.dropFirst(2)).trimmingCharacters(in: .whitespaces) + guard !text.isEmpty else { return nil } + guard !text.lowercased().contains("quick reference") else { return nil } + return normalizedGroupName(text) +} + +private func parseDefine(from line: String) -> DefineMatch? { + let ns = line as NSString + let range = NSRange(location: 0, length: ns.length) + guard let match = defineLinePattern.firstMatch(in: line, range: range) else { return nil } + let name = ns.substring(with: match.range(at: 1)) + let trailingComment: String? + if match.range(at: 2).location != NSNotFound { + trailingComment = ns.substring(with: match.range(at: 2)) + } else { + trailingComment = nil + } + return DefineMatch(name: name, trailingComment: trailingComment) +} + +private func extractAnyAXDefineName(from line: String) -> String? { + guard let range = line.range(of: #"\bkAX\w+\b"#, options: .regularExpression) else { return nil } + return String(line[range]) +} + +private func cleanBlockComment(lines: [String]) -> [String] { + let cleaned = lines.map { raw -> String in + var line = raw + line = line.replacingOccurrences( + of: #"^\s*/\*+!?"#, + with: "", + options: .regularExpression + ) + line = line.replacingOccurrences( + of: #"\*/\s*$"#, + with: "", + options: .regularExpression + ) + line = line.replacingOccurrences( + of: #"^\s*\* ?"#, + with: "", + options: .regularExpression + ) + return line.trimmingCharacters(in: .whitespaces) + } + return trimmingEmptyEdgeLines(cleaned) +} + +private func extractGroupName(from cleanedBlock: [String]) -> String? { + guard !cleanedBlock.isEmpty else { return nil } + + for line in cleanedBlock { + if let tag = parseDoxygenTag(from: line), tag.name.lowercased() == "group" { + if let value = tag.value?.trimmingCharacters(in: .whitespaces), !value.isEmpty { + return normalizedGroupName(value) + } + } + } + + guard cleanedBlock.count == 1 else { return nil } + let candidate = cleanedBlock[0] + guard looksLikeSectionHeading(candidate) else { return nil } + return normalizedGroupName(candidate) +} + +private func extractSectionHeadingFromLineComment(_ text: String) -> String? { + let trimmed = text.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return nil } + + let lower = trimmed.lowercased() + if lower.contains("cfstringref") || lower.contains("cfbooleanref") || lower.hasSuffix("ref") { + return nil + } + + let headingKeywords = [ + "attribute", "attributes", + "subrole", "subroles", + "role", "roles", + "action", "actions", + "notification", "notifications", + "value", "values", + ] + guard headingKeywords.contains(where: { lower.contains($0) }) else { return nil } + guard looksLikeSectionHeading(trimmed) else { return nil } + return normalizedGroupName(trimmed) +} + +private func extractDocumentation(from blockLines: [String]) -> String? { + let cleaned = cleanBlockComment(lines: blockLines) + guard !cleaned.isEmpty else { return nil } + if cleaned.allSatisfy({ line in + line.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || line.range(of: #"[A-Za-z0-9]"#, options: .regularExpression) == nil + }) { + return nil + } + + let text = cleaned.joined(separator: "\n") + if text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return nil } + if text.contains("Need discussion for following") { return nil } + if text.contains("@group") { return nil } + if text.contains("@header") && !text.contains("@defined") && !text.contains("@define") { return nil } + if cleaned.count == 1 && looksLikeSectionHeading(cleaned[0]) { return nil } + + var sections: [DocSection] = [] + var currentTitle: String? + var currentLines: [String] = [] + var sawDoxygenTag = false + + func flushSection() { + let trimmedLines = trimmingEmptyEdgeLines(currentLines) + guard !trimmedLines.isEmpty else { + currentLines.removeAll(keepingCapacity: true) + currentTitle = nil + return + } + sections.append(DocSection(title: currentTitle, lines: trimmedLines)) + currentLines.removeAll(keepingCapacity: true) + currentTitle = nil + } + + func startSection(title: String?) { + flushSection() + currentTitle = title + } + + for line in cleaned { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { + if !currentLines.isEmpty { + currentLines.append("") + } + continue + } + + if let tag = parseDoxygenTag(from: trimmed) { + sawDoxygenTag = true + let name = tag.name.lowercased() + let value = tag.value?.trimmingCharacters(in: .whitespaces) ?? "" + switch name { + case "define", "defined", "group", "header", "textblock", "/textblock": + continue + case "abstract": + startSection(title: "Abstract") + if !value.isEmpty { currentLines.append(value) } + case "discussion": + startSection(title: "Discussion") + if !value.isEmpty { currentLines.append(value) } + case "attributeblock": + startSection(title: value.isEmpty ? "Attribute" : value) + default: + startSection(title: friendlyTagTitle(name)) + if !value.isEmpty { currentLines.append(value) } + } + continue + } + + currentLines.append(trimmed) + } + flushSection() + + if sections.isEmpty { + if sawDoxygenTag { return nil } + let plain = text.trimmingCharacters(in: .whitespacesAndNewlines) + return plain.isEmpty ? nil : plain + } + + var rendered: [String] = [] + for (idx, section) in sections.enumerated() { + if let title = section.title { + rendered.append("\(title):") + } + rendered.append(contentsOf: section.lines) + if idx < (sections.count - 1) { + rendered.append("") + } + } + + return rendered.joined(separator: "\n") +} + +private func parseDoxygenTag(from line: String) -> DoxygenTag? { + let ns = line as NSString + let range = NSRange(location: 0, length: ns.length) + guard let match = doxygenTagPattern.firstMatch(in: line, range: range) else { return nil } + let name = ns.substring(with: match.range(at: 1)) + let value: String? + if match.range(at: 2).location != NSNotFound { + value = ns.substring(with: match.range(at: 2)) + } else { + value = nil + } + return DoxygenTag(name: name, value: value) +} + +private func looksLikeSectionHeading(_ text: String) -> Bool { + let normalized = text + .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) + .trimmingCharacters(in: .whitespaces) + guard !normalized.isEmpty else { return false } + guard normalized.range(of: #"[A-Za-z]"#, options: .regularExpression) != nil else { return false } + guard !normalized.hasPrefix("@") else { return false } + guard normalized.count <= 80 else { return false } + + let lower = normalized.lowercased() + for banned in ["need discussion", "copyright", "quick reference", "tbd", "header"] { + if lower.contains(banned) { return false } + } + return true +} + +private func normalizedGroupName(_ text: String) -> String { + let collapsed = text + .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + + guard !collapsed.isEmpty else { return collapsed } + if collapsed == collapsed.lowercased(), let first = collapsed.first { + return String(first).uppercased() + String(collapsed.dropFirst()) + } + return collapsed +} + +private func isEquivalentGroup(_ lhs: String, _ rhs: String) -> Bool { + normalizeGroupForComparison(lhs) == normalizeGroupForComparison(rhs) +} + +private func normalizeGroupForComparison(_ text: String) -> String { + text + .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() +} + +private func friendlyTagTitle(_ tag: String) -> String { + switch tag { + case "seealso": + return "See Also" + default: + return String(tag.prefix(1)).uppercased() + String(tag.dropFirst()) + } +} + +private func trimmingEmptyEdgeLines(_ lines: [String]) -> [String] { + var start = 0 + var end = lines.count + + while start < end, lines[start].trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + start += 1 + } + while end > start, lines[end - 1].trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + end -= 1 + } + return Array(lines[start.. String { + let sourceHeaders = orderedUniqueSourceHeaders(constants) + let sourceComment = sourceHeaders.count == 1 + ? "// Extracted from: \(sourceHeaders[0])" + : "// Extracted from: \(sourceHeaders.joined(separator: ", "))" + + var lines: [String] = [ + "import ApplicationServices", + "", + sourceComment, + "", + ] + + switch config.mode { + case .caselessEnum(let name): + lines.append("extension Accessibility {") + lines.append(" public enum \(name) {") + appendConstants( + constants, + to: &lines, + comments: comments, + groups: groups, + indent: " " + ) { constant in + "public static let \(constant.propertyName) = \(constant.define)" + } + lines.append(" }") + lines.append("}") + + case .extensionOnType(let typeName): + lines.append("public extension Accessibility.\(typeName) {") + appendConstants( + constants, + to: &lines, + comments: comments, + groups: groups, + indent: " " + ) { constant in + "static let \(constant.propertyName) = Self(\(constant.define))" + } + lines.append("}") + } + + lines.append("") + return lines.joined(separator: "\n") +} + +private func appendConstants( + _ constants: [Constant], + to lines: inout [String], + comments: [String: String], + groups: [String: String], + indent: String, + propertyLine: (Constant) -> String +) { + let sections = buildConstantSections(constants: constants, groups: groups) + for (sectionIndex, section) in sections.enumerated() { + if sectionIndex > 0, lines.last?.isEmpty == false { + lines.append("") + } + if let title = section.title { + lines.append("\(indent)// MARK: - \(title)") + lines.append("") + } + + for constant in section.constants { + if let comment = comments[constant.define], + !comment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + appendDocumentationBlock(comment, indent: indent, to: &lines) + } + lines.append("\(indent)\(propertyLine(constant))") + } + } +} + +private func appendDocumentationBlock(_ comment: String, indent: String, to lines: inout [String]) { + lines.append("\(indent)/**") + for line in comment.components(separatedBy: "\n") { + if line.isEmpty { + lines.append("\(indent) *") + } else { + lines.append("\(indent) * \(line)") + } + } + lines.append("\(indent) */") +} + +private struct ConstantSection { + let title: String? + var constants: [Constant] +} + +private func buildConstantSections(constants: [Constant], groups: [String: String]) -> [ConstantSection] { + var sections: [ConstantSection] = [] + var sectionIndexByGroupKey: [String: Int] = [:] + var ungroupedSectionIndex: Int? + + for constant in constants { + let group = groups[constant.define]?.trimmingCharacters(in: .whitespacesAndNewlines) + if let group, !group.isEmpty { + let groupKey = normalizeGroupForComparison(group) + if let sectionIndex = sectionIndexByGroupKey[groupKey] { + sections[sectionIndex].constants.append(constant) + } else { + sections.append(ConstantSection(title: group, constants: [constant])) + sectionIndexByGroupKey[groupKey] = sections.count - 1 + } + } else if let ungroupedSectionIndex { + sections[ungroupedSectionIndex].constants.append(constant) + } else { + sections.append(ConstantSection(title: nil, constants: [constant])) + ungroupedSectionIndex = sections.count - 1 + } + } + + return sections +} + +private func orderedUniqueSourceHeaders(_ constants: [Constant]) -> [String] { + var ordered: [String] = [] + var seen: Set = [] + + for constant in constants { + if seen.insert(constant.sourceHeaderPath).inserted { + ordered.append(constant.sourceHeaderPath) + } + } + return ordered +} diff --git a/Sources/AccessibilityControl/Accessibility+Action.swift b/Sources/AccessibilityControl/Accessibility+Action.swift new file mode 100644 index 0000000..5d80df3 --- /dev/null +++ b/Sources/AccessibilityControl/Accessibility+Action.swift @@ -0,0 +1,58 @@ +import ApplicationServices + +// Extracted from: System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/HIServices.framework/Versions/A/Headers/AXActionConstants.h + +public extension Accessibility.Action.Name { + // MARK: - Standard Actions + + /** + * Discussion: + * Simulate clicking the UIElement, such as a button. + */ + static let press = Self(kAXPressAction) + /** + * Discussion: + * Increment the value of the UIElement. + */ + static let increment = Self(kAXIncrementAction) + /** + * Discussion: + * Decrement the value of the UIElement. + */ + static let decrement = Self(kAXDecrementAction) + /** + * Discussion: + * Simulate pressing Return in the UIElement, such as a text field. + */ + static let confirm = Self(kAXConfirmAction) + /** + * Discussion: + * Simulate a Cancel action, such as hitting the Cancel button. + */ + static let cancel = Self(kAXCancelAction) + /** + * Discussion: + * Show alternate or hidden UI. + * This is often used to trigger the same change that would occur on a mouse hover. + */ + static let showAlternateUI = Self(kAXShowAlternateUIAction) + /** + * Discussion: + * Show default UI. + * This is often used to trigger the same change that would occur when a mouse hover ends. + */ + static let showDefaultUI = Self(kAXShowDefaultUIAction) + + // MARK: - New Actions + + static let raise = Self(kAXRaiseAction) + static let showMenu = Self(kAXShowMenuAction) + + // MARK: - Obsolete Actions + + /** + * Discussion: + * Select the UIElement, such as a menu item. + */ + static let pick = Self(kAXPickAction) +} diff --git a/Sources/AccessibilityControl/Accessibility+AttributeKey.swift b/Sources/AccessibilityControl/Accessibility+AttributeKey.swift new file mode 100644 index 0000000..274ded5 --- /dev/null +++ b/Sources/AccessibilityControl/Accessibility+AttributeKey.swift @@ -0,0 +1,1165 @@ +import ApplicationServices + +// Extracted from: System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/HIServices.framework/Versions/A/Headers/AXAttributeConstants.h, System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/HIServices.framework/Versions/A/Headers/AXWebConstants.h + +extension Accessibility { + public enum AttributeKey { + // MARK: - Informational Attributes + + /** + * Abstract: + * Identifies the basic type of an element. + * + * Value: + * A CFStringRef of one of the role strings defined in this header, or a new + * role string of your own invention. The string should not be localized, and it does + * not need to be human-readable. Instead of inventing new role strings, see if a + * custom element can be identified by an existing role string and a new subrole. See + * kAXSubroleAttribute. + * + * Writable: + * No + * + * Discussion: + * Required for all elements. Even in the worst case scenario where an element cannot + * figure out what its basic type is, it can still supply the value kAXUnknownRole. + * + * Carbon Accessorization Notes: + * If your HIObjectClass or Carbon Event handler provides + * the kAXRoleAttribute, it must also provide the kAXRoleDescriptionAttribute. + */ + public static let role = kAXRoleAttribute + /** + * Abstract: + * More specifically identifies the type of an element beyond the basic type provided + * by kAXRoleAttribute. + * + * Value: + * A CFStringRef of one of the subrole strings defined in this header, or a new + * subrole string of your own invention. The string should not be localized, and it does + * not need to be human-readable. + * + * Writable: + * No + * + * Discussion: + * Required only when an element's kAXRoleAttribute alone doesn't provide an assistive + * application with enough information to convey the meaning of this element to the user. + * + * An example element that offers the kAXSubroleAttribute is a window's close box. Its + * kAXRoleAttribute value is kAXButtonRole and its kAXSubroleAttribute is + * kAXCloseButtonSubrole. It is of role kAXButtonRole because it offers no additional + * actions or attributes above and beyond what other kAXButtonRole elements provide; it + * was given a subrole in order to allow an assistive app to communicate the close box's + * semantic difference to the user. + * + * Carbon Accessorization Notes: + * If your HIObjectClass or Carbon Event handler provides + * the kAXSubroleAttribute, it must also provide the kAXRoleDescriptionAttribute. + */ + public static let subrole = kAXSubroleAttribute + /** + * Discussion: + * A localized, human-readable string that an assistive application can present to the user + * as an explanation of an element's basic type or purpose. Examples would be "push button" + * or "secure text field". The string's language should match the language of the app that + * the element lives within. The string should be all lower-case and contain no punctuation. + * + * Two elements with the same kAXRoleAttribute and kAXSubroleAttribute should have the + * same kAXRoleDescriptionAttribute. + * + * Value: + * A localized, human-readable CFStringRef + * + * Writable: + * No + * + * Abstract: + * Required for all elements. Even in the worst case scenario where an element cannot + * figure out what its basic type is, it can still supply the value "unknown". + * + * Carbon Accessorization Notes: + * The HIObjectClass or Carbon Event handler that provides + * the AXRole and/or AXSubrole for an element is the one that must also handle the + * AXRoleDescription attribute. If an HIObjectClass or Carbon Event handler does not + * provide either the AXRole or AXSubrole, it must not provide the AXRoleDescription. + */ + public static let roleDescription = kAXRoleDescriptionAttribute + /** + * Abstract: + * A localized, human-readable CFStringRef that offers help content for an element. + * + * Discussion: + * This is often the same information that would be provided in a help tag for the element. + * + * Recommended for any element that has help data available. + * + * Value: + * A localized, human-readable CFStringRef. + * + * Writable: + * No. + */ + public static let help = kAXHelpAttribute + /** + * Discussion: + * The localized, human-readable string that is displayed as part of the element's + * normal visual interface. For example, an OK button's kAXTitleElement is the string + * "OK", and a menu item's kAXTitleElement is the text of the menu item. + * + * Required if the element draws a string as part of its normal visual interface. + * + * Value: + * A localized, human-readable CFStringRef + * + * Writable: + * No + */ + public static let title = kAXTitleAttribute + /** + * A localized, human-readable string that indicates an element's purpose in a way + * that is slightly more specific than the kAXRoleDescriptionAttribute, but which + * is less wordy than the kAXHelpAttribute. Typically, the description should be + * an adjective or short phrase that describes the element's usage. For example, + * the description of a slider in a font panel might be "font size". The string + * should be all lower-case and contain no punctuation. + * + * Value: A localized, human-readable CFStringRef. + * + * Writable? No. + * + * Recommended for all elements because it gives the user a concise indication of + * an element's purpose. + */ + public static let description = kAXDescriptionAttribute + + // MARK: - Value Attributes + + /** + * Discussion: + * A catch-all attribute that represents a user modifiable setting of an element. For + * example, the contents of an editable text field, the position of a scroll bar thumb, + * and whether a check box is checked are all communicated by the kAXValueAttribute of + * their respective elements. + * + * Required for many user manipulatable elements, or those whose value state conveys + * important information. + * + * Value: + * Varies, but will always be the same type for a given kind of element. Each + * role that offers kAXValueAttribute will specify the type of data that will be used + * for its value. + * + * Writable: + * Generally yes. However, it does not need to be writable if some other form + * of direct manipulation is more appropriate for causing a value change. For example, + * a kAXScrollBar's kAXValueAttribute is writable because it allows an efficient way + * for the user to get to a specific position in the element being scrolled. By + * contrast, a kAXCheckBox's kAXValueAttribute is not settable because underlying + * functionality of the check box widget relies on it being clicked on; therefore, it + * changes its own kAXValueAttribute appropriately in response to the kAXPressAction. + * + * Required for many user manipulatable elements, or those whose value state conveys + * important information. + */ + public static let value = kAXValueAttribute + /** + * Used to supplement kAXValueAttribute. This attribute returns a string description that best + * describes the current value stored in kAXValueAttribute. This is useful for things like + * slider where the numeric value in kAXValueAttribute does not always convey enough information + * about the adjustment made on the slider. As an example, a color slider that adjusts thru various + * colors cannot be well-described by the numeric value in existing AXValueAttribute. This is where + * the kAXValueDescriptionAttribute comes in handy. In this example, the developer can provide the + * color information using this attribute. + * + * Value: A localized, human-readable CFStringRef. + * + * Writable? No. + * + * Recommended for elements that support kAXValueAttribute. + */ + public static let valueDescription = kAXValueDescriptionAttribute + /** + * Only used in conjunction with kAXValueAttribute and kAXMaxValueAttribute, this + * attribute represents the minimum value that an element can display. This is useful + * for things like sliders and scroll bars, where the user needs to have an understanding + * of how much the kAXValueAttribute can vary. + * + * Value: Same data type as the element's kAXValueAttribute. + * + * Writable? No. + * + * Required for many user maniipulatable elements. See kAXValueAttribute for more + * details. + */ + public static let minValue = kAXMinValueAttribute + /** + * Only used in conjunction with kAXValueAttribute and kAXMinValueAttribute, this + * attribute represents the maximum value that an element can display. This is useful + * for things like sliders and scroll bars, where the user needs to have an understanding + * of how much the kAXValueAttribute can vary. + * + * Value: Same data type as the element's kAXValueAttribute. + * + * Writable? No. + * + * Required for many user maniipulatable elements. See kAXValueAttribute for more + * details. + */ + public static let maxValue = kAXMaxValueAttribute + /** + * Only used in conjunction with kAXValueAttribute, this attribute represents the amount + * a value will change in one action on the given element. In particular, it is used on + * elements of role kAXIncrementorRole in order to give the user an idea of how much its + * value will change with a single click on the up or down arrow. + * + * Value: Same data type as the element's kAXValueAttribute. + * + * Writable? No. + * + * Recommended for kAXIncrementorRole and other similar elements. + */ + public static let valueIncrement = kAXValueIncrementAttribute + /** + * An array of the allowed values for a slider or other widget that displays + * a large value range, but which can only be set to a small subset of values + * within that range. + * + * Value: A CFArrayRef of whatever type the element uses for its kAXValueAttribute. + * + * Writable? No. + * + * Recommended for sliders or other elements that can only be set to a small + * set of values. + */ + public static let allowedValues = kAXAllowedValuesAttribute + /** + * kAXPlaceholderValueAttribute + * + * The value of placeholder text as found in a text field. + * + * Value: A CFStringRef. + * + * Writable? No. + * + * Recommended for text fields and other elements that have a placeholder value. + */ + public static let placeholderValue = kAXPlaceholderValueAttribute + public static let insertionPointLineNumber = kAXInsertionPointLineNumberAttribute + /** + * kAXFullScreenButtonAttribute + * + * A convenience attribute so assistive apps can quickly access a window's full screen + * button element. + * + * Value: An AXUIElementRef of the window's full screen button element. + * + * Writable? No. + * + * Required for all window elements that have a full screen button. + */ + public static let fullScreenButton = kAXFullScreenButtonAttribute + public static let valueWraps = kAXValueWrapsAttribute + + // MARK: - Visual state attributes + + /** + * Indicates whether the element can be interacted with by the user. For example, + * a disabled push button's kAXEnabledAttribute will be false. + * + * Value: A CFBooleanRef. True means enabled, false means disabled. + * + * Writable? No. + * + * Required for all views, menus, and menu items. Not required for windows. + */ + public static let enabled = kAXEnabledAttribute + /** + * Indicates whether the element is the current keyboard focus. It should be writable + * for any element that can accept keyboard focus, though you can only set the value + * of kAXFocusedAttribute to true. You cannot unfocus an element by setting the value + * to false. Only one element in a window's entire accessibility hierarchy should be + * marked as focused. + * + * Value: A CFBooleanRef. True means focused, false means not focused. + * + * Writable? Yes, for any focusable element. No in all other cases. + * + * Required for any focusable element. Not required for other elements, though it is + * often offered for non-focusable elements in a read-only fashion. + */ + public static let focused = kAXFocusedAttribute + /** + * The global screen position of the top-left corner of an element. + * + * Value: An AXValueRef with type kAXValueCGPointType. 0,0 is the top-left + * corner of the screen that displays the menu bar. The value of the horizontal + * axis increases to the right. The value of the vertical axis increases + * downward. Units are points. + * + * Writable? Generally no. However, some elements that can be moved by the user + * through direct manipulation (like windows) should offer a writable position + * attribute. + * + * Required for all elements that are visible on the screen, which is virtually + * all elements. + */ + public static let position = kAXPositionAttribute + /** + * The vertical and horizontal dimensions of the element. + * + * Value: An AXValueRef with type kAXValueCGSizeType. Units are points. + * + * Writable? Generally no. However, some elements that can be resized by the user + * through direct manipulation (like windows) should offer a writable size attribute. + * + * Required for all elements that are visible on the screen, which is virtually + * all elements. + */ + public static let size = kAXSizeAttribute + + // MARK: - Miscellaneous or role-specific attributes + + /** + * Indicates that an element is busy. This status conveys + * that an element is in the process of updating or loading and that + * new information will be available when the busy state completes. + * + * Value: A CFBooleanRef. True means busy, false means not busy. + * + * Writable? Yes. + */ + public static let elementBusy = kAXElementBusyAttribute + /** + * An indication of whether an element is drawn and/or interacted with in a + * vertical or horizontal manner. Elements such as scroll bars and sliders offer + * the kAXOrientationAttribute. + * + * Value: kAXHorizontalOrientationValue, kAXVerticalOrientationValue, or rarely + * kAXUnknownOrientationValue. + * + * Writable? No. + * + * Required for scroll bars, sliders, or other elements whose semantic or + * associative meaning changes based on their orientation. + */ + public static let orientation = kAXOrientationAttribute + /** + * A convenience attribute whose value is an element that is a header for another + * element. For example, an outline element has a header attribute whose value is + * a element of role AXGroup that contains the header buttons for each column. + * Used for things like tables, outlines, columns, etc. + * + * Value: An AXUIElementRef whose role varies. + * + * Writable? No. + * + * Recommended for elements that have header elements contained within them that an + * assistive application might want convenient access to. + */ + public static let header = kAXHeaderAttribute + public static let edited = kAXEditedAttribute + public static let tabs = kAXTabsAttribute + public static let horizontalScrollBar = kAXHorizontalScrollBarAttribute + public static let verticalScrollBar = kAXVerticalScrollBarAttribute + public static let overflowButton = kAXOverflowButtonAttribute + public static let filename = kAXFilenameAttribute + public static let expanded = kAXExpandedAttribute + public static let selected = kAXSelectedAttribute + public static let splitters = kAXSplittersAttribute + public static let nextContents = kAXNextContentsAttribute + public static let document = kAXDocumentAttribute + public static let decrementButton = kAXDecrementButtonAttribute + public static let incrementButton = kAXIncrementButtonAttribute + public static let previousContents = kAXPreviousContentsAttribute + /** + * A convenience attribute so assistive apps can find interesting child elements + * of a given element, while at the same time avoiding non-interesting child + * elements. For example, the contents of a scroll area are the children that get + * scrolled, and not the horizontal and/or vertical scroll bars. The contents of + * a tab group does not include the tabs themselves. + * + * Value: A CFArrayRef of AXUIElementRefs. + * + * Writable? No. + * + * Recommended for elements that have children that act upon or are separate from + * other children. + */ + public static let contents = kAXContentsAttribute + /** + * Convenience attribute that yields the incrementor of a time field or date + * field element. + * + * Value: A AXUIElementRef of role kAXIncrementorRole. + * + * Writable? No. + * + * Required for time field and date field elements that display an incrementor. + */ + public static let incrementor = kAXIncrementorAttribute + public static let columnTitle = kAXColumnTitleAttribute + /** + * Value: A CFURLRef. + * + * Writable? No. + * + * Required for elements that represent a disk or network item. + */ + public static let url = kAXURLAttribute + public static let labelUIElements = kAXLabelUIElementsAttribute + public static let labelValue = kAXLabelValueAttribute + public static let shownMenuUIElement = kAXShownMenuUIElementAttribute + public static let isApplicationRunning = kAXIsApplicationRunningAttribute + public static let focusedApplication = kAXFocusedApplicationAttribute + public static let alternateUIVisible = kAXAlternateUIVisibleAttribute + + // MARK: - Hierarchy or relationship attributes + + /** + * Indicates the element's container element in the visual element hierarchy. A push + * button's kAXParentElement might be a window element or a group. A sheet's + * kAXParentElement will be a window element. A window's kAXParentElement will be the + * application element. A menu item's kAXParentElement will be a menu element. + * + * Value: An AXUIElementRef. + * + * Writable? No. + * + * Required for every element except the application. Everything else in the visual + * element hierarchy must have a parent. + */ + public static let parent = kAXParentAttribute + /** + * Indicates the sub elements of a given element in the visual element hierarchy. A tab + * group's kAXChildrenAttribute is an array of tab radio button elements. A window's + * kAXChildrenAttribute is an array of the first-order views elements within the window. + * A menu's kAXChildrenAttribute is an array of the menu item elements. + * + * A given element may only be in the child array of one other element. If an element is + * in the child array of some other element, the element's kAXParentAttribute must be + * the other element. + * + * Value: A CFArrayRef of AXUIElementRefs. + * + * Writable? No. + * + * Required for elements that contain sub elements. + */ + public static let children = kAXChildrenAttribute + /** + * Indicates the selected sub elements of a given element in the visual element hierarchy. + * This is a the subset of the element's kAXChildrenAttribute that are selected. This is + * commonly used in lists so an assistive app can know which list item are selected. + * + * Value: A CFArrayRef of AXUIElementRefs. + * + * Writable? Only if there is no other way to manipulate the set of selected elements via + * accessibilty attributes or actions. Even if other ways exist, this attribute can be + * writable as a convenience to assistive applications and their users. If + * kAXSelectedChildrenAttribute is writable, a write request with a value of an empty + * array should deselect all selected children. + * + * Required for elements that contain selectable sub elements. + */ + public static let selectedChildren = kAXSelectedChildrenAttribute + /** + * Indicates the visible sub elements of a given element in the visual element hierarchy. + * This is a the subset of the element's kAXChildrenAttribute that a sighted user can + * see on the screen. In a list element, kAXVisibleChildrenAttribute would be an array + * of child elements that are currently scrolled into view. + * + * Value: A CFArrayRef of AXUIElementRefs. + * + * Writable? No. + * + * Recommended for elements whose child elements can be occluded or scrolled out of view. + */ + public static let visibleChildren = kAXVisibleChildrenAttribute + /** + * A short cut for traversing an element's parent hierarchy until an element of role + * kAXWindowRole is found. Note that the value for kAXWindowAttribute should not be + * an element of role kAXSheetRole or kAXDrawerRole; instead, the value should be the + * element of kAXWindowRole that the sheet or drawer is attached to. + * + * Value: an AXUIElementRef of role kAXWindowRole. + * + * Writable? No. + * + * Required for any element that has an element of role kAXWindowRole somewhere + * in its parent chain. + */ + public static let window = kAXWindowAttribute + /** + * This is very much like the kAXWindowAttribute, except that the value of this + * attribute can be an element with role kAXSheetRole or kAXDrawerRole. It is + * a short cut for traversing an element's parent hierarchy until an element of + * role kAXWindowRole, kAXSheetRole, or kAXDrawerRole is found. + * + * Value: An AXUIElementRef of role kAXWindowRole, kAXSheetRole, or kAXDrawerRole. + * + * Writable? No. + * + * Required for any element that has an appropriate element somewhere in its + * parent chain. + */ + public static let topLevelUIElement = kAXTopLevelUIElementAttribute + /** + * Returns an array of elements that also have keyboard focus when a given element has + * keyboard focus. A common usage of this attribute is to report that both a search + * text field and a list of resulting suggestions share keyboard focus because keyboard + * events can be handled by either UI element. In this example, the text field would be + * the first responder and it would report the list of suggestions as an element in the + * array returned for kAXSharedFocusElementsAttribute. + * + * Value: A CFArrayRef of AXUIElementsRefs. + * + * Writable? No. + */ + public static let sharedFocusElements = kAXSharedFocusElementsAttribute + public static let titleUIElement = kAXTitleUIElementAttribute + public static let servesAsTitleForUIElements = kAXServesAsTitleForUIElementsAttribute + public static let linkedUIElements = kAXLinkedUIElementsAttribute + + // MARK: - Text-specific attributes + + /** + * The selected text of an editable text element. + * + * Value: A CFStringRef with the currently selected text of the element. + * + * Writable? No. + * + * Required for all editable text elements. + */ + public static let selectedText = kAXSelectedTextAttribute + /** + * The range of characters (not bytes) that defines the current selection of an + * editable text element. + * + * Value: An AXValueRef of type kAXValueCFRange. + * + * Writable? Yes. + * + * Required for all editable text elements. + */ + public static let selectedTextRange = kAXSelectedTextRangeAttribute + /** + * An array of noncontiguous ranges of characters (not bytes) that defines the current selections of an + * editable text element. + * + * Value: A CFArrayRef of kAXValueCFRanges. + * + * Writable? Yes. + * + * Recommended for text elements that support noncontiguous selections. + */ + public static let selectedTextRanges = kAXSelectedTextRangesAttribute + /** + * The range of characters (not bytes) that are scrolled into view in the editable + * text element. + * + * Value: An AXValueRef of type kAXValueCFRange. + * + * Writable? No. + * + * Required for elements of role kAXTextAreaRole. Not required for any other + * elements, including those of role kAXTextFieldRole. + */ + public static let visibleCharacterRange = kAXVisibleCharacterRangeAttribute + /** + * The total number of characters (not bytes) in an editable text element. + * + * Value: CFNumberRef + * + * Writable? No. + * + * Required for editable text elements. + */ + public static let numberOfCharacters = kAXNumberOfCharactersAttribute + /** + * Value: CFArrayRef of AXUIElementRefs + * + * Writable? No. + * + * Optional? + */ + public static let sharedTextUIElements = kAXSharedTextUIElementsAttribute + /** + * Value: AXValueRef of type kAXValueCFRangeType + * + * Writable? No. + * + * Optional? + */ + public static let sharedCharacterRange = kAXSharedCharacterRangeAttribute + + // MARK: - Window, sheet, or drawer-specific attributes + + /** + * Whether a window is the main document window of an application. For an active + * app, the main window is the single active document window. For an inactive app, + * the main window is the single document window which would be active if the app + * were active. Main does not necessarily imply that the window has key focus. + * + * Value: A CFBooleanRef. True means the window is main. False means it is not. + * + * Writable? Yes. + * + * Required for all window elements. + */ + public static let main = kAXMainAttribute + /** + * Whether a window is currently minimized to the dock. + * + * Value: A CFBooleanRef. True means minimized. + * + * Writable? Yes. + * + * Required for all window elements that can be minimized. + */ + public static let minimized = kAXMinimizedAttribute + /** + * A convenience attribute so assistive apps can quickly access a window's close + * button element. + * + * Value: An AXUIElementRef of the window's close button element. + * + * Writable? No. + * + * Required for all window elements that have a close button. + */ + public static let closeButton = kAXCloseButtonAttribute + /** + * A convenience attribute so assistive apps can quickly access a window's zoom + * button element. + * + * Value: An AXUIElementRef of the window's zoom button element. + * + * Writable? No. + * + * Required for all window elements that have a zoom button. + */ + public static let zoomButton = kAXZoomButtonAttribute + /** + * A convenience attribute so assistive apps can quickly access a window's minimize + * button element. + * + * Value: An AXUIElementRef of the window's minimize button element. + * + * Writable? No. + * + * Required for all window elements that have a minimize button. + */ + public static let minimizeButton = kAXMinimizeButtonAttribute + /** + * A convenience attribute so assistive apps can quickly access a window's toolbar + * button element. + * + * Value: An AXUIElementRef of the window's toolbar button element. + * + * Writable? No. + * + * Required for all window elements that have a toolbar button. + */ + public static let toolbarButton = kAXToolbarButtonAttribute + /** + * A convenience attribute so assistive apps can quickly access a window's document + * proxy element. + * + * Value: An AXUIElementRef of the window's document proxy element. + * + * Writable? No. + * + * Required for all window elements that have a document proxy. + */ + public static let proxy = kAXProxyAttribute + /** + * A convenience attribute so assistive apps can quickly access a window's grow + * area element. + * + * Value: An AXUIElementRef of the window's grow area element. + * + * Writable? No. + * + * Required for all window elements that have a grow area. + */ + public static let growArea = kAXGrowAreaAttribute + /** + * Whether a window is modal. + * + * Value: A CFBooleanRef. True means the window is modal. + * + * Writable? No. + * + * Required for all window elements. + */ + public static let modal = kAXModalAttribute + /** + * A convenience attribute so assistive apps can quickly access a window's default + * button element, if any. + * + * Value: An AXUIElementRef of the window's default button element. + * + * Writable? No. + * + * Required for all window elements that have a default button. + */ + public static let defaultButton = kAXDefaultButtonAttribute + /** + * A convenience attribute so assistive apps can quickly access a window's cancel + * button element, if any. + * + * Value: An AXUIElementRef of the window's cancel button element. + * + * Writable? No. + * + * Required for all window elements that have a cancel button. + */ + public static let cancelButton = kAXCancelButtonAttribute + + // MARK: - Menu or menu item-specific attributes + + public static let menuItemCmdChar = kAXMenuItemCmdCharAttribute + public static let menuItemCmdVirtualKey = kAXMenuItemCmdVirtualKeyAttribute + public static let menuItemCmdGlyph = kAXMenuItemCmdGlyphAttribute + public static let menuItemCmdModifiers = kAXMenuItemCmdModifiersAttribute + public static let menuItemMarkChar = kAXMenuItemMarkCharAttribute + public static let menuItemPrimaryUIElement = kAXMenuItemPrimaryUIElementAttribute + + // MARK: - Application element-specific attributes + + public static let menuBar = kAXMenuBarAttribute + public static let windows = kAXWindowsAttribute + public static let frontmost = kAXFrontmostAttribute + public static let hidden = kAXHiddenAttribute + public static let mainWindow = kAXMainWindowAttribute + public static let focusedWindow = kAXFocusedWindowAttribute + public static let focusedUIElement = kAXFocusedUIElementAttribute + public static let extrasMenuBar = kAXExtrasMenuBarAttribute + + // MARK: - Date/time-specific attributes + + /** + * Convenience attribute that yields the hour field of a time field element. + * + * Value: A AXUIElementRef of role kAXTextFieldRole that is used to edit the + * hours in a time field element. + * + * Writable? No. + * + * Required for time field elements that display hours. + */ + public static let hourField = kAXHourFieldAttribute + /** + * Convenience attribute that yields the minute field of a time field element. + * + * Value: A AXUIElementRef of role kAXTextFieldRole that is used to edit the + * minutes in a time field element. + * + * Writable? No. + * + * Required for time field elements that display minutes. + */ + public static let minuteField = kAXMinuteFieldAttribute + /** + * Convenience attribute that yields the seconds field of a time field element. + * + * Value: A AXUIElementRef of role kAXTextFieldRole that is used to edit the + * seconds in a time field element. + * + * Writable? No. + * + * Required for time field elements that display seconds. + */ + public static let secondField = kAXSecondFieldAttribute + /** + * Convenience attribute that yields the AM/PM field of a time field element. + * + * Value: A AXUIElementRef of role kAXTextFieldRole that is used to edit the + * AM/PM setting in a time field element. + * + * Writable? No. + * + * Required for time field elements that displays an AM/PM setting. + */ + public static let ampmField = kAXAMPMFieldAttribute + /** + * Convenience attribute that yields the day field of a date field element. + * + * Value: A AXUIElementRef of role kAXTextFieldRole that is used to edit the + * day in a date field element. + * + * Writable? No. + * + * Required for date field elements that display days. + */ + public static let dayField = kAXDayFieldAttribute + /** + * Convenience attribute that yields the month field of a date field element. + * + * Value: A AXUIElementRef of role kAXTextFieldRole that is used to edit the + * month in a date field element. + * + * Writable? No. + * + * Required for date field elements that display months. + */ + public static let monthField = kAXMonthFieldAttribute + /** + * Convenience attribute that yields the year field of a date field element. + * + * Value: A AXUIElementRef of role kAXTextFieldRole that is used to edit the + * year in a date field element. + * + * Writable? No. + * + * Required for date field elements that display years. + */ + public static let yearField = kAXYearFieldAttribute + + // MARK: - Table, outline, or browser-specific attributes + + public static let rows = kAXRowsAttribute + public static let visibleRows = kAXVisibleRowsAttribute + public static let selectedRows = kAXSelectedRowsAttribute + public static let columns = kAXColumnsAttribute + /** + * Indicates the visible column sub-elements of a kAXBrowserRole element. + * This is the subset of a browser's kAXColumnsAttribute where each column in the + * array is one that is currently scrolled into view within the browser. It does + * not include any columns that are currently scrolled out of view. + * + * Value: A CFArrayRef of AXUIElementRefs representing the columns of a browser. + * The columns will be grandchild elements of the browser, and will generally be + * of role kAXScrollArea. + * + * Writable? No. + * + * Required for all browser elements. + */ + public static let visibleColumns = kAXVisibleColumnsAttribute + public static let selectedColumns = kAXSelectedColumnsAttribute + public static let sortDirection = kAXSortDirectionAttribute + public static let index = kAXIndexAttribute + public static let disclosing = kAXDisclosingAttribute + public static let disclosedRows = kAXDisclosedRowsAttribute + public static let disclosedByRow = kAXDisclosedByRowAttribute + public static let columnHeaderUIElements = kAXColumnHeaderUIElementsAttribute + + // MARK: - Outline attributes + + public static let disclosureLevel = kAXDisclosureLevelAttribute + + // MARK: - Matte-specific attributes + + public static let matteHole = kAXMatteHoleAttribute + public static let matteContentUIElement = kAXMatteContentUIElementAttribute + + // MARK: - Ruler-specific attributes + + public static let markerUIElements = kAXMarkerUIElementsAttribute + public static let units = kAXUnitsAttribute + public static let unitDescription = kAXUnitDescriptionAttribute + public static let markerType = kAXMarkerTypeAttribute + public static let markerTypeDescription = kAXMarkerTypeDescriptionAttribute + + // MARK: - Search field attributes + + public static let searchButton = kAXSearchButtonAttribute + public static let clearButton = kAXClearButtonAttribute + + // MARK: - Grid attributes + + public static let rowCount = kAXRowCountAttribute + public static let columnCount = kAXColumnCountAttribute + public static let orderedByRow = kAXOrderedByRowAttribute + + // MARK: - Level indicator attributes + + public static let warningValue = kAXWarningValueAttribute + public static let criticalValue = kAXCriticalValueAttribute + + // MARK: - Cell-based table attributes + + public static let selectedCells = kAXSelectedCellsAttribute + public static let visibleCells = kAXVisibleCellsAttribute + public static let rowHeaderUIElements = kAXRowHeaderUIElementsAttribute + + // MARK: - Cell attributes + + public static let rowIndexRange = kAXRowIndexRangeAttribute + public static let columnIndexRange = kAXColumnIndexRangeAttribute + + // MARK: - Layout area attributes + + public static let horizontalUnits = kAXHorizontalUnitsAttribute + public static let verticalUnits = kAXVerticalUnitsAttribute + public static let horizontalUnitDescription = kAXHorizontalUnitDescriptionAttribute + public static let verticalUnitDescription = kAXVerticalUnitDescriptionAttribute + public static let handles = kAXHandlesAttribute + + // MARK: - Obsolete/unknown attributes + + public static let text = kAXTextAttribute + public static let visibleText = kAXVisibleTextAttribute + public static let isEditable = kAXIsEditableAttribute + public static let columnTitles = kAXColumnTitlesAttribute + + // MARK: - UI element identification attributes + + public static let identifier = kAXIdentifierAttribute + + // MARK: - Attributes + + public static let ariaAtomic = kAXARIAAtomicAttribute + /** + * CFNumberRef, 1-based + */ + public static let ariaColumnCount = kAXARIAColumnCountAttribute + /** + * CFNumberRef, 1-based + */ + public static let ariaColumnIndex = kAXARIAColumnIndexAttribute + /** + * CFStringRef + */ + public static let ariaCurrent = kAXARIACurrentAttribute + /** + * CFStringRef + */ + public static let ariaLive = kAXARIALiveAttribute + /** + * CFNumberRef, 1-based + */ + public static let ariaPosInSet = kAXARIAPosInSetAttribute + /** + * CFStringRef + */ + public static let ariaRelevant = kAXARIARelevantAttribute + /** + * CFNumberRef, 1-based + */ + public static let ariaRowCount = kAXARIARowCountAttribute + /** + * CFNumberRef, 1-based + */ + public static let ariaRowIndex = kAXARIARowIndexAttribute + /** + * CFNumberRef, 1-based + */ + public static let ariaSetSize = kAXARIASetSizeAttribute + /** + * CFStringRef + */ + public static let accessKey = kAXAccessKeyAttribute + /** + * AXUIElementRef + */ + public static let activeElement = kAXActiveElementAttribute + /** + * CFStringRef + */ + public static let brailleLabel = kAXBrailleLabelAttribute + /** + * CFStringRef + */ + public static let brailleRoleDescription = kAXBrailleRoleDescriptionAttribute + /** + * CFBooleanRef + */ + public static let caretBrowsingEnabled = kAXCaretBrowsingEnabledAttribute + /** + * CFArrayRef of CFStringRef + */ + public static let domClassList = kAXDOMClassListAttribute + /** + * CFStringRef + */ + public static let domIdentifier = kAXDOMIdentifierAttribute + /** + * CFStringRef + */ + public static let datetimeValue = kAXDatetimeValueAttribute + /** + * CFArrayRef of AXUIElementRef + */ + public static let describedBy = kAXDescribedByAttribute + /** + * CFArrayRef of CFStringRef + */ + public static let dropEffects = kAXDropEffectsAttribute + /** + * AXUIElementRef + */ + public static let editableAncestor = kAXEditableAncestorAttribute + /** + * AXTextMarkerRef + */ + public static let endTextMarker = kAXEndTextMarkerAttribute + /** + * CFArrayRef of AXUIElementRef + */ + public static let errorMessageElements = kAXErrorMessageElementsAttribute + /** + * CFBooleanRef + */ + public static let expandedTextValue = kAXExpandedTextValueAttribute + /** + * AXUIElementRef + */ + public static let focusableAncestor = kAXFocusableAncestorAttribute + /** + * CFBooleanRef + */ + public static let grabbed = kAXGrabbedAttribute + /** + * CFBooleanRef + */ + public static let hasDocumentRoleAncestor = kAXHasDocumentRoleAncestorAttribute + /** + * CFBooleanRef + */ + public static let hasPopup = kAXHasPopupAttribute + /** + * CFBooleanRef + */ + public static let hasWebApplicationAncestor = kAXHasWebApplicationAncestorAttribute + /** + * AXUIElementRef + */ + public static let highestEditableAncestor = kAXHighestEditableAncestorAttribute + /** + * CFBooleanRef + */ + public static let inlineText = kAXInlineTextAttribute + /** + * CFRange + */ + public static let intersectionWithSelectionRange = kAXIntersectionWithSelectionRangeAttribute + /** + * CFStringRef + */ + public static let invalid = kAXInvalidAttribute + /** + * CFStringRef + */ + public static let keyShortcuts = kAXKeyShortcutsAttribute + /** + * CFArrayRef of AXUIElementRef + */ + public static let linkUIElements = kAXLinkUIElementsAttribute + /** + * CFBooleanRef + */ + public static let loaded = kAXLoadedAttribute + /** + * CFNumber, double, 0.0 - 1.0 + */ + public static let loadingProgress = kAXLoadingProgressAttribute + /** + * AXUIElementRef + */ + public static let mathBase = kAXMathBaseAttribute + /** + * CFStringRef + */ + public static let mathFencedClose = kAXMathFencedCloseAttribute + /** + * CFStringRef + */ + public static let mathFencedOpen = kAXMathFencedOpenAttribute + /** + * AXUIElementRef + */ + public static let mathFractionDenominator = kAXMathFractionDenominatorAttribute + /** + * AXUIElementRef + */ + public static let mathFractionNumerator = kAXMathFractionNumeratorAttribute + /** + * CFNumberRef + */ + public static let mathLineThickness = kAXMathLineThicknessAttribute + /** + * AXUIElementRef + */ + public static let mathOver = kAXMathOverAttribute + /** + * CFArrayRef of CFDictionary + */ + public static let mathPostscripts = kAXMathPostscriptsAttribute + /** + * CFArrayRef of CFDictionary + */ + public static let mathPrescripts = kAXMathPrescriptsAttribute + /** + * AXUIElementRef + */ + public static let mathRootIndex = kAXMathRootIndexAttribute + /** + * CFArrayRef of AXUIElementRef + */ + public static let mathRootRadicand = kAXMathRootRadicandAttribute + /** + * AXUIElementRef + */ + public static let mathSubscript = kAXMathSubscriptAttribute + /** + * AXUIElementRef + */ + public static let mathSuperscript = kAXMathSuperscriptAttribute + /** + * AXUIElementRef + */ + public static let mathUnder = kAXMathUnderAttribute + /** + * CFArrayRef of AXUIElementRef + */ + public static let owns = kAXOwnsAttribute + /** + * CFStringRef + */ + public static let popupValue = kAXPopupValueAttribute + /** + * CFBooleanRef + */ + public static let preventKeyboardDOMEventDispatch = kAXPreventKeyboardDOMEventDispatchAttribute + /** + * AXTextMarkerRangeRef + */ + public static let selectedTextMarkerRange = kAXSelectedTextMarkerRangeAttribute + /** + * AXTextMarkerRef + */ + public static let startTextMarker = kAXStartTextMarkerAttribute + /** + * AXTextMarkerRangeRef + */ + public static let textInputMarkedTextMarkerRange = kAXTextInputMarkedTextMarkerRangeAttribute + /** + * CFBooleanRef + */ + public static let valueAutofillAvailable = kAXValueAutofillAvailableAttribute + + // MARK: - Attributed string keys + + public static let didSpellCheckString = kAXDidSpellCheckStringAttribute + /** + * CFBooleanRef + */ + public static let highlightString = kAXHighlightStringAttribute + /** + * CFBooleanRef + */ + public static let isSuggestedDeletionString = kAXIsSuggestedDeletionStringAttribute + /** + * CFBooleanRef + */ + public static let isSuggestedInsertionString = kAXIsSuggestedInsertionStringAttribute + /** + * CFBooleanRef + */ + public static let isSuggestionString = kAXIsSuggestionStringAttribute + } +} diff --git a/Sources/AccessibilityControl/Accessibility+Notification.swift b/Sources/AccessibilityControl/Accessibility+Notification.swift new file mode 100644 index 0000000..748662a --- /dev/null +++ b/Sources/AccessibilityControl/Accessibility+Notification.swift @@ -0,0 +1,296 @@ +import ApplicationServices + +// Extracted from: System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/HIServices.framework/Versions/A/Headers/AXNotificationConstants.h, System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/HIServices.framework/Versions/A/Headers/AXWebConstants.h + +public extension Accessibility.Notification { + // MARK: - Focus Notifications + + /** + * Abstract: + * Notification of a change in the main window. + * + * Discussion: + * Value is the new main window UIElement or the + * Application UIElement if there's no main window. + */ + static let mainWindowChanged = Self(kAXMainWindowChangedNotification) + /** + * Abstract: + * Notification that the focused window changed. + */ + static let focusedWindowChanged = Self(kAXFocusedWindowChangedNotification) + /** + * Abstract: + * Notification that the focused UI element has changed. + * + * Discussion: + * Value is the new focused UIElement or + * the Application UIElement if there's no focus + */ + static let focusedUIElementChanged = Self(kAXFocusedUIElementChangedNotification) + + // MARK: - Application Notifications + + /** + * Abstract: + * Notification that an application was activated. + * + * Discussion: + * Value is an application UIElement. + */ + static let applicationActivated = Self(kAXApplicationActivatedNotification) + /** + * Abstract: + * Notification that an application was deactivated. + * + * Discussion: + * Value is an application UIElement + */ + static let applicationDeactivated = Self(kAXApplicationDeactivatedNotification) + /** + * Abstract: + * Notification that an application has been hidden. + * + * Discussion: + * Value is an application UIElement + */ + static let applicationHidden = Self(kAXApplicationHiddenNotification) + /** + * Abstract: + * Notification that an application is no longer hidden. + * + * Discussion: + * Value is an application UIElement + */ + static let applicationShown = Self(kAXApplicationShownNotification) + + // MARK: - Window Notifications + + /** + * Abstract: + * Notification that a window was created. + * + * Discussion: + * Value is a new window UIElement + */ + static let windowCreated = Self(kAXWindowCreatedNotification) + /** + * Abstract: + * Notification that a window moved. + * + * Discussion: + * This notification is sent at the end of the window move, not continuously as the window is being moved. + * + * Value is the moved window UIElement + */ + static let windowMoved = Self(kAXWindowMovedNotification) + /** + * Abstract: + * Notification that a window was resized. + * + * Discussion: + * This notification is sent at the end of the window resize, not continuously as the window is being resized. + * + * Value is the resized window UIElement + */ + static let windowResized = Self(kAXWindowResizedNotification) + /** + * Abstract: + * Notification that a window was minimized. + * + * Discussion: + * Value is the minimized window UIElement + */ + static let windowMiniaturized = Self(kAXWindowMiniaturizedNotification) + /** + * Abstract: + * Notification that a window is no longer minimized. + * + * Discussion: + * Value is the unminimized window UIElement + */ + static let windowDeminiaturized = Self(kAXWindowDeminiaturizedNotification) + + // MARK: - New Drawer, Sheet, and Help Notifications + + /** + * Abstract: + * Notification that a drawer was created. + */ + static let drawerCreated = Self(kAXDrawerCreatedNotification) + /** + * Abstract: + * Notification that a sheet was created. + */ + static let sheetCreated = Self(kAXSheetCreatedNotification) + /** + * Abstract: + * Notification that a help tag was created. + */ + static let helpTagCreated = Self(kAXHelpTagCreatedNotification) + + // MARK: - Element Notifications + + /** + * Discussion: + * This notification is sent when the value of the UIElement's value attribute has changed, not when the value of any other attribute has changed. + * + * Value is the modified UIElement + */ + static let valueChanged = Self(kAXValueChangedNotification) + /** + * Discussion: + * The returned UIElement is no longer valid in the target application. You can still use the local reference + * with calls like CFEqual (for example, to remove it from a list), but you should not pass it to the accessibility APIs. + * + * Value is the destroyed UIElement + */ + static let uiElementDestroyed = Self(kAXUIElementDestroyedNotification) + /** + * Abstract: + * Notification that an element's busy state has changed. + * + * Discussion: + * Value is the (un)busy UIElement. + */ + static let elementBusyChanged = Self(kAXElementBusyChangedNotification) + + // MARK: - Menu Notifications + + /** + * Abstract: + * Notification that a menu has been opened. + * + * Discussion: + * Value is the opened menu UIElement. + */ + static let menuOpened = Self(kAXMenuOpenedNotification) + /** + * Abstract: + * Notification that a menu has been closed. + * + * Discussion: + * Value is the closed menu UIElement. + */ + static let menuClosed = Self(kAXMenuClosedNotification) + /** + * Abstract: + * Notification that a menu item has been seleted. + * + * Discussion: + * Value is the selected menu item UIElement. + */ + static let menuItemSelected = Self(kAXMenuItemSelectedNotification) + + // MARK: - Table/outline notifications + + /** + * Abstract: + * Notification that the number of rows in this table has changed. + */ + static let rowCountChanged = Self(kAXRowCountChangedNotification) + + // MARK: - Outline notifications + + /** + * Abstract: + * Notification that a row in an outline has been expanded. + * + * Discussion: + * The value is the collapsed row UIElement. + */ + static let rowExpanded = Self(kAXRowExpandedNotification) + /** + * Abstract: + * Notification that a row in an outline has been collapsed. + * + * Discussion: + * The value is the collapsed row UIElement. + */ + static let rowCollapsed = Self(kAXRowCollapsedNotification) + + // MARK: - Cell-based table notifications + + /** + * Abstract: + * Notification that the selected cells have changed. + */ + static let selectedCellsChanged = Self(kAXSelectedCellsChangedNotification) + + // MARK: - Layout area notifications + + /** + * Abstract: + * Notification that the units have changed. + */ + static let unitsChanged = Self(kAXUnitsChangedNotification) + /** + * Abstract: + * Notification that the selected children have moved. + */ + static let selectedChildrenMoved = Self(kAXSelectedChildrenMovedNotification) + + // MARK: - Other notifications + + /** + * Abstract: + * Notification that a different subset of this element's children were selected. + */ + static let selectedChildrenChanged = Self(kAXSelectedChildrenChangedNotification) + /** + * Abstract: + * Notification that this element has been resized. + */ + static let resized = Self(kAXResizedNotification) + /** + * Abstract: + * Notification that this element has moved. + */ + static let moved = Self(kAXMovedNotification) + /** + * Abstract: + * Notification that an element was created. + */ + static let created = Self(kAXCreatedNotification) + /** + * Abstract: + * Notification that the set of selected rows changed. + */ + static let selectedRowsChanged = Self(kAXSelectedRowsChangedNotification) + /** + * Abstract: + * Notification that the set of selected columns changed. + */ + static let selectedColumnsChanged = Self(kAXSelectedColumnsChangedNotification) + /** + * Abstract: + * Notification that a different set of text was selected. + */ + static let selectedTextChanged = Self(kAXSelectedTextChangedNotification) + /** + * Abstract: + * Notification that the title changed. + */ + static let titleChanged = Self(kAXTitleChangedNotification) + /** + * Abstract: + * Notification that the layout changed. + */ + static let layoutChanged = Self(kAXLayoutChangedNotification) + /** + * Abstract: + * Notification to request an announcement to be spoken. + */ + static let announcementRequested = Self(kAXAnnouncementRequestedNotification) + + // MARK: - Notifications + + static let activeElementChanged = Self(kAXActiveElementChangedNotification) + static let currentStateChanged = Self(kAXCurrentStateChangedNotification) + static let expandedChanged = Self(kAXExpandedChangedNotification) + static let invalidStatusChanged = Self(kAXInvalidStatusChangedNotification) + static let layoutComplete = Self(kAXLayoutCompleteNotification) + static let liveRegionChanged = Self(kAXLiveRegionChangedNotification) + static let liveRegionCreated = Self(kAXLiveRegionCreatedNotification) + static let loadComplete = Self(kAXLoadCompleteNotification) +} diff --git a/Sources/AccessibilityControl/Accessibility+ParameterizedAttributeKey.swift b/Sources/AccessibilityControl/Accessibility+ParameterizedAttributeKey.swift new file mode 100644 index 0000000..f3b3faf --- /dev/null +++ b/Sources/AccessibilityControl/Accessibility+ParameterizedAttributeKey.swift @@ -0,0 +1,158 @@ +import ApplicationServices + +// Extracted from: System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/HIServices.framework/Versions/A/Headers/AXAttributeConstants.h, System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/HIServices.framework/Versions/A/Headers/AXWebConstants.h + +extension Accessibility { + public enum ParameterizedAttributeKey { + // MARK: - Text Suite Parameterized Attributes + + public static let lineForIndex = kAXLineForIndexParameterizedAttribute + public static let rangeForLine = kAXRangeForLineParameterizedAttribute + public static let stringForRange = kAXStringForRangeParameterizedAttribute + public static let rangeForPosition = kAXRangeForPositionParameterizedAttribute + public static let rangeForIndex = kAXRangeForIndexParameterizedAttribute + public static let boundsForRange = kAXBoundsForRangeParameterizedAttribute + public static let rtfForRange = kAXRTFForRangeParameterizedAttribute + public static let attributedStringForRange = kAXAttributedStringForRangeParameterizedAttribute + public static let styleRangeForIndex = kAXStyleRangeForIndexParameterizedAttribute + + // MARK: - Cell-based table parameterized attributes + + public static let cellForColumnAndRow = kAXCellForColumnAndRowParameterizedAttribute + + // MARK: - Layout area parameterized attributes + + public static let layoutPointForScreenPoint = kAXLayoutPointForScreenPointParameterizedAttribute + public static let layoutSizeForScreenSize = kAXLayoutSizeForScreenSizeParameterizedAttribute + public static let screenPointForLayoutPoint = kAXScreenPointForLayoutPointParameterizedAttribute + public static let screenSizeForLayoutSize = kAXScreenSizeForLayoutSizeParameterizedAttribute + + // MARK: - Parameterized Attributes + + public static let attributedStringForTextMarkerRange = kAXAttributedStringForTextMarkerRangeParameterizedAttribute + /** + * (NSValue *) - (rectValue); param: AXTextMarkerRangeRef + */ + public static let boundsForTextMarkerRange = kAXBoundsForTextMarkerRangeParameterizedAttribute + + // MARK: - (NSValue *) - (rectValue); param: (NSValue *) - (rectValue) + + public static let convertRelativeFrame = kAXConvertRelativeFrameParameterizedAttribute + /** + * CFNumberRef; param: AXTextMarkerRef + */ + public static let indexForTextMarker = kAXIndexForTextMarkerParameterizedAttribute + /** + * AXTextMarkerRangeRef; param: AXTextMarkerRef + */ + public static let leftLineTextMarkerRangeForTextMarker = kAXLeftLineTextMarkerRangeForTextMarkerParameterizedAttribute + /** + * AXTextMarkerRangeRef; param: AXTextMarkerRef + */ + public static let leftWordTextMarkerRangeForTextMarker = kAXLeftWordTextMarkerRangeForTextMarkerParameterizedAttribute + /** + * CFNumberRef; param: AXTextMarkerRangeRef + */ + public static let lengthForTextMarkerRange = kAXLengthForTextMarkerRangeParameterizedAttribute + /** + * CFNumberRef; param: AXTextMarkerRef + */ + public static let lineForTextMarker = kAXLineForTextMarkerParameterizedAttribute + /** + * AXTextMarkerRangeRef; param: AXTextMarkerRef + */ + public static let lineTextMarkerRangeForTextMarker = kAXLineTextMarkerRangeForTextMarkerParameterizedAttribute + /** + * AXTextMarkerRef; param: AXTextMarkerRef + */ + public static let nextLineEndTextMarkerForTextMarker = kAXNextLineEndTextMarkerForTextMarkerParameterizedAttribute + /** + * AXTextMarkerRef; param: AXTextMarkerRef + */ + public static let nextParagraphEndTextMarkerForTextMarker = kAXNextParagraphEndTextMarkerForTextMarkerParameterizedAttribute + /** + * AXTextMarkerRef; param: AXTextMarkerRef + */ + public static let nextSentenceEndTextMarkerForTextMarker = kAXNextSentenceEndTextMarkerForTextMarkerParameterizedAttribute + /** + * AXTextMarkerRef; param: AXTextMarkerRef + */ + public static let nextTextMarkerForTextMarker = kAXNextTextMarkerForTextMarkerParameterizedAttribute + /** + * AXTextMarkerRef; param: AXTextMarkerRef + */ + public static let nextWordEndTextMarkerForTextMarker = kAXNextWordEndTextMarkerForTextMarkerParameterizedAttribute + /** + * AXTextMarkerRangeRef; param: AXTextMarkerRef + */ + public static let paragraphTextMarkerRangeForTextMarker = kAXParagraphTextMarkerRangeForTextMarkerParameterizedAttribute + /** + * AXTextMarkerRef; param: AXTextMarkerRef + */ + public static let previousLineStartTextMarkerForTextMarker = kAXPreviousLineStartTextMarkerForTextMarkerParameterizedAttribute + /** + * AXTextMarkerRef; param: AXTextMarkerRef + */ + public static let previousParagraphStartTextMarkerForTextMarker = kAXPreviousParagraphStartTextMarkerForTextMarkerParameterizedAttribute + /** + * AXTextMarkerRef; param: AXTextMarkerRef + */ + public static let previousSentenceStartTextMarkerForTextMarker = kAXPreviousSentenceStartTextMarkerForTextMarkerParameterizedAttribute + /** + * AXTextMarkerRef; param: AXTextMarkerRef + */ + public static let previousTextMarkerForTextMarker = kAXPreviousTextMarkerForTextMarkerParameterizedAttribute + /** + * AXTextMarkerRef; param: AXTextMarkerRef + */ + public static let previousWordStartTextMarkerForTextMarker = kAXPreviousWordStartTextMarkerForTextMarkerParameterizedAttribute + /** + * AXTextMarkerRangeRef; param: AXTextMarkerRef + */ + public static let rightLineTextMarkerRangeForTextMarker = kAXRightLineTextMarkerRangeForTextMarkerParameterizedAttribute + /** + * AXTextMarkerRangeRef; param: AXTextMarkerRef + */ + public static let rightWordTextMarkerRangeForTextMarker = kAXRightWordTextMarkerRangeForTextMarkerParameterizedAttribute + /** + * AXTextMarkerRangeRef; param: AXTextMarkerRef + */ + public static let sentenceTextMarkerRangeForTextMarker = kAXSentenceTextMarkerRangeForTextMarkerParameterizedAttribute + /** + * CFStringRef; param: AXTextMarkerRef + */ + public static let stringForTextMarkerRange = kAXStringForTextMarkerRangeParameterizedAttribute + /** + * AXTextMarkerRangeRef; param: AXTextMarkerRef + */ + public static let styleTextMarkerRangeForTextMarker = kAXStyleTextMarkerRangeForTextMarkerParameterizedAttribute + /** + * AXTextMarkerRef; param: CFNumberRef + */ + public static let textMarkerForIndex = kAXTextMarkerForIndexParameterizedAttribute + + // MARK: - AXTextMarkerRef; param: (NSValue *) - (pointValue) + + public static let textMarkerForPosition = kAXTextMarkerForPositionParameterizedAttribute + /** + * CFBooleanRef; param: AXTextMarkerRef + */ + public static let textMarkerIsValid = kAXTextMarkerIsValidParameterizedAttribute + /** + * AXTextMarkerRangeRef; param: CFNumberRef + */ + public static let textMarkerRangeForLine = kAXTextMarkerRangeForLineParameterizedAttribute + /** + * AXTextMarkerRangeRef; param: AXUIElementRef + */ + public static let textMarkerRangeForUIElement = kAXTextMarkerRangeForUIElementParameterizedAttribute + /** + * AXTextMarkerRangeRef; param: CFArrayRef of AXTextMarkerRef + */ + public static let textMarkerRangeForUnorderedTextMarkers = kAXTextMarkerRangeForUnorderedTextMarkersParameterizedAttribute + /** + * AXUIElementRef; param: AXTextMarkerRef + */ + public static let uiElementForTextMarker = kAXUIElementForTextMarkerParameterizedAttribute + } +} diff --git a/Sources/AccessibilityControl/Accessibility+Role.swift b/Sources/AccessibilityControl/Accessibility+Role.swift index 6324efd..8b5bf00 100644 --- a/Sources/AccessibilityControl/Accessibility+Role.swift +++ b/Sources/AccessibilityControl/Accessibility+Role.swift @@ -1,8 +1,11 @@ import ApplicationServices +// Extracted from: System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/HIServices.framework/Versions/A/Headers/AXRoleConstants.h, System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/HIServices.framework/Versions/A/Headers/AXWebConstants.h + extension Accessibility { - // https://developer.apple.com/documentation/applicationservices/carbon_accessibility/roles public enum Role { + // MARK: - Standard Roles + public static let application = kAXApplicationRole public static let systemWide = kAXSystemWideRole public static let window = kAXWindowRole @@ -20,7 +23,111 @@ extension Accessibility { public static let table = kAXTableRole public static let column = kAXColumnRole public static let row = kAXRowRole + /** + * Discussion: + * An element that contains row-based data. It may use disclosure triangles to manage the + * display of hierarchies within the data. It may arrange each row's data into columns and + * offer a header button above each column. The best example is the list view in a Finder + * window or Open/Save dialog. + * + * Outlines are typically children of AXScrollAreas, which manages the horizontal and/or + * vertical scrolling for the outline. Outlines are expected to follow certain conventions + * with respect to their hierarchy of sub-elements. In particular, if the outline uses + * columns, the data should be accessible via either rows or columns. Thus, the data in a + * given cell will be represented as two diffrent elements. Here's a hierarchy for a + * typical outline: + * + *
+         * AXScrollArea (parent of the outline)
+         * AXScrollBar (if necessary, horizontal)
+         * AXScrollBar (if necessary, vertical)
+         * AXOutline
+         * AXGroup (header buttons, optional)
+         * AXButton, AXMenuButton, or  (header button)
+         * ...
+         * AXRow (first row)
+         * AXStaticText (just one possible example)
+         * AXButton (just another possible example)
+         * AXTextField (ditto)
+         * AXCheckBox (ditto)
+         * AXRow (as above)
+         * ...
+         * AXColumn (first column)
+         * AXStaticText (assumes the first column displays text)
+         * AXStaticText
+         * ...
+         * AXColumn (second column)
+         * AXButton (assumes the second column displays buttons)
+         * AXButton
+         * ...
+         * ...
+         * 
+ * + * Supported attributes: + * + *
+ *
AXFocused
+ *
(w)
+ *
AXRows
+ *
Array of subset of AXChildren that are rows
+ *
AXVisibleRows
+ *
Array of subset of AXRows that are visible
+ *
AXSelectedRows
+ *
Array of subset of AXRows that are selected (w)
+ *
AXColumns
+ *
Array of subset of children that are columns
+ *
AXVisibleColumns
+ *
Array of subset of columns that are visible
+ *
AXSelectedColumns
+ *
Array of subset of columns that are selected (o)
+ *
AXHeader
+ *
The AXGroup element that contains the header buttons (o)
+ *
+ */ public static let outline = kAXOutlineRole + /** + * Discussion: + * An element that contains columns of hierarchical data. Examples include the column view + * in Finder windows and Open/Save dialogs. Carbon's Data Browser in column view mode + * represents itself as an AXBrowser. Cocoa's NSBrowser represents itself as an AXBrowser. + * + * Browser elements are expected to have a particular hierarchy of sub-elements within it. + * In particular, the child of an AXBrowser must be an AXScrollArea that manages the + * horizontal scrolling. The horizontal AXScrollArea must include a child for each column + * the interface displays. Columns can be any role that makes sense. Typically, columns + * are vertical AXScrollAreas with AXList children. Here's a hierarchy for a typical + * browser: + * + *
+         * AXBrowser
+         * AXScrollArea (manages the horizontal scrolling)
+         * AXScrollBar (horizontal scroll bar)
+         * AXScrollArea (first column)
+         * AXScrollBar (column's vertical scroll bar)
+         * AXList (column content is typically a list, but it could be another role)
+         *  (cell)
+         * ...
+         *  (cell)
+         * AXScrollArea (second column)
+         * ...
+         * AXScrollArea (third column)
+         * ...
+         * AXGroup (preview column)
+         * ...
+         * 
+ * + * Attributes: + *
    + *
  • AXFocused (w)
  • + *
  • AXColumns - Array of the grandchild column elements, which are typically + * of the AXScrollArea role.
  • + *
  • AXVisibleColumns - Array of the subset of elements in the AXColumns array + * that are currently visible.
  • + *
  • AXColumnTitles (o)
  • + *
  • AXHorizontalScrollBar - The horizontal AXScrollBar of the browser's child + * AXScrollArea.
  • + *
+ */ public static let browser = kAXBrowserRole public static let scrollArea = kAXScrollAreaRole public static let scrollBar = kAXScrollBarRole @@ -39,6 +146,7 @@ extension Accessibility { public static let textField = kAXTextFieldRole public static let textArea = kAXTextAreaRole public static let staticText = kAXStaticTextRole + public static let heading = kAXHeadingRole public static let menuBar = kAXMenuBarRole public static let menuBarItem = kAXMenuBarItemRole public static let menu = kAXMenuRole @@ -51,6 +159,18 @@ extension Accessibility { public static let helpTag = kAXHelpTagRole public static let matte = kAXMatteRole public static let dockItem = kAXDockItemRole + public static let ruler = kAXRulerRole + public static let rulerMarker = kAXRulerMarkerRole + public static let grid = kAXGridRole + public static let levelIndicator = kAXLevelIndicatorRole public static let cell = kAXCellRole + public static let layoutArea = kAXLayoutAreaRole + public static let layoutItem = kAXLayoutItemRole + public static let handle = kAXHandleRole + public static let popover = kAXPopoverRole + + // MARK: - Roles + + public static let imageMap = kAXImageMapRole } } diff --git a/Sources/AccessibilityControl/Accessibility+Subrole.swift b/Sources/AccessibilityControl/Accessibility+Subrole.swift index 9770cbb..336919a 100644 --- a/Sources/AccessibilityControl/Accessibility+Subrole.swift +++ b/Sources/AccessibilityControl/Accessibility+Subrole.swift @@ -1,27 +1,51 @@ import ApplicationServices +// Extracted from: System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/HIServices.framework/Versions/A/Headers/AXRoleConstants.h, System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/HIServices.framework/Versions/A/Headers/AXWebConstants.h + extension Accessibility { public enum Subrole { - public static let `switch` = kAXSwitchSubrole + // MARK: - Standard subroles + public static let closeButton = kAXCloseButtonSubrole public static let minimizeButton = kAXMinimizeButtonSubrole public static let zoomButton = kAXZoomButtonSubrole public static let toolbarButton = kAXToolbarButtonSubrole + public static let fullScreenButton = kAXFullScreenButtonSubrole public static let secureTextField = kAXSecureTextFieldSubrole public static let tableRow = kAXTableRowSubrole public static let outlineRow = kAXOutlineRowSubrole public static let unknown = kAXUnknownSubrole + + // MARK: - New subroles + public static let standardWindow = kAXStandardWindowSubrole public static let dialog = kAXDialogSubrole public static let systemDialog = kAXSystemDialogSubrole public static let floatingWindow = kAXFloatingWindowSubrole public static let systemFloatingWindow = kAXSystemFloatingWindowSubrole + public static let decorative = kAXDecorativeSubrole public static let incrementArrow = kAXIncrementArrowSubrole public static let decrementArrow = kAXDecrementArrowSubrole public static let incrementPage = kAXIncrementPageSubrole public static let decrementPage = kAXDecrementPageSubrole public static let sortButton = kAXSortButtonSubrole public static let searchField = kAXSearchFieldSubrole + public static let timeline = kAXTimelineSubrole + public static let ratingIndicator = kAXRatingIndicatorSubrole + public static let contentList = kAXContentListSubrole + /** + * superceded by kAXDescriptionListSubrole in OS X 10.9 + */ + public static let definitionList = kAXDefinitionListSubrole + /** + * OS X 10.9 and later + */ + public static let descriptionList = kAXDescriptionListSubrole + public static let toggle = kAXToggleSubrole + public static let `switch` = kAXSwitchSubrole + + // MARK: - Dock subroles + public static let applicationDockItem = kAXApplicationDockItemSubrole public static let documentDockItem = kAXDocumentDockItemSubrole public static let folderDockItem = kAXFolderDockItemSubrole @@ -29,6 +53,66 @@ extension Accessibility { public static let urlDockItem = kAXURLDockItemSubrole public static let dockExtraDockItem = kAXDockExtraDockItemSubrole public static let trashDockItem = kAXTrashDockItemSubrole + public static let separatorDockItem = kAXSeparatorDockItemSubrole public static let processSwitcherList = kAXProcessSwitcherListSubrole + + // MARK: - Subroles + + public static let applicationAlertDialog = kAXApplicationAlertDialogSubrole + public static let applicationAlert = kAXApplicationAlertSubrole + public static let applicationDialog = kAXApplicationDialogSubrole + public static let applicationGroup = kAXApplicationGroupSubrole + public static let applicationLog = kAXApplicationLogSubrole + public static let applicationMarquee = kAXApplicationMarqueeSubrole + public static let applicationStatus = kAXApplicationStatusSubrole + public static let applicationTimer = kAXApplicationTimerSubrole + public static let audio = kAXAudioSubrole + public static let codeStyleGroup = kAXCodeStyleGroupSubrole + public static let definition = kAXDefinitionSubrole + public static let deleteStyleGroup = kAXDeleteStyleGroupSubrole + public static let details = kAXDetailsSubrole + public static let documentArticle = kAXDocumentArticleSubrole + public static let documentMath = kAXDocumentMathSubrole + public static let documentNote = kAXDocumentNoteSubrole + public static let emptyGroup = kAXEmptyGroupSubrole + public static let fieldset = kAXFieldsetSubrole + public static let fileUploadButton = kAXFileUploadButtonSubrole + public static let insertStyleGroup = kAXInsertStyleGroupSubrole + public static let landmarkBanner = kAXLandmarkBannerSubrole + public static let landmarkComplementary = kAXLandmarkComplementarySubrole + public static let landmarkContentInfo = kAXLandmarkContentInfoSubrole + public static let landmarkMain = kAXLandmarkMainSubrole + public static let landmarkNavigation = kAXLandmarkNavigationSubrole + public static let landmarkRegion = kAXLandmarkRegionSubrole + public static let landmarkSearch = kAXLandmarkSearchSubrole + public static let mathFenceOperator = kAXMathFenceOperatorSubrole + public static let mathFenced = kAXMathFencedSubrole + public static let mathFraction = kAXMathFractionSubrole + public static let mathIdentifier = kAXMathIdentifierSubrole + public static let mathMultiscript = kAXMathMultiscriptSubrole + public static let mathNumber = kAXMathNumberSubrole + public static let mathOperator = kAXMathOperatorSubrole + public static let mathRoot = kAXMathRootSubrole + public static let mathRow = kAXMathRowSubrole + public static let mathSeparatorOperator = kAXMathSeparatorOperatorSubrole + public static let mathSquareRoot = kAXMathSquareRootSubrole + public static let mathSubscriptSuperscript = kAXMathSubscriptSuperscriptSubrole + public static let mathTableCell = kAXMathTableCellSubrole + public static let mathTableRow = kAXMathTableRowSubrole + public static let mathTable = kAXMathTableSubrole + public static let mathText = kAXMathTextSubrole + public static let mathUnderOver = kAXMathUnderOverSubrole + public static let meter = kAXMeterSubrole + public static let rubyInline = kAXRubyInlineSubrole + public static let rubyText = kAXRubyTextSubrole + public static let subscriptStyleGroup = kAXSubscriptStyleGroupSubrole + public static let summary = kAXSummarySubrole + public static let superscriptStyleGroup = kAXSuperscriptStyleGroupSubrole + public static let tabPanel = kAXTabPanelSubrole + public static let term = kAXTermSubrole + public static let timeGroup = kAXTimeGroupSubrole + public static let userInterfaceTooltip = kAXUserInterfaceTooltipSubrole + public static let video = kAXVideoSubrole + public static let webApplication = kAXWebApplicationSubrole } } diff --git a/Sources/AccessibilityControl/Accessibility+Value.swift b/Sources/AccessibilityControl/Accessibility+Value.swift new file mode 100644 index 0000000..ec6c932 --- /dev/null +++ b/Sources/AccessibilityControl/Accessibility+Value.swift @@ -0,0 +1,19 @@ +import ApplicationServices + +// Extracted from: System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/HIServices.framework/Versions/A/Headers/AXValueConstants.h + +extension Accessibility { + public enum Value { + // MARK: - orientations (see kAXOrientationAttribute in AXAttributeConstants.h) + + public static let horizontalOrientation = kAXHorizontalOrientationValue + public static let verticalOrientation = kAXVerticalOrientationValue + public static let unknownOrientation = kAXUnknownOrientationValue + + // MARK: - sort directions (see kAXSortDirectionAttribute in AXAttributeConstants.h) + + public static let ascendingSortDirection = kAXAscendingSortDirectionValue + public static let descendingSortDirection = kAXDescendingSortDirectionValue + public static let unknownSortDirection = kAXUnknownSortDirectionValue + } +} diff --git a/Sources/AccessibilityControl/Element+Hierarchy.swift b/Sources/AccessibilityControl/Element+Hierarchy.swift new file mode 100644 index 0000000..f892178 --- /dev/null +++ b/Sources/AccessibilityControl/Element+Hierarchy.swift @@ -0,0 +1,252 @@ +import Foundation +import ApplicationServices + +// MARK: - Wire Keys + +/// Stable IPC protocol keys used by `AXUIElementCopyHierarchy`. +/// These are hardcoded wire values — no runtime resolution needed. +private enum HierarchyWireKey { + // Option keys + static let arrayAttributes = "AXCHAA" + static let skipInspection = "AXCHSIA" + static let maxArrayCount = "AXCHMAC" + static let maxDepth = "AXCHMD" + static let returnErrors = "AXCHRE" + static let truncateStrings = "AXTRUNC" + + // Result keys + static let value = "value" + static let count = "count" + static let error = "error" + static let incomplete = "incmplt" +} + +// MARK: - dlsym resolution + +private typealias AXUIElementCopyHierarchyFn = @convention(c) ( + AXUIElement, + CFArray, + CFTypeRef?, + UnsafeMutablePointer?>? +) -> AXError + +private let _copyHierarchy: AXUIElementCopyHierarchyFn? = { + guard let sym = dlsym(dlopen(nil, RTLD_NOW), "AXUIElementCopyHierarchy") else { + return nil + } + return unsafeBitCast(sym, to: AXUIElementCopyHierarchyFn.self) +}() + +// MARK: - HierarchyOptions + +extension Accessibility { + /// Options for `AXUIElementCopyHierarchy`. + public struct HierarchyOptions { + /// Extra array-valued attributes to traverse into (e.g. `kAXChildrenAttribute`). + public var arrayAttributes: [String]? + + /// Attributes to return values for but NOT recurse into. + public var skipInspectionAttributes: [String]? + + /// Cap per-attribute array expansion. + public var maxArrayCount: Int? + + /// Max traversal depth (effect varies by macOS version). + public var maxDepth: Int? + + /// Include error wrappers for failed attribute fetches. + public var returnAttributeErrors: Bool? + + /// Truncate string values to 512 characters. + public var truncateStrings: Bool? + + public init( + arrayAttributes: [String]? = nil, + skipInspectionAttributes: [String]? = nil, + maxArrayCount: Int? = nil, + maxDepth: Int? = nil, + returnAttributeErrors: Bool? = nil, + truncateStrings: Bool? = nil + ) { + self.arrayAttributes = arrayAttributes + self.skipInspectionAttributes = skipInspectionAttributes + self.maxArrayCount = maxArrayCount + self.maxDepth = maxDepth + self.returnAttributeErrors = returnAttributeErrors + self.truncateStrings = truncateStrings + } + + fileprivate func toCFDictionary() -> CFDictionary? { + var dict = [String: Any]() + + if let arrayAttributes { + dict[HierarchyWireKey.arrayAttributes] = arrayAttributes + } + if let skipInspectionAttributes { + dict[HierarchyWireKey.skipInspection] = skipInspectionAttributes + } + if let maxArrayCount { + dict[HierarchyWireKey.maxArrayCount] = maxArrayCount + } + if let maxDepth { + dict[HierarchyWireKey.maxDepth] = maxDepth + } + if let returnAttributeErrors { + dict[HierarchyWireKey.returnErrors] = returnAttributeErrors + } + if let truncateStrings { + dict[HierarchyWireKey.truncateStrings] = truncateStrings + } + + guard !dict.isEmpty else { return nil } + return dict as CFDictionary + } + } +} + +// MARK: - HierarchyResult + +extension Accessibility { + /// Result of `AXUIElementCopyHierarchy`, wrapping the raw output dictionary. + /// + /// The raw dictionary is keyed by `AXUIElementRef` with per-element attribute data as values. + public struct HierarchyResult { + /// The raw `NSDictionary` returned by the API. + public let raw: NSDictionary + + /// Number of element entries in the result. + public var elementCount: Int { raw.count } + + /// All `AXUIElement` keys as `Element`s. + public var elements: [Element] { + raw.allKeys.compactMap { key -> Element? in + guard CFGetTypeID(key as CFTypeRef) == AXUIElementGetTypeID() else { return nil } + return Element(raw: key as! AXUIElement) + } + } + + /// Look up one element's attribute data. + public func snapshot(for element: Element) -> ElementSnapshot? { + guard let perElement = raw[element.raw] as? NSDictionary else { return nil } + return ElementSnapshot(raw: perElement) + } + + /// Iterate all element snapshots. + public func allSnapshots() -> [(element: Element, snapshot: ElementSnapshot)] { + raw.compactMap { key, value -> (Element, ElementSnapshot)? in + guard CFGetTypeID(key as CFTypeRef) == AXUIElementGetTypeID(), + let perElement = value as? NSDictionary else { return nil } + return (Element(raw: key as! AXUIElement), ElementSnapshot(raw: perElement)) + } + } + + // MARK: - ElementSnapshot + + /// Per-element attribute data from a hierarchy result. + public struct ElementSnapshot { + fileprivate let raw: NSDictionary + + /// `true` for uninspectable sentinel entries (`{ "incmplt": true }`). + public var isIncomplete: Bool { + raw.count == 1 + && (raw[HierarchyWireKey.incomplete] as? Bool) == true + } + + /// Look up one attribute by name string. + public func entry(for attributeName: String) -> AttributeEntry? { + guard let wrapper = raw[attributeName] as? NSDictionary else { return nil } + return AttributeEntry(raw: wrapper) + } + + /// Look up one attribute by typed `Attribute.Name`. + public func entry(for name: Attribute.Name) -> AttributeEntry? { + entry(for: name.value) + } + + /// All attribute names present in this snapshot. + public var attributeNames: [String] { + raw.allKeys.compactMap { $0 as? String } + } + } + + // MARK: - AttributeEntry + + /// Per-attribute wrapper from a hierarchy result. + public struct AttributeEntry { + fileprivate let raw: NSDictionary + + /// The raw value for this attribute. + public var value: Any? { + raw[HierarchyWireKey.value] + } + + /// The value as an array, if it is one. + public var arrayValue: [Any]? { + raw[HierarchyWireKey.value] as? [Any] + } + + /// The value as a string, if it is one. + public var stringValue: String? { + raw[HierarchyWireKey.value] as? String + } + + /// The value as element references, if applicable. + public var elementValues: [Element]? { + guard let arr = raw[HierarchyWireKey.value] as? [AnyObject] else { return nil } + let elements = arr.compactMap { Element(erased: $0 as CFTypeRef) } + guard elements.count == arr.count else { return nil } + return elements + } + + /// True array count (may exceed `arrayValue.count` when capped by `maxArrayCount`). + public var count: Int? { + (raw[HierarchyWireKey.count] as? NSNumber)?.intValue + } + + /// Error code for this attribute (only present with `returnAttributeErrors`). + public var error: AXError? { + guard let num = raw[HierarchyWireKey.error] as? NSNumber else { return nil } + return AXError(rawValue: num.int32Value) + } + + /// Whether this entry represents an error. + public var isError: Bool { + raw[HierarchyWireKey.error] != nil + } + } + } +} + +// MARK: - Element.copyHierarchy + +extension Accessibility.Element { + /// Bulk-fetch the accessibility hierarchy in a single IPC round-trip. + /// + /// This calls the private `AXUIElementCopyHierarchy` API, which is dramatically faster + /// than recursively calling `AXUIElementCopyAttributeValue` per element. + /// + /// - Parameters: + /// - attributes: Attribute names to fetch for each element (e.g. `[kAXRoleAttribute, kAXChildrenAttribute]`). + /// - options: Optional configuration for traversal behavior. + /// - Returns: A `HierarchyResult` containing per-element attribute data. + /// - Throws: `AccessibilityError` if the call fails, or if the symbol is unavailable. + public func copyHierarchy( + requesting attributes: [String], + options: Accessibility.HierarchyOptions? = nil, + file: StaticString = #fileID, + line: UInt = #line + ) throws -> Accessibility.HierarchyResult { + guard let fn = _copyHierarchy else { + throw AccessibilityError(.failure, file: file, line: line) + } + var outRef: Unmanaged? + try Accessibility.check( + fn(raw, attributes as CFArray, options?.toCFDictionary(), &outRef), + file: file, line: line + ) + guard let result = outRef?.takeRetainedValue() as? NSDictionary else { + throw AccessibilityError(.failure, file: file, line: line) + } + return Accessibility.HierarchyResult(raw: result) + } +} diff --git a/Sources/AccessibilityControl/Notification+Standard.swift b/Sources/AccessibilityControl/Notification+Standard.swift deleted file mode 100644 index cc0407c..0000000 --- a/Sources/AccessibilityControl/Notification+Standard.swift +++ /dev/null @@ -1,14 +0,0 @@ -import ApplicationServices - -public extension Accessibility.Notification { - static let layoutChanged = Self(kAXLayoutChangedNotification) - static let focusedUIElementChanged = Self(kAXFocusedUIElementChangedNotification) - static let applicationActivated = Self(kAXApplicationActivatedNotification) - static let applicationDeactivated = Self(kAXApplicationDeactivatedNotification) - static let applicationShown = Self(kAXApplicationShownNotification) - static let applicationHidden = Self(kAXApplicationHiddenNotification) - static let windowMoved = Self(kAXWindowMovedNotification) - static let windowResized = Self(kAXWindowResizedNotification) - static let windowCreated = Self(kAXWindowCreatedNotification) - static let titleChanged = Self(kAXTitleChangedNotification) -}