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;