Each configured GUI app (e.g. Nextcloud) gets its own dock icon and opens as a regular Atlus tab. Under the hood: Xvfb virtual display, ImageMagick captures individual window pixmaps as JPEG, streams over WebSocket to a canvas element, with xdotool forwarding mouse/keyboard input back to the X11 window. Apps persist in background when tab is closed, and streaming pauses when no viewers are attached. New files: backend/display.py (DisplayManager + ManagedGuiApp), backend/routers/display.py (WebSocket + REST), frontend display.js/css. Config: gui_apps array in settings for registered applications. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
120 lines
3.6 KiB
Python
120 lines
3.6 KiB
Python
"""Settings — config read/write, hostname, timezone management."""
|
|
|
|
import asyncio
|
|
import platform
|
|
import socket
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from pydantic import BaseModel
|
|
|
|
from backend.auth import get_current_user
|
|
from backend.config import load_config, save_config
|
|
|
|
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
|
|
|
|
|
class ConfigUpdate(BaseModel):
|
|
"""Partial config update — only send fields to change."""
|
|
hostname_display: Optional[str] = None
|
|
timezone: Optional[str] = None
|
|
ntp_enabled: Optional[bool] = None
|
|
dock_apps: Optional[list[str]] = None
|
|
panel_services: Optional[list[str]] = None
|
|
gui_apps: Optional[list[dict]] = None
|
|
session_timeout_minutes: Optional[int] = None
|
|
stats_interval_seconds: Optional[int] = None
|
|
|
|
|
|
class HostnameRequest(BaseModel):
|
|
hostname: str
|
|
|
|
|
|
class TimezoneRequest(BaseModel):
|
|
timezone: str
|
|
|
|
|
|
async def _run(cmd: list[str]) -> tuple[int, str, str]:
|
|
import shutil
|
|
if not shutil.which(cmd[0]):
|
|
return 1, "", f"{cmd[0]}: command not found"
|
|
proc = await asyncio.create_subprocess_exec(
|
|
*cmd,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
stdout, stderr = await proc.communicate()
|
|
return proc.returncode, stdout.decode(), stderr.decode()
|
|
|
|
|
|
@router.get("")
|
|
async def get_config(_user: str = Depends(get_current_user)):
|
|
return load_config()
|
|
|
|
|
|
@router.put("")
|
|
async def update_config(update: ConfigUpdate, _user: str = Depends(get_current_user)):
|
|
cfg = load_config()
|
|
for field, value in update.model_dump(exclude_none=True).items():
|
|
cfg[field] = value
|
|
save_config(cfg)
|
|
return cfg
|
|
|
|
|
|
@router.get("/system")
|
|
async def system_info(_user: str = Depends(get_current_user)):
|
|
"""System info for the About section."""
|
|
hostname = socket.gethostname()
|
|
uname = platform.uname()
|
|
|
|
# OS release info
|
|
os_name = "Linux"
|
|
os_version = ""
|
|
try:
|
|
with open("/etc/os-release") as f:
|
|
for line in f:
|
|
if line.startswith("PRETTY_NAME="):
|
|
os_name = line.split("=", 1)[1].strip().strip('"')
|
|
elif line.startswith("VERSION="):
|
|
os_version = line.split("=", 1)[1].strip().strip('"')
|
|
except FileNotFoundError:
|
|
pass
|
|
|
|
return {
|
|
"hostname": hostname,
|
|
"os": os_name,
|
|
"os_version": os_version,
|
|
"kernel": uname.release,
|
|
"arch": uname.machine,
|
|
"python": platform.python_version(),
|
|
}
|
|
|
|
|
|
@router.post("/hostname")
|
|
async def set_hostname(req: HostnameRequest, _user: str = Depends(get_current_user)):
|
|
rc, _, err = await _run(["hostnamectl", "set-hostname", req.hostname])
|
|
if rc != 0:
|
|
raise HTTPException(500, f"Failed to set hostname: {err}")
|
|
return {"hostname": req.hostname}
|
|
|
|
|
|
@router.get("/timezone")
|
|
async def get_timezone(_user: str = Depends(get_current_user)):
|
|
rc, out, _ = await _run(["timedatectl", "show", "--property=Timezone", "--value"])
|
|
return {"timezone": out.strip() if rc == 0 else "unknown"}
|
|
|
|
|
|
@router.post("/timezone")
|
|
async def set_timezone(req: TimezoneRequest, _user: str = Depends(get_current_user)):
|
|
rc, _, err = await _run(["timedatectl", "set-timezone", req.timezone])
|
|
if rc != 0:
|
|
raise HTTPException(500, f"Failed to set timezone: {err}")
|
|
return {"timezone": req.timezone}
|
|
|
|
|
|
@router.get("/timezones")
|
|
async def list_timezones(_user: str = Depends(get_current_user)):
|
|
rc, out, _ = await _run(["timedatectl", "list-timezones"])
|
|
if rc != 0:
|
|
return []
|
|
return out.strip().splitlines()
|