InputDriver.swift
1 import CoreGraphics 2 import AppKit 3 4 struct InputDriver { 5 static func mouseEvent(type: String, x: Double, y: Double, 6 button: String = "left", clicks: Int = 1) -> (ActionResult?, ErrorInfo?) { 7 let point = CGPoint(x: x, y: y) 8 9 switch type { 10 case "click": 11 let (btn, down, up) = mouseConstants(button) 12 for i in 0..<clicks { 13 if let event = CGEvent(mouseEventSource: nil, mouseType: down, 14 mouseCursorPosition: point, mouseButton: btn) { 15 if clicks > 1 { 16 event.setIntegerValueField(.mouseEventClickState, value: Int64(i + 1)) 17 } 18 event.post(tap: .cghidEventTap) 19 } 20 if let event = CGEvent(mouseEventSource: nil, mouseType: up, 21 mouseCursorPosition: point, mouseButton: btn) { 22 if clicks > 1 { 23 event.setIntegerValueField(.mouseEventClickState, value: Int64(i + 1)) 24 } 25 event.post(tap: .cghidEventTap) 26 } 27 } 28 return (ActionResult(result: "clicked \(button) at (\(Int(x)), \(Int(y))) \(clicks)x"), nil) 29 30 case "move": 31 if let event = CGEvent(mouseEventSource: nil, mouseType: .mouseMoved, 32 mouseCursorPosition: point, mouseButton: .left) { 33 event.post(tap: .cghidEventTap) 34 } 35 return (ActionResult(result: "moved to (\(Int(x)), \(Int(y)))"), nil) 36 37 default: 38 return (nil, ErrorInfo(code: -1, message: "unknown mouse event type: \(type)")) 39 } 40 } 41 42 static func keyEvent(key: String, modifiers: [String]) -> (ActionResult?, ErrorInfo?) { 43 let validMods: Set<String> = ["command", "cmd", "shift", "option", "alt", "control", "ctrl"] 44 var flags: CGEventFlags = [] 45 for mod in modifiers { 46 let m = mod.lowercased() 47 guard validMods.contains(m) else { 48 return (nil, ErrorInfo(code: -1, message: "unknown modifier: \(mod) (valid: command, cmd, shift, option, alt, control, ctrl)")) 49 } 50 switch m { 51 case "command", "cmd": flags.insert(.maskCommand) 52 case "shift": flags.insert(.maskShift) 53 case "option", "alt": flags.insert(.maskAlternate) 54 case "control", "ctrl": flags.insert(.maskControl) 55 default: break 56 } 57 } 58 59 guard let keyCode = keyCodeMap[key.lowercased()] else { 60 return (nil, ErrorInfo(code: -1, message: "unknown key: \(key)")) 61 } 62 63 if let down = CGEvent(keyboardEventSource: nil, virtualKey: keyCode, keyDown: true) { 64 down.flags = flags 65 down.post(tap: .cghidEventTap) 66 } 67 if let up = CGEvent(keyboardEventSource: nil, virtualKey: keyCode, keyDown: false) { 68 up.flags = flags 69 up.post(tap: .cghidEventTap) 70 } 71 72 let modStr = modifiers.isEmpty ? "" : modifiers.joined(separator: "+") + "+" 73 return (ActionResult(result: "pressed \(modStr)\(key)"), nil) 74 } 75 76 /// Type text. Non-ASCII (CJK, emoji) routes through clipboard paste 77 /// because CGEvent synthetic keystrokes produce wrong output when an IME is active 78 /// (macOS reads virtualKey=0 → 'a' instead of the Unicode string). 79 static func typeText(_ text: String) -> (ActionResult?, ErrorInfo?) { 80 let hasNonASCII = text.unicodeScalars.contains { $0.value > 0x7F } 81 82 if hasNonASCII || text.count > 20 { 83 // Clipboard paste path — safe for CJK/emoji/long text 84 let pasteboard = NSPasteboard.general 85 86 // Save all pasteboard items (not just string) to preserve files/images/HTML 87 var savedItems: [[NSPasteboard.PasteboardType: Data]] = [] 88 for item in pasteboard.pasteboardItems ?? [] { 89 var itemData: [NSPasteboard.PasteboardType: Data] = [:] 90 for type in item.types { 91 if let data = item.data(forType: type) { 92 itemData[type] = data 93 } 94 } 95 if !itemData.isEmpty { savedItems.append(itemData) } 96 } 97 98 pasteboard.clearContents() 99 guard pasteboard.setString(text, forType: .string) else { 100 return (nil, ErrorInfo(code: -1, message: "Failed to set pasteboard")) 101 } 102 // Cmd+V 103 let vKey: CGKeyCode = 0x09 104 if let down = CGEvent(keyboardEventSource: nil, virtualKey: vKey, keyDown: true) { 105 down.flags = .maskCommand 106 down.post(tap: .cghidEventTap) 107 } 108 if let up = CGEvent(keyboardEventSource: nil, virtualKey: vKey, keyDown: false) { 109 up.flags = .maskCommand 110 up.post(tap: .cghidEventTap) 111 } 112 // Wait for paste to complete before restoring clipboard 113 Thread.sleep(forTimeInterval: 0.1) 114 115 // Restore original pasteboard contents 116 pasteboard.clearContents() 117 if !savedItems.isEmpty { 118 var pbItems: [NSPasteboardItem] = [] 119 for itemData in savedItems { 120 let pbItem = NSPasteboardItem() 121 for (type, data) in itemData { 122 pbItem.setData(data, forType: type) 123 } 124 pbItems.append(pbItem) 125 } 126 pasteboard.writeObjects(pbItems) 127 } 128 129 let method = hasNonASCII ? "paste (non-ASCII)" : "paste (long text)" 130 return (ActionResult(result: "typed via \(method): \(text)"), nil) 131 } 132 133 // Short ASCII text — direct keystroke synthesis 134 for char in text { 135 let key = String(char).lowercased() 136 let needsShift = char.isUppercase || shiftChars.contains(char) 137 138 // For shifted symbols (!@#...), look up the base key 139 let baseKey = shiftedCharMap[char] ?? key 140 guard let keyCode = keyCodeMap[baseKey] ?? keyCodeMap[String(char)] else { 141 // Unknown char — skip 142 continue 143 } 144 145 var flags: CGEventFlags = [] 146 if needsShift { flags.insert(.maskShift) } 147 148 if let down = CGEvent(keyboardEventSource: nil, virtualKey: keyCode, keyDown: true) { 149 down.flags = flags 150 down.post(tap: .cghidEventTap) 151 } 152 if let up = CGEvent(keyboardEventSource: nil, virtualKey: keyCode, keyDown: false) { 153 up.flags = flags 154 up.post(tap: .cghidEventTap) 155 } 156 Thread.sleep(forTimeInterval: 0.01) 157 } 158 return (ActionResult(result: "typed: \(text)"), nil) 159 } 160 161 static func scroll(dx: Int, dy: Int) { 162 if let event = CGEvent(scrollWheelEvent2Source: nil, units: .pixel, 163 wheelCount: 2, wheel1: Int32(dy), wheel2: Int32(dx), wheel3: 0) { 164 event.post(tap: .cghidEventTap) 165 } 166 } 167 168 private static func mouseConstants(_ button: String) -> (CGMouseButton, CGEventType, CGEventType) { 169 switch button.lowercased() { 170 case "right": 171 return (.right, .rightMouseDown, .rightMouseUp) 172 default: 173 return (.left, .leftMouseDown, .leftMouseUp) 174 } 175 } 176 } 177 178 /// Characters that require shift to produce on a US keyboard. 179 private let shiftChars: Set<Character> = Set("~!@#$%^&*()_+{}|:\"<>?ABCDEFGHIJKLMNOPQRSTUVWXYZ") 180 181 /// Maps shifted symbols to their base key for keycode lookup (US keyboard layout). 182 private let shiftedCharMap: [Character: String] = [ 183 "~": "`", "!": "1", "@": "2", "#": "3", "$": "4", 184 "%": "5", "^": "6", "&": "7", "*": "8", "(": "9", 185 ")": "0", "_": "-", "+": "=", "{": "[", "}": "]", 186 "|": "\\", ":": ";", "\"": "'", "<": ",", ">": ".", 187 "?": "/", 188 ] 189 190 let keyCodeMap: [String: CGKeyCode] = [ 191 // Letters 192 "a": 0x00, "b": 0x0B, "c": 0x08, "d": 0x02, "e": 0x0E, 193 "f": 0x03, "g": 0x05, "h": 0x04, "i": 0x22, "j": 0x26, 194 "k": 0x28, "l": 0x25, "m": 0x2E, "n": 0x2D, "o": 0x1F, 195 "p": 0x23, "q": 0x0C, "r": 0x0F, "s": 0x01, "t": 0x11, 196 "u": 0x20, "v": 0x09, "w": 0x0D, "x": 0x07, "y": 0x10, 197 "z": 0x06, 198 199 // Numbers 200 "0": 0x1D, "1": 0x12, "2": 0x13, "3": 0x14, "4": 0x15, 201 "5": 0x17, "6": 0x16, "7": 0x1A, "8": 0x1C, "9": 0x19, 202 203 // Special keys 204 "return": 0x24, "enter": 0x24, "tab": 0x30, "space": 0x31, 205 "delete": 0x33, "backspace": 0x33, "escape": 0x35, "esc": 0x35, 206 207 // Arrow keys 208 "left": 0x7B, "right": 0x7C, "down": 0x7D, "up": 0x7E, 209 210 // Function keys 211 "f1": 0x7A, "f2": 0x78, "f3": 0x63, "f4": 0x76, 212 "f5": 0x60, "f6": 0x61, "f7": 0x62, "f8": 0x64, 213 "f9": 0x65, "f10": 0x6D, "f11": 0x67, "f12": 0x6F, 214 215 // Modifiers (for standalone use) 216 "command": 0x37, "shift": 0x38, "option": 0x3A, "control": 0x3B, 217 218 // Punctuation 219 "-": 0x1B, "=": 0x18, "[": 0x21, "]": 0x1E, 220 "\\": 0x2A, ";": 0x29, "'": 0x27, ",": 0x2B, 221 ".": 0x2F, "/": 0x2C, "`": 0x32, 222 223 // Navigation 224 "home": 0x73, "end": 0x77, "pageup": 0x74, "pagedown": 0x79, 225 "forwarddelete": 0x75, 226 ]