2 * MeshCentral Desktop Viewer Component
3 * Direct integration with MeshCentral's desktop multiplexer
4 * Supports KVM (Keyboard, Video, Mouse) operations
7import React, { useRef, useState, useEffect, useCallback } from 'react';
8import './MeshDesktopViewer.css';
10export default function MeshDesktopViewer({ meshcentralNodeId, meshcentralUrl, onError }) {
11 const canvasRef = useRef(null);
12 const wsRef = useRef(null);
13 const ctxRef = useRef(null);
15 const [status, setStatus] = useState('disconnected');
16 const [desktopInfo, setDesktopInfo] = useState(null);
17 const [canvasSize, setCanvasSize] = useState({ width: 1024, height: 768 });
18 const [isFullscreen, setIsFullscreen] = useState(false);
20 // Mouse state for relative mode
21 const mouseState = useRef({ x: 0, y: 0, buttons: 0 });
24 * Connect to MeshCentral desktop stream
26 const connect = useCallback(() => {
27 if (!meshcentralNodeId) {
28 onError?.('No node ID provided');
32 setStatus('connecting');
34 // Get JWT from localStorage
35 const token = localStorage.getItem('token');
38 console.error('[MeshDesktop] No authentication token found in localStorage');
40 onError?.('No authentication token found. Please log in again.');
44 console.log('[MeshDesktop] Token found:', token.substring(0, 20) + '...');
46 // Build WebSocket URL for desktop connection
47 // Use our custom canvas-desktop endpoint
48 const baseUrl = (meshcentralUrl || 'wss://rmm-psa-meshcentral-aq48h.ondigitalocean.app').replace(/^https/, 'wss');
49 const wsUrl = `${baseUrl}/api/canvas-desktop/${encodeURIComponent(meshcentralNodeId)}${token ? `?token=${encodeURIComponent(token)}` : ''}`;
51 console.log('[MeshDesktop] Connecting to:', wsUrl.replace(token || '', 'TOKEN'));
54 const ws = new WebSocket(wsUrl);
55 ws.binaryType = 'arraybuffer';
59 console.log('[MeshDesktop] ✅ Connected');
60 setStatus('connected');
62 // Don't send initial options - wait for 'connected' message first
65 ws.onmessage = (event) => {
69 ws.onerror = (err) => {
70 console.error('[MeshDesktop] ❌ Error:', err);
72 onError?.('Connection error');
76 console.log('[MeshDesktop] 🔌 Disconnected');
77 setStatus('disconnected');
81 console.error('[MeshDesktop] ❌ Failed to create WebSocket:', err);
83 onError?.(err.message);
85 }, [meshcentralNodeId, meshcentralUrl, onError]);
88 * Handle incoming messages from MeshCentral
90 const handleMessage = useCallback((event) => {
91 const data = event.data;
93 // Check if binary (desktop tiles/images) or text (JSON control)
94 if (data instanceof ArrayBuffer || data instanceof Blob) {
95 // Binary desktop data from MeshCentral multiplexor
96 handleBinaryMessage(data);
97 } else if (typeof data === 'string') {
98 // JSON control messages
100 const msg = JSON.parse(data);
101 handleJSONMessage(msg);
103 console.error('[MeshDesktop] Failed to parse JSON:', err);
109 * Handle JSON command messages
111 const handleJSONMessage = (msg) => {
112 console.log('[MeshDesktop] JSON message:', msg);
114 switch (msg.action || msg.type) {
116 console.log('[MeshDesktop] Test message received:', msg.message);
120 console.log('[MeshDesktop] Connected to canvas endpoint, Phase:', msg.phase);
121 console.log('[MeshDesktop] Capabilities:', msg.capabilities);
125 console.log('[MeshDesktop] Desktop streaming ready');
126 // Desktop stream should start automatically now
131 // Desktop metadata (resolution, etc.)
132 if (msg.width && msg.height) {
133 setDesktopInfo({ width: msg.width, height: msg.height });
134 setCanvasSize({ width: msg.width, height: msg.height });
135 console.log('[MeshDesktop] Desktop resolution:', msg.width, 'x', msg.height);
140 console.error('[MeshDesktop] Server error:', msg.message || msg.msg);
141 onError?.(msg.message || msg.msg || 'Unknown error');
145 console.log('[MeshDesktop] Unknown message:', msg);
150 * Handle binary frame data from MeshCentral desktop multiplexor
151 * MeshCentral uses a custom tile-based protocol
153 const handleBinaryMessage = (data) => {
154 if (!canvasRef.current || !ctxRef.current) return;
156 // Convert to ArrayBuffer if it's a Blob
157 if (data instanceof Blob) {
158 data.arrayBuffer().then(buffer => processMeshData(buffer));
160 processMeshData(data);
165 * Process MeshCentral desktop protocol data
167 const processMeshData = (buffer) => {
168 // MeshCentral desktop protocol format:
169 // First byte is command type
170 const view = new DataView(buffer);
172 if (buffer.byteLength < 1) return;
174 const cmd = view.getUint8(0);
176 // Display resolution/info command
177 if (cmd === 65 && buffer.byteLength >= 17) { // 'A' - Display info
178 const width = view.getUint16(1, true);
179 const height = view.getUint16(3, true);
181 console.log('[MeshDesktop] Display resolution:', width, 'x', height);
183 if (width > 0 && height > 0) {
184 setDesktopInfo({ width, height });
185 setCanvasSize({ width, height });
188 if (canvasRef.current) {
189 canvasRef.current.width = width;
190 canvasRef.current.height = height;
195 else if (cmd === 3 && buffer.byteLength > 13) {
196 // Desktop tile command: cmd(1) + x(2) + y(2) + width(2) + height(2) + imgType(1) + imgLen(3) + imgData
197 const x = view.getUint16(1, true);
198 const y = view.getUint16(3, true);
199 const width = view.getUint16(5, true);
200 const height = view.getUint16(7, true);
201 const imgType = view.getUint8(9);
203 // Image data starts at byte 13
204 const imgData = buffer.slice(13);
206 // Create blob and load as image
207 const blob = new Blob([imgData], { type: 'image/jpeg' });
208 const url = URL.createObjectURL(blob);
210 const img = new Image();
212 if (ctxRef.current && canvasRef.current) {
213 // Draw tile at specified position
214 ctxRef.current.drawImage(img, x, y, width, height);
216 URL.revokeObjectURL(url);
218 img.onerror = () => {
219 console.error('[MeshDesktop] Failed to load tile image');
220 URL.revokeObjectURL(url);
225 else if (cmd === 99) { // 'c' - console message
226 const message = new TextDecoder().decode(buffer.slice(1));
227 console.log('[MeshDesktop] Console:', message);
232 * Send command to MeshCentral
234 const sendCommand = useCallback((type, data) => {
235 if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
236 const msg = { action: type, ...data };
237 wsRef.current.send(JSON.stringify(msg));
238 console.log('[MeshDesktop] 📤 Sent:', type, data);
243 * Mouse event handler
245 const handleMouseEvent = useCallback((e) => {
246 if (!canvasRef.current || status !== 'connected') return;
250 const rect = canvasRef.current.getBoundingClientRect();
251 const x = Math.floor((e.clientX - rect.left) * (canvasSize.width / rect.width));
252 const y = Math.floor((e.clientY - rect.top) * (canvasSize.height / rect.height));
254 // Update mouse state
255 mouseState.current.x = x;
256 mouseState.current.y = y;
257 mouseState.current.buttons = e.buttons;
259 // Send mouse event to MeshCentral
260 // Protocol: mouse,x,y,buttons
261 if (e.type === 'mousemove') {
262 sendCommand('mouse', { x, y });
263 } else if (e.type === 'mousedown' || e.type === 'mouseup') {
264 const button = e.button === 0 ? 1 : e.button === 2 ? 2 : e.button === 1 ? 4 : 0;
265 sendCommand('mousebutton', { x, y, button, down: e.type === 'mousedown' });
267 }, [canvasSize, status, sendCommand]);
270 * Keyboard event handler
272 const handleKeyboardEvent = useCallback((e) => {
273 if (status !== 'connected') return;
275 // Prevent default browser shortcuts
276 if (e.ctrlKey || e.altKey || e.metaKey) {
280 const keyCode = e.keyCode || e.which;
281 const isDown = e.type === 'keydown';
283 // Send keyboard event to MeshCentral
296 }, [status, sendCommand]);
301 const toggleFullscreen = () => {
302 const container = canvasRef.current?.parentElement;
303 if (!container) return;
305 if (!document.fullscreenElement) {
306 container.requestFullscreen?.()
307 || container.webkitRequestFullscreen?.()
308 || container.mozRequestFullScreen?.();
309 setIsFullscreen(true);
311 document.exitFullscreen?.()
312 || document.webkitExitFullscreen?.()
313 || document.mozCancelFullScreen?.();
314 setIsFullscreen(false);
321 const sendCtrlAltDel = () => {
322 sendCommand('key', { special: 'cad' });
328 const disconnect = () => {
330 wsRef.current.close();
331 wsRef.current = null;
333 setStatus('disconnected');
336 // Auto-connect on mount
340 // Setup canvas context
341 if (canvasRef.current) {
342 ctxRef.current = canvasRef.current.getContext('2d');
348 }, [meshcentralNodeId]);
350 // Keyboard focus management
352 const canvas = canvasRef.current;
355 // Set tabindex to make canvas focusable
356 canvas.setAttribute('tabindex', '0');
358 // Focus canvas automatically when connected
359 if (status === 'connected') {
365 <div className="mesh-desktop-viewer">
367 <div className="mesh-desktop-toolbar">
368 <div className="toolbar-status">
369 <span className={`status-indicator status-${status}`}>●</span>
370 <span className="status-text">
371 {status === 'connected' ? 'Connected' :
372 status === 'connecting' ? 'Connecting...' :
373 status === 'error' ? 'Error' : 'Disconnected'}
376 <span className="desktop-info">
377 {desktopInfo.width} × {desktopInfo.height}
382 <div className="toolbar-actions">
384 onClick={sendCtrlAltDel}
385 disabled={status !== 'connected'}
386 title="Send Ctrl+Alt+Del"
387 className="toolbar-btn"
389 <span className="material-symbols-outlined">keyboard</span>
394 onClick={toggleFullscreen}
395 title="Toggle Fullscreen"
396 className="toolbar-btn"
398 <span className="material-symbols-outlined">
399 {isFullscreen ? 'fullscreen_exit' : 'fullscreen'}
405 disabled={status === 'connecting' || status === 'connected'}
407 className="toolbar-btn"
409 <span className="material-symbols-outlined">refresh</span>
414 disabled={status === 'disconnected'}
416 className="toolbar-btn btn-danger"
418 <span className="material-symbols-outlined">close</span>
423 {/* Canvas container */}
424 <div className="mesh-desktop-canvas-container">
427 width={canvasSize.width}
428 height={canvasSize.height}
429 onMouseMove={handleMouseEvent}
430 onMouseDown={handleMouseEvent}
431 onMouseUp={handleMouseEvent}
432 onContextMenu={(e) => e.preventDefault()}
433 onKeyDown={handleKeyboardEvent}
434 onKeyUp={handleKeyboardEvent}
435 className="mesh-desktop-canvas"
438 {status === 'connecting' && (
439 <div className="mesh-desktop-overlay">
440 <div className="spinner"></div>
441 <p>Connecting to remote desktop...</p>
445 {status === 'error' && (
446 <div className="mesh-desktop-overlay">
447 <span className="material-symbols-outlined error-icon">error</span>
448 <p>Connection failed</p>
449 <button onClick={connect} className="retry-btn">Retry</button>
453 {status === 'disconnected' && meshcentralNodeId && (
454 <div className="mesh-desktop-overlay">
455 <span className="material-symbols-outlined">desktop_windows</span>
456 <p>Click Connect to start remote desktop session</p>
457 <button onClick={connect} className="connect-btn">Connect</button>