diff --git a/DeviceKitTests/JSONRPC/Handlers/IOKeys.swift b/DeviceKitTests/JSONRPC/Handlers/IOKeys.swift new file mode 100644 index 0000000..abf2480 --- /dev/null +++ b/DeviceKitTests/JSONRPC/Handlers/IOKeys.swift @@ -0,0 +1,128 @@ +import XCTest +import os + +struct IOKeysCombo: Codable { + let key: String + let modifiers: [String] +} + +struct IOKeysRequest: Codable { + let keys: [IOKeysCombo] +} + +private struct ResolvedKeyCombo { + let key: String + let flags: XCUIElement.KeyModifierFlags + let preferTypeText: Bool +} + +private let modifierFlagsByName: [String: XCUIElement.KeyModifierFlags] = [ + "command": .command, + "control": .control, + "option": .option, + "shift": .shift, + "fn": .function, +] + +private let keyboardKeysByName: [String: XCUIKeyboardKey] = [ + "return": .return, + "enter": .enter, + "backspace": .delete, + "delete": .delete, + "forwarddelete": .forwardDelete, + "tab": .tab, + "space": .space, + "escape": .escape, + "up": .upArrow, + "down": .downArrow, + "left": .leftArrow, + "right": .rightArrow, + "home": .home, + "end": .end, + "pageup": .pageUp, + "pagedown": .pageDown, + "f1": .F1, "f2": .F2, "f3": .F3, "f4": .F4, + "f5": .F5, "f6": .F6, "f7": .F7, "f8": .F8, + "f9": .F9, "f10": .F10, "f11": .F11, "f12": .F12, +] + +// Named keys whose XCUIKeyboardKey rawValue is a real control character that +// typeText delivers correctly without a modifier. Every other named key (arrows, +// home/end, page nav, F-keys) carries a scalar that typeText would insert as a +// literal glyph, so it must be sent through typeKey instead. +private let typeTextKeyNames: Set = [ + "return", "enter", "backspace", "delete", "forwarddelete", + "tab", "space", "escape", +] + +@MainActor +struct IOKeysMethodHandler: RPCMethodHandler { + static let methodName = "device.io.keys" + + private let logger = Logger( + subsystem: Bundle.main.bundleIdentifier!, + category: String(describing: Self.self) + ) + + func execute(params: JSONValue?) async throws -> JSONValue { + let request = try decodeParams(IOKeysRequest.self, from: params) + guard !request.keys.isEmpty else { + throw RPCMethodError.invalidParams("At least one key is required") + } + + // resolve all combos upfront, so an invalid combo fails before any + // key is pressed + let resolvedCombos = try request.keys.map { combo -> ResolvedKeyCombo in + let resolved = try resolveKey(combo.key) + return ResolvedKeyCombo( + key: resolved.value, + flags: try resolveModifiers(combo.modifiers), + preferTypeText: resolved.preferTypeText + ) + } + + let start = Date() + logger.info("[Start] Typing \(request.keys.count) keys") + + let app = RunningApp.getForegroundApp() ?? XCUIApplication(bundleIdentifier: RunningApp.springboardBundleId) + for combo in resolvedCombos { + // typeKey delivers modifier combos and navigation/function keys; + // typeText handles literal characters and control-character keys, + // which typeKey does not deliver reliably without a modifier. + if combo.flags.isEmpty && combo.preferTypeText { + app.typeText(combo.key) + } else { + app.typeKey(combo.key, modifierFlags: combo.flags) + } + } + + let duration = Date().timeIntervalSince(start) + logger.info("[Done] Typing keys took \(duration)") + return .object(["success": .bool(true)]) + } + + private func resolveKey(_ key: String) throws -> (value: String, preferTypeText: Bool) { + let lowered = key.lowercased() + if let namedKey = keyboardKeysByName[lowered] { + return (namedKey.rawValue, typeTextKeyNames.contains(lowered)) + } + + guard key.count == 1 else { + throw RPCMethodError.invalidParams("Unsupported key: \(key)") + } + + return (key, true) + } + + private func resolveModifiers(_ modifiers: [String]) throws -> XCUIElement.KeyModifierFlags { + var flags: XCUIElement.KeyModifierFlags = [] + for modifier in modifiers { + guard let flag = modifierFlagsByName[modifier.lowercased()] else { + throw RPCMethodError.invalidParams("Unsupported modifier: \(modifier)") + } + flags.insert(flag) + } + + return flags + } +} diff --git a/DeviceKitTests/JSONRPC/JSONRPCDispatcher.swift b/DeviceKitTests/JSONRPC/JSONRPCDispatcher.swift index 3d02dc6..d70ce7d 100644 --- a/DeviceKitTests/JSONRPC/JSONRPCDispatcher.swift +++ b/DeviceKitTests/JSONRPC/JSONRPCDispatcher.swift @@ -15,6 +15,7 @@ final class JSONRPCDispatcher { registerHandler(IOTapMethodHandler()) registerHandler(DumpUIMethodHandler()) registerHandler(IOTextMethodHandler()) + registerHandler(IOKeysMethodHandler()) registerHandler(AppsLaunchMethodHandler()) registerHandler(AppsTerminateMethodHandler()) registerHandler(IOSwipeMethodHandler()) diff --git a/devicekit-ios.xcodeproj/project.pbxproj b/devicekit-ios.xcodeproj/project.pbxproj index 9f7f102..7cde7c2 100644 --- a/devicekit-ios.xcodeproj/project.pbxproj +++ b/devicekit-ios.xcodeproj/project.pbxproj @@ -42,6 +42,7 @@ 612DE414298426EF003C2BE0 /* EventRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 612DE413298426EF003C2BE0 /* EventRecord.swift */; }; 613E87D7299A64BD00FF8551 /* PointerEventPath.swift in Sources */ = {isa = PBXBuildFile; fileRef = 613E87D6299A64BD00FF8551 /* PointerEventPath.swift */; }; 78147F0F2F27AA6B00E61E3B /* IOText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78147F0E2F27AA6B00E61E3B /* IOText.swift */; }; + 78E4A1B12F5C110200D4F1AA /* IOKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78E4A1B02F5C110200D4F1AA /* IOKeys.swift */; }; 78147F3D2F27BDBC00E61E3B /* AppsLaunch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78147F3C2F27BDBC00E61E3B /* AppsLaunch.swift */; }; 78147FCB2F27C04400E61E3B /* IOSwipe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78147FCA2F27C03500E61E3B /* IOSwipe.swift */; }; 7814800B2F27C56100E61E3B /* IOLongpress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7814800A2F27C55500E61E3B /* IOLongpress.swift */; }; @@ -124,6 +125,7 @@ 612DE413298426EF003C2BE0 /* EventRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventRecord.swift; sourceTree = ""; }; 613E87D6299A64BD00FF8551 /* PointerEventPath.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointerEventPath.swift; sourceTree = ""; }; 78147F0E2F27AA6B00E61E3B /* IOText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOText.swift; sourceTree = ""; }; + 78E4A1B02F5C110200D4F1AA /* IOKeys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOKeys.swift; sourceTree = ""; }; 78147F3C2F27BDBC00E61E3B /* AppsLaunch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppsLaunch.swift; sourceTree = ""; }; 78147FCA2F27C03500E61E3B /* IOSwipe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOSwipe.swift; sourceTree = ""; }; 7814800A2F27C55500E61E3B /* IOLongpress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOLongpress.swift; sourceTree = ""; }; @@ -339,6 +341,7 @@ 78147F3C2F27BDBC00E61E3B /* AppsLaunch.swift */, 78EA9F902F36611B008B17D3 /* AppsTerminate.swift */, 78147F0E2F27AA6B00E61E3B /* IOText.swift */, + 78E4A1B02F5C110200D4F1AA /* IOKeys.swift */, 787C47DC2F228B770027A012 /* DumpUI.swift */, 787C47DD2F228B770027A012 /* IOTap.swift */, ); @@ -533,6 +536,7 @@ 78EA9F912F36611B008B17D3 /* AppsTerminate.swift in Sources */, 52D8FAB02D6DD4100015FAB3 /* FBConfiguration.m in Sources */, 78147F0F2F27AA6B00E61E3B /* IOText.swift in Sources */, + 78E4A1B12F5C110200D4F1AA /* IOKeys.swift in Sources */, 6124329E2A4DA5BB00F5F619 /* AXElement.swift in Sources */, 52E35D432A654F67001D97A8 /* RunningApp.swift in Sources */, 52D8FAB42D6DD54F0015FAB3 /* XCUIApplication+FBQuiescence.m in Sources */,