Files
holesail-browser/extension/dashboard.js
T
Raven Scott 6612beffa1
CI / Build & Test (push) Successful in 3m2s
SSH AUTH
2026-02-28 16:49:53 -05:00

2218 lines
92 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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, '&quot;');
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, '&quot;');
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, '&quot;');
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 165535'); 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 165535'); 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 &nbsp;·&nbsp; ${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();