Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,5 @@ DerivedData/

# SwiftLint Remote Config Cache
.swiftlint/RemoteConfigCache
.claude
MEGAREADME.md
16 changes: 16 additions & 0 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 16 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
// 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",
targets: ["AccessibilityControl"]
),
.executable(
name: "axdump",
targets: ["axdump"]
),
],
dependencies: [
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.2.0"),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
],
targets: [
.target(
Expand All @@ -26,5 +33,12 @@ let package = Package(
name: "AccessibilityControl",
dependencies: ["CAccessibilityControl", "WindowControl"]
),
.executableTarget(
name: "axdump",
dependencies: [
"AccessibilityControl",
.product(name: "ArgumentParser", package: "swift-argument-parser"),
]
),
]
)
4 changes: 3 additions & 1 deletion Sources/AccessibilityControl/Accessibility.swift
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,9 @@ public enum Accessibility {
public typealias MutableAttributeName<T> = MutableAttribute<T>.Name
public typealias ParameterizedAttributeName<Parameter, Return> = ParameterizedAttribute<Parameter, Return>.Name

init() {}
init() {

}
}

public static func isTrusted(shouldPrompt: Bool = false) -> Bool {
Expand Down
9 changes: 9 additions & 0 deletions Sources/AccessibilityControl/Element.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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()
}
}
66 changes: 54 additions & 12 deletions Sources/AccessibilityControl/Observer.swift
Original file line number Diff line number Diff line change
@@ -1,35 +1,50 @@
import Foundation
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<Box<Accessibility.Observer.Callback>>
.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
}
}

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
Expand All @@ -38,7 +53,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 {
Expand All @@ -57,9 +72,21 @@ extension Accessibility {
_ notification: Notification,
for element: Element,
callback: @escaping Callback
) throws -> Token {
return try observe(
NSAccessibility.Notification(from: 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,
Expand All @@ -79,16 +106,31 @@ extension Accessibility {
}
}

extension Accessibility.Element {
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,
on runLoop: RunLoop = .current,
_ notification: NSAccessibility.Notification,
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: NSAccessibility.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)
}
}
8 changes: 4 additions & 4 deletions Sources/WindowControl/Dock.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -29,15 +29,15 @@ 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()
}
}

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)
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/WindowControl/Space.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
67 changes: 67 additions & 0 deletions Sources/axdump/AXDump.swift
Original file line number Diff line number Diff line change
@@ -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 <command> --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
)
}

Loading