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>
84 lines
2.8 KiB
Python
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
|