projects/shelled/rustdesk-web-client/js/app.js
Z User 7b758ebe01 Add standalone RustDesk web client (reverse-engineered)
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/
2026-04-06 21:11:21 +00:00

587 lines
19 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

/**
* 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');
}
});