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>
This commit is contained in:
parent
c3e127421b
commit
220a5234cd
7 changed files with 689 additions and 146 deletions
|
|
@ -11,7 +11,8 @@ 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
|
||||
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(
|
||||
|
|
@ -30,12 +31,30 @@ 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")
|
||||
|
||||
|
||||
|
|
@ -89,6 +108,7 @@ 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)
|
||||
|
||||
|
||||
|
|
|
|||
79
backend/routers/session.py
Normal file
79
backend/routers/session.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
"""Desktop session — persistent state + terminal management."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.auth import get_current_user
|
||||
from backend.sessions import manager
|
||||
|
||||
router = APIRouter(prefix="/api/session", tags=["session"])
|
||||
log = logging.getLogger("atlus.session")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class DesktopState(BaseModel):
|
||||
open_apps: list[str] = []
|
||||
active_app: str | None = None
|
||||
terminal_tabs: list[dict] = [] # [{terminal_id, title}]
|
||||
|
||||
|
||||
class TerminalCreate(BaseModel):
|
||||
cols: int = 120
|
||||
rows: int = 30
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("")
|
||||
async def get_session(user: str = Depends(get_current_user)):
|
||||
"""Get or create the user's desktop session."""
|
||||
session = manager.get_or_create(user)
|
||||
return session.to_dict()
|
||||
|
||||
|
||||
@router.put("/state")
|
||||
async def save_state(
|
||||
state: DesktopState,
|
||||
user: str = Depends(get_current_user),
|
||||
):
|
||||
"""Save desktop state (open apps, active app, terminal tab info)."""
|
||||
manager.save_state(user, state.model_dump())
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Terminal endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/terminals")
|
||||
async def list_terminals(user: str = Depends(get_current_user)):
|
||||
"""List all alive terminals for the user."""
|
||||
return manager.list_terminals(user)
|
||||
|
||||
|
||||
@router.post("/terminals")
|
||||
async def create_terminal(
|
||||
req: TerminalCreate,
|
||||
user: str = Depends(get_current_user),
|
||||
):
|
||||
"""Create a new persistent terminal."""
|
||||
terminal = manager.create_terminal(user, cols=req.cols, rows=req.rows)
|
||||
return terminal.to_dict()
|
||||
|
||||
|
||||
@router.delete("/terminals/{terminal_id}")
|
||||
async def close_terminal(
|
||||
terminal_id: str,
|
||||
user: str = Depends(get_current_user),
|
||||
):
|
||||
"""Close and kill a terminal."""
|
||||
if not manager.close_terminal(user, terminal_id):
|
||||
raise HTTPException(404, "Terminal not found")
|
||||
return {"ok": True}
|
||||
|
|
@ -1,61 +1,31 @@
|
|||
"""Terminal PTY via WebSocket — one PTY per connection."""
|
||||
"""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
|
||||
import os
|
||||
import pwd
|
||||
import signal
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
from ptyprocess import PtyProcess
|
||||
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")
|
||||
|
||||
# Track active PTYs for cleanup
|
||||
_active_ptys: dict[int, PtyProcess] = {}
|
||||
|
||||
|
||||
def _spawn_pty(username: str, cols: int = 120, rows: int = 30) -> PtyProcess:
|
||||
"""Spawn a login shell for the given user."""
|
||||
try:
|
||||
pw = pwd.getpwnam(username)
|
||||
except KeyError:
|
||||
pw = None
|
||||
|
||||
shell = pw.pw_shell if pw else "/bin/bash"
|
||||
home = pw.pw_dir if pw else "/"
|
||||
uid = pw.pw_uid if pw else 0
|
||||
gid = pw.pw_gid if pw else 0
|
||||
|
||||
env = {
|
||||
"TERM": "xterm-256color",
|
||||
"HOME": home,
|
||||
"USER": username,
|
||||
"SHELL": shell,
|
||||
"LANG": os.environ.get("LANG", "en_US.UTF-8"),
|
||||
"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
}
|
||||
|
||||
pty = PtyProcess.spawn(
|
||||
[shell, "-l"],
|
||||
dimensions=(rows, cols),
|
||||
env=env,
|
||||
cwd=home,
|
||||
)
|
||||
|
||||
# If running as root, setuid to the target user
|
||||
# (PtyProcess doesn't do this automatically)
|
||||
# Note: the child already inherits from spawn, but we track it
|
||||
_active_ptys[pty.pid] = pty
|
||||
return pty
|
||||
|
||||
|
||||
@router.websocket("/api/terminal/ws")
|
||||
async def terminal_ws(websocket: WebSocket):
|
||||
"""Full-duplex PTY session over WebSocket.
|
||||
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
|
||||
|
|
@ -63,28 +33,30 @@ async def terminal_ws(websocket: WebSocket):
|
|||
|
||||
Server sends:
|
||||
- {"type": "output", "data": "..."} — terminal output
|
||||
- {"type": "scrollback", "data": "..."} — initial scrollback replay
|
||||
"""
|
||||
username = await ws_authenticate(websocket)
|
||||
await websocket.accept()
|
||||
|
||||
pty = _spawn_pty(username)
|
||||
fd = pty.fd
|
||||
loop = asyncio.get_event_loop()
|
||||
if not terminal_id:
|
||||
await websocket.send_json({"type": "error", "data": "terminal_id required"})
|
||||
await websocket.close(code=4000)
|
||||
return
|
||||
|
||||
async def read_pty():
|
||||
"""Read from PTY fd and push to WebSocket."""
|
||||
while pty.isalive():
|
||||
try:
|
||||
raw = await loop.run_in_executor(None, lambda: pty.read(4096))
|
||||
if raw:
|
||||
text = raw.decode("utf-8", errors="replace") if isinstance(raw, bytes) else raw
|
||||
await websocket.send_json({"type": "output", "data": text})
|
||||
except EOFError:
|
||||
break
|
||||
except Exception:
|
||||
break
|
||||
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
|
||||
|
||||
reader_task = asyncio.create_task(read_pty())
|
||||
# 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:
|
||||
|
|
@ -92,25 +64,21 @@ async def terminal_ws(websocket: WebSocket):
|
|||
msg_type = msg.get("type")
|
||||
|
||||
if msg_type == "input":
|
||||
if terminal.alive:
|
||||
data = msg["data"]
|
||||
if isinstance(data, str):
|
||||
data = data.encode("utf-8")
|
||||
pty.write(data)
|
||||
terminal.pty.write(data)
|
||||
elif msg_type == "resize":
|
||||
if terminal.alive:
|
||||
cols = msg.get("cols", 120)
|
||||
rows = msg.get("rows", 30)
|
||||
pty.setwinsize(rows, cols)
|
||||
terminal.pty.setwinsize(rows, cols)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception:
|
||||
log.exception("terminal ws error")
|
||||
log.exception("terminal ws error for %s/%s", username, terminal_id)
|
||||
finally:
|
||||
reader_task.cancel()
|
||||
if pty.isalive():
|
||||
try:
|
||||
pty.kill(signal.SIGHUP)
|
||||
pty.wait()
|
||||
except Exception:
|
||||
pass
|
||||
_active_ptys.pop(pty.pid, None)
|
||||
log.info("Terminal session ended for %s (pid %d)", username, pty.pid)
|
||||
terminal.detach_ws(websocket)
|
||||
log.info("WebSocket detached from terminal %s for %s", terminal_id, username)
|
||||
# PTY stays alive — no kill here
|
||||
|
|
|
|||
313
backend/sessions.py
Normal file
313
backend/sessions.py
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
"""Atlus — Persistent desktop session manager.
|
||||
|
||||
Each user gets one desktop session that survives browser refreshes/closes.
|
||||
PTY processes are owned by the SessionManager, not by WebSocket connections.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import pwd
|
||||
import signal
|
||||
import time
|
||||
import uuid
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from ptyprocess import PtyProcess
|
||||
|
||||
from backend.config import DATA_DIR
|
||||
|
||||
log = logging.getLogger("atlus.sessions")
|
||||
|
||||
SESSIONS_DIR = DATA_DIR / "sessions"
|
||||
SCROLLBACK_MAXLEN = 5000 # lines of scrollback per terminal
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Terminal — a long-lived PTY with background reader
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class ManagedTerminal:
|
||||
"""A PTY process that lives independently of any WebSocket."""
|
||||
|
||||
terminal_id: str
|
||||
pty: PtyProcess
|
||||
scrollback: deque = field(default_factory=lambda: deque(maxlen=SCROLLBACK_MAXLEN))
|
||||
title: str = "Shell"
|
||||
created_at: float = field(default_factory=time.time)
|
||||
_reader_task: Optional[asyncio.Task] = field(default=None, repr=False)
|
||||
_websockets: list = field(default_factory=list, repr=False)
|
||||
|
||||
@property
|
||||
def alive(self) -> bool:
|
||||
try:
|
||||
return self.pty.isalive()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def attach_ws(self, ws):
|
||||
"""Register a WebSocket to receive live output."""
|
||||
if ws not in self._websockets:
|
||||
self._websockets.append(ws)
|
||||
|
||||
def detach_ws(self, ws):
|
||||
"""Unregister a WebSocket."""
|
||||
try:
|
||||
self._websockets.remove(ws)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
async def _read_loop(self):
|
||||
"""Background task: read PTY output → scrollback + attached WebSockets."""
|
||||
loop = asyncio.get_event_loop()
|
||||
while self.alive:
|
||||
try:
|
||||
raw = await loop.run_in_executor(None, lambda: self.pty.read(4096))
|
||||
if not raw:
|
||||
continue
|
||||
text = raw.decode("utf-8", errors="replace") if isinstance(raw, bytes) else raw
|
||||
self.scrollback.append(text)
|
||||
|
||||
# Fan out to attached WebSockets
|
||||
dead = []
|
||||
for ws in self._websockets:
|
||||
try:
|
||||
await ws.send_json({"type": "output", "data": text})
|
||||
except Exception:
|
||||
dead.append(ws)
|
||||
for ws in dead:
|
||||
self.detach_ws(ws)
|
||||
|
||||
except EOFError:
|
||||
break
|
||||
except Exception:
|
||||
break
|
||||
|
||||
# PTY died — notify attached clients
|
||||
for ws in list(self._websockets):
|
||||
try:
|
||||
await ws.send_json({"type": "output", "data": "\r\n\x1b[31m[Process exited]\x1b[0m\r\n"})
|
||||
except Exception:
|
||||
pass
|
||||
log.info("Terminal %s reader loop ended (pid %s)", self.terminal_id, self.pty.pid)
|
||||
|
||||
def start_reader(self):
|
||||
"""Start the background reader task."""
|
||||
if self._reader_task is None or self._reader_task.done():
|
||||
self._reader_task = asyncio.create_task(self._read_loop())
|
||||
|
||||
def kill(self):
|
||||
"""Terminate the PTY process."""
|
||||
if self._reader_task and not self._reader_task.done():
|
||||
self._reader_task.cancel()
|
||||
if self.alive:
|
||||
try:
|
||||
self.pty.kill(signal.SIGHUP)
|
||||
self.pty.wait()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"terminal_id": self.terminal_id,
|
||||
"title": self.title,
|
||||
"alive": self.alive,
|
||||
"pid": self.pty.pid,
|
||||
"created_at": self.created_at,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Desktop Session — one per user
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class DesktopSession:
|
||||
"""Persistent desktop session for a single user."""
|
||||
|
||||
username: str
|
||||
terminals: dict[str, ManagedTerminal] = field(default_factory=dict)
|
||||
desktop_state: dict = field(default_factory=dict) # open apps, active app, etc.
|
||||
created_at: float = field(default_factory=time.time)
|
||||
last_active: float = field(default_factory=time.time)
|
||||
|
||||
def touch(self):
|
||||
self.last_active = time.time()
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"username": self.username,
|
||||
"terminals": {tid: t.to_dict() for tid, t in self.terminals.items() if t.alive},
|
||||
"desktop_state": self.desktop_state,
|
||||
"created_at": self.created_at,
|
||||
"last_active": self.last_active,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session Manager — singleton
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class SessionManager:
|
||||
"""Manages all user desktop sessions."""
|
||||
|
||||
def __init__(self):
|
||||
self._sessions: dict[str, DesktopSession] = {}
|
||||
SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ---- Session lifecycle ----
|
||||
|
||||
def get_or_create(self, username: str) -> DesktopSession:
|
||||
"""Get existing session or create a new one for the user."""
|
||||
if username not in self._sessions:
|
||||
session = DesktopSession(username=username)
|
||||
# Try to restore desktop state from disk
|
||||
state_file = SESSIONS_DIR / f"{username}.json"
|
||||
if state_file.exists():
|
||||
try:
|
||||
data = json.loads(state_file.read_text())
|
||||
session.desktop_state = data.get("desktop_state", {})
|
||||
log.info("Restored desktop state for %s", username)
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
log.warning("Failed to restore session for %s: %s", username, e)
|
||||
self._sessions[username] = session
|
||||
log.info("Created session for %s", username)
|
||||
session = self._sessions[username]
|
||||
session.touch()
|
||||
# Prune dead terminals
|
||||
self._prune_dead(session)
|
||||
return session
|
||||
|
||||
def _prune_dead(self, session: DesktopSession):
|
||||
"""Remove terminals whose PTY has exited."""
|
||||
dead = [tid for tid, t in session.terminals.items() if not t.alive]
|
||||
for tid in dead:
|
||||
t = session.terminals.pop(tid)
|
||||
t.kill()
|
||||
log.info("Pruned dead terminal %s for %s", tid, session.username)
|
||||
|
||||
def save_state(self, username: str, desktop_state: dict):
|
||||
"""Persist desktop state to disk."""
|
||||
session = self._sessions.get(username)
|
||||
if not session:
|
||||
return
|
||||
session.desktop_state = desktop_state
|
||||
session.touch()
|
||||
|
||||
state_file = SESSIONS_DIR / f"{username}.json"
|
||||
try:
|
||||
data = {
|
||||
"desktop_state": desktop_state,
|
||||
"terminal_ids": [
|
||||
{"terminal_id": t.terminal_id, "title": t.title}
|
||||
for t in session.terminals.values() if t.alive
|
||||
],
|
||||
"saved_at": time.time(),
|
||||
}
|
||||
state_file.write_text(json.dumps(data, indent=2))
|
||||
except OSError as e:
|
||||
log.warning("Failed to save session for %s: %s", username, e)
|
||||
|
||||
# ---- Terminal lifecycle ----
|
||||
|
||||
def create_terminal(self, username: str, cols: int = 120, rows: int = 30) -> ManagedTerminal:
|
||||
"""Spawn a new PTY terminal for the user."""
|
||||
session = self.get_or_create(username)
|
||||
|
||||
# Spawn PTY
|
||||
try:
|
||||
pw = pwd.getpwnam(username)
|
||||
except KeyError:
|
||||
pw = None
|
||||
|
||||
shell = pw.pw_shell if pw else "/bin/bash"
|
||||
home = pw.pw_dir if pw else "/"
|
||||
|
||||
env = {
|
||||
"TERM": "xterm-256color",
|
||||
"HOME": home,
|
||||
"USER": username,
|
||||
"SHELL": shell,
|
||||
"LANG": os.environ.get("LANG", "en_US.UTF-8"),
|
||||
"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
}
|
||||
|
||||
pty = PtyProcess.spawn(
|
||||
[shell, "-l"],
|
||||
dimensions=(rows, cols),
|
||||
env=env,
|
||||
cwd=home,
|
||||
)
|
||||
|
||||
terminal_id = str(uuid.uuid4())[:8]
|
||||
terminal = ManagedTerminal(
|
||||
terminal_id=terminal_id,
|
||||
pty=pty,
|
||||
title=f"Shell {len(session.terminals) + 1}",
|
||||
)
|
||||
terminal.start_reader()
|
||||
session.terminals[terminal_id] = terminal
|
||||
session.touch()
|
||||
|
||||
log.info("Created terminal %s for %s (pid %d)", terminal_id, username, pty.pid)
|
||||
return terminal
|
||||
|
||||
def get_terminal(self, username: str, terminal_id: str) -> Optional[ManagedTerminal]:
|
||||
"""Get a terminal by ID."""
|
||||
session = self._sessions.get(username)
|
||||
if not session:
|
||||
return None
|
||||
return session.terminals.get(terminal_id)
|
||||
|
||||
def close_terminal(self, username: str, terminal_id: str) -> bool:
|
||||
"""Kill and remove a terminal."""
|
||||
session = self._sessions.get(username)
|
||||
if not session:
|
||||
return False
|
||||
terminal = session.terminals.pop(terminal_id, None)
|
||||
if not terminal:
|
||||
return False
|
||||
terminal.kill()
|
||||
session.touch()
|
||||
log.info("Closed terminal %s for %s", terminal_id, username)
|
||||
return True
|
||||
|
||||
def list_terminals(self, username: str) -> list[dict]:
|
||||
"""List all alive terminals for a user."""
|
||||
session = self._sessions.get(username)
|
||||
if not session:
|
||||
return []
|
||||
self._prune_dead(session)
|
||||
return [t.to_dict() for t in session.terminals.values() if t.alive]
|
||||
|
||||
# ---- Cleanup ----
|
||||
|
||||
async def cleanup_stale(self, max_idle_hours: float = 24):
|
||||
"""Remove sessions that have been idle too long."""
|
||||
cutoff = time.time() - (max_idle_hours * 3600)
|
||||
stale = [u for u, s in self._sessions.items() if s.last_active < cutoff]
|
||||
for username in stale:
|
||||
session = self._sessions.pop(username)
|
||||
for terminal in session.terminals.values():
|
||||
terminal.kill()
|
||||
log.info("Cleaned up stale session for %s (idle since %s)",
|
||||
username, time.ctime(session.last_active))
|
||||
|
||||
def shutdown_all(self):
|
||||
"""Kill all PTYs — called on server shutdown."""
|
||||
for session in self._sessions.values():
|
||||
for terminal in session.terminals.values():
|
||||
terminal.kill()
|
||||
log.info("All sessions shut down")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Singleton instance
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
manager = SessionManager()
|
||||
|
|
@ -1,17 +1,41 @@
|
|||
/* Atlus — Terminal app (xterm.js + PTY WebSocket) */
|
||||
/* Atlus — Terminal app (xterm.js + persistent PTY sessions) */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
let sessions = []; // {id, ws, term, fitAddon}
|
||||
let activeSession = 0;
|
||||
let sessionCounter = 0;
|
||||
let sessions = []; // {terminalId, ws, term, fitAddon, title}
|
||||
let activeTerminalId = null;
|
||||
let container = null;
|
||||
let termContainer = null;
|
||||
let tabsContainer = null;
|
||||
let kbdVisible = true;
|
||||
let resizeObserver = null;
|
||||
|
||||
// ---- Create or attach to a terminal ----
|
||||
|
||||
async function createNewTerminal() {
|
||||
// Ask backend to create a persistent terminal
|
||||
try {
|
||||
const res = await Atlus.apiFetch('/api/session/terminals', {
|
||||
method: 'POST',
|
||||
body: { cols: 120, rows: 30 },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return attachToTerminal(data.terminal_id, data.title);
|
||||
} catch (e) {
|
||||
console.error('Failed to create terminal:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function attachToTerminal(terminalId, title) {
|
||||
// Don't attach twice
|
||||
const existing = sessions.find(s => s.terminalId === terminalId);
|
||||
if (existing) {
|
||||
switchSession(terminalId);
|
||||
return existing;
|
||||
}
|
||||
|
||||
function createSession() {
|
||||
const id = ++sessionCounter;
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
|
|
@ -44,10 +68,11 @@
|
|||
const fitAddon = new FitAddon.FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
|
||||
const ws = new WebSocket(Atlus.wsUrl('/api/terminal/ws'));
|
||||
// Connect WebSocket to the persistent terminal
|
||||
const wsUrl = Atlus.wsUrl(`/api/terminal/ws?terminal_id=${terminalId}`);
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
// Send initial size
|
||||
setTimeout(() => {
|
||||
fitAddon.fit();
|
||||
ws.send(JSON.stringify({
|
||||
|
|
@ -62,11 +87,16 @@
|
|||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'output') {
|
||||
term.write(msg.data);
|
||||
} else if (msg.type === 'scrollback') {
|
||||
// Replay scrollback from server
|
||||
term.write(msg.data);
|
||||
} else if (msg.type === 'error') {
|
||||
term.write(`\r\n\x1b[31m[Error: ${msg.data}]\x1b[0m\r\n`);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
term.write('\r\n\x1b[31m[Session ended]\x1b[0m\r\n');
|
||||
term.write('\r\n\x1b[33m[Disconnected — refresh to reconnect]\x1b[0m\r\n');
|
||||
};
|
||||
|
||||
// Send input to PTY
|
||||
|
|
@ -83,10 +113,11 @@
|
|||
}
|
||||
});
|
||||
|
||||
const session = { id, ws, term, fitAddon };
|
||||
const session = { terminalId, ws, term, fitAddon, title: title || 'Shell' };
|
||||
sessions.push(session);
|
||||
|
||||
// Connect keyboard — only send to PTY, let PTY echo handle display
|
||||
// Connect on-screen keyboard
|
||||
if (Atlus.keyboard) {
|
||||
Atlus.keyboard.setTerminal({
|
||||
input: (data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
|
|
@ -94,54 +125,66 @@
|
|||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
function switchSession(id) {
|
||||
activeSession = id;
|
||||
function switchSession(terminalId) {
|
||||
activeTerminalId = terminalId;
|
||||
renderTabs();
|
||||
renderTerminal();
|
||||
if (Atlus.saveDesktopState) Atlus.saveDesktopState();
|
||||
}
|
||||
|
||||
function closeSession(id) {
|
||||
const idx = sessions.findIndex(s => s.id === id);
|
||||
async function closeSession(terminalId) {
|
||||
const idx = sessions.findIndex(s => s.terminalId === terminalId);
|
||||
if (idx === -1) return;
|
||||
|
||||
const session = sessions[idx];
|
||||
|
||||
// Close WebSocket (doesn't kill PTY)
|
||||
if (session.ws.readyState === WebSocket.OPEN) {
|
||||
session.ws.close();
|
||||
}
|
||||
session.term.dispose();
|
||||
sessions.splice(idx, 1);
|
||||
|
||||
// Tell server to kill the PTY
|
||||
try {
|
||||
await Atlus.apiFetch(`/api/session/terminals/${terminalId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
} catch (e) { /* best effort */ }
|
||||
|
||||
if (sessions.length === 0) {
|
||||
Atlus.closeApp('terminal');
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeSession === id) {
|
||||
activeSession = sessions[sessions.length - 1].id;
|
||||
if (activeTerminalId === terminalId) {
|
||||
activeTerminalId = sessions[sessions.length - 1].terminalId;
|
||||
}
|
||||
renderTabs();
|
||||
renderTerminal();
|
||||
if (Atlus.saveDesktopState) Atlus.saveDesktopState();
|
||||
}
|
||||
|
||||
function renderTabs() {
|
||||
if (!tabsContainer) return;
|
||||
tabsContainer.innerHTML = '';
|
||||
sessions.forEach(s => {
|
||||
sessions.forEach((s, i) => {
|
||||
const tab = document.createElement('button');
|
||||
tab.className = 'terminal-tab' + (s.id === activeSession ? ' active' : '');
|
||||
tab.innerHTML = `<span>Shell ${s.id}</span>`;
|
||||
tab.className = 'terminal-tab' + (s.terminalId === activeTerminalId ? ' active' : '');
|
||||
tab.innerHTML = `<span>${s.title || 'Shell ' + (i + 1)}</span>`;
|
||||
if (sessions.length > 1) {
|
||||
tab.innerHTML += `<span class="terminal-tab-close">×</span>`;
|
||||
}
|
||||
tab.addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('terminal-tab-close')) {
|
||||
closeSession(s.id);
|
||||
closeSession(s.terminalId);
|
||||
} else {
|
||||
switchSession(s.id);
|
||||
switchSession(s.terminalId);
|
||||
}
|
||||
});
|
||||
tabsContainer.appendChild(tab);
|
||||
|
|
@ -150,10 +193,9 @@
|
|||
|
||||
function renderTerminal() {
|
||||
if (!termContainer) return;
|
||||
// Clear
|
||||
termContainer.innerHTML = '';
|
||||
|
||||
const session = sessions.find(s => s.id === activeSession);
|
||||
const session = sessions.find(s => s.terminalId === activeTerminalId);
|
||||
if (!session) return;
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
|
|
@ -161,12 +203,12 @@
|
|||
termContainer.appendChild(wrap);
|
||||
session.term.open(wrap);
|
||||
|
||||
// Fit after render
|
||||
requestAnimationFrame(() => {
|
||||
session.fitAddon.fit();
|
||||
});
|
||||
|
||||
// Update keyboard terminal ref — only send to PTY
|
||||
// Update keyboard terminal ref
|
||||
if (Atlus.keyboard) {
|
||||
Atlus.keyboard.setTerminal({
|
||||
input: (data) => {
|
||||
if (session.ws.readyState === WebSocket.OPEN) {
|
||||
|
|
@ -175,9 +217,42 @@
|
|||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Resize observer
|
||||
let resizeObserver = null;
|
||||
// ---- Session restore logic ----
|
||||
|
||||
async function initTerminals() {
|
||||
const restored = Atlus._restoredTerminals || [];
|
||||
const tabInfo = Atlus._restoredTerminalTabs || [];
|
||||
|
||||
if (restored.length > 0) {
|
||||
// Re-attach to existing server-side terminals
|
||||
for (const t of restored) {
|
||||
// Find title from tab info if available
|
||||
const info = tabInfo.find(ti => ti.terminal_id === t.terminal_id);
|
||||
const title = (info && info.title) || t.title || 'Shell';
|
||||
attachToTerminal(t.terminal_id, title);
|
||||
}
|
||||
if (sessions.length > 0) {
|
||||
activeTerminalId = sessions[0].terminalId;
|
||||
}
|
||||
}
|
||||
|
||||
// If no terminals were restored, create a fresh one
|
||||
if (sessions.length === 0) {
|
||||
const s = await createNewTerminal();
|
||||
if (s) activeTerminalId = s.terminalId;
|
||||
}
|
||||
|
||||
// Clear restored data so re-opening terminal app doesn't double-attach
|
||||
delete Atlus._restoredTerminals;
|
||||
delete Atlus._restoredTerminalTabs;
|
||||
|
||||
renderTabs();
|
||||
renderTerminal();
|
||||
}
|
||||
|
||||
// ---- App registration ----
|
||||
|
||||
Atlus.registerApp('terminal', {
|
||||
title: 'Terminal',
|
||||
|
|
@ -198,9 +273,9 @@
|
|||
newTabBtn.className = 'terminal-new-tab';
|
||||
newTabBtn.textContent = '+';
|
||||
newTabBtn.title = 'New tab';
|
||||
newTabBtn.addEventListener('click', () => {
|
||||
const s = createSession();
|
||||
switchSession(s.id);
|
||||
newTabBtn.addEventListener('click', async () => {
|
||||
const s = await createNewTerminal();
|
||||
if (s) switchSession(s.terminalId);
|
||||
});
|
||||
toolbar.appendChild(newTabBtn);
|
||||
|
||||
|
|
@ -211,9 +286,8 @@
|
|||
kbdBtn.addEventListener('click', () => {
|
||||
kbdVisible = !kbdVisible;
|
||||
kbdBtn.classList.toggle('active', kbdVisible);
|
||||
Atlus.keyboard.toggle();
|
||||
// Refit terminal
|
||||
const session = sessions.find(s => s.id === activeSession);
|
||||
if (Atlus.keyboard) Atlus.keyboard.toggle();
|
||||
const session = sessions.find(s => s.terminalId === activeTerminalId);
|
||||
if (session) {
|
||||
requestAnimationFrame(() => session.fitAddon.fit());
|
||||
}
|
||||
|
|
@ -231,19 +305,18 @@
|
|||
container.appendChild(termContainer);
|
||||
|
||||
// On-screen keyboard
|
||||
if (Atlus.keyboard) {
|
||||
const kbdEl = Atlus.keyboard.create();
|
||||
if (!kbdVisible) kbdEl.classList.add('hidden');
|
||||
container.appendChild(kbdEl);
|
||||
}
|
||||
|
||||
// Create first session
|
||||
const s = createSession();
|
||||
activeSession = s.id;
|
||||
renderTabs();
|
||||
renderTerminal();
|
||||
// Initialize — restore or create terminals
|
||||
initTerminals();
|
||||
|
||||
// Resize observer
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
const session = sessions.find(s => s.id === activeSession);
|
||||
const session = sessions.find(s => s.terminalId === activeTerminalId);
|
||||
if (session) {
|
||||
try { session.fitAddon.fit(); } catch (e) {}
|
||||
}
|
||||
|
|
@ -256,19 +329,20 @@
|
|||
resizeObserver.disconnect();
|
||||
resizeObserver = null;
|
||||
}
|
||||
// Close WebSockets but do NOT kill PTYs — they persist on server
|
||||
sessions.forEach(s => {
|
||||
if (s.ws.readyState === WebSocket.OPEN) s.ws.close();
|
||||
s.term.dispose();
|
||||
});
|
||||
sessions = [];
|
||||
sessionCounter = 0;
|
||||
activeTerminalId = null;
|
||||
container = null;
|
||||
termContainer = null;
|
||||
tabsContainer = null;
|
||||
},
|
||||
|
||||
onFocus() {
|
||||
const session = sessions.find(s => s.id === activeSession);
|
||||
const session = sessions.find(s => s.terminalId === activeTerminalId);
|
||||
if (session) {
|
||||
requestAnimationFrame(() => {
|
||||
session.fitAddon.fit();
|
||||
|
|
@ -276,5 +350,13 @@
|
|||
});
|
||||
}
|
||||
},
|
||||
|
||||
/** Public API: get terminal IDs for session state saving */
|
||||
getTerminalIds() {
|
||||
return sessions.map(s => ({
|
||||
terminal_id: s.terminalId,
|
||||
title: s.title,
|
||||
}));
|
||||
},
|
||||
});
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@
|
|||
// =====================================================================
|
||||
// Auth guard
|
||||
// =====================================================================
|
||||
const TOKEN = sessionStorage.getItem('atlus_token');
|
||||
const USER = sessionStorage.getItem('atlus_user');
|
||||
const TOKEN = localStorage.getItem('atlus_token');
|
||||
const USER = localStorage.getItem('atlus_user');
|
||||
if (!TOKEN) {
|
||||
window.location.href = '/';
|
||||
return;
|
||||
|
|
@ -33,7 +33,8 @@
|
|||
}
|
||||
const res = await fetch(url, opts);
|
||||
if (res.status === 401) {
|
||||
sessionStorage.clear();
|
||||
localStorage.removeItem('atlus_token');
|
||||
localStorage.removeItem('atlus_user');
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
|
|
@ -104,6 +105,7 @@
|
|||
if (app.init) app.init(container);
|
||||
|
||||
focusApp(appId);
|
||||
saveDesktopState();
|
||||
}
|
||||
|
||||
function focusApp(appId) {
|
||||
|
|
@ -134,6 +136,8 @@
|
|||
|
||||
// Notify app it got focus
|
||||
if (app && app.onFocus) app.onFocus();
|
||||
|
||||
saveDesktopState();
|
||||
}
|
||||
|
||||
function closeApp(appId) {
|
||||
|
|
@ -165,6 +169,7 @@
|
|||
if (welcomeScreen) welcomeScreen.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
saveDesktopState();
|
||||
}
|
||||
|
||||
function addTab(appId, title) {
|
||||
|
|
@ -220,7 +225,8 @@
|
|||
|
||||
if (action === 'logout') {
|
||||
await Atlus.apiFetch('/api/auth/logout', { method: 'POST' });
|
||||
sessionStorage.clear();
|
||||
localStorage.removeItem('atlus_token');
|
||||
localStorage.removeItem('atlus_user');
|
||||
window.location.href = '/';
|
||||
}
|
||||
});
|
||||
|
|
@ -450,6 +456,77 @@
|
|||
.catch(() => setTimeout(() => attemptReload(attempt + 1), 3000));
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Session persistence — save/restore desktop state
|
||||
// =====================================================================
|
||||
let _saveTimer = null;
|
||||
|
||||
function saveDesktopState() {
|
||||
// Debounce — save at most once per second
|
||||
if (_saveTimer) clearTimeout(_saveTimer);
|
||||
_saveTimer = setTimeout(_doSaveState, 1000);
|
||||
}
|
||||
|
||||
function _doSaveState() {
|
||||
const termApp = Atlus.apps.terminal;
|
||||
const terminalTabs = (termApp && termApp.getTerminalIds)
|
||||
? termApp.getTerminalIds()
|
||||
: [];
|
||||
|
||||
const state = {
|
||||
open_apps: Atlus.openApps.slice(),
|
||||
active_app: Atlus.activeApp,
|
||||
terminal_tabs: terminalTabs,
|
||||
};
|
||||
|
||||
Atlus.apiFetch('/api/session/state', {
|
||||
method: 'PUT',
|
||||
body: state,
|
||||
}).catch(() => {}); // best-effort
|
||||
}
|
||||
|
||||
async function restoreSession() {
|
||||
try {
|
||||
const res = await Atlus.apiFetch('/api/session');
|
||||
if (!res || !res.ok) return;
|
||||
const session = await res.json();
|
||||
const state = session.desktop_state || {};
|
||||
const openApps = state.open_apps || [];
|
||||
const activeApp = state.active_app;
|
||||
const serverTerminals = session.terminals || {};
|
||||
|
||||
// Store terminal info for the terminal app to use when it inits
|
||||
Atlus._restoredTerminals = Object.values(serverTerminals);
|
||||
Atlus._restoredTerminalTabs = state.terminal_tabs || [];
|
||||
|
||||
// Re-open apps in saved order
|
||||
for (const appId of openApps) {
|
||||
if (Atlus.apps[appId]) {
|
||||
openApp(appId);
|
||||
}
|
||||
}
|
||||
|
||||
// Focus the previously active app
|
||||
if (activeApp && Atlus.openApps.includes(activeApp)) {
|
||||
focusApp(activeApp);
|
||||
}
|
||||
} catch (e) {
|
||||
// First visit or server error — no session to restore
|
||||
}
|
||||
}
|
||||
|
||||
// Save state on visibility change (tab switch / minimize)
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
_doSaveState(); // immediate save on hide
|
||||
}
|
||||
});
|
||||
|
||||
// Save state before unload
|
||||
window.addEventListener('beforeunload', () => {
|
||||
_doSaveState();
|
||||
});
|
||||
|
||||
// =====================================================================
|
||||
// Init
|
||||
// =====================================================================
|
||||
|
|
@ -468,4 +545,8 @@
|
|||
window.Atlus.openApp = openApp;
|
||||
window.Atlus.closeApp = closeApp;
|
||||
window.Atlus.focusApp = focusApp;
|
||||
window.Atlus.saveDesktopState = saveDesktopState;
|
||||
|
||||
// Restore previous session (after a brief delay to let app modules register)
|
||||
setTimeout(restoreSession, 100);
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
'use strict';
|
||||
|
||||
// Redirect to desktop if already authenticated
|
||||
const token = sessionStorage.getItem('atlus_token');
|
||||
const token = localStorage.getItem('atlus_token');
|
||||
if (token) {
|
||||
window.location.href = '/desktop';
|
||||
return;
|
||||
|
|
@ -37,8 +37,8 @@
|
|||
}
|
||||
|
||||
const data = await res.json();
|
||||
sessionStorage.setItem('atlus_token', data.token);
|
||||
sessionStorage.setItem('atlus_user', data.username);
|
||||
localStorage.setItem('atlus_token', data.token);
|
||||
localStorage.setItem('atlus_user', data.username);
|
||||
window.location.href = '/desktop';
|
||||
} catch (err) {
|
||||
errorEl.textContent = err.message;
|
||||
|
|
|
|||
Loading…
Reference in a new issue