/**
* 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 = `
No SSH connections
Add a connection to get started. You'll need an hs:// key for the remote peer.
`;
return;
}
grid.innerHTML = sshConnections.map(conn => `
${escapeHtml(conn.label || conn.username + '@ssh')}
${conn.autoReconnect ? '
Auto-reconnect enabled
' : ''}
`).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}
*/
function _getXtermTheme() {
const isLight = document.documentElement.getAttribute('data-theme') === 'light';
if (isLight) {
return {
background: '#1a1a1e',
foreground: '#e4e4e7',
cursor: '#0891b2',
cursorAccent: '#1a1a1e',
selectionBackground: 'rgba(8,145,178,0.30)',
black: '#18181b',
red: '#e11d48',
green: '#16a34a',
yellow: '#d97706',
blue: '#2563eb',
magenta: '#9333ea',
cyan: '#0891b2',
white: '#e4e4e7',
brightBlack: '#3f3f46',
brightRed: '#fb7185',
brightGreen: '#86efac',
brightYellow: '#fde68a',
brightBlue: '#93c5fd',
brightMagenta: '#d8b4fe',
brightCyan: '#67e8f9',
brightWhite: '#fafafa',
};
}
return {
background: '#0d0d0f',
foreground: '#e4e4e7',
cursor: '#22d3ee',
cursorAccent: '#0d0d0f',
selectionBackground: 'rgba(34,211,238,0.25)',
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',
};
}
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,
theme: _getXtermTheme()
});
const fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
const container = $('terminalContainer');
container.innerHTML = '';
term.open(container);
// 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}
*/
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() {
$('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 });
}
}