From 220a5234cdfc6d3351c049c731842cf1fb707833 Mon Sep 17 00:00:00 2001 From: roberts Date: Sat, 14 Mar 2026 21:41:28 -0500 Subject: [PATCH] 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 --- backend/main.py | 22 ++- backend/routers/session.py | 79 +++++++++ backend/routers/terminal.py | 126 ++++++-------- backend/sessions.py | 313 +++++++++++++++++++++++++++++++++++ frontend/js/apps/terminal.js | 200 +++++++++++++++------- frontend/js/atlus.js | 89 +++++++++- frontend/js/auth.js | 6 +- 7 files changed, 689 insertions(+), 146 deletions(-) create mode 100644 backend/routers/session.py create mode 100644 backend/sessions.py 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;