Files
holesail-browser/extension/dashboard/pages/ssh.js
T
Raven Scott 5c4f0b4aba
CI / Build & Test (push) Failing after 28s
efactor(extension): modularize dashboard, background, and docs
Split monolithic dashboard.js (2753 lines) into 18 focused modules
under dashboard/{core,data,ui,pages}/ with refresh.js and events.js
as orchestrators. Extracted ~900-line inline <style> into dashboard.css
and moved dashboard.html to dashboard/dashboard.html.

Split background.js (609 lines) into background/{logs,state,proxy,
native-messaging,tab-lifecycle,message-router}.js with a thin entry
point using importScripts().

Deleted dead files: wrong-domain.js (duplicate of inline script).
Updated manifest.json web_accessible_resources for new paths.
Updated docs/ARCHITECTURE.md to reflect the new file structure.

No functionality changed. No build step introduced.
2026-02-28 23:02:52 -05:00

390 lines
14 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.
// 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 }
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();
}
);
}
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>
</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');
}
});
});
}
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 || '') : '';
$('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;
}
async function connectSsh(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: {
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'
}
});
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);
};
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');
};
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.
let resizeTimer = null;
const resizeObserver = new ResizeObserver(() => {
try { fitAddon.fit(); updateTermSizeDisplay(term); } catch (_) {}
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
try {
sendToNative('resizeSshSession', { sessionId, cols: term.cols, rows: term.rows });
} catch (_) {}
}, 150);
});
resizeObserver.observe(container);
activeSshSession = { sessionId, wsPort, term, fitAddon, ws, resizeObserver, conn, dataDisposable };
}
async function disconnectSsh() {
if (!activeSshSession) return;
const { sessionId, ws, term, fitAddon, resizeObserver, dataDisposable } = activeSshSession;
activeSshSession = null;
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 });
}
}
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; }
if (editId) {
const idx = sshConnections.findIndex(c => c.id === editId);
if (idx !== -1) {
sshConnections[idx] = { ...sshConnections[idx], label, hsUrl, username, password };
}
} else {
sshConnections.push({ id: generateSshId(), label, hsUrl, username, password });
}
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'] });
}
}