Coverage for integrations / remote_desktop / sunshine_bridge.py: 60.3%

146 statements  

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

1""" 

2Sunshine + Moonlight Bridge — Wraps Sunshine (host) and Moonlight (viewer) for 

3high-fidelity game-streaming-quality remote desktop. 

4 

5Sunshine: GPL-3.0 host that captures screen with hardware encoding (NVENC/AMF/QSV/VAAPI). 

6Moonlight: GPL-3.0 viewer that decodes and renders at up to 4K@120fps with <10ms latency. 

7 

8Both installed as native OS apps. HARTOS invokes via CLI + Sunshine REST API. 

9No code linking — separate processes. 

10 

11Sunshine REST API (default: https://localhost:47990): 

12 GET /api/apps — List configured apps 

13 POST /api/apps — Add app configuration 

14 GET /api/config — Get server config 

15 POST /api/config — Set server config 

16 POST /api/pin — Pair with PIN 

17 GET /api/clients — List paired clients 

18 

19Use cases: 

20 - VLM agents needing high-FPS remote screen capture (60fps for computer-use) 

21 - Gaming across devices (same user, different rooms) 

22 - Creative work (video editing, 3D modeling remote) 

23""" 

24 

25import json 

26import logging 

27import os 

28import platform 

29import shutil 

30import subprocess 

31import time 

32from typing import Any, Dict, List, Optional, Tuple 

33 

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

35 

36# ── Optional: pooled HTTP for Sunshine REST API ───────────────── 

37_http_pool = None 

38try: 

39 from core.http_pool import pooled_get as _pooled_get, pooled_post as _pooled_post 

40 _http_pool = True 

41except ImportError: 

42 _pooled_get = None 

43 _pooled_post = None 

44 

45 

46class SunshineBridge: 

47 """Bridge between HARTOS and Sunshine host service.""" 

48 

49 BINARY_NAMES = { 

50 'Windows': ['sunshine.exe'], 

51 'Linux': ['sunshine'], 

52 'Darwin': ['sunshine'], 

53 } 

54 

55 INSTALL_PATHS = { 

56 'Windows': [ 

57 os.path.join(os.environ.get('PROGRAMFILES', 'C:\\Program Files'), 'Sunshine'), 

58 os.path.join(os.environ.get('PROGRAMFILES', 'C:\\Program Files'), 

59 'Sunshine', 'sunshine.exe'), 

60 ], 

61 'Linux': [ 

62 '/usr/bin', 

63 '/usr/local/bin', 

64 '/opt/sunshine', 

65 ], 

66 'Darwin': [ 

67 '/Applications/Sunshine.app/Contents/MacOS', 

68 '/usr/local/bin', 

69 ], 

70 } 

71 

72 DEFAULT_API_PORT = 47990 

73 DEFAULT_API_URL = f'https://localhost:{DEFAULT_API_PORT}' 

74 

75 def __init__(self, binary_path: Optional[str] = None, 

76 api_url: Optional[str] = None, 

77 username: str = 'sunshine', 

78 password: str = 'sunshine'): 

79 self._binary = binary_path or self._find_binary() 

80 self._api_url = api_url or os.environ.get( 

81 'SUNSHINE_API_URL', self.DEFAULT_API_URL) 

82 self._username = username 

83 self._password = password 

84 

85 def _find_binary(self) -> Optional[str]: 

86 """Auto-detect Sunshine binary.""" 

87 system = platform.system() 

88 for name in self.BINARY_NAMES.get(system, ['sunshine']): 

89 path = shutil.which(name) 

90 if path: 

91 return path 

92 for dir_path in self.INSTALL_PATHS.get(system, []): 

93 for name in self.BINARY_NAMES.get(system, ['sunshine']): 

94 full = os.path.join(dir_path, name) 

95 if os.path.isfile(full): 

96 return full 

97 return None 

98 

99 @property 

100 def available(self) -> bool: 

101 return self._binary is not None 

102 

103 # ── REST API ──────────────────────────────────────────────── 

104 

105 def _api_get(self, endpoint: str) -> Optional[dict]: 

106 """GET request to Sunshine REST API.""" 

107 if not _http_pool: 

108 return None 

109 try: 

110 resp = _pooled_get( 

111 f"{self._api_url}{endpoint}", 

112 auth=(self._username, self._password), 

113 verify=False, # Sunshine uses self-signed cert 

114 timeout=5, 

115 ) 

116 if resp.status_code == 200: 

117 return resp.json() 

118 except Exception as e: 

119 logger.debug(f"Sunshine API GET {endpoint} failed: {e}") 

120 return None 

121 

122 def _api_post(self, endpoint: str, data: dict) -> Optional[dict]: 

123 """POST request to Sunshine REST API.""" 

124 if not _http_pool: 

125 return None 

126 try: 

127 resp = _pooled_post( 

128 f"{self._api_url}{endpoint}", 

129 json=data, 

130 auth=(self._username, self._password), 

131 verify=False, 

132 timeout=5, 

133 ) 

134 if resp.status_code in (200, 201): 

135 return resp.json() if resp.text else {'success': True} 

136 except Exception as e: 

137 logger.debug(f"Sunshine API POST {endpoint} failed: {e}") 

138 return None 

139 

140 def get_apps(self) -> Optional[list]: 

141 """List configured streaming apps.""" 

142 result = self._api_get('/api/apps') 

143 return result.get('apps', []) if result else None 

144 

145 def get_config(self) -> Optional[dict]: 

146 """Get Sunshine server configuration.""" 

147 return self._api_get('/api/config') 

148 

149 def set_config(self, config: dict) -> bool: 

150 """Update Sunshine configuration.""" 

151 result = self._api_post('/api/config', config) 

152 return result is not None 

153 

154 def pair_with_pin(self, pin: str) -> bool: 

155 """Pair a Moonlight client using PIN. 

156 

157 The PIN is displayed on the Moonlight client. 

158 """ 

159 result = self._api_post('/api/pin', {'pin': pin}) 

160 return result is not None 

161 

162 def get_paired_clients(self) -> Optional[list]: 

163 """List paired Moonlight clients.""" 

164 result = self._api_get('/api/clients') 

165 return result.get('clients', []) if result else None 

166 

167 # ── Service Management ────────────────────────────────────── 

168 

169 def start_service(self) -> bool: 

170 """Start Sunshine streaming service.""" 

171 if not self._binary: 

172 return False 

173 try: 

174 subprocess.Popen( 

175 [self._binary], 

176 stdout=subprocess.DEVNULL, 

177 stderr=subprocess.DEVNULL, 

178 creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0), 

179 ) 

180 time.sleep(2) # Give it time to start 

181 return self.is_running() 

182 except Exception as e: 

183 logger.error(f"Failed to start Sunshine: {e}") 

184 return False 

185 

186 def stop_service(self) -> bool: 

187 """Stop Sunshine service.""" 

188 system = platform.system() 

189 try: 

190 if system == 'Windows': 

191 subprocess.run(['taskkill', '/f', '/im', 'sunshine.exe'], 

192 capture_output=True, timeout=5) 

193 else: 

194 subprocess.run(['pkill', '-f', 'sunshine'], 

195 capture_output=True, timeout=5) 

196 return True 

197 except Exception: 

198 return False 

199 

200 def is_running(self) -> bool: 

201 """Check if Sunshine is running (try API ping).""" 

202 result = self._api_get('/api/config') 

203 return result is not None 

204 

205 # ── Status ────────────────────────────────────────────────── 

206 

207 def get_status(self) -> dict: 

208 return { 

209 'engine': 'sunshine', 

210 'available': self.available, 

211 'binary_path': self._binary, 

212 'api_url': self._api_url, 

213 'running': self.is_running() if self.available else False, 

214 'paired_clients': len(self.get_paired_clients() or []) if self.available else 0, 

215 } 

216 

217 def get_install_command(self) -> str: 

218 system = platform.system() 

219 if system == 'Linux': 

220 return ( 

221 "# Debian/Ubuntu:\n" 

222 "wget https://github.com/LizardByte/Sunshine/releases/latest/download/" 

223 "sunshine-ubuntu-22.04-amd64.deb && sudo dpkg -i sunshine-*.deb\n" 

224 "# NixOS:\n" 

225 "nix-env -iA nixpkgs.sunshine\n" 

226 "# Flatpak:\n" 

227 "flatpak install flathub dev.lizardbyte.app.Sunshine" 

228 ) 

229 elif system == 'Darwin': 

230 return "brew install --cask sunshine" 

231 elif system == 'Windows': 

232 return "winget install LizardByte.Sunshine" 

233 return "Visit https://github.com/LizardByte/Sunshine/releases" 

234 

235 

236class MoonlightBridge: 

237 """Bridge between HARTOS and Moonlight viewer client.""" 

238 

239 BINARY_NAMES = { 

240 'Windows': ['moonlight.exe', 'Moonlight.exe'], 

241 'Linux': ['moonlight', 'moonlight-qt'], 

242 'Darwin': ['moonlight', 'Moonlight'], 

243 } 

244 

245 def __init__(self, binary_path: Optional[str] = None): 

246 self._binary = binary_path or self._find_binary() 

247 

248 def _find_binary(self) -> Optional[str]: 

249 system = platform.system() 

250 for name in self.BINARY_NAMES.get(system, ['moonlight']): 

251 path = shutil.which(name) 

252 if path: 

253 return path 

254 return None 

255 

256 @property 

257 def available(self) -> bool: 

258 return self._binary is not None 

259 

260 def stream(self, host: str, app: str = 'Desktop', 

261 resolution: str = '1920x1080', fps: int = 60) -> Tuple[bool, str]: 

262 """Start streaming from a Sunshine host. 

263 

264 Args: 

265 host: Sunshine host IP or hostname 

266 app: App name to stream (default: 'Desktop') 

267 resolution: Stream resolution (e.g., '1920x1080', '3840x2160') 

268 fps: Frame rate (30, 60, 120) 

269 """ 

270 if not self._binary: 

271 return False, 'Moonlight not installed' 

272 

273 width, height = resolution.split('x') 

274 args = [ 

275 self._binary, 'stream', 

276 host, app, 

277 '--resolution', resolution, 

278 '--fps', str(fps), 

279 ] 

280 

281 try: 

282 proc = subprocess.Popen( 

283 args, 

284 stdout=subprocess.DEVNULL, 

285 stderr=subprocess.DEVNULL, 

286 ) 

287 return True, f'Streaming {app} from {host} at {resolution}@{fps}fps (pid={proc.pid})' 

288 except Exception as e: 

289 return False, str(e) 

290 

291 def pair(self, host: str) -> Tuple[bool, str]: 

292 """Pair with Sunshine host (will display PIN on Moonlight).""" 

293 if not self._binary: 

294 return False, 'Moonlight not installed' 

295 try: 

296 result = subprocess.run( 

297 [self._binary, 'pair', host], 

298 capture_output=True, text=True, timeout=30, 

299 ) 

300 return result.returncode == 0, result.stdout.strip() 

301 except Exception as e: 

302 return False, str(e) 

303 

304 def list_hosts(self) -> List[str]: 

305 """List discovered Sunshine hosts.""" 

306 if not self._binary: 

307 return [] 

308 try: 

309 result = subprocess.run( 

310 [self._binary, 'list'], 

311 capture_output=True, text=True, timeout=10, 

312 ) 

313 if result.returncode == 0: 

314 return [line.strip() for line in result.stdout.splitlines() if line.strip()] 

315 except Exception: 

316 pass 

317 return [] 

318 

319 def get_status(self) -> dict: 

320 return { 

321 'engine': 'moonlight', 

322 'available': self.available, 

323 'binary_path': self._binary, 

324 } 

325 

326 def get_install_command(self) -> str: 

327 system = platform.system() 

328 if system == 'Linux': 

329 return ( 

330 "# Debian/Ubuntu:\n" 

331 "sudo apt install moonlight-qt\n" 

332 "# NixOS:\n" 

333 "nix-env -iA nixpkgs.moonlight-qt\n" 

334 "# Flatpak:\n" 

335 "flatpak install flathub com.moonlight_stream.Moonlight" 

336 ) 

337 elif system == 'Darwin': 

338 return "brew install --cask moonlight" 

339 elif system == 'Windows': 

340 return "winget install MoonlightGameStreamingProject.Moonlight" 

341 return "Visit https://moonlight-stream.org" 

342 

343 

344# ── Singletons ────────────────────────────────────────────────── 

345 

346_sunshine: Optional[SunshineBridge] = None 

347_moonlight: Optional[MoonlightBridge] = None 

348 

349 

350def get_sunshine_bridge() -> SunshineBridge: 

351 global _sunshine 

352 if _sunshine is None: 

353 _sunshine = SunshineBridge() 

354 return _sunshine 

355 

356 

357def get_moonlight_bridge() -> MoonlightBridge: 

358 global _moonlight 

359 if _moonlight is None: 

360 _moonlight = MoonlightBridge() 

361 return _moonlight