CI / Build & Test (push) Failing after 28s
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.
390 lines
14 KiB
JavaScript
390 lines
14 KiB
JavaScript
// 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'] });
|
||
}
|
||
}
|