Compare commits

..

11 Commits
v0.1.0 ... main

Author SHA1 Message Date
Peter Steinberger
c276ac88a0
docs: position README banner 2026-05-28 20:48:39 +01:00
Peter Steinberger
d800d4239e
docs: add README banner 2026-05-28 19:43:52 +01:00
Peter Steinberger
fbb2a577c9
fix(input): type printable characters with key events 2026-05-08 12:07:24 +01:00
Peter Steinberger
b6df8b09e5
fix: harden AXorcist command handling 2026-05-08 09:01:46 +01:00
Peter Steinberger
931e59e80c
fix(axorcist): release hotkey modifiers reliably 2026-05-07 02:18:46 +01:00
Peter Steinberger
16ce92bf2b
fix: preserve CFRange AX values 2026-05-06 06:48:28 +01:00
Winn Cook
1a0c312e39
fix(axorcist): preserve CFRange values in ValueUnwrapper (#4) 2026-05-06 06:44:36 +01:00
Peter Steinberger
761ae3048f
style: apply formatting cleanup 2026-05-04 02:10:00 +01:00
Peter Steinberger
289d403c53
fix: keep checkout dependency resolution remote 2026-04-28 01:55:24 +01:00
Peter Steinberger
b63ee9a326
chore: release 0.1.1 2026-04-28 01:52:30 +01:00
Peter Steinberger
7b38b16b82
build: prefer local Commander when vendored 2026-03-13 02:05:39 +00:00
15 changed files with 300 additions and 87 deletions

42
.vscode/launch.json vendored
View File

@ -1,22 +1,22 @@
{
"configurations": [
{
"type": "swift",
"request": "launch",
"args": [],
"cwd": "${workspaceFolder:AXorcist}",
"name": "Debug axorc",
"program": "${workspaceFolder:AXorcist}/.build/debug/axorc",
"preLaunchTask": "swift: Build Debug axorc"
},
{
"type": "swift",
"request": "launch",
"args": [],
"cwd": "${workspaceFolder:AXorcist}",
"name": "Release axorc",
"program": "${workspaceFolder:AXorcist}/.build/release/axorc",
"preLaunchTask": "swift: Build Release axorc"
}
]
}
"configurations": [
{
"type": "swift",
"request": "launch",
"args": [],
"cwd": "${workspaceFolder:AXorcist}",
"name": "Debug axorc",
"program": "${workspaceFolder:AXorcist}/.build/debug/axorc",
"preLaunchTask": "swift: Build Debug axorc"
},
{
"type": "swift",
"request": "launch",
"args": [],
"cwd": "${workspaceFolder:AXorcist}",
"name": "Release axorc",
"program": "${workspaceFolder:AXorcist}/.build/release/axorc",
"preLaunchTask": "swift: Build Release axorc"
}
]
}

View File

@ -2,6 +2,24 @@
All notable changes to AXorcist will be documented in this file.
## [Unreleased]
### Fixed
- Printable ASCII typing now uses physical key events before falling back to Unicode events, improving reliability in VM and headless launch paths.
- Unsupported command dispatch now returns an `unknown_command` error instead of trapping, and JSON path hint parsing no longer writes warnings to stdout for unknown attributes.
- Preserve CFRange-backed AXValue attributes such as selected text ranges instead of misclassifying raw value 4 as a boolean. Thanks @WinnCook.
## [0.1.2] - 2026-04-28
### Fixed
- Avoid treating SwiftPM's `.build/checkouts` cache as a vendored workspace when resolving Commander.
## [0.1.1] - 2026-04-28
### Changed
- Prefer a vendored local Commander checkout when present, while keeping the external release dependency exact.
- Refresh SwiftLog dependency pins.
## [0.1.0] - 2026-01-18
### Added

View File

@ -1,13 +1,13 @@
{
"originHash" : "619e995a786edef531957047f8309989d97da5524fd56921d038f3677c822488",
"originHash" : "903d57ca4ce1a2cffdc42cc8f73c40c751397fa74948f20679efc3176edb4196",
"pins" : [
{
"identity" : "swift-log",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-log.git",
"state" : {
"revision" : "bc386b95f2a16ccd0150a8235e7c69eab2b866ca",
"version" : "1.8.0"
"revision" : "5073617dac96330a486245e4c0179cb0a6fd2256",
"version" : "1.12.0"
}
}
],

View File

@ -1,6 +1,7 @@
// swift-tools-version: 6.2
// The swift-tools-version declares the minimum version of Swift required to build this package.
import Foundation
import PackageDescription
let approachableConcurrencySettings: [SwiftSetting] = [
@ -10,6 +11,16 @@ let approachableConcurrencySettings: [SwiftSetting] = [
.defaultIsolation(MainActor.self),
]
let packageDirectory = URL(fileURLWithPath: #filePath).deletingLastPathComponent()
let localCommanderPath = packageDirectory.deletingLastPathComponent().appendingPathComponent("Commander").path
let isSwiftPMCheckout = packageDirectory.path.contains("/.build/checkouts/")
let commanderDependency: Package.Dependency =
if !isSwiftPMCheckout, FileManager.default.fileExists(atPath: localCommanderPath) {
.package(path: "../Commander")
} else {
.package(url: "https://github.com/steipete/Commander.git", exact: "0.2.2")
}
let package = Package(
name: "axPackage", // Renamed package slightly to avoid any confusion with executable name
platforms: [
@ -20,7 +31,7 @@ let package = Package(
.executable(name: "axorc", targets: ["axorc"]), // Product 'axorc' comes from target 'axorc'
],
dependencies: [
.package(url: "https://github.com/steipete/Commander.git", exact: "0.2.1"),
commanderDependency,
.package(url: "https://github.com/apple/swift-log.git", from: "1.5.4"),
],
targets: [

View File

@ -1,5 +1,7 @@
# AXorcist 🧙‍♂️ - The power of Swift compels your UI to obey!
![AXorcist banner](docs/assets/readme-banner.jpg)
<p align="center">
<img src="assets/logo.png" alt="AXorcist Logo">
</p>

View File

@ -198,7 +198,9 @@ public class AXorcist {
case let .observe(observeCommand):
handleObserve(command: observeCommand)
default:
fatalError("Unsupported command type: \(envelope.command)")
.errorResponse(
message: "Unsupported command type: \(envelope.command.type)",
code: .unknownCommand)
}
}

View File

@ -175,14 +175,110 @@ extension Element {
try self.typeCharacter(character)
}
if delay > 0 {
Thread.sleep(forTimeInterval: delay)
}
Thread.sleep(forTimeInterval: delay > 0 ? delay : 0.001)
}
}
/// Type a single character
@MainActor public static func typeCharacter(_ character: Character) throws {
// Physical key events survive VM/headless launch paths that can silently drop Unicode-only events.
if let stroke = self.keyboardStroke(for: character) {
try self.postKeyboardStroke(stroke)
return
}
try self.postUnicodeCharacter(character)
}
static func keyboardStroke(for character: Character) -> (keyCode: CGKeyCode, flags: CGEventFlags)? {
let string = String(character)
guard string.count == 1 else { return nil }
if let scalar = string.unicodeScalars.first,
CharacterSet.lowercaseLetters.contains(scalar),
let key = SpecialKey(rawValue: string),
let keyCode = key.keyCode
{
return (keyCode, [])
}
if let scalar = string.unicodeScalars.first,
CharacterSet.uppercaseLetters.contains(scalar),
let key = SpecialKey(rawValue: string.lowercased()),
let keyCode = key.keyCode
{
return (keyCode, .maskShift)
}
if let key = SpecialKey(rawValue: string),
let keyCode = key.keyCode
{
return (keyCode, [])
}
let shiftedSymbols: [Character: CGKeyCode] = [
"!": 18,
"@": 19,
"#": 20,
"$": 21,
"%": 23,
"^": 22,
"&": 26,
"*": 28,
"(": 25,
")": 29,
"_": 27,
"+": 24,
"{": 33,
"}": 30,
"|": 42,
":": 41,
"\"": 39,
"<": 43,
">": 47,
"?": 44,
"~": 50,
]
if let keyCode = shiftedSymbols[character] {
return (keyCode, .maskShift)
}
let symbols: [Character: CGKeyCode] = [
" ": 49,
"-": 27,
"=": 24,
"[": 33,
"]": 30,
"\\": 42,
";": 41,
"'": 39,
",": 43,
".": 47,
"/": 44,
"`": 50,
]
if let keyCode = symbols[character] {
return (keyCode, [])
}
return nil
}
private static func postKeyboardStroke(_ stroke: (keyCode: CGKeyCode, flags: CGEventFlags)) throws {
guard let keyDown = CGEvent(keyboardEventSource: nil, virtualKey: stroke.keyCode, keyDown: true),
let keyUp = CGEvent(keyboardEventSource: nil, virtualKey: stroke.keyCode, keyDown: false)
else {
throw UIAutomationError.failedToCreateEvent
}
keyDown.flags = stroke.flags
keyUp.flags = stroke.flags
keyDown.post(tap: .cghidEventTap)
Thread.sleep(forTimeInterval: 0.001)
keyUp.post(tap: .cghidEventTap)
}
private static func postUnicodeCharacter(_ character: Character) throws {
let string = String(character)
// Create keyboard event
@ -236,22 +332,22 @@ extension Element {
/// Perform a hotkey combination
@MainActor public static func performHotkey(keys: [String], holdDuration: TimeInterval = 0.1) throws {
var modifiers: CGEventFlags = []
var modifiers: [(keyCode: CGKeyCode?, flag: CGEventFlags)] = []
var mainKey: SpecialKey?
// Parse keys
for key in keys {
switch key.lowercased() {
case "cmd", "command":
modifiers.insert(.maskCommand)
modifiers.append((keyCode: 0x37, flag: .maskCommand))
case "shift":
modifiers.insert(.maskShift)
modifiers.append((keyCode: 0x38, flag: .maskShift))
case "option", "opt", "alt":
modifiers.insert(.maskAlternate)
modifiers.append((keyCode: 0x3A, flag: .maskAlternate))
case "ctrl", "control":
modifiers.insert(.maskControl)
modifiers.append((keyCode: 0x3B, flag: .maskControl))
case "fn", "function":
modifiers.insert(.maskSecondaryFn)
modifiers.append((keyCode: nil, flag: .maskSecondaryFn))
default:
// Try to parse as special key
if let special = SpecialKey(rawValue: key.lowercased()) {
@ -269,11 +365,42 @@ extension Element {
throw UIAutomationError.invalidHotkey(keys.joined(separator: "+"))
}
// Type the key with modifiers
try self.typeKey(key, modifiers: modifiers)
guard let mainKeyCode = key.keyCode else {
throw UIAutomationError.unsupportedKey(key.rawValue)
}
// Hold for specified duration
Thread.sleep(forTimeInterval: holdDuration)
func makeKeyboardEvent(keyCode: CGKeyCode, keyDown: Bool, flags: CGEventFlags) throws -> CGEvent {
guard let event = CGEvent(keyboardEventSource: nil, virtualKey: keyCode, keyDown: keyDown) else {
throw UIAutomationError.failedToCreateEvent
}
event.flags = flags
return event
}
func postKeyboardEvent(keyCode: CGKeyCode, keyDown: Bool, flags: CGEventFlags) throws {
try makeKeyboardEvent(keyCode: keyCode, keyDown: keyDown, flags: flags).post(tap: .cghidEventTap)
}
var activeFlags: CGEventFlags = []
for modifier in modifiers {
activeFlags.insert(modifier.flag)
if let keyCode = modifier.keyCode {
try postKeyboardEvent(keyCode: keyCode, keyDown: true, flags: activeFlags)
}
}
try postKeyboardEvent(keyCode: mainKeyCode, keyDown: true, flags: activeFlags)
if holdDuration > 0 {
Thread.sleep(forTimeInterval: holdDuration)
}
try postKeyboardEvent(keyCode: mainKeyCode, keyDown: false, flags: activeFlags)
for modifier in modifiers.reversed() {
activeFlags.remove(modifier.flag)
if let keyCode = modifier.keyCode {
try postKeyboardEvent(keyCode: keyCode, keyDown: false, flags: activeFlags)
}
}
}
}

View File

@ -1,4 +1,4 @@
# ``AXorcist``
# `AXorcist`
A powerful Swift framework for macOS accessibility automation.
@ -32,31 +32,31 @@ if hasPermissions {
### Essentials
- ``AXorcist``
- ``AXPermissionHelpers``
- ``AXCommandEnvelope``
- ``AXResponse``
- `AXorcist`
- `AXPermissionHelpers`
- `AXCommandEnvelope`
- `AXResponse`
### Commands
- ``AXCommand``
- ``AXQueryCommand``
- ``AXActionCommand``
- ``AXGetAttributesCommand``
- `AXCommand`
- `AXQueryCommand`
- `AXActionCommand`
- `AXGetAttributesCommand`
### Elements
- ``Element``
- ``ElementSearch``
- ``SearchCriteria``
- `Element`
- `ElementSearch`
- `SearchCriteria`
### Utilities
- ``GlobalAXLogger``
- ``AXLogEntry``
- ``ProcessUtils``
- `GlobalAXLogger`
- `AXLogEntry`
- `ProcessUtils`
### Error Handling
- ``AccessibilityError``
- ``AXError``
- `AccessibilityError`
- `AXError`

View File

@ -67,13 +67,7 @@ public struct JSONPathHintComponent: Codable, Sendable {
/// Converts this component to a simple criteria dictionary for use with existing matching logic.
public var simpleCriteria: [String: String]? {
guard let resolvedAttributeName = axAttributeName else {
// Log a warning here if this component is used, as it means an invalid attribute type was provided.
// GlobalAXLogger.shared.log(...) or axWarningLog(...) - Requires importing/access
// For now, just return nil. The calling code should handle this.
print("WARNING: JSONPathHintComponent has unrecognized attribute type: \(self.attribute)")
return nil
}
guard let resolvedAttributeName = axAttributeName else { return nil }
return [resolvedAttributeName: self.value]
}

View File

@ -6,10 +6,8 @@
//
import ApplicationServices
import Foundation
#if canImport(AppKit)
import AppKit
#endif
import Foundation
#if canImport(CoreGraphics)
import CoreGraphics // Added for CGWindowListCopyWindowInfo
#endif
@ -49,12 +47,7 @@ public struct RunningApplicationHelper {
/// Get the current application
public static var currentApplication: NSRunningApplication {
#if canImport(AppKit)
return NSRunningApplication.current
#else
// Fallback - create a minimal implementation
fatalError("NSRunningApplication.current not available on this platform")
#endif
NSRunningApplication.current
}
/// Get the current application's process info

View File

@ -68,15 +68,8 @@ enum ValueUnwrapper {
""".trimmingCharacters(in: .whitespacesAndNewlines)
axDebugLog(message)
// Handle special boolean type
if axValueType.rawValue == 4 { // kAXValueBooleanType (private)
var boolResult: DarwinBoolean = false
if AXValueGetValue(axVal, axValueType, &boolResult) {
return boolResult.boolValue
}
}
// Use new AXValue extensions for cleaner unwrapping
// AXValueType.cfRange also uses raw value 4, so raw-value guesses can corrupt
// range-based attributes like selectedTextRange into booleans.
let unwrappedExtensionValue = axVal.value()
let valueDescription = String(describing: unwrappedExtensionValue)
let returnMessage = "ValueUnwrapper.unwrapAXValue: axVal.value() returned: \(valueDescription) " +

View File

@ -18,4 +18,39 @@ struct InputDriverTests {
// If running in CI without UI, location may be nil; just assert cache mirrors result.
#expect(cache == InputDriver.currentLocation())
}
@Test("keyboardStroke maps printable ASCII to physical key events")
@MainActor
func keyboardStrokeMapsPrintableASCII() throws {
let lower = try #require(Element.keyboardStroke(for: "a"))
#expect(lower.keyCode == 0)
#expect(!lower.flags.contains(.maskShift))
let upper = try #require(Element.keyboardStroke(for: "A"))
#expect(upper.keyCode == 0)
#expect(upper.flags.contains(.maskShift))
let digit = try #require(Element.keyboardStroke(for: "1"))
#expect(digit.keyCode == 18)
#expect(!digit.flags.contains(.maskShift))
let symbol = try #require(Element.keyboardStroke(for: "!"))
#expect(symbol.keyCode == 18)
#expect(symbol.flags.contains(.maskShift))
let hyphen = try #require(Element.keyboardStroke(for: "-"))
#expect(hyphen.keyCode == 27)
#expect(!hyphen.flags.contains(.maskShift))
let underscore = try #require(Element.keyboardStroke(for: "_"))
#expect(underscore.keyCode == 27)
#expect(underscore.flags.contains(.maskShift))
}
@Test("keyboardStroke leaves non-ASCII for Unicode fallback")
@MainActor
func keyboardStrokeLeavesNonASCIIForUnicodeFallback() {
#expect(Element.keyboardStroke(for: "é") == nil)
#expect(Element.keyboardStroke(for: "🙂") == nil)
}
}

View File

@ -1,9 +1,25 @@
import Foundation
private final class PipeStreamBuffer: @unchecked Sendable {
private let queue = DispatchQueue(
label: "axorcist.tests.pipe-stream.\(UUID().uuidString)",
qos: .userInitiated)
nonisolated(unsafe) private var data = Data()
nonisolated func append(_ chunk: Data) {
self.queue.async {
self.data.append(chunk)
}
}
nonisolated func snapshot() -> Data {
self.queue.sync { self.data }
}
}
@discardableResult
func startStreaming(pipe: Pipe) -> () -> Data {
let queue = DispatchQueue(label: "axorcist.tests.pipe-stream.\(UUID().uuidString)", qos: .userInitiated)
var collected = Data()
let buffer = PipeStreamBuffer()
let group = DispatchGroup()
group.enter()
@ -14,13 +30,11 @@ func startStreaming(pipe: Pipe) -> () -> Data {
group.leave()
return
}
queue.async {
collected.append(chunk)
}
buffer.append(chunk)
}
return {
group.wait()
return queue.sync { collected }
return buffer.snapshot()
}
}

View File

@ -0,0 +1,24 @@
import ApplicationServices
import Testing
@testable import AXorcist
@Suite("ValueUnwrapper Tests", .tags(.safe))
struct ValueUnwrapperTests {
@MainActor
@Test("unwrap preserves CFRange AXValue", .tags(.safe))
func unwrapPreservesCFRangeAXValue() {
let expected = CFRange(location: 12, length: 34)
guard let axValue = AXValue.create(range: expected) else {
Issue.record("Failed to create AXValue from CFRange")
return
}
guard let actual = ValueUnwrapper.unwrap(axValue) as? CFRange else {
Issue.record("Expected ValueUnwrapper to return a CFRange")
return
}
#expect(actual.location == expected.location)
#expect(actual.length == expected.length)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB