diff --git a/backend/display.py b/backend/display.py new file mode 100644 index 0000000..8876ed4 --- /dev/null +++ b/backend/display.py @@ -0,0 +1,574 @@ +"""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 98a7ca4..bad1b1e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -11,8 +11,9 @@ 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 +from backend.routers import stats, terminal, files, services, processes, settings, network, packages, updates, session, display from backend.sessions import manager as session_manager +from backend.display import display_manager from backend.routers.plugins import asi_bridge logging.basicConfig( @@ -55,6 +56,8 @@ 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") @@ -109,6 +112,7 @@ 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 new file mode 100644 index 0000000..7c00a7d --- /dev/null +++ b/backend/routers/display.py @@ -0,0 +1,170 @@ +"""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/backend/routers/network.py b/backend/routers/network.py index bcc6492..4daebea 100644 --- a/backend/routers/network.py +++ b/backend/routers/network.py @@ -1,4 +1,4 @@ -"""Network configuration via NetworkManager (nmcli).""" +"""Network configuration via NetworkManager (nmcli) or direct ip/interfaces.""" import asyncio import ipaddress @@ -6,6 +6,7 @@ import logging import os import re import shutil +from pathlib import Path from typing import Literal, Optional from fastapi import APIRouter, Depends, HTTPException @@ -16,7 +17,26 @@ from backend.auth import get_current_user router = APIRouter(prefix="/api/network", tags=["network"]) log = logging.getLogger("atlus.network") -_HAS_NMCLI = bool(shutil.which("nmcli")) + +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", "/sbin", "/bin"): + full = os.path.join(p, name) + if os.path.isfile(full) and os.access(full, os.X_OK): + return full + return None + + +_HAS_NMCLI = bool(_find_bin("nmcli")) +_IP_BIN = _find_bin("ip") or "ip" + +# Debian/Armbian network config paths +_INTERFACES_FILE = Path("/etc/network/interfaces") +_INTERFACES_DIR = Path("/etc/network/interfaces.d") +_RESOLV_CONF = Path("/etc/resolv.conf") # --------------------------------------------------------------------------- @@ -354,3 +374,415 @@ async def apply_connection(name: str, _user: str = Depends(get_current_user)): """Reapply/reconnect a connection.""" await _nmcli("connection", "up", name, timeout=15) return {"success": True} + + +# =========================================================================== +# Fallback: direct `ip` + /etc/network/interfaces (no NetworkManager) +# =========================================================================== + +_IFACE_NAME_RE = re.compile(r"^[a-zA-Z0-9_.-]+$") + + +def _validate_iface_name(name: str): + """Prevent injection via interface name.""" + if not _IFACE_NAME_RE.match(name) or len(name) > 32: + raise HTTPException(400, "Invalid interface name") + + +async def _run_cmd(*args: str, timeout: float = 15) -> tuple[int, str, str]: + """Run a command with safe env, return (rc, stdout, stderr).""" + proc = await asyncio.create_subprocess_exec( + *args, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env=_safe_env(), + ) + try: + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) + except asyncio.TimeoutError: + proc.kill() + await proc.wait() + return 1, "", "Command timed out" + return proc.returncode, stdout.decode(), stderr.decode() + + +async def _parse_ip_addr() -> list[dict]: + """Parse `ip -j addr show` into a list of interface dicts.""" + rc, stdout, _ = await _run_cmd(_IP_BIN, "-j", "addr", "show") + if rc != 0 or not stdout.strip(): + # Fallback: parse non-json output + return await _parse_ip_addr_text() + + import json + try: + ifaces = json.loads(stdout) + except json.JSONDecodeError: + return await _parse_ip_addr_text() + + results = [] + for iface in ifaces: + name = iface.get("ifname", "") + if name == "lo": + continue + state = iface.get("operstate", "UNKNOWN").upper() + flags = iface.get("flags", []) + mac = iface.get("address", "") + + ipv4 = "" + ipv6 = "" + gateway = "" + for ai in iface.get("addr_info", []): + if ai.get("family") == "inet" and not ipv4: + ipv4 = f"{ai['local']}/{ai.get('prefixlen', 24)}" + elif ai.get("family") == "inet6" and not ipv6: + if not ai.get("local", "").startswith("fe80"): + ipv6 = ai.get("local", "") + + results.append({ + "name": name, + "state": state, + "up": state == "UP" or "UP" in flags, + "mac": mac, + "ipv4": ipv4, + "ipv6": ipv6, + }) + + # Get default gateway + rc2, gw_out, _ = await _run_cmd(_IP_BIN, "-j", "route", "show", "default") + if rc2 == 0 and gw_out.strip(): + try: + routes = json.loads(gw_out) + for route in routes: + gw = route.get("gateway", "") + dev = route.get("dev", "") + for iface in results: + if iface["name"] == dev: + iface["gateway"] = gw + break + except json.JSONDecodeError: + pass + + return results + + +async def _parse_ip_addr_text() -> list[dict]: + """Fallback parser for `ip addr show` plain text output.""" + rc, stdout, _ = await _run_cmd(_IP_BIN, "addr", "show") + if rc != 0: + return [] + + results = [] + current = None + for line in stdout.splitlines(): + # New interface line: "2: eth0: ..." + m = re.match(r"^\d+:\s+(\S+?):\s+<([^>]*)>", line) + if m: + if current: + results.append(current) + name = m.group(1).rstrip(":") + flags = m.group(2) + if name == "lo": + current = None + continue + current = { + "name": name, + "state": "UP" if "UP" in flags else "DOWN", + "up": "UP" in flags, + "mac": "", + "ipv4": "", + "ipv6": "", + "gateway": "", + } + continue + + if not current: + continue + + # MAC address: " link/ether aa:bb:cc:dd:ee:ff ..." + m = re.match(r"\s+link/ether\s+(\S+)", line) + if m: + current["mac"] = m.group(1) + + # IPv4: " inet 192.168.1.100/24 ..." + m = re.match(r"\s+inet\s+(\S+)", line) + if m and not current["ipv4"]: + current["ipv4"] = m.group(1) + + # IPv6: " inet6 2001:db8::1/64 ..." + m = re.match(r"\s+inet6\s+(\S+)", line) + if m and not current["ipv6"]: + addr = m.group(1).split("/")[0] + if not addr.startswith("fe80"): + current["ipv6"] = addr + + if current: + results.append(current) + + # Get default gateway + rc2, gw_out, _ = await _run_cmd(_IP_BIN, "route", "show", "default") + if rc2 == 0: + for line in gw_out.splitlines(): + m = re.match(r"default via (\S+) dev (\S+)", line) + if m: + for iface in results: + if iface["name"] == m.group(2): + iface["gateway"] = m.group(1) + break + + return results + + +def _parse_interfaces_file() -> dict[str, dict]: + """Parse /etc/network/interfaces into per-iface config dicts.""" + configs = {} + # Also include files from interfaces.d + content = "" + if _INTERFACES_FILE.exists(): + content = _INTERFACES_FILE.read_text() + # Check for source lines + for line in content.splitlines(): + m = re.match(r"^source\s+(.+)", line) + if m: + pattern = m.group(1).strip() + from glob import glob as gglob + for path in gglob(pattern): + try: + content += "\n" + Path(path).read_text() + except OSError: + pass + # Also read interfaces.d/*.cfg and interfaces.d/* files + if _INTERFACES_DIR.is_dir(): + for f in sorted(_INTERFACES_DIR.iterdir()): + if f.is_file(): + try: + content += "\n" + f.read_text() + except OSError: + pass + + current_iface = None + for line in content.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + # "auto eth0" or "allow-hotplug eth0" + m = re.match(r"^(auto|allow-hotplug)\s+(\S+)", stripped) + if m: + iface_name = m.group(2) + if iface_name not in configs: + configs[iface_name] = {"auto": False, "method": "dhcp"} + if m.group(1) == "auto": + configs[iface_name]["auto"] = True + continue + # "iface eth0 inet static" + m = re.match(r"^iface\s+(\S+)\s+inet\s+(\S+)", stripped) + if m: + iface_name = m.group(1) + method = m.group(2) # static, dhcp, manual, loopback + if iface_name not in configs: + configs[iface_name] = {"auto": False} + configs[iface_name]["method"] = method + current_iface = iface_name + continue + # Indented config lines under an iface block + if current_iface and line[0] in (" ", "\t"): + m = re.match(r"\s+(address|netmask|gateway|dns-nameservers|network|broadcast)\s+(.+)", stripped) + if m: + key = m.group(1) + val = m.group(2).strip() + configs[current_iface][key] = val + else: + current_iface = None + + return configs + + +def _write_interface_config(iface: str, method: str, address: str = "", + gateway: str = "", dns: list[str] = None): + """Write or update an interface block in /etc/network/interfaces.d/{iface}. + + Uses interfaces.d to avoid clobbering the main file. + """ + _INTERFACES_DIR.mkdir(parents=True, exist_ok=True) + + if method == "dhcp": + content = f"""# Managed by Atlus +auto {iface} +iface {iface} inet dhcp +""" + else: + # Parse CIDR to address + netmask + try: + net_if = ipaddress.ip_interface(address) + addr = str(net_if.ip) + mask = str(net_if.netmask) + except ValueError: + raise HTTPException(422, f"Invalid address: {address}") + + lines = [ + f"# Managed by Atlus", + f"auto {iface}", + f"iface {iface} inet static", + f" address {addr}", + f" netmask {mask}", + ] + if gateway: + lines.append(f" gateway {gateway}") + if dns: + lines.append(f" dns-nameservers {' '.join(dns)}") + content = "\n".join(lines) + "\n" + + config_file = _INTERFACES_DIR / iface + config_file.write_text(content) + log.info("Wrote interface config for %s: method=%s", iface, method) + + # Also update resolv.conf for static DNS + if method == "static" and dns: + _write_resolv_conf(dns) + + +def _write_resolv_conf(dns: list[str]): + """Write DNS servers to /etc/resolv.conf.""" + try: + lines = ["# Managed by Atlus\n"] + for d in dns: + lines.append(f"nameserver {d}\n") + _RESOLV_CONF.write_text("".join(lines)) + except OSError as e: + log.warning("Failed to write resolv.conf: %s", e) + + +# --- Fallback endpoints --- + +@router.get("/interfaces") +async def list_interfaces(_user: str = Depends(get_current_user)): + """List network interfaces using ip commands (no NetworkManager required).""" + ifaces = await _parse_ip_addr() + + # Merge with /etc/network/interfaces config + configs = _parse_interfaces_file() + for iface in ifaces: + cfg = configs.get(iface["name"], {}) + iface["config_method"] = cfg.get("method", "unknown") + iface["config_address"] = cfg.get("address", "") + iface["config_netmask"] = cfg.get("netmask", "") + iface["config_gateway"] = cfg.get("gateway", "") + iface["config_dns"] = cfg.get("dns-nameservers", "") + + # Get DNS from resolv.conf + dns_servers = [] + if _RESOLV_CONF.exists(): + try: + for line in _RESOLV_CONF.read_text().splitlines(): + m = re.match(r"^nameserver\s+(\S+)", line.strip()) + if m: + dns_servers.append(m.group(1)) + except OSError: + pass + + return { + "interfaces": ifaces, + "dns": dns_servers, + "has_nmcli": _HAS_NMCLI, + } + + +class InterfaceConfig(BaseModel): + method: Literal["dhcp", "static"] + address: Optional[str] = None # CIDR e.g. "192.168.1.100/24" + gateway: Optional[str] = None + dns: Optional[list[str]] = None + + +@router.put("/interfaces/{name}/config") +async def configure_interface( + name: str, + cfg: InterfaceConfig, + _user: str = Depends(get_current_user), +): + """Configure an interface in /etc/network/interfaces.d/ and apply.""" + _validate_iface_name(name) + + if cfg.method == "static": + if not cfg.address: + raise HTTPException(422, "IP address is required for static configuration") + if not cfg.gateway: + raise HTTPException(422, "Gateway is required for static configuration") + _validate_ipv4_config(IPv4Config(method="manual", address=cfg.address, + gateway=cfg.gateway, dns=cfg.dns)) + + # Write config file + _write_interface_config( + iface=name, + method=cfg.method, + address=cfg.address or "", + gateway=cfg.gateway or "", + dns=cfg.dns, + ) + + # Apply: bring interface down then up + # Use ifdown/ifup if available, else ip commands + ifdown = _find_bin("ifdown") + ifup = _find_bin("ifup") + + if ifdown and ifup: + await _run_cmd(ifdown, name, timeout=10) + rc, _, stderr = await _run_cmd(ifup, name, timeout=15) + if rc != 0: + log.warning("ifup %s failed: %s", name, stderr.strip()) + # Try ip commands as fallback + await _apply_with_ip(name, cfg) + else: + await _apply_with_ip(name, cfg) + + return {"success": True, "method": cfg.method} + + +async def _apply_with_ip(name: str, cfg: InterfaceConfig): + """Apply network config using ip commands directly.""" + # Flush existing addresses + await _run_cmd(_IP_BIN, "addr", "flush", "dev", name) + + if cfg.method == "static" and cfg.address: + # Add address + await _run_cmd(_IP_BIN, "addr", "add", cfg.address, "dev", name) + # Bring up + await _run_cmd(_IP_BIN, "link", "set", name, "up") + # Set default route + if cfg.gateway: + await _run_cmd(_IP_BIN, "route", "del", "default", "dev", name) + await _run_cmd(_IP_BIN, "route", "add", "default", "via", cfg.gateway, "dev", name) + # Set DNS + if cfg.dns: + _write_resolv_conf(cfg.dns) + else: + # DHCP — try dhclient or udhcpc + dhclient = _find_bin("dhclient") + udhcpc = _find_bin("udhcpc") + if dhclient: + await _run_cmd(dhclient, "-r", name, timeout=5) + await _run_cmd(dhclient, name, timeout=15) + elif udhcpc: + await _run_cmd(udhcpc, "-i", name, "-n", timeout=15) + else: + await _run_cmd(_IP_BIN, "link", "set", name, "up") + log.warning("No DHCP client found — interface %s brought up without DHCP", name) + + +@router.post("/interfaces/{name}/up") +async def interface_up(name: str, _user: str = Depends(get_current_user)): + """Bring an interface up.""" + _validate_iface_name(name) + rc, _, stderr = await _run_cmd(_IP_BIN, "link", "set", name, "up") + if rc != 0: + raise HTTPException(500, f"Failed to bring up {name}: {stderr.strip()}") + return {"success": True} + + +@router.post("/interfaces/{name}/down") +async def interface_down(name: str, _user: str = Depends(get_current_user)): + """Bring an interface down.""" + _validate_iface_name(name) + rc, _, stderr = await _run_cmd(_IP_BIN, "link", "set", name, "down") + if rc != 0: + raise HTTPException(500, f"Failed to bring down {name}: {stderr.strip()}") + return {"success": True} diff --git a/backend/routers/updates.py b/backend/routers/updates.py index 9ad2cee..27bc391 100644 --- a/backend/routers/updates.py +++ b/backend/routers/updates.py @@ -1,6 +1,7 @@ """Self-update — check Gitea repo for new commits and apply updates.""" import asyncio +import logging import os import shutil @@ -10,6 +11,7 @@ from backend.auth import get_current_user from backend.config import BASE_DIR router = APIRouter(prefix="/api/updates", tags=["updates"]) +log = logging.getLogger("atlus.updates") # --------------------------------------------------------------------------- # Guard @@ -18,18 +20,19 @@ router = APIRouter(prefix="/api/updates", tags=["updates"]) _IS_GIT = (BASE_DIR / ".git").is_dir() -def _find_git() -> bool: - """Check if git is available, even with systemd's minimal PATH.""" - if shutil.which("git"): - return True - # systemd services often have a stripped PATH — check common locations +def _find_git() -> str | None: + """Find the git binary, even with systemd's minimal PATH.""" + found = shutil.which("git") + if found: + return found for p in ("/usr/bin/git", "/usr/local/bin/git", "/bin/git"): if os.path.isfile(p) and os.access(p, os.X_OK): - return True - return False + return p + return None -_HAS_GIT = _find_git() +_GIT_BIN = _find_git() +_HAS_GIT = _GIT_BIN is not None def _require_git(): @@ -51,13 +54,21 @@ def _safe_env(): if p not in path: path = p + ":" + path env["PATH"] = path + # Ensure HOME is set — systemd may strip it, git needs it for SSH keys + if "HOME" not in env: + import pwd + try: + env["HOME"] = pwd.getpwuid(os.getuid()).pw_dir + except KeyError: + env["HOME"] = "/root" return env async def _git(*args: str, timeout: float = 30) -> str: """Run a git command in the Atlus install directory.""" _require_git() - cmd = ["git", "-C", str(BASE_DIR)] + list(args) + cmd = [_GIT_BIN, "-C", str(BASE_DIR)] + list(args) + log.debug("Running: %s", " ".join(cmd)) proc = await asyncio.create_subprocess_exec( *cmd, stdout=asyncio.subprocess.PIPE, @@ -72,6 +83,7 @@ async def _git(*args: str, timeout: float = 30) -> str: raise HTTPException(504, "git operation timed out") if proc.returncode != 0: msg = stderr.decode().strip() or stdout.decode().strip() + log.warning("git %s failed (rc=%d): %s", args[0] if args else "?", proc.returncode, msg) raise HTTPException(500, f"git error: {msg}") return stdout.decode().strip() @@ -79,7 +91,7 @@ async def _git(*args: str, timeout: float = 30) -> str: async def _git_nofail(*args: str, timeout: float = 30) -> tuple[int, str]: """Run a git command, return (returncode, stdout) without raising.""" _require_git() - cmd = ["git", "-C", str(BASE_DIR)] + list(args) + cmd = [_GIT_BIN, "-C", str(BASE_DIR)] + list(args) proc = await asyncio.create_subprocess_exec( *cmd, stdout=asyncio.subprocess.PIPE, @@ -92,6 +104,9 @@ async def _git_nofail(*args: str, timeout: float = 30) -> tuple[int, str]: proc.kill() await proc.wait() return 1, "" + if proc.returncode != 0: + log.debug("git %s returned %d: %s", args[0] if args else "?", + proc.returncode, stderr.decode().strip()) return proc.returncode, stdout.decode().strip() @@ -108,14 +123,16 @@ async def check_for_updates(_user: str = Depends(get_current_user)): # Fetch latest from remote (may take a few seconds) try: await _git("fetch", "origin", timeout=30) - except HTTPException: - # Fetch failed (no network, etc.) — report no update + except HTTPException as e: + # Fetch failed (no network, no auth, etc.) + error_msg = str(e.detail) if hasattr(e, 'detail') else str(e) + log.warning("git fetch failed: %s", error_msg) return { "available": False, "local_hash": local_hash[:8], "remote_hash": local_hash[:8], "behind_count": 0, - "error": "Could not reach remote repository", + "error": f"Could not reach remote: {error_msg}", } # Get remote HEAD diff --git a/frontend/css/apps/services.css b/frontend/css/apps/services.css index 60999fb..3b6eefa 100644 --- a/frontend/css/apps/services.css +++ b/frontend/css/apps/services.css @@ -149,3 +149,18 @@ background: var(--accent-hover); color: var(--accent); } + +.svc-pin-btn { + opacity: 0.35; + font-size: 11px; + transition: opacity var(--transition-fast); +} + +.svc-pin-btn:hover { + opacity: 1; +} + +.svc-pin-btn.pinned { + opacity: 1; + color: var(--accent); +} diff --git a/frontend/css/apps/xdisplay.css b/frontend/css/apps/xdisplay.css new file mode 100644 index 0000000..e8bd4a5 --- /dev/null +++ b/frontend/css/apps/xdisplay.css @@ -0,0 +1,185 @@ +/* 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 87ea6d3..c7ce1e9 100644 --- a/frontend/css/panel.css +++ b/frontend/css/panel.css @@ -282,3 +282,68 @@ 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 c7fadeb..2960229 100644 --- a/frontend/desktop.html +++ b/frontend/desktop.html @@ -23,6 +23,7 @@ + @@ -60,6 +61,10 @@ 📦 Packages +
@@ -59,6 +88,7 @@ ${svc.active} ${svc.sub}
+
`; @@ -84,6 +114,10 @@ loadServices(); }); + // Pin handler + const pinBtn = row.querySelector('.svc-pin-btn'); + pinBtn.addEventListener('click', () => togglePin(svc.unit)); + listEl.appendChild(row); }); } @@ -123,7 +157,7 @@ listEl.className = 'services-list'; container.appendChild(listEl); - loadServices(); + loadPinnedServices().then(() => loadServices()); }, destroy() { @@ -132,6 +166,7 @@ searchInput = null; allServices = []; filterActive = false; + pinnedServices = []; }, }); diff --git a/frontend/js/apps/settings.js b/frontend/js/apps/settings.js index be0d439..f07671e 100644 --- a/frontend/js/apps/settings.js +++ b/frontend/js/apps/settings.js @@ -252,35 +252,220 @@ } async function _renderNetworkReadonly() { - // Fallback: read-only view from psutil stats - const res = await Atlus.apiFetch('/api/stats'); - const data = await res.json(); + // Fallback: use /api/network/interfaces (ip commands, no NetworkManager) + let ifacesRes; + try { + ifacesRes = await Atlus.apiFetch('/api/network/interfaces'); + } catch (e) { + ifacesRes = null; + } + + if (!ifacesRes || !ifacesRes.ok) { + // Final fallback: read-only psutil view + const res = await Atlus.apiFetch('/api/stats'); + const data = await res.json(); + let html = '
Network
'; + const ifaces = data.network.interfaces; + for (const [name, info] of Object.entries(ifaces)) { + html += ` +
+
${name.toUpperCase()}
+
+
Status
+ ${info.up ? 'Up' : 'Down'} +
+
+
IPv4
+ ${info.ipv4 || '--'} +
+
+ `; + } + contentEl.innerHTML = html; + return; + } + + const ifData = await ifacesRes.json(); + const ifaces = ifData.interfaces || []; + const dnsServers = ifData.dns || []; let html = '
Network
'; - html += '
NetworkManager not available — showing read-only status
'; - const ifaces = data.network.interfaces; - for (const [name, info] of Object.entries(ifaces)) { + // Per-interface cards + for (const iface of ifaces) { + const isUp = iface.up; + const dotColor = isUp ? 'var(--status-green)' : 'var(--status-red)'; + const ipDisplay = iface.ipv4 ? iface.ipv4.split('/')[0] : '--'; + const cidr = iface.ipv4 || ''; + const method = iface.config_method || 'unknown'; + const isStatic = method === 'static'; + html += ` -
-
${name.toUpperCase()}
-
-
Status
- ${info.up ? 'Up' : 'Down'} +
+
+
+ ${iface.name.toUpperCase()} + ${iface.mac || ''}
-
IPv4
- ${info.ipv4 || '--'} +
+
Status
+
+
+ ${iface.state} + +
-
IPv6
- ${info.ipv6 || '--'} +
+
IPv4
+
+ ${ipDisplay} +
+ ${iface.gateway ? ` +
+
Gateway
+ ${iface.gateway} +
` : ''} +
+
+
IP Configuration
+
Toggle between DHCP and Static IP
+
+ +
+
+
+
IP Address
CIDR notation (e.g. 192.168.1.100/24)
+ +
+
+
Gateway
+ +
+
+
Primary DNS
+ +
+
+
Secondary DNS
+ +
+
+ +
+
`; } + // DNS section + html += ` +
+
DNS
+
+
Nameservers
+ ${dnsServers.join(', ') || '--'} +
+
+ `; + contentEl.innerHTML = html; + + // ---- Event handlers ---- + + // Interface up/down toggle + contentEl.querySelectorAll('[data-iface-toggle]').forEach(btn => { + btn.addEventListener('click', async () => { + const name = btn.dataset.ifaceToggle; + const isUp = btn.dataset.isUp === 'true'; + const action = isUp ? 'down' : 'up'; + + if (isUp && !confirm(`Bring down ${name}? You may lose connectivity if this is your primary interface.`)) return; + + btn.disabled = true; + btn.textContent = isUp ? 'Disabling…' : 'Enabling…'; + try { + await Atlus.apiFetch(`/api/network/interfaces/${name}/${action}`, { method: 'POST' }); + } catch (e) { /* may lose connection */ } + setTimeout(() => _renderNetworkReadonly(), 2000); + }); + }); + + // DHCP/Static toggle + contentEl.querySelectorAll('[data-iface-dhcp]').forEach(toggle => { + const name = toggle.dataset.ifaceDhcp; + const staticFields = contentEl.querySelector(`#staticFields-${name}`); + toggle.addEventListener('click', () => { + toggle.classList.toggle('on'); + const isNowStatic = toggle.classList.contains('on'); + toggle.querySelector('.toggle-label').textContent = isNowStatic ? 'Static' : 'DHCP'; + staticFields.classList.toggle('hidden', !isNowStatic); + }); + }); + + // Apply config + contentEl.querySelectorAll('[data-iface-apply]').forEach(btn => { + btn.addEventListener('click', async () => { + const name = btn.dataset.ifaceApply; + const errEl = contentEl.querySelector(`#ifError-${name}`); + errEl.classList.add('hidden'); + + const toggle = contentEl.querySelector(`[data-iface-dhcp="${name}"]`); + const isStatic = toggle.classList.contains('on'); + const method = isStatic ? 'static' : 'dhcp'; + const body = { method }; + + if (isStatic) { + body.address = contentEl.querySelector(`#ifAddr-${name}`).value.trim(); + body.gateway = contentEl.querySelector(`#ifGw-${name}`).value.trim(); + const dns1 = contentEl.querySelector(`#ifDns1-${name}`).value.trim(); + const dns2 = contentEl.querySelector(`#ifDns2-${name}`).value.trim(); + body.dns = [dns1, dns2].filter(Boolean); + + if (!body.address || !body.gateway) { + errEl.textContent = 'IP address and gateway are required for static configuration.'; + errEl.classList.remove('hidden'); + return; + } + } + + if (!confirm(`Apply network changes to ${name}? If this is your primary interface, you may lose access temporarily.`)) return; + + btn.disabled = true; + btn.textContent = 'Applying…'; + + try { + const res = await Atlus.apiFetch(`/api/network/interfaces/${name}/config`, { + method: 'PUT', + body: body, + }); + + if (res && res.ok) { + btn.textContent = 'Applied ✓'; + setTimeout(() => _renderNetworkReadonly(), 3000); + } else { + const err = res ? await res.json().catch(() => ({})) : {}; + errEl.textContent = err.detail || 'Failed to apply configuration.'; + errEl.classList.remove('hidden'); + btn.disabled = false; + btn.textContent = 'Apply'; + } + } catch (e) { + // Connection may be lost if changing the interface we're connected through + btn.textContent = 'Applied (reconnecting…)'; + setTimeout(() => { + window.location.reload(); + }, 5000); + } + }); + }); } async function _loadEthernetConfig(dev) { diff --git a/frontend/js/apps/xdisplay.js b/frontend/js/apps/xdisplay.js new file mode 100644 index 0000000..839b4ab --- /dev/null +++ b/frontend/js/apps/xdisplay.js @@ -0,0 +1,301 @@ +/* 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 = 'No apps running'; + return; + } + + apps.forEach(app => { + const item = document.createElement('div'); + item.className = 'xdisplay-app-item'; + item.innerHTML = ` + ${app.name} + + `; + 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 e505f5c..52dbbeb 100644 --- a/frontend/js/atlus.js +++ b/frontend/js/atlus.js @@ -397,17 +397,36 @@ if (!panel) return; try { const res = await Atlus.apiFetch('/api/updates/check'); - if (!res || !res.ok) return; + if (!res || !res.ok) { + // Show error state if endpoint fails (503 = git not found, etc.) + panel.classList.remove('hidden'); + const status = res ? res.status : 0; + const detail = res ? await res.json().catch(() => ({})) : {}; + panel.innerHTML = ` +
+ +
+
Updates unavailable
+
${detail.detail || 'Could not check'}
+
+
+ `; + return; + } const data = await res.json(); panel.classList.remove('hidden'); - if (data.available && data.behind_count > 0) { + if (data.error) { + showUpdateError(panel, data); + } else if (data.available && data.behind_count > 0) { showUpdateAvailable(panel, data); } else { showUpToDate(panel, data); } - } catch (e) { /* ignore */ } + } catch (e) { + console.warn('Update check failed:', e); + } } function showUpToDate(panel, data) { @@ -422,6 +441,18 @@ `; } + function showUpdateError(panel, data) { + panel.innerHTML = ` +
+ +
+
Check failed
+
${data.error}
+
+
+ `; + } + function showUpdateAvailable(panel, data) { panel.innerHTML = `
@@ -463,6 +494,55 @@ .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 // ===================================================================== @@ -550,6 +630,7 @@ loadPanelServices(); connectStats(); checkForUpdates(); + pollDesktopApps(); // Refresh services panel periodically setInterval(loadPanelServices, 30000); @@ -557,6 +638,9 @@ // 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 7cd2eae..4c1a2dc 100755 --- a/install.sh +++ b/install.sh @@ -52,6 +52,22 @@ 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..." @@ -126,6 +142,7 @@ main() { require_root detect_os install_deps + install_x11_deps install_atlus setup_dirs install_service