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

1""" 

2Service Lifecycle Manager — Manages external remote desktop engines as HARTOS services. 

3 

4Follows the NodeWatchdog register pattern (heartbeat-based process monitoring) 

5and CodingToolBackend detection pattern (detect/install/start lifecycle). 

6 

7Each engine (RustDesk, Sunshine, Moonlight) is managed as a background service: 

8 detect → install prompt → start → health monitor → auto-restart 

9 

10The ServiceManager is the lifecycle layer; the Orchestrator (orchestrator.py) 

11is the brain that decides *which* engine to use and coordinates sessions. 

12""" 

13 

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 

21 

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

23 

24 

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' 

33 

34 

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 

46 

47 

48class EngineService: 

49 """Lifecycle wrapper for a single external remote desktop engine. 

50 

51 Delegates detection and control to the appropriate bridge module. 

52 """ 

53 

54 def __init__(self, engine_name: str): 

55 self.engine_name = engine_name 

56 self.info = EngineInfo(name=engine_name) 

57 self._bridge = None 

58 

59 def _get_bridge(self): 

60 """Lazy-load the bridge for this engine.""" 

61 if self._bridge is not None: 

62 return self._bridge 

63 

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 

77 

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 

87 

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}" 

94 

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 

102 

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 

111 

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 

128 

129 def stop(self) -> bool: 

130 """Stop the engine service.""" 

131 bridge = self._get_bridge() 

132 if not bridge: 

133 return False 

134 

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 

146 

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 

152 

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 

161 

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 

172 

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 

181 

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 

187 

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 } 

200 

201 

202class ServiceManager: 

203 """Manages lifecycle of all remote desktop engines. 

204 

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 """ 

211 

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 

217 

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() 

224 

225 def ensure_engine(self, engine_name: str) -> Tuple[bool, str]: 

226 """Ensure an engine is installed and running. 

227 

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' 

233 

234 service = self._engines.get(engine_name) 

235 if not service: 

236 return False, f'Unknown engine: {engine_name}' 

237 

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}' 

242 

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}' 

249 

250 return True, f'{engine_name} ready' 

251 

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() 

263 

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() 

269 

270 results['native'] = { 

271 'engine': 'native', 

272 'state': 'running', 

273 'installed': True, 

274 'running': True, 

275 'healthy': True, 

276 } 

277 return results 

278 

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 

285 

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() 

291 

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'} 

306 

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 

320 

321 def register_with_watchdog(self) -> bool: 

322 """Register running engines with NodeWatchdog for auto-restart. 

323 

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 

329 

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 

336 

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") 

347 

348 self._watchdog_registered = True 

349 return True 

350 except Exception as e: 

351 logger.debug(f"Watchdog registration failed: {e}") 

352 return False 

353 

354 def heartbeat(self, engine_name: str) -> None: 

355 """Send heartbeat for an engine (called by health check loop). 

356 

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 

366 

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 

379 

380 

381# ── Singleton ──────────────────────────────────────────────── 

382 

383_service_manager: Optional[ServiceManager] = None 

384 

385 

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