atlus/backend/routers/terminal.py
roberts 220a5234cd Add persistent/roaming desktop sessions
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>
2026-03-14 21:41:28 -05:00

84 lines
2.8 KiB
Python

"""Terminal WebSocket — attaches to persistent PTY sessions.
The PTY lifecycle is managed by backend.sessions.SessionManager.
WebSocket connections attach/detach without killing the PTY.
"""
import asyncio
import logging
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
from backend.auth import ws_authenticate
from backend.sessions import manager
router = APIRouter(tags=["terminal"])
log = logging.getLogger("atlus.terminal")
@router.websocket("/api/terminal/ws")
async def terminal_ws(
websocket: WebSocket,
terminal_id: str = Query(default=None),
):
"""Attach a WebSocket to an existing persistent terminal.
Query params:
- terminal_id: required — the terminal to attach to
- token: auth token (handled by ws_authenticate)
Client sends:
- {"type": "input", "data": "..."} — keystrokes
- {"type": "resize", "cols": N, "rows": N}
Server sends:
- {"type": "output", "data": "..."} — terminal output
- {"type": "scrollback", "data": "..."} — initial scrollback replay
"""
username = await ws_authenticate(websocket)
await websocket.accept()
if not terminal_id:
await websocket.send_json({"type": "error", "data": "terminal_id required"})
await websocket.close(code=4000)
return
terminal = manager.get_terminal(username, terminal_id)
if not terminal or not terminal.alive:
await websocket.send_json({"type": "error", "data": "Terminal not found or dead"})
await websocket.close(code=4004)
return
# Send scrollback replay so the client sees previous output
if terminal.scrollback:
scrollback_text = "".join(terminal.scrollback)
await websocket.send_json({"type": "scrollback", "data": scrollback_text})
# Attach this WebSocket to receive live output
terminal.attach_ws(websocket)
log.info("WebSocket attached to terminal %s for %s", terminal_id, username)
try:
while True:
msg = await websocket.receive_json()
msg_type = msg.get("type")
if msg_type == "input":
if terminal.alive:
data = msg["data"]
if isinstance(data, str):
data = data.encode("utf-8")
terminal.pty.write(data)
elif msg_type == "resize":
if terminal.alive:
cols = msg.get("cols", 120)
rows = msg.get("rows", 30)
terminal.pty.setwinsize(rows, cols)
except WebSocketDisconnect:
pass
except Exception:
log.exception("terminal ws error for %s/%s", username, terminal_id)
finally:
terminal.detach_ws(websocket)
log.info("WebSocket detached from terminal %s for %s", terminal_id, username)
# PTY stays alive — no kill here