diff --git a/backend/display.py b/backend/display.py deleted file mode 100644 index 8876ed4..0000000 --- a/backend/display.py +++ /dev/null @@ -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() diff --git a/backend/main.py b/backend/main.py index bad1b1e..98a7ca4 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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) diff --git a/backend/routers/display.py b/backend/routers/display.py deleted file mode 100644 index 7c00a7d..0000000 --- a/backend/routers/display.py +++ /dev/null @@ -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, - } diff --git a/frontend/css/apps/xdisplay.css b/frontend/css/apps/xdisplay.css deleted file mode 100644 index e8bd4a5..0000000 --- a/frontend/css/apps/xdisplay.css +++ /dev/null @@ -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; -} diff --git a/frontend/css/panel.css b/frontend/css/panel.css index c7ce1e9..87ea6d3 100644 --- a/frontend/css/panel.css +++ b/frontend/css/panel.css @@ -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); -} diff --git a/frontend/desktop.html b/frontend/desktop.html index 2960229..c7fadeb 100644 --- a/frontend/desktop.html +++ b/frontend/desktop.html @@ -23,7 +23,6 @@ - @@ -61,10 +60,6 @@ 📦 Packages -
- `; - 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 */ } - } -})(); diff --git a/frontend/js/atlus.js b/frontend/js/atlus.js index 52dbbeb..b763040 100644 --- a/frontend/js/atlus.js +++ b/frontend/js/atlus.js @@ -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 = ` - - ${app.name} - - `; - // 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; diff --git a/install.sh b/install.sh index 4c1a2dc..7cd2eae 100755 --- a/install.sh +++ b/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