diff --git a/backend/main.py b/backend/main.py
index f7eb2fb..98a7ca4 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -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)
diff --git a/backend/routers/session.py b/backend/routers/session.py
new file mode 100644
index 0000000..11a76c1
--- /dev/null
+++ b/backend/routers/session.py
@@ -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}
diff --git a/backend/routers/terminal.py b/backend/routers/terminal.py
index 020e15d..aa0c20f 100644
--- a/backend/routers/terminal.py
+++ b/backend/routers/terminal.py
@@ -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":
- data = msg["data"]
- if isinstance(data, str):
- data = data.encode("utf-8")
- pty.write(data)
+ if terminal.alive:
+ data = msg["data"]
+ if isinstance(data, str):
+ data = data.encode("utf-8")
+ terminal.pty.write(data)
elif msg_type == "resize":
- cols = msg.get("cols", 120)
- rows = msg.get("rows", 30)
- pty.setwinsize(rows, cols)
+ if terminal.alive:
+ cols = msg.get("cols", 120)
+ rows = msg.get("rows", 30)
+ terminal.pty.setwinsize(rows, cols)
except WebSocketDisconnect:
pass
except Exception:
- log.exception("terminal ws error")
+ 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
diff --git a/backend/sessions.py b/backend/sessions.py
new file mode 100644
index 0000000..c2f0dcc
--- /dev/null
+++ b/backend/sessions.py
@@ -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()
diff --git a/frontend/js/apps/terminal.js b/frontend/js/apps/terminal.js
index e2e4bec..39f884c 100644
--- a/frontend/js/apps/terminal.js
+++ b/frontend/js/apps/terminal.js
@@ -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,65 +113,78 @@
}
});
- 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
- Atlus.keyboard.setTerminal({
- input: (data) => {
- if (ws.readyState === WebSocket.OPEN) {
- ws.send(JSON.stringify({ type: 'input', data }));
- }
- },
- });
+ // Connect on-screen keyboard
+ if (Atlus.keyboard) {
+ Atlus.keyboard.setTerminal({
+ input: (data) => {
+ if (ws.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify({ type: 'input', data }));
+ }
+ },
+ });
+ }
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 = `Shell ${s.id}`;
+ tab.className = 'terminal-tab' + (s.terminalId === activeTerminalId ? ' active' : '');
+ tab.innerHTML = `${s.title || 'Shell ' + (i + 1)}`;
if (sessions.length > 1) {
tab.innerHTML += `×`;
}
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,23 +203,56 @@
termContainer.appendChild(wrap);
session.term.open(wrap);
- // Fit after render
requestAnimationFrame(() => {
session.fitAddon.fit();
});
- // Update keyboard terminal ref — only send to PTY
- Atlus.keyboard.setTerminal({
- input: (data) => {
- if (session.ws.readyState === WebSocket.OPEN) {
- session.ws.send(JSON.stringify({ type: 'input', data }));
- }
- },
- });
+ // Update keyboard terminal ref
+ if (Atlus.keyboard) {
+ Atlus.keyboard.setTerminal({
+ input: (data) => {
+ if (session.ws.readyState === WebSocket.OPEN) {
+ session.ws.send(JSON.stringify({ type: 'input', data }));
+ }
+ },
+ });
+ }
}
- // 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
- const kbdEl = Atlus.keyboard.create();
- if (!kbdVisible) kbdEl.classList.add('hidden');
- container.appendChild(kbdEl);
+ 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,
+ }));
+ },
});
})();
diff --git a/frontend/js/atlus.js b/frontend/js/atlus.js
index 18813bd..2c3991e 100644
--- a/frontend/js/atlus.js
+++ b/frontend/js/atlus.js
@@ -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);
})();
diff --git a/frontend/js/auth.js b/frontend/js/auth.js
index 5b6f31f..4b6517d 100644
--- a/frontend/js/auth.js
+++ b/frontend/js/auth.js
@@ -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;