atlus/backend/routers/display.py
2026-03-14 22:41:00 -05:00

170 lines
5.4 KiB
Python

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