atlus/backend/routers/display.py
roberts 8d48e3eeb8 App exit diagnostics, desktop file discovery, WiFi wpa_supplicant fix
- Capture stderr from GUI apps and show meaningful exit reasons (signal names,
  error messages) instead of generic "Application exited"
- Add /api/display/discover-apps endpoint that scans .desktop files for
  installed GUI apps, replacing manual-only app configuration
- Settings > Applications now shows discoverable apps with one-click Add,
  with manual form available as fallback
- Fix WiFi: start wpa_supplicant before bringing interface up, handle rfkill
  blocks, add retry logic and error messages to scan results
- Fix WiFi scan frontend bug: response is {networks:[...]} not a bare array
- Add iw and wpasupplicant to install.sh dependencies

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 00:03:13 -05:00

290 lines
9.1 KiB
Python

"""Display — WebSocket frame streaming + REST app management for GUI apps.
Pattern mirrors terminal.py: WebSocket attaches to a running GUI app,
receives JPEG frames, sends input events. REST endpoints manage app lifecycle.
"""
import logging
import os
import re
import shutil
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
from pydantic import BaseModel
from backend.auth import get_current_user, ws_authenticate
from backend.display import display_manager, HAS_DISPLAY_DEPS
router = APIRouter(prefix="/api/display", tags=["display"])
log = logging.getLogger("atlus.display.router")
# ---------------------------------------------------------------------------
# Guards
# ---------------------------------------------------------------------------
def _require_deps():
if not HAS_DISPLAY_DEPS:
raise HTTPException(503, "Display dependencies not installed (Xvfb, xdotool, ImageMagick)")
# ---------------------------------------------------------------------------
# REST — app lifecycle
# ---------------------------------------------------------------------------
class AppLaunchRequest(BaseModel):
command: str
title: str = ""
args: list[str] = []
target_fps: int = 10
@router.get("/apps")
async def list_apps(user: str = Depends(get_current_user)):
"""List running GUI apps for the current user."""
_require_deps()
return {"apps": display_manager.list_apps(user)}
@router.post("/apps")
async def launch_app(req: AppLaunchRequest, user: str = Depends(get_current_user)):
"""Launch a GUI application on the user's virtual display."""
_require_deps()
# Check if this command is already running
existing = display_manager.get_app_by_command(user, req.command)
if existing:
return existing.to_dict()
try:
app = await display_manager.launch_app(
user, req.command, req.title, req.args, req.target_fps,
)
return app.to_dict()
except ValueError as e:
raise HTTPException(400, str(e))
except FileNotFoundError as e:
raise HTTPException(404, str(e))
except RuntimeError as e:
raise HTTPException(500, str(e))
@router.delete("/apps/{app_id}")
async def close_app(app_id: str, user: str = Depends(get_current_user)):
"""Stop a running GUI application."""
_require_deps()
if not display_manager.close_app(user, app_id):
raise HTTPException(404, "App not found")
return {"ok": True}
@router.get("/status")
async def display_status(user: str = Depends(get_current_user)):
"""Check display system availability."""
return {
"available": HAS_DISPLAY_DEPS,
"apps": display_manager.list_apps(user),
}
# ---------------------------------------------------------------------------
# Desktop file discovery — find installed GUI apps
# ---------------------------------------------------------------------------
_DESKTOP_DIRS = [
Path("/usr/share/applications"),
Path("/usr/local/share/applications"),
Path(os.path.expanduser("~/.local/share/applications")),
Path("/var/lib/flatpak/exports/share/applications"),
Path(os.path.expanduser("~/.local/share/flatpak/exports/share/applications")),
Path("/snap/applications"),
]
# Categories that indicate a docked/GUI application
_GUI_CATEGORIES = {
"network", "webbrowser", "email", "office", "graphics", "audio", "video",
"chat", "instantmessaging", "filetransfer", "p2p", "remoteaccess",
"viewer", "player", "game", "utility", "system", "settings",
"monitor", "filesystem", "security", "accessibility",
}
# Commands to exclude (system utilities, terminals, etc.)
_EXCLUDED_CMDS = {
"bash", "sh", "zsh", "fish", "xterm", "xfce4-terminal", "gnome-terminal",
"lxterminal", "urxvt", "alacritty", "kitty", "foot",
"true", "false", "update-manager", "software-properties-gtk",
}
def _parse_desktop_file(path: Path) -> Optional[dict]:
"""Parse a .desktop file and return app info, or None if not a GUI app."""
try:
content = path.read_text(errors="replace")
except Exception:
return None
entry = {}
in_desktop_entry = False
for line in content.splitlines():
line = line.strip()
if line == "[Desktop Entry]":
in_desktop_entry = True
continue
if line.startswith("[") and line.endswith("]"):
if in_desktop_entry:
break # End of [Desktop Entry] section
continue
if not in_desktop_entry or "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
value = value.strip()
if key in ("Name", "Exec", "Icon", "Type", "NoDisplay", "Hidden",
"Categories", "Terminal", "Comment"):
entry[key] = value
# Must be Type=Application
if entry.get("Type") != "Application":
return None
# Skip hidden/no-display
if entry.get("NoDisplay", "").lower() == "true":
return None
if entry.get("Hidden", "").lower() == "true":
return None
# Skip terminal apps
if entry.get("Terminal", "").lower() == "true":
return None
exec_line = entry.get("Exec", "")
if not exec_line:
return None
# Extract command: remove field codes (%u, %U, %f, %F, etc.)
exec_clean = re.sub(r"%[a-zA-Z]", "", exec_line).strip()
# Get first token as command, strip path
parts = exec_clean.split()
if not parts:
return None
command = os.path.basename(parts[0])
if command in _EXCLUDED_CMDS or not command:
return None
# Check the command actually exists
if not shutil.which(command):
return None
# Parse categories
categories = set()
if entry.get("Categories"):
categories = {c.strip().lower() for c in entry["Categories"].split(";")}
name = entry.get("Name", command)
icon = entry.get("Icon", "🖥")
comment = entry.get("Comment", "")
return {
"name": name,
"command": command,
"icon": icon,
"comment": comment,
"categories": sorted(categories - {""}),
"desktop_file": path.name,
}
@router.get("/discover-apps")
async def discover_apps(_user: str = Depends(get_current_user)):
"""Scan .desktop files for installed GUI applications."""
apps = {}
for d in _DESKTOP_DIRS:
if not d.is_dir():
continue
for f in d.glob("*.desktop"):
app = _parse_desktop_file(f)
if app and app["command"] not in apps:
apps[app["command"]] = app
# Sort alphabetically
result = sorted(apps.values(), key=lambda a: a["name"].lower())
return result
# ---------------------------------------------------------------------------
# WebSocket — frame streaming + input
# ---------------------------------------------------------------------------
@router.websocket("/ws")
async def display_ws(
websocket: WebSocket,
app_id: str = Query(default=None),
):
"""Attach to a GUI app's window — receive JPEG frames, send input.
Query params:
- app_id: required — the GUI app to stream
- token: auth token (handled by ws_authenticate)
Server sends:
- Binary: raw JPEG frame bytes
- {"type": "meta", "app_id": "...", "title": "...", "command": "..."}
- {"type": "closed", "data": "Application exited"}
- {"type": "error", "data": "..."}
Client sends:
- {"type": "mouse", "action": "click|dblclick|move|scroll",
"x": N, "y": N, "button": 1, "delta": N}
- {"type": "key", "action": "press|release",
"key": "a", "code": "KeyA", "modifiers": ["ctrl"]}
- {"type": "set_fps", "fps": 15}
"""
username = await ws_authenticate(websocket)
await websocket.accept()
if not app_id:
await websocket.send_json({"type": "error", "data": "app_id required"})
await websocket.close(code=4000)
return
app = display_manager.get_app(username, app_id)
if not app:
await websocket.send_json({"type": "error", "data": "App not found"})
await websocket.close(code=4004)
return
# Send metadata
await websocket.send_json({
"type": "meta",
"app_id": app.app_id,
"title": app.title,
"command": app.command,
})
# Send last captured frame for instant display on reconnect
if app.last_frame:
await websocket.send_bytes(app.last_frame)
app.attach_ws(websocket)
log.info("WebSocket attached to app %s (%s) for %s", app_id, app.command, username)
try:
while True:
msg = await websocket.receive_json()
msg_type = msg.get("type")
if msg_type in ("mouse", "key"):
await app.send_input(msg)
elif msg_type == "set_fps":
fps = msg.get("fps", 10)
app.target_fps = max(1, min(30, fps))
except WebSocketDisconnect:
pass
except Exception:
log.exception("Display WS error for %s/%s", username, app_id)
finally:
app.detach_ws(websocket)
log.info("WebSocket detached from app %s for %s", app_id, username)