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:
roberts 2026-03-14 21:41:28 -05:00
parent c3e127421b
commit 220a5234cd
7 changed files with 689 additions and 146 deletions

View file

@ -11,7 +11,8 @@ from pydantic import BaseModel
from backend.auth import authenticate_user, create_token, logout from backend.auth import authenticate_user, create_token, logout
from backend.config import FRONTEND_DIR, HOST, PORT 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 from backend.routers.plugins import asi_bridge
logging.basicConfig( logging.basicConfig(
@ -30,12 +31,30 @@ async def lifespan(app: FastAPI):
log.info("Atlus starting on %s:%d", HOST, PORT) log.info("Atlus starting on %s:%d", HOST, PORT)
# Start stats broadcaster # Start stats broadcaster
broadcaster = asyncio.create_task(stats.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 yield
broadcaster.cancel() broadcaster.cancel()
cleanup_task.cancel()
try: try:
await broadcaster await broadcaster
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
try:
await cleanup_task
except asyncio.CancelledError:
pass
# Kill all PTYs on shutdown
session_manager.shutdown_all()
log.info("Atlus shutdown complete") log.info("Atlus shutdown complete")
@ -89,6 +108,7 @@ app.include_router(settings.router)
app.include_router(network.router) app.include_router(network.router)
app.include_router(packages.router) app.include_router(packages.router)
app.include_router(updates.router) app.include_router(updates.router)
app.include_router(session.router)
app.include_router(asi_bridge.router) app.include_router(asi_bridge.router)

View 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}

View file

@ -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 asyncio
import logging import logging
import os
import pwd
import signal
from fastapi import APIRouter, WebSocket, WebSocketDisconnect from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
from ptyprocess import PtyProcess
from backend.auth import ws_authenticate from backend.auth import ws_authenticate
from backend.sessions import manager
router = APIRouter(tags=["terminal"]) router = APIRouter(tags=["terminal"])
log = logging.getLogger("atlus.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") @router.websocket("/api/terminal/ws")
async def terminal_ws(websocket: WebSocket): async def terminal_ws(
"""Full-duplex PTY session over WebSocket. 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: Client sends:
- {"type": "input", "data": "..."} keystrokes - {"type": "input", "data": "..."} keystrokes
@ -63,28 +33,30 @@ async def terminal_ws(websocket: WebSocket):
Server sends: Server sends:
- {"type": "output", "data": "..."} terminal output - {"type": "output", "data": "..."} terminal output
- {"type": "scrollback", "data": "..."} initial scrollback replay
""" """
username = await ws_authenticate(websocket) username = await ws_authenticate(websocket)
await websocket.accept() await websocket.accept()
pty = _spawn_pty(username) if not terminal_id:
fd = pty.fd await websocket.send_json({"type": "error", "data": "terminal_id required"})
loop = asyncio.get_event_loop() await websocket.close(code=4000)
return
async def read_pty(): terminal = manager.get_terminal(username, terminal_id)
"""Read from PTY fd and push to WebSocket.""" if not terminal or not terminal.alive:
while pty.isalive(): await websocket.send_json({"type": "error", "data": "Terminal not found or dead"})
try: await websocket.close(code=4004)
raw = await loop.run_in_executor(None, lambda: pty.read(4096)) return
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
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: try:
while True: while True:
@ -92,25 +64,21 @@ async def terminal_ws(websocket: WebSocket):
msg_type = msg.get("type") msg_type = msg.get("type")
if msg_type == "input": if msg_type == "input":
if terminal.alive:
data = msg["data"] data = msg["data"]
if isinstance(data, str): if isinstance(data, str):
data = data.encode("utf-8") data = data.encode("utf-8")
pty.write(data) terminal.pty.write(data)
elif msg_type == "resize": elif msg_type == "resize":
if terminal.alive:
cols = msg.get("cols", 120) cols = msg.get("cols", 120)
rows = msg.get("rows", 30) rows = msg.get("rows", 30)
pty.setwinsize(rows, cols) terminal.pty.setwinsize(rows, cols)
except WebSocketDisconnect: except WebSocketDisconnect:
pass pass
except Exception: except Exception:
log.exception("terminal ws error") log.exception("terminal ws error for %s/%s", username, terminal_id)
finally: finally:
reader_task.cancel() terminal.detach_ws(websocket)
if pty.isalive(): log.info("WebSocket detached from terminal %s for %s", terminal_id, username)
try: # PTY stays alive — no kill here
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)

313
backend/sessions.py Normal file
View 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()

View file

@ -1,17 +1,41 @@
/* Atlus — Terminal app (xterm.js + PTY WebSocket) */ /* Atlus — Terminal app (xterm.js + persistent PTY sessions) */
(function () { (function () {
'use strict'; 'use strict';
let sessions = []; // {id, ws, term, fitAddon} let sessions = []; // {terminalId, ws, term, fitAddon, title}
let activeSession = 0; let activeTerminalId = null;
let sessionCounter = 0;
let container = null; let container = null;
let termContainer = null; let termContainer = null;
let tabsContainer = null; let tabsContainer = null;
let kbdVisible = true; 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({ const term = new Terminal({
cursorBlink: true, cursorBlink: true,
fontSize: 14, fontSize: 14,
@ -44,10 +68,11 @@
const fitAddon = new FitAddon.FitAddon(); const fitAddon = new FitAddon.FitAddon();
term.loadAddon(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 = () => { ws.onopen = () => {
// Send initial size
setTimeout(() => { setTimeout(() => {
fitAddon.fit(); fitAddon.fit();
ws.send(JSON.stringify({ ws.send(JSON.stringify({
@ -62,11 +87,16 @@
const msg = JSON.parse(e.data); const msg = JSON.parse(e.data);
if (msg.type === 'output') { if (msg.type === 'output') {
term.write(msg.data); 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 = () => { 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 // 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); sessions.push(session);
// Connect keyboard — only send to PTY, let PTY echo handle display // Connect on-screen keyboard
if (Atlus.keyboard) {
Atlus.keyboard.setTerminal({ Atlus.keyboard.setTerminal({
input: (data) => { input: (data) => {
if (ws.readyState === WebSocket.OPEN) { if (ws.readyState === WebSocket.OPEN) {
@ -94,54 +125,66 @@
} }
}, },
}); });
}
return session; return session;
} }
function switchSession(id) { function switchSession(terminalId) {
activeSession = id; activeTerminalId = terminalId;
renderTabs(); renderTabs();
renderTerminal(); renderTerminal();
if (Atlus.saveDesktopState) Atlus.saveDesktopState();
} }
function closeSession(id) { async function closeSession(terminalId) {
const idx = sessions.findIndex(s => s.id === id); const idx = sessions.findIndex(s => s.terminalId === terminalId);
if (idx === -1) return; if (idx === -1) return;
const session = sessions[idx]; const session = sessions[idx];
// Close WebSocket (doesn't kill PTY)
if (session.ws.readyState === WebSocket.OPEN) { if (session.ws.readyState === WebSocket.OPEN) {
session.ws.close(); session.ws.close();
} }
session.term.dispose(); session.term.dispose();
sessions.splice(idx, 1); 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) { if (sessions.length === 0) {
Atlus.closeApp('terminal'); Atlus.closeApp('terminal');
return; return;
} }
if (activeSession === id) { if (activeTerminalId === terminalId) {
activeSession = sessions[sessions.length - 1].id; activeTerminalId = sessions[sessions.length - 1].terminalId;
} }
renderTabs(); renderTabs();
renderTerminal(); renderTerminal();
if (Atlus.saveDesktopState) Atlus.saveDesktopState();
} }
function renderTabs() { function renderTabs() {
if (!tabsContainer) return; if (!tabsContainer) return;
tabsContainer.innerHTML = ''; tabsContainer.innerHTML = '';
sessions.forEach(s => { sessions.forEach((s, i) => {
const tab = document.createElement('button'); const tab = document.createElement('button');
tab.className = 'terminal-tab' + (s.id === activeSession ? ' active' : ''); tab.className = 'terminal-tab' + (s.terminalId === activeTerminalId ? ' active' : '');
tab.innerHTML = `<span>Shell ${s.id}</span>`; tab.innerHTML = `<span>${s.title || 'Shell ' + (i + 1)}</span>`;
if (sessions.length > 1) { if (sessions.length > 1) {
tab.innerHTML += `<span class="terminal-tab-close">&times;</span>`; tab.innerHTML += `<span class="terminal-tab-close">&times;</span>`;
} }
tab.addEventListener('click', (e) => { tab.addEventListener('click', (e) => {
if (e.target.classList.contains('terminal-tab-close')) { if (e.target.classList.contains('terminal-tab-close')) {
closeSession(s.id); closeSession(s.terminalId);
} else { } else {
switchSession(s.id); switchSession(s.terminalId);
} }
}); });
tabsContainer.appendChild(tab); tabsContainer.appendChild(tab);
@ -150,10 +193,9 @@
function renderTerminal() { function renderTerminal() {
if (!termContainer) return; if (!termContainer) return;
// Clear
termContainer.innerHTML = ''; termContainer.innerHTML = '';
const session = sessions.find(s => s.id === activeSession); const session = sessions.find(s => s.terminalId === activeTerminalId);
if (!session) return; if (!session) return;
const wrap = document.createElement('div'); const wrap = document.createElement('div');
@ -161,12 +203,12 @@
termContainer.appendChild(wrap); termContainer.appendChild(wrap);
session.term.open(wrap); session.term.open(wrap);
// Fit after render
requestAnimationFrame(() => { requestAnimationFrame(() => {
session.fitAddon.fit(); session.fitAddon.fit();
}); });
// Update keyboard terminal ref — only send to PTY // Update keyboard terminal ref
if (Atlus.keyboard) {
Atlus.keyboard.setTerminal({ Atlus.keyboard.setTerminal({
input: (data) => { input: (data) => {
if (session.ws.readyState === WebSocket.OPEN) { if (session.ws.readyState === WebSocket.OPEN) {
@ -175,9 +217,42 @@
}, },
}); });
} }
}
// Resize observer // ---- Session restore logic ----
let resizeObserver = null;
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', { Atlus.registerApp('terminal', {
title: 'Terminal', title: 'Terminal',
@ -198,9 +273,9 @@
newTabBtn.className = 'terminal-new-tab'; newTabBtn.className = 'terminal-new-tab';
newTabBtn.textContent = '+'; newTabBtn.textContent = '+';
newTabBtn.title = 'New tab'; newTabBtn.title = 'New tab';
newTabBtn.addEventListener('click', () => { newTabBtn.addEventListener('click', async () => {
const s = createSession(); const s = await createNewTerminal();
switchSession(s.id); if (s) switchSession(s.terminalId);
}); });
toolbar.appendChild(newTabBtn); toolbar.appendChild(newTabBtn);
@ -211,9 +286,8 @@
kbdBtn.addEventListener('click', () => { kbdBtn.addEventListener('click', () => {
kbdVisible = !kbdVisible; kbdVisible = !kbdVisible;
kbdBtn.classList.toggle('active', kbdVisible); kbdBtn.classList.toggle('active', kbdVisible);
Atlus.keyboard.toggle(); if (Atlus.keyboard) Atlus.keyboard.toggle();
// Refit terminal const session = sessions.find(s => s.terminalId === activeTerminalId);
const session = sessions.find(s => s.id === activeSession);
if (session) { if (session) {
requestAnimationFrame(() => session.fitAddon.fit()); requestAnimationFrame(() => session.fitAddon.fit());
} }
@ -231,19 +305,18 @@
container.appendChild(termContainer); container.appendChild(termContainer);
// On-screen keyboard // On-screen keyboard
if (Atlus.keyboard) {
const kbdEl = Atlus.keyboard.create(); const kbdEl = Atlus.keyboard.create();
if (!kbdVisible) kbdEl.classList.add('hidden'); if (!kbdVisible) kbdEl.classList.add('hidden');
container.appendChild(kbdEl); container.appendChild(kbdEl);
}
// Create first session // Initialize — restore or create terminals
const s = createSession(); initTerminals();
activeSession = s.id;
renderTabs();
renderTerminal();
// Resize observer // Resize observer
resizeObserver = new ResizeObserver(() => { resizeObserver = new ResizeObserver(() => {
const session = sessions.find(s => s.id === activeSession); const session = sessions.find(s => s.terminalId === activeTerminalId);
if (session) { if (session) {
try { session.fitAddon.fit(); } catch (e) {} try { session.fitAddon.fit(); } catch (e) {}
} }
@ -256,19 +329,20 @@
resizeObserver.disconnect(); resizeObserver.disconnect();
resizeObserver = null; resizeObserver = null;
} }
// Close WebSockets but do NOT kill PTYs — they persist on server
sessions.forEach(s => { sessions.forEach(s => {
if (s.ws.readyState === WebSocket.OPEN) s.ws.close(); if (s.ws.readyState === WebSocket.OPEN) s.ws.close();
s.term.dispose(); s.term.dispose();
}); });
sessions = []; sessions = [];
sessionCounter = 0; activeTerminalId = null;
container = null; container = null;
termContainer = null; termContainer = null;
tabsContainer = null; tabsContainer = null;
}, },
onFocus() { onFocus() {
const session = sessions.find(s => s.id === activeSession); const session = sessions.find(s => s.terminalId === activeTerminalId);
if (session) { if (session) {
requestAnimationFrame(() => { requestAnimationFrame(() => {
session.fitAddon.fit(); 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,
}));
},
}); });
})(); })();

View file

@ -5,8 +5,8 @@
// ===================================================================== // =====================================================================
// Auth guard // Auth guard
// ===================================================================== // =====================================================================
const TOKEN = sessionStorage.getItem('atlus_token'); const TOKEN = localStorage.getItem('atlus_token');
const USER = sessionStorage.getItem('atlus_user'); const USER = localStorage.getItem('atlus_user');
if (!TOKEN) { if (!TOKEN) {
window.location.href = '/'; window.location.href = '/';
return; return;
@ -33,7 +33,8 @@
} }
const res = await fetch(url, opts); const res = await fetch(url, opts);
if (res.status === 401) { if (res.status === 401) {
sessionStorage.clear(); localStorage.removeItem('atlus_token');
localStorage.removeItem('atlus_user');
window.location.href = '/'; window.location.href = '/';
return; return;
} }
@ -104,6 +105,7 @@
if (app.init) app.init(container); if (app.init) app.init(container);
focusApp(appId); focusApp(appId);
saveDesktopState();
} }
function focusApp(appId) { function focusApp(appId) {
@ -134,6 +136,8 @@
// Notify app it got focus // Notify app it got focus
if (app && app.onFocus) app.onFocus(); if (app && app.onFocus) app.onFocus();
saveDesktopState();
} }
function closeApp(appId) { function closeApp(appId) {
@ -165,6 +169,7 @@
if (welcomeScreen) welcomeScreen.style.display = 'flex'; if (welcomeScreen) welcomeScreen.style.display = 'flex';
} }
} }
saveDesktopState();
} }
function addTab(appId, title) { function addTab(appId, title) {
@ -220,7 +225,8 @@
if (action === 'logout') { if (action === 'logout') {
await Atlus.apiFetch('/api/auth/logout', { method: 'POST' }); await Atlus.apiFetch('/api/auth/logout', { method: 'POST' });
sessionStorage.clear(); localStorage.removeItem('atlus_token');
localStorage.removeItem('atlus_user');
window.location.href = '/'; window.location.href = '/';
} }
}); });
@ -450,6 +456,77 @@
.catch(() => setTimeout(() => attemptReload(attempt + 1), 3000)); .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 // Init
// ===================================================================== // =====================================================================
@ -468,4 +545,8 @@
window.Atlus.openApp = openApp; window.Atlus.openApp = openApp;
window.Atlus.closeApp = closeApp; window.Atlus.closeApp = closeApp;
window.Atlus.focusApp = focusApp; window.Atlus.focusApp = focusApp;
window.Atlus.saveDesktopState = saveDesktopState;
// Restore previous session (after a brief delay to let app modules register)
setTimeout(restoreSession, 100);
})(); })();

View file

@ -3,7 +3,7 @@
'use strict'; 'use strict';
// Redirect to desktop if already authenticated // Redirect to desktop if already authenticated
const token = sessionStorage.getItem('atlus_token'); const token = localStorage.getItem('atlus_token');
if (token) { if (token) {
window.location.href = '/desktop'; window.location.href = '/desktop';
return; return;
@ -37,8 +37,8 @@
} }
const data = await res.json(); const data = await res.json();
sessionStorage.setItem('atlus_token', data.token); localStorage.setItem('atlus_token', data.token);
sessionStorage.setItem('atlus_user', data.username); localStorage.setItem('atlus_user', data.username);
window.location.href = '/desktop'; window.location.href = '/desktop';
} catch (err) { } catch (err) {
errorEl.textContent = err.message; errorEl.textContent = err.message;