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
« 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.
4Backend 1: pynput (low-level, modifier tracking) — optional
5Backend 2: pyautogui (fallback, reuses local_computer_tool.py action format)
7Security: runs classify_remote_input() on destructive hotkeys (Alt+F4 etc.)
8"""
10import logging
11import threading
12from typing import Any, Dict, Optional
14logger = logging.getLogger('hevolve.remote_desktop')
16# ── Optional dependencies ───────────────────────────────────────
18_pynput_mouse = None
19_pynput_keyboard = None
20_pyautogui = None
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
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
37# ── Key Mapping ─────────────────────────────────────────────────
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 }
61class InputHandler:
62 """Dispatches remote input events to local system.
64 Event format: {type, x, y, button, key, text, hotkey, delta_x, delta_y}
65 """
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
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
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'}")
91 @property
92 def control_enabled(self) -> bool:
93 return self._allow_control
95 def handle_input_event(self, event: dict) -> dict:
96 """Dispatch a remote input event.
98 Args:
99 event: {type, x, y, button, key, text, hotkey, delta_x, delta_y}
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'}
108 event_type = event.get('type', '')
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}
118 self._event_count += 1
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}
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)
154 # ── Mouse Events ────────────────────────────────────────────
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)
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)
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)
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)
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)
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)
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)
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)
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)
244 # ── File Drop Event ────────────────────────────────────────
246 def _handle_file_drop(self, event: dict) -> None:
247 """Simulate file drop at (x, y) on host OS.
249 event: {type: 'file_drop', x, y, file_paths: [...]}
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})")
260 # ── Keyboard Events ─────────────────────────────────────────
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)
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)
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)
310 # ── Stats ───────────────────────────────────────────────────
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 }