Coverage for integrations / remote_desktop / viewer_client.py: 53.0%

115 statements  

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

1""" 

2Viewer Client — Native remote desktop viewer for HARTOS. 

3 

4Connects to a native host (or agent headless screen view). 

5Only used when the engine_selector picks NATIVE. RustDesk/Moonlight 

6handle their own viewing — this is the pure-Python fallback. 

7 

8Also used by agents for headless screen viewing (no GUI needed). 

9 

10Reuses: 

11 - transport.py: auto_negotiate_transport() for tier selection 

12 - signaling.py: SignalingChannel for connection negotiation 

13 - input_handler.py: event format (sent to host) 

14 - clipboard_sync.py: bidirectional clipboard bridge 

15""" 

16 

17import logging 

18import threading 

19import time 

20from typing import Any, Callable, Dict, List, Optional 

21 

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

23 

24 

25class ViewerClient: 

26 """Native viewer: receives frames, sends input to remote host. 

27 

28 Lifecycle: 

29 connect() → on_frame callbacks → send_mouse/keyboard → disconnect() 

30 """ 

31 

32 def __init__(self): 

33 self._connected = False 

34 self._transport = None 

35 self._signaling = None 

36 self._clipboard_sync = None 

37 self._session_id: Optional[str] = None 

38 self._remote_device_id: Optional[str] = None 

39 self._frame_callback: Optional[Callable[[bytes], None]] = None 

40 self._status_callback: Optional[Callable[[dict], None]] = None 

41 self._lock = threading.Lock() 

42 self._frame_count = 0 

43 self._connected_at: Optional[float] = None 

44 self._last_frame_at: Optional[float] = None 

45 

46 def connect(self, device_id: str, password: str, 

47 mode: str = 'full_control', 

48 transport_offers: Optional[dict] = None) -> dict: 

49 """Connect to a remote native host. 

50 

51 Args: 

52 device_id: Remote host's device ID 

53 password: OTP password 

54 mode: 'full_control' or 'view_only' 

55 transport_offers: Pre-negotiated offers (skip signaling) 

56 

57 Returns: 

58 {status, session_id, transport_tier} 

59 """ 

60 self._remote_device_id = device_id 

61 

62 # Authenticate 

63 try: 

64 from integrations.remote_desktop.security import authenticate_connection 

65 from integrations.remote_desktop.device_id import get_device_id 

66 local_id = get_device_id() 

67 ok, msg = authenticate_connection(device_id, local_id, password) 

68 if not ok: 

69 return {'status': 'auth_failed', 'error': msg} 

70 except Exception as e: 

71 logger.warning(f"Auth module unavailable: {e}") 

72 local_id = 'viewer' 

73 

74 # Create session 

75 from integrations.remote_desktop.session_manager import ( 

76 get_session_manager, SessionMode, 

77 ) 

78 sm = get_session_manager() 

79 mode_enum = (SessionMode.FULL_CONTROL if mode == 'full_control' 

80 else SessionMode.VIEW_ONLY) 

81 session = sm.create_session( 

82 host_device_id=device_id, 

83 viewer_device_id=local_id, 

84 mode=mode_enum, 

85 ) 

86 self._session_id = session.session_id 

87 

88 # Negotiate transport 

89 if transport_offers: 

90 self._transport = self._negotiate_transport(transport_offers) 

91 else: 

92 # Use signaling to get transport offers from host 

93 self._transport = self._signal_and_negotiate(device_id, password) 

94 

95 if not self._transport: 

96 return {'status': 'error', 'error': 'No transport available'} 

97 

98 # Register callbacks on transport 

99 self._transport.on_frame(self._on_frame_received) 

100 self._transport.on_event(self._on_event_received) 

101 

102 self._connected = True 

103 self._connected_at = time.time() 

104 

105 # Start clipboard sync 

106 self._start_clipboard_sync() 

107 

108 logger.info(f"Connected to {device_id[:12]} via {self._transport.tier.value}") 

109 return { 

110 'status': 'connected', 

111 'session_id': self._session_id, 

112 'transport_tier': self._transport.tier.value, 

113 } 

114 

115 def disconnect(self) -> None: 

116 """Disconnect from remote host.""" 

117 self._connected = False 

118 

119 # Send BYE via transport 

120 if self._transport and self._transport.connected: 

121 self._transport.send_event({ 

122 'type': 'control', 

123 'action': 'disconnect', 

124 'session_id': self._session_id, 

125 }) 

126 self._transport.close() 

127 

128 # Stop clipboard sync 

129 if self._clipboard_sync: 

130 self._clipboard_sync.stop_monitoring() 

131 self._clipboard_sync = None 

132 

133 # Disconnect session 

134 if self._session_id: 

135 try: 

136 from integrations.remote_desktop.session_manager import get_session_manager 

137 get_session_manager().disconnect_session(self._session_id) 

138 except Exception: 

139 pass 

140 

141 self._transport = None 

142 self._session_id = None 

143 logger.info("Viewer disconnected") 

144 

145 # ── Frame receive ──────────────────────────────────────── 

146 

147 def on_frame(self, callback: Callable[[bytes], None]) -> None: 

148 """Register callback for received frames (JPEG bytes).""" 

149 self._frame_callback = callback 

150 

151 def on_status(self, callback: Callable[[dict], None]) -> None: 

152 """Register callback for status updates.""" 

153 self._status_callback = callback 

154 

155 def _on_frame_received(self, data: bytes) -> None: 

156 """Handle incoming frame from host.""" 

157 self._frame_count += 1 

158 self._last_frame_at = time.time() 

159 if self._frame_callback: 

160 try: 

161 self._frame_callback(data) 

162 except Exception as e: 

163 logger.debug(f"Frame callback error: {e}") 

164 

165 def _on_event_received(self, event: dict) -> None: 

166 """Handle incoming event from host (clipboard, file, control).""" 

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

168 

169 if event_type == 'clipboard': 

170 content = event.get('content', '') 

171 if content and self._clipboard_sync: 

172 self._clipboard_sync.apply_remote_clipboard(content) 

173 

174 elif event_type == 'control': 

175 action = event.get('action', '') 

176 if action == 'disconnect': 

177 logger.info("Host initiated disconnect") 

178 self._connected = False 

179 

180 # ── Input sending ──────────────────────────────────────── 

181 

182 def send_mouse(self, event_type: str, x: int, y: int, 

183 button: str = 'left') -> bool: 

184 """Send mouse event to remote host.""" 

185 if not self._connected or not self._transport: 

186 return False 

187 return self._transport.send_event({ 

188 'type': event_type, 

189 'x': x, 

190 'y': y, 

191 'button': button, 

192 }) 

193 

194 def send_keyboard(self, event_type: str, key: str) -> bool: 

195 """Send keyboard event to remote host.""" 

196 if not self._connected or not self._transport: 

197 return False 

198 return self._transport.send_event({ 

199 'type': event_type, 

200 'key': key, 

201 }) 

202 

203 def send_text(self, text: str) -> bool: 

204 """Send text typing event to remote host.""" 

205 if not self._connected or not self._transport: 

206 return False 

207 return self._transport.send_event({ 

208 'type': 'type', 

209 'text': text, 

210 }) 

211 

212 def send_hotkey(self, hotkey: str) -> bool: 

213 """Send hotkey combo to remote host (e.g., 'ctrl+c').""" 

214 if not self._connected or not self._transport: 

215 return False 

216 return self._transport.send_event({ 

217 'type': 'hotkey', 

218 'key': hotkey, 

219 }) 

220 

221 # ── File transfer ──────────────────────────────────────── 

222 

223 def transfer_file(self, local_path: str) -> dict: 

224 """Send a file to the remote host.""" 

225 if not self._connected or not self._transport: 

226 return {'success': False, 'error': 'Not connected'} 

227 try: 

228 from integrations.remote_desktop.file_transfer import FileTransfer 

229 ft = FileTransfer() 

230 return ft.send_file(self._transport, local_path) 

231 except Exception as e: 

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

233 

234 # ── Status ─────────────────────────────────────────────── 

235 

236 @property 

237 def connected(self) -> bool: 

238 return self._connected 

239 

240 def get_status(self) -> dict: 

241 """Get viewer client status.""" 

242 fps = 0.0 

243 if self._connected_at and self._frame_count > 0: 

244 elapsed = time.time() - self._connected_at 

245 if elapsed > 0: 

246 fps = self._frame_count / elapsed 

247 

248 latency_ms = None 

249 if self._last_frame_at and self._connected_at: 

250 # Rough estimate: time since last frame 

251 latency_ms = (time.time() - self._last_frame_at) * 1000 

252 

253 return { 

254 'connected': self._connected, 

255 'session_id': self._session_id, 

256 'remote_device_id': self._remote_device_id, 

257 'transport_tier': self._transport.tier.value if self._transport else None, 

258 'fps': round(fps, 1), 

259 'latency_ms': round(latency_ms, 1) if latency_ms else None, 

260 'frames_received': self._frame_count, 

261 'transport_stats': self._transport.get_stats() if self._transport else None, 

262 'clipboard_active': self._clipboard_sync.is_running if self._clipboard_sync else False, 

263 } 

264 

265 # ── Internal ───────────────────────────────────────────── 

266 

267 def _negotiate_transport(self, offers: dict): 

268 """Negotiate transport from pre-provided offers.""" 

269 try: 

270 from integrations.remote_desktop.transport import auto_negotiate_transport 

271 return auto_negotiate_transport( 

272 self._session_id or 'anon', 

273 offers, 

274 role='viewer', 

275 ) 

276 except Exception as e: 

277 logger.error(f"Transport negotiation failed: {e}") 

278 return None 

279 

280 def _signal_and_negotiate(self, device_id: str, password: str): 

281 """Use signaling to discover host and negotiate transport.""" 

282 try: 

283 from integrations.remote_desktop.signaling import ( 

284 SignalingChannel, create_connect_request, 

285 ) 

286 from integrations.remote_desktop.device_id import get_device_id 

287 local_id = get_device_id() 

288 

289 channel = SignalingChannel(local_id) 

290 channel.start() 

291 

292 # Send connect request 

293 request = create_connect_request( 

294 local_id, device_id, password, 

295 session_id=self._session_id, 

296 ) 

297 channel.send_signal(device_id, request) 

298 

299 # Wait for accept (timeout 10s) 

300 accept = None 

301 for _ in range(100): 

302 pending = channel.get_pending() 

303 for msg in pending: 

304 if msg.msg_type == 'connect_accept': 

305 accept = msg 

306 break 

307 if accept: 

308 break 

309 time.sleep(0.1) 

310 

311 channel.close() 

312 

313 if accept: 

314 offers = accept.payload.get('transport_offers', {}) 

315 return self._negotiate_transport(offers) 

316 

317 logger.warning("No connect_accept received (timeout)") 

318 return None 

319 except Exception as e: 

320 logger.error(f"Signaling failed: {e}") 

321 return None 

322 

323 def drop_files(self, file_paths: list, x: int, y: int) -> dict: 

324 """Drop local files at position (x, y) on the remote host. 

325 

326 Uses DragDropBridge to transfer files and simulate drop. 

327 """ 

328 if not self._connected or not self._transport: 

329 return {'success': False, 'error': 'Not connected'} 

330 

331 try: 

332 from integrations.remote_desktop.drag_drop import DragDropBridge 

333 bridge = DragDropBridge(transport=self._transport) 

334 return bridge.handle_local_drop(file_paths, x, y) 

335 except Exception as e: 

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

337 

338 def _start_clipboard_sync(self) -> None: 

339 """Start clipboard bridge for this viewer session.""" 

340 try: 

341 from integrations.remote_desktop.clipboard_sync import ClipboardSync 

342 

343 def on_local_change(content): 

344 if self._transport and self._connected: 

345 self._transport.send_event({ 

346 'type': 'clipboard', 

347 'content': content, 

348 }) 

349 

350 self._clipboard_sync = ClipboardSync( 

351 on_change=on_local_change, 

352 dlp_enabled=True, 

353 ) 

354 self._clipboard_sync.start_monitoring() 

355 except Exception as e: 

356 logger.debug(f"Clipboard sync unavailable: {e}") 

357 

358 

359# ── Singleton ──────────────────────────────────────────────── 

360 

361_viewer_client: Optional[ViewerClient] = None 

362 

363 

364def get_viewer_client() -> ViewerClient: 

365 """Get or create the singleton ViewerClient.""" 

366 global _viewer_client 

367 if _viewer_client is None: 

368 _viewer_client = ViewerClient() 

369 return _viewer_client