// Depends on: core/utils.js ($, escapeHtml, log), core/messaging.js (sendToNative), // ui/toast.js (showToast), ui/modal.js (openModal, closeModal, showModalError) let rdpConnections = []; let activeRdpSession = null; // { sessionId, wsPort, type, ws, rfb, conn } // Reusable offscreen canvas for bitmap rendering — resized only when dimensions change. let _rdpOffscreen = null; let _rdpOffscreenCtx = null; let _rdpOffscreenW = 0; let _rdpOffscreenH = 0; 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`; return `
${escapeHtml(conn.label || (conn.type === 'rdp' ? 'RDP Desktop' : 'VNC Desktop'))} ${typeBadge}
`; }).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 = ''; 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)'; }; 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()); 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, 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; 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 } if (!_rdpOffscreen || _rdpOffscreenW !== width || _rdpOffscreenH !== height) { _rdpOffscreen = document.createElement('canvas'); _rdpOffscreen.width = width; _rdpOffscreen.height = height; _rdpOffscreenCtx = _rdpOffscreen.getContext('2d'); _rdpOffscreenW = width; _rdpOffscreenH = height; } _rdpOffscreenCtx.putImageData(imgData, 0, 0); ctx.drawImage(_rdpOffscreen, 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 = ''; 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) { ws.onclose = null; ws.onerror = null; ws.onmessage = null; try { ws.close(); } catch (_) {} } const container = $('rdpViewerContainer'); if (container) container.innerHTML = ''; if (sessionId) { await sendToNative('stopRdpSession', { sessionId }); } } function setupRdpEvents() { $('addRdpBtn')?.addEventListener('click', () => openAddRdpModal(null)); document.querySelectorAll('input[name="rdpProtocol"]').forEach(radio => { radio.addEventListener('change', () => updateRdpProtocolUI(radio.value)); }); $('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'); }); $('removeRdpConfirm')?.addEventListener('click', () => { const id = $('removeRdpConfirm').dataset.rdpId; rdpConnections = rdpConnections.filter(c => c.id !== id); saveRdpConnections(); closeModal('modal-removeRdp'); showToast('Connection removed', 'success'); }); $('rdpDisconnectBtn')?.addEventListener('click', async () => { await disconnectRdp(); closeModal('modal-rdpViewer'); }); $('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'; } }); const viewerModal = $('modal-rdpViewer'); if (viewerModal) { const observer = new MutationObserver(() => { if (!viewerModal.classList.contains('open') && activeRdpSession) { disconnectRdp(); } }); observer.observe(viewerModal, { attributes: true, attributeFilter: ['class'] }); window.addEventListener('beforeunload', () => observer.disconnect(), { once: true }); } }