Files
holesail-browser/extension/dashboard/pages/ssh.js
T
Raven Scott dc29b6481c
CI / Build & Test (push) Successful in 4m14s
fixes for popouts
2026-03-27 05:32:32 -04:00

505 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* SSH Connections page — connection grid, add/edit modal, and full in-browser SSH terminal.
* Session lifecycle: start Holesail tunnel → open WebSocket → spawn SSH via PTY on native host
* → bridge PTY ↔ WebSocket ↔ xterm.js in the browser.
* Handles auto-reconnect with exponential backoff and PTY resize via ResizeObserver.
* Depends on: core/utils.js ($, escapeHtml, log), core/messaging.js (sendToNative, sendToNativeResult, notifyNativeFailure),
* ui/toast.js (showToast, copyToClipboard), ui/modal.js (openModal, closeModal, showModalError)
*/
let sshConnections = [];
let activeSshSession = null; // { sessionId, wsPort, term, fitAddon, ws, resizeObserver, conn, dataDisposable }
let _sshReconnectTimer = null;
let _sshConnecting = false;
const SSH_RECONNECT_BASE_MS = 3000;
const SSH_RECONNECT_MAX_MS = 60000;
function generateSshId() {
return 'ssh-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 7);
}
function loadSshConnections(cb) {
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'getSshConnections' } },
(response) => {
if (response && response.ok && Array.isArray(response.sshConnections)) {
sshConnections = response.sshConnections.map(c => {
let password = '';
if (c.passwordB64) {
try { password = decodeURIComponent(escape(atob(c.passwordB64))); } catch (_) {}
}
return { ...c, password };
});
}
if (cb) cb(sshConnections);
}
);
}
function saveSshConnections(cb) {
const toSave = sshConnections.map(c => {
const { password, ...rest } = c; // eslint-disable-line no-unused-vars
if (password) rest.passwordB64 = btoa(unescape(encodeURIComponent(password)));
else delete rest.passwordB64;
return rest;
});
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'setSshConnections', payload: { connections: toSave } } },
(response) => {
if (response && !response.ok) {
log('saveSshConnections failed:', response.error);
}
renderSshGrid();
const countEl = $('sshCount');
if (countEl) countEl.textContent = sshConnections.length;
if (cb) cb();
}
);
}
/**
* Re-render the SSH connection grid from the current `sshConnections` array.
*/
function renderSshGrid() {
const grid = $('sshGrid');
if (!grid) return;
if (sshConnections.length === 0) {
grid.innerHTML = `
<div class="empty-state" style="grid-column:1/-1">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="4" width="20" height="16" rx="2"/><polyline points="8,10 12,14 16,10"/></svg>
<div class="empty-state-title">No SSH connections</div>
<div class="empty-state-desc">Add a connection to get started. You'll need an hs:// key for the remote peer.</div>
</div>`;
return;
}
grid.innerHTML = sshConnections.map(conn => `
<div class="ssh-conn-card" data-ssh-id="${escapeHtml(conn.id)}">
<div class="ssh-conn-card-top">
<div class="ssh-conn-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="4" width="20" height="16" rx="2"/>
<polyline points="8,10 12,14 16,10"/>
</svg>
</div>
<div class="ssh-conn-info">
<div class="ssh-conn-label">${escapeHtml(conn.label || conn.username + '@ssh')}</div>
${conn.autoReconnect ? '<div style="font-size:10.5px;color:var(--cyan);margin-top:2px;">Auto-reconnect enabled</div>' : ''}
</div>
<div class="ssh-conn-actions">
<button class="btn btn-primary" data-ssh-connect="${escapeHtml(conn.id)}" style="padding:6px 14px;font-size:12px;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:13px;height:13px;"><polyline points="5,12 19,12"/><polyline points="12,5 19,12 12,19"/></svg>
Connect
</button>
<button class="btn btn-ghost" data-ssh-edit="${escapeHtml(conn.id)}" style="padding:6px 10px;" title="Edit">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
<button class="btn btn-ghost" data-ssh-remove="${escapeHtml(conn.id)}" style="padding:6px 10px;color:var(--red);" title="Remove">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;"><polyline points="3,6 5,6 21,6"/><path d="M19,6l-1,14a2,2,0,0,1-2,2H8a2,2,0,0,1-2-2L5,6"/></svg>
</button>
</div>
</div>
</div>`).join('');
grid.querySelectorAll('[data-ssh-connect]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const conn = sshConnections.find(c => c.id === btn.dataset.sshConnect);
if (conn) connectSsh(conn);
});
});
grid.querySelectorAll('[data-ssh-edit]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const conn = sshConnections.find(c => c.id === btn.dataset.sshEdit);
if (conn) openAddSshModal(conn);
});
});
grid.querySelectorAll('[data-ssh-remove]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const conn = sshConnections.find(c => c.id === btn.dataset.sshRemove);
if (conn) {
$('removeSshName').textContent = conn.label || conn.username;
$('removeSshConfirm').dataset.sshId = conn.id;
openModal('modal-removeSsh');
}
});
});
}
/**
* Open the Add/Edit SSH Connection modal, pre-populated with `conn` if provided.
* @param {object} [conn] - Existing connection to edit; omit to add a new one.
*/
function openAddSshModal(conn) {
const isEdit = !!conn;
$('modal-addSsh-title').textContent = isEdit ? 'Edit SSH Connection' : 'Add SSH Connection';
$('sshConnLabel').value = conn ? conn.label : '';
$('sshConnHsUrl').value = conn ? conn.hsUrl : '';
$('sshConnUsername').value = conn ? conn.username : '';
$('sshConnPassword').value = conn ? (conn.password || '') : '';
const arEl = $('sshConnAutoReconnect');
if (arEl) arEl.checked = conn ? !!conn.autoReconnect : false;
$('sshConnEditId').value = conn ? conn.id : '';
$('sshConnSubmit').textContent = isEdit ? 'Save Changes' : 'Save Connection';
openModal('modal-addSsh');
}
function updateTermSizeDisplay(term) {
const el = $('termSizeDisplay');
if (el && term) el.textContent = term.cols + '×' + term.rows;
}
/**
* Open the SSH terminal modal and start a new SSH session for the given connection.
* Guards against concurrent calls with `_sshConnecting`. Disconnects any existing session first.
* @param {object} conn - SSH connection object with `hsUrl`, `username`, `password`, etc.
* @returns {Promise<void>}
*/
function _getXtermTheme() {
const isLight = document.documentElement.getAttribute('data-theme') === 'light';
if (isLight) {
return {
background: '#f8fafc',
foreground: '#111827',
cursor: '#0891b2',
cursorAccent: '#f8fafc',
selectionBackground: 'rgba(8,145,178,0.22)',
selectionForeground: '#0f172a',
selectionInactiveBackground: 'rgba(148,163,184,0.24)',
black: '#111827',
red: '#e11d48',
green: '#16a34a',
yellow: '#d97706',
blue: '#2563eb',
magenta: '#9333ea',
cyan: '#0891b2',
white: '#f3f4f6',
brightBlack: '#6b7280',
brightRed: '#fb7185',
brightGreen: '#86efac',
brightYellow: '#fde68a',
brightBlue: '#93c5fd',
brightMagenta: '#d8b4fe',
brightCyan: '#67e8f9',
brightWhite: '#ffffff',
};
}
return {
background: '#0d0d0f',
foreground: '#e4e4e7',
cursor: '#22d3ee',
cursorAccent: '#0d0d0f',
selectionBackground: 'rgba(34,211,238,0.25)',
selectionForeground: '#f8fafc',
selectionInactiveBackground: 'rgba(63,63,70,0.30)',
black: '#18181b',
red: '#f43f5e',
green: '#4ade80',
yellow: '#fbbf24',
blue: '#60a5fa',
magenta: '#c084fc',
cyan: '#22d3ee',
white: '#e4e4e7',
brightBlack: '#3f3f46',
brightRed: '#fb7185',
brightGreen: '#86efac',
brightYellow: '#fde68a',
brightBlue: '#93c5fd',
brightMagenta: '#d8b4fe',
brightCyan: '#67e8f9',
brightWhite: '#fafafa',
};
}
function applySshTerminalTheme() {
if (!activeSshSession || !activeSshSession.term) return;
const term = activeSshSession.term;
const theme = _getXtermTheme();
term.options.theme = theme;
const container = $('terminalContainer');
if (container && theme && theme.background) {
container.style.backgroundColor = theme.background;
}
try { term.refresh(0, term.rows - 1); } catch (_) {}
}
async function connectSsh(conn) {
if (_sshConnecting) return;
_sshConnecting = true;
if (_sshReconnectTimer) { clearTimeout(_sshReconnectTimer); _sshReconnectTimer = null; }
try {
return await _connectSshImpl(conn);
} finally {
_sshConnecting = false;
}
}
async function _connectSshImpl(conn) {
openModal('modal-sshTerminal');
$('termConnLabel').textContent = conn.label || conn.username;
$('termUserHost').textContent = conn.username + '@ssh';
$('termStatusDot').className = 'terminal-status-dot';
$('termStateDisplay').textContent = 'Connecting…';
$('termStateDisplay').style.color = 'var(--amber)';
await disconnectSsh();
const term = new Terminal({
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace",
fontSize: 13,
lineHeight: 1.3,
cursorBlink: true,
cursorStyle: 'block',
scrollback: 5000,
minimumContrastRatio: 4.5,
theme: _getXtermTheme()
});
const fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
const container = $('terminalContainer');
container.innerHTML = '';
term.open(container);
container.style.backgroundColor = _getXtermTheme().background;
// Fit synchronously now that the container is in the DOM, then wait a frame
// for the browser to finish layout so dimensions are accurate before we
// send cols/rows to the native host.
await new Promise(resolve => requestAnimationFrame(() => {
try { fitAddon.fit(); } catch (_) {}
updateTermSizeDisplay(term);
resolve();
}));
term.writeln('\x1b[36mConnecting to ' + escapeHtml(conn.label || conn.username) + '…\x1b[0m');
term.writeln('\x1b[90mEstablishing Holesail tunnel…\x1b[0m');
const cols = term.cols || 80;
const rows = term.rows || 24;
const r = await sendToNativeResult('startSshSession', {
hsUrl: conn.hsUrl,
username: conn.username,
password: conn.password || '',
cols,
rows,
label: conn.label || conn.username
});
if (!r.ok) {
notifyNativeFailure('SSH', r.error);
const errMsg = r.error || 'Unknown error';
term.writeln('\x1b[31mFailed to start session: ' + errMsg + '\x1b[0m');
$('termStatusDot').className = 'terminal-status-dot disconnected';
$('termStateDisplay').textContent = 'Error';
$('termStateDisplay').style.color = 'var(--red)';
activeSshSession = { term, fitAddon, ws: null, resizeObserver: null, conn, sessionId: null };
return;
}
const { sessionId, wsPort } = r.data;
term.writeln('\x1b[90mTunnel ready — connecting SSH…\x1b[0m');
let ws;
try {
ws = new WebSocket('ws://127.0.0.1:' + wsPort);
ws.binaryType = 'arraybuffer';
} catch (e) {
term.writeln('\x1b[31mWebSocket connection failed: ' + e.message + '\x1b[0m');
sendToNative('stopSshSession', { sessionId });
return;
}
ws.onopen = () => {
$('termStatusDot').className = 'terminal-status-dot';
$('termStateDisplay').textContent = 'Connected';
$('termStateDisplay').style.color = 'var(--green)';
// Send a ready-signal so the native host knows the browser WebSocket is
// fully open and can safely flush buffered PTY output (MOTD, prompt).
ws.send('\x00');
term.focus();
};
let firstMessage = true;
ws.onmessage = (event) => {
const data = event.data instanceof ArrayBuffer
? new Uint8Array(event.data)
: event.data;
if (firstMessage) {
firstMessage = false;
// Prepend ESC[2J (clear screen) + ESC[H (cursor home) to the first SSH
// data chunk so the clear and the MOTD are written atomically in the
// same xterm.js render pass — avoids the race where term.clear() wipes
// data that was already queued by term.write().
const CLEAR_HOME = '\x1b[2J\x1b[H';
if (typeof data === 'string') {
term.write(CLEAR_HOME + data);
} else {
const prefix = new TextEncoder().encode(CLEAR_HOME);
const combined = new Uint8Array(prefix.length + data.length);
combined.set(prefix);
combined.set(data, prefix.length);
term.write(combined);
}
return;
}
term.write(data);
};
let _reconnectDelay = SSH_RECONNECT_BASE_MS;
ws.onclose = () => {
$('termStatusDot').className = 'terminal-status-dot disconnected';
$('termStateDisplay').textContent = 'Disconnected';
$('termStateDisplay').style.color = 'var(--text3)';
term.writeln('\r\n\x1b[90m[Session closed]\x1b[0m');
if (conn.autoReconnect && activeSshSession) {
const delay = _reconnectDelay;
_reconnectDelay = Math.min(_reconnectDelay * 2, SSH_RECONNECT_MAX_MS);
term.writeln('\x1b[33mAuto-reconnect in ' + Math.round(delay / 1000) + 's…\x1b[0m');
$('termStateDisplay').textContent = 'Reconnecting…';
$('termStateDisplay').style.color = 'var(--amber)';
if (_sshReconnectTimer) clearTimeout(_sshReconnectTimer);
_sshReconnectTimer = setTimeout(() => {
_sshReconnectTimer = null;
if (activeSshSession && conn.autoReconnect) connectSsh(conn);
}, delay);
}
};
ws.onerror = () => {
term.writeln('\r\n\x1b[31m[WebSocket error]\x1b[0m');
};
const dataDisposable = term.onData((data) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(data);
}
});
// Resize observer — refit on container resize, debounced so we don't
// flood the native host with stty commands during a window drag.
activeSshSession = { sessionId, wsPort, term, fitAddon, ws, resizeObserver: null, resizeTimer: null, conn, dataDisposable };
const resizeObserver = new ResizeObserver(() => {
try { fitAddon.fit(); updateTermSizeDisplay(term); } catch (_) {}
if (activeSshSession) clearTimeout(activeSshSession.resizeTimer);
if (activeSshSession) {
activeSshSession.resizeTimer = setTimeout(() => {
if (activeSshSession) activeSshSession.resizeTimer = null;
try {
sendToNative('resizeSshSession', { sessionId, cols: term.cols, rows: term.rows });
} catch (_) {}
}, 150);
}
});
resizeObserver.observe(container);
activeSshSession.resizeObserver = resizeObserver;
}
/**
* Disconnect the active SSH session: close the WebSocket, dispose the xterm terminal,
* disconnect the ResizeObserver, and send a stopSshSession message to the native host.
* @returns {Promise<void>}
*/
async function disconnectSsh() {
if (!activeSshSession) return;
const { sessionId, ws, term, fitAddon, resizeObserver, resizeTimer, dataDisposable } = activeSshSession;
activeSshSession = null;
if (_sshReconnectTimer) { clearTimeout(_sshReconnectTimer); _sshReconnectTimer = null; }
if (resizeTimer) clearTimeout(resizeTimer);
if (resizeObserver) resizeObserver.disconnect();
if (dataDisposable) dataDisposable.dispose();
if (ws) { try { ws.close(); } catch (_) {} }
if (term) { try { term.dispose(); } catch (_) {} }
if (sessionId) {
await sendToNative('stopSshSession', { sessionId });
}
}
/**
* Attach all event listeners for the SSH Connections page.
* Called once during dashboard initialisation.
*/
function setupSshEvents() {
window.applySshTerminalTheme = applySshTerminalTheme;
$('addSshBtn')?.addEventListener('click', () => openAddSshModal(null));
$('sshConnSubmit')?.addEventListener('click', () => {
const label = $('sshConnLabel').value.trim();
const hsUrl = $('sshConnHsUrl').value.trim();
const username = $('sshConnUsername').value.trim();
const password = $('sshConnPassword').value;
const editId = $('sshConnEditId').value;
if (!hsUrl) { showModalError('modal-addSsh', 'sshConnError', 'Holesail key is required'); return; }
if (!username) { showModalError('modal-addSsh', 'sshConnError', 'Username is required'); return; }
if (!hsUrl.startsWith('hs://')) { showModalError('modal-addSsh', 'sshConnError', 'Key must start with hs://'); return; }
const autoReconnect = !!$('sshConnAutoReconnect')?.checked;
if (editId) {
const idx = sshConnections.findIndex(c => c.id === editId);
if (idx !== -1) {
sshConnections[idx] = { ...sshConnections[idx], label, hsUrl, username, password, autoReconnect };
}
} else {
sshConnections.push({ id: generateSshId(), label, hsUrl, username, password, autoReconnect });
}
saveSshConnections();
closeModal('modal-addSsh');
showToast(editId ? 'Connection updated' : 'Connection saved', 'success');
});
$('removeSshConfirm')?.addEventListener('click', () => {
const id = $('removeSshConfirm').dataset.sshId;
sshConnections = sshConnections.filter(c => c.id !== id);
saveSshConnections();
closeModal('modal-removeSsh');
showToast('Connection removed', 'success');
});
$('termDisconnectBtn')?.addEventListener('click', async () => {
await disconnectSsh();
closeModal('modal-sshTerminal');
});
$('termCopyBtn')?.addEventListener('click', () => {
if (activeSshSession && activeSshSession.term) {
const sel = activeSshSession.term.getSelection();
if (sel) copyToClipboard(sel, null);
else showToast('No text selected', 'default');
}
});
$('termFullscreenBtn')?.addEventListener('click', () => {
const modal = document.querySelector('#modal-sshTerminal .modal');
if (!modal) return;
if (modal.style.width === '100vw') {
modal.style.width = '';
modal.style.height = '';
modal.style.borderRadius = '';
} else {
modal.style.width = '100vw';
modal.style.height = '100vh';
modal.style.borderRadius = '0';
}
setTimeout(() => {
if (activeSshSession && activeSshSession.fitAddon) {
activeSshSession.fitAddon.fit();
updateTermSizeDisplay(activeSshSession.term);
}
}, 50);
});
const termModal = $('modal-sshTerminal');
if (termModal) {
const observer = new MutationObserver(() => {
if (!termModal.classList.contains('open') && activeSshSession) {
disconnectSsh();
}
});
observer.observe(termModal, { attributes: true, attributeFilter: ['class'] });
window.addEventListener('beforeunload', () => observer.disconnect(), { once: true });
}
}