PTY terminals now survive browser refresh and close. Session manager owns PTY lifecycle independently of WebSocket connections, with background readers storing scrollback for replay on reconnect. Desktop state (open apps, active app, terminal tabs) persists server-side and restores automatically on login. Auth tokens moved to localStorage. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
160 lines
4.9 KiB
Python
160 lines
4.9 KiB
Python
"""Atlus — main FastAPI application entry point."""
|
|
|
|
import asyncio
|
|
import logging
|
|
from contextlib import asynccontextmanager
|
|
|
|
from fastapi import FastAPI, HTTPException, Request
|
|
from fastapi.responses import FileResponse, JSONResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
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.sessions import manager as session_manager
|
|
from backend.routers.plugins import asi_bridge
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s %(name)s %(levelname)s %(message)s",
|
|
datefmt="%H:%M:%S",
|
|
)
|
|
log = logging.getLogger("atlus")
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lifespan — start/stop background tasks
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
log.info("Atlus starting on %s:%d", HOST, PORT)
|
|
# Start stats broadcaster
|
|
broadcaster = asyncio.create_task(stats.stats_broadcaster())
|
|
|
|
# Start periodic session cleanup (every hour, remove sessions idle > 24h)
|
|
async def _session_cleanup_loop():
|
|
while True:
|
|
await asyncio.sleep(3600)
|
|
await session_manager.cleanup_stale(max_idle_hours=24)
|
|
|
|
cleanup_task = asyncio.create_task(_session_cleanup_loop())
|
|
|
|
yield
|
|
|
|
broadcaster.cancel()
|
|
cleanup_task.cancel()
|
|
try:
|
|
await broadcaster
|
|
except asyncio.CancelledError:
|
|
pass
|
|
try:
|
|
await cleanup_task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
# Kill all PTYs on shutdown
|
|
session_manager.shutdown_all()
|
|
log.info("Atlus shutdown complete")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# App
|
|
# ---------------------------------------------------------------------------
|
|
|
|
app = FastAPI(
|
|
title="Atlus",
|
|
description="Web-based desktop environment for headless SBCs",
|
|
version="0.1.0",
|
|
lifespan=lifespan,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Auth endpoints (not behind auth — they *create* auth)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class LoginRequest(BaseModel):
|
|
username: str
|
|
password: str
|
|
|
|
|
|
@app.post("/api/auth/login")
|
|
async def login(req: LoginRequest):
|
|
if not authenticate_user(req.username, req.password):
|
|
raise HTTPException(401, "Invalid credentials")
|
|
token, jti = create_token(req.username)
|
|
return {"token": token, "username": req.username}
|
|
|
|
|
|
@app.post("/api/auth/logout")
|
|
async def logout_endpoint(request: Request):
|
|
auth = request.headers.get("Authorization", "")
|
|
if auth.startswith("Bearer "):
|
|
logout(auth[7:])
|
|
return {"ok": True}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# API routers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
app.include_router(stats.router)
|
|
app.include_router(terminal.router)
|
|
app.include_router(files.router)
|
|
app.include_router(services.router)
|
|
app.include_router(processes.router)
|
|
app.include_router(settings.router)
|
|
app.include_router(network.router)
|
|
app.include_router(packages.router)
|
|
app.include_router(updates.router)
|
|
app.include_router(session.router)
|
|
app.include_router(asi_bridge.router)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Frontend — served as static files, SPA-style routing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Mount static assets (CSS, JS, images)
|
|
app.mount("/css", StaticFiles(directory=str(FRONTEND_DIR / "css")), name="css")
|
|
app.mount("/js", StaticFiles(directory=str(FRONTEND_DIR / "js")), name="js")
|
|
app.mount("/assets", StaticFiles(directory=str(FRONTEND_DIR / "assets")), name="assets")
|
|
|
|
|
|
@app.get("/")
|
|
async def serve_login():
|
|
return FileResponse(str(FRONTEND_DIR / "index.html"))
|
|
|
|
|
|
@app.get("/desktop")
|
|
async def serve_desktop():
|
|
return FileResponse(str(FRONTEND_DIR / "desktop.html"))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Error handler
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@app.exception_handler(Exception)
|
|
async def global_exception_handler(request: Request, exc: Exception):
|
|
log.exception("Unhandled error: %s", exc)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"detail": "Internal server error"},
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CLI entry point
|
|
# ---------------------------------------------------------------------------
|
|
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
uvicorn.run(
|
|
"backend.main:app",
|
|
host=HOST,
|
|
port=PORT,
|
|
reload=False,
|
|
log_level="info",
|
|
)
|