Files
holesail-browser/extension/dashboard/pages/rdp.js
T
Raven Scott 5c4f0b4aba
CI / Build & Test (push) Failing after 28s
efactor(extension): modularize dashboard, background, and docs
Split monolithic dashboard.js (2753 lines) into 18 focused modules
under dashboard/{core,data,ui,pages}/ with refresh.js and events.js
as orchestrators. Extracted ~900-line inline <style> into dashboard.css
and moved dashboard.html to dashboard/dashboard.html.

Split background.js (609 lines) into background/{logs,state,proxy,
native-messaging,tab-lifecycle,message-router}.js with a thin entry
point using importScripts().

Deleted dead files: wrong-domain.js (duplicate of inline script).
Updated manifest.json web_accessible_resources for new paths.
Updated docs/ARCHITECTURE.md to reflect the new file structure.

No functionality changed. No build step introduced.
2026-02-28 23:02:52 -05:00

489 lines
19 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.
// 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 }
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 = `
<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');
}
});
});
}
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
}
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 = '';
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));
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'] });
}
}