Remove X11/noVNC display feature entirely
The Xvfb + x11vnc + websockify + noVNC approach defeats the purpose of a native web desktop environment. Removed all related backend (display.py, routers/display.py), frontend (xdisplay.js/css, panel tray polling), and installer (X11 deps) code. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a88e7bbb06
commit
6b407a056b
9 changed files with 1 additions and 1382 deletions
|
|
@ -1,574 +0,0 @@
|
||||||
"""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()
|
|
||||||
|
|
@ -11,9 +11,8 @@ from pydantic import BaseModel
|
||||||
|
|
||||||
from backend.auth import authenticate_user, create_token, logout
|
from backend.auth import authenticate_user, create_token, logout
|
||||||
from backend.config import FRONTEND_DIR, HOST, PORT
|
from backend.config import FRONTEND_DIR, HOST, PORT
|
||||||
from backend.routers import stats, terminal, files, services, processes, settings, network, packages, updates, session, display
|
from backend.routers import stats, terminal, files, services, processes, settings, network, packages, updates, session
|
||||||
from backend.sessions import manager as session_manager
|
from backend.sessions import manager as session_manager
|
||||||
from backend.display import display_manager
|
|
||||||
from backend.routers.plugins import asi_bridge
|
from backend.routers.plugins import asi_bridge
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
|
|
@ -56,8 +55,6 @@ async def lifespan(app: FastAPI):
|
||||||
|
|
||||||
# Kill all PTYs on shutdown
|
# Kill all PTYs on shutdown
|
||||||
session_manager.shutdown_all()
|
session_manager.shutdown_all()
|
||||||
# Stop all X11 display sessions
|
|
||||||
await display_manager.shutdown_all()
|
|
||||||
log.info("Atlus shutdown complete")
|
log.info("Atlus shutdown complete")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -112,7 +109,6 @@ app.include_router(network.router)
|
||||||
app.include_router(packages.router)
|
app.include_router(packages.router)
|
||||||
app.include_router(updates.router)
|
app.include_router(updates.router)
|
||||||
app.include_router(session.router)
|
app.include_router(session.router)
|
||||||
app.include_router(display.router)
|
|
||||||
app.include_router(asi_bridge.router)
|
app.include_router(asi_bridge.router)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,170 +0,0 @@
|
||||||
"""X11 Display — manage virtual display, launch/control GUI apps."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from backend.auth import get_current_user
|
|
||||||
from backend.display import display_manager, check_dependencies, _require_display_deps
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/display", tags=["display"])
|
|
||||||
log = logging.getLogger("atlus.display")
|
|
||||||
|
|
||||||
# Safe command pattern — allow typical binary paths
|
|
||||||
_SAFE_CMD = re.compile(r"^[a-zA-Z0-9_./-]+$")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Models
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class LaunchAppRequest(BaseModel):
|
|
||||||
command: str # e.g. "nextcloud" or "/usr/bin/nextcloud"
|
|
||||||
args: list[str] = []
|
|
||||||
name: str = "" # friendly name
|
|
||||||
has_tray_icon: bool = False # hint: this app has a system tray icon
|
|
||||||
|
|
||||||
|
|
||||||
class FocusWindowRequest(BaseModel):
|
|
||||||
window_id: str
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Endpoints
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@router.get("/status")
|
|
||||||
async def display_status(user: str = Depends(get_current_user)):
|
|
||||||
"""Get display session status and dependency info."""
|
|
||||||
deps = check_dependencies()
|
|
||||||
session = display_manager.get_session(user)
|
|
||||||
return {
|
|
||||||
"dependencies": deps,
|
|
||||||
"missing": _require_display_deps(),
|
|
||||||
"session": session.to_dict() if session else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/start")
|
|
||||||
async def start_display(user: str = Depends(get_current_user)):
|
|
||||||
"""Start the virtual display session."""
|
|
||||||
missing = _require_display_deps()
|
|
||||||
if missing:
|
|
||||||
raise HTTPException(
|
|
||||||
503,
|
|
||||||
f"Missing required packages: {', '.join(missing)}. "
|
|
||||||
f"Install with: sudo apt install {' '.join(p.lower() for p in missing)}"
|
|
||||||
)
|
|
||||||
session = await display_manager.ensure_started(user)
|
|
||||||
return session.to_dict()
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/stop")
|
|
||||||
async def stop_display(user: str = Depends(get_current_user)):
|
|
||||||
"""Stop the virtual display session (kills all apps)."""
|
|
||||||
ok = await display_manager.stop_session(user)
|
|
||||||
if not ok:
|
|
||||||
raise HTTPException(404, "No active display session")
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/apps/launch")
|
|
||||||
async def launch_app(
|
|
||||||
req: LaunchAppRequest,
|
|
||||||
user: str = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""Launch a GUI application on the virtual display."""
|
|
||||||
missing = _require_display_deps()
|
|
||||||
if missing:
|
|
||||||
raise HTTPException(503, f"Missing required packages: {', '.join(missing)}")
|
|
||||||
|
|
||||||
# Validate command (prevent injection)
|
|
||||||
if not _SAFE_CMD.match(req.command):
|
|
||||||
raise HTTPException(400, "Invalid command — only alphanumeric, dots, dashes, slashes allowed")
|
|
||||||
|
|
||||||
# Validate args too
|
|
||||||
for arg in req.args:
|
|
||||||
if arg.startswith("-"):
|
|
||||||
continue # flags are OK
|
|
||||||
if not _SAFE_CMD.match(arg):
|
|
||||||
raise HTTPException(400, f"Invalid argument: {arg}")
|
|
||||||
|
|
||||||
session = await display_manager.ensure_started(user)
|
|
||||||
try:
|
|
||||||
app = await session.launch_app(
|
|
||||||
command=req.command,
|
|
||||||
args=req.args,
|
|
||||||
name=req.name,
|
|
||||||
has_tray_icon=req.has_tray_icon,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(500, f"Failed to launch app: {e}")
|
|
||||||
|
|
||||||
return app.to_dict()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/apps")
|
|
||||||
async def list_apps(user: str = Depends(get_current_user)):
|
|
||||||
"""List running GUI apps."""
|
|
||||||
session = display_manager.get_session(user)
|
|
||||||
if not session:
|
|
||||||
return {"apps": []}
|
|
||||||
# Prune dead apps
|
|
||||||
dead = [aid for aid, a in session.apps.items() if not a.alive]
|
|
||||||
for aid in dead:
|
|
||||||
session.apps.pop(aid, None)
|
|
||||||
return {"apps": [a.to_dict() for a in session.apps.values()]}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/apps/{app_id}")
|
|
||||||
async def stop_app(app_id: str, user: str = Depends(get_current_user)):
|
|
||||||
"""Stop a specific GUI app."""
|
|
||||||
session = display_manager.get_session(user)
|
|
||||||
if not session:
|
|
||||||
raise HTTPException(404, "No active display session")
|
|
||||||
ok = await session.stop_app(app_id)
|
|
||||||
if not ok:
|
|
||||||
raise HTTPException(404, "App not found")
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/windows")
|
|
||||||
async def list_windows(user: str = Depends(get_current_user)):
|
|
||||||
"""List X11 windows via wmctrl."""
|
|
||||||
session = display_manager.get_session(user)
|
|
||||||
if not session or not session.started:
|
|
||||||
return {"windows": []}
|
|
||||||
windows = await session.list_windows()
|
|
||||||
return {"windows": windows}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/windows/focus")
|
|
||||||
async def focus_window(
|
|
||||||
req: FocusWindowRequest,
|
|
||||||
user: str = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""Focus/activate a specific window."""
|
|
||||||
session = display_manager.get_session(user)
|
|
||||||
if not session or not session.started:
|
|
||||||
raise HTTPException(404, "No active display session")
|
|
||||||
ok = await session.focus_window(req.window_id)
|
|
||||||
if not ok:
|
|
||||||
raise HTTPException(500, "Failed to focus window")
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/connect")
|
|
||||||
async def get_connection_info(user: str = Depends(get_current_user)):
|
|
||||||
"""Get WebSocket connection info for noVNC."""
|
|
||||||
session = display_manager.get_session(user)
|
|
||||||
if not session or not session.started:
|
|
||||||
raise HTTPException(404, "No active display session — start one first")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"ws_port": session.ws_port,
|
|
||||||
"display": session.display,
|
|
||||||
"resolution": session.resolution,
|
|
||||||
}
|
|
||||||
|
|
@ -1,185 +0,0 @@
|
||||||
/* X11 Display viewer */
|
|
||||||
|
|
||||||
.app-xdisplay {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Toolbar */
|
|
||||||
.xdisplay-toolbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border-bottom: 1px solid var(--border-structural);
|
|
||||||
min-height: 40px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xdisplay-btn {
|
|
||||||
height: 28px;
|
|
||||||
padding: 0 12px;
|
|
||||||
background: var(--bg-elevated);
|
|
||||||
border: 1px solid var(--border-component);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xdisplay-btn:hover {
|
|
||||||
background: var(--accent-hover);
|
|
||||||
border-color: var(--accent);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.xdisplay-connect-btn {
|
|
||||||
margin-left: auto;
|
|
||||||
background: var(--accent);
|
|
||||||
color: #fff;
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.xdisplay-connect-btn:hover {
|
|
||||||
opacity: 0.9;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Launch dropdown */
|
|
||||||
.xdisplay-launch-group {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xdisplay-launch-menu {
|
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
|
||||||
left: 0;
|
|
||||||
margin-top: 4px;
|
|
||||||
min-width: 160px;
|
|
||||||
background: var(--bg-elevated);
|
|
||||||
border: 1px solid var(--border-component);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
z-index: 100;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xdisplay-launch-menu-item {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
padding: 8px 12px;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 12px;
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xdisplay-launch-menu-item:hover {
|
|
||||||
background: var(--accent-hover);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Running apps list */
|
|
||||||
.xdisplay-app-list {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
flex: 1;
|
|
||||||
overflow-x: auto;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xdisplay-app-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 4px 8px;
|
|
||||||
background: var(--bg-elevated);
|
|
||||||
border: 1px solid var(--border-structural);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xdisplay-app-name {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.xdisplay-app-stop {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xdisplay-app-stop:hover {
|
|
||||||
background: var(--status-red);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xdisplay-no-apps {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Viewer area */
|
|
||||||
.xdisplay-viewer {
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
position: relative;
|
|
||||||
background: #0d0f14;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xdisplay-iframe {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
border: none;
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Status messages */
|
|
||||||
.xdisplay-status {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
text-align: center;
|
|
||||||
padding: 20px;
|
|
||||||
max-width: 400px;
|
|
||||||
line-height: 1.5;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xdisplay-status.info {
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.xdisplay-status.error {
|
|
||||||
color: var(--status-red);
|
|
||||||
}
|
|
||||||
|
|
||||||
.xdisplay-status.hidden {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
@ -282,68 +282,3 @@
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Desktop Apps (tray) */
|
|
||||||
.panel-tray-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-tray-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
min-height: 36px;
|
|
||||||
padding: 4px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-tray-dot {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-tray-dot.running {
|
|
||||||
background: var(--status-green);
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-tray-dot.stopped {
|
|
||||||
background: var(--status-red);
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-tray-name {
|
|
||||||
flex: 1;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
cursor: pointer;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-tray-name:hover {
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-tray-open {
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 14px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-tray-open:hover {
|
|
||||||
background: var(--accent-hover);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@
|
||||||
<link rel="stylesheet" href="/css/apps/settings.css">
|
<link rel="stylesheet" href="/css/apps/settings.css">
|
||||||
<link rel="stylesheet" href="/css/apps/packages.css">
|
<link rel="stylesheet" href="/css/apps/packages.css">
|
||||||
<link rel="stylesheet" href="/css/apps/editor.css">
|
<link rel="stylesheet" href="/css/apps/editor.css">
|
||||||
<link rel="stylesheet" href="/css/apps/xdisplay.css">
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- ================================================================= -->
|
<!-- ================================================================= -->
|
||||||
|
|
@ -61,10 +60,6 @@
|
||||||
<span class="dock-icon">📦</span>
|
<span class="dock-icon">📦</span>
|
||||||
<span class="dock-label">Packages</span>
|
<span class="dock-label">Packages</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="dock-item" data-app="xdisplay">
|
|
||||||
<span class="dock-icon">🖥</span>
|
|
||||||
<span class="dock-label">Display</span>
|
|
||||||
</button>
|
|
||||||
<div class="dock-separator"></div>
|
<div class="dock-separator"></div>
|
||||||
<button class="dock-item" data-app="asi-bridge">
|
<button class="dock-item" data-app="asi-bridge">
|
||||||
<span class="dock-icon">🔭</span>
|
<span class="dock-icon">🔭</span>
|
||||||
|
|
@ -145,12 +140,6 @@
|
||||||
<!-- Update notification -->
|
<!-- Update notification -->
|
||||||
<div class="panel-section panel-updates hidden" id="panelUpdates"></div>
|
<div class="panel-section panel-updates hidden" id="panelUpdates"></div>
|
||||||
|
|
||||||
<!-- Desktop Apps (tray) -->
|
|
||||||
<div class="panel-section panel-tray hidden" id="panelTray">
|
|
||||||
<div class="panel-section-title">DESKTOP APPS</div>
|
|
||||||
<div id="panelTrayApps" class="panel-tray-list"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Network -->
|
<!-- Network -->
|
||||||
<div class="panel-section panel-network">
|
<div class="panel-section panel-network">
|
||||||
<div class="panel-section-title">NETWORK</div>
|
<div class="panel-section-title">NETWORK</div>
|
||||||
|
|
@ -218,7 +207,6 @@
|
||||||
<script src="/js/apps/settings.js"></script>
|
<script src="/js/apps/settings.js"></script>
|
||||||
<script src="/js/apps/packages.js"></script>
|
<script src="/js/apps/packages.js"></script>
|
||||||
<script src="/js/apps/editor.js"></script>
|
<script src="/js/apps/editor.js"></script>
|
||||||
<script src="/js/apps/xdisplay.js"></script>
|
|
||||||
<script src="/js/apps/asi_bridge.js"></script>
|
<script src="/js/apps/asi_bridge.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1,301 +0,0 @@
|
||||||
/* Atlus — X11 Display app (noVNC viewer for virtual desktop) */
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
let container = null;
|
|
||||||
let iframeEl = null;
|
|
||||||
let statusEl = null;
|
|
||||||
let toolbarEl = null;
|
|
||||||
let wsPort = null;
|
|
||||||
let displayStarted = false;
|
|
||||||
|
|
||||||
// ---- Toolbar buttons state ----
|
|
||||||
let appListEl = null;
|
|
||||||
let refreshTimer = null;
|
|
||||||
|
|
||||||
// ---- Known launchable apps ----
|
|
||||||
const KNOWN_APPS = [
|
|
||||||
{ command: 'nextcloud', name: 'Nextcloud', has_tray_icon: true },
|
|
||||||
];
|
|
||||||
|
|
||||||
// ---- Display management ----
|
|
||||||
|
|
||||||
async function startDisplay() {
|
|
||||||
setStatus('Starting display…', 'info');
|
|
||||||
try {
|
|
||||||
const res = await Atlus.apiFetch('/api/display/start', { method: 'POST' });
|
|
||||||
if (!res || !res.ok) {
|
|
||||||
const err = res ? await res.json().catch(() => ({ detail: 'Unknown error' })) : { detail: 'No response' };
|
|
||||||
setStatus(err.detail || 'Failed to start display', 'error');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const data = await res.json();
|
|
||||||
wsPort = data.ws_port;
|
|
||||||
displayStarted = true;
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
setStatus('Failed to start display: ' + e.message, 'error');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function connectViewer() {
|
|
||||||
if (!displayStarted) {
|
|
||||||
const ok = await startDisplay();
|
|
||||||
if (!ok) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setStatus('Connecting to display…', 'info');
|
|
||||||
|
|
||||||
// Use noVNC served by websockify
|
|
||||||
// The websockify --web flag serves noVNC files
|
|
||||||
// Try direct websockify noVNC endpoint first
|
|
||||||
const host = location.hostname;
|
|
||||||
const novncUrl = `http://${host}:${wsPort}/vnc.html?host=${host}&port=${wsPort}&autoconnect=true&resize=scale&reconnect=true&reconnect_delay=1000&view_only=false`;
|
|
||||||
|
|
||||||
if (iframeEl) {
|
|
||||||
iframeEl.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
iframeEl = document.createElement('iframe');
|
|
||||||
iframeEl.className = 'xdisplay-iframe';
|
|
||||||
iframeEl.src = novncUrl;
|
|
||||||
iframeEl.setAttribute('allowfullscreen', 'true');
|
|
||||||
|
|
||||||
iframeEl.onload = () => {
|
|
||||||
statusEl.classList.add('hidden');
|
|
||||||
};
|
|
||||||
|
|
||||||
iframeEl.onerror = () => {
|
|
||||||
// If noVNC files aren't served by websockify, fallback to raw WebSocket viewer
|
|
||||||
setStatus('noVNC not available — install novnc package', 'error');
|
|
||||||
};
|
|
||||||
|
|
||||||
const viewerArea = container.querySelector('.xdisplay-viewer');
|
|
||||||
viewerArea.appendChild(iframeEl);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- App launcher ----
|
|
||||||
|
|
||||||
async function launchApp(command, args, name, hasTrayIcon) {
|
|
||||||
try {
|
|
||||||
const res = await Atlus.apiFetch('/api/display/apps/launch', {
|
|
||||||
method: 'POST',
|
|
||||||
body: { command, args: args || [], name: name || command, has_tray_icon: hasTrayIcon || false },
|
|
||||||
});
|
|
||||||
if (!res || !res.ok) {
|
|
||||||
const err = res ? await res.json().catch(() => ({})) : {};
|
|
||||||
console.error('Failed to launch app:', err.detail || 'unknown');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const data = await res.json();
|
|
||||||
refreshAppList();
|
|
||||||
return data;
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to launch app:', e);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function stopApp(appId) {
|
|
||||||
try {
|
|
||||||
await Atlus.apiFetch(`/api/display/apps/${appId}`, { method: 'DELETE' });
|
|
||||||
} catch (e) { /* best effort */ }
|
|
||||||
refreshAppList();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshAppList() {
|
|
||||||
if (!appListEl) return;
|
|
||||||
try {
|
|
||||||
const res = await Atlus.apiFetch('/api/display/apps');
|
|
||||||
if (!res || !res.ok) return;
|
|
||||||
const data = await res.json();
|
|
||||||
renderAppList(data.apps || []);
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderAppList(apps) {
|
|
||||||
if (!appListEl) return;
|
|
||||||
appListEl.innerHTML = '';
|
|
||||||
|
|
||||||
if (apps.length === 0) {
|
|
||||||
appListEl.innerHTML = '<span class="xdisplay-no-apps">No apps running</span>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
apps.forEach(app => {
|
|
||||||
const item = document.createElement('div');
|
|
||||||
item.className = 'xdisplay-app-item';
|
|
||||||
item.innerHTML = `
|
|
||||||
<span class="xdisplay-app-name">${app.name}</span>
|
|
||||||
<button class="xdisplay-app-stop" data-id="${app.app_id}" title="Stop">×</button>
|
|
||||||
`;
|
|
||||||
item.querySelector('.xdisplay-app-stop').addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
stopApp(app.app_id);
|
|
||||||
});
|
|
||||||
appListEl.appendChild(item);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Status ----
|
|
||||||
|
|
||||||
function setStatus(message, type) {
|
|
||||||
if (!statusEl) return;
|
|
||||||
statusEl.textContent = message;
|
|
||||||
statusEl.className = 'xdisplay-status';
|
|
||||||
if (type) statusEl.classList.add(type);
|
|
||||||
statusEl.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- App registration ----
|
|
||||||
|
|
||||||
Atlus.registerApp('xdisplay', {
|
|
||||||
title: 'Display',
|
|
||||||
|
|
||||||
init(el) {
|
|
||||||
container = el;
|
|
||||||
container.classList.add('app-xdisplay');
|
|
||||||
|
|
||||||
// Toolbar
|
|
||||||
toolbarEl = document.createElement('div');
|
|
||||||
toolbarEl.className = 'xdisplay-toolbar';
|
|
||||||
|
|
||||||
// Launch dropdown
|
|
||||||
const launchGroup = document.createElement('div');
|
|
||||||
launchGroup.className = 'xdisplay-launch-group';
|
|
||||||
|
|
||||||
const launchBtn = document.createElement('button');
|
|
||||||
launchBtn.className = 'xdisplay-btn xdisplay-launch-btn';
|
|
||||||
launchBtn.textContent = '+ Launch App';
|
|
||||||
|
|
||||||
const launchMenu = document.createElement('div');
|
|
||||||
launchMenu.className = 'xdisplay-launch-menu hidden';
|
|
||||||
|
|
||||||
KNOWN_APPS.forEach(app => {
|
|
||||||
const item = document.createElement('button');
|
|
||||||
item.className = 'xdisplay-launch-menu-item';
|
|
||||||
item.textContent = app.name;
|
|
||||||
item.addEventListener('click', () => {
|
|
||||||
launchMenu.classList.add('hidden');
|
|
||||||
launchApp(app.command, [], app.name, app.has_tray_icon);
|
|
||||||
});
|
|
||||||
launchMenu.appendChild(item);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Custom app option
|
|
||||||
const customItem = document.createElement('button');
|
|
||||||
customItem.className = 'xdisplay-launch-menu-item';
|
|
||||||
customItem.textContent = 'Custom…';
|
|
||||||
customItem.addEventListener('click', () => {
|
|
||||||
launchMenu.classList.add('hidden');
|
|
||||||
const cmd = prompt('Enter command to launch:');
|
|
||||||
if (cmd && cmd.trim()) {
|
|
||||||
const parts = cmd.trim().split(/\s+/);
|
|
||||||
launchApp(parts[0], parts.slice(1), parts[0], false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
launchMenu.appendChild(customItem);
|
|
||||||
|
|
||||||
launchBtn.addEventListener('click', () => {
|
|
||||||
launchMenu.classList.toggle('hidden');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close menu on outside click
|
|
||||||
document.addEventListener('click', (e) => {
|
|
||||||
if (!launchGroup.contains(e.target)) {
|
|
||||||
launchMenu.classList.add('hidden');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
launchGroup.appendChild(launchBtn);
|
|
||||||
launchGroup.appendChild(launchMenu);
|
|
||||||
toolbarEl.appendChild(launchGroup);
|
|
||||||
|
|
||||||
// Running apps list
|
|
||||||
appListEl = document.createElement('div');
|
|
||||||
appListEl.className = 'xdisplay-app-list';
|
|
||||||
toolbarEl.appendChild(appListEl);
|
|
||||||
|
|
||||||
// Connection button (right side)
|
|
||||||
const connectBtn = document.createElement('button');
|
|
||||||
connectBtn.className = 'xdisplay-btn xdisplay-connect-btn';
|
|
||||||
connectBtn.textContent = 'Connect';
|
|
||||||
connectBtn.addEventListener('click', () => connectViewer());
|
|
||||||
toolbarEl.appendChild(connectBtn);
|
|
||||||
|
|
||||||
container.appendChild(toolbarEl);
|
|
||||||
|
|
||||||
// Status
|
|
||||||
statusEl = document.createElement('div');
|
|
||||||
statusEl.className = 'xdisplay-status';
|
|
||||||
statusEl.textContent = 'Click Connect to start the virtual display';
|
|
||||||
|
|
||||||
// Viewer area
|
|
||||||
const viewerArea = document.createElement('div');
|
|
||||||
viewerArea.className = 'xdisplay-viewer';
|
|
||||||
viewerArea.appendChild(statusEl);
|
|
||||||
container.appendChild(viewerArea);
|
|
||||||
|
|
||||||
// Check if display is already running
|
|
||||||
checkExistingDisplay();
|
|
||||||
|
|
||||||
// Poll running apps every 5 seconds
|
|
||||||
refreshTimer = setInterval(refreshAppList, 5000);
|
|
||||||
},
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
if (refreshTimer) {
|
|
||||||
clearInterval(refreshTimer);
|
|
||||||
refreshTimer = null;
|
|
||||||
}
|
|
||||||
if (iframeEl) {
|
|
||||||
iframeEl.remove();
|
|
||||||
iframeEl = null;
|
|
||||||
}
|
|
||||||
container = null;
|
|
||||||
toolbarEl = null;
|
|
||||||
statusEl = null;
|
|
||||||
appListEl = null;
|
|
||||||
},
|
|
||||||
|
|
||||||
onFocus() {
|
|
||||||
refreshAppList();
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Public API: launch an app from outside (e.g., panel tray click) */
|
|
||||||
launchApp(command, args, name, hasTrayIcon) {
|
|
||||||
return launchApp(command, args, name, hasTrayIcon);
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Public API: connect/show the viewer */
|
|
||||||
showViewer() {
|
|
||||||
if (!displayStarted) {
|
|
||||||
connectViewer();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
async function checkExistingDisplay() {
|
|
||||||
try {
|
|
||||||
const res = await Atlus.apiFetch('/api/display/status');
|
|
||||||
if (!res || !res.ok) return;
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
if (data.missing && data.missing.length > 0) {
|
|
||||||
setStatus(
|
|
||||||
`Missing packages: ${data.missing.join(', ')}. Install with: sudo apt install ${data.missing.map(n => n.toLowerCase()).join(' ')}`,
|
|
||||||
'error'
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.session && data.session.started) {
|
|
||||||
wsPort = data.session.ws_port;
|
|
||||||
displayStarted = true;
|
|
||||||
connectViewer();
|
|
||||||
refreshAppList();
|
|
||||||
}
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
@ -494,55 +494,6 @@
|
||||||
.catch(() => setTimeout(() => attemptReload(attempt + 1), 3000));
|
.catch(() => setTimeout(() => attemptReload(attempt + 1), 3000));
|
||||||
}
|
}
|
||||||
|
|
||||||
// =====================================================================
|
|
||||||
// Panel — Desktop Apps (X11 tray)
|
|
||||||
// =====================================================================
|
|
||||||
|
|
||||||
async function pollDesktopApps() {
|
|
||||||
const trayPanel = $('#panelTray');
|
|
||||||
const trayList = $('#panelTrayApps');
|
|
||||||
if (!trayPanel || !trayList) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await Atlus.apiFetch('/api/display/apps');
|
|
||||||
if (!res || !res.ok) {
|
|
||||||
trayPanel.classList.add('hidden');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const data = await res.json();
|
|
||||||
const apps = data.apps || [];
|
|
||||||
|
|
||||||
if (apps.length === 0) {
|
|
||||||
trayPanel.classList.add('hidden');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
trayPanel.classList.remove('hidden');
|
|
||||||
trayList.innerHTML = '';
|
|
||||||
|
|
||||||
apps.forEach(app => {
|
|
||||||
const row = document.createElement('div');
|
|
||||||
row.className = 'panel-tray-item';
|
|
||||||
row.innerHTML = `
|
|
||||||
<span class="panel-tray-dot ${app.alive ? 'running' : 'stopped'}"></span>
|
|
||||||
<span class="panel-tray-name">${app.name}</span>
|
|
||||||
<button class="panel-tray-open" title="Open Display">↗</button>
|
|
||||||
`;
|
|
||||||
// Click app name or open button → switch to display app
|
|
||||||
row.querySelector('.panel-tray-open').addEventListener('click', () => {
|
|
||||||
openApp('xdisplay');
|
|
||||||
});
|
|
||||||
row.querySelector('.panel-tray-name').addEventListener('click', () => {
|
|
||||||
openApp('xdisplay');
|
|
||||||
});
|
|
||||||
trayList.appendChild(row);
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
// Display API not available (no X11 deps) — hide tray
|
|
||||||
trayPanel.classList.add('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
// Session persistence — save/restore desktop state
|
// Session persistence — save/restore desktop state
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
|
|
@ -630,7 +581,6 @@
|
||||||
loadPanelServices();
|
loadPanelServices();
|
||||||
connectStats();
|
connectStats();
|
||||||
checkForUpdates();
|
checkForUpdates();
|
||||||
pollDesktopApps();
|
|
||||||
|
|
||||||
// Refresh services panel periodically
|
// Refresh services panel periodically
|
||||||
setInterval(loadPanelServices, 30000);
|
setInterval(loadPanelServices, 30000);
|
||||||
|
|
@ -638,9 +588,6 @@
|
||||||
// Check for updates every 60 seconds
|
// Check for updates every 60 seconds
|
||||||
setInterval(checkForUpdates, 60 * 1000);
|
setInterval(checkForUpdates, 60 * 1000);
|
||||||
|
|
||||||
// Poll desktop apps every 5 seconds
|
|
||||||
setInterval(pollDesktopApps, 5000);
|
|
||||||
|
|
||||||
// Expose for app modules
|
// Expose for app modules
|
||||||
window.Atlus.openApp = openApp;
|
window.Atlus.openApp = openApp;
|
||||||
window.Atlus.closeApp = closeApp;
|
window.Atlus.closeApp = closeApp;
|
||||||
|
|
|
||||||
17
install.sh
17
install.sh
|
|
@ -52,22 +52,6 @@ install_deps() {
|
||||||
ok "System dependencies installed."
|
ok "System dependencies installed."
|
||||||
}
|
}
|
||||||
|
|
||||||
install_x11_deps() {
|
|
||||||
info "Installing X11 display dependencies (for GUI app support)..."
|
|
||||||
apt-get install -y -qq \
|
|
||||||
xvfb \
|
|
||||||
openbox \
|
|
||||||
x11vnc \
|
|
||||||
novnc \
|
|
||||||
websockify \
|
|
||||||
stalonetray \
|
|
||||||
wmctrl \
|
|
||||||
xdotool \
|
|
||||||
x11-utils \
|
|
||||||
> /dev/null 2>&1
|
|
||||||
ok "X11 display dependencies installed."
|
|
||||||
}
|
|
||||||
|
|
||||||
install_atlus() {
|
install_atlus() {
|
||||||
info "Installing Atlus to $INSTALL_DIR..."
|
info "Installing Atlus to $INSTALL_DIR..."
|
||||||
|
|
||||||
|
|
@ -142,7 +126,6 @@ main() {
|
||||||
require_root
|
require_root
|
||||||
detect_os
|
detect_os
|
||||||
install_deps
|
install_deps
|
||||||
install_x11_deps
|
|
||||||
install_atlus
|
install_atlus
|
||||||
setup_dirs
|
setup_dirs
|
||||||
install_service
|
install_service
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue