/** * 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), * 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 result = await sendToNative('startSshSession', { hsUrl: conn.hsUrl, username: conn.username, password: conn.password || '', cols, rows, label: conn.label || conn.username }); if (!result || !result.ok) { const errMsg = (result && result.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 } = result; 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 }); } }