Coverage for integrations / remote_desktop / rustdesk_bridge.py: 54.6%
119 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"""
2RustDesk Bridge — Wraps RustDesk CLI/API for HARTOS agent integration.
4RustDesk is installed as a native OS app. HARTOS invokes it via CLI commands
5and manages sessions through its API. No code linking — separate process.
7RustDesk CLI commands:
8 rustdesk --get-id # Get this device's RustDesk ID
9 rustdesk --password <pass> # Set permanent password
10 rustdesk --config <key> <value> # Set configuration
11 rustdesk --connect <id> # Connect to remote device
12 rustdesk --file-transfer <id> # Open file transfer
13 rustdesk --port-forward <id> ... # Port forwarding
15RustDesk capabilities:
16 - Screen sharing + remote control (VP8/VP9/AV1)
17 - File transfer (drag-and-drop)
18 - Clipboard sync (text, images, files, HTML, RTF)
19 - Audio streaming
20 - Chat
21 - P2P with NAT traversal (UDP/TCP hole punching)
22 - Self-hosted relay server (hbbs + hbbr)
23"""
25import json
26import logging
27import os
28import platform
29import shutil
30import subprocess
31import time
32from typing import Any, Dict, List, Optional, Tuple
34logger = logging.getLogger('hevolve.remote_desktop')
37class RustDeskBridge:
38 """Bridge between HARTOS and RustDesk native application."""
40 # RustDesk binary names by platform
41 BINARY_NAMES = {
42 'Windows': ['rustdesk.exe', 'RustDesk.exe'],
43 'Linux': ['rustdesk'],
44 'Darwin': ['rustdesk', 'RustDesk'],
45 }
47 # Common installation paths
48 INSTALL_PATHS = {
49 'Windows': [
50 os.path.join(os.environ.get('PROGRAMFILES', 'C:\\Program Files'), 'RustDesk'),
51 os.path.join(os.environ.get('LOCALAPPDATA', ''), 'RustDesk'),
52 ],
53 'Linux': [
54 '/usr/bin',
55 '/usr/local/bin',
56 '/opt/rustdesk',
57 '/snap/bin',
58 '/flatpak/exports/bin',
59 ],
60 'Darwin': [
61 '/Applications/RustDesk.app/Contents/MacOS',
62 '/usr/local/bin',
63 ],
64 }
66 def __init__(self, binary_path: Optional[str] = None,
67 server_url: Optional[str] = None):
68 """
69 Args:
70 binary_path: Explicit path to rustdesk binary. Auto-detected if None.
71 server_url: Custom relay server URL (self-hosted). Uses default if None.
72 """
73 self._binary = binary_path or self._find_binary()
74 self._server_url = server_url or os.environ.get('RUSTDESK_SERVER')
75 self._device_id: Optional[str] = None
77 # ── Detection ───────────────────────────────────────────────
79 def _find_binary(self) -> Optional[str]:
80 """Auto-detect RustDesk binary on this system."""
81 system = platform.system()
82 binary_names = self.BINARY_NAMES.get(system, ['rustdesk'])
84 # Check PATH first
85 for name in binary_names:
86 path = shutil.which(name)
87 if path:
88 logger.info(f"RustDesk found in PATH: {path}")
89 return path
91 # Check common install paths
92 install_paths = self.INSTALL_PATHS.get(system, [])
93 for dir_path in install_paths:
94 for name in binary_names:
95 full_path = os.path.join(dir_path, name)
96 if os.path.isfile(full_path):
97 logger.info(f"RustDesk found at: {full_path}")
98 return full_path
100 logger.info("RustDesk not found on this system")
101 return None
103 @property
104 def available(self) -> bool:
105 """Whether RustDesk is installed and accessible."""
106 return self._binary is not None
108 @property
109 def binary_path(self) -> Optional[str]:
110 return self._binary
112 # ── CLI Commands ────────────────────────────────────────────
114 def _run(self, args: List[str], timeout: int = 10) -> Tuple[bool, str]:
115 """Run RustDesk CLI command.
117 Returns:
118 (success, output)
119 """
120 if not self._binary:
121 return False, 'RustDesk not installed'
123 cmd = [self._binary] + args
124 try:
125 result = subprocess.run(
126 cmd,
127 capture_output=True,
128 text=True,
129 timeout=timeout,
130 creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0),
131 )
132 output = (result.stdout or '').strip()
133 if result.returncode == 0:
134 return True, output
135 error = (result.stderr or '').strip()
136 return False, error or output or f'Exit code {result.returncode}'
137 except subprocess.TimeoutExpired:
138 return False, 'Command timed out'
139 except FileNotFoundError:
140 self._binary = None
141 return False, 'RustDesk binary not found'
142 except Exception as e:
143 return False, str(e)
145 def get_id(self) -> Optional[str]:
146 """Get this device's RustDesk ID.
148 Returns:
149 RustDesk ID string (e.g., '123456789') or None.
150 """
151 if self._device_id:
152 return self._device_id
154 ok, output = self._run(['--get-id'])
155 if ok and output:
156 self._device_id = output.strip()
157 logger.info(f"RustDesk ID: {self._device_id}")
158 return self._device_id
159 return None
161 def set_password(self, password: str) -> bool:
162 """Set permanent access password.
164 Returns:
165 True if password was set.
166 """
167 ok, _ = self._run(['--password', password])
168 return ok
170 def get_config(self, key: str) -> Optional[str]:
171 """Get RustDesk configuration value."""
172 ok, output = self._run(['--get-config', key])
173 return output if ok else None
175 def set_config(self, key: str, value: str) -> bool:
176 """Set RustDesk configuration value."""
177 ok, _ = self._run(['--config', key, value])
178 return ok
180 def configure_server(self, relay_server: str,
181 api_server: Optional[str] = None,
182 key: Optional[str] = None) -> bool:
183 """Configure custom relay server (self-hosted).
185 Args:
186 relay_server: Relay server address (e.g., 'relay.example.com')
187 api_server: API server address (optional)
188 key: Server public key (optional)
189 """
190 results = [self.set_config('relay-server', relay_server)]
191 if api_server:
192 results.append(self.set_config('api-server', api_server))
193 if key:
194 results.append(self.set_config('key', key))
195 return all(results)
197 # ── Session Control ─────────────────────────────────────────
199 def connect(self, remote_id: str, password: Optional[str] = None,
200 file_transfer: bool = False) -> Tuple[bool, str]:
201 """Connect to remote device.
203 Args:
204 remote_id: Target RustDesk ID
205 password: Access password (optional for same-user)
206 file_transfer: Open file transfer mode instead of remote control
208 Returns:
209 (success, message)
210 """
211 args = ['--file-transfer' if file_transfer else '--connect', remote_id]
212 if password:
213 args.extend(['--password', password])
215 # Launch as background process (non-blocking)
216 if not self._binary:
217 return False, 'RustDesk not installed'
219 try:
220 cmd = [self._binary] + args
221 proc = subprocess.Popen(
222 cmd,
223 stdout=subprocess.DEVNULL,
224 stderr=subprocess.DEVNULL,
225 creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0),
226 )
227 logger.info(f"RustDesk connecting to {remote_id} (pid={proc.pid})")
228 return True, f'Connecting to {remote_id}'
229 except Exception as e:
230 return False, str(e)
232 def disconnect_all(self) -> bool:
233 """Close all RustDesk connections."""
234 # RustDesk doesn't have a direct disconnect CLI; close the process
235 system = platform.system()
236 try:
237 if system == 'Windows':
238 subprocess.run(['taskkill', '/f', '/im', 'rustdesk.exe'],
239 capture_output=True, timeout=5)
240 else:
241 subprocess.run(['pkill', '-f', 'rustdesk'],
242 capture_output=True, timeout=5)
243 return True
244 except Exception:
245 return False
247 # ── Service Management ──────────────────────────────────────
249 def start_service(self) -> bool:
250 """Start RustDesk background service (enables incoming connections)."""
251 ok, _ = self._run(['--service', 'start'], timeout=15)
252 if not ok:
253 # Alternative: launch in service mode
254 try:
255 if self._binary:
256 subprocess.Popen(
257 [self._binary, '--service'],
258 stdout=subprocess.DEVNULL,
259 stderr=subprocess.DEVNULL,
260 )
261 return True
262 except Exception:
263 pass
264 return ok
266 def stop_service(self) -> bool:
267 """Stop RustDesk background service."""
268 ok, _ = self._run(['--service', 'stop'], timeout=15)
269 return ok
271 def is_service_running(self) -> bool:
272 """Check if RustDesk service is running."""
273 system = platform.system()
274 try:
275 if system == 'Windows':
276 result = subprocess.run(
277 ['tasklist', '/fi', 'imagename eq rustdesk.exe'],
278 capture_output=True, text=True, timeout=5,
279 )
280 return 'rustdesk.exe' in result.stdout.lower()
281 else:
282 result = subprocess.run(
283 ['pgrep', '-f', 'rustdesk'],
284 capture_output=True, timeout=5,
285 )
286 return result.returncode == 0
287 except Exception:
288 return False
290 # ── Installation ────────────────────────────────────────────
292 def get_install_command(self) -> str:
293 """Get platform-specific install command for RustDesk."""
294 system = platform.system()
295 if system == 'Linux':
296 return (
297 "# Debian/Ubuntu:\n"
298 "wget https://github.com/rustdesk/rustdesk/releases/latest/download/"
299 "rustdesk-<version>-x86_64.deb && sudo dpkg -i rustdesk-*.deb\n"
300 "# NixOS:\n"
301 "nix-env -iA nixpkgs.rustdesk\n"
302 "# Flatpak:\n"
303 "flatpak install flathub com.rustdesk.RustDesk"
304 )
305 elif system == 'Darwin':
306 return "brew install --cask rustdesk"
307 elif system == 'Windows':
308 return "winget install RustDesk.RustDesk"
309 return "Visit https://rustdesk.com/download"
311 # ── Status ──────────────────────────────────────────────────
313 def get_status(self) -> dict:
314 """Get RustDesk status for HARTOS."""
315 return {
316 'engine': 'rustdesk',
317 'available': self.available,
318 'binary_path': self._binary,
319 'device_id': self.get_id() if self.available else None,
320 'service_running': self.is_service_running() if self.available else False,
321 'server_url': self._server_url,
322 'install_command': self.get_install_command() if not self.available else None,
323 }
326# ── Singleton ───────────────────────────────────────────────────
328_rustdesk_bridge: Optional[RustDeskBridge] = None
331def get_rustdesk_bridge() -> RustDeskBridge:
332 """Get or create the singleton RustDeskBridge."""
333 global _rustdesk_bridge
334 if _rustdesk_bridge is None:
335 _rustdesk_bridge = RustDeskBridge()
336 return _rustdesk_bridge