Complete browser-based remote desktop client built from protocol analysis: - Protobuf message definitions (75+ message types, 10 enums) - NaCl encryption (Ed25519 + Curve25519 ECDH + XSalsa20-Poly1305) - WebSocket signaling (hbbs) + relay (hbbr) connection lifecycle - VP8/VP9/AV1 video decoding via ogvjs WASM + yuv-canvas WebGL - Opus audio decoding via libopus WASM + Web Audio API - Full mouse/keyboard input forwarding to protobuf events - Connection dialog, status bar, toolbar, log panel UI Also tracked web_deps (codec libraries) in rustdesk-as-ref/
587 lines
19 KiB
JavaScript
587 lines
19 KiB
JavaScript
/**
|
||
* RustDesk Standalone Web Client - Application
|
||
*
|
||
* Main application logic, UI management, and event coordination.
|
||
*/
|
||
|
||
const RDApp = (() => {
|
||
let _logEntries = [];
|
||
let _logVisible = false;
|
||
let _toasts = [];
|
||
|
||
/**
|
||
* Initialize the application
|
||
*/
|
||
async function init() {
|
||
log('info', 'Initializing RustDesk Standalone Web Client...');
|
||
|
||
// Initialize protobuf definitions
|
||
if (!RDProto.init()) {
|
||
showError('Failed to initialize protocol definitions');
|
||
return false;
|
||
}
|
||
|
||
// Setup UI event listeners
|
||
setupUI();
|
||
|
||
// Initialize audio (will be activated on user gesture)
|
||
// RDAudio.init() called on first user interaction
|
||
|
||
log('info', 'Protocol definitions loaded successfully');
|
||
log('info', 'Ready to connect. Enter server address and peer ID.');
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Setup UI event listeners
|
||
*/
|
||
function setupUI() {
|
||
// Connect button
|
||
document.getElementById('btn-connect').addEventListener('click', handleConnect);
|
||
|
||
// Password dialog
|
||
document.getElementById('btn-password-submit').addEventListener('click', handlePasswordSubmit);
|
||
document.getElementById('btn-password-cancel').addEventListener('click', handlePasswordCancel);
|
||
document.getElementById('password-input').addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') handlePasswordSubmit();
|
||
if (e.key === 'Escape') handlePasswordCancel();
|
||
});
|
||
|
||
// Enter key on connect form
|
||
const inputs = document.querySelectorAll('#connection-form input');
|
||
inputs.forEach(input => {
|
||
input.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') handleConnect();
|
||
});
|
||
});
|
||
|
||
// Toolbar buttons
|
||
document.getElementById('btn-disconnect').addEventListener('click', handleDisconnect);
|
||
document.getElementById('btn-fullscreen').addEventListener('click', handleFullscreen);
|
||
document.getElementById('btn-mute').addEventListener('click', handleToggleMute);
|
||
document.getElementById('btn-ctrl-alt-del').addEventListener('click', handleCtrlAltDel);
|
||
document.getElementById('btn-keyboard').addEventListener('click', handleToggleKeyboard);
|
||
document.getElementById('btn-log').addEventListener('click', handleToggleLog);
|
||
|
||
// Canvas resize
|
||
window.addEventListener('resize', handleResize);
|
||
|
||
// Log panel close
|
||
document.getElementById('btn-log-close').addEventListener('click', () => {
|
||
_logVisible = false;
|
||
document.getElementById('log-panel').classList.remove('visible');
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Handle connect button click
|
||
*/
|
||
function handleConnect() {
|
||
const serverAddr = document.getElementById('server-addr').value.trim();
|
||
const peerId = document.getElementById('peer-id').value.trim();
|
||
const serverKey = document.getElementById('server-key').value.trim();
|
||
const tls = document.getElementById('tls-check').checked;
|
||
|
||
if (!serverAddr) {
|
||
showToast('Please enter a server address', 'error');
|
||
return;
|
||
}
|
||
|
||
if (!peerId) {
|
||
showToast('Please enter a remote peer ID', 'error');
|
||
return;
|
||
}
|
||
|
||
log('info', `Connecting to ${serverAddr} (TLS: ${tls})...`);
|
||
log('info', `Target peer: ${peerId}`);
|
||
|
||
const btn = document.getElementById('btn-connect');
|
||
btn.disabled = true;
|
||
btn.innerHTML = '<span class="spinner"></span> Connecting...';
|
||
|
||
// Connect
|
||
RDConnection.connect(serverAddr, {
|
||
tls: tls,
|
||
serverKey: serverKey,
|
||
onPassword: handlePasswordRequired,
|
||
on2FA: handle2FARequired,
|
||
onPeerInfo: handlePeerInfo,
|
||
onVideoFrame: handleVideoFrame,
|
||
onAudioFrame: handleAudioFrame,
|
||
onCursorData: handleCursorData,
|
||
onCursorPosition: handleCursorPosition,
|
||
onMiscEvent: handleMiscEvent,
|
||
onClipboard: handleClipboard,
|
||
onClose: handleSessionClose,
|
||
onStateChange: handleStateChange,
|
||
onError: handleError,
|
||
});
|
||
|
||
// Set target peer after connecting to hbbs
|
||
setTimeout(() => {
|
||
RDConnection.connectToPeer(peerId);
|
||
}, 100);
|
||
}
|
||
|
||
/**
|
||
* Handle password required callback
|
||
*/
|
||
let _passwordResolve = null;
|
||
function handlePasswordRequired(hash, resolve) {
|
||
_passwordResolve = resolve;
|
||
document.getElementById('password-overlay').classList.add('visible');
|
||
document.getElementById('password-input').value = '';
|
||
document.getElementById('password-input').focus();
|
||
}
|
||
|
||
function handlePasswordSubmit() {
|
||
const password = document.getElementById('password-input').value;
|
||
document.getElementById('password-overlay').classList.remove('visible');
|
||
if (_passwordResolve) {
|
||
_passwordResolve(password);
|
||
_passwordResolve = null;
|
||
}
|
||
}
|
||
|
||
function handlePasswordCancel() {
|
||
document.getElementById('password-overlay').classList.remove('visible');
|
||
if (_passwordResolve) {
|
||
_passwordResolve(null);
|
||
_passwordResolve = null;
|
||
}
|
||
}
|
||
|
||
function handle2FARequired(resolve) {
|
||
// Simple prompt for 2FA (in production, use a proper dialog)
|
||
const code = prompt('Enter 2FA code:');
|
||
resolve(code);
|
||
}
|
||
|
||
/**
|
||
* Handle state changes
|
||
*/
|
||
function handleStateChange(newState, oldState) {
|
||
const statusText = document.getElementById('status-text');
|
||
const indicator = document.getElementById('status-indicator');
|
||
|
||
switch (newState) {
|
||
case RDConnection.STATE.CONNECTING:
|
||
statusText.textContent = 'Connecting to server...';
|
||
break;
|
||
case RDConnection.STATE.REGISTERING:
|
||
statusText.textContent = 'Registering...';
|
||
break;
|
||
case RDConnection.STATE.PUNCHING:
|
||
statusText.textContent = 'Discovering peer...';
|
||
break;
|
||
case RDConnection.STATE.RELAYING:
|
||
statusText.textContent = 'Establishing relay...';
|
||
break;
|
||
case RDConnection.STATE.KEY_EXCHANGE:
|
||
statusText.textContent = 'Exchanging keys...';
|
||
break;
|
||
case RDConnection.STATE.AUTHENTICATING:
|
||
statusText.textContent = 'Authenticating...';
|
||
break;
|
||
case RDConnection.STATE.CONNECTED:
|
||
statusText.textContent = 'Connected';
|
||
indicator.classList.add('connected');
|
||
onSessionConnected();
|
||
break;
|
||
case RDConnection.STATE.FAILED:
|
||
statusText.textContent = 'Connection failed';
|
||
onSessionFailed();
|
||
break;
|
||
case RDConnection.STATE.CLOSED:
|
||
statusText.textContent = 'Disconnected';
|
||
indicator.classList.remove('connected');
|
||
onSessionClosed();
|
||
break;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handle peer info received after login
|
||
*/
|
||
function handlePeerInfo(peerInfo) {
|
||
log('info', `Connected to: ${peerInfo.username}@${peerInfo.hostname} (${peerInfo.platform}) v${peerInfo.version}`);
|
||
|
||
if (peerInfo.displays && peerInfo.displays.length > 0) {
|
||
const display = peerInfo.displays[0];
|
||
log('info', `Display: ${display.width}x${display.height} (${display.name})`);
|
||
RDInput.setDisplaySize(display.width, display.height);
|
||
RDVideo.resize(display.width, display.height);
|
||
}
|
||
|
||
// Enable audio
|
||
RDAudio.init();
|
||
|
||
// Enable input
|
||
RDInput.setEnabled(true);
|
||
}
|
||
|
||
/**
|
||
* Handle video frame
|
||
*/
|
||
function handleVideoFrame(videoFrame) {
|
||
RDVideo.handleVideoFrame(videoFrame);
|
||
}
|
||
|
||
/**
|
||
* Handle audio frame
|
||
*/
|
||
function handleAudioFrame(audioFrame) {
|
||
RDAudio.handleAudioFrame(audioFrame);
|
||
}
|
||
|
||
/**
|
||
* Handle cursor data
|
||
*/
|
||
function handleCursorData(cursorData) {
|
||
if (!cursorData) return;
|
||
|
||
const canvas = document.getElementById('remote-canvas');
|
||
const width = cursorData.width || 0;
|
||
const height = cursorData.height || 0;
|
||
|
||
if (width === 0 || height === 0 || !cursorData.colors || cursorData.colors.length === 0) {
|
||
canvas.style.cursor = 'default';
|
||
return;
|
||
}
|
||
|
||
// Create cursor from RGBA data
|
||
const hotx = cursorData.hotx || 0;
|
||
const hoty = cursorData.hoty || 0;
|
||
const colors = new Uint8Array(cursorData.colors.buffer || cursorData.colors);
|
||
|
||
// Create a canvas for the cursor image
|
||
const cursorCanvas = document.createElement('canvas');
|
||
cursorCanvas.width = width;
|
||
cursorCanvas.height = height;
|
||
const ctx = cursorCanvas.getContext('2d');
|
||
const imageData = ctx.createImageData(width, height);
|
||
|
||
for (let i = 0; i < width * height; i++) {
|
||
const srcIdx = i * 4;
|
||
const dstIdx = i * 4;
|
||
// RustDesk sends BGRA, convert to RGBA
|
||
imageData.data[dstIdx] = colors[srcIdx + 2]; // R
|
||
imageData.data[dstIdx + 1] = colors[srcIdx + 1]; // G
|
||
imageData.data[dstIdx + 2] = colors[srcIdx]; // B
|
||
imageData.data[dstIdx + 3] = colors[srcIdx + 3]; // A
|
||
}
|
||
|
||
ctx.putImageData(imageData, 0, 0);
|
||
const url = cursorCanvas.toDataURL();
|
||
canvas.style.cursor = `url(${url}) ${hotx} ${hoty}, default`;
|
||
}
|
||
|
||
/**
|
||
* Handle cursor position update
|
||
*/
|
||
function handleCursorPosition(pos) {
|
||
// Position updates are informational; the local cursor is already tracked
|
||
}
|
||
|
||
/**
|
||
* Handle misc events
|
||
*/
|
||
function handleMiscEvent(field, data) {
|
||
switch (field) {
|
||
case 'audio_format':
|
||
if (data.sample_rate) {
|
||
RDAudio.setFormat(data.sample_rate, data.channels || 2);
|
||
}
|
||
break;
|
||
case 'switch_display':
|
||
if (data.width && data.height) {
|
||
RDInput.setDisplaySize(data.width, data.height);
|
||
RDVideo.resize(data.width, data.height);
|
||
}
|
||
break;
|
||
case 'close_reason':
|
||
log('warn', `Session closed by peer: ${data.close_reason}`);
|
||
break;
|
||
case 'permission_info':
|
||
const permName = data.permission_info.permission;
|
||
const enabled = data.permission_info.enabled;
|
||
log('info', `Permission ${permName}: ${enabled ? 'granted' : 'revoked'}`);
|
||
break;
|
||
case 'message_box':
|
||
showToast(`${data.title}: ${data.text}`, 'info');
|
||
break;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handle clipboard event
|
||
*/
|
||
function handleClipboard(clipboard) {
|
||
if (!clipboard || !clipboard.contents || clipboard.contents.length === 0) return;
|
||
|
||
for (const content of clipboard.contents) {
|
||
if (content.format === 0 && content.data) { // Text format
|
||
const text = new TextDecoder().decode(content.data);
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
navigator.clipboard.writeText(text).catch(() => {});
|
||
}
|
||
log('info', 'Clipboard updated from remote');
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handle errors
|
||
*/
|
||
function handleError(error) {
|
||
log('error', error);
|
||
showToast(error, 'error');
|
||
resetConnectButton();
|
||
}
|
||
|
||
/**
|
||
* Handle session close
|
||
*/
|
||
function handleSessionClose(reason) {
|
||
log('warn', 'Session closed:', reason);
|
||
showToast('Session closed: ' + (reason || 'Unknown reason'), 'info');
|
||
onSessionClosed();
|
||
}
|
||
|
||
/**
|
||
* Called when session is connected
|
||
*/
|
||
function onSessionConnected() {
|
||
resetConnectButton();
|
||
|
||
// Hide connection overlay, show remote desktop
|
||
document.getElementById('connection-overlay').classList.add('hidden');
|
||
document.getElementById('status-bar').classList.add('visible');
|
||
document.getElementById('toolbar').classList.add('visible');
|
||
document.getElementById('remote-canvas-wrapper').classList.add('visible');
|
||
|
||
// Initialize video and input
|
||
const canvas = document.getElementById('remote-canvas');
|
||
RDVideo.init(canvas);
|
||
RDInput.init(canvas);
|
||
|
||
// Update status
|
||
const peerId = document.getElementById('peer-id').value.trim();
|
||
document.getElementById('peer-id-display').textContent = peerId;
|
||
|
||
log('info', 'Session established!');
|
||
|
||
// Start FPS display update
|
||
setInterval(updateFpsDisplay, 1000);
|
||
}
|
||
|
||
/**
|
||
* Called when session fails
|
||
*/
|
||
function onSessionFailed() {
|
||
resetConnectButton();
|
||
RDConnection.close();
|
||
}
|
||
|
||
/**
|
||
* Called when session is closed
|
||
*/
|
||
function onSessionClosed() {
|
||
resetConnectButton();
|
||
|
||
// Show connection overlay, hide remote desktop
|
||
document.getElementById('connection-overlay').classList.remove('hidden');
|
||
document.getElementById('status-bar').classList.remove('visible');
|
||
document.getElementById('toolbar').classList.remove('visible');
|
||
document.getElementById('remote-canvas-wrapper').classList.remove('visible');
|
||
|
||
// Disable input
|
||
RDInput.setEnabled(false);
|
||
|
||
// Cleanup audio
|
||
RDAudio.cleanup();
|
||
}
|
||
|
||
/**
|
||
* Reset connect button state
|
||
*/
|
||
function resetConnectButton() {
|
||
const btn = document.getElementById('btn-connect');
|
||
btn.disabled = false;
|
||
btn.innerHTML = 'Connect';
|
||
}
|
||
|
||
/**
|
||
* Handle disconnect button
|
||
*/
|
||
function handleDisconnect() {
|
||
RDConnection.close();
|
||
}
|
||
|
||
/**
|
||
* Handle fullscreen toggle
|
||
*/
|
||
function handleFullscreen() {
|
||
if (document.fullscreenElement) {
|
||
document.exitFullscreen();
|
||
} else {
|
||
document.documentElement.requestFullscreen().catch(() => {});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handle mute toggle
|
||
*/
|
||
function handleToggleMute() {
|
||
const muted = RDAudio.toggleMute();
|
||
const btn = document.getElementById('btn-mute');
|
||
btn.classList.toggle('active', muted);
|
||
btn.textContent = muted ? '🔇' : '🔊';
|
||
log('info', muted ? 'Audio muted' : 'Audio unmuted');
|
||
}
|
||
|
||
/**
|
||
* Handle Ctrl+Alt+Del
|
||
*/
|
||
function handleCtrlAltDel() {
|
||
RDConnection.sendCtrlAltDel();
|
||
log('info', 'Sent Ctrl+Alt+Del');
|
||
}
|
||
|
||
/**
|
||
* Handle keyboard toggle
|
||
*/
|
||
function handleToggleKeyboard() {
|
||
// Toggle keyboard capture mode
|
||
const btn = document.getElementById('btn-keyboard');
|
||
btn.classList.toggle('active');
|
||
}
|
||
|
||
/**
|
||
* Handle log toggle
|
||
*/
|
||
function handleToggleLog() {
|
||
_logVisible = !_logVisible;
|
||
const panel = document.getElementById('log-panel');
|
||
panel.classList.toggle('visible', _logVisible);
|
||
const btn = document.getElementById('btn-log');
|
||
btn.classList.toggle('active', _logVisible);
|
||
}
|
||
|
||
/**
|
||
* Handle window resize
|
||
*/
|
||
function handleResize() {
|
||
if (RDConnection.isConnected) {
|
||
RDInput.setDisplaySize(RDVideo.getDisplaySize().width, RDVideo.getDisplaySize().height);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Update FPS display
|
||
*/
|
||
function updateFpsDisplay() {
|
||
if (!RDConnection.isConnected) return;
|
||
const fps = RDVideo.getFps();
|
||
document.getElementById('fps-display').textContent = `${fps} FPS`;
|
||
}
|
||
|
||
/**
|
||
* Add log entry
|
||
*/
|
||
function log(level, message) {
|
||
const timestamp = new Date().toLocaleTimeString();
|
||
const entry = { level, message, timestamp };
|
||
_logEntries.push(entry);
|
||
|
||
// Update log panel if visible
|
||
const logContent = document.getElementById('log-content');
|
||
if (logContent) {
|
||
const div = document.createElement('div');
|
||
div.className = `log-entry ${level}`;
|
||
div.textContent = `[${timestamp}] ${message}`;
|
||
logContent.appendChild(div);
|
||
logContent.scrollTop = logContent.scrollHeight;
|
||
}
|
||
|
||
// Also log to console
|
||
const prefix = { info: 'ℹ️', warn: '⚠️', error: '❌', debug: '🐛' }[level] || '';
|
||
console.log(`${prefix} [RD] ${message}`);
|
||
}
|
||
|
||
/**
|
||
* Show error
|
||
*/
|
||
function showError(message) {
|
||
log('error', message);
|
||
showToast(message, 'error');
|
||
}
|
||
|
||
/**
|
||
* Show toast notification
|
||
*/
|
||
function showToast(message, type = 'info') {
|
||
const container = document.getElementById('toast-container');
|
||
const toast = document.createElement('div');
|
||
toast.className = `toast ${type}`;
|
||
toast.textContent = message;
|
||
container.appendChild(toast);
|
||
|
||
setTimeout(() => {
|
||
toast.style.opacity = '0';
|
||
toast.style.transform = 'translateX(20px)';
|
||
toast.style.transition = 'all 0.3s ease';
|
||
setTimeout(() => toast.remove(), 300);
|
||
}, 5000);
|
||
}
|
||
|
||
return { init, log, showError, showToast };
|
||
})();
|
||
|
||
// ===== Bootstrap =====
|
||
|
||
document.addEventListener('DOMContentLoaded', async () => {
|
||
// Wait for external libraries to load
|
||
const waitForLib = (name, check, timeout = 5000) => {
|
||
return new Promise((resolve, reject) => {
|
||
if (check()) { resolve(); return; }
|
||
const start = Date.now();
|
||
const interval = setInterval(() => {
|
||
if (check()) { clearInterval(interval); resolve(); return; }
|
||
if (Date.now() - start > timeout) {
|
||
clearInterval(interval);
|
||
reject(new Error(`${name} not available after ${timeout}ms`));
|
||
}
|
||
}, 100);
|
||
});
|
||
};
|
||
|
||
try {
|
||
// Wait for protobuf.js
|
||
await waitForLib('protobuf.js', () => typeof protobuf !== 'undefined');
|
||
// Wait for TweetNaCl.js
|
||
await waitForLib('TweetNaCl.js', () => typeof nacl !== 'undefined');
|
||
|
||
RDApp.log('info', 'External libraries loaded');
|
||
|
||
// Try to load yuv-canvas
|
||
if (typeof YUVCanvas !== 'undefined') {
|
||
RDApp.log('info', 'yuv-canvas loaded');
|
||
} else {
|
||
RDApp.log('warn', 'yuv-canvas not available, video rendering may be limited');
|
||
}
|
||
|
||
// Initialize the app
|
||
await RDApp.init();
|
||
|
||
RDApp.log('info', 'RustDesk Standalone Web Client ready!');
|
||
RDApp.log('info', 'Supported codecs: VP8, VP9, AV1 (via ogvjs)');
|
||
RDApp.log('info', 'Audio: Opus (via libopus)');
|
||
|
||
} catch (e) {
|
||
RDApp.log('error', `Initialization failed: ${e.message}`);
|
||
RDApp.showToast('Failed to initialize. Check console for details.', 'error');
|
||
}
|
||
});
|