EverydayTech Platform - Developer Reference
Complete Source Code Documentation - All Applications
Loading...
Searching...
No Matches
MeshDesktopViewer.jsx
Go to the documentation of this file.
1/**
2 * MeshCentral Desktop Viewer Component
3 * Direct integration with MeshCentral's desktop multiplexer
4 * Supports KVM (Keyboard, Video, Mouse) operations
5 */
6
7import React, { useRef, useState, useEffect, useCallback } from 'react';
8import './MeshDesktopViewer.css';
9
10export default function MeshDesktopViewer({ meshcentralNodeId, meshcentralUrl, onError }) {
11 const canvasRef = useRef(null);
12 const wsRef = useRef(null);
13 const ctxRef = useRef(null);
14
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);
19
20 // Mouse state for relative mode
21 const mouseState = useRef({ x: 0, y: 0, buttons: 0 });
22
23 /**
24 * Connect to MeshCentral desktop stream
25 */
26 const connect = useCallback(() => {
27 if (!meshcentralNodeId) {
28 onError?.('No node ID provided');
29 return;
30 }
31
32 setStatus('connecting');
33
34 // Get JWT from localStorage
35 const token = localStorage.getItem('token');
36
37 if (!token) {
38 console.error('[MeshDesktop] No authentication token found in localStorage');
39 setStatus('error');
40 onError?.('No authentication token found. Please log in again.');
41 return;
42 }
43
44 console.log('[MeshDesktop] Token found:', token.substring(0, 20) + '...');
45
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)}` : ''}`;
50
51 console.log('[MeshDesktop] Connecting to:', wsUrl.replace(token || '', 'TOKEN'));
52
53 try {
54 const ws = new WebSocket(wsUrl);
55 ws.binaryType = 'arraybuffer';
56 wsRef.current = ws;
57
58 ws.onopen = () => {
59 console.log('[MeshDesktop] ✅ Connected');
60 setStatus('connected');
61
62 // Don't send initial options - wait for 'connected' message first
63 };
64
65 ws.onmessage = (event) => {
66 handleMessage(event);
67 };
68
69 ws.onerror = (err) => {
70 console.error('[MeshDesktop] ❌ Error:', err);
71 setStatus('error');
72 onError?.('Connection error');
73 };
74
75 ws.onclose = () => {
76 console.log('[MeshDesktop] 🔌 Disconnected');
77 setStatus('disconnected');
78 };
79
80 } catch (err) {
81 console.error('[MeshDesktop] ❌ Failed to create WebSocket:', err);
82 setStatus('error');
83 onError?.(err.message);
84 }
85 }, [meshcentralNodeId, meshcentralUrl, onError]);
86
87 /**
88 * Handle incoming messages from MeshCentral
89 */
90 const handleMessage = useCallback((event) => {
91 const data = event.data;
92
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
99 try {
100 const msg = JSON.parse(data);
101 handleJSONMessage(msg);
102 } catch (err) {
103 console.error('[MeshDesktop] Failed to parse JSON:', err);
104 }
105 }
106 }, []);
107
108 /**
109 * Handle JSON command messages
110 */
111 const handleJSONMessage = (msg) => {
112 console.log('[MeshDesktop] JSON message:', msg);
113
114 switch (msg.action || msg.type) {
115 case 'test':
116 console.log('[MeshDesktop] Test message received:', msg.message);
117 break;
118
119 case 'connected':
120 console.log('[MeshDesktop] Connected to canvas endpoint, Phase:', msg.phase);
121 console.log('[MeshDesktop] Capabilities:', msg.capabilities);
122 break;
123
124 case 'ready':
125 console.log('[MeshDesktop] Desktop streaming ready');
126 // Desktop stream should start automatically now
127 break;
128
129 case 'metadata':
130 case 'console':
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);
136 }
137 break;
138
139 case 'error':
140 console.error('[MeshDesktop] Server error:', msg.message || msg.msg);
141 onError?.(msg.message || msg.msg || 'Unknown error');
142 break;
143
144 default:
145 console.log('[MeshDesktop] Unknown message:', msg);
146 }
147 };
148
149 /**
150 * Handle binary frame data from MeshCentral desktop multiplexor
151 * MeshCentral uses a custom tile-based protocol
152 */
153 const handleBinaryMessage = (data) => {
154 if (!canvasRef.current || !ctxRef.current) return;
155
156 // Convert to ArrayBuffer if it's a Blob
157 if (data instanceof Blob) {
158 data.arrayBuffer().then(buffer => processMeshData(buffer));
159 } else {
160 processMeshData(data);
161 }
162 };
163
164 /**
165 * Process MeshCentral desktop protocol data
166 */
167 const processMeshData = (buffer) => {
168 // MeshCentral desktop protocol format:
169 // First byte is command type
170 const view = new DataView(buffer);
171
172 if (buffer.byteLength < 1) return;
173
174 const cmd = view.getUint8(0);
175
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);
180
181 console.log('[MeshDesktop] Display resolution:', width, 'x', height);
182
183 if (width > 0 && height > 0) {
184 setDesktopInfo({ width, height });
185 setCanvasSize({ width, height });
186
187 // Resize canvas
188 if (canvasRef.current) {
189 canvasRef.current.width = width;
190 canvasRef.current.height = height;
191 }
192 }
193 }
194 // JPEG tile data
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);
202
203 // Image data starts at byte 13
204 const imgData = buffer.slice(13);
205
206 // Create blob and load as image
207 const blob = new Blob([imgData], { type: 'image/jpeg' });
208 const url = URL.createObjectURL(blob);
209
210 const img = new Image();
211 img.onload = () => {
212 if (ctxRef.current && canvasRef.current) {
213 // Draw tile at specified position
214 ctxRef.current.drawImage(img, x, y, width, height);
215 }
216 URL.revokeObjectURL(url);
217 };
218 img.onerror = () => {
219 console.error('[MeshDesktop] Failed to load tile image');
220 URL.revokeObjectURL(url);
221 };
222 img.src = url;
223 }
224 // Console message
225 else if (cmd === 99) { // 'c' - console message
226 const message = new TextDecoder().decode(buffer.slice(1));
227 console.log('[MeshDesktop] Console:', message);
228 }
229 };
230
231 /**
232 * Send command to MeshCentral
233 */
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);
239 }
240 }, []);
241
242 /**
243 * Mouse event handler
244 */
245 const handleMouseEvent = useCallback((e) => {
246 if (!canvasRef.current || status !== 'connected') return;
247
248 e.preventDefault();
249
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));
253
254 // Update mouse state
255 mouseState.current.x = x;
256 mouseState.current.y = y;
257 mouseState.current.buttons = e.buttons;
258
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' });
266 }
267 }, [canvasSize, status, sendCommand]);
268
269 /**
270 * Keyboard event handler
271 */
272 const handleKeyboardEvent = useCallback((e) => {
273 if (status !== 'connected') return;
274
275 // Prevent default browser shortcuts
276 if (e.ctrlKey || e.altKey || e.metaKey) {
277 e.preventDefault();
278 }
279
280 const keyCode = e.keyCode || e.which;
281 const isDown = e.type === 'keydown';
282
283 // Send keyboard event to MeshCentral
284 sendCommand('key', {
285 keyCode,
286 key: e.key,
287 code: e.code,
288 down: isDown,
289 shift: e.shiftKey,
290 ctrl: e.ctrlKey,
291 alt: e.altKey,
292 meta: e.metaKey
293 });
294
295 e.preventDefault();
296 }, [status, sendCommand]);
297
298 /**
299 * Toggle fullscreen
300 */
301 const toggleFullscreen = () => {
302 const container = canvasRef.current?.parentElement;
303 if (!container) return;
304
305 if (!document.fullscreenElement) {
306 container.requestFullscreen?.()
307 || container.webkitRequestFullscreen?.()
308 || container.mozRequestFullScreen?.();
309 setIsFullscreen(true);
310 } else {
311 document.exitFullscreen?.()
312 || document.webkitExitFullscreen?.()
313 || document.mozCancelFullScreen?.();
314 setIsFullscreen(false);
315 }
316 };
317
318 /**
319 * Send Ctrl+Alt+Del
320 */
321 const sendCtrlAltDel = () => {
322 sendCommand('key', { special: 'cad' });
323 };
324
325 /**
326 * Disconnect
327 */
328 const disconnect = () => {
329 if (wsRef.current) {
330 wsRef.current.close();
331 wsRef.current = null;
332 }
333 setStatus('disconnected');
334 };
335
336 // Auto-connect on mount
337 useEffect(() => {
338 connect();
339
340 // Setup canvas context
341 if (canvasRef.current) {
342 ctxRef.current = canvasRef.current.getContext('2d');
343 }
344
345 return () => {
346 disconnect();
347 };
348 }, [meshcentralNodeId]);
349
350 // Keyboard focus management
351 useEffect(() => {
352 const canvas = canvasRef.current;
353 if (!canvas) return;
354
355 // Set tabindex to make canvas focusable
356 canvas.setAttribute('tabindex', '0');
357
358 // Focus canvas automatically when connected
359 if (status === 'connected') {
360 canvas.focus();
361 }
362 }, [status]);
363
364 return (
365 <div className="mesh-desktop-viewer">
366 {/* Toolbar */}
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'}
374 </span>
375 {desktopInfo && (
376 <span className="desktop-info">
377 {desktopInfo.width} × {desktopInfo.height}
378 </span>
379 )}
380 </div>
381
382 <div className="toolbar-actions">
383 <button
384 onClick={sendCtrlAltDel}
385 disabled={status !== 'connected'}
386 title="Send Ctrl+Alt+Del"
387 className="toolbar-btn"
388 >
389 <span className="material-symbols-outlined">keyboard</span>
390 Ctrl+Alt+Del
391 </button>
392
393 <button
394 onClick={toggleFullscreen}
395 title="Toggle Fullscreen"
396 className="toolbar-btn"
397 >
398 <span className="material-symbols-outlined">
399 {isFullscreen ? 'fullscreen_exit' : 'fullscreen'}
400 </span>
401 </button>
402
403 <button
404 onClick={connect}
405 disabled={status === 'connecting' || status === 'connected'}
406 title="Reconnect"
407 className="toolbar-btn"
408 >
409 <span className="material-symbols-outlined">refresh</span>
410 </button>
411
412 <button
413 onClick={disconnect}
414 disabled={status === 'disconnected'}
415 title="Disconnect"
416 className="toolbar-btn btn-danger"
417 >
418 <span className="material-symbols-outlined">close</span>
419 </button>
420 </div>
421 </div>
422
423 {/* Canvas container */}
424 <div className="mesh-desktop-canvas-container">
425 <canvas
426 ref={canvasRef}
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"
436 />
437
438 {status === 'connecting' && (
439 <div className="mesh-desktop-overlay">
440 <div className="spinner"></div>
441 <p>Connecting to remote desktop...</p>
442 </div>
443 )}
444
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>
450 </div>
451 )}
452
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>
458 </div>
459 )}
460 </div>
461 </div>
462 );
463}