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.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)

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 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
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 () {
'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">&times;</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,
}));
},
});
})();

View file

@ -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);
})();

View file

@ -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;