/** * Holesail Dashboard - UI logic * Guard: only run when loaded as the actual dashboard page. */ if (!document.getElementById('page-dashboard') && !document.querySelector('.sidebar-logo')) { throw new Error('dashboard.js loaded outside dashboard context — aborting'); } 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) => ``; if (!state || state === '—') return `${dot('text4')}—`; if (state === 'ready') return `${dot('green')}ready`; if (state === 'error') return `${dot('red')}error`; if (state === 'closed') return `${dot('text4')}closed`; if (state === 'connecting') return `${dot('amber')}connecting`; return `${dot('amber')}${escapeHtml(state)}`; } // ── 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 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 (chrome.runtime.lastError) { showToast('Settings save failed: ' + chrome.runtime.lastError.message, 'error'); return; } if (response && response.ok) { if (response.settings) settings = { ...SETTINGS_DEFAULTS, ...response.settings }; if (response.requiresRestart) { showToast('Settings saved — restart the native host for proxy port changes to take effect', 'warning'); } else { 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) { // Encode password as base64 before persisting so it survives page reloads const toSave = rdpConnections.map(c => { const { password, ...rest } = c; // eslint-disable-line no-unused-vars if (password) rest.passwordB64 = btoa(unescape(encodeURIComponent(password))); else delete rest.passwordB64; return rest; }); chrome.runtime.sendMessage( { target: 'holesail-native', action: 'send', payload: { type: '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 = `
No remote desktop connections
Add a VNC or RDP connection. You'll need an hs:// key for the remote peer.
`; return; } grid.innerHTML = rdpConnections.map(conn => { const typeBadge = conn.type === 'rdp' ? `RDP` : `VNC`; const metaUser = conn.type === 'rdp' && conn.username ? escapeHtml(conn.username) + '@rdp' : conn.type.toUpperCase(); return `
${escapeHtml(conn.label || (conn.type === 'rdp' ? 'RDP Desktop' : 'VNC Desktop'))} ${typeBadge}
${metaUser}  ·  ${escapeHtml(conn.hsUrl)}
`; }).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 = conn ? (conn.password || '') : ''; $('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 = '
noVNC (RFB) not loaded. Check vendor/novnc.js.
'; 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 = '
Failed to init noVNC: ' + escapeHtml(e.message) + '
'; 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 = '
WebSocket failed: ' + escapeHtml(e.message) + '
'; 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 = `
No backups yet
Click "Take Backup" to create your first backup
`; 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 ` ${name} ${created} ${size}
`; }).join(''); } function refreshBackups() { chrome.runtime.sendMessage( { target: 'holesail-native', action: 'send', payload: { type: 'listBackups' } }, (response) => { if (chrome.runtime.lastError) return; 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 = ` 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 (chrome.runtime.lastError) { log('fetchState error:', chrome.runtime.lastError.message); resolve(null); return; } if (response && response.ok) resolve(response.state); else resolve(null); } ); }); } // ── Overview infinite-scroll + debounced filter engine ─────────────────────── // // Each list is managed by an OvList instance: // - holds the full dataset and current filter string // - renders PAGE_SIZE rows at a time into a // - an IntersectionObserver on a sentinel
appends the next page when // the sentinel scrolls into view (infinite scroll) // - a debounced input handler resets the list on every keystroke const OV_PAGE = 20; // rows per page class OvList { constructor({ bodyId, sentinelId, searchId, subtitleId, rowFn, emptyMsg, cols }) { this.body = $(bodyId); this.sentinel = $(sentinelId); this.searchEl = $(searchId); this.subtitleEl = $(subtitleId); this.rowFn = rowFn; // (item) => HTML string this.emptyMsg = emptyMsg; this.cols = cols; // colspan for empty row this.data = []; // full unfiltered dataset this.filtered = []; // after filter applied this.rendered = 0; // rows currently in DOM this._debounce = null; this._observer = null; this._initObserver(); this._initSearch(); } _initObserver() { if (!this.sentinel) return; this._observer = new IntersectionObserver(entries => { if (entries[0].isIntersecting) this._appendPage(); }, { threshold: 0 }); this._observer.observe(this.sentinel); } _initSearch() { if (!this.searchEl) return; this.searchEl.addEventListener('input', () => { clearTimeout(this._debounce); this._debounce = setTimeout(() => this._applyFilter(), 180); }); } setData(data, subtitleText) { this.data = data; if (this.subtitleEl) this.subtitleEl.textContent = subtitleText; this._applyFilter(); } _applyFilter() { const q = (this.searchEl?.value || '').trim().toLowerCase(); this.filtered = q ? this.data.filter(item => JSON.stringify(item).toLowerCase().includes(q)) : this.data.slice(); this._reset(); } _reset() { if (!this.body) return; this.rendered = 0; this.body.innerHTML = ''; if (this.filtered.length === 0) { this.body.innerHTML = `${this.emptyMsg}`; return; } this._appendPage(); } _appendPage() { if (!this.body || this.rendered >= this.filtered.length) return; const next = this.filtered.slice(this.rendered, this.rendered + OV_PAGE); this.body.insertAdjacentHTML('beforeend', next.map(this.rowFn).join('')); this.rendered += next.length; } } // Instances — created once, reused on every updateDashboard call let _ovVhost = null; let _ovServers = null; let _ovSvc = null; let _ovSsh = null; let _ovRdp = null; function initOvLists() { _ovVhost = new OvList({ bodyId: 'recentConnections', sentinelId: 'ovVhostSentinel', searchId: 'ovVhostSearch', subtitleId: 'ovVhostSubtitle', cols: 3, emptyMsg: 'No virtual hosts', rowFn: v => { const h = v.hostname || v.id || ''; return ` ${escapeHtml(h)} ${stateTag(v.state)} Open `; } }); _ovServers = new OvList({ bodyId: 'ovServersBody', sentinelId: 'ovServersSentinel', searchId: 'ovServersSearch', subtitleId: 'ovServersSubtitle', cols: 3, emptyMsg: 'No server tunnels', rowFn: s => { const key = s.hsUrl || ''; const short = key.length > 22 ? key.slice(0, 9) + '…' + key.slice(-7) : key; return ` ${escapeHtml(String(s.port || '—'))} ${stateTag(s.state)} ${escapeHtml(short)} `; } }); _ovSvc = new OvList({ bodyId: 'ovSvcBody', sentinelId: 'ovSvcSentinel', searchId: 'ovSvcSearch', subtitleId: 'ovSvcSubtitle', cols: 3, emptyMsg: 'No service tunnels', rowFn: t => ` ${escapeHtml(t.label || '—')} ${escapeHtml(String(t.localPort || '—'))} ${stateTag(t.state)} ` }); _ovSsh = new OvList({ bodyId: 'ovSshBody', sentinelId: 'ovSshSentinel', searchId: 'ovSshSearch', subtitleId: 'ovSshSubtitle', cols: 2, emptyMsg: 'No SSH connections', rowFn: c => ` ${escapeHtml(c.label || c.hsUrl || '—')} ${escapeHtml(c.username || '—')} ` }); _ovRdp = new OvList({ bodyId: 'ovRdpBody', sentinelId: 'ovRdpSentinel', searchId: 'ovRdpSearch', subtitleId: 'ovRdpSubtitle', cols: 2, emptyMsg: 'No remote desktops', rowFn: c => ` ${escapeHtml(c.label || '—')} ${escapeHtml((c.type || 'vnc').toUpperCase())} ` }); // Quick action buttons → open existing modals directly $('qaAddVhost') ?.addEventListener('click', () => openModal('modal-addVhost')); $('qaAddServer') ?.addEventListener('click', () => openModal('modal-startServer')); $('qaAddSvc') ?.addEventListener('click', () => openModal('modal-addServiceTunnel')); $('qaAddSsh') ?.addEventListener('click', () => openAddSshModal(null)); $('qaAddRdp') ?.addEventListener('click', () => openAddRdpModal(null)); $('qaInstallCA') ?.addEventListener('click', () => openModal('modal-installCA')); } // ── Dashboard / Overview ───────────────────────────────────────────────────── function updateDashboard(state) { currentState = state; const servers = state.servers || []; const virtualHosts = state.virtualHosts || []; const serviceTunnels = state.serviceTunnels || []; // 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'; } const setText = (id, val) => { const el = $(id); if (el) el.textContent = val; }; // ── Stat cards ──────────────────────────────────────────────────────────── setText('dashConnections', virtualHosts.length); setText('dashSwarms', servers.length); setText('dashServiceTunnels', serviceTunnels.length); setText('dashSsh', sshConnections.length); setText('dashRdp', rdpConnections.length); setText('dashUptime', state.caInstalled ? 'Trusted' : 'Not trusted'); 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)'; } // ── System status bar ───────────────────────────────────────────────────── const ovHostDot = $('ovHostDot'); if (ovHostDot) { ovHostDot.classList.toggle('connected', !!state.hostConnected); ovHostDot.classList.toggle('disconnected', !state.hostConnected); } setText('ovHostLabel', state.hostConnected ? 'Connected' : 'Disconnected'); const ovCaDot = $('ovCaDot'); if (ovCaDot) { ovCaDot.style.background = state.caInstalled ? 'var(--green)' : 'var(--red)'; ovCaDot.style.boxShadow = state.caInstalled ? '0 0 5px var(--green)' : 'none'; } setText('ovCaLabel', state.caInstalled ? 'Installed & trusted' : 'Not installed'); setText('ovProxyLabel', state.proxyPort != null ? `port ${state.proxyPort}` : '—'); setText('ovConnectLabel', state.connectProxyPort != null ? `port ${state.connectProxyPort}` : '—'); const lu = $('overviewLastUpdated'); if (lu) lu.textContent = 'Updated ' + new Date().toLocaleTimeString(); // ── Nav badges ──────────────────────────────────────────────────────────── setText('swarmCount', servers.length); setText('connCount', virtualHosts.length); setText('serviceTunnelCount', serviceTunnels.length); setText('sshCount', sshConnections.length); setText('rdpCount', rdpConnections.length); setText('tabCount', state.caInstalled ? 'CA ✓' : 'CA'); // ── Initialise list engines on first call ───────────────────────────────── if (!_ovVhost) initOvLists(); // ── Feed data into each list (subtitle auto-computed) ───────────────────── const sub = (n, unit, readyArr) => { if (n === 0) return `No ${unit}s`; const r = readyArr.filter(x => x.state === 'ready').length; return `${n} ${unit}${n !== 1 ? 's' : ''} — ${r} ready`; }; _ovVhost .setData(virtualHosts, sub(virtualHosts.length, 'host', virtualHosts)); _ovServers.setData(servers, sub(servers.length, 'tunnel', servers)); _ovSvc .setData(serviceTunnels, sub(serviceTunnels.length, 'tunnel', serviceTunnels)); _ovSsh .setData(sshConnections, sshConnections.length === 0 ? 'No SSH connections' : `${sshConnections.length} saved`); _ovRdp .setData(rdpConnections, rdpConnections.length === 0 ? 'No remote desktops' : `${rdpConnections.length} saved`); } // ── 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 = `
No virtual hosts
Click "Add Host" to assign a hostname to an hs:// tunnel
`; 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 ` ${escapeHtml(hostname)}
${truncate(hsUrl, 28)}
${escapeHtml(backend)} ${stateTag(v.state)}
Open ${needsReconnect ? `` : ''}
`; }).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 = `
No server tunnels
Click "New Server" to expose a local port as an hs:// tunnel
`; return; } tbody.innerHTML = servers.map(s => { const id = s.id || s.serverId || ''; const url = s.url || s.hsUrl || ''; const safeId = id.replace(/"/g, '"'); return ` ${escapeHtml(truncate(id, 20))} ${s.port ?? '—'}
${truncate(url, 30)} ${url ? `` : ''}
${s.udp ? 'UDP' : 'TCP'}${s.secure ? `secure` : `plain`}
`; }).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 = `No virtual hosts to test — add one in Virtual Hosts`; return; } tbody.innerHTML = virtualHosts.map(v => { const hostname = v.hostname || ''; const r = validationResults.get(hostname); return ` ${escapeHtml(hostname)} ${renderTlsCell(hostname, r)} ${renderStatusCell(hostname, r)} ${renderTimeCell(hostname, r)} `; }).join(''); tbody.querySelectorAll('[data-validate-host]').forEach(btn => { btn.addEventListener('click', () => runValidation(btn.dataset.validateHost)); }); } function renderTlsCell(hostname, r) { if (!r) return ``; if (r.status === 'running') return `Testing…`; if (r.tlsOk) return `✓ Trusted`; return `✗ Untrusted`; } function renderStatusCell(hostname, r) { if (!r || r.status === 'running') return ``; if (!r.tlsOk) return ``; if (r.httpStatus == null) return `No response`; const cls = r.httpStatus < 400 ? 'badge-green' : r.httpStatus < 500 ? 'badge-amber' : 'badge-red'; return `${r.httpStatus}`; } function renderTimeCell(hostname, r) { if (!r || r.status === 'running' || !r.tlsOk || r.ms == null) return ``; const color = r.ms < 500 ? 'var(--green)' : r.ms < 2000 ? 'var(--amber)' : 'var(--red)'; return `${r.ms}ms`; } 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') ? ` Testing…` : ` 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 = `
No service tunnels
Click "Add Tunnel" to connect a remote TCP/UDP service via Holesail
`; 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 ` ${escapeHtml(label)}
${truncate(hsUrl, 28)} ${hsUrl ? `` : ''}
${escapeHtml(localAddr)} ${t.localPort != null ? `` : ''}
${stateTag(t.state)}
${(t.state === 'error' || t.state === 'closed') ? ` ` : ''}
`; }).join(''); tbody.querySelectorAll('[data-copy]').forEach(btn => { btn.addEventListener('click', () => copyToClipboard(btn.dataset.copy, btn)); }); tbody.querySelectorAll('[data-reconnect-svc]').forEach(btn => { btn.addEventListener('click', () => { const tunnelId = btn.dataset.reconnectSvc; const hsUrl = btn.dataset.hsurl; const label = btn.dataset.label; const localPort = parseInt(btn.dataset.localport, 10); btn.disabled = true; btn.textContent = 'Reconnecting…'; chrome.runtime.sendMessage( { target: 'holesail-native', action: 'send', payload: { type: 'updateServiceTunnel', payload: { tunnelId, hsUrl, label, localPort } } }, (response) => { if (chrome.runtime.lastError) { showToast('Reconnect failed: ' + chrome.runtime.lastError.message, 'error'); return; } if (response?.ok) { showToast('Service tunnel reconnecting…', 'success'); } else { showToast(response?.error || 'Reconnect failed', 'error'); } refresh(); } ); }); }); 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 — decode base64 password if present if (Array.isArray(state.sshConnections)) { sshConnections = state.sshConnections.map(c => { const existing = sshConnections.find(e => e.id === c.id); // Prefer in-memory password (user just typed it), then decode persisted base64 let password = (existing && existing.password) || ''; if (!password && c.passwordB64) { try { password = decodeURIComponent(escape(atob(c.passwordB64))); } catch (_) {} } return { ...c, password }; }); renderSshGrid(); } // Sync RDP connections from native host state — decode base64 password if present if (Array.isArray(state.rdpConnections)) { rdpConnections = state.rdpConnections.map(c => { const existing = rdpConnections.find(e => e.id === c.id); // Prefer in-memory password (user just typed it), then decode persisted base64 let password = (existing && existing.password) || ''; if (!password && c.passwordB64) { try { password = decodeURIComponent(escape(atob(c.passwordB64))); } catch (_) {} } return { ...c, password }; }); 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 // 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'); } ); }); // ── 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 = `
${logFilter ? 'No matching logs' : 'No logs yet'}
${logFilter ? 'Try a different filter' : 'Logs will appear here as the extension runs'}
`; return; } container.innerHTML = filtered.map(entry => { const time = new Date(entry.timestamp).toLocaleTimeString(); const cls = getLogClass(entry.message); return `
${time} ${escapeHtml(entry.message)}
`; }).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.map(c => { let password = ''; if (c.passwordB64) { try { password = decodeURIComponent(escape(atob(c.passwordB64))); } catch (_) {} } return { ...c, password }; }); } if (cb) cb(sshConnections); } ); } function saveSshConnections(cb) { // Encode password as base64 before persisting so it survives page reloads const toSave = sshConnections.map(c => { const { password, ...rest } = c; // eslint-disable-line no-unused-vars if (password) rest.passwordB64 = btoa(unescape(encodeURIComponent(password))); else delete rest.passwordB64; return rest; }); chrome.runtime.sendMessage( { target: 'holesail-native', action: 'send', payload: { type: 'setSshConnections', payload: { connections: toSave } } }, (response) => { if (response && !response.ok) { log('saveSshConnections failed:', response.error); } renderSshGrid(); const countEl = $('sshCount'); if (countEl) countEl.textContent = sshConnections.length; if (cb) cb(); } ); } function renderSshGrid() { const grid = $('sshGrid'); if (!grid) return; if (sshConnections.length === 0) { grid.innerHTML = `
No SSH connections
Add a connection to get started. You'll need an hs:// key for the remote peer.
`; return; } grid.innerHTML = sshConnections.map(conn => `
${escapeHtml(conn.label || conn.username + '@ssh')}
${escapeHtml(conn.username)}@ssh  ·  ${escapeHtml(conn.hsUrl)}
`).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 = conn ? (conn.password || '') : ''; $('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'); term.focus(); }; let firstMessage = true; ws.onmessage = (event) => { const data = event.data instanceof ArrayBuffer ? new Uint8Array(event.data) : event.data; if (firstMessage) { firstMessage = false; // Prepend ESC[2J (clear screen) + ESC[H (cursor home) to the first SSH // data chunk so the clear and the MOTD are written atomically in the // same xterm.js render pass — avoids the race where term.clear() wipes // data that was already queued by term.write(). const CLEAR_HOME = '\x1b[2J\x1b[H'; if (typeof data === 'string') { term.write(CLEAR_HOME + data); } else { const prefix = new TextEncoder().encode(CLEAR_HOME); const combined = new Uint8Array(prefix.length + data.length); combined.set(prefix); combined.set(data, prefix.length); term.write(combined); } return; } term.write(data); }; ws.onclose = () => { $('termStatusDot').className = 'terminal-status-dot disconnected'; $('termStateDisplay').textContent = 'Disconnected'; $('termStateDisplay').style.color = 'var(--text3)'; term.writeln('\r\n\x1b[90m[Session closed]\x1b[0m'); }; ws.onerror = () => { term.writeln('\r\n\x1b[31m[WebSocket error]\x1b[0m'); }; // 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; const editId = $('sshConnEditId').value; if (!hsUrl) { showModalError('modal-addSsh', 'sshConnError', 'Holesail key is required'); return; } if (!username) { showModalError('modal-addSsh', 'sshConnError', 'Username is required'); return; } if (!hsUrl.startsWith('hs://')) { showModalError('modal-addSsh', 'sshConnError', 'Key must start with hs://'); return; } if (editId) { const idx = sshConnections.findIndex(c => c.id === editId); if (idx !== -1) { sshConnections[idx] = { ...sshConnections[idx], label, hsUrl, username, password }; } } else { sshConnections.push({ id: generateSshId(), label, hsUrl, username, password }); } saveSshConnections(); closeModal('modal-addSsh'); showToast(editId ? 'Connection updated' : 'Connection saved', 'success'); }); // 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…'); // Set dynamic version from manifest try { const manifest = chrome.runtime.getManifest(); const versionEl = $('sidebarVersion'); if (versionEl && manifest.version) { versionEl.textContent = 'v' + manifest.version + ' · hole.sail'; } } catch (_) {} setupNavigation(); setupEvents(); setupCertValidator(); setupSshEvents(); setupRdpEvents(); setupBackupEvents(); // refresh() fetches state from native host which includes settings + sshConnections await refresh(); setInterval(refresh, 2000); } init();