PTY terminals now survive browser refresh and close. Session manager owns PTY lifecycle independently of WebSocket connections, with background readers storing scrollback for replay on reconnect. Desktop state (open apps, active app, terminal tabs) persists server-side and restores automatically on login. Auth tokens moved to localStorage. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
362 lines
12 KiB
JavaScript
362 lines
12 KiB
JavaScript
/* Atlus — Terminal app (xterm.js + persistent PTY sessions) */
|
|
(function () {
|
|
'use strict';
|
|
|
|
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;
|
|
}
|
|
|
|
const term = new Terminal({
|
|
cursorBlink: true,
|
|
fontSize: 14,
|
|
fontFamily: "'IBM Plex Mono', monospace",
|
|
theme: {
|
|
background: '#111318',
|
|
foreground: '#c8ccd8',
|
|
cursor: '#6ea6f0',
|
|
selectionBackground: 'rgba(110, 166, 240, 0.3)',
|
|
black: '#0d0f14',
|
|
red: '#e05a4a',
|
|
green: '#3ab86a',
|
|
yellow: '#e09a2a',
|
|
blue: '#6ea6f0',
|
|
magenta: '#c678dd',
|
|
cyan: '#56b6c2',
|
|
white: '#c8ccd8',
|
|
brightBlack: '#4a5068',
|
|
brightRed: '#e05a4a',
|
|
brightGreen: '#3ab86a',
|
|
brightYellow: '#e09a2a',
|
|
brightBlue: '#6ea6f0',
|
|
brightMagenta: '#c678dd',
|
|
brightCyan: '#56b6c2',
|
|
brightWhite: '#ffffff',
|
|
},
|
|
allowProposedApi: true,
|
|
});
|
|
|
|
const fitAddon = new FitAddon.FitAddon();
|
|
term.loadAddon(fitAddon);
|
|
|
|
// Connect WebSocket to the persistent terminal
|
|
const wsUrl = Atlus.wsUrl(`/api/terminal/ws?terminal_id=${terminalId}`);
|
|
const ws = new WebSocket(wsUrl);
|
|
|
|
ws.onopen = () => {
|
|
setTimeout(() => {
|
|
fitAddon.fit();
|
|
ws.send(JSON.stringify({
|
|
type: 'resize',
|
|
cols: term.cols,
|
|
rows: term.rows,
|
|
}));
|
|
}, 100);
|
|
};
|
|
|
|
ws.onmessage = (e) => {
|
|
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[33m[Disconnected — refresh to reconnect]\x1b[0m\r\n');
|
|
};
|
|
|
|
// Send input to PTY
|
|
term.onData((data) => {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ type: 'input', data }));
|
|
}
|
|
});
|
|
|
|
// Handle resize
|
|
term.onResize(({ cols, rows }) => {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ type: 'resize', cols, rows }));
|
|
}
|
|
});
|
|
|
|
const session = { terminalId, ws, term, fitAddon, title: title || 'Shell' };
|
|
sessions.push(session);
|
|
|
|
// 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(terminalId) {
|
|
activeTerminalId = terminalId;
|
|
renderTabs();
|
|
renderTerminal();
|
|
if (Atlus.saveDesktopState) Atlus.saveDesktopState();
|
|
}
|
|
|
|
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 (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, i) => {
|
|
const tab = document.createElement('button');
|
|
tab.className = 'terminal-tab' + (s.terminalId === activeTerminalId ? ' active' : '');
|
|
tab.innerHTML = `<span>${s.title || 'Shell ' + (i + 1)}</span>`;
|
|
if (sessions.length > 1) {
|
|
tab.innerHTML += `<span class="terminal-tab-close">×</span>`;
|
|
}
|
|
tab.addEventListener('click', (e) => {
|
|
if (e.target.classList.contains('terminal-tab-close')) {
|
|
closeSession(s.terminalId);
|
|
} else {
|
|
switchSession(s.terminalId);
|
|
}
|
|
});
|
|
tabsContainer.appendChild(tab);
|
|
});
|
|
}
|
|
|
|
function renderTerminal() {
|
|
if (!termContainer) return;
|
|
termContainer.innerHTML = '';
|
|
|
|
const session = sessions.find(s => s.terminalId === activeTerminalId);
|
|
if (!session) return;
|
|
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'terminal-container';
|
|
termContainer.appendChild(wrap);
|
|
session.term.open(wrap);
|
|
|
|
requestAnimationFrame(() => {
|
|
session.fitAddon.fit();
|
|
});
|
|
|
|
// 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 }));
|
|
}
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
// ---- 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',
|
|
|
|
init(el) {
|
|
container = el;
|
|
container.classList.add('app-terminal');
|
|
|
|
// Toolbar
|
|
const toolbar = document.createElement('div');
|
|
toolbar.className = 'terminal-toolbar';
|
|
|
|
tabsContainer = document.createElement('div');
|
|
tabsContainer.className = 'terminal-tabs';
|
|
toolbar.appendChild(tabsContainer);
|
|
|
|
const newTabBtn = document.createElement('button');
|
|
newTabBtn.className = 'terminal-new-tab';
|
|
newTabBtn.textContent = '+';
|
|
newTabBtn.title = 'New tab';
|
|
newTabBtn.addEventListener('click', async () => {
|
|
const s = await createNewTerminal();
|
|
if (s) switchSession(s.terminalId);
|
|
});
|
|
toolbar.appendChild(newTabBtn);
|
|
|
|
const kbdBtn = document.createElement('button');
|
|
kbdBtn.className = 'terminal-kbd-toggle' + (kbdVisible ? ' active' : '');
|
|
kbdBtn.textContent = '⌨';
|
|
kbdBtn.title = 'Toggle keyboard';
|
|
kbdBtn.addEventListener('click', () => {
|
|
kbdVisible = !kbdVisible;
|
|
kbdBtn.classList.toggle('active', kbdVisible);
|
|
if (Atlus.keyboard) Atlus.keyboard.toggle();
|
|
const session = sessions.find(s => s.terminalId === activeTerminalId);
|
|
if (session) {
|
|
requestAnimationFrame(() => session.fitAddon.fit());
|
|
}
|
|
});
|
|
toolbar.appendChild(kbdBtn);
|
|
|
|
container.appendChild(toolbar);
|
|
|
|
// Terminal area
|
|
termContainer = document.createElement('div');
|
|
termContainer.style.flex = '1';
|
|
termContainer.style.overflow = 'hidden';
|
|
termContainer.style.display = 'flex';
|
|
termContainer.style.flexDirection = 'column';
|
|
container.appendChild(termContainer);
|
|
|
|
// On-screen keyboard
|
|
if (Atlus.keyboard) {
|
|
const kbdEl = Atlus.keyboard.create();
|
|
if (!kbdVisible) kbdEl.classList.add('hidden');
|
|
container.appendChild(kbdEl);
|
|
}
|
|
|
|
// Initialize — restore or create terminals
|
|
initTerminals();
|
|
|
|
// Resize observer
|
|
resizeObserver = new ResizeObserver(() => {
|
|
const session = sessions.find(s => s.terminalId === activeTerminalId);
|
|
if (session) {
|
|
try { session.fitAddon.fit(); } catch (e) {}
|
|
}
|
|
});
|
|
resizeObserver.observe(termContainer);
|
|
},
|
|
|
|
destroy() {
|
|
if (resizeObserver) {
|
|
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 = [];
|
|
activeTerminalId = null;
|
|
container = null;
|
|
termContainer = null;
|
|
tabsContainer = null;
|
|
},
|
|
|
|
onFocus() {
|
|
const session = sessions.find(s => s.terminalId === activeTerminalId);
|
|
if (session) {
|
|
requestAnimationFrame(() => {
|
|
session.fitAddon.fit();
|
|
session.term.focus();
|
|
});
|
|
}
|
|
},
|
|
|
|
/** Public API: get terminal IDs for session state saving */
|
|
getTerminalIds() {
|
|
return sessions.map(s => ({
|
|
terminal_id: s.terminalId,
|
|
title: s.title,
|
|
}));
|
|
},
|
|
});
|
|
})();
|