Fixed polling agent and status bars.

This commit is contained in:
roberts 2026-03-14 22:41:00 -05:00
parent 91e3fa13c6
commit a88e7bbb06
14 changed files with 2131 additions and 35 deletions

574
backend/display.py Normal file
View file

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

View file

@ -11,8 +11,9 @@ from pydantic import BaseModel
from backend.auth import authenticate_user, create_token, logout from backend.auth import authenticate_user, create_token, logout
from backend.config import FRONTEND_DIR, HOST, PORT from backend.config import FRONTEND_DIR, HOST, PORT
from backend.routers import stats, terminal, files, services, processes, settings, network, packages, updates, session 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.sessions import manager as session_manager
from backend.display import display_manager
from backend.routers.plugins import asi_bridge from backend.routers.plugins import asi_bridge
logging.basicConfig( logging.basicConfig(
@ -55,6 +56,8 @@ async def lifespan(app: FastAPI):
# Kill all PTYs on shutdown # Kill all PTYs on shutdown
session_manager.shutdown_all() session_manager.shutdown_all()
# Stop all X11 display sessions
await display_manager.shutdown_all()
log.info("Atlus shutdown complete") log.info("Atlus shutdown complete")
@ -109,6 +112,7 @@ app.include_router(network.router)
app.include_router(packages.router) app.include_router(packages.router)
app.include_router(updates.router) app.include_router(updates.router)
app.include_router(session.router) app.include_router(session.router)
app.include_router(display.router)
app.include_router(asi_bridge.router) app.include_router(asi_bridge.router)

170
backend/routers/display.py Normal file
View file

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

View file

@ -1,4 +1,4 @@
"""Network configuration via NetworkManager (nmcli).""" """Network configuration via NetworkManager (nmcli) or direct ip/interfaces."""
import asyncio import asyncio
import ipaddress import ipaddress
@ -6,6 +6,7 @@ import logging
import os import os
import re import re
import shutil import shutil
from pathlib import Path
from typing import Literal, Optional from typing import Literal, Optional
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
@ -16,7 +17,26 @@ from backend.auth import get_current_user
router = APIRouter(prefix="/api/network", tags=["network"]) router = APIRouter(prefix="/api/network", tags=["network"])
log = logging.getLogger("atlus.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.""" """Reapply/reconnect a connection."""
await _nmcli("connection", "up", name, timeout=15) await _nmcli("connection", "up", name, timeout=15)
return {"success": True} 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: <BROADCAST,MULTICAST,UP,LOWER_UP> ..."
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}

View file

@ -1,6 +1,7 @@
"""Self-update — check Gitea repo for new commits and apply updates.""" """Self-update — check Gitea repo for new commits and apply updates."""
import asyncio import asyncio
import logging
import os import os
import shutil import shutil
@ -10,6 +11,7 @@ from backend.auth import get_current_user
from backend.config import BASE_DIR from backend.config import BASE_DIR
router = APIRouter(prefix="/api/updates", tags=["updates"]) router = APIRouter(prefix="/api/updates", tags=["updates"])
log = logging.getLogger("atlus.updates")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Guard # Guard
@ -18,18 +20,19 @@ router = APIRouter(prefix="/api/updates", tags=["updates"])
_IS_GIT = (BASE_DIR / ".git").is_dir() _IS_GIT = (BASE_DIR / ".git").is_dir()
def _find_git() -> bool: def _find_git() -> str | None:
"""Check if git is available, even with systemd's minimal PATH.""" """Find the git binary, even with systemd's minimal PATH."""
if shutil.which("git"): found = shutil.which("git")
return True if found:
# systemd services often have a stripped PATH — check common locations return found
for p in ("/usr/bin/git", "/usr/local/bin/git", "/bin/git"): for p in ("/usr/bin/git", "/usr/local/bin/git", "/bin/git"):
if os.path.isfile(p) and os.access(p, os.X_OK): if os.path.isfile(p) and os.access(p, os.X_OK):
return True return p
return False return None
_HAS_GIT = _find_git() _GIT_BIN = _find_git()
_HAS_GIT = _GIT_BIN is not None
def _require_git(): def _require_git():
@ -51,13 +54,21 @@ def _safe_env():
if p not in path: if p not in path:
path = p + ":" + path path = p + ":" + path
env["PATH"] = 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 return env
async def _git(*args: str, timeout: float = 30) -> str: async def _git(*args: str, timeout: float = 30) -> str:
"""Run a git command in the Atlus install directory.""" """Run a git command in the Atlus install directory."""
_require_git() _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( proc = await asyncio.create_subprocess_exec(
*cmd, *cmd,
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
@ -72,6 +83,7 @@ async def _git(*args: str, timeout: float = 30) -> str:
raise HTTPException(504, "git operation timed out") raise HTTPException(504, "git operation timed out")
if proc.returncode != 0: if proc.returncode != 0:
msg = stderr.decode().strip() or stdout.decode().strip() 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}") raise HTTPException(500, f"git error: {msg}")
return stdout.decode().strip() 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]: async def _git_nofail(*args: str, timeout: float = 30) -> tuple[int, str]:
"""Run a git command, return (returncode, stdout) without raising.""" """Run a git command, return (returncode, stdout) without raising."""
_require_git() _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( proc = await asyncio.create_subprocess_exec(
*cmd, *cmd,
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
@ -92,6 +104,9 @@ async def _git_nofail(*args: str, timeout: float = 30) -> tuple[int, str]:
proc.kill() proc.kill()
await proc.wait() await proc.wait()
return 1, "" 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() 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) # Fetch latest from remote (may take a few seconds)
try: try:
await _git("fetch", "origin", timeout=30) await _git("fetch", "origin", timeout=30)
except HTTPException: except HTTPException as e:
# Fetch failed (no network, etc.) — report no update # 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 { return {
"available": False, "available": False,
"local_hash": local_hash[:8], "local_hash": local_hash[:8],
"remote_hash": local_hash[:8], "remote_hash": local_hash[:8],
"behind_count": 0, "behind_count": 0,
"error": "Could not reach remote repository", "error": f"Could not reach remote: {error_msg}",
} }
# Get remote HEAD # Get remote HEAD

View file

@ -149,3 +149,18 @@
background: var(--accent-hover); background: var(--accent-hover);
color: var(--accent); 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);
}

View file

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

View file

@ -282,3 +282,68 @@
opacity: 0.6; opacity: 0.6;
cursor: not-allowed; cursor: not-allowed;
} }
/* Desktop Apps (tray) */
.panel-tray-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.panel-tray-item {
display: flex;
align-items: center;
gap: 8px;
min-height: 36px;
padding: 4px 0;
}
.panel-tray-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.panel-tray-dot.running {
background: var(--status-green);
}
.panel-tray-dot.stopped {
background: var(--status-red);
}
.panel-tray-name {
flex: 1;
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-primary);
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.panel-tray-name:hover {
color: var(--accent);
}
.panel-tray-open {
width: 28px;
height: 28px;
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
flex-shrink: 0;
}
.panel-tray-open:hover {
background: var(--accent-hover);
color: var(--accent);
}

View file

@ -23,6 +23,7 @@
<link rel="stylesheet" href="/css/apps/settings.css"> <link rel="stylesheet" href="/css/apps/settings.css">
<link rel="stylesheet" href="/css/apps/packages.css"> <link rel="stylesheet" href="/css/apps/packages.css">
<link rel="stylesheet" href="/css/apps/editor.css"> <link rel="stylesheet" href="/css/apps/editor.css">
<link rel="stylesheet" href="/css/apps/xdisplay.css">
</head> </head>
<body> <body>
<!-- ================================================================= --> <!-- ================================================================= -->
@ -60,6 +61,10 @@
<span class="dock-icon">📦</span> <span class="dock-icon">📦</span>
<span class="dock-label">Packages</span> <span class="dock-label">Packages</span>
</button> </button>
<button class="dock-item" data-app="xdisplay">
<span class="dock-icon">🖥</span>
<span class="dock-label">Display</span>
</button>
<div class="dock-separator"></div> <div class="dock-separator"></div>
<button class="dock-item" data-app="asi-bridge"> <button class="dock-item" data-app="asi-bridge">
<span class="dock-icon">🔭</span> <span class="dock-icon">🔭</span>
@ -140,6 +145,12 @@
<!-- Update notification --> <!-- Update notification -->
<div class="panel-section panel-updates hidden" id="panelUpdates"></div> <div class="panel-section panel-updates hidden" id="panelUpdates"></div>
<!-- Desktop Apps (tray) -->
<div class="panel-section panel-tray hidden" id="panelTray">
<div class="panel-section-title">DESKTOP APPS</div>
<div id="panelTrayApps" class="panel-tray-list"></div>
</div>
<!-- Network --> <!-- Network -->
<div class="panel-section panel-network"> <div class="panel-section panel-network">
<div class="panel-section-title">NETWORK</div> <div class="panel-section-title">NETWORK</div>
@ -207,6 +218,7 @@
<script src="/js/apps/settings.js"></script> <script src="/js/apps/settings.js"></script>
<script src="/js/apps/packages.js"></script> <script src="/js/apps/packages.js"></script>
<script src="/js/apps/editor.js"></script> <script src="/js/apps/editor.js"></script>
<script src="/js/apps/xdisplay.js"></script>
<script src="/js/apps/asi_bridge.js"></script> <script src="/js/apps/asi_bridge.js"></script>
</body> </body>
</html> </html>

View file

@ -7,6 +7,33 @@
let searchInput = null; let searchInput = null;
let allServices = []; let allServices = [];
let filterActive = false; let filterActive = false;
let pinnedServices = []; // unit names pinned to right panel
async function loadPinnedServices() {
try {
const res = await Atlus.apiFetch('/api/settings');
if (!res || !res.ok) return;
const cfg = await res.json();
pinnedServices = cfg.panel_services || [];
} catch (e) { /* ignore */ }
}
async function togglePin(unit) {
const idx = pinnedServices.indexOf(unit);
if (idx >= 0) {
pinnedServices.splice(idx, 1);
} else {
pinnedServices.push(unit);
}
// Save to settings
try {
await Atlus.apiFetch('/api/settings', {
method: 'PUT',
body: { panel_services: pinnedServices },
});
} catch (e) { /* ignore */ }
renderServices();
}
async function loadServices() { async function loadServices() {
try { try {
@ -48,6 +75,8 @@
const stateClass = svc.active === 'active' ? 'active' : const stateClass = svc.active === 'active' ? 'active' :
svc.active === 'failed' ? 'failed' : 'inactive'; svc.active === 'failed' ? 'failed' : 'inactive';
const isPinned = pinnedServices.includes(svc.unit);
row.innerHTML = ` row.innerHTML = `
<div class="svc-toggle"> <div class="svc-toggle">
<button class="service-toggle ${isActive ? 'on' : ''}" data-unit="${svc.unit}"></button> <button class="service-toggle ${isActive ? 'on' : ''}" data-unit="${svc.unit}"></button>
@ -59,6 +88,7 @@
<span class="svc-state ${stateClass}">${svc.active}</span> <span class="svc-state ${stateClass}">${svc.active}</span>
<span class="svc-sub">${svc.sub}</span> <span class="svc-sub">${svc.sub}</span>
<div class="svc-actions"> <div class="svc-actions">
<button class="svc-action-btn svc-pin-btn ${isPinned ? 'pinned' : ''}" data-unit="${svc.unit}" title="${isPinned ? 'Unpin from panel' : 'Pin to panel'}">📌</button>
<button class="svc-action-btn" data-unit="${svc.unit}" data-action="restart" title="Restart"></button> <button class="svc-action-btn" data-unit="${svc.unit}" data-action="restart" title="Restart"></button>
</div> </div>
`; `;
@ -84,6 +114,10 @@
loadServices(); loadServices();
}); });
// Pin handler
const pinBtn = row.querySelector('.svc-pin-btn');
pinBtn.addEventListener('click', () => togglePin(svc.unit));
listEl.appendChild(row); listEl.appendChild(row);
}); });
} }
@ -123,7 +157,7 @@
listEl.className = 'services-list'; listEl.className = 'services-list';
container.appendChild(listEl); container.appendChild(listEl);
loadServices(); loadPinnedServices().then(() => loadServices());
}, },
destroy() { destroy() {
@ -132,6 +166,7 @@
searchInput = null; searchInput = null;
allServices = []; allServices = [];
filterActive = false; filterActive = false;
pinnedServices = [];
}, },
}); });

View file

@ -252,35 +252,220 @@
} }
async function _renderNetworkReadonly() { async function _renderNetworkReadonly() {
// Fallback: read-only view from psutil stats // Fallback: use /api/network/interfaces (ip commands, no NetworkManager)
const res = await Atlus.apiFetch('/api/stats'); let ifacesRes;
const data = await res.json(); 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 = '<div class="settings-section-title">Network</div>';
const ifaces = data.network.interfaces;
for (const [name, info] of Object.entries(ifaces)) {
html += `
<div class="settings-group">
<div class="settings-group-title">${name.toUpperCase()}</div>
<div class="settings-row">
<div class="settings-row-label">Status</div>
<span style="color:${info.up ? 'var(--status-green)' : 'var(--status-red)'};">${info.up ? 'Up' : 'Down'}</span>
</div>
<div class="settings-row">
<div class="settings-row-label">IPv4</div>
<span style="font-family:var(--font-mono);font-size:13px;">${info.ipv4 || '--'}</span>
</div>
</div>
`;
}
contentEl.innerHTML = html;
return;
}
const ifData = await ifacesRes.json();
const ifaces = ifData.interfaces || [];
const dnsServers = ifData.dns || [];
let html = '<div class="settings-section-title">Network</div>'; let html = '<div class="settings-section-title">Network</div>';
html += '<div class="net-error" style="margin-bottom:16px;">NetworkManager not available — showing read-only status</div>';
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 += ` html += `
<div class="settings-group"> <div class="settings-group" id="ifaceGroup-${iface.name}">
<div class="settings-group-title">${name.toUpperCase()}</div> <div class="settings-group-title" style="display:flex;align-items:center;gap:10px;">
<div class="settings-row"> <div style="width:8px;height:8px;border-radius:50%;background:${dotColor};flex-shrink:0;"></div>
<div class="settings-row-label">Status</div> ${iface.name.toUpperCase()}
<span style="color:${info.up ? 'var(--status-green)' : 'var(--status-red)'};">${info.up ? 'Up' : 'Down'}</span> <span style="font-weight:400;color:var(--text-muted);font-size:9px;letter-spacing:0.5px;">${iface.mac || ''}</span>
</div> </div>
<div class="settings-row"> <div class="settings-row">
<div class="settings-row-label">IPv4</div> <div>
<span style="font-family:var(--font-mono);font-size:13px;">${info.ipv4 || '--'}</span> <div class="settings-row-label">Status</div>
</div>
<div style="display:flex;align-items:center;gap:8px;">
<span style="color:${isUp ? 'var(--status-green)' : 'var(--status-red)'};">${iface.state}</span>
<button class="settings-btn secondary" style="height:24px;font-size:11px;padding:0 8px;"
data-iface-toggle="${iface.name}" data-is-up="${isUp}">
${isUp ? 'Disable' : 'Enable'}
</button>
</div>
</div> </div>
<div class="settings-row"> <div class="settings-row">
<div class="settings-row-label">IPv6</div> <div>
<span style="font-family:var(--font-mono);font-size:13px;">${info.ipv6 || '--'}</span> <div class="settings-row-label">IPv4</div>
</div>
<span style="font-family:var(--font-mono);font-size:13px;">${ipDisplay}</span>
</div>
${iface.gateway ? `
<div class="settings-row">
<div><div class="settings-row-label">Gateway</div></div>
<span style="font-family:var(--font-mono);font-size:13px;">${iface.gateway}</span>
</div>` : ''}
<div class="settings-row">
<div>
<div class="settings-row-label">IP Configuration</div>
<div class="settings-row-desc">Toggle between DHCP and Static IP</div>
</div>
<button class="settings-toggle ${isStatic ? 'on' : ''}" data-iface-dhcp="${iface.name}">
<span class="toggle-label">${isStatic ? 'Static' : 'DHCP'}</span>
</button>
</div>
<div class="net-static-fields ${isStatic ? '' : 'hidden'}" id="staticFields-${iface.name}">
<div class="settings-row">
<div><div class="settings-row-label">IP Address</div><div class="settings-row-desc">CIDR notation (e.g. 192.168.1.100/24)</div></div>
<input class="settings-input" id="ifAddr-${iface.name}" value="${cidr}" placeholder="192.168.1.100/24">
</div>
<div class="settings-row">
<div><div class="settings-row-label">Gateway</div></div>
<input class="settings-input" id="ifGw-${iface.name}" value="${iface.gateway || iface.config_gateway || ''}" placeholder="192.168.1.1">
</div>
<div class="settings-row">
<div><div class="settings-row-label">Primary DNS</div></div>
<input class="settings-input" id="ifDns1-${iface.name}" value="${dnsServers[0] || ''}" placeholder="8.8.8.8">
</div>
<div class="settings-row">
<div><div class="settings-row-label">Secondary DNS</div></div>
<input class="settings-input" id="ifDns2-${iface.name}" value="${dnsServers[1] || ''}" placeholder="8.8.4.4">
</div>
</div>
<div id="ifError-${iface.name}" class="net-error hidden"></div>
<div class="settings-actions">
<button class="settings-btn" data-iface-apply="${iface.name}">Apply</button>
</div> </div>
</div> </div>
`; `;
} }
// DNS section
html += `
<div class="settings-group">
<div class="settings-group-title">DNS</div>
<div class="settings-row">
<div class="settings-row-label">Nameservers</div>
<span style="font-family:var(--font-mono);font-size:13px;">${dnsServers.join(', ') || '--'}</span>
</div>
</div>
`;
contentEl.innerHTML = html; 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) { async function _loadEthernetConfig(dev) {

View file

@ -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 = '<span class="xdisplay-no-apps">No apps running</span>';
return;
}
apps.forEach(app => {
const item = document.createElement('div');
item.className = 'xdisplay-app-item';
item.innerHTML = `
<span class="xdisplay-app-name">${app.name}</span>
<button class="xdisplay-app-stop" data-id="${app.app_id}" title="Stop">&times;</button>
`;
item.querySelector('.xdisplay-app-stop').addEventListener('click', (e) => {
e.stopPropagation();
stopApp(app.app_id);
});
appListEl.appendChild(item);
});
}
// ---- Status ----
function setStatus(message, type) {
if (!statusEl) return;
statusEl.textContent = message;
statusEl.className = 'xdisplay-status';
if (type) statusEl.classList.add(type);
statusEl.classList.remove('hidden');
}
// ---- App registration ----
Atlus.registerApp('xdisplay', {
title: 'Display',
init(el) {
container = el;
container.classList.add('app-xdisplay');
// Toolbar
toolbarEl = document.createElement('div');
toolbarEl.className = 'xdisplay-toolbar';
// Launch dropdown
const launchGroup = document.createElement('div');
launchGroup.className = 'xdisplay-launch-group';
const launchBtn = document.createElement('button');
launchBtn.className = 'xdisplay-btn xdisplay-launch-btn';
launchBtn.textContent = '+ Launch App';
const launchMenu = document.createElement('div');
launchMenu.className = 'xdisplay-launch-menu hidden';
KNOWN_APPS.forEach(app => {
const item = document.createElement('button');
item.className = 'xdisplay-launch-menu-item';
item.textContent = app.name;
item.addEventListener('click', () => {
launchMenu.classList.add('hidden');
launchApp(app.command, [], app.name, app.has_tray_icon);
});
launchMenu.appendChild(item);
});
// Custom app option
const customItem = document.createElement('button');
customItem.className = 'xdisplay-launch-menu-item';
customItem.textContent = 'Custom…';
customItem.addEventListener('click', () => {
launchMenu.classList.add('hidden');
const cmd = prompt('Enter command to launch:');
if (cmd && cmd.trim()) {
const parts = cmd.trim().split(/\s+/);
launchApp(parts[0], parts.slice(1), parts[0], false);
}
});
launchMenu.appendChild(customItem);
launchBtn.addEventListener('click', () => {
launchMenu.classList.toggle('hidden');
});
// Close menu on outside click
document.addEventListener('click', (e) => {
if (!launchGroup.contains(e.target)) {
launchMenu.classList.add('hidden');
}
});
launchGroup.appendChild(launchBtn);
launchGroup.appendChild(launchMenu);
toolbarEl.appendChild(launchGroup);
// Running apps list
appListEl = document.createElement('div');
appListEl.className = 'xdisplay-app-list';
toolbarEl.appendChild(appListEl);
// Connection button (right side)
const connectBtn = document.createElement('button');
connectBtn.className = 'xdisplay-btn xdisplay-connect-btn';
connectBtn.textContent = 'Connect';
connectBtn.addEventListener('click', () => connectViewer());
toolbarEl.appendChild(connectBtn);
container.appendChild(toolbarEl);
// Status
statusEl = document.createElement('div');
statusEl.className = 'xdisplay-status';
statusEl.textContent = 'Click Connect to start the virtual display';
// Viewer area
const viewerArea = document.createElement('div');
viewerArea.className = 'xdisplay-viewer';
viewerArea.appendChild(statusEl);
container.appendChild(viewerArea);
// Check if display is already running
checkExistingDisplay();
// Poll running apps every 5 seconds
refreshTimer = setInterval(refreshAppList, 5000);
},
destroy() {
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
if (iframeEl) {
iframeEl.remove();
iframeEl = null;
}
container = null;
toolbarEl = null;
statusEl = null;
appListEl = null;
},
onFocus() {
refreshAppList();
},
/** Public API: launch an app from outside (e.g., panel tray click) */
launchApp(command, args, name, hasTrayIcon) {
return launchApp(command, args, name, hasTrayIcon);
},
/** Public API: connect/show the viewer */
showViewer() {
if (!displayStarted) {
connectViewer();
}
},
});
async function checkExistingDisplay() {
try {
const res = await Atlus.apiFetch('/api/display/status');
if (!res || !res.ok) return;
const data = await res.json();
if (data.missing && data.missing.length > 0) {
setStatus(
`Missing packages: ${data.missing.join(', ')}. Install with: sudo apt install ${data.missing.map(n => n.toLowerCase()).join(' ')}`,
'error'
);
return;
}
if (data.session && data.session.started) {
wsPort = data.session.ws_port;
displayStarted = true;
connectViewer();
refreshAppList();
}
} catch (e) { /* ignore */ }
}
})();

View file

@ -397,17 +397,36 @@
if (!panel) return; if (!panel) return;
try { try {
const res = await Atlus.apiFetch('/api/updates/check'); 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 = `
<div class="update-status">
<span class="update-status-dot" style="background:var(--text-muted);"></span>
<div class="update-status-text">
<div class="update-status-title">Updates unavailable</div>
<div class="update-status-hash">${detail.detail || 'Could not check'}</div>
</div>
</div>
`;
return;
}
const data = await res.json(); const data = await res.json();
panel.classList.remove('hidden'); 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); showUpdateAvailable(panel, data);
} else { } else {
showUpToDate(panel, data); showUpToDate(panel, data);
} }
} catch (e) { /* ignore */ } } catch (e) {
console.warn('Update check failed:', e);
}
} }
function showUpToDate(panel, data) { function showUpToDate(panel, data) {
@ -422,6 +441,18 @@
`; `;
} }
function showUpdateError(panel, data) {
panel.innerHTML = `
<div class="update-status">
<span class="update-status-dot" style="background:var(--status-red);"></span>
<div class="update-status-text">
<div class="update-status-title">Check failed</div>
<div class="update-status-hash">${data.error}</div>
</div>
</div>
`;
}
function showUpdateAvailable(panel, data) { function showUpdateAvailable(panel, data) {
panel.innerHTML = ` panel.innerHTML = `
<div class="update-status update-available"> <div class="update-status update-available">
@ -463,6 +494,55 @@
.catch(() => setTimeout(() => attemptReload(attempt + 1), 3000)); .catch(() => setTimeout(() => attemptReload(attempt + 1), 3000));
} }
// =====================================================================
// Panel — Desktop Apps (X11 tray)
// =====================================================================
async function pollDesktopApps() {
const trayPanel = $('#panelTray');
const trayList = $('#panelTrayApps');
if (!trayPanel || !trayList) return;
try {
const res = await Atlus.apiFetch('/api/display/apps');
if (!res || !res.ok) {
trayPanel.classList.add('hidden');
return;
}
const data = await res.json();
const apps = data.apps || [];
if (apps.length === 0) {
trayPanel.classList.add('hidden');
return;
}
trayPanel.classList.remove('hidden');
trayList.innerHTML = '';
apps.forEach(app => {
const row = document.createElement('div');
row.className = 'panel-tray-item';
row.innerHTML = `
<span class="panel-tray-dot ${app.alive ? 'running' : 'stopped'}"></span>
<span class="panel-tray-name">${app.name}</span>
<button class="panel-tray-open" title="Open Display">&nearr;</button>
`;
// Click app name or open button → switch to display app
row.querySelector('.panel-tray-open').addEventListener('click', () => {
openApp('xdisplay');
});
row.querySelector('.panel-tray-name').addEventListener('click', () => {
openApp('xdisplay');
});
trayList.appendChild(row);
});
} catch (e) {
// Display API not available (no X11 deps) — hide tray
trayPanel.classList.add('hidden');
}
}
// ===================================================================== // =====================================================================
// Session persistence — save/restore desktop state // Session persistence — save/restore desktop state
// ===================================================================== // =====================================================================
@ -550,6 +630,7 @@
loadPanelServices(); loadPanelServices();
connectStats(); connectStats();
checkForUpdates(); checkForUpdates();
pollDesktopApps();
// Refresh services panel periodically // Refresh services panel periodically
setInterval(loadPanelServices, 30000); setInterval(loadPanelServices, 30000);
@ -557,6 +638,9 @@
// Check for updates every 60 seconds // Check for updates every 60 seconds
setInterval(checkForUpdates, 60 * 1000); setInterval(checkForUpdates, 60 * 1000);
// Poll desktop apps every 5 seconds
setInterval(pollDesktopApps, 5000);
// Expose for app modules // Expose for app modules
window.Atlus.openApp = openApp; window.Atlus.openApp = openApp;
window.Atlus.closeApp = closeApp; window.Atlus.closeApp = closeApp;

View file

@ -52,6 +52,22 @@ install_deps() {
ok "System dependencies installed." ok "System dependencies installed."
} }
install_x11_deps() {
info "Installing X11 display dependencies (for GUI app support)..."
apt-get install -y -qq \
xvfb \
openbox \
x11vnc \
novnc \
websockify \
stalonetray \
wmctrl \
xdotool \
x11-utils \
> /dev/null 2>&1
ok "X11 display dependencies installed."
}
install_atlus() { install_atlus() {
info "Installing Atlus to $INSTALL_DIR..." info "Installing Atlus to $INSTALL_DIR..."
@ -126,6 +142,7 @@ main() {
require_root require_root
detect_os detect_os
install_deps install_deps
install_x11_deps
install_atlus install_atlus
setup_dirs setup_dirs
install_service install_service