From fbb2a577c98015cbfcefb606eefdd2369ce99de5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 12:07:24 +0100 Subject: [PATCH] fix(input): type printable characters with key events --- CHANGELOG.md | 1 + .../AXorcist/Core/Element+UIAutomation.swift | 98 +++++++++++++++++++ Tests/AXorcistTests/InputDriverTests.swift | 35 +++++++ 3 files changed, 134 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a1ef80..e445754 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ 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. diff --git a/Sources/AXorcist/Core/Element+UIAutomation.swift b/Sources/AXorcist/Core/Element+UIAutomation.swift index b022f32..d86fe1c 100644 --- a/Sources/AXorcist/Core/Element+UIAutomation.swift +++ b/Sources/AXorcist/Core/Element+UIAutomation.swift @@ -181,6 +181,104 @@ extension Element { /// 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 diff --git a/Tests/AXorcistTests/InputDriverTests.swift b/Tests/AXorcistTests/InputDriverTests.swift index afa68ba..93e0aca 100644 --- a/Tests/AXorcistTests/InputDriverTests.swift +++ b/Tests/AXorcistTests/InputDriverTests.swift @@ -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) + } }