Files
holesail-browser/extension/dashboard/pages/rdp.js
T
Raven Scott 24f189ec5c
CI / Build & Test (push) Has been cancelled
feat(dashboard, host): cross-repo parity — a11y, messaging, validation, tooling
- Dashboard: skip link, main landmark, focus-visible, toast live region, keyboard
  shortcuts modal (?), aria-current on nav, shared state-panel CSS, theme spacing tokens
- messaging: sendToNativeResult + notifyNativeFailure; SSH/RDP use them for failures
- Host: validate setVirtualHost/removeVirtualHost/startServer/stopServer in message-router;
  log on handler errors; logWarn in logger
- SW: skipWaiting + clients.claim; register-sw controllerchange hook
- Tests: test/paths.js + env HOLESAIL_HOST_ROOT / HOLESAIL_DASH_DATA_ROOT; mirror tests in
  Holesail; data/package.json for CJS under ESM root; eslint.config.cjs + lint script
- Data: install-commands cross-repo note; extension/dashboard/data package.json (Browser)
2026-03-27 02:11:19 -04:00

532 lines
20 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.
/**
* Remote Desktop page — connection grid, add/edit modal, VNC viewer (noVNC over WebSocket),
* and RDP bitmap viewer (node-rdpjs-2 over WebSocket with offscreen canvas rendering).
* Depends on: core/utils.js ($, escapeHtml, log), core/messaging.js (sendToNative, sendToNativeResult, notifyNativeFailure),
* 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();
}
);
}
/**
* Re-render the RDP/VNC connection grid from the current `rdpConnections` array.
*/
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>`;
return `
<div class="rdp-conn-card" data-rdp-id="${escapeHtml(conn.id)}">
<div class="rdp-conn-card-top">
<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>
<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>
</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');
}
});
});
}
/**
* Open the Add/Edit Remote Desktop Connection modal, pre-populated with `conn` if provided.
* @param {object} [conn] - Existing connection to edit; omit to add a new one.
*/
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 = '<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)';
};
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);
}
/**
* Open the viewer modal and start a VNC or RDP session for the given connection.
* @param {object} conn - RDP/VNC connection object with `type`, `hsUrl`, `port`, etc.
* @returns {Promise<void>}
*/
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 r = await sendToNativeResult('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 (!r.ok) {
notifyNativeFailure('Remote desktop', r.error);
$('rdpStatusDot').className = 'terminal-status-dot disconnected';
$('rdpStateDisplay').textContent = 'Failed: ' + (r.error || 'Unknown error');
$('rdpStateDisplay').style.color = 'var(--red)';
return;
}
const { sessionId, wsPort } = r.data;
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 };
}
}
/**
* Disconnect the active RDP/VNC session: close the WebSocket, stop the protocol client,
* and send a stopRdpSession message to the native host.
* @returns {Promise<void>}
*/
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 });
}
}
/**
* Attach all event listeners for the Remote Desktop page.
* Called once during dashboard initialisation.
*/
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 });
}
}