CI / Build & Test (push) Successful in 2m54s
Add docs/CONTRIBUTING.md covering the build system, dev workflow, all npm scripts, how to add new native host message types, code style, and debugging guidance. Add CHANGELOG.md at the project root documenting all features and fixes across the 1.0.0 release. Add JSDoc (@param, @returns) to all previously undocumented exported functions across 35 JS files: - native-host/holesail-manager/ (index, virtual-hosts, service-tunnels, servers, port-allocator) - native-host top-level managers (startup, connect-proxy, https-proxy, certificate-authority, ssh-manager, rdp-manager) - extension/background/ (logs, native-messaging, proxy, message-router) - extension/dashboard/core/ (utils, navigation, init) - extension/dashboard/ui/ (modal, toast, state-tag) - extension/dashboard/pages/ (all 10 page files) - extension/dashboard/refresh.js, events.js - extension/dashboard/data/hostname-validator.js - scripts/ (build-host, run-install)
531 lines
20 KiB
JavaScript
531 lines
20 KiB
JavaScript
/**
|
||
* 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),
|
||
* 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 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 };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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 });
|
||
}
|
||
}
|