574 lines
19 KiB
Python
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()
|