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