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
« 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.
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.
8Also used by agents for headless screen viewing (no GUI needed).
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"""
17import logging
18import threading
19import time
20from typing import Any, Callable, Dict, List, Optional
22logger = logging.getLogger('hevolve.remote_desktop')
25class ViewerClient:
26 """Native viewer: receives frames, sends input to remote host.
28 Lifecycle:
29 connect() → on_frame callbacks → send_mouse/keyboard → disconnect()
30 """
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
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.
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)
57 Returns:
58 {status, session_id, transport_tier}
59 """
60 self._remote_device_id = device_id
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'
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
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)
95 if not self._transport:
96 return {'status': 'error', 'error': 'No transport available'}
98 # Register callbacks on transport
99 self._transport.on_frame(self._on_frame_received)
100 self._transport.on_event(self._on_event_received)
102 self._connected = True
103 self._connected_at = time.time()
105 # Start clipboard sync
106 self._start_clipboard_sync()
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 }
115 def disconnect(self) -> None:
116 """Disconnect from remote host."""
117 self._connected = False
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()
128 # Stop clipboard sync
129 if self._clipboard_sync:
130 self._clipboard_sync.stop_monitoring()
131 self._clipboard_sync = None
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
141 self._transport = None
142 self._session_id = None
143 logger.info("Viewer disconnected")
145 # ── Frame receive ────────────────────────────────────────
147 def on_frame(self, callback: Callable[[bytes], None]) -> None:
148 """Register callback for received frames (JPEG bytes)."""
149 self._frame_callback = callback
151 def on_status(self, callback: Callable[[dict], None]) -> None:
152 """Register callback for status updates."""
153 self._status_callback = callback
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}")
165 def _on_event_received(self, event: dict) -> None:
166 """Handle incoming event from host (clipboard, file, control)."""
167 event_type = event.get('type', '')
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)
174 elif event_type == 'control':
175 action = event.get('action', '')
176 if action == 'disconnect':
177 logger.info("Host initiated disconnect")
178 self._connected = False
180 # ── Input sending ────────────────────────────────────────
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 })
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 })
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 })
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 })
221 # ── File transfer ────────────────────────────────────────
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)}
234 # ── Status ───────────────────────────────────────────────
236 @property
237 def connected(self) -> bool:
238 return self._connected
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
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
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 }
265 # ── Internal ─────────────────────────────────────────────
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
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()
289 channel = SignalingChannel(local_id)
290 channel.start()
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)
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)
311 channel.close()
313 if accept:
314 offers = accept.payload.get('transport_offers', {})
315 return self._negotiate_transport(offers)
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
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.
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'}
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)}
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
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 })
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}")
359# ── Singleton ────────────────────────────────────────────────
361_viewer_client: Optional[ViewerClient] = None
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