/ internal / tools / axserver / Sources / InputDriver.swift
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  ]