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
« 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.
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.
8Both installed as native OS apps. HARTOS invokes via CLI + Sunshine REST API.
9No code linking — separate processes.
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
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"""
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')
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
46class SunshineBridge:
47 """Bridge between HARTOS and Sunshine host service."""
49 BINARY_NAMES = {
50 'Windows': ['sunshine.exe'],
51 'Linux': ['sunshine'],
52 'Darwin': ['sunshine'],
53 }
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 }
72 DEFAULT_API_PORT = 47990
73 DEFAULT_API_URL = f'https://localhost:{DEFAULT_API_PORT}'
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
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
99 @property
100 def available(self) -> bool:
101 return self._binary is not None
103 # ── REST API ────────────────────────────────────────────────
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
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
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
145 def get_config(self) -> Optional[dict]:
146 """Get Sunshine server configuration."""
147 return self._api_get('/api/config')
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
154 def pair_with_pin(self, pin: str) -> bool:
155 """Pair a Moonlight client using PIN.
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
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
167 # ── Service Management ──────────────────────────────────────
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
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
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
205 # ── Status ──────────────────────────────────────────────────
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 }
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"
236class MoonlightBridge:
237 """Bridge between HARTOS and Moonlight viewer client."""
239 BINARY_NAMES = {
240 'Windows': ['moonlight.exe', 'Moonlight.exe'],
241 'Linux': ['moonlight', 'moonlight-qt'],
242 'Darwin': ['moonlight', 'Moonlight'],
243 }
245 def __init__(self, binary_path: Optional[str] = None):
246 self._binary = binary_path or self._find_binary()
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
256 @property
257 def available(self) -> bool:
258 return self._binary is not None
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.
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'
273 width, height = resolution.split('x')
274 args = [
275 self._binary, 'stream',
276 host, app,
277 '--resolution', resolution,
278 '--fps', str(fps),
279 ]
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)
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)
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 []
319 def get_status(self) -> dict:
320 return {
321 'engine': 'moonlight',
322 'available': self.available,
323 'binary_path': self._binary,
324 }
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"
344# ── Singletons ──────────────────────────────────────────────────
346_sunshine: Optional[SunshineBridge] = None
347_moonlight: Optional[MoonlightBridge] = None
350def get_sunshine_bridge() -> SunshineBridge:
351 global _sunshine
352 if _sunshine is None:
353 _sunshine = SunshineBridge()
354 return _sunshine
357def get_moonlight_bridge() -> MoonlightBridge:
358 global _moonlight
359 if _moonlight is None:
360 _moonlight = MoonlightBridge()
361 return _moonlight