Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
128 changes: 128 additions & 0 deletions DeviceKitTests/JSONRPC/Handlers/IOKeys.swift
Original file line number Diff line number Diff line change
@@ -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<String> = [
"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
}
}
1 change: 1 addition & 0 deletions DeviceKitTests/JSONRPC/JSONRPCDispatcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ final class JSONRPCDispatcher {
registerHandler(IOTapMethodHandler())
registerHandler(DumpUIMethodHandler())
registerHandler(IOTextMethodHandler())
registerHandler(IOKeysMethodHandler())
registerHandler(AppsLaunchMethodHandler())
registerHandler(AppsTerminateMethodHandler())
registerHandler(IOSwipeMethodHandler())
Expand Down
4 changes: 4 additions & 0 deletions devicekit-ios.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -124,6 +125,7 @@
612DE413298426EF003C2BE0 /* EventRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventRecord.swift; sourceTree = "<group>"; };
613E87D6299A64BD00FF8551 /* PointerEventPath.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointerEventPath.swift; sourceTree = "<group>"; };
78147F0E2F27AA6B00E61E3B /* IOText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOText.swift; sourceTree = "<group>"; };
78E4A1B02F5C110200D4F1AA /* IOKeys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOKeys.swift; sourceTree = "<group>"; };
78147F3C2F27BDBC00E61E3B /* AppsLaunch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppsLaunch.swift; sourceTree = "<group>"; };
78147FCA2F27C03500E61E3B /* IOSwipe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOSwipe.swift; sourceTree = "<group>"; };
7814800A2F27C55500E61E3B /* IOLongpress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOLongpress.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -339,6 +341,7 @@
78147F3C2F27BDBC00E61E3B /* AppsLaunch.swift */,
78EA9F902F36611B008B17D3 /* AppsTerminate.swift */,
78147F0E2F27AA6B00E61E3B /* IOText.swift */,
78E4A1B02F5C110200D4F1AA /* IOKeys.swift */,
787C47DC2F228B770027A012 /* DumpUI.swift */,
787C47DD2F228B770027A012 /* IOTap.swift */,
);
Expand Down Expand Up @@ -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 */,
Expand Down
Loading