2218 lines
92 KiB
JavaScript
2218 lines
92 KiB
JavaScript
/**
|
||
* Holesail Dashboard - UI logic
|
||
*/
|
||
|
||
const SETTINGS_DEFAULTS = {
|
||
proxyPort: 8443,
|
||
connectProxyPort: 8442,
|
||
readyTimeoutMs: 0,
|
||
notifyOnDisconnect: true,
|
||
debug: false,
|
||
disableOnFileUrls: false,
|
||
backupRetention: 5
|
||
};
|
||
let currentState = null;
|
||
let settings = { ...SETTINGS_DEFAULTS };
|
||
|
||
function $(id) { return document.getElementById(id); }
|
||
function log(...args) { console.log('[Holesail-dashboard]', ...args); }
|
||
|
||
// ── Utilities ──────────────────────────────────────────────────────────────
|
||
|
||
function timeAgo(timestamp) {
|
||
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
||
if (seconds < 60) return seconds + 's ago';
|
||
const minutes = Math.floor(seconds / 60);
|
||
if (minutes < 60) return minutes + 'm ago';
|
||
const hours = Math.floor(minutes / 60);
|
||
if (hours < 24) return hours + 'h ago';
|
||
return Math.floor(hours / 24) + 'd ago';
|
||
}
|
||
|
||
function formatUptime(ms) {
|
||
const seconds = Math.floor(ms / 1000);
|
||
if (seconds < 60) return seconds + 's';
|
||
const minutes = Math.floor(seconds / 60);
|
||
if (minutes < 60) return minutes + 'm ' + (seconds % 60) + 's';
|
||
const hours = Math.floor(minutes / 60);
|
||
return hours + 'h ' + (minutes % 60) + 'm';
|
||
}
|
||
|
||
function truncate(str, len = 20) {
|
||
if (!str) return '';
|
||
return str.length > len ? str.slice(0, len) + '…' : str;
|
||
}
|
||
|
||
function escapeHtml(str) {
|
||
const div = document.createElement('div');
|
||
div.textContent = str;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
function stateTag(state) {
|
||
const dot = (color) => `<span style="display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--${color});margin-right:5px;flex-shrink:0;${color === 'green' ? 'box-shadow:0 0 5px var(--green);' : ''}"></span>`;
|
||
if (!state || state === '—') return `<span class="badge badge-neutral">${dot('text4')}—</span>`;
|
||
if (state === 'ready') return `<span class="badge badge-green">${dot('green')}ready</span>`;
|
||
if (state === 'error') return `<span class="badge badge-red">${dot('red')}error</span>`;
|
||
if (state === 'closed') return `<span class="badge badge-neutral">${dot('text4')}closed</span>`;
|
||
if (state === 'connecting') return `<span class="badge badge-amber">${dot('amber')}connecting</span>`;
|
||
return `<span class="badge badge-amber">${dot('amber')}${escapeHtml(state)}</span>`;
|
||
}
|
||
|
||
// ── Toast ───────────────────────────────────────────────────────────────────
|
||
|
||
let toastTimer = null;
|
||
function showToast(msg, type = 'default') {
|
||
const el = $('toast');
|
||
if (!el) return;
|
||
el.textContent = msg;
|
||
el.className = 'toast show' + (type !== 'default' ? ' ' + type : '');
|
||
clearTimeout(toastTimer);
|
||
toastTimer = setTimeout(() => { el.className = 'toast'; }, 2800);
|
||
}
|
||
|
||
// ── Copy to clipboard ───────────────────────────────────────────────────────
|
||
|
||
function copyToClipboard(text, btnEl) {
|
||
navigator.clipboard.writeText(text).then(() => {
|
||
if (btnEl) {
|
||
btnEl.classList.add('copied');
|
||
setTimeout(() => btnEl.classList.remove('copied'), 1500);
|
||
}
|
||
showToast('Copied to clipboard', 'success');
|
||
}).catch(() => showToast('Copy failed', 'error'));
|
||
}
|
||
|
||
// ── Modals ──────────────────────────────────────────────────────────────────
|
||
|
||
function openModal(id) {
|
||
const el = $(id);
|
||
if (!el) return;
|
||
el.classList.add('open');
|
||
// Focus first input
|
||
setTimeout(() => {
|
||
const input = el.querySelector('input:not([type="checkbox"])');
|
||
if (input) input.focus();
|
||
}, 50);
|
||
}
|
||
|
||
function closeModal(id) {
|
||
const el = $(id);
|
||
if (!el) return;
|
||
el.classList.remove('open');
|
||
// Clear errors
|
||
el.querySelectorAll('.modal-error').forEach(e => { e.style.display = 'none'; e.textContent = ''; });
|
||
}
|
||
|
||
function showModalError(modalId, errorId, msg) {
|
||
const el = $(errorId);
|
||
if (!el) return;
|
||
el.textContent = msg;
|
||
el.style.display = 'block';
|
||
}
|
||
|
||
// Close modals on backdrop click or close button
|
||
document.addEventListener('click', (e) => {
|
||
// Close button
|
||
const closeBtn = e.target.closest('[data-close-modal]');
|
||
if (closeBtn) {
|
||
closeModal(closeBtn.dataset.closeModal);
|
||
return;
|
||
}
|
||
// Backdrop click (clicking the backdrop itself, not the modal)
|
||
if (e.target.classList.contains('modal-backdrop')) {
|
||
closeModal(e.target.id);
|
||
}
|
||
// Page link button
|
||
const pageLink = e.target.closest('[data-page-link]');
|
||
if (pageLink) {
|
||
navigateTo(pageLink.dataset.pageLink);
|
||
}
|
||
});
|
||
|
||
// Escape key closes topmost open modal
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') {
|
||
const open = document.querySelector('.modal-backdrop.open');
|
||
if (open) closeModal(open.id);
|
||
}
|
||
});
|
||
|
||
// ── Navigation ──────────────────────────────────────────────────────────────
|
||
|
||
const PAGE_TITLES = {
|
||
dashboard: 'Overview',
|
||
connections: 'Virtual Hosts',
|
||
swarms: 'Server Tunnels',
|
||
'service-tunnels': 'Service Tunnels',
|
||
tabs: 'Proxy & CA',
|
||
ssh: 'SSH Connections',
|
||
rdp: 'Remote Desktop',
|
||
backups: 'Backups',
|
||
logs: 'Logs',
|
||
settings: 'Settings'
|
||
};
|
||
|
||
function navigateTo(page) {
|
||
document.querySelectorAll('.nav-item').forEach(i => i.classList.remove('active'));
|
||
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
|
||
const navItem = document.querySelector(`.nav-item[data-page="${page}"]`);
|
||
if (navItem) navItem.classList.add('active');
|
||
const pageEl = $(`page-${page}`);
|
||
if (pageEl) pageEl.classList.add('active');
|
||
const titleEl = $('topbarTitle');
|
||
if (titleEl) titleEl.textContent = PAGE_TITLES[page] || page;
|
||
}
|
||
|
||
function setupNavigation() {
|
||
document.querySelectorAll('.nav-item').forEach(item => {
|
||
item.addEventListener('click', () => navigateTo(item.dataset.page));
|
||
});
|
||
}
|
||
|
||
// ── Settings ────────────────────────────────────────────────────────────────
|
||
|
||
function loadSettings() {
|
||
chrome.runtime.sendMessage(
|
||
{ target: 'holesail-native', action: 'send', payload: { type: 'getSettings' } },
|
||
(response) => {
|
||
if (response && response.ok && response.settings) {
|
||
settings = { ...SETTINGS_DEFAULTS, ...response.settings };
|
||
}
|
||
updateSettingsUI();
|
||
}
|
||
);
|
||
}
|
||
|
||
function updateSettingsUI() {
|
||
$('toggleNotify')?.classList.toggle('active', settings.notifyOnDisconnect === true);
|
||
$('toggleDebug')?.classList.toggle('active', settings.debug === true);
|
||
$('toggleDisableFileUrls')?.classList.toggle('active', settings.disableOnFileUrls === true);
|
||
const proxyPortEl = $('proxyPort');
|
||
const readyTimeoutMsEl = $('readyTimeoutMs');
|
||
const backupRetentionEl = $('backupRetention');
|
||
if (proxyPortEl) proxyPortEl.value = settings.proxyPort ?? SETTINGS_DEFAULTS.proxyPort;
|
||
if (readyTimeoutMsEl) readyTimeoutMsEl.value = settings.readyTimeoutMs ?? SETTINGS_DEFAULTS.readyTimeoutMs;
|
||
if (backupRetentionEl) backupRetentionEl.value = settings.backupRetention ?? SETTINGS_DEFAULTS.backupRetention;
|
||
}
|
||
|
||
function saveSettings() {
|
||
settings.notifyOnDisconnect = $('toggleNotify')?.classList.contains('active') ?? SETTINGS_DEFAULTS.notifyOnDisconnect;
|
||
settings.debug = $('toggleDebug')?.classList.contains('active') ?? SETTINGS_DEFAULTS.debug;
|
||
settings.disableOnFileUrls = $('toggleDisableFileUrls')?.classList.contains('active') ?? SETTINGS_DEFAULTS.disableOnFileUrls;
|
||
settings.proxyPort = parseInt($('proxyPort')?.value, 10) || SETTINGS_DEFAULTS.proxyPort;
|
||
settings.readyTimeoutMs = parseInt($('readyTimeoutMs')?.value, 10) || SETTINGS_DEFAULTS.readyTimeoutMs;
|
||
settings.backupRetention = Math.max(1, parseInt($('backupRetention')?.value, 10) || SETTINGS_DEFAULTS.backupRetention);
|
||
chrome.runtime.sendMessage(
|
||
{ target: 'holesail-native', action: 'send', payload: { type: 'updateSettings', payload: { ...settings } } },
|
||
(response) => {
|
||
if (response && response.ok) {
|
||
if (response.settings) settings = { ...SETTINGS_DEFAULTS, ...response.settings };
|
||
showToast('Settings saved', 'success');
|
||
} else {
|
||
showToast(response?.error || 'Failed to save settings', 'error');
|
||
}
|
||
}
|
||
);
|
||
}
|
||
|
||
// ── Remote Desktop ───────────────────────────────────────────────────────────
|
||
|
||
let rdpConnections = [];
|
||
let activeRdpSession = null; // { sessionId, wsPort, type, ws, rfb, conn }
|
||
|
||
function generateRdpId() {
|
||
return 'rdp-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 7);
|
||
}
|
||
|
||
function saveRdpConnections(cb) {
|
||
// Strip password before persisting — passwords are session-only
|
||
const toSave = rdpConnections.map(c => {
|
||
const { password, ...rest } = c; // eslint-disable-line no-unused-vars
|
||
return rest;
|
||
});
|
||
chrome.runtime.sendMessage(
|
||
{ target: 'holesail-native', action: 'send', payload: { type: 'setRdpConnections', payload: { connections: toSave } } },
|
||
(response) => {
|
||
if (response && !response.ok) log('saveRdpConnections failed:', response.error);
|
||
renderRdpGrid();
|
||
const countEl = $('rdpCount');
|
||
if (countEl) countEl.textContent = rdpConnections.length;
|
||
if (cb) cb();
|
||
}
|
||
);
|
||
}
|
||
|
||
function renderRdpGrid() {
|
||
const grid = $('rdpGrid');
|
||
if (!grid) return;
|
||
const countEl = $('rdpCount');
|
||
if (countEl) countEl.textContent = rdpConnections.length;
|
||
if (rdpConnections.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="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||
<div class="empty-state-title">No remote desktop connections</div>
|
||
<div class="empty-state-desc">Add a VNC or RDP connection. You'll need an hs:// key for the remote peer.</div>
|
||
</div>`;
|
||
return;
|
||
}
|
||
grid.innerHTML = rdpConnections.map(conn => {
|
||
const typeBadge = conn.type === 'rdp'
|
||
? `<span class="badge badge-amber" style="font-size:10px;padding:2px 7px;">RDP</span>`
|
||
: `<span class="badge badge-cyan" style="font-size:10px;padding:2px 7px;">VNC</span>`;
|
||
const meta = (conn.type === 'rdp' ? (conn.username ? conn.username + '@rdp' : 'rdp') : 'vnc')
|
||
+ ' · ' + escapeHtml(truncate(conn.hsUrl, 28));
|
||
return `
|
||
<div class="rdp-conn-card" data-rdp-id="${escapeHtml(conn.id)}">
|
||
<div class="rdp-conn-icon">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<rect x="2" y="3" width="20" height="14" rx="2"/>
|
||
<line x1="8" y1="21" x2="16" y2="21"/>
|
||
<line x1="12" y1="17" x2="12" y2="21"/>
|
||
</svg>
|
||
</div>
|
||
<div class="rdp-conn-info">
|
||
<div class="rdp-conn-label">${escapeHtml(conn.label || (conn.type === 'rdp' ? 'RDP Desktop' : 'VNC Desktop'))} ${typeBadge}</div>
|
||
<div class="rdp-conn-meta">${escapeHtml(meta)}</div>
|
||
</div>
|
||
<div class="rdp-conn-actions">
|
||
<button class="btn btn-primary" data-rdp-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-rdp-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-rdp-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>`;
|
||
}).join('');
|
||
|
||
grid.querySelectorAll('[data-rdp-connect]').forEach(btn => {
|
||
btn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
const conn = rdpConnections.find(c => c.id === btn.dataset.rdpConnect);
|
||
if (conn) connectRdp(conn);
|
||
});
|
||
});
|
||
grid.querySelectorAll('[data-rdp-edit]').forEach(btn => {
|
||
btn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
const conn = rdpConnections.find(c => c.id === btn.dataset.rdpEdit);
|
||
if (conn) openAddRdpModal(conn);
|
||
});
|
||
});
|
||
grid.querySelectorAll('[data-rdp-remove]').forEach(btn => {
|
||
btn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
const conn = rdpConnections.find(c => c.id === btn.dataset.rdpRemove);
|
||
if (conn) {
|
||
$('removeRdpName').textContent = conn.label || conn.type.toUpperCase() + ' Desktop';
|
||
$('removeRdpConfirm').dataset.rdpId = conn.id;
|
||
openModal('modal-removeRdp');
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function openAddRdpModal(conn) {
|
||
const isEdit = !!conn;
|
||
$('modal-addRdp-title').textContent = isEdit ? 'Edit Remote Desktop Connection' : 'Add Remote Desktop Connection';
|
||
$('rdpConnLabel').value = conn ? (conn.label || '') : '';
|
||
$('rdpConnHsUrl').value = conn ? conn.hsUrl : '';
|
||
$('rdpConnPort').value = conn ? conn.port : 5900;
|
||
$('rdpConnWidth').value = conn ? (conn.width || 1280) : 1280;
|
||
$('rdpConnHeight').value = conn ? (conn.height || 720) : 720;
|
||
$('rdpConnUsername').value = conn ? (conn.username || '') : '';
|
||
$('rdpConnPassword').value = '';
|
||
$('rdpConnEditId').value = conn ? conn.id : '';
|
||
$('rdpConnSubmit').textContent = isEdit ? 'Save Changes' : 'Save Connection';
|
||
|
||
const type = conn ? conn.type : 'vnc';
|
||
document.querySelector('input[name="rdpProtocol"][value="' + type + '"]').checked = true;
|
||
updateRdpProtocolUI(type);
|
||
openModal('modal-addRdp');
|
||
}
|
||
|
||
function updateRdpProtocolUI(type) {
|
||
const portEl = $('rdpConnPort');
|
||
const usernameGroup = $('rdpUsernameGroup');
|
||
if (type === 'rdp') {
|
||
if (portEl && portEl.value === '5900') portEl.value = '3389';
|
||
if (usernameGroup) usernameGroup.style.display = '';
|
||
} else {
|
||
if (portEl && portEl.value === '3389') portEl.value = '5900';
|
||
if (usernameGroup) usernameGroup.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// ── VNC viewer (noVNC RFB) ────────────────────────────────────────────────────
|
||
|
||
function initVncViewer(wsPort, conn) {
|
||
const container = $('rdpViewerContainer');
|
||
if (!container) return null;
|
||
container.innerHTML = '';
|
||
|
||
// noVNC is loaded as an ES module that sets window.RFB
|
||
const RFB = window.RFB;
|
||
if (!RFB) {
|
||
container.innerHTML = '<div style="color:var(--red);padding:20px;font-size:13px;">noVNC (RFB) not loaded. Check vendor/novnc.js.</div>';
|
||
return null;
|
||
}
|
||
|
||
let rfb;
|
||
try {
|
||
rfb = new RFB(container, 'ws://127.0.0.1:' + wsPort, {
|
||
credentials: conn.password ? { password: conn.password } : undefined
|
||
});
|
||
rfb.scaleViewport = true;
|
||
rfb.resizeSession = false;
|
||
rfb.viewOnly = false;
|
||
rfb.clipViewport = false;
|
||
rfb.dragViewport = false;
|
||
rfb.focusOnClick = true;
|
||
rfb.background = '#000';
|
||
} catch (e) {
|
||
container.innerHTML = '<div style="color:var(--red);padding:20px;font-size:13px;">Failed to init noVNC: ' + escapeHtml(e.message) + '</div>';
|
||
return null;
|
||
}
|
||
|
||
rfb.addEventListener('connect', () => {
|
||
$('rdpStatusDot').className = 'terminal-status-dot';
|
||
$('rdpStateDisplay').textContent = 'Connected';
|
||
$('rdpStateDisplay').style.color = 'var(--green)';
|
||
const w = rfb._fbWidth || conn.width || '?';
|
||
const h = rfb._fbHeight || conn.height || '?';
|
||
$('rdpResDisplay').textContent = w + '×' + h;
|
||
});
|
||
|
||
rfb.addEventListener('disconnect', (e) => {
|
||
$('rdpStatusDot').className = 'terminal-status-dot disconnected';
|
||
$('rdpStateDisplay').textContent = e.detail && e.detail.clean ? 'Disconnected' : 'Connection lost';
|
||
$('rdpStateDisplay').style.color = 'var(--text3)';
|
||
});
|
||
|
||
rfb.addEventListener('desktopname', (e) => {
|
||
if (e.detail && e.detail.name) $('rdpViewerInfo').textContent = e.detail.name;
|
||
});
|
||
|
||
rfb.addEventListener('credentialsrequired', () => {
|
||
const pw = prompt('VNC password required:');
|
||
if (pw !== null) rfb.sendCredentials({ password: pw });
|
||
});
|
||
|
||
return rfb;
|
||
}
|
||
|
||
// ── RDP viewer (node-rdpjs bitmap renderer) ───────────────────────────────────
|
||
|
||
function initRdpViewer(wsPort, conn) {
|
||
const container = $('rdpViewerContainer');
|
||
if (!container) return null;
|
||
container.innerHTML = '';
|
||
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = conn.width || 1280;
|
||
canvas.height = conn.height || 720;
|
||
canvas.style.display = 'block';
|
||
canvas.style.cursor = 'default';
|
||
container.appendChild(canvas);
|
||
|
||
const ctx = canvas.getContext('2d');
|
||
$('rdpResDisplay').textContent = canvas.width + '×' + canvas.height;
|
||
|
||
let ws;
|
||
try {
|
||
ws = new WebSocket('ws://127.0.0.1:' + wsPort);
|
||
} catch (e) {
|
||
container.innerHTML = '<div style="color:var(--red);padding:20px;font-size:13px;">WebSocket failed: ' + escapeHtml(e.message) + '</div>';
|
||
return null;
|
||
}
|
||
|
||
ws.onopen = () => {
|
||
$('rdpStateDisplay').textContent = 'Waiting for RDP…';
|
||
$('rdpStateDisplay').style.color = 'var(--amber)';
|
||
};
|
||
|
||
ws.onmessage = (event) => {
|
||
let msg;
|
||
try { msg = JSON.parse(event.data); } catch (_) { return; }
|
||
|
||
if (msg.type === 'connected') {
|
||
$('rdpStatusDot').className = 'terminal-status-dot';
|
||
$('rdpStateDisplay').textContent = 'Connected';
|
||
$('rdpStateDisplay').style.color = 'var(--green)';
|
||
if (msg.width && msg.height) {
|
||
canvas.width = msg.width;
|
||
canvas.height = msg.height;
|
||
$('rdpResDisplay').textContent = msg.width + '×' + msg.height;
|
||
}
|
||
} else if (msg.type === 'bitmap') {
|
||
renderRdpBitmap(ctx, msg);
|
||
} else if (msg.type === 'close') {
|
||
$('rdpStatusDot').className = 'terminal-status-dot disconnected';
|
||
$('rdpStateDisplay').textContent = 'Disconnected';
|
||
$('rdpStateDisplay').style.color = 'var(--text3)';
|
||
} else if (msg.type === 'error') {
|
||
$('rdpStatusDot').className = 'terminal-status-dot disconnected';
|
||
$('rdpStateDisplay').textContent = 'Error: ' + (msg.message || 'unknown');
|
||
$('rdpStateDisplay').style.color = 'var(--red)';
|
||
}
|
||
};
|
||
|
||
ws.onclose = () => {
|
||
$('rdpStatusDot').className = 'terminal-status-dot disconnected';
|
||
$('rdpStateDisplay').textContent = 'Disconnected';
|
||
$('rdpStateDisplay').style.color = 'var(--text3)';
|
||
};
|
||
|
||
ws.onerror = () => {
|
||
$('rdpStateDisplay').textContent = 'WebSocket error';
|
||
$('rdpStateDisplay').style.color = 'var(--red)';
|
||
};
|
||
|
||
// Mouse events → WS
|
||
canvas.addEventListener('mousemove', (e) => {
|
||
if (ws.readyState !== WebSocket.OPEN) return;
|
||
const r = canvas.getBoundingClientRect();
|
||
const scaleX = canvas.width / r.width;
|
||
const scaleY = canvas.height / r.height;
|
||
ws.send(JSON.stringify({ type: 'mouseMove', x: Math.round((e.clientX - r.left) * scaleX), y: Math.round((e.clientY - r.top) * scaleY) }));
|
||
});
|
||
|
||
canvas.addEventListener('mousedown', (e) => {
|
||
if (ws.readyState !== WebSocket.OPEN) return;
|
||
const r = canvas.getBoundingClientRect();
|
||
const scaleX = canvas.width / r.width;
|
||
const scaleY = canvas.height / r.height;
|
||
const btn = e.button === 2 ? 2 : e.button === 1 ? 3 : 1;
|
||
ws.send(JSON.stringify({ type: 'mouseButton', x: Math.round((e.clientX - r.left) * scaleX), y: Math.round((e.clientY - r.top) * scaleY), button: btn, isDown: true }));
|
||
});
|
||
|
||
canvas.addEventListener('mouseup', (e) => {
|
||
if (ws.readyState !== WebSocket.OPEN) return;
|
||
const r = canvas.getBoundingClientRect();
|
||
const scaleX = canvas.width / r.width;
|
||
const scaleY = canvas.height / r.height;
|
||
const btn = e.button === 2 ? 2 : e.button === 1 ? 3 : 1;
|
||
ws.send(JSON.stringify({ type: 'mouseButton', x: Math.round((e.clientX - r.left) * scaleX), y: Math.round((e.clientY - r.top) * scaleY), button: btn, isDown: false }));
|
||
});
|
||
|
||
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
|
||
|
||
// Keyboard events → WS (unicode-based for broad compatibility)
|
||
canvas.setAttribute('tabindex', '0');
|
||
canvas.addEventListener('keydown', (e) => {
|
||
e.preventDefault();
|
||
if (ws.readyState !== WebSocket.OPEN) return;
|
||
ws.send(JSON.stringify({ type: 'keyUnicode', code: e.key.charCodeAt(0) || 0, isDown: true }));
|
||
});
|
||
canvas.addEventListener('keyup', (e) => {
|
||
e.preventDefault();
|
||
if (ws.readyState !== WebSocket.OPEN) return;
|
||
ws.send(JSON.stringify({ type: 'keyUnicode', code: e.key.charCodeAt(0) || 0, isDown: false }));
|
||
});
|
||
|
||
return ws;
|
||
}
|
||
|
||
function renderRdpBitmap(ctx, bitmap) {
|
||
const { destLeft, destTop, destRight, destBottom, width, height, bitsPerPixel, isCompress, data } = bitmap;
|
||
if (!data) return;
|
||
|
||
const raw = atob(data);
|
||
const bytes = new Uint8Array(raw.length);
|
||
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
|
||
|
||
const drawW = destRight - destLeft;
|
||
const drawH = destBottom - destTop;
|
||
if (drawW <= 0 || drawH <= 0) return;
|
||
|
||
// Convert raw bitmap bytes to RGBA ImageData
|
||
// node-rdpjs sends uncompressed data as raw RGB/BGR pixels
|
||
const imgData = ctx.createImageData(width, height);
|
||
const pixels = imgData.data;
|
||
|
||
if (bitsPerPixel === 32) {
|
||
for (let i = 0, p = 0; i < bytes.length && p < pixels.length; i += 4, p += 4) {
|
||
pixels[p] = bytes[i + 2]; // R (BGRA → RGBA)
|
||
pixels[p + 1] = bytes[i + 1]; // G
|
||
pixels[p + 2] = bytes[i]; // B
|
||
pixels[p + 3] = 255;
|
||
}
|
||
} else if (bitsPerPixel === 24) {
|
||
for (let i = 0, p = 0; i < bytes.length && p < pixels.length; i += 3, p += 4) {
|
||
pixels[p] = bytes[i + 2]; // R
|
||
pixels[p + 1] = bytes[i + 1]; // G
|
||
pixels[p + 2] = bytes[i]; // B
|
||
pixels[p + 3] = 255;
|
||
}
|
||
} else if (bitsPerPixel === 16) {
|
||
for (let i = 0, p = 0; i < bytes.length - 1 && p < pixels.length; i += 2, p += 4) {
|
||
const v = bytes[i] | (bytes[i + 1] << 8);
|
||
pixels[p] = ((v >> 11) & 0x1f) << 3;
|
||
pixels[p + 1] = ((v >> 5) & 0x3f) << 2;
|
||
pixels[p + 2] = (v & 0x1f) << 3;
|
||
pixels[p + 3] = 255;
|
||
}
|
||
} else {
|
||
return; // unsupported depth
|
||
}
|
||
|
||
// Draw bitmap to an offscreen canvas then blit to main canvas at dest coords
|
||
const offscreen = document.createElement('canvas');
|
||
offscreen.width = width;
|
||
offscreen.height = height;
|
||
offscreen.getContext('2d').putImageData(imgData, 0, 0);
|
||
ctx.drawImage(offscreen, destLeft, destTop, drawW, drawH);
|
||
}
|
||
|
||
async function connectRdp(conn) {
|
||
openModal('modal-rdpViewer');
|
||
|
||
const labelEl = $('rdpViewerLabel');
|
||
if (labelEl) labelEl.textContent = conn.label || (conn.type === 'rdp' ? 'RDP Desktop' : 'VNC Desktop');
|
||
|
||
const protoBadge = $('rdpProtocolBadge');
|
||
if (protoBadge) {
|
||
protoBadge.textContent = conn.type.toUpperCase();
|
||
protoBadge.className = 'badge ' + (conn.type === 'rdp' ? 'badge-amber' : 'badge-cyan');
|
||
}
|
||
|
||
$('rdpStatusDot').className = 'terminal-status-dot';
|
||
$('rdpStateDisplay').textContent = 'Connecting…';
|
||
$('rdpStateDisplay').style.color = 'var(--amber)';
|
||
$('rdpResDisplay').textContent = (conn.width || 1280) + '×' + (conn.height || 720);
|
||
$('rdpViewerInfo').textContent = '';
|
||
|
||
// Clean up any existing session
|
||
await disconnectRdp();
|
||
|
||
const result = await sendToNative('startRdpSession', {
|
||
type: conn.type,
|
||
hsUrl: conn.hsUrl,
|
||
port: conn.port,
|
||
username: conn.username || '',
|
||
password: conn.password || '',
|
||
domain: '',
|
||
width: conn.width || 1280,
|
||
height: conn.height || 720,
|
||
label: conn.label || ''
|
||
});
|
||
|
||
if (!result || !result.ok) {
|
||
$('rdpStatusDot').className = 'terminal-status-dot disconnected';
|
||
$('rdpStateDisplay').textContent = 'Failed: ' + ((result && result.error) || 'Unknown error');
|
||
$('rdpStateDisplay').style.color = 'var(--red)';
|
||
return;
|
||
}
|
||
|
||
const { sessionId, wsPort } = result;
|
||
let viewer = null;
|
||
|
||
if (conn.type === 'vnc') {
|
||
viewer = initVncViewer(wsPort, conn);
|
||
activeRdpSession = { sessionId, wsPort, type: 'vnc', rfb: viewer, ws: null, conn };
|
||
} else {
|
||
viewer = initRdpViewer(wsPort, conn);
|
||
activeRdpSession = { sessionId, wsPort, type: 'rdp', rfb: null, ws: viewer, conn };
|
||
}
|
||
}
|
||
|
||
async function disconnectRdp() {
|
||
if (!activeRdpSession) return;
|
||
const { sessionId, rfb, ws } = activeRdpSession;
|
||
activeRdpSession = null;
|
||
|
||
if (rfb) { try { rfb.disconnect(); } catch (_) {} }
|
||
if (ws) { try { ws.close(); } catch (_) {} }
|
||
|
||
const container = $('rdpViewerContainer');
|
||
if (container) container.innerHTML = '';
|
||
|
||
if (sessionId) {
|
||
await sendToNative('stopRdpSession', { sessionId });
|
||
}
|
||
}
|
||
|
||
function setupRdpEvents() {
|
||
$('addRdpBtn')?.addEventListener('click', () => openAddRdpModal(null));
|
||
|
||
// Protocol radio change → update port default and username visibility
|
||
document.querySelectorAll('input[name="rdpProtocol"]').forEach(radio => {
|
||
radio.addEventListener('change', () => updateRdpProtocolUI(radio.value));
|
||
});
|
||
|
||
// Save connection
|
||
$('rdpConnSubmit')?.addEventListener('click', () => {
|
||
const label = $('rdpConnLabel').value.trim();
|
||
const hsUrl = $('rdpConnHsUrl').value.trim();
|
||
const type = document.querySelector('input[name="rdpProtocol"]:checked')?.value || 'vnc';
|
||
const port = parseInt($('rdpConnPort').value, 10) || (type === 'rdp' ? 3389 : 5900);
|
||
const width = parseInt($('rdpConnWidth').value, 10) || 1280;
|
||
const height = parseInt($('rdpConnHeight').value, 10) || 720;
|
||
const username = $('rdpConnUsername').value.trim();
|
||
const password = $('rdpConnPassword').value;
|
||
const editId = $('rdpConnEditId').value;
|
||
|
||
if (!hsUrl) { showModalError('modal-addRdp', 'rdpConnError', 'Holesail key is required'); return; }
|
||
if (!hsUrl.startsWith('hs://')) { showModalError('modal-addRdp', 'rdpConnError', 'Key must start with hs://'); return; }
|
||
|
||
const entry = { id: editId || generateRdpId(), label, hsUrl, type, port, width, height, username, password };
|
||
|
||
if (editId) {
|
||
const idx = rdpConnections.findIndex(c => c.id === editId);
|
||
if (idx !== -1) rdpConnections[idx] = entry;
|
||
} else {
|
||
rdpConnections.push(entry);
|
||
}
|
||
saveRdpConnections();
|
||
closeModal('modal-addRdp');
|
||
showToast(editId ? 'Connection updated' : 'Connection saved', 'success');
|
||
});
|
||
|
||
// Remove confirm
|
||
$('removeRdpConfirm')?.addEventListener('click', () => {
|
||
const id = $('removeRdpConfirm').dataset.rdpId;
|
||
rdpConnections = rdpConnections.filter(c => c.id !== id);
|
||
saveRdpConnections();
|
||
closeModal('modal-removeRdp');
|
||
showToast('Connection removed', 'success');
|
||
});
|
||
|
||
// Disconnect button
|
||
$('rdpDisconnectBtn')?.addEventListener('click', async () => {
|
||
await disconnectRdp();
|
||
closeModal('modal-rdpViewer');
|
||
});
|
||
|
||
// Fullscreen toggle
|
||
$('rdpFullscreenBtn')?.addEventListener('click', () => {
|
||
const modal = document.querySelector('#modal-rdpViewer .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';
|
||
}
|
||
});
|
||
|
||
// Clean up session when viewer modal is closed via backdrop/escape
|
||
const viewerModal = $('modal-rdpViewer');
|
||
if (viewerModal) {
|
||
const observer = new MutationObserver(() => {
|
||
if (!viewerModal.classList.contains('open') && activeRdpSession) {
|
||
disconnectRdp();
|
||
}
|
||
});
|
||
observer.observe(viewerModal, { attributes: true, attributeFilter: ['class'] });
|
||
}
|
||
}
|
||
|
||
// ── Backups ──────────────────────────────────────────────────────────────────
|
||
|
||
let pendingRestoreFilename = null;
|
||
let pendingDeleteFilename = null;
|
||
|
||
function formatBytes(bytes) {
|
||
if (bytes < 1024) return bytes + ' B';
|
||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||
return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
|
||
}
|
||
|
||
function updateBackupsTable(backups) {
|
||
const tbody = $('backupsTable');
|
||
if (!tbody) return;
|
||
const countEl = $('backupCount');
|
||
if (countEl) countEl.textContent = backups ? backups.length : 0;
|
||
if (!backups || backups.length === 0) {
|
||
tbody.innerHTML = `<tr><td colspan="4"><div class="empty-state">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="width:32px;height:32px;color:var(--text4)"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17,8 12,3 7,8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||
<div class="empty-state-title">No backups yet</div>
|
||
<div class="empty-state-desc">Click "Take Backup" to create your first backup</div>
|
||
</div></td></tr>`;
|
||
return;
|
||
}
|
||
tbody.innerHTML = backups.map((b) => {
|
||
const name = escapeHtml(b.filename);
|
||
const created = b.createdAt ? timeAgo(b.createdAt) : '—';
|
||
const size = b.size ? formatBytes(b.size) : '—';
|
||
return `<tr>
|
||
<td><span class="mono" style="font-size:12px;">${name}</span></td>
|
||
<td>${created}</td>
|
||
<td>${size}</td>
|
||
<td>
|
||
<div style="display:flex;gap:6px;">
|
||
<button class="btn btn-secondary btn-sm" data-backup-restore="${escapeHtml(b.filename)}">Restore</button>
|
||
<button class="btn btn-danger btn-sm" data-backup-delete="${escapeHtml(b.filename)}">Delete</button>
|
||
</div>
|
||
</td>
|
||
</tr>`;
|
||
}).join('');
|
||
}
|
||
|
||
function refreshBackups() {
|
||
chrome.runtime.sendMessage(
|
||
{ target: 'holesail-native', action: 'send', payload: { type: 'listBackups' } },
|
||
(response) => {
|
||
if (response && response.ok) {
|
||
updateBackupsTable(response.backups || []);
|
||
}
|
||
}
|
||
);
|
||
}
|
||
|
||
function setupBackupEvents() {
|
||
// Take backup button
|
||
$('btnTakeBackup')?.addEventListener('click', () => {
|
||
const btn = $('btnTakeBackup');
|
||
if (btn) { btn.disabled = true; btn.textContent = 'Creating…'; }
|
||
chrome.runtime.sendMessage(
|
||
{ target: 'holesail-native', action: 'send', payload: { type: 'createBackup' } },
|
||
(response) => {
|
||
if (btn) {
|
||
btn.disabled = false;
|
||
btn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17,8 12,3 7,8"/><line x1="12" y1="3" x2="12" y2="15"/></svg> Take Backup`;
|
||
}
|
||
if (response && response.ok) {
|
||
showToast('Backup created: ' + response.filename, 'success');
|
||
refreshBackups();
|
||
} else {
|
||
showToast(response?.error || 'Backup failed', 'error');
|
||
}
|
||
}
|
||
);
|
||
});
|
||
|
||
// Restore / Delete buttons (event delegation on the table)
|
||
$('backupsTable')?.addEventListener('click', (e) => {
|
||
const restoreBtn = e.target.closest('[data-backup-restore]');
|
||
if (restoreBtn) {
|
||
pendingRestoreFilename = restoreBtn.dataset.backupRestore;
|
||
const nameEl = $('restoreBackupName');
|
||
if (nameEl) nameEl.textContent = pendingRestoreFilename;
|
||
openModal('modal-restoreBackup');
|
||
return;
|
||
}
|
||
const deleteBtn = e.target.closest('[data-backup-delete]');
|
||
if (deleteBtn) {
|
||
pendingDeleteFilename = deleteBtn.dataset.backupDelete;
|
||
const nameEl = $('deleteBackupName');
|
||
if (nameEl) nameEl.textContent = pendingDeleteFilename;
|
||
openModal('modal-deleteBackup');
|
||
}
|
||
});
|
||
|
||
// Restore confirm
|
||
$('restoreBackupConfirm')?.addEventListener('click', () => {
|
||
if (!pendingRestoreFilename) return;
|
||
const btn = $('restoreBackupConfirm');
|
||
if (btn) btn.disabled = true;
|
||
chrome.runtime.sendMessage(
|
||
{ target: 'holesail-native', action: 'send', payload: { type: 'restoreBackup', payload: { filename: pendingRestoreFilename } } },
|
||
(response) => {
|
||
if (btn) btn.disabled = false;
|
||
if (response && response.ok) {
|
||
closeModal('modal-restoreBackup');
|
||
const certsNote = response.restoredCerts ? ' Certificates restored.' : '';
|
||
showToast('Backup restored.' + certsNote + ' Restart tunnels to apply changes.', 'success');
|
||
pendingRestoreFilename = null;
|
||
refresh();
|
||
} else {
|
||
showModalError('modal-restoreBackup', 'restoreBackupError', response?.error || 'Restore failed');
|
||
}
|
||
}
|
||
);
|
||
});
|
||
|
||
// Delete confirm
|
||
$('deleteBackupConfirm')?.addEventListener('click', () => {
|
||
if (!pendingDeleteFilename) return;
|
||
const btn = $('deleteBackupConfirm');
|
||
if (btn) btn.disabled = true;
|
||
chrome.runtime.sendMessage(
|
||
{ target: 'holesail-native', action: 'send', payload: { type: 'deleteBackup', payload: { filename: pendingDeleteFilename } } },
|
||
(response) => {
|
||
if (btn) btn.disabled = false;
|
||
if (response && response.ok) {
|
||
closeModal('modal-deleteBackup');
|
||
showToast('Backup deleted', 'success');
|
||
pendingDeleteFilename = null;
|
||
refreshBackups();
|
||
} else {
|
||
showModalError('modal-deleteBackup', 'deleteBackupError', response?.error || 'Delete failed');
|
||
}
|
||
}
|
||
);
|
||
});
|
||
}
|
||
|
||
// ── Fetch state ─────────────────────────────────────────────────────────────
|
||
|
||
async function fetchState() {
|
||
return new Promise((resolve) => {
|
||
chrome.runtime.sendMessage(
|
||
{ target: 'holesail-native', action: 'getState' },
|
||
(response) => {
|
||
if (response && response.ok) resolve(response.state);
|
||
else resolve(null);
|
||
}
|
||
);
|
||
});
|
||
}
|
||
|
||
// ── Dashboard / Overview ─────────────────────────────────────────────────────
|
||
|
||
function updateDashboard(state) {
|
||
currentState = state;
|
||
const servers = state.servers || [];
|
||
const virtualHosts = state.virtualHosts || [];
|
||
|
||
// Sidebar status
|
||
const dot = $('sidebarDot');
|
||
const statusText = $('sidebarStatus');
|
||
if (state.hostConnected) {
|
||
dot?.classList.add('connected');
|
||
dot?.classList.remove('disconnected');
|
||
if (statusText) statusText.textContent = 'Connected';
|
||
} else {
|
||
dot?.classList.remove('connected');
|
||
dot?.classList.add('disconnected');
|
||
if (statusText) statusText.textContent = 'Disconnected';
|
||
}
|
||
|
||
// Stats
|
||
const dashConnections = $('dashConnections');
|
||
const dashSwarms = $('dashSwarms');
|
||
const dashTabs = $('dashTabs');
|
||
const dashUptime = $('dashUptime');
|
||
if (dashConnections) dashConnections.textContent = virtualHosts.length;
|
||
if (dashSwarms) dashSwarms.textContent = servers.length;
|
||
if (dashTabs) dashTabs.textContent = state.proxyPort != null ? state.proxyPort : '—';
|
||
if (dashUptime) dashUptime.textContent = state.caInstalled ? 'Trusted' : 'Not trusted';
|
||
|
||
// CA stat icon color
|
||
const caIcon = $('caStatIcon');
|
||
if (caIcon) {
|
||
caIcon.style.background = state.caInstalled ? 'var(--green-dim)' : 'var(--red-dim)';
|
||
caIcon.style.color = state.caInstalled ? 'var(--green)' : 'var(--red)';
|
||
}
|
||
|
||
// Nav badges
|
||
const swarmCount = $('swarmCount');
|
||
const connCount = $('connCount');
|
||
const tabCount = $('tabCount');
|
||
if (swarmCount) swarmCount.textContent = servers.length;
|
||
if (connCount) connCount.textContent = virtualHosts.length;
|
||
if (tabCount) tabCount.textContent = state.caInstalled ? 'CA ✓' : 'CA';
|
||
|
||
// Virtual hosts preview on overview page
|
||
const recentConnBody = $('recentConnections');
|
||
if (recentConnBody) {
|
||
if (virtualHosts.length === 0) {
|
||
recentConnBody.innerHTML = `<tr><td colspan="4" class="empty">No virtual hosts yet — <button class="btn btn-ghost btn-sm" id="addVhostFromEmpty" style="display:inline-flex;">Add one</button></td></tr>`;
|
||
$('addVhostFromEmpty')?.addEventListener('click', () => openModal('modal-addVhost'));
|
||
} else {
|
||
const proxyPort = state.proxyPort || 8443;
|
||
recentConnBody.innerHTML = virtualHosts.slice(0, 8).map(v => {
|
||
const hostname = v.hostname || v.id || '';
|
||
const openUrl = `https://${hostname}`;
|
||
return `
|
||
<tr>
|
||
<td><span class="mono-chip">${escapeHtml(hostname)}</span></td>
|
||
<td>${stateTag(v.state)}</td>
|
||
<td class="mono" style="font-size:11px;color:var(--text3);">${v.localHost ? v.localHost + ':' + v.localPort : '—'}</td>
|
||
<td><a href="${openUrl}" target="_blank" rel="noopener" class="btn btn-ghost btn-sm">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:12px;height:12px;"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15,3 21,3 21,9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||
Open
|
||
</a></td>
|
||
</tr>`;
|
||
}).join('');
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Virtual Hosts table ──────────────────────────────────────────────────────
|
||
|
||
function updateConnectionsTable(state) {
|
||
const tbody = $('connectionsTable');
|
||
if (!tbody) return;
|
||
const virtualHosts = state.virtualHosts || [];
|
||
const proxyPort = state.proxyPort || 8443;
|
||
|
||
if (virtualHosts.length === 0) {
|
||
tbody.innerHTML = `
|
||
<tr><td colspan="5">
|
||
<div class="empty-state">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="2"/><circle cx="4" cy="6" r="2"/><circle cx="20" cy="6" r="2"/><circle cx="4" cy="18" r="2"/><circle cx="20" cy="18" r="2"/><line x1="6" y1="6" x2="10" y2="11"/><line x1="18" y1="6" x2="14" y2="11"/><line x1="6" y1="18" x2="10" y2="13"/><line x1="18" y1="18" x2="14" y2="13"/></svg>
|
||
<div class="empty-state-title">No virtual hosts</div>
|
||
<div class="empty-state-desc">Click "Add Host" to assign a hostname to an hs:// tunnel</div>
|
||
</div>
|
||
</td></tr>`;
|
||
return;
|
||
}
|
||
|
||
tbody.innerHTML = virtualHosts.map(v => {
|
||
const hostname = v.hostname || v.id || '';
|
||
const hsUrl = v.hsUrl || '';
|
||
const backend = (v.localHost && v.localPort != null) ? v.localHost + ':' + v.localPort : '—';
|
||
const openUrl = `https://${hostname}`;
|
||
const safeHostname = hostname.replace(/"/g, '"');
|
||
const needsReconnect = v.state === 'error' || v.state === 'closed';
|
||
return `
|
||
<tr>
|
||
<td><span class="mono-chip">${escapeHtml(hostname)}</span></td>
|
||
<td>
|
||
<div style="display:flex;align-items:center;gap:6px;">
|
||
<span class="mono" title="${escapeHtml(hsUrl)}" style="color:var(--text3);font-size:11px;">${truncate(hsUrl, 28)}</span>
|
||
<button class="btn-icon copy-btn" data-copy="${escapeHtml(hsUrl)}" title="Copy hs:// URL" style="flex-shrink:0;">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||
<span class="copy-tooltip">Copied!</span>
|
||
</button>
|
||
</div>
|
||
</td>
|
||
<td class="mono" style="font-size:11px;color:var(--text3);">${escapeHtml(backend)}</td>
|
||
<td>${stateTag(v.state)}</td>
|
||
<td>
|
||
<div style="display:flex;align-items:center;gap:6px;">
|
||
<a href="${openUrl}" target="_blank" rel="noopener" class="btn btn-secondary btn-sm">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15,3 21,3 21,9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||
Open
|
||
</a>
|
||
${needsReconnect ? `<button class="btn btn-secondary btn-sm" data-reconnect-vhost="${safeHostname}" data-hs-url="${escapeHtml(hsUrl)}" title="Reconnect tunnel">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23,4 23,10 17,10"/><path d="M20.49 15a9 9 0 1 1-.07-8.13"/></svg>
|
||
Reconnect
|
||
</button>` : ''}
|
||
<button class="btn btn-danger btn-sm" data-remove-vhost="${safeHostname}">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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>
|
||
Remove
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>`;
|
||
}).join('');
|
||
|
||
// Wire copy buttons
|
||
tbody.querySelectorAll('[data-copy]').forEach(btn => {
|
||
btn.addEventListener('click', () => copyToClipboard(btn.dataset.copy, btn));
|
||
});
|
||
|
||
// Wire reconnect buttons
|
||
tbody.querySelectorAll('[data-reconnect-vhost]').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const hostname = btn.dataset.reconnectVhost;
|
||
const hsUrl = btn.dataset.hsUrl;
|
||
btn.disabled = true;
|
||
btn.textContent = 'Reconnecting…';
|
||
chrome.runtime.sendMessage(
|
||
{ target: 'holesail-native', action: 'send', payload: { type: 'setVirtualHost', payload: { hostname, hsUrl } } },
|
||
(response) => {
|
||
if (response?.ok) {
|
||
showToast('Tunnel reconnecting…', 'success');
|
||
} else {
|
||
showToast(response?.error || 'Reconnect failed', 'error');
|
||
}
|
||
refresh();
|
||
}
|
||
);
|
||
});
|
||
});
|
||
|
||
// Wire remove buttons
|
||
tbody.querySelectorAll('[data-remove-vhost]').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const hostname = btn.dataset.removeVhost;
|
||
const nameEl = $('removeVhostName');
|
||
if (nameEl) nameEl.textContent = hostname;
|
||
$('removeVhostConfirm').dataset.hostname = hostname;
|
||
openModal('modal-removeVhost');
|
||
});
|
||
});
|
||
}
|
||
|
||
// ── Servers table ────────────────────────────────────────────────────────────
|
||
|
||
function updateSwarmsTable(state) {
|
||
const tbody = $('swarmsTable');
|
||
if (!tbody) return;
|
||
const servers = state.servers || [];
|
||
|
||
if (servers.length === 0) {
|
||
tbody.innerHTML = `
|
||
<tr><td colspan="5">
|
||
<div class="empty-state">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="3" width="6" height="6" rx="1"/><rect x="16" y="3" width="6" height="6" rx="1"/><rect x="9" y="15" width="6" height="6" rx="1"/><line x1="5" y1="9" x2="12" y2="15"/><line x1="19" y1="9" x2="12" y2="15"/></svg>
|
||
<div class="empty-state-title">No server tunnels</div>
|
||
<div class="empty-state-desc">Click "New Server" to expose a local port as an hs:// tunnel</div>
|
||
</div>
|
||
</td></tr>`;
|
||
return;
|
||
}
|
||
|
||
tbody.innerHTML = servers.map(s => {
|
||
const id = s.id || s.serverId || '';
|
||
const url = s.url || s.hsUrl || '';
|
||
const safeId = id.replace(/"/g, '"');
|
||
return `
|
||
<tr>
|
||
<td class="mono" style="font-size:11px;color:var(--text3);">${escapeHtml(truncate(id, 20))}</td>
|
||
<td style="font-weight:600;color:var(--text);">${s.port ?? '—'}</td>
|
||
<td>
|
||
<div style="display:flex;align-items:center;gap:6px;">
|
||
<span class="mono" title="${escapeHtml(url)}" style="font-size:11px;color:var(--cyan);">${truncate(url, 30)}</span>
|
||
${url ? `<button class="btn-icon copy-btn" data-copy="${escapeHtml(url)}" title="Copy hs:// URL">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||
<span class="copy-tooltip">Copied!</span>
|
||
</button>` : ''}
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<span class="badge badge-neutral" style="margin-right:4px;">${s.udp ? 'UDP' : 'TCP'}</span>${s.secure ? `<span class="badge badge-green">secure</span>` : `<span class="badge badge-neutral">plain</span>`}
|
||
</td>
|
||
<td>
|
||
<div style="display:flex;align-items:center;gap:6px;">
|
||
<button class="btn btn-ghost btn-sm" data-edit-server="${safeId}" title="Edit server">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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>
|
||
Edit
|
||
</button>
|
||
<button class="btn btn-danger btn-sm" data-stop-server="${safeId}">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/></svg>
|
||
Stop
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>`;
|
||
}).join('');
|
||
|
||
tbody.querySelectorAll('[data-copy]').forEach(btn => {
|
||
btn.addEventListener('click', () => copyToClipboard(btn.dataset.copy, btn));
|
||
});
|
||
|
||
tbody.querySelectorAll('[data-edit-server]').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const serverId = btn.dataset.editServer;
|
||
const server = (state.servers || []).find(s => (s.id || s.serverId) === serverId);
|
||
if (!server) return;
|
||
$('serverEditId').value = serverId;
|
||
const portEl = $('startServerPort');
|
||
const hostEl = $('startServerHost');
|
||
const secureEl = $('startServerSecure');
|
||
if (portEl) portEl.value = server.port ?? 3000;
|
||
if (hostEl) hostEl.value = server.host ?? '127.0.0.1';
|
||
if (secureEl) secureEl.checked = server.secure !== false;
|
||
const udpEl = document.querySelector('input[name="startServerProtocol"][value="' + (server.udp ? 'udp' : 'tcp') + '"]');
|
||
if (udpEl) udpEl.checked = true;
|
||
const titleEl = $('modal-startServer-title');
|
||
if (titleEl) titleEl.textContent = 'Edit Server Tunnel';
|
||
const submitEl = $('startServerSubmit');
|
||
if (submitEl) submitEl.textContent = 'Save Changes';
|
||
openModal('modal-startServer');
|
||
});
|
||
});
|
||
|
||
tbody.querySelectorAll('[data-stop-server]').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const serverId = btn.dataset.stopServer;
|
||
const nameEl = $('stopServerName');
|
||
if (nameEl) nameEl.textContent = serverId;
|
||
$('stopServerConfirm').dataset.serverId = serverId;
|
||
openModal('modal-stopServer');
|
||
});
|
||
});
|
||
}
|
||
|
||
// ── Proxy & CA page ──────────────────────────────────────────────────────────
|
||
|
||
function updateTabsTable(state) {
|
||
const portEl = $('proxyInfoPort');
|
||
const caEl = $('proxyInfoCA');
|
||
if (portEl) portEl.textContent = state.proxyPort != null ? state.proxyPort : '—';
|
||
if (caEl) {
|
||
caEl.textContent = state.caInstalled ? 'Installed' : 'Not installed';
|
||
caEl.style.color = state.caInstalled ? 'var(--green)' : 'var(--red)';
|
||
}
|
||
|
||
// Show validator only when CA is installed
|
||
const validatorCard = $('certValidatorCard');
|
||
if (validatorCard) validatorCard.style.display = state.caInstalled ? '' : 'none';
|
||
|
||
if (state.caInstalled) {
|
||
renderValidatorTable(state.virtualHosts || []);
|
||
}
|
||
}
|
||
|
||
// ── Certificate Validator ─────────────────────────────────────────────────────
|
||
|
||
// Per-host test results: hostname -> { status, tlsOk, httpStatus, ms, error }
|
||
const validationResults = new Map();
|
||
|
||
function renderValidatorTable(virtualHosts) {
|
||
const tbody = $('certValidatorBody');
|
||
if (!tbody) return;
|
||
|
||
if (virtualHosts.length === 0) {
|
||
tbody.innerHTML = `<tr><td colspan="5" class="empty">No virtual hosts to test — add one in Virtual Hosts</td></tr>`;
|
||
return;
|
||
}
|
||
|
||
tbody.innerHTML = virtualHosts.map(v => {
|
||
const hostname = v.hostname || '';
|
||
const r = validationResults.get(hostname);
|
||
return `<tr id="vrow-${CSS.escape(hostname)}">
|
||
<td><span class="mono-chip">${escapeHtml(hostname)}</span></td>
|
||
<td>${renderTlsCell(hostname, r)}</td>
|
||
<td>${renderStatusCell(hostname, r)}</td>
|
||
<td>${renderTimeCell(hostname, r)}</td>
|
||
<td>
|
||
<button class="btn btn-secondary btn-sm" data-validate-host="${escapeHtml(hostname)}" id="vbtn-${CSS.escape(hostname)}">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:12px;height:12px;"><polygon points="5,3 19,12 5,21"/></svg>
|
||
Test
|
||
</button>
|
||
</td>
|
||
</tr>`;
|
||
}).join('');
|
||
|
||
tbody.querySelectorAll('[data-validate-host]').forEach(btn => {
|
||
btn.addEventListener('click', () => runValidation(btn.dataset.validateHost));
|
||
});
|
||
}
|
||
|
||
function renderTlsCell(hostname, r) {
|
||
if (!r) return `<span class="badge badge-neutral">—</span>`;
|
||
if (r.status === 'running') return `<span class="badge badge-amber">Testing…</span>`;
|
||
if (r.tlsOk) return `<span class="badge badge-green">✓ Trusted</span>`;
|
||
return `<span class="badge badge-red" title="${escapeHtml(r.error || '')}">✗ Untrusted</span>`;
|
||
}
|
||
|
||
function renderStatusCell(hostname, r) {
|
||
if (!r || r.status === 'running') return `<span style="color:var(--text4);">—</span>`;
|
||
if (!r.tlsOk) return `<span style="color:var(--text4);">—</span>`;
|
||
if (r.httpStatus == null) return `<span class="badge badge-red">No response</span>`;
|
||
const cls = r.httpStatus < 400 ? 'badge-green' : r.httpStatus < 500 ? 'badge-amber' : 'badge-red';
|
||
return `<span class="badge ${cls}">${r.httpStatus}</span>`;
|
||
}
|
||
|
||
function renderTimeCell(hostname, r) {
|
||
if (!r || r.status === 'running' || !r.tlsOk || r.ms == null) return `<span style="color:var(--text4);">—</span>`;
|
||
const color = r.ms < 500 ? 'var(--green)' : r.ms < 2000 ? 'var(--amber)' : 'var(--red)';
|
||
return `<span style="font-family:'JetBrains Mono',monospace;font-size:12px;color:${color};">${r.ms}ms</span>`;
|
||
}
|
||
|
||
function updateValidatorRow(hostname) {
|
||
const r = validationResults.get(hostname);
|
||
const row = document.getElementById('vrow-' + CSS.escape(hostname));
|
||
if (!row) return;
|
||
const cells = row.querySelectorAll('td');
|
||
if (cells[1]) cells[1].innerHTML = renderTlsCell(hostname, r);
|
||
if (cells[2]) cells[2].innerHTML = renderStatusCell(hostname, r);
|
||
if (cells[3]) cells[3].innerHTML = renderTimeCell(hostname, r);
|
||
const btn = document.getElementById('vbtn-' + CSS.escape(hostname));
|
||
if (btn) {
|
||
btn.disabled = r && r.status === 'running';
|
||
btn.innerHTML = (r && r.status === 'running')
|
||
? `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:12px;height:12px;animation:spin 1s linear infinite;"><polyline points="23,4 23,10 17,10"/><path d="M20.49 15a9 9 0 1 1-.07-8.13"/></svg> Testing…`
|
||
: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:12px;height:12px;"><polygon points="5,3 19,12 5,21"/></svg> Test`;
|
||
}
|
||
}
|
||
|
||
async function runValidation(hostname) {
|
||
validationResults.set(hostname, { status: 'running' });
|
||
updateValidatorRow(hostname);
|
||
|
||
const url = `https://${hostname}`;
|
||
const start = Date.now();
|
||
try {
|
||
// fetch() goes through the PAC proxy → HTTPS proxy → Holesail tunnel.
|
||
// If the CA is not trusted by the browser, this throws a TypeError (net::ERR_CERT_AUTHORITY_INVALID).
|
||
// mode: 'no-cors' avoids CORS errors from opaque responses — we only care about TLS + reachability.
|
||
const resp = await fetch(url, { mode: 'no-cors', cache: 'no-store', signal: AbortSignal.timeout(15000) });
|
||
const ms = Date.now() - start;
|
||
// 'opaque' response (no-cors) means TLS succeeded and server responded — status is 0 but that's expected
|
||
const httpStatus = resp.type === 'opaque' ? null : resp.status;
|
||
validationResults.set(hostname, { status: 'done', tlsOk: true, httpStatus, ms });
|
||
} catch (err) {
|
||
const ms = Date.now() - start;
|
||
const msg = err.message || String(err);
|
||
// Distinguish TLS failure from tunnel/network failure
|
||
const isTlsError = msg.includes('ERR_CERT') || msg.includes('certificate') || msg.includes('SSL') || msg.includes('CERT');
|
||
validationResults.set(hostname, { status: 'done', tlsOk: false, ms, error: msg, isTlsError });
|
||
}
|
||
updateValidatorRow(hostname);
|
||
}
|
||
|
||
async function runAllValidations(virtualHosts) {
|
||
if (!virtualHosts || virtualHosts.length === 0) return;
|
||
// Run sequentially to avoid hammering the proxy
|
||
for (const v of virtualHosts) {
|
||
await runValidation(v.hostname || '');
|
||
}
|
||
}
|
||
|
||
function setupCertValidator() {
|
||
$('runAllValidationsBtn')?.addEventListener('click', () => {
|
||
const state = currentState;
|
||
if (state) runAllValidations(state.virtualHosts || []);
|
||
});
|
||
}
|
||
|
||
// ── Service Tunnels table ─────────────────────────────────────────────────────
|
||
|
||
function updateServiceTunnelsTable(state) {
|
||
const tbody = $('serviceTunnelsTable');
|
||
if (!tbody) return;
|
||
const tunnels = state.serviceTunnels || [];
|
||
|
||
const countEl = $('serviceTunnelCount');
|
||
if (countEl) countEl.textContent = tunnels.length;
|
||
|
||
if (tunnels.length === 0) {
|
||
tbody.innerHTML = `
|
||
<tr><td colspan="5">
|
||
<div class="empty-state">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
|
||
<div class="empty-state-title">No service tunnels</div>
|
||
<div class="empty-state-desc">Click "Add Tunnel" to connect a remote TCP/UDP service via Holesail</div>
|
||
</div>
|
||
</td></tr>`;
|
||
return;
|
||
}
|
||
|
||
tbody.innerHTML = tunnels.map(t => {
|
||
const id = t.id || '';
|
||
const label = t.label || id;
|
||
const hsUrl = t.hsUrl || '';
|
||
const localPort = t.localPort != null ? t.localPort : '—';
|
||
const localAddr = t.localPort != null ? `127.0.0.1:${t.localPort}` : '—';
|
||
const safeId = id.replace(/"/g, '"');
|
||
return `
|
||
<tr>
|
||
<td style="font-weight:600;color:var(--text);">${escapeHtml(label)}</td>
|
||
<td>
|
||
<div style="display:flex;align-items:center;gap:6px;">
|
||
<span class="mono" title="${escapeHtml(hsUrl)}" style="font-size:11px;color:var(--text3);">${truncate(hsUrl, 28)}</span>
|
||
${hsUrl ? `<button class="btn-icon copy-btn" data-copy="${escapeHtml(hsUrl)}" title="Copy hs:// key">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||
<span class="copy-tooltip">Copied!</span>
|
||
</button>` : ''}
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<div style="display:flex;align-items:center;gap:6px;">
|
||
<span style="font-weight:600;color:var(--cyan);font-family:'JetBrains Mono',monospace;font-size:13px;">${escapeHtml(localAddr)}</span>
|
||
${t.localPort != null ? `<button class="btn-icon copy-btn" data-copy="${escapeHtml(localAddr)}" title="Copy local address">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||
<span class="copy-tooltip">Copied!</span>
|
||
</button>` : ''}
|
||
</div>
|
||
</td>
|
||
<td>${stateTag(t.state)}</td>
|
||
<td>
|
||
<div style="display:flex;align-items:center;gap:6px;">
|
||
<button class="btn btn-ghost btn-sm" data-edit-service-tunnel="${safeId}" title="Edit tunnel">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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>
|
||
Edit
|
||
</button>
|
||
<button class="btn btn-danger btn-sm" data-remove-service-tunnel="${safeId}">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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>
|
||
Remove
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>`;
|
||
}).join('');
|
||
|
||
tbody.querySelectorAll('[data-copy]').forEach(btn => {
|
||
btn.addEventListener('click', () => copyToClipboard(btn.dataset.copy, btn));
|
||
});
|
||
|
||
tbody.querySelectorAll('[data-edit-service-tunnel]').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const tunnelId = btn.dataset.editServiceTunnel;
|
||
const tunnel = (state.serviceTunnels || []).find(t => t.id === tunnelId);
|
||
if (!tunnel) return;
|
||
$('serviceTunnelEditId').value = tunnelId;
|
||
$('serviceTunnelLabel').value = tunnel.label || '';
|
||
$('serviceTunnelHsUrl').value = tunnel.hsUrl || '';
|
||
$('serviceTunnelLocalPort').value = tunnel.localPort != null ? tunnel.localPort : '';
|
||
const titleEl = $('modal-addServiceTunnel-title');
|
||
if (titleEl) titleEl.textContent = 'Edit Service Tunnel';
|
||
const submitEl = $('serviceTunnelSubmit');
|
||
if (submitEl) submitEl.textContent = 'Save';
|
||
openModal('modal-addServiceTunnel');
|
||
});
|
||
});
|
||
|
||
tbody.querySelectorAll('[data-remove-service-tunnel]').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const tunnelId = btn.dataset.removeServiceTunnel;
|
||
const tunnel = (state.serviceTunnels || []).find(t => t.id === tunnelId);
|
||
const nameEl = $('removeServiceTunnelName');
|
||
if (nameEl) nameEl.textContent = (tunnel && tunnel.label) || tunnelId;
|
||
$('removeServiceTunnelConfirm').dataset.tunnelId = tunnelId;
|
||
openModal('modal-removeServiceTunnel');
|
||
});
|
||
});
|
||
}
|
||
|
||
// ── Refresh ──────────────────────────────────────────────────────────────────
|
||
|
||
async function refresh() {
|
||
const state = await fetchState();
|
||
if (state) {
|
||
// Sync SSH connections from native host state (passwords are not persisted)
|
||
if (Array.isArray(state.sshConnections)) {
|
||
// Merge: keep in-memory passwords for connections that already exist
|
||
sshConnections = state.sshConnections.map(c => {
|
||
const existing = sshConnections.find(e => e.id === c.id);
|
||
return existing ? { ...c, password: existing.password || '' } : c;
|
||
});
|
||
renderSshGrid();
|
||
}
|
||
// Sync RDP connections from native host state (passwords are not persisted)
|
||
if (Array.isArray(state.rdpConnections)) {
|
||
// Merge: keep in-memory passwords for connections that already exist
|
||
rdpConnections = state.rdpConnections.map(c => {
|
||
const existing = rdpConnections.find(e => e.id === c.id);
|
||
return existing ? { ...c, password: existing.password || '' } : c;
|
||
});
|
||
renderRdpGrid();
|
||
}
|
||
// Sync settings from native host state
|
||
if (state.settings && typeof state.settings === 'object') {
|
||
settings = { ...SETTINGS_DEFAULTS, ...state.settings };
|
||
}
|
||
updateDashboard(state);
|
||
updateConnectionsTable(state);
|
||
updateSwarmsTable(state);
|
||
updateTabsTable(state);
|
||
updateServiceTunnelsTable(state);
|
||
updateSettingsUI();
|
||
}
|
||
const sshCountEl = $('sshCount');
|
||
if (sshCountEl) sshCountEl.textContent = sshConnections.length;
|
||
refreshBackups();
|
||
}
|
||
|
||
// ── Events ───────────────────────────────────────────────────────────────────
|
||
|
||
function setupEvents() {
|
||
// FAB refresh
|
||
$('refreshBtn')?.addEventListener('click', refresh);
|
||
|
||
// Toggle switches
|
||
document.querySelectorAll('.toggle').forEach(toggle => {
|
||
toggle.addEventListener('click', () => toggle.classList.toggle('active'));
|
||
});
|
||
|
||
// Save settings
|
||
$('btnSaveSettings')?.addEventListener('click', saveSettings);
|
||
|
||
// Reset settings to defaults
|
||
$('btnResetSettings')?.addEventListener('click', () => {
|
||
settings = { ...SETTINGS_DEFAULTS };
|
||
chrome.runtime.sendMessage(
|
||
{ target: 'holesail-native', action: 'send', payload: { type: 'updateSettings', payload: { ...settings } } },
|
||
(response) => {
|
||
if (response && response.settings) settings = { ...SETTINGS_DEFAULTS, ...response.settings };
|
||
updateSettingsUI();
|
||
showToast('Settings reset to defaults', 'success');
|
||
}
|
||
);
|
||
});
|
||
|
||
// ── Export connections ───────────────────────────────────────────────────
|
||
$('btnExportConnections')?.addEventListener('click', async () => {
|
||
const state = currentState || {};
|
||
const exportData = {
|
||
version: 1,
|
||
exportedAt: new Date().toISOString(),
|
||
virtualHosts: state.virtualHosts || [],
|
||
serviceTunnels: (state.serviceTunnels || []).map(t => ({ label: t.label, hsUrl: t.hsUrl, localPort: t.localPort })),
|
||
sshConnections: sshConnections.map(c => ({ label: c.label, hsUrl: c.hsUrl, username: c.username }))
|
||
};
|
||
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = 'holesail-connections-' + new Date().toISOString().slice(0, 10) + '.json';
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
showToast('Connections exported', 'success');
|
||
});
|
||
|
||
// ── Import connections ───────────────────────────────────────────────────
|
||
$('btnImportConnections')?.addEventListener('click', () => {
|
||
$('importFileInput')?.click();
|
||
});
|
||
|
||
$('importFileInput')?.addEventListener('change', async (e) => {
|
||
const file = e.target.files && e.target.files[0];
|
||
if (!file) return;
|
||
const statusEl = $('importStatus');
|
||
try {
|
||
const text = await file.text();
|
||
const data = JSON.parse(text);
|
||
if (!data || typeof data !== 'object') throw new Error('Invalid file format');
|
||
|
||
let imported = 0;
|
||
let failed = 0;
|
||
|
||
// Import virtual hosts
|
||
for (const v of (data.virtualHosts || [])) {
|
||
if (!v.hostname || !v.hsUrl) continue;
|
||
await new Promise(resolve => {
|
||
chrome.runtime.sendMessage(
|
||
{ target: 'holesail-native', action: 'send', payload: { type: 'setVirtualHost', payload: { hostname: v.hostname, hsUrl: v.hsUrl } } },
|
||
(r) => { if (r?.ok) imported++; else failed++; resolve(); }
|
||
);
|
||
});
|
||
}
|
||
|
||
// Import service tunnels
|
||
for (const t of (data.serviceTunnels || [])) {
|
||
if (!t.label || !t.hsUrl || !t.localPort) continue;
|
||
await new Promise(resolve => {
|
||
chrome.runtime.sendMessage(
|
||
{ target: 'holesail-native', action: 'send', payload: { type: 'startServiceTunnel', payload: { label: t.label, hsUrl: t.hsUrl, localPort: t.localPort } } },
|
||
(r) => { if (r?.ok) imported++; else failed++; resolve(); }
|
||
);
|
||
});
|
||
}
|
||
|
||
// Import SSH connections
|
||
for (const c of (data.sshConnections || [])) {
|
||
if (!c.hsUrl || !c.username) continue;
|
||
const exists = sshConnections.some(x => x.hsUrl === c.hsUrl && x.username === c.username);
|
||
if (!exists) {
|
||
sshConnections.push({ id: generateSshId(), label: c.label || '', hsUrl: c.hsUrl, username: c.username });
|
||
imported++;
|
||
}
|
||
}
|
||
if (data.sshConnections && data.sshConnections.length) saveSshConnections();
|
||
|
||
if (statusEl) statusEl.textContent = `Imported ${imported} item(s)${failed ? ', ' + failed + ' failed' : ''}.`;
|
||
showToast(`Imported ${imported} connection(s)`, 'success');
|
||
refresh();
|
||
} catch (err) {
|
||
if (statusEl) statusEl.textContent = 'Import failed: ' + (err.message || 'Unknown error');
|
||
showToast('Import failed', 'error');
|
||
}
|
||
// Reset file input so the same file can be re-imported
|
||
e.target.value = '';
|
||
});
|
||
|
||
// ── Add Virtual Host ────────────────────────────────────────────────────
|
||
$('addVhostBtn')?.addEventListener('click', () => openModal('modal-addVhost'));
|
||
|
||
$('addVhostSubmit')?.addEventListener('click', () => {
|
||
const hostnameEl = $('addVhostHostname');
|
||
const hsUrlEl = $('addVhostHsUrl');
|
||
// Sanitize: strip protocol, port, path, trailing slashes
|
||
let hostname = (hostnameEl?.value || '').trim();
|
||
hostname = hostname.replace(/^https?:\/\//i, '').replace(/[/:?#].*$/, '').toLowerCase().trim();
|
||
const hsUrl = (hsUrlEl?.value || '').trim();
|
||
if (!hostname) { showModalError('modal-addVhost', 'addVhostError', 'Hostname is required'); return; }
|
||
if (!hostname.endsWith('.hole.sail')) { showModalError('modal-addVhost', 'addVhostError', 'Hostname must end with .hole.sail (e.g. myapp.hole.sail)'); return; }
|
||
if (!hsUrl || !hsUrl.startsWith('hs://')) { showModalError('modal-addVhost', 'addVhostError', 'Enter a valid hs:// URL'); return; }
|
||
const btn = $('addVhostSubmit');
|
||
if (btn) { btn.disabled = true; btn.textContent = 'Adding…'; }
|
||
chrome.runtime.sendMessage(
|
||
{ target: 'holesail-native', action: 'send', payload: { type: 'setVirtualHost', payload: { hostname, hsUrl } } },
|
||
(response) => {
|
||
if (btn) { btn.disabled = false; btn.textContent = 'Add Host'; }
|
||
if (response?.ok) {
|
||
if (hostnameEl) hostnameEl.value = '';
|
||
if (hsUrlEl) hsUrlEl.value = '';
|
||
closeModal('modal-addVhost');
|
||
showToast('Virtual host added', 'success');
|
||
refresh();
|
||
} else {
|
||
showModalError('modal-addVhost', 'addVhostError', response?.error || 'Failed to add');
|
||
}
|
||
}
|
||
);
|
||
});
|
||
|
||
// ── Remove Virtual Host ─────────────────────────────────────────────────
|
||
$('removeVhostConfirm')?.addEventListener('click', () => {
|
||
const hostname = $('removeVhostConfirm').dataset.hostname;
|
||
if (!hostname) return;
|
||
const btn = $('removeVhostConfirm');
|
||
btn.disabled = true; btn.textContent = 'Removing…';
|
||
chrome.runtime.sendMessage(
|
||
{ target: 'holesail-native', action: 'send', payload: { type: 'removeVirtualHost', payload: { hostname } } },
|
||
(response) => {
|
||
btn.disabled = false; btn.textContent = 'Remove';
|
||
closeModal('modal-removeVhost');
|
||
if (response?.ok) {
|
||
showToast('Virtual host removed', 'success');
|
||
} else {
|
||
showToast(response?.error || 'Failed to remove', 'error');
|
||
}
|
||
refresh();
|
||
}
|
||
);
|
||
});
|
||
|
||
// ── Start Server ────────────────────────────────────────────────────────
|
||
$('startServerBtn')?.addEventListener('click', () => {
|
||
// Reset to "new server" mode
|
||
const editIdEl = $('serverEditId');
|
||
if (editIdEl) editIdEl.value = '';
|
||
const titleEl = $('modal-startServer-title');
|
||
if (titleEl) titleEl.textContent = 'Start Server Tunnel';
|
||
const submitEl = $('startServerSubmit');
|
||
if (submitEl) submitEl.textContent = 'Start Server';
|
||
const tcpEl = $('startServerProtocolTcp');
|
||
if (tcpEl) tcpEl.checked = true;
|
||
openModal('modal-startServer');
|
||
});
|
||
|
||
$('startServerSubmit')?.addEventListener('click', () => {
|
||
const portEl = $('startServerPort');
|
||
const hostEl = $('startServerHost');
|
||
const secureEl = $('startServerSecure');
|
||
const port = parseInt(portEl?.value, 10) || 3000;
|
||
const host = (hostEl?.value || '127.0.0.1').trim();
|
||
const secure = secureEl?.checked !== false;
|
||
const udp = document.querySelector('input[name="startServerProtocol"]:checked')?.value === 'udp';
|
||
const editId = ($('serverEditId')?.value || '').trim();
|
||
if (!port || port < 1 || port > 65535) { showModalError('modal-startServer', 'startServerError', 'Port must be 1–65535'); return; }
|
||
const btn = $('startServerSubmit');
|
||
if (btn) { btn.disabled = true; btn.textContent = editId ? 'Saving…' : 'Starting…'; }
|
||
|
||
const doStart = () => {
|
||
chrome.runtime.sendMessage(
|
||
{ target: 'holesail-native', action: 'send', payload: { type: 'startServer', payload: { port, host, secure, udp } } },
|
||
(response) => {
|
||
if (btn) { btn.disabled = false; btn.textContent = editId ? 'Save Changes' : 'Start Server'; }
|
||
if (response?.ok) {
|
||
if (editIdEl) editIdEl.value = '';
|
||
closeModal('modal-startServer');
|
||
showToast(editId ? 'Server updated' : 'Server started', 'success');
|
||
refresh();
|
||
} else {
|
||
showModalError('modal-startServer', 'startServerError', response?.error || 'Failed to start');
|
||
}
|
||
}
|
||
);
|
||
};
|
||
|
||
if (editId) {
|
||
// Stop old server first, then start new one with updated settings
|
||
chrome.runtime.sendMessage(
|
||
{ target: 'holesail-native', action: 'send', payload: { type: 'stopServer', payload: { serverId: editId } } },
|
||
() => doStart()
|
||
);
|
||
} else {
|
||
doStart();
|
||
}
|
||
});
|
||
|
||
// ── Stop Server ─────────────────────────────────────────────────────────
|
||
$('stopServerConfirm')?.addEventListener('click', () => {
|
||
const serverId = $('stopServerConfirm').dataset.serverId;
|
||
if (!serverId) return;
|
||
const btn = $('stopServerConfirm');
|
||
btn.disabled = true; btn.textContent = 'Stopping…';
|
||
chrome.runtime.sendMessage(
|
||
{ target: 'holesail-native', action: 'send', payload: { type: 'stopServer', payload: { serverId } } },
|
||
(response) => {
|
||
btn.disabled = false; btn.textContent = 'Stop Server';
|
||
closeModal('modal-stopServer');
|
||
if (response?.ok) showToast('Server stopped', 'success');
|
||
else showToast(response?.error || 'Failed to stop', 'error');
|
||
refresh();
|
||
}
|
||
);
|
||
});
|
||
|
||
// ── Install CA ──────────────────────────────────────────────────────────
|
||
$('installCaBtn')?.addEventListener('click', () => openModal('modal-installCA'));
|
||
|
||
$('installCaSubmit')?.addEventListener('click', () => {
|
||
const btn = $('installCaSubmit');
|
||
const errEl = $('installCaError');
|
||
const successEl = $('installCaSuccess');
|
||
if (btn) { btn.disabled = true; btn.textContent = 'Installing…'; }
|
||
if (errEl) { errEl.style.display = 'none'; errEl.textContent = ''; }
|
||
if (successEl) { successEl.style.display = 'none'; }
|
||
chrome.runtime.sendMessage(
|
||
{ target: 'holesail-native', action: 'send', payload: { type: 'installRootCA', payload: {} } },
|
||
(response) => {
|
||
if (btn) { btn.disabled = false; btn.textContent = 'Install CA'; }
|
||
if (response?.ok) {
|
||
if (successEl) { successEl.textContent = '✓ Root CA installed. Fully quit and reopen Chrome (Cmd+Q) to apply trust.'; successEl.style.display = 'block'; }
|
||
showToast('Root CA installed', 'success');
|
||
setTimeout(() => closeModal('modal-installCA'), 2000);
|
||
refresh();
|
||
} else {
|
||
if (errEl) { errEl.textContent = response?.error || 'Installation failed'; errEl.style.display = 'block'; }
|
||
}
|
||
}
|
||
);
|
||
});
|
||
|
||
// ── Logs ────────────────────────────────────────────────────────────────
|
||
let logs = [];
|
||
let autoScroll = true;
|
||
let logFilter = '';
|
||
|
||
chrome.runtime.sendMessage(
|
||
{ target: 'holesail-native', action: 'registerDashboard' },
|
||
(response) => {
|
||
if (response && response.logs) { logs = response.logs; updateLogsDisplay(); }
|
||
}
|
||
);
|
||
|
||
chrome.runtime.onMessage.addListener((message) => {
|
||
if (message.type === 'holesail-logs' && message.logs) {
|
||
logs = message.logs;
|
||
updateLogsDisplay();
|
||
}
|
||
});
|
||
|
||
function getLogClass(msg) {
|
||
const m = (msg || '').toLowerCase();
|
||
if (m.includes('error') || m.includes('fail') || m.includes('err:')) return 'is-error';
|
||
if (m.includes('warn') || m.includes('warning')) return 'is-warn';
|
||
return '';
|
||
}
|
||
|
||
function updateLogsDisplay() {
|
||
const container = $('logsContainer');
|
||
if (!container) return;
|
||
const filtered = logFilter
|
||
? logs.filter(e => (e.message || '').toLowerCase().includes(logFilter.toLowerCase()))
|
||
: logs;
|
||
if (filtered.length === 0) {
|
||
container.innerHTML = `
|
||
<div class="empty-state">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><polyline points="22,12 18,12 15,21 9,3 6,12 2,12"/></svg>
|
||
<div class="empty-state-title">${logFilter ? 'No matching logs' : 'No logs yet'}</div>
|
||
<div class="empty-state-desc">${logFilter ? 'Try a different filter' : 'Logs will appear here as the extension runs'}</div>
|
||
</div>`;
|
||
return;
|
||
}
|
||
container.innerHTML = filtered.map(entry => {
|
||
const time = new Date(entry.timestamp).toLocaleTimeString();
|
||
const cls = getLogClass(entry.message);
|
||
return `<div class="log-entry">
|
||
<span class="log-time">${time}</span>
|
||
<span class="log-msg ${cls}">${escapeHtml(entry.message)}</span>
|
||
</div>`;
|
||
}).join('');
|
||
if (autoScroll) container.scrollTop = container.scrollHeight;
|
||
}
|
||
|
||
$('btnClearLogs')?.addEventListener('click', () => { logs = []; updateLogsDisplay(); });
|
||
|
||
$('btnAutoScroll')?.addEventListener('click', () => {
|
||
autoScroll = !autoScroll;
|
||
const btn = $('btnAutoScroll');
|
||
if (btn) {
|
||
const svgPart = btn.querySelector('svg')?.outerHTML || '';
|
||
btn.innerHTML = svgPart + ' Auto-scroll: ' + (autoScroll ? 'ON' : 'OFF');
|
||
}
|
||
});
|
||
|
||
$('logsFilter')?.addEventListener('input', (e) => {
|
||
logFilter = e.target.value;
|
||
updateLogsDisplay();
|
||
});
|
||
|
||
window.addEventListener('beforeunload', () => {
|
||
chrome.runtime.sendMessage({ target: 'holesail-native', action: 'unregisterDashboard' }, () => {});
|
||
});
|
||
|
||
// ── Service Tunnels ──────────────────────────────────────────────────────
|
||
$('addServiceTunnelBtn')?.addEventListener('click', () => {
|
||
$('serviceTunnelEditId').value = '';
|
||
$('serviceTunnelLabel').value = '';
|
||
$('serviceTunnelHsUrl').value = '';
|
||
$('serviceTunnelLocalPort').value = '';
|
||
const titleEl = $('modal-addServiceTunnel-title');
|
||
if (titleEl) titleEl.textContent = 'Add Service Tunnel';
|
||
const submitEl = $('serviceTunnelSubmit');
|
||
if (submitEl) submitEl.textContent = 'Connect';
|
||
openModal('modal-addServiceTunnel');
|
||
});
|
||
|
||
$('serviceTunnelSubmit')?.addEventListener('click', () => {
|
||
const label = ($('serviceTunnelLabel')?.value || '').trim();
|
||
const hsUrl = ($('serviceTunnelHsUrl')?.value || '').trim();
|
||
const localPort = parseInt($('serviceTunnelLocalPort')?.value, 10);
|
||
const editId = ($('serviceTunnelEditId')?.value || '').trim();
|
||
|
||
if (!label) { showModalError('modal-addServiceTunnel', 'serviceTunnelError', 'Label is required'); return; }
|
||
if (!hsUrl || !hsUrl.startsWith('hs://')) { showModalError('modal-addServiceTunnel', 'serviceTunnelError', 'Enter a valid hs:// key'); return; }
|
||
if (!localPort || localPort < 1 || localPort > 65535) { showModalError('modal-addServiceTunnel', 'serviceTunnelError', 'Local port must be 1–65535'); return; }
|
||
|
||
const btn = $('serviceTunnelSubmit');
|
||
if (btn) { btn.disabled = true; btn.textContent = 'Connecting…'; }
|
||
|
||
const type = editId ? 'updateServiceTunnel' : 'startServiceTunnel';
|
||
const payload = editId ? { tunnelId: editId, label, hsUrl, localPort } : { label, hsUrl, localPort };
|
||
|
||
chrome.runtime.sendMessage(
|
||
{ target: 'holesail-native', action: 'send', payload: { type, payload } },
|
||
(response) => {
|
||
if (btn) { btn.disabled = false; btn.textContent = editId ? 'Save' : 'Connect'; }
|
||
if (response?.ok) {
|
||
closeModal('modal-addServiceTunnel');
|
||
showToast(editId ? 'Tunnel updated' : 'Service tunnel connected', 'success');
|
||
refresh();
|
||
} else {
|
||
showModalError('modal-addServiceTunnel', 'serviceTunnelError', response?.error || 'Failed to connect');
|
||
}
|
||
}
|
||
);
|
||
});
|
||
|
||
$('removeServiceTunnelConfirm')?.addEventListener('click', () => {
|
||
const tunnelId = $('removeServiceTunnelConfirm').dataset.tunnelId;
|
||
if (!tunnelId) return;
|
||
const btn = $('removeServiceTunnelConfirm');
|
||
btn.disabled = true; btn.textContent = 'Removing…';
|
||
chrome.runtime.sendMessage(
|
||
{ target: 'holesail-native', action: 'send', payload: { type: 'stopServiceTunnel', payload: { tunnelId } } },
|
||
(response) => {
|
||
btn.disabled = false; btn.textContent = 'Remove';
|
||
closeModal('modal-removeServiceTunnel');
|
||
if (response?.ok) showToast('Service tunnel removed', 'success');
|
||
else showToast(response?.error || 'Failed to remove', 'error');
|
||
refresh();
|
||
}
|
||
);
|
||
});
|
||
}
|
||
|
||
// ── SSH Connections ───────────────────────────────────────────────────────────
|
||
|
||
let sshConnections = []; // saved connections from native host state.json
|
||
let activeSshSession = null; // { sessionId, wsPort, term, fitAddon, ws, resizeObserver, conn }
|
||
|
||
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;
|
||
}
|
||
if (cb) cb(sshConnections);
|
||
}
|
||
);
|
||
}
|
||
|
||
function saveSshConnections(cb) {
|
||
// Strip passwords before persisting — passwords are session-only
|
||
const toSave = sshConnections.map(c => {
|
||
const { password, ...rest } = c; // eslint-disable-line no-unused-vars
|
||
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-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 class="ssh-conn-meta">${escapeHtml(conn.username)}@ssh · ${escapeHtml(truncate(conn.hsUrl, 32))}</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>`).join('');
|
||
|
||
// Wire up card buttons
|
||
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 = '';
|
||
$('sshConnEditId').value = conn ? conn.id : '';
|
||
$('sshConnSubmit').textContent = isEdit ? 'Save Changes' : 'Save Connection';
|
||
openModal('modal-addSsh');
|
||
}
|
||
|
||
function sendToNative(type, payload) {
|
||
return new Promise((resolve) => {
|
||
chrome.runtime.sendMessage(
|
||
{ target: 'holesail-native', action: 'send', payload: { type, payload } },
|
||
(response) => resolve(response)
|
||
);
|
||
});
|
||
}
|
||
|
||
async function connectSsh(conn) {
|
||
// Show terminal modal immediately with a connecting state
|
||
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)';
|
||
|
||
// Clean up any existing session
|
||
await disconnectSsh();
|
||
|
||
// Initialize xterm.js
|
||
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');
|
||
|
||
// Request native host to start SSH session
|
||
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');
|
||
|
||
// Connect WebSocket to the WS bridge
|
||
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');
|
||
};
|
||
|
||
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');
|
||
};
|
||
|
||
// Terminal input → WebSocket
|
||
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 };
|
||
}
|
||
|
||
function updateTermSizeDisplay(term) {
|
||
const el = $('termSizeDisplay');
|
||
if (el && term) el.textContent = term.cols + '×' + term.rows;
|
||
}
|
||
|
||
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() {
|
||
// Add connection button
|
||
$('addSshBtn')?.addEventListener('click', () => openAddSshModal(null));
|
||
|
||
// Save / update connection
|
||
$('sshConnSubmit')?.addEventListener('click', () => {
|
||
const label = $('sshConnLabel').value.trim();
|
||
const hsUrl = $('sshConnHsUrl').value.trim();
|
||
const username = $('sshConnUsername').value.trim();
|
||
const password = $('sshConnPassword').value; // session-only, not persisted
|
||
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) {
|
||
// Keep existing in-memory password if user left the field blank
|
||
const existingPassword = sshConnections[idx].password || '';
|
||
sshConnections[idx] = { ...sshConnections[idx], label, hsUrl, username, password: password || existingPassword };
|
||
}
|
||
} else {
|
||
sshConnections.push({ id: generateSshId(), label, hsUrl, username, password });
|
||
}
|
||
saveSshConnections();
|
||
closeModal('modal-addSsh');
|
||
showToast(editId ? 'Connection updated' : 'Connection saved', 'success');
|
||
});
|
||
|
||
// Remove connection confirm
|
||
$('removeSshConfirm')?.addEventListener('click', () => {
|
||
const id = $('removeSshConfirm').dataset.sshId;
|
||
sshConnections = sshConnections.filter(c => c.id !== id);
|
||
saveSshConnections();
|
||
closeModal('modal-removeSsh');
|
||
showToast('Connection removed', 'success');
|
||
});
|
||
|
||
// Terminal disconnect button
|
||
$('termDisconnectBtn')?.addEventListener('click', async () => {
|
||
await disconnectSsh();
|
||
closeModal('modal-sshTerminal');
|
||
});
|
||
|
||
// Terminal copy selection button
|
||
$('termCopyBtn')?.addEventListener('click', () => {
|
||
if (activeSshSession && activeSshSession.term) {
|
||
const sel = activeSshSession.term.getSelection();
|
||
if (sel) copyToClipboard(sel, null);
|
||
else showToast('No text selected', 'default');
|
||
}
|
||
});
|
||
|
||
// Fullscreen toggle
|
||
$('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);
|
||
});
|
||
|
||
// Clean up session when terminal modal is closed via backdrop/escape
|
||
const termModal = $('modal-sshTerminal');
|
||
if (termModal) {
|
||
const observer = new MutationObserver(() => {
|
||
if (!termModal.classList.contains('open') && activeSshSession) {
|
||
disconnectSsh();
|
||
}
|
||
});
|
||
observer.observe(termModal, { attributes: true, attributeFilter: ['class'] });
|
||
}
|
||
}
|
||
|
||
// ── Init ─────────────────────────────────────────────────────────────────────
|
||
|
||
async function init() {
|
||
log('Dashboard initializing…');
|
||
setupNavigation();
|
||
setupEvents();
|
||
setupCertValidator();
|
||
setupSshEvents();
|
||
setupRdpEvents();
|
||
setupBackupEvents();
|
||
// refresh() fetches state from native host which includes settings + sshConnections
|
||
await refresh();
|
||
setInterval(refresh, 2000);
|
||
}
|
||
|
||
init();
|