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:
roberts 2026-03-14 22:45:18 -05:00
parent a88e7bbb06
commit 6b407a056b
9 changed files with 1 additions and 1382 deletions

View file

@ -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()

View file

@ -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)

View file

@ -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,
}

View file

@ -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;
}

View file

@ -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);
}

View file

@ -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>

View file

@ -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">&times;</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 */ }
}
})();

View file

@ -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">&nearr;</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;

View file

@ -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