Coverage for integrations / remote_desktop / service_manager.py: 74.4%
219 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"""
2Service Lifecycle Manager — Manages external remote desktop engines as HARTOS services.
4Follows the NodeWatchdog register pattern (heartbeat-based process monitoring)
5and CodingToolBackend detection pattern (detect/install/start lifecycle).
7Each engine (RustDesk, Sunshine, Moonlight) is managed as a background service:
8 detect → install prompt → start → health monitor → auto-restart
10The ServiceManager is the lifecycle layer; the Orchestrator (orchestrator.py)
11is the brain that decides *which* engine to use and coordinates sessions.
12"""
14import logging
15import platform
16import time
17import threading
18from dataclasses import dataclass, field
19from enum import Enum
20from typing import Callable, Dict, List, Optional, Tuple
22logger = logging.getLogger('hevolve.remote_desktop')
25class EngineState(Enum):
26 UNKNOWN = 'unknown'
27 NOT_INSTALLED = 'not_installed'
28 INSTALLED = 'installed' # Detected but not running
29 STARTING = 'starting'
30 RUNNING = 'running'
31 STOPPED = 'stopped'
32 ERROR = 'error'
35@dataclass
36class EngineInfo:
37 """Runtime state for a managed engine."""
38 name: str
39 state: EngineState = EngineState.UNKNOWN
40 pid: Optional[int] = None
41 started_at: Optional[float] = None
42 last_health_check: Optional[float] = None
43 healthy: bool = False
44 restart_count: int = 0
45 error: Optional[str] = None
48class EngineService:
49 """Lifecycle wrapper for a single external remote desktop engine.
51 Delegates detection and control to the appropriate bridge module.
52 """
54 def __init__(self, engine_name: str):
55 self.engine_name = engine_name
56 self.info = EngineInfo(name=engine_name)
57 self._bridge = None
59 def _get_bridge(self):
60 """Lazy-load the bridge for this engine."""
61 if self._bridge is not None:
62 return self._bridge
64 try:
65 if self.engine_name == 'rustdesk':
66 from integrations.remote_desktop.rustdesk_bridge import get_rustdesk_bridge
67 self._bridge = get_rustdesk_bridge()
68 elif self.engine_name == 'sunshine':
69 from integrations.remote_desktop.sunshine_bridge import get_sunshine_bridge
70 self._bridge = get_sunshine_bridge()
71 elif self.engine_name == 'moonlight':
72 from integrations.remote_desktop.sunshine_bridge import get_moonlight_bridge
73 self._bridge = get_moonlight_bridge()
74 except Exception as e:
75 logger.debug(f"Bridge load failed for {self.engine_name}: {e}")
76 return self._bridge
78 def detect(self) -> bool:
79 """Check if the engine binary is installed on this system."""
80 bridge = self._get_bridge()
81 if bridge and bridge.available:
82 if self.info.state == EngineState.UNKNOWN:
83 self.info.state = EngineState.INSTALLED
84 return True
85 self.info.state = EngineState.NOT_INSTALLED
86 return False
88 def install_command(self) -> str:
89 """Get platform-specific install command."""
90 bridge = self._get_bridge()
91 if bridge and hasattr(bridge, 'get_install_command'):
92 return bridge.get_install_command()
93 return f"Visit https://hartosai.com/docs/remote-desktop/{self.engine_name}"
95 def start(self) -> bool:
96 """Start the engine service/daemon."""
97 bridge = self._get_bridge()
98 if not bridge or not bridge.available:
99 self.info.state = EngineState.NOT_INSTALLED
100 self.info.error = 'Not installed'
101 return False
103 self.info.state = EngineState.STARTING
104 try:
105 if hasattr(bridge, 'start_service'):
106 ok = bridge.start_service()
107 else:
108 # Moonlight doesn't have a persistent service
109 self.info.state = EngineState.INSTALLED
110 return True
112 if ok:
113 self.info.state = EngineState.RUNNING
114 self.info.started_at = time.time()
115 self.info.healthy = True
116 self.info.error = None
117 logger.info(f"Engine {self.engine_name} started")
118 return True
119 else:
120 self.info.state = EngineState.ERROR
121 self.info.error = 'start_service returned False'
122 return False
123 except Exception as e:
124 self.info.state = EngineState.ERROR
125 self.info.error = str(e)
126 logger.warning(f"Failed to start {self.engine_name}: {e}")
127 return False
129 def stop(self) -> bool:
130 """Stop the engine service."""
131 bridge = self._get_bridge()
132 if not bridge:
133 return False
135 try:
136 if hasattr(bridge, 'stop_service'):
137 bridge.stop_service()
138 self.info.state = EngineState.STOPPED
139 self.info.pid = None
140 self.info.healthy = False
141 logger.info(f"Engine {self.engine_name} stopped")
142 return True
143 except Exception as e:
144 self.info.error = str(e)
145 return False
147 def is_running(self) -> bool:
148 """Check if engine process is alive and healthy."""
149 bridge = self._get_bridge()
150 if not bridge:
151 return False
153 try:
154 if hasattr(bridge, 'is_service_running'):
155 running = bridge.is_service_running()
156 elif hasattr(bridge, 'is_running'):
157 running = bridge.is_running()
158 else:
159 # Moonlight is on-demand, not a service
160 running = bridge.available
162 self.info.last_health_check = time.time()
163 self.info.healthy = running
164 if running and self.info.state != EngineState.RUNNING:
165 self.info.state = EngineState.RUNNING
166 elif not running and self.info.state == EngineState.RUNNING:
167 self.info.state = EngineState.STOPPED
168 return running
169 except Exception:
170 self.info.healthy = False
171 return False
173 def restart(self) -> bool:
174 """Stop then start the engine."""
175 self.stop()
176 time.sleep(1)
177 ok = self.start()
178 if ok:
179 self.info.restart_count += 1
180 return ok
182 def get_status(self) -> dict:
183 """Get engine status dict."""
184 uptime = None
185 if self.info.started_at and self.info.state == EngineState.RUNNING:
186 uptime = time.time() - self.info.started_at
188 return {
189 'engine': self.engine_name,
190 'state': self.info.state.value,
191 'installed': self.detect(),
192 'running': self.info.state == EngineState.RUNNING,
193 'healthy': self.info.healthy,
194 'pid': self.info.pid,
195 'uptime_seconds': uptime,
196 'restart_count': self.info.restart_count,
197 'error': self.info.error,
198 'install_command': self.install_command() if not self.detect() else None,
199 }
202class ServiceManager:
203 """Manages lifecycle of all remote desktop engines.
205 Singleton via get_service_manager(). Provides:
206 - Engine detection and startup
207 - Health monitoring via NodeWatchdog integration
208 - Auto-restart on crash
209 - Unified status across all engines
210 """
212 # Engines that run as persistent services
213 SERVICE_ENGINES = ['rustdesk', 'sunshine']
214 # Engines that are on-demand (no persistent service)
215 ON_DEMAND_ENGINES = ['moonlight']
216 ALL_ENGINES = SERVICE_ENGINES + ON_DEMAND_ENGINES
218 def __init__(self):
219 self._engines: Dict[str, EngineService] = {
220 name: EngineService(name) for name in self.ALL_ENGINES
221 }
222 self._watchdog_registered = False
223 self._lock = threading.Lock()
225 def ensure_engine(self, engine_name: str) -> Tuple[bool, str]:
226 """Ensure an engine is installed and running.
228 Returns:
229 (ready, message) — ready=True if engine is available for use.
230 """
231 if engine_name == 'native':
232 return True, 'Native engine always available'
234 service = self._engines.get(engine_name)
235 if not service:
236 return False, f'Unknown engine: {engine_name}'
238 # Step 1: Detect
239 if not service.detect():
240 cmd = service.install_command()
241 return False, f'{engine_name} not installed. Install with:\n{cmd}'
243 # Step 2: Start if service-type engine and not running
244 if engine_name in self.SERVICE_ENGINES:
245 if not service.is_running():
246 ok = service.start()
247 if not ok:
248 return False, f'{engine_name} failed to start: {service.info.error}'
250 return True, f'{engine_name} ready'
252 def start_all_available(self) -> Dict[str, dict]:
253 """Start all detected engines. Returns status map."""
254 results = {}
255 for name in self.SERVICE_ENGINES:
256 service = self._engines[name]
257 if service.detect():
258 if not service.is_running():
259 service.start()
260 results[name] = service.get_status()
261 else:
262 results[name] = service.get_status()
264 # On-demand engines: just detect
265 for name in self.ON_DEMAND_ENGINES:
266 service = self._engines[name]
267 service.detect()
268 results[name] = service.get_status()
270 results['native'] = {
271 'engine': 'native',
272 'state': 'running',
273 'installed': True,
274 'running': True,
275 'healthy': True,
276 }
277 return results
279 def stop_engine(self, engine_name: str) -> bool:
280 """Stop a specific engine."""
281 service = self._engines.get(engine_name)
282 if service:
283 return service.stop()
284 return False
286 def stop_all(self) -> None:
287 """Stop all running engines."""
288 for service in self._engines.values():
289 if service.info.state == EngineState.RUNNING:
290 service.stop()
292 def get_engine_status(self, engine_name: str) -> dict:
293 """Get status for a specific engine."""
294 if engine_name == 'native':
295 return {
296 'engine': 'native',
297 'state': 'running',
298 'installed': True,
299 'running': True,
300 'healthy': True,
301 }
302 service = self._engines.get(engine_name)
303 if service:
304 return service.get_status()
305 return {'engine': engine_name, 'error': 'Unknown engine'}
307 def get_all_status(self) -> Dict[str, dict]:
308 """Get status for all engines."""
309 result = {}
310 for name, service in self._engines.items():
311 result[name] = service.get_status()
312 result['native'] = {
313 'engine': 'native',
314 'state': 'running',
315 'installed': True,
316 'running': True,
317 'healthy': True,
318 }
319 return result
321 def register_with_watchdog(self) -> bool:
322 """Register running engines with NodeWatchdog for auto-restart.
324 Reuses security/node_watchdog.py:64 pattern:
325 watchdog.register(name, expected_interval, restart_fn, stop_fn)
326 """
327 if self._watchdog_registered:
328 return True
330 try:
331 from security.node_watchdog import get_watchdog
332 watchdog = get_watchdog()
333 if not watchdog:
334 logger.debug("NodeWatchdog not running, skipping registration")
335 return False
337 for name in self.SERVICE_ENGINES:
338 service = self._engines[name]
339 if service.info.state == EngineState.RUNNING:
340 watchdog.register(
341 f'rd_{name}',
342 expected_interval=30,
343 restart_fn=service.restart,
344 stop_fn=service.stop,
345 )
346 logger.info(f"Registered {name} with NodeWatchdog")
348 self._watchdog_registered = True
349 return True
350 except Exception as e:
351 logger.debug(f"Watchdog registration failed: {e}")
352 return False
354 def heartbeat(self, engine_name: str) -> None:
355 """Send heartbeat for an engine (called by health check loop).
357 Reuses NodeWatchdog heartbeat pattern.
358 """
359 try:
360 from security.node_watchdog import get_watchdog
361 watchdog = get_watchdog()
362 if watchdog:
363 watchdog.heartbeat(f'rd_{engine_name}')
364 except Exception:
365 pass
367 def health_check_all(self) -> Dict[str, bool]:
368 """Run health checks on all engines and send heartbeats."""
369 results = {}
370 for name in self.SERVICE_ENGINES:
371 service = self._engines[name]
372 if service.info.state in (EngineState.RUNNING, EngineState.STARTING):
373 running = service.is_running()
374 results[name] = running
375 if running:
376 self.heartbeat(name)
377 results['native'] = True
378 return results
381# ── Singleton ────────────────────────────────────────────────
383_service_manager: Optional[ServiceManager] = None
386def get_service_manager() -> ServiceManager:
387 """Get or create the singleton ServiceManager."""
388 global _service_manager
389 if _service_manager is None:
390 _service_manager = ServiceManager()
391 return _service_manager