Fixed polling agent and status bars.
This commit is contained in:
parent
91e3fa13c6
commit
a88e7bbb06
14 changed files with 2131 additions and 35 deletions
574
backend/display.py
Normal file
574
backend/display.py
Normal 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()
|
||||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
170
backend/routers/display.py
Normal file
170
backend/routers/display.py
Normal 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,
|
||||
}
|
||||
|
|
@ -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: <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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
185
frontend/css/apps/xdisplay.css
Normal file
185
frontend/css/apps/xdisplay.css
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
<link rel="stylesheet" href="/css/apps/settings.css">
|
||||
<link rel="stylesheet" href="/css/apps/packages.css">
|
||||
<link rel="stylesheet" href="/css/apps/editor.css">
|
||||
<link rel="stylesheet" href="/css/apps/xdisplay.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- ================================================================= -->
|
||||
|
|
@ -60,6 +61,10 @@
|
|||
<span class="dock-icon">📦</span>
|
||||
<span class="dock-label">Packages</span>
|
||||
</button>
|
||||
<button class="dock-item" data-app="xdisplay">
|
||||
<span class="dock-icon">🖥</span>
|
||||
<span class="dock-label">Display</span>
|
||||
</button>
|
||||
<div class="dock-separator"></div>
|
||||
<button class="dock-item" data-app="asi-bridge">
|
||||
<span class="dock-icon">🔭</span>
|
||||
|
|
@ -140,6 +145,12 @@
|
|||
<!-- Update notification -->
|
||||
<div class="panel-section panel-updates hidden" id="panelUpdates"></div>
|
||||
|
||||
<!-- Desktop Apps (tray) -->
|
||||
<div class="panel-section panel-tray hidden" id="panelTray">
|
||||
<div class="panel-section-title">DESKTOP APPS</div>
|
||||
<div id="panelTrayApps" class="panel-tray-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- Network -->
|
||||
<div class="panel-section panel-network">
|
||||
<div class="panel-section-title">NETWORK</div>
|
||||
|
|
@ -207,6 +218,7 @@
|
|||
<script src="/js/apps/settings.js"></script>
|
||||
<script src="/js/apps/packages.js"></script>
|
||||
<script src="/js/apps/editor.js"></script>
|
||||
<script src="/js/apps/xdisplay.js"></script>
|
||||
<script src="/js/apps/asi_bridge.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,33 @@
|
|||
let searchInput = null;
|
||||
let allServices = [];
|
||||
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() {
|
||||
try {
|
||||
|
|
@ -48,6 +75,8 @@
|
|||
const stateClass = svc.active === 'active' ? 'active' :
|
||||
svc.active === 'failed' ? 'failed' : 'inactive';
|
||||
|
||||
const isPinned = pinnedServices.includes(svc.unit);
|
||||
|
||||
row.innerHTML = `
|
||||
<div class="svc-toggle">
|
||||
<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-sub">${svc.sub}</span>
|
||||
<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>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -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 = [];
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = '<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>';
|
||||
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 += `
|
||||
<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 class="settings-group" id="ifaceGroup-${iface.name}">
|
||||
<div class="settings-group-title" style="display:flex;align-items:center;gap:10px;">
|
||||
<div style="width:8px;height:8px;border-radius:50%;background:${dotColor};flex-shrink:0;"></div>
|
||||
${iface.name.toUpperCase()}
|
||||
<span style="font-weight:400;color:var(--text-muted);font-size:9px;letter-spacing:0.5px;">${iface.mac || ''}</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 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 class="settings-row">
|
||||
<div class="settings-row-label">IPv6</div>
|
||||
<span style="font-family:var(--font-mono);font-size:13px;">${info.ipv6 || '--'}</span>
|
||||
<div>
|
||||
<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>
|
||||
`;
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
// ---- 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) {
|
||||
|
|
|
|||
301
frontend/js/apps/xdisplay.js
Normal file
301
frontend/js/apps/xdisplay.js
Normal 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">×</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 */ }
|
||||
}
|
||||
})();
|
||||
|
|
@ -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 = `
|
||||
<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();
|
||||
|
||||
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 = `
|
||||
<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) {
|
||||
panel.innerHTML = `
|
||||
<div class="update-status update-available">
|
||||
|
|
@ -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 = `
|
||||
<span class="panel-tray-dot ${app.alive ? 'running' : 'stopped'}"></span>
|
||||
<span class="panel-tray-name">${app.name}</span>
|
||||
<button class="panel-tray-open" title="Open Display">↗</button>
|
||||
`;
|
||||
// Click app name or open button → switch to display app
|
||||
row.querySelector('.panel-tray-open').addEventListener('click', () => {
|
||||
openApp('xdisplay');
|
||||
});
|
||||
row.querySelector('.panel-tray-name').addEventListener('click', () => {
|
||||
openApp('xdisplay');
|
||||
});
|
||||
trayList.appendChild(row);
|
||||
});
|
||||
} catch (e) {
|
||||
// Display API not available (no X11 deps) — hide tray
|
||||
trayPanel.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Session persistence — save/restore desktop state
|
||||
// =====================================================================
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
17
install.sh
17
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue