Coverage for integrations / remote_desktop / input_handler.py: 38.5%

179 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-05-12 04:49 +0000

1""" 

2Input Handler — Cross-platform mouse/keyboard/scroll input relay. 

3 

4Backend 1: pynput (low-level, modifier tracking) — optional 

5Backend 2: pyautogui (fallback, reuses local_computer_tool.py action format) 

6 

7Security: runs classify_remote_input() on destructive hotkeys (Alt+F4 etc.) 

8""" 

9 

10import logging 

11import threading 

12from typing import Any, Dict, Optional 

13 

14logger = logging.getLogger('hevolve.remote_desktop') 

15 

16# ── Optional dependencies ─────────────────────────────────────── 

17 

18_pynput_mouse = None 

19_pynput_keyboard = None 

20_pyautogui = None 

21 

22try: 

23 from pynput.mouse import Controller as MouseController, Button 

24 from pynput.keyboard import Controller as KeyboardController, Key 

25 _pynput_mouse = MouseController 

26 _pynput_keyboard = KeyboardController 

27except ImportError: 

28 pass 

29 

30try: 

31 import pyautogui as _pyautogui_module 

32 _pyautogui = _pyautogui_module 

33 _pyautogui.FAILSAFE = False # Disable corner failsafe for remote control 

34except ImportError: 

35 pass 

36 

37# ── Key Mapping ───────────────────────────────────────────────── 

38 

39# Map common key names to pynput Key enums 

40_PYNPUT_SPECIAL_KEYS = {} 

41if _pynput_keyboard: 

42 _PYNPUT_SPECIAL_KEYS = { 

43 'enter': Key.enter, 'return': Key.enter, 

44 'tab': Key.tab, 'space': Key.space, 

45 'backspace': Key.backspace, 'delete': Key.delete, 

46 'escape': Key.esc, 'esc': Key.esc, 

47 'up': Key.up, 'down': Key.down, 'left': Key.left, 'right': Key.right, 

48 'home': Key.home, 'end': Key.end, 

49 'pageup': Key.page_up, 'pagedown': Key.page_down, 

50 'insert': Key.insert, 

51 'f1': Key.f1, 'f2': Key.f2, 'f3': Key.f3, 'f4': Key.f4, 

52 'f5': Key.f5, 'f6': Key.f6, 'f7': Key.f7, 'f8': Key.f8, 

53 'f9': Key.f9, 'f10': Key.f10, 'f11': Key.f11, 'f12': Key.f12, 

54 'shift': Key.shift, 'ctrl': Key.ctrl, 'control': Key.ctrl, 

55 'alt': Key.alt, 'super': Key.cmd, 'win': Key.cmd, 'cmd': Key.cmd, 

56 'capslock': Key.caps_lock, 'numlock': Key.num_lock, 

57 'printscreen': Key.print_screen, 

58 } 

59 

60 

61class InputHandler: 

62 """Dispatches remote input events to local system. 

63 

64 Event format: {type, x, y, button, key, text, hotkey, delta_x, delta_y} 

65 """ 

66 

67 def __init__(self, allow_control: bool = True): 

68 self._allow_control = allow_control 

69 self._lock = threading.Lock() 

70 self._mouse = None 

71 self._keyboard = None 

72 self._event_count = 0 

73 

74 # Initialize backends 

75 if _pynput_mouse: 

76 try: 

77 self._mouse = _pynput_mouse() 

78 except Exception: 

79 pass 

80 if _pynput_keyboard: 

81 try: 

82 self._keyboard = _pynput_keyboard() 

83 except Exception: 

84 pass 

85 

86 def set_control_mode(self, allow: bool) -> None: 

87 """Toggle view-only mode.""" 

88 self._allow_control = allow 

89 logger.info(f"Control mode: {'enabled' if allow else 'view-only'}") 

90 

91 @property 

92 def control_enabled(self) -> bool: 

93 return self._allow_control 

94 

95 def handle_input_event(self, event: dict) -> dict: 

96 """Dispatch a remote input event. 

97 

98 Args: 

99 event: {type, x, y, button, key, text, hotkey, delta_x, delta_y} 

100 

101 Returns: 

102 {success: bool, error: str|None, classification: str} 

103 """ 

104 if not self._allow_control: 

105 return {'success': False, 'error': 'view_only_mode', 

106 'classification': 'blocked'} 

107 

108 event_type = event.get('type', '') 

109 

110 # Security classification 

111 from integrations.remote_desktop.security import classify_remote_input 

112 classification = classify_remote_input(event) 

113 if classification == 'destructive': 

114 logger.warning(f"Destructive input blocked: {event}") 

115 return {'success': False, 'error': 'destructive_action_blocked', 

116 'classification': classification} 

117 

118 self._event_count += 1 

119 

120 try: 

121 handler = self._get_handler(event_type) 

122 if handler: 

123 handler(event) 

124 return {'success': True, 'error': None, 

125 'classification': classification} 

126 else: 

127 return {'success': False, 'error': f'unknown_event_type: {event_type}', 

128 'classification': classification} 

129 except Exception as e: 

130 logger.error(f"Input event failed: {e}") 

131 return {'success': False, 'error': str(e), 

132 'classification': classification} 

133 

134 def _get_handler(self, event_type: str): 

135 """Map event type to handler method.""" 

136 handlers = { 

137 'click': self._handle_click, 

138 'rightclick': self._handle_rightclick, 

139 'doubleclick': self._handle_doubleclick, 

140 'middleclick': self._handle_middleclick, 

141 'move': self._handle_move, 

142 'mouse_move': self._handle_move, 

143 'drag': self._handle_drag, 

144 'scroll': self._handle_scroll, 

145 'key': self._handle_key, 

146 'type': self._handle_type, 

147 'hotkey': self._handle_hotkey, 

148 'mouse_down': self._handle_mouse_down, 

149 'mouse_up': self._handle_mouse_up, 

150 'file_drop': self._handle_file_drop, 

151 } 

152 return handlers.get(event_type) 

153 

154 # ── Mouse Events ──────────────────────────────────────────── 

155 

156 def _handle_click(self, event: dict) -> None: 

157 x, y = event.get('x', 0), event.get('y', 0) 

158 if self._mouse: 

159 self._mouse.position = (x, y) 

160 self._mouse.click(Button.left) 

161 elif _pyautogui: 

162 _pyautogui.click(x, y) 

163 

164 def _handle_rightclick(self, event: dict) -> None: 

165 x, y = event.get('x', 0), event.get('y', 0) 

166 if self._mouse: 

167 self._mouse.position = (x, y) 

168 self._mouse.click(Button.right) 

169 elif _pyautogui: 

170 _pyautogui.rightClick(x, y) 

171 

172 def _handle_doubleclick(self, event: dict) -> None: 

173 x, y = event.get('x', 0), event.get('y', 0) 

174 if self._mouse: 

175 self._mouse.position = (x, y) 

176 self._mouse.click(Button.left, 2) 

177 elif _pyautogui: 

178 _pyautogui.doubleClick(x, y) 

179 

180 def _handle_middleclick(self, event: dict) -> None: 

181 x, y = event.get('x', 0), event.get('y', 0) 

182 if self._mouse: 

183 self._mouse.position = (x, y) 

184 self._mouse.click(Button.middle) 

185 elif _pyautogui: 

186 _pyautogui.middleClick(x, y) 

187 

188 def _handle_move(self, event: dict) -> None: 

189 x, y = event.get('x', 0), event.get('y', 0) 

190 if self._mouse: 

191 self._mouse.position = (x, y) 

192 elif _pyautogui: 

193 _pyautogui.moveTo(x, y, _pause=False) 

194 

195 def _handle_drag(self, event: dict) -> None: 

196 start_x = event.get('start_x', event.get('x', 0)) 

197 start_y = event.get('start_y', event.get('y', 0)) 

198 end_x = event.get('end_x', event.get('x', 0)) 

199 end_y = event.get('end_y', event.get('y', 0)) 

200 if _pyautogui: 

201 _pyautogui.moveTo(start_x, start_y) 

202 _pyautogui.drag(end_x - start_x, end_y - start_y) 

203 elif self._mouse: 

204 self._mouse.position = (start_x, start_y) 

205 self._mouse.press(Button.left) 

206 self._mouse.position = (end_x, end_y) 

207 self._mouse.release(Button.left) 

208 

209 def _handle_scroll(self, event: dict) -> None: 

210 delta_y = event.get('delta_y', 0) 

211 delta_x = event.get('delta_x', 0) 

212 x, y = event.get('x'), event.get('y') 

213 if self._mouse: 

214 if x is not None and y is not None: 

215 self._mouse.position = (x, y) 

216 self._mouse.scroll(delta_x, delta_y) 

217 elif _pyautogui: 

218 if x is not None and y is not None: 

219 _pyautogui.moveTo(x, y, _pause=False) 

220 _pyautogui.scroll(delta_y) 

221 

222 def _handle_mouse_down(self, event: dict) -> None: 

223 x, y = event.get('x', 0), event.get('y', 0) 

224 button_name = event.get('button', 'left') 

225 if self._mouse: 

226 self._mouse.position = (x, y) 

227 button = {'left': Button.left, 'right': Button.right, 

228 'middle': Button.middle}.get(button_name, Button.left) 

229 self._mouse.press(button) 

230 elif _pyautogui: 

231 _pyautogui.mouseDown(x, y, button=button_name) 

232 

233 def _handle_mouse_up(self, event: dict) -> None: 

234 x, y = event.get('x', 0), event.get('y', 0) 

235 button_name = event.get('button', 'left') 

236 if self._mouse: 

237 self._mouse.position = (x, y) 

238 button = {'left': Button.left, 'right': Button.right, 

239 'middle': Button.middle}.get(button_name, Button.left) 

240 self._mouse.release(button) 

241 elif _pyautogui: 

242 _pyautogui.mouseUp(x, y, button=button_name) 

243 

244 # ── File Drop Event ──────────────────────────────────────── 

245 

246 def _handle_file_drop(self, event: dict) -> None: 

247 """Simulate file drop at (x, y) on host OS. 

248 

249 event: {type: 'file_drop', x, y, file_paths: [...]} 

250 

251 Moves cursor to position and opens file manager at the received 

252 file location. 

253 """ 

254 x = event.get('x', 0) 

255 y = event.get('y', 0) 

256 # Move cursor to drop position 

257 self._handle_move({'x': x, 'y': y}) 

258 logger.info(f"File drop simulated at ({x}, {y})") 

259 

260 # ── Keyboard Events ───────────────────────────────────────── 

261 

262 def _handle_key(self, event: dict) -> None: 

263 """Single key press+release.""" 

264 key = event.get('key', '') 

265 if not key: 

266 return 

267 if self._keyboard: 

268 pynput_key = _PYNPUT_SPECIAL_KEYS.get(key.lower()) 

269 if pynput_key: 

270 self._keyboard.press(pynput_key) 

271 self._keyboard.release(pynput_key) 

272 else: 

273 self._keyboard.press(key) 

274 self._keyboard.release(key) 

275 elif _pyautogui: 

276 _pyautogui.press(key) 

277 

278 def _handle_type(self, event: dict) -> None: 

279 """Type text string.""" 

280 text = event.get('text', '') 

281 if not text: 

282 return 

283 if self._keyboard: 

284 self._keyboard.type(text) 

285 elif _pyautogui: 

286 _pyautogui.typewrite(text, interval=0.02) 

287 

288 def _handle_hotkey(self, event: dict) -> None: 

289 """Execute hotkey combo (e.g., 'ctrl+c').""" 

290 hotkey = event.get('hotkey', '') 

291 if not hotkey: 

292 return 

293 keys = [k.strip() for k in hotkey.split('+')] 

294 if self._keyboard: 

295 pressed = [] 

296 try: 

297 for k in keys: 

298 pynput_key = _PYNPUT_SPECIAL_KEYS.get(k.lower(), k) 

299 self._keyboard.press(pynput_key) 

300 pressed.append(pynput_key) 

301 finally: 

302 for k in reversed(pressed): 

303 try: 

304 self._keyboard.release(k) 

305 except Exception: 

306 pass 

307 elif _pyautogui: 

308 _pyautogui.hotkey(*keys) 

309 

310 # ── Stats ─────────────────────────────────────────────────── 

311 

312 def get_stats(self) -> dict: 

313 return { 

314 'control_enabled': self._allow_control, 

315 'event_count': self._event_count, 

316 'backends': { 

317 'pynput_mouse': self._mouse is not None, 

318 'pynput_keyboard': self._keyboard is not None, 

319 'pyautogui': _pyautogui is not None, 

320 }, 

321 }