atlus/backend/display.py
2026-03-14 22:41:00 -05:00

574 lines
19 KiB
Python

"""Atlus — X11 Display Manager.
Manages a virtual X11 display (Xvfb) with window manager (openbox),
VNC server (x11vnc), WebSocket proxy (websockify), and system tray
(stalonetray) for running native GUI apps on headless SBCs.
One shared display per user. Apps launch into the display and are
accessible via noVNC in the browser.
"""
import asyncio
import logging
import os
import shutil
import signal
import time
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
from backend.config import DATA_DIR
log = logging.getLogger("atlus.display")
DISPLAY_DATA_DIR = DATA_DIR / "display"
# Default virtual display settings
DEFAULT_RESOLUTION = "1280x800"
DEFAULT_DEPTH = 24
XVFB_DISPLAY_BASE = 50 # Start numbering from :50 to avoid conflicts
# ---------------------------------------------------------------------------
# Dependency checks
# ---------------------------------------------------------------------------
def _find_bin(name: str) -> Optional[str]:
"""Find a binary, even with systemd's minimal PATH."""
found = shutil.which(name)
if found:
return found
for p in ("/usr/bin", "/usr/local/bin", "/usr/sbin", "/bin"):
full = os.path.join(p, name)
if os.path.isfile(full) and os.access(full, os.X_OK):
return full
return None
BINS = {
"Xvfb": _find_bin("Xvfb"),
"openbox": _find_bin("openbox"),
"x11vnc": _find_bin("x11vnc"),
"websockify": _find_bin("websockify"),
"stalonetray": _find_bin("stalonetray"),
"wmctrl": _find_bin("wmctrl"),
"xdotool": _find_bin("xdotool"),
"xdpyinfo": _find_bin("xdpyinfo"),
}
def check_dependencies() -> dict:
"""Return dependency status dict."""
return {name: (path is not None) for name, path in BINS.items()}
def _require_display_deps():
"""Check that minimum required binaries are available."""
missing = []
for name in ("Xvfb", "openbox", "x11vnc", "websockify"):
if not BINS[name]:
missing.append(name)
return missing
# ---------------------------------------------------------------------------
# Managed Process — async subprocess wrapper
# ---------------------------------------------------------------------------
@dataclass
class ManagedProcess:
"""A long-lived subprocess with monitoring."""
name: str
proc: Optional[asyncio.subprocess.Process] = None
pid: Optional[int] = None
started_at: float = 0
_monitor_task: Optional[asyncio.Task] = field(default=None, repr=False)
@property
def alive(self) -> bool:
return self.proc is not None and self.proc.returncode is None
async def start(self, *args, env=None, **kwargs):
"""Launch the subprocess."""
log.info("Starting %s: %s", self.name, " ".join(str(a) for a in args))
self.proc = await asyncio.create_subprocess_exec(
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env,
**kwargs,
)
self.pid = self.proc.pid
self.started_at = time.time()
log.info("%s started (pid %d)", self.name, self.pid)
async def stop(self):
"""Terminate the process gracefully, then force-kill."""
if not self.alive:
return
if self._monitor_task and not self._monitor_task.done():
self._monitor_task.cancel()
try:
self.proc.terminate()
try:
await asyncio.wait_for(self.proc.wait(), timeout=5)
except asyncio.TimeoutError:
self.proc.kill()
await self.proc.wait()
except ProcessLookupError:
pass
log.info("%s stopped (pid %d)", self.name, self.pid)
def to_dict(self) -> dict:
return {
"name": self.name,
"pid": self.pid,
"alive": self.alive,
"started_at": self.started_at,
}
# ---------------------------------------------------------------------------
# X11 App — a launched GUI application
# ---------------------------------------------------------------------------
@dataclass
class X11App:
"""A GUI application running on the virtual display."""
app_id: str
command: str
args: list[str] = field(default_factory=list)
name: str = ""
proc: Optional[asyncio.subprocess.Process] = None
pid: Optional[int] = None
started_at: float = 0
has_tray_icon: bool = False
@property
def alive(self) -> bool:
return self.proc is not None and self.proc.returncode is None
async def start(self, env: dict):
"""Launch the application."""
cmd = [self.command] + self.args
log.info("Launching X11 app %s: %s", self.app_id, " ".join(cmd))
try:
self.proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env,
)
self.pid = self.proc.pid
self.started_at = time.time()
log.info("X11 app %s started (pid %d)", self.app_id, self.pid)
except Exception:
log.exception("Failed to launch X11 app %s", self.app_id)
raise
async def stop(self):
"""Terminate the application."""
if not self.alive:
return
try:
self.proc.terminate()
try:
await asyncio.wait_for(self.proc.wait(), timeout=5)
except asyncio.TimeoutError:
self.proc.kill()
await self.proc.wait()
except ProcessLookupError:
pass
log.info("X11 app %s stopped", self.app_id)
def to_dict(self) -> dict:
return {
"app_id": self.app_id,
"command": self.command,
"name": self.name or self.command,
"alive": self.alive,
"pid": self.pid,
"started_at": self.started_at,
"has_tray_icon": self.has_tray_icon,
}
# ---------------------------------------------------------------------------
# Display Session — one virtual display per user
# ---------------------------------------------------------------------------
class DisplaySession:
"""Manages a complete virtual X11 display with all supporting services."""
def __init__(self, username: str, display_num: int):
self.username = username
self.display_num = display_num
self.display = f":{display_num}"
self.resolution = DEFAULT_RESOLUTION
self.depth = DEFAULT_DEPTH
# Managed processes
self.xvfb = ManagedProcess("Xvfb")
self.openbox = ManagedProcess("openbox")
self.x11vnc = ManagedProcess("x11vnc")
self.websockify = ManagedProcess("websockify")
self.stalonetray = ManagedProcess("stalonetray")
# Launched GUI apps
self.apps: dict[str, X11App] = {}
# VNC/WebSocket ports
self.vnc_port = 5900 + display_num
self.ws_port = 6080 + display_num
self.started = False
self.created_at = time.time()
self.last_active = time.time()
# Data directory for this session
self.session_dir = DISPLAY_DATA_DIR / username
self.session_dir.mkdir(parents=True, exist_ok=True)
def _display_env(self) -> dict:
"""Build environment dict with DISPLAY set."""
env = {**os.environ}
env["DISPLAY"] = self.display
env["HOME"] = os.path.expanduser(f"~{self.username}") or "/"
env["USER"] = self.username
env["XAUTHORITY"] = str(self.session_dir / ".Xauthority")
# Ensure full PATH for systemd
path = env.get("PATH", "")
for p in ("/usr/local/bin", "/usr/bin", "/usr/sbin", "/bin", "/sbin"):
if p not in path:
path = p + ":" + path
env["PATH"] = path
env["LC_ALL"] = "C"
return env
async def start(self):
"""Start the complete display stack: Xvfb → openbox → stalonetray → x11vnc → websockify."""
if self.started:
return
env = self._display_env()
# 1. Start Xvfb
xvfb_bin = BINS["Xvfb"]
await self.xvfb.start(
xvfb_bin,
self.display,
"-screen", "0", f"{self.resolution}x{self.depth}",
"-ac", # disable access control
"-nolisten", "tcp",
env=env,
)
# Wait for display to be ready
await self._wait_for_display(env, timeout=10)
# 2. Start openbox (window manager)
openbox_bin = BINS["openbox"]
await self.openbox.start(
openbox_bin,
"--config-file", "/dev/null",
env=env,
)
await asyncio.sleep(0.5)
# 3. Start stalonetray (system tray)
if BINS["stalonetray"]:
stalonetray_bin = BINS["stalonetray"]
# Configure stalonetray: small, at top-right, transparent
await self.stalonetray.start(
stalonetray_bin,
"--geometry", "1x1+0+0",
"--icon-size", "24",
"--kludges", "force_icons_size",
"--window-type", "dock",
"--grow-gravity", "E",
"--icon-gravity", "E",
"--background", "#111318",
"--no-shrink",
env=env,
)
await asyncio.sleep(0.3)
# 4. Start x11vnc
x11vnc_bin = BINS["x11vnc"]
await self.x11vnc.start(
x11vnc_bin,
"-display", self.display,
"-rfbport", str(self.vnc_port),
"-shared",
"-forever",
"-nopw",
"-noxdamage",
"-cursor", "most",
"-ncache", "10",
env=env,
)
await asyncio.sleep(0.5)
# 5. Start websockify (WebSocket → VNC proxy)
websockify_bin = BINS["websockify"]
# Serve noVNC client files if available
novnc_dir = self._find_novnc()
ws_args = [
websockify_bin,
"--heartbeat", "30",
str(self.ws_port),
f"localhost:{self.vnc_port}",
]
if novnc_dir:
ws_args.insert(-2, "--web")
ws_args.insert(-2, str(novnc_dir))
await self.websockify.start(*ws_args, env=env)
await asyncio.sleep(0.3)
self.started = True
self.last_active = time.time()
log.info("Display session started for %s on %s (VNC:%d, WS:%d)",
self.username, self.display, self.vnc_port, self.ws_port)
async def _wait_for_display(self, env: dict, timeout: float = 10):
"""Wait until the Xvfb display is accepting connections."""
xdpyinfo = BINS.get("xdpyinfo")
if not xdpyinfo:
# No xdpyinfo available — just wait a bit
await asyncio.sleep(1.5)
return
start = time.time()
while time.time() - start < timeout:
proc = await asyncio.create_subprocess_exec(
xdpyinfo, "-display", self.display,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
env=env,
)
await proc.wait()
if proc.returncode == 0:
log.info("Display %s is ready", self.display)
return
await asyncio.sleep(0.3)
log.warning("Display %s may not be ready after %.1fs", self.display, timeout)
def _find_novnc(self) -> Optional[Path]:
"""Find noVNC installation directory."""
candidates = [
Path("/usr/share/novnc"),
Path("/usr/share/noVNC"),
Path("/usr/local/share/novnc"),
Path("/usr/local/share/noVNC"),
Path("/opt/novnc"),
Path("/opt/noVNC"),
Path("/snap/novnc/current/usr/share/novnc"),
]
for p in candidates:
if p.is_dir() and (p / "vnc.html").exists():
return p
if p.is_dir() and (p / "vnc_lite.html").exists():
return p
return None
async def stop(self):
"""Stop everything in reverse order."""
# Stop all apps first
for app in list(self.apps.values()):
await app.stop()
self.apps.clear()
# Stop services in reverse order
for svc in (self.websockify, self.x11vnc, self.stalonetray, self.openbox, self.xvfb):
await svc.stop()
self.started = False
log.info("Display session stopped for %s", self.username)
async def launch_app(self, command: str, args: list[str] = None,
name: str = "", has_tray_icon: bool = False) -> X11App:
"""Launch a GUI application on this display."""
if not self.started:
await self.start()
app_id = str(uuid.uuid4())[:8]
app = X11App(
app_id=app_id,
command=command,
args=args or [],
name=name or os.path.basename(command),
has_tray_icon=has_tray_icon,
)
env = self._display_env()
await app.start(env)
self.apps[app_id] = app
self.last_active = time.time()
# Start a monitor task that removes app when it exits
asyncio.create_task(self._monitor_app(app_id))
return app
async def _monitor_app(self, app_id: str):
"""Wait for an app to exit, then clean up."""
app = self.apps.get(app_id)
if not app or not app.proc:
return
try:
await app.proc.wait()
except Exception:
pass
# Remove from apps dict (it's already dead)
self.apps.pop(app_id, None)
log.info("X11 app %s (%s) exited", app_id, app.name)
async def stop_app(self, app_id: str) -> bool:
"""Stop a specific app."""
app = self.apps.pop(app_id, None)
if not app:
return False
await app.stop()
return True
async def list_windows(self) -> list[dict]:
"""List windows using wmctrl."""
wmctrl = BINS.get("wmctrl")
if not wmctrl or not self.started:
return []
env = self._display_env()
try:
proc = await asyncio.create_subprocess_exec(
wmctrl, "-l", "-p",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env,
)
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=5)
if proc.returncode != 0:
return []
windows = []
for line in stdout.decode().strip().split("\n"):
if not line.strip():
continue
parts = line.split(None, 4)
if len(parts) >= 5:
windows.append({
"window_id": parts[0],
"desktop": parts[1],
"pid": int(parts[2]) if parts[2].isdigit() else 0,
"host": parts[3],
"title": parts[4],
})
return windows
except Exception:
return []
async def focus_window(self, window_id: str) -> bool:
"""Activate a window using wmctrl."""
wmctrl = BINS.get("wmctrl")
if not wmctrl or not self.started:
return False
env = self._display_env()
try:
proc = await asyncio.create_subprocess_exec(
wmctrl, "-i", "-a", window_id,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
env=env,
)
await asyncio.wait_for(proc.wait(), timeout=5)
return proc.returncode == 0
except Exception:
return False
def touch(self):
self.last_active = time.time()
def to_dict(self) -> dict:
return {
"username": self.username,
"display": self.display,
"resolution": self.resolution,
"vnc_port": self.vnc_port,
"ws_port": self.ws_port,
"started": self.started,
"created_at": self.created_at,
"last_active": self.last_active,
"apps": {aid: a.to_dict() for aid, a in self.apps.items() if a.alive},
"services": {
"xvfb": self.xvfb.to_dict(),
"openbox": self.openbox.to_dict(),
"x11vnc": self.x11vnc.to_dict(),
"websockify": self.websockify.to_dict(),
"stalonetray": self.stalonetray.to_dict(),
},
}
# ---------------------------------------------------------------------------
# Display Manager — singleton
# ---------------------------------------------------------------------------
class DisplayManager:
"""Manages all user display sessions."""
def __init__(self):
self._sessions: dict[str, DisplaySession] = {}
self._next_display = XVFB_DISPLAY_BASE
DISPLAY_DATA_DIR.mkdir(parents=True, exist_ok=True)
def _allocate_display(self) -> int:
"""Allocate the next available display number."""
num = self._next_display
self._next_display += 1
return num
async def get_or_create(self, username: str) -> DisplaySession:
"""Get existing display session or create (but don't start) a new one."""
if username not in self._sessions:
display_num = self._allocate_display()
self._sessions[username] = DisplaySession(username, display_num)
log.info("Created display session for %s on :%d", username, display_num)
session = self._sessions[username]
session.touch()
return session
async def ensure_started(self, username: str) -> DisplaySession:
"""Get or create a display session and ensure it's started."""
session = await self.get_or_create(username)
if not session.started:
await session.start()
return session
def get_session(self, username: str) -> Optional[DisplaySession]:
"""Get display session if it exists."""
return self._sessions.get(username)
async def stop_session(self, username: str) -> bool:
"""Stop and remove a display session."""
session = self._sessions.pop(username, None)
if not session:
return False
await session.stop()
return True
async def shutdown_all(self):
"""Stop all display sessions — called on server shutdown."""
for username in list(self._sessions.keys()):
await self.stop_session(username)
log.info("All display sessions shut down")
# ---------------------------------------------------------------------------
# Singleton
# ---------------------------------------------------------------------------
display_manager = DisplayManager()