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.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.display import display_manager
|
||||
from backend.routers.plugins import asi_bridge
|
||||
|
||||
logging.basicConfig(
|
||||
|
|
@ -56,8 +55,6 @@ async def lifespan(app: FastAPI):
|
|||
|
||||
# Kill all PTYs on shutdown
|
||||
session_manager.shutdown_all()
|
||||
# Stop all X11 display sessions
|
||||
await display_manager.shutdown_all()
|
||||
log.info("Atlus shutdown complete")
|
||||
|
||||
|
||||
|
|
@ -112,7 +109,6 @@ app.include_router(network.router)
|
|||
app.include_router(packages.router)
|
||||
app.include_router(updates.router)
|
||||
app.include_router(session.router)
|
||||
app.include_router(display.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;
|
||||
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/packages.css">
|
||||
<link rel="stylesheet" href="/css/apps/editor.css">
|
||||
<link rel="stylesheet" href="/css/apps/xdisplay.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- ================================================================= -->
|
||||
|
|
@ -61,10 +60,6 @@
|
|||
<span class="dock-icon">📦</span>
|
||||
<span class="dock-label">Packages</span>
|
||||
</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>
|
||||
<button class="dock-item" data-app="asi-bridge">
|
||||
<span class="dock-icon">🔭</span>
|
||||
|
|
@ -145,12 +140,6 @@
|
|||
<!-- Update notification -->
|
||||
<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 -->
|
||||
<div class="panel-section panel-network">
|
||||
<div class="panel-section-title">NETWORK</div>
|
||||
|
|
@ -218,7 +207,6 @@
|
|||
<script src="/js/apps/settings.js"></script>
|
||||
<script src="/js/apps/packages.js"></script>
|
||||
<script src="/js/apps/editor.js"></script>
|
||||
<script src="/js/apps/xdisplay.js"></script>
|
||||
<script src="/js/apps/asi_bridge.js"></script>
|
||||
</body>
|
||||
</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));
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// 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
|
||||
// =====================================================================
|
||||
|
|
@ -630,7 +581,6 @@
|
|||
loadPanelServices();
|
||||
connectStats();
|
||||
checkForUpdates();
|
||||
pollDesktopApps();
|
||||
|
||||
// Refresh services panel periodically
|
||||
setInterval(loadPanelServices, 30000);
|
||||
|
|
@ -638,9 +588,6 @@
|
|||
// Check for updates every 60 seconds
|
||||
setInterval(checkForUpdates, 60 * 1000);
|
||||
|
||||
// Poll desktop apps every 5 seconds
|
||||
setInterval(pollDesktopApps, 5000);
|
||||
|
||||
// Expose for app modules
|
||||
window.Atlus.openApp = openApp;
|
||||
window.Atlus.closeApp = closeApp;
|
||||
|
|
|
|||
17
install.sh
17
install.sh
|
|
@ -52,22 +52,6 @@ install_deps() {
|
|||
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() {
|
||||
info "Installing Atlus to $INSTALL_DIR..."
|
||||
|
||||
|
|
@ -142,7 +126,6 @@ main() {
|
|||
require_root
|
||||
detect_os
|
||||
install_deps
|
||||
install_x11_deps
|
||||
install_atlus
|
||||
setup_dirs
|
||||
install_service
|
||||
|
|
|
|||
Loading…
Reference in a new issue