infra/stacks/terminal/files/index.html

950 lines
42 KiB
HTML

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Terminal</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
<style>
/* Themes — set on <body> as theme-{carbon,slate,mono,ink}. Slate is the default. */
body.theme-carbon {
--bg-page: #0c0c0a;
--bg-sidebar: #131311;
--bg-card: #1a1916;
--bg-card-hover: #22211d;
--text-primary: #e8e3d6;
--text-muted: #8a8474;
--border: #2a2826;
--border-strong: #3a3733;
--accent: #d4a574;
--danger: #c47a5a;
--success: #88a888;
--terminal-bg: #0c0c0a;
--terminal-fg: #e8e3d6;
--terminal-cursor: #d4a574;
--terminal-selection: rgba(212,165,116,0.22);
}
body.theme-slate {
--bg-page: #0d1117;
--bg-sidebar: #11151c;
--bg-card: #161b22;
--bg-card-hover: #1c2128;
--text-primary: #e6e8eb;
--text-muted: #7d8590;
--border: #1f242d;
--border-strong: #30363d;
--accent: #4493f8;
--danger: #f47067;
--success: #56d364;
--terminal-bg: #0d1117;
--terminal-fg: #e6e8eb;
--terminal-cursor: #4493f8;
--terminal-selection: rgba(68,147,248,0.22);
}
body.theme-mono {
--bg-page: #0d0d0d;
--bg-sidebar: #131313;
--bg-card: #1a1a1a;
--bg-card-hover: #232323;
--text-primary: #e0e0e0;
--text-muted: #7d7d7d;
--border: #2a2a2a;
--border-strong: #3a3a3a;
--accent: #e8e8e8;
--danger: #cc7070;
--success: #9ccb9c;
--terminal-bg: #0d0d0d;
--terminal-fg: #e0e0e0;
--terminal-cursor: #e8e8e8;
--terminal-selection: rgba(255,255,255,0.18);
}
body.theme-ink {
--bg-page: #faf7f2;
--bg-sidebar: #f1ebda;
--bg-card: #ffffff;
--bg-card-hover: #f4eee0;
--text-primary: #1d1b18;
--text-muted: #6e6862;
--border: #d8d2c4;
--border-strong: #b8b0a0;
--accent: #b5482d;
--danger: #b5482d;
--success: #3d6b3d;
--terminal-bg: #faf7f2;
--terminal-fg: #1d1b18;
--terminal-cursor: #b5482d;
--terminal-selection: rgba(181,72,45,0.18);
}
html, body {
margin: 0; padding: 0; height: 100%; overflow: hidden;
background: var(--bg-page, #0d1117);
color: var(--text-primary, #e6e8eb);
}
#terminal { height: 100%; width: 100%; }
.hidden { display: none !important; }
#toast {
position: fixed; top: 16px; right: 16px; z-index: 9999;
background: var(--bg-card); color: var(--text-primary);
border: 1px solid var(--border); border-radius: 8px;
padding: 10px 18px; font-family: monospace;
font-size: 14px; opacity: 0; transition: opacity 0.3s;
pointer-events: none; max-width: 500px; word-break: break-all;
}
#toast.visible { opacity: 1; }
#toast.error { color: var(--danger); border-color: var(--danger); }
#toast.success { color: var(--success); border-color: var(--success); }
#paste-btn, #img-btn {
position: fixed; bottom: 24px; z-index: 9999;
width: 48px; height: 48px; border-radius: 12px;
background: color-mix(in srgb, var(--accent) 22%, transparent);
border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent);
color: var(--text-primary); font-size: 22px; cursor: pointer;
display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
transition: background 0.2s, transform 0.1s;
touch-action: manipulation; -webkit-tap-highlight-color: transparent;
}
#paste-btn { right: 24px; }
#img-btn { right: 80px; }
#paste-btn:hover, #img-btn:hover {
background: color-mix(in srgb, var(--accent) 35%, transparent);
}
#paste-btn:active, #img-btn:active { transform: scale(0.92); }
#img-input { display: none; }
/* Lobby */
#lobby {
display: none; height: 100vh; box-sizing: border-box;
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, 'Courier New', monospace;
color: var(--text-primary); background: var(--bg-sidebar);
}
#lobby.visible { display: block; }
#lobby.access-denied { padding: 32px; overflow-y: auto; }
#lobby-shell {
display: grid; grid-template-columns: 260px 1fr; height: 100vh;
}
#lobby-sidebar {
padding: 20px 14px; overflow-y: auto;
border-right: 1px solid var(--border);
background: var(--bg-sidebar); min-width: 0;
display: flex; flex-direction: column;
}
#lobby-content {
position: relative; background: var(--terminal-bg);
min-width: 0; height: 100vh; overflow: hidden;
}
#session-frame { width: 100%; height: 100%; border: 0; display: block; }
#session-frame.hidden { display: none; }
#lobby-empty {
color: var(--text-muted); display: flex; align-items: center; justify-content: center;
height: 100%; font-style: italic; padding: 24px; text-align: center;
}
#lobby-empty.hidden { display: none; }
.lobby-header { font-size: 18px; color: var(--accent); margin: 0 0 4px 0; font-weight: 600; }
.lobby-sub { color: var(--text-muted); font-size: 12px; margin: 0 0 18px 0; line-height: 1.5; }
.new-row { display: flex; flex-direction: column; gap: 6px; margin-bottom: 18px; }
.new-row input {
box-sizing: border-box; padding: 8px 12px; border-radius: 8px;
border: 1px solid var(--border); background: var(--bg-card);
color: var(--text-primary); font-family: inherit; font-size: 13px;
}
.new-row input:focus { outline: none; border-color: var(--accent); }
.new-row button {
padding: 8px 12px; border-radius: 8px;
border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent);
background: color-mix(in srgb, var(--accent) 18%, transparent);
color: var(--text-primary); font-family: inherit; font-size: 13px; cursor: pointer;
}
.new-row button:hover {
background: color-mix(in srgb, var(--accent) 30%, transparent);
}
.session-list { display: flex; flex-direction: column; gap: 6px; }
.session-card {
display: flex; align-items: center; justify-content: space-between;
background: var(--bg-card); border: 1px solid var(--border);
border-left: 3px solid transparent; border-radius: 8px;
padding: 10px 12px; cursor: pointer;
transition: background 0.15s, border-color 0.15s;
font-family: inherit; color: var(--text-primary); text-align: left;
}
.session-card:hover { background: var(--bg-card-hover); }
.session-card:focus-visible {
outline: 2px solid color-mix(in srgb, var(--accent) 60%, transparent);
outline-offset: -2px;
}
.session-card.active {
background: var(--bg-card-hover);
border-color: color-mix(in srgb, var(--accent) 50%, transparent);
border-left-color: var(--accent);
}
.session-meta {
display: flex; flex-direction: column; gap: 2px;
min-width: 0; flex: 1;
}
.session-name {
font-size: 14px; font-weight: 600; color: var(--text-primary);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.session-detail { font-size: 11px; color: var(--text-muted); }
.session-detail.attached { color: var(--success); }
.session-actions { display: flex; gap: 6px; flex-shrink: 0; }
.session-actions button {
padding: 4px 8px; border-radius: 6px; border: 1px solid var(--border);
background: var(--bg-card-hover); color: var(--text-primary);
font-family: inherit; font-size: 11px; cursor: pointer;
}
.session-actions button.rename {
color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 45%, transparent);
}
.session-actions button.kill {
color: var(--danger);
border-color: color-mix(in srgb, var(--danger) 45%, transparent);
}
.session-actions button:hover { filter: brightness(1.25); }
.session-card.dragging { opacity: 0.4; cursor: grabbing; }
.empty { color: var(--text-muted); font-style: italic; padding: 12px 0; font-size: 12px; }
/* Theme picker — sits at the bottom of the sidebar */
.theme-picker {
margin-top: auto; padding-top: 14px;
border-top: 1px solid var(--border);
font-size: 11px; color: var(--text-muted);
}
.theme-picker-label {
display: block; margin-bottom: 6px;
text-transform: uppercase; letter-spacing: 0.06em;
}
.theme-options { display: flex; gap: 4px; }
.theme-options button {
flex: 1; padding: 5px 4px; border-radius: 6px;
background: var(--bg-card); color: var(--text-muted);
border: 1px solid var(--border); font-family: inherit;
font-size: 11px; cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.theme-options button:hover {
background: var(--bg-card-hover); color: var(--text-primary);
}
.theme-options button.active {
border-color: var(--accent); color: var(--accent);
background: var(--bg-card-hover);
}
@media (max-width: 720px) {
#lobby-shell {
grid-template-columns: 1fr;
grid-template-rows: auto 60vh;
}
#lobby-sidebar {
border-right: 0; border-bottom: 1px solid var(--border);
max-height: 40vh;
}
#lobby-content { height: 60vh; }
}
</style>
</head>
<body>
<div id="terminal"></div>
<div id="lobby">
<div id="lobby-shell">
<aside id="lobby-sidebar">
<h1 class="lobby-header">tmux sessions</h1>
<p class="lobby-sub">Pick a session or create one. Sessions persist after you close the tab.</p>
<div class="new-row">
<input id="new-name" type="text" placeholder="new session name" maxlength="32" autocomplete="off">
<button id="new-btn">Create &amp; Open</button>
</div>
<div id="session-list" class="session-list"></div>
<div id="theme-picker" class="theme-picker">
<span class="theme-picker-label">Theme</span>
<div class="theme-options">
<button type="button" data-theme="carbon">Carbon</button>
<button type="button" data-theme="slate">Slate</button>
<button type="button" data-theme="mono">Mono</button>
<button type="button" data-theme="ink">Ink</button>
</div>
</div>
</aside>
<main id="lobby-content">
<div id="lobby-empty">Pick a session, or create one above.</div>
<iframe id="session-frame" class="hidden" referrerpolicy="same-origin" allow="clipboard-read; clipboard-write" title="terminal session"></iframe>
</main>
</div>
</div>
<div id="toast"></div>
<button id="img-btn" title="Upload image">&#128247;</button>
<button id="paste-btn" title="Paste from clipboard">&#128203;</button>
<input type="file" id="img-input" accept="image/*">
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-webgl@0.18.0/lib/addon-webgl.min.js"></script>
<script>
(async function() {
const NAME_RE = /^[a-zA-Z0-9_-]{1,32}$/;
const SESSIONS_API = '/api/sessions/sessions';
const WHOAMI_API = '/api/sessions/whoami';
const params = new URLSearchParams(location.search);
const rawArg = params.get('arg');
const validArg = rawArg && NAME_RE.test(rawArg) ? rawArg : null;
// Theme — shared by lobby + terminal-mode iframe (same-origin localStorage).
const THEMES = ['carbon', 'slate', 'mono', 'ink'];
const THEME_KEY = 'tmux-theme';
function getTheme() {
try {
const t = localStorage.getItem(THEME_KEY);
return THEMES.includes(t) ? t : 'slate';
} catch (e) { return 'slate'; }
}
function applyTheme(t) {
THEMES.forEach(x => document.body.classList.remove('theme-' + x));
document.body.classList.add('theme-' + t);
}
function readTerminalTheme() {
const cs = getComputedStyle(document.body);
const v = k => cs.getPropertyValue(k).trim();
return {
background: v('--terminal-bg') || '#0d1117',
foreground: v('--terminal-fg') || '#e6e8eb',
cursor: v('--terminal-cursor') || '#4493f8',
selectionBackground: v('--terminal-selection') || 'rgba(68,147,248,0.22)'
};
}
applyTheme(getTheme());
function showToast(msg, type, duration) {
const el = document.getElementById('toast');
el.textContent = msg;
el.className = 'visible' + (type ? ' ' + type : '');
clearTimeout(el._timer);
el._timer = setTimeout(() => { el.className = ''; }, duration || 3000);
}
function clearChildren(el) {
while (el.firstChild) el.removeChild(el.firstChild);
}
function emptyState(text) {
const e = document.createElement('div');
e.className = 'empty';
e.textContent = text;
return e;
}
function showAccessDenied(detail) {
document.getElementById('terminal').classList.add('hidden');
document.getElementById('paste-btn').classList.add('hidden');
document.getElementById('img-btn').classList.add('hidden');
const lobby = document.getElementById('lobby');
clearChildren(lobby);
const h = document.createElement('h1');
h.className = 'lobby-header';
h.textContent = 'Access denied';
lobby.appendChild(h);
const p = document.createElement('p');
p.className = 'lobby-sub';
p.textContent = detail || 'You do not have a terminal account on this server.';
lobby.appendChild(p);
const p2 = document.createElement('p');
p2.className = 'lobby-sub';
p2.textContent = 'Authentik logs you in; access here requires an OS-user mapping in /etc/ttyd-user-map. Ask Viktor to add one.';
lobby.appendChild(p2);
lobby.classList.add('visible', 'access-denied');
document.title = 'access denied';
}
// Preflight: ask tmux-api who we are. 403 = unmapped Authentik user → deny.
let whoami = null;
try {
const resp = await fetch(WHOAMI_API, { credentials: 'same-origin' });
if (resp.status === 401 || resp.status === 403) {
showAccessDenied((await resp.text()).trim());
return;
}
if (!resp.ok) {
showAccessDenied('Preflight failed: HTTP ' + resp.status);
return;
}
whoami = await resp.json();
} catch (err) {
showAccessDenied('Preflight failed: ' + err.message);
return;
}
if (!validArg) {
// ============================================================
// LOBBY MODE — no valid ?arg=, render sidebar + iframe shell
// ============================================================
document.getElementById('terminal').classList.add('hidden');
document.getElementById('paste-btn').classList.add('hidden');
document.getElementById('img-btn').classList.add('hidden');
document.getElementById('lobby').classList.add('visible');
const baseTitle = 'tmux sessions (' + whoami.osUser + ')';
document.title = baseTitle;
document.querySelector('#lobby .lobby-sub').textContent =
'Logged in as ' + whoami.osUser + ' (' + whoami.authentik + '). Sessions are kernel-isolated per Unix user; you only see your own.';
const listEl = document.getElementById('session-list');
const newNameEl = document.getElementById('new-name');
const newBtnEl = document.getElementById('new-btn');
const frameEl = document.getElementById('session-frame');
const emptyEl = document.getElementById('lobby-empty');
let currentActive = null;
let dragSrcCard = null;
let isDragging = false;
const ORDER_KEY = 'tmux-session-order-' + whoami.osUser;
function relativeTime(epochSec) {
if (!epochSec) return '';
const diff = Math.floor(Date.now() / 1000) - epochSec;
if (diff < 60) return diff + 's ago';
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
return Math.floor(diff / 86400) + 'd ago';
}
function getSavedOrder() {
try {
const raw = localStorage.getItem(ORDER_KEY);
if (!raw) return [];
const arr = JSON.parse(raw);
return Array.isArray(arr) ? arr.filter(n => typeof n === 'string') : [];
} catch (e) { return []; }
}
function saveOrder(names) {
try { localStorage.setItem(ORDER_KEY, JSON.stringify(names)); } catch (e) {}
}
function captureOrderFromDom() {
return Array.from(listEl.querySelectorAll('.session-card')).map(c => c.dataset.name);
}
function applySavedOrder(sessions) {
const saved = getSavedOrder();
const byName = new Map(sessions.map(s => [s.name, s]));
const out = [];
for (const name of saved) {
const s = byName.get(name);
if (s) { out.push(s); byName.delete(name); }
}
for (const s of sessions) {
if (byName.has(s.name)) out.push(s);
}
return out;
}
function renameInOrder(oldName, newName) {
const order = getSavedOrder();
const i = order.indexOf(oldName);
if (i < 0) return;
order[i] = newName;
saveOrder(order);
}
function updateActiveCard() {
listEl.querySelectorAll('.session-card').forEach(c => {
if (c.dataset.name === currentActive) c.classList.add('active');
else c.classList.remove('active');
});
}
function activateSession(name) {
if (!NAME_RE.test(name)) { showToast('Invalid name', 'error'); return; }
if (currentActive === name) { updateActiveCard(); return; }
currentActive = name;
emptyEl.classList.add('hidden');
frameEl.classList.remove('hidden');
frameEl.src = '/?arg=' + encodeURIComponent(name);
if (location.hash.slice(1) !== name) {
history.replaceState(null, '', '#' + name);
}
document.title = 'tmux: ' + whoami.osUser + '/' + name;
updateActiveCard();
}
function deactivateSession() {
if (!currentActive && frameEl.classList.contains('hidden')) return;
currentActive = null;
frameEl.classList.add('hidden');
emptyEl.classList.remove('hidden');
frameEl.src = 'about:blank';
if (location.hash) {
history.replaceState(null, '', location.pathname + location.search);
}
document.title = baseTitle;
updateActiveCard();
}
async function killSession(name) {
if (!NAME_RE.test(name)) { showToast('Invalid name', 'error'); return; }
if (!confirm('Kill tmux session "' + name + '"? Any running processes inside it will be terminated.')) return;
try {
const resp = await fetch(SESSIONS_API + '/' + encodeURIComponent(name), {
method: 'DELETE', credentials: 'same-origin'
});
if (resp.ok) {
showToast('Killed ' + name, 'success');
} else if (resp.status === 404) {
showToast('Session not found', 'error');
} else {
showToast('Kill failed: HTTP ' + resp.status, 'error');
}
if (currentActive === name) deactivateSession();
renderLobby();
} catch (err) {
showToast('Kill error: ' + err.message, 'error');
}
}
async function renameSession(oldName) {
if (!NAME_RE.test(oldName)) { showToast('Invalid name', 'error'); return; }
const input = prompt('Rename "' + oldName + '" to:', oldName);
if (input === null) return;
const newName = input.trim();
if (!NAME_RE.test(newName)) {
showToast('Name must match ^[a-zA-Z0-9_-]{1,32}$', 'error', 4000);
return;
}
if (newName === oldName) return;
try {
const resp = await fetch(SESSIONS_API + '/' + encodeURIComponent(oldName) + '/rename', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName })
});
if (resp.status === 204) {
renameInOrder(oldName, newName);
showToast('Renamed to ' + newName, 'success');
if (currentActive === oldName) {
currentActive = newName;
frameEl.src = '/?arg=' + encodeURIComponent(newName);
history.replaceState(null, '', '#' + newName);
document.title = 'tmux: ' + whoami.osUser + '/' + newName;
}
renderLobby();
} else if (resp.status === 404) {
showToast('Session not found', 'error');
} else if (resp.status === 409) {
showToast('Name "' + newName + '" already taken', 'error', 4000);
} else if (resp.status === 400) {
showToast('Invalid name', 'error');
} else {
showToast('Rename failed: HTTP ' + resp.status, 'error');
}
} catch (err) {
showToast('Rename error: ' + err.message, 'error');
}
}
function renderCard(s) {
const card = document.createElement('div');
card.className = 'session-card';
card.dataset.name = s.name;
card.setAttribute('role', 'button');
card.setAttribute('tabindex', '0');
card.setAttribute('aria-label', 'Activate session ' + s.name);
card.draggable = true;
if (s.name === currentActive) card.classList.add('active');
const meta = document.createElement('div');
meta.className = 'session-meta';
const name = document.createElement('div');
name.className = 'session-name';
name.textContent = s.name;
meta.appendChild(name);
const detail = document.createElement('div');
detail.className = 'session-detail' + (s.attached > 0 ? ' attached' : '');
const attachedText = s.attached > 0 ? (s.attached + ' attached') : 'idle';
detail.textContent = attachedText + ' · ' + relativeTime(s.lastActivity);
meta.appendChild(detail);
card.appendChild(meta);
const actions = document.createElement('div');
actions.className = 'session-actions';
const renameBtn = document.createElement('button');
renameBtn.className = 'rename';
renameBtn.textContent = 'Rename';
renameBtn.draggable = false;
renameBtn.addEventListener('click', (e) => {
e.stopPropagation();
renameSession(s.name);
});
actions.appendChild(renameBtn);
const killBtn = document.createElement('button');
killBtn.className = 'kill';
killBtn.textContent = 'Kill';
killBtn.draggable = false;
killBtn.addEventListener('click', (e) => {
e.stopPropagation();
killSession(s.name);
});
actions.appendChild(killBtn);
card.appendChild(actions);
card.addEventListener('click', () => activateSession(s.name));
card.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
activateSession(s.name);
}
});
card.addEventListener('dragstart', (e) => {
dragSrcCard = card;
isDragging = true;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', s.name);
card.classList.add('dragging');
});
card.addEventListener('dragend', () => {
card.classList.remove('dragging');
if (dragSrcCard === card) saveOrder(captureOrderFromDom());
dragSrcCard = null;
isDragging = false;
});
card.addEventListener('dragover', (e) => {
if (!dragSrcCard || dragSrcCard === card) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const rect = card.getBoundingClientRect();
const after = (e.clientY - rect.top) > rect.height / 2;
if (after) card.after(dragSrcCard);
else card.before(dragSrcCard);
});
return card;
}
async function renderLobby() {
if (isDragging) return;
try {
const resp = await fetch(SESSIONS_API, { credentials: 'same-origin' });
if (!resp.ok) {
clearChildren(listEl);
listEl.appendChild(emptyState('Failed to load sessions: HTTP ' + resp.status));
return;
}
const sessions = await resp.json();
if (currentActive && !sessions.some(s => s.name === currentActive)) {
deactivateSession();
}
clearChildren(listEl);
if (sessions.length === 0) {
listEl.appendChild(emptyState('No sessions yet.'));
return;
}
const ordered = applySavedOrder(sessions);
for (const s of ordered) listEl.appendChild(renderCard(s));
} catch (err) {
clearChildren(listEl);
listEl.appendChild(emptyState('Error: ' + err.message));
}
}
newBtnEl.addEventListener('click', () => {
const name = newNameEl.value.trim();
if (!NAME_RE.test(name)) {
showToast('Name must match ^[a-zA-Z0-9_-]{1,32}$', 'error', 4000);
return;
}
newNameEl.value = '';
activateSession(name);
});
newNameEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter') newBtnEl.click();
});
window.addEventListener('hashchange', () => {
const hash = location.hash.slice(1);
if (hash && NAME_RE.test(hash)) {
if (hash !== currentActive) activateSession(hash);
} else if (currentActive) {
deactivateSession();
}
});
// Theme picker — clicking a swatch swaps body class, persists, and
// reloads the iframe so the inner terminal re-themes too.
const themePickerEl = document.getElementById('theme-picker');
function paintThemeButtons() {
const active = getTheme();
themePickerEl.querySelectorAll('button[data-theme]').forEach(b => {
b.classList.toggle('active', b.dataset.theme === active);
});
}
themePickerEl.querySelectorAll('button[data-theme]').forEach(btn => {
btn.addEventListener('click', () => {
const t = btn.dataset.theme;
if (!THEMES.includes(t)) return;
try { localStorage.setItem(THEME_KEY, t); } catch (e) {}
applyTheme(t);
paintThemeButtons();
if (currentActive && frameEl.src) {
// Reload iframe so xterm picks up the new theme.
const url = frameEl.src;
frameEl.src = 'about:blank';
setTimeout(() => { frameEl.src = url; }, 0);
}
});
});
paintThemeButtons();
renderLobby();
setInterval(renderLobby, 5000);
const initialHash = location.hash.slice(1);
if (initialHash && NAME_RE.test(initialHash)) {
activateSession(initialHash);
}
return;
}
// ============================================================
// TERMINAL MODE — valid ?arg=<session>, attach via ttyd
// ============================================================
// ttyd binary protocol
const MSG_OUTPUT = 0x30; // '0' - terminal output
const MSG_SET_PREFS = 0x31; // '1' - JSON preferences
const MSG_SET_TITLE = 0x32; // '2' - window title
const MSG_INPUT = '0'; // client → server: input data
const MSG_RESIZE = '1'; // client → server: {columns, rows}
let ws = null;
const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();
const term = new Terminal({
cursorBlink: true,
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, 'Courier New', monospace",
fontSize: 15,
theme: readTerminalTheme(),
allowProposedApi: true
});
const fitAddon = new FitAddon.FitAddon();
const webLinksAddon = new WebLinksAddon.WebLinksAddon();
term.loadAddon(fitAddon);
term.loadAddon(webLinksAddon);
try {
const webglAddon = new WebglAddon.WebglAddon();
webglAddon.onContextLoss(() => { webglAddon.dispose(); });
term.loadAddon(webglAddon);
} catch (e) {
console.warn('WebGL addon failed:', e);
}
term.open(document.getElementById('terminal'));
fitAddon.fit();
document.title = 'tmux: ' + whoami.osUser + '/' + validArg;
function sendInput(data) {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
const payload = textEncoder.encode(data);
const buf = new Uint8Array(payload.length + 1);
buf[0] = MSG_INPUT.charCodeAt(0);
buf.set(payload, 1);
ws.send(buf.buffer);
}
function sendResize() {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
const json = JSON.stringify({ columns: term.cols, rows: term.rows });
const payload = textEncoder.encode(json);
const buf = new Uint8Array(payload.length + 1);
buf[0] = MSG_RESIZE.charCodeAt(0);
buf.set(payload, 1);
ws.send(buf.buffer);
}
term.onData(sendInput);
term.onBinary((data) => {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
const bytes = new Uint8Array(data.length + 1);
bytes[0] = MSG_INPUT.charCodeAt(0);
for (let i = 0; i < data.length; i++) bytes[i + 1] = data.charCodeAt(i);
ws.send(bytes.buffer);
});
term.onResize(() => sendResize());
window.addEventListener('resize', () => fitAddon.fit());
term.attachCustomKeyEventHandler((e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'c' && term.hasSelection()) {
navigator.clipboard.writeText(term.getSelection());
return false;
}
if ((e.ctrlKey || e.metaKey) && e.key === 'v') {
return false; // let browser paste event fire
}
return true;
});
document.addEventListener('paste', async (e) => {
const items = e.clipboardData?.items;
if (!items) return;
for (const item of items) {
if (item.type.startsWith('image/')) {
e.preventDefault();
e.stopPropagation();
const blob = item.getAsFile();
if (!blob) { showToast('Failed to read image', 'error'); return; }
showToast('Uploading image...', '');
try {
const formData = new FormData();
formData.append('image', blob);
const resp = await fetch('/clipboard/upload', { method: 'POST', body: formData });
if (!resp.ok) { showToast('Upload failed: ' + await resp.text(), 'error', 5000); return; }
const { path } = await resp.json();
sendInput(path);
showToast('Pasted: ' + path, 'success', 4000);
} catch (err) {
showToast('Upload error: ' + err.message, 'error', 5000);
}
return;
}
}
const text = e.clipboardData.getData('text');
if (text) {
e.preventDefault();
sendInput(text);
}
}, true);
document.getElementById('paste-btn').addEventListener('click', async () => {
try {
if (navigator.clipboard.read) {
const items = await navigator.clipboard.read();
for (const item of items) {
const imageType = item.types.find(t => t.startsWith('image/'));
if (imageType) {
const blob = await item.getType(imageType);
showToast('Uploading image...', '');
const formData = new FormData();
formData.append('image', blob);
const resp = await fetch('/clipboard/upload', { method: 'POST', body: formData });
if (!resp.ok) { showToast('Upload failed: ' + await resp.text(), 'error', 5000); return; }
const { path } = await resp.json();
sendInput(path);
showToast('Pasted: ' + path, 'success', 4000);
return;
}
if (item.types.includes('text/plain')) {
const blob = await item.getType('text/plain');
const text = await blob.text();
if (text) { sendInput(text); return; }
}
}
} else {
const text = await navigator.clipboard.readText();
if (text) sendInput(text);
}
} catch (err) {
showToast('Clipboard access denied', 'error', 3000);
console.error('Clipboard read failed:', err);
}
term.focus();
});
document.getElementById('img-btn').addEventListener('click', () => {
document.getElementById('img-input').click();
});
document.getElementById('img-input').addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
e.target.value = ''; // reset so same file can be re-selected
showToast('Uploading image...', '');
try {
const formData = new FormData();
formData.append('image', file);
const resp = await fetch('/clipboard/upload', { method: 'POST', body: formData });
if (!resp.ok) { showToast('Upload failed: ' + await resp.text(), 'error', 5000); return; }
const { path } = await resp.json();
sendInput(path);
showToast('Pasted: ' + path, 'success', 4000);
} catch (err) {
showToast('Upload error: ' + err.message, 'error', 5000);
}
term.focus();
});
function connect() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const argSuffix = '?arg=' + encodeURIComponent(validArg);
const base = location.pathname.replace(/\/+$/, '');
const wsUrl = proto + '//' + location.host + base + '/ws' + argSuffix;
fetch(base + '/token' + argSuffix, { credentials: 'same-origin' })
.then(r => r.json())
.then(tokenData => {
const token = tokenData.token || '';
ws = new WebSocket(wsUrl, ['tty']);
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
console.log('Connected to ttyd (session: ' + validArg + ')');
const initMsg = JSON.stringify({
AuthToken: token,
columns: term.cols,
rows: term.rows
});
ws.send(initMsg);
};
ws.onmessage = (event) => {
const data = event.data;
if (data instanceof ArrayBuffer) {
const view = new Uint8Array(data);
if (view.length < 1) return;
const msgType = view[0];
const payload = view.slice(1);
switch (msgType) {
case MSG_OUTPUT:
term.write(payload);
break;
case MSG_SET_PREFS:
try {
const prefs = JSON.parse(textDecoder.decode(payload));
console.log('ttyd prefs:', prefs);
} catch (e) {}
break;
case MSG_SET_TITLE:
document.title = textDecoder.decode(payload);
break;
}
}
};
ws.onclose = () => {
term.write('\r\n\x1b[31mDisconnected. Reconnecting...\x1b[0m\r\n');
setTimeout(connect, 3000);
};
ws.onerror = (e) => console.error('WebSocket error:', e);
})
.catch(err => {
console.error('Token fetch failed:', err);
term.write('\r\n\x1b[31mFailed to connect. Retrying...\x1b[0m\r\n');
setTimeout(connect, 3000);
});
}
connect();
})();
</script>
</body>
</html>