448 lines
17 KiB
JavaScript
448 lines
17 KiB
JavaScript
|
|
// ── Session context (injected as inline <script> in shell.html) ─────
|
||
|
|
// SESSION_ID and AUTHED are defined before this file loads.
|
||
|
|
|
||
|
|
// ── Terminal theme ──────────────────────────────────────────────────
|
||
|
|
const TERM_THEME = {
|
||
|
|
background: '#0d0f14', foreground: '#e2e8f0',
|
||
|
|
cursor: '#6ee7b7', cursorAccent: '#0d0f14',
|
||
|
|
selectionBackground: 'rgba(110,231,183,0.25)',
|
||
|
|
};
|
||
|
|
|
||
|
|
// ── Global state ─────────────────────────────────────────────────────
|
||
|
|
let tabs = [];
|
||
|
|
let activeTab = null;
|
||
|
|
let tabCounter = 0;
|
||
|
|
// Runtime auth flag — AUTHED is the page-load value; this tracks whether
|
||
|
|
// the user has authenticated in THIS session (including via the auth modal
|
||
|
|
// after page load, where AUTHED remains false but we are now connected).
|
||
|
|
let isAuthenticated = AUTHED;
|
||
|
|
|
||
|
|
// ── Helpers ──────────────────────────────────────────────────────────
|
||
|
|
function randHexClient(n) {
|
||
|
|
const arr = new Uint8Array(n);
|
||
|
|
crypto.getRandomValues(arr);
|
||
|
|
return Array.from(arr, b => b.toString(16).padStart(2, '0')).join('');
|
||
|
|
}
|
||
|
|
|
||
|
|
function pasteToTerminal() {
|
||
|
|
navigator.clipboard.readText().then(
|
||
|
|
text => { if (text && activeTab) activeTab.term.paste(text); },
|
||
|
|
() => toast('Clipboard access denied — allow it in the browser address bar', 'err')
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── URL hash: persist tab layout ──────────────────────────────────────
|
||
|
|
//
|
||
|
|
// Format: /s/<firstTabId>#t=id1,id2,id3
|
||
|
|
// When another browser opens this URL it reads the hash and opens the
|
||
|
|
// same sessions. The server only sees /s/<firstTabId> (hash is client-only).
|
||
|
|
function updateURLHash() {
|
||
|
|
if (tabs.length === 0) return;
|
||
|
|
const ids = tabs.map(t => t.id).join(',');
|
||
|
|
history.replaceState(null, '', '/s/' + tabs[0].id + '#t=' + ids);
|
||
|
|
}
|
||
|
|
|
||
|
|
function getTabIDsFromHash() {
|
||
|
|
const hash = location.hash; // e.g. "#t=aabb...,ccdd..."
|
||
|
|
if (!hash.startsWith('#t=')) return null;
|
||
|
|
const ids = hash.slice(3).split(',').filter(id => /^[0-9a-f]{32}$/.test(id));
|
||
|
|
return ids.length ? ids : null;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Global keyboard handler (capture phase) ───────────────────────────
|
||
|
|
document.addEventListener('keydown', function(e) {
|
||
|
|
const C = e.ctrlKey, S = e.shiftKey, A = e.altKey, M = e.metaKey;
|
||
|
|
|
||
|
|
if (A && !C && !M) {
|
||
|
|
// Alt+T → new tab
|
||
|
|
if (!S && e.code === 'KeyT') { e.preventDefault(); newTab(); return; }
|
||
|
|
// Alt+W → close active tab (last tab stays)
|
||
|
|
if (!S && e.code === 'KeyW') { e.preventDefault(); if (activeTab) closeTab(activeTab); return; }
|
||
|
|
// Alt+Shift+← / Alt+Shift+→ → prev / next tab
|
||
|
|
// (plain Alt+← / Alt+→ is word-nav below; Shift disambiguates)
|
||
|
|
if (S && e.code === 'ArrowLeft') {
|
||
|
|
e.preventDefault();
|
||
|
|
const idx = tabs.indexOf(activeTab);
|
||
|
|
if (idx > 0) switchTab(tabs[idx - 1]);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (S && e.code === 'ArrowRight') {
|
||
|
|
e.preventDefault();
|
||
|
|
const idx = tabs.indexOf(activeTab);
|
||
|
|
if (idx < tabs.length - 1) switchTab(tabs[idx + 1]);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
// Alt+← / Alt+→ → readline word-backward / word-forward
|
||
|
|
if (!S && (e.code === 'ArrowLeft' || e.code === 'ArrowRight')) {
|
||
|
|
e.preventDefault();
|
||
|
|
const t = activeTab;
|
||
|
|
if (t && t.socket && t.socket.readyState === WebSocket.OPEN)
|
||
|
|
t.socket.send(e.code === 'ArrowLeft' ? '\x1bb' : '\x1bf');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Block all Ctrl+key browser shortcuts; xterm still receives the event.
|
||
|
|
if (C && !A && !M) {
|
||
|
|
e.preventDefault();
|
||
|
|
if (!S && e.code === 'KeyW') {
|
||
|
|
const t = activeTab;
|
||
|
|
if (t && t.socket && t.socket.readyState === WebSocket.OPEN) t.socket.send('\x17');
|
||
|
|
}
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Block all function keys
|
||
|
|
if (!M && /^F\d+$/.test(e.key)) { e.preventDefault(); return; }
|
||
|
|
|
||
|
|
}, true); // capture phase
|
||
|
|
|
||
|
|
// Chrome processes Ctrl+W before dispatching keydown — last guard.
|
||
|
|
window.addEventListener('beforeunload', function(e) {
|
||
|
|
if (tabs.some(t => t.socket && t.socket.readyState === WebSocket.OPEN)) {
|
||
|
|
e.preventDefault();
|
||
|
|
return e.returnValue = '';
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// ── Toast ─────────────────────────────────────────────────────────────
|
||
|
|
let toastTimer;
|
||
|
|
function toast(msg, type) {
|
||
|
|
const el = document.getElementById('toast');
|
||
|
|
el.textContent = msg;
|
||
|
|
el.className = 'toast show ' + (type || '');
|
||
|
|
clearTimeout(toastTimer);
|
||
|
|
toastTimer = setTimeout(() => { el.className = 'toast'; }, 2200);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Status bar ────────────────────────────────────────────────────────
|
||
|
|
const dotEl = document.getElementById('dot');
|
||
|
|
const labelEl = document.getElementById('statusLabel');
|
||
|
|
|
||
|
|
function setStatus(text, state) {
|
||
|
|
labelEl.textContent = text;
|
||
|
|
dotEl.className = 'dot' + (state === 'ok' ? ' ok' : state === 'err' ? ' err' : '');
|
||
|
|
}
|
||
|
|
|
||
|
|
function updateStatus() {
|
||
|
|
if (!activeTab) return;
|
||
|
|
const s = activeTab.socket;
|
||
|
|
if (!s || s.readyState === WebSocket.CONNECTING) setStatus('connecting...', '');
|
||
|
|
else if (s.readyState === WebSocket.OPEN) setStatus('connected ' + activeTab.id.slice(0, 8) + '...', 'ok');
|
||
|
|
else setStatus('disconnected', 'err');
|
||
|
|
}
|
||
|
|
|
||
|
|
// copyLink encodes all open tab IDs in the URL hash so the recipient
|
||
|
|
// browser opens the same session layout.
|
||
|
|
function copyLink() {
|
||
|
|
const ids = tabs.map(t => t.id).join(',');
|
||
|
|
const url = location.protocol + '//' + location.host +
|
||
|
|
'/s/' + tabs[0].id + '#t=' + ids;
|
||
|
|
navigator.clipboard.writeText(url).then(
|
||
|
|
() => toast('Session link copied!', 'ok'),
|
||
|
|
() => toast('Copy failed', 'err')
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Modal helpers ─────────────────────────────────────────────────────
|
||
|
|
function openModal(id) {
|
||
|
|
document.getElementById(id).classList.remove('hidden');
|
||
|
|
const inp = document.querySelector('#' + id + ' .m-input');
|
||
|
|
if (inp) setTimeout(() => inp.focus(), 60);
|
||
|
|
}
|
||
|
|
function closeModal(id) {
|
||
|
|
document.getElementById(id).classList.add('hidden');
|
||
|
|
if (activeTab) activeTab.term.focus();
|
||
|
|
}
|
||
|
|
function bgClose(evt, id) {
|
||
|
|
if (evt.target.id === id) closeModal(id);
|
||
|
|
}
|
||
|
|
document.addEventListener('keydown', e => {
|
||
|
|
if (e.key === 'Escape') {
|
||
|
|
['upOverlay', 'dlOverlay'].forEach(id => {
|
||
|
|
if (!document.getElementById(id).classList.contains('hidden')) closeModal(id);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// ── Auth ──────────────────────────────────────────────────────────────
|
||
|
|
async function doAuth() {
|
||
|
|
const username = document.getElementById('fUser').value.trim();
|
||
|
|
const password = document.getElementById('fPass').value;
|
||
|
|
const btn = document.getElementById('authBtn');
|
||
|
|
const errDiv = document.getElementById('authErr');
|
||
|
|
|
||
|
|
if (!username || !password) { showAuthErr('Please enter username and password'); return; }
|
||
|
|
|
||
|
|
btn.disabled = true; btn.classList.add('busy');
|
||
|
|
errDiv.classList.remove('show');
|
||
|
|
|
||
|
|
const form = new URLSearchParams();
|
||
|
|
form.append('username', username);
|
||
|
|
form.append('password', password);
|
||
|
|
|
||
|
|
try {
|
||
|
|
const res = await fetch('/auth', { method: 'POST', body: form,
|
||
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' } });
|
||
|
|
const data = await res.json();
|
||
|
|
if (data.ok) {
|
||
|
|
isAuthenticated = true; // runtime flag: future newTab() calls will connect
|
||
|
|
document.getElementById('authOverlay').classList.add('hidden');
|
||
|
|
tabs.forEach(t => { if (!t.socket) connectTab(t); });
|
||
|
|
if (activeTab) activeTab.term.focus();
|
||
|
|
} else {
|
||
|
|
showAuthErr(data.error || 'Authentication failed');
|
||
|
|
}
|
||
|
|
} catch (_) {
|
||
|
|
showAuthErr('Network error — try again');
|
||
|
|
} finally {
|
||
|
|
btn.disabled = false; btn.classList.remove('busy');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function showAuthErr(msg) {
|
||
|
|
const e = document.getElementById('authErr');
|
||
|
|
e.textContent = msg; e.classList.add('show');
|
||
|
|
document.getElementById('fPass').value = '';
|
||
|
|
document.getElementById('fPass').focus();
|
||
|
|
}
|
||
|
|
|
||
|
|
document.getElementById('fUser').addEventListener('keydown', e => {
|
||
|
|
if (e.key === 'Enter') document.getElementById('fPass').focus();
|
||
|
|
});
|
||
|
|
document.getElementById('fPass').addEventListener('keydown', e => {
|
||
|
|
if (e.key === 'Enter') doAuth();
|
||
|
|
});
|
||
|
|
|
||
|
|
// ── Tab management ────────────────────────────────────────────────────
|
||
|
|
//
|
||
|
|
// Each tab:
|
||
|
|
// id 32-char hex session ID (server PTY key)
|
||
|
|
// term xterm.js Terminal
|
||
|
|
// fit FitAddon
|
||
|
|
// pane .term-pane <div>
|
||
|
|
// socket WebSocket /ws/<id>
|
||
|
|
// tabEl .tab-item <div>
|
||
|
|
//
|
||
|
|
// The "+" button lives inside #tabList; new tab elements are inserted
|
||
|
|
// before it so "+" always appears right after the last tab.
|
||
|
|
//
|
||
|
|
// Tab IDs are encoded in the URL hash (#t=id1,id2,...) so the link
|
||
|
|
// button produces a URL that reopens the same sessions in another browser.
|
||
|
|
|
||
|
|
function newTab(sessionId) {
|
||
|
|
const id = sessionId || randHexClient(16);
|
||
|
|
const tabNewBtn = document.getElementById('tabNew');
|
||
|
|
tabCounter++;
|
||
|
|
const tabLabel = 'bash ' + tabCounter;
|
||
|
|
|
||
|
|
// Terminal pane
|
||
|
|
const pane = document.createElement('div');
|
||
|
|
pane.className = 'term-pane';
|
||
|
|
pane.style.display = 'none';
|
||
|
|
document.getElementById('termContainer').appendChild(pane);
|
||
|
|
|
||
|
|
const term = new Terminal({
|
||
|
|
theme: TERM_THEME,
|
||
|
|
cursorBlink: true, fontSize: 14, scrollback: 20000,
|
||
|
|
fontFamily: '"JetBrains Mono","Fira Mono",monospace',
|
||
|
|
});
|
||
|
|
const fit = new FitAddon.FitAddon();
|
||
|
|
term.loadAddon(fit);
|
||
|
|
term.open(pane);
|
||
|
|
|
||
|
|
const tab = { id, label: tabLabel, term, fit, pane, socket: null, tabEl: null };
|
||
|
|
tabs.push(tab);
|
||
|
|
|
||
|
|
// Per-terminal key handler
|
||
|
|
term.attachCustomKeyEventHandler(evt => {
|
||
|
|
if (evt.type !== 'keydown') return true;
|
||
|
|
const C = evt.ctrlKey, S = evt.shiftKey, A = evt.altKey;
|
||
|
|
|
||
|
|
if (C && S && evt.code === 'KeyC') {
|
||
|
|
const sel = term.getSelection();
|
||
|
|
if (sel) navigator.clipboard.writeText(sel).then(
|
||
|
|
() => toast('Copied!', 'ok'),
|
||
|
|
() => toast('Copy failed', 'err')
|
||
|
|
);
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
if (C && evt.code === 'KeyV') { pasteToTerminal(); return false; }
|
||
|
|
if (C && !S && !A && evt.code === 'KeyW') return false;
|
||
|
|
if (C && S && evt.code === 'KeyW') return false;
|
||
|
|
return true;
|
||
|
|
});
|
||
|
|
|
||
|
|
// Tab button — inserted BEFORE the "+" button so "+" stays at end
|
||
|
|
const tabEl = document.createElement('div');
|
||
|
|
tabEl.className = 'tab-item';
|
||
|
|
tabEl.title = 'Session: ' + id;
|
||
|
|
tabEl.innerHTML =
|
||
|
|
`<span class="tab-label">${tabLabel}</span>` +
|
||
|
|
`<button class="tab-x" title="Close tab (Alt+W)">×</button>`;
|
||
|
|
tabEl.querySelector('.tab-label').addEventListener('click', () => switchTab(tab));
|
||
|
|
tabEl.querySelector('.tab-x').addEventListener('click', ev => {
|
||
|
|
ev.stopPropagation(); closeTab(tab);
|
||
|
|
});
|
||
|
|
tabNewBtn.parentElement.insertBefore(tabEl, tabNewBtn); // right before "+"
|
||
|
|
tab.tabEl = tabEl;
|
||
|
|
|
||
|
|
switchTab(tab);
|
||
|
|
updateURLHash();
|
||
|
|
|
||
|
|
if (isAuthenticated) connectTab(tab); // connect immediately if already authed
|
||
|
|
return tab;
|
||
|
|
}
|
||
|
|
|
||
|
|
function switchTab(tab) {
|
||
|
|
if (activeTab) {
|
||
|
|
activeTab.pane.style.display = 'none';
|
||
|
|
activeTab.tabEl.classList.remove('active');
|
||
|
|
}
|
||
|
|
activeTab = tab;
|
||
|
|
tab.pane.style.display = 'block';
|
||
|
|
tab.tabEl.classList.add('active');
|
||
|
|
tab.fit.fit();
|
||
|
|
tab.term.focus();
|
||
|
|
updateStatus();
|
||
|
|
}
|
||
|
|
|
||
|
|
function closeTab(tab) {
|
||
|
|
if (tabs.length === 1) return; // never close the last tab
|
||
|
|
if (tab.socket) {
|
||
|
|
tab.socket.onclose = null; // prevent auto-reconnect
|
||
|
|
tab.socket.close();
|
||
|
|
}
|
||
|
|
tab.term.dispose();
|
||
|
|
tab.pane.remove();
|
||
|
|
tab.tabEl.remove();
|
||
|
|
|
||
|
|
const idx = tabs.indexOf(tab);
|
||
|
|
tabs.splice(idx, 1);
|
||
|
|
|
||
|
|
if (activeTab === tab)
|
||
|
|
switchTab(tabs[Math.min(idx, tabs.length - 1)]);
|
||
|
|
|
||
|
|
updateURLHash();
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── WebSocket (per tab) ───────────────────────────────────────────────
|
||
|
|
function connectTab(tab) {
|
||
|
|
tab.socket = new WebSocket('wss://' + location.host + '/ws/' + tab.id);
|
||
|
|
tab.socket.binaryType = 'arraybuffer';
|
||
|
|
|
||
|
|
tab.socket.onopen = () => {
|
||
|
|
tab.fit.fit();
|
||
|
|
sendResizeFor(tab);
|
||
|
|
if (activeTab === tab) { updateStatus(); tab.term.focus(); }
|
||
|
|
};
|
||
|
|
|
||
|
|
tab.socket.onmessage = e => {
|
||
|
|
if (typeof e.data === 'string') {
|
||
|
|
try {
|
||
|
|
const msg = JSON.parse(e.data);
|
||
|
|
if (msg.type === 'session' && activeTab === tab)
|
||
|
|
setStatus((msg.new ? 'new' : 'resumed') + ' ' + tab.id.slice(0, 8) + '...', 'ok');
|
||
|
|
} catch (_) {}
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
tab.term.write(new Uint8Array(e.data));
|
||
|
|
};
|
||
|
|
|
||
|
|
tab.socket.onclose = () => {
|
||
|
|
if (activeTab === tab) setStatus('reconnecting...', 'err');
|
||
|
|
setTimeout(() => connectTab(tab), 2000);
|
||
|
|
};
|
||
|
|
tab.socket.onerror = () => tab.socket.close();
|
||
|
|
|
||
|
|
tab.term.onData(d => {
|
||
|
|
if (tab.socket && tab.socket.readyState === WebSocket.OPEN) tab.socket.send(d);
|
||
|
|
});
|
||
|
|
tab.term.onResize(() => sendResizeFor(tab));
|
||
|
|
}
|
||
|
|
|
||
|
|
function sendResizeFor(tab) {
|
||
|
|
if (tab.socket && tab.socket.readyState === WebSocket.OPEN)
|
||
|
|
tab.socket.send(JSON.stringify({ type: 'resize', cols: tab.term.cols, rows: tab.term.rows }));
|
||
|
|
}
|
||
|
|
|
||
|
|
window.addEventListener('resize', () => { if (activeTab) activeTab.fit.fit(); });
|
||
|
|
|
||
|
|
// ── Upload ────────────────────────────────────────────────────────────
|
||
|
|
function onFileChosen() {
|
||
|
|
const f = document.getElementById('upFile').files[0];
|
||
|
|
const zone = document.getElementById('fileZone');
|
||
|
|
const name = document.getElementById('upFileName');
|
||
|
|
if (f) { zone.classList.add('has-file'); name.textContent = f.name; }
|
||
|
|
else { zone.classList.remove('has-file'); name.textContent = 'Click to select file'; }
|
||
|
|
}
|
||
|
|
|
||
|
|
async function doUpload() {
|
||
|
|
const f = document.getElementById('upFile').files[0];
|
||
|
|
if (!f) { modalFb('upFb', 'Select a file first', 'err'); return; }
|
||
|
|
|
||
|
|
const btn = document.getElementById('upBtn');
|
||
|
|
const form = new FormData();
|
||
|
|
form.append('file', f);
|
||
|
|
form.append('dest', document.getElementById('upDest').value.trim());
|
||
|
|
form.append('sid', activeTab ? activeTab.id : SESSION_ID);
|
||
|
|
|
||
|
|
btn.disabled = true; btn.classList.add('busy');
|
||
|
|
modalFb('upFb', '', '');
|
||
|
|
|
||
|
|
try {
|
||
|
|
const res = await fetch('/upload', { method: 'POST', body: form });
|
||
|
|
const data = await res.json();
|
||
|
|
if (data.error) {
|
||
|
|
modalFb('upFb', data.error, 'err');
|
||
|
|
} else {
|
||
|
|
modalFb('upFb', 'Staged to ' + data.dest, 'ok');
|
||
|
|
document.getElementById('upFile').value = '';
|
||
|
|
onFileChosen();
|
||
|
|
setTimeout(() => closeModal('upOverlay'), 1400);
|
||
|
|
}
|
||
|
|
} catch (_) {
|
||
|
|
modalFb('upFb', 'Network error', 'err');
|
||
|
|
} finally {
|
||
|
|
btn.disabled = false; btn.classList.remove('busy');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Download ──────────────────────────────────────────────────────────
|
||
|
|
function doDownload() {
|
||
|
|
const p = document.getElementById('dlPath').value.trim();
|
||
|
|
if (!p) { modalFb('dlFb', 'Enter a file path', 'err'); return; }
|
||
|
|
const a = document.createElement('a');
|
||
|
|
a.href = '/download?path=' + encodeURIComponent(p);
|
||
|
|
a.download = '';
|
||
|
|
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
||
|
|
modalFb('dlFb', 'Download started', 'ok');
|
||
|
|
setTimeout(() => closeModal('dlOverlay'), 1000);
|
||
|
|
}
|
||
|
|
|
||
|
|
function modalFb(id, msg, type) {
|
||
|
|
const el = document.getElementById(id);
|
||
|
|
el.textContent = msg;
|
||
|
|
el.className = 'm-fb' + (type ? ' ' + type : '');
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Bootstrap ─────────────────────────────────────────────────────────
|
||
|
|
//
|
||
|
|
// If the URL hash encodes tab IDs, open all of them (another browser
|
||
|
|
// shared the link). Otherwise open a single tab with the server-provided
|
||
|
|
// SESSION_ID.
|
||
|
|
(function bootstrap() {
|
||
|
|
const hashIds = getTabIDsFromHash();
|
||
|
|
if (hashIds && hashIds.length > 0) {
|
||
|
|
// Restore all sessions from the shared link.
|
||
|
|
// SESSION_ID might or might not be in the list — use hash as source of truth.
|
||
|
|
hashIds.forEach(id => newTab(id));
|
||
|
|
} else {
|
||
|
|
newTab(SESSION_ID);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!isAuthenticated) {
|
||
|
|
setTimeout(() => document.getElementById('fUser').focus(), 80);
|
||
|
|
}
|
||
|
|
})();
|