2 * RDS (Remote Desktop) Viewer Component
4 * Integrates with existing WebSocket RDS infrastructure to display remote desktop
5 * sessions using HTML canvas rendering with mouse/keyboard input.
8 * - Real-time screen streaming via WebSocket
9 * - Mouse click and movement
11 * - Clipboard sync (bidirectional)
12 * - Connection status indicators
13 * - Auto-reconnect on disconnect
16import { useState, useEffect, useRef } from 'react';
28} from '@mui/material';
30 Fullscreen as FullscreenIcon,
31 FullscreenExit as FullscreenExitIcon,
32 Refresh as RefreshIcon,
33 ContentCopy as CopyIcon,
34 ContentPaste as PasteIcon
35} from '@mui/icons-material';
36import { apiFetch } from '../lib/api';
38export default function RDSViewer({ agentId, agentUuid }) {
39 const [status, setStatus] = useState('idle'); // idle, starting, connecting, connected, error, disconnected
40 const [error, setError] = useState(null);
41 const [sessionId, setSessionId] = useState(null);
42 const [fullscreen, setFullscreen] = useState(false);
43 const [clipboard, setClipboard] = useState('');
44 const [agentClipboard, setAgentClipboard] = useState('');
46 const canvasRef = useRef(null);
47 const wsRef = useRef(null);
48 const containerRef = useRef(null);
49 const contextRef = useRef(null);
50 const imageRef = useRef(new Image());
53 const startSession = async () => {
55 setStatus('starting');
58 // Request session from backend
59 const response = await apiFetch(`/rds/start/${agentUuid}`, {
63 setSessionId(response.sessionId);
64 setStatus('connecting');
66 // Connect to viewer WebSocket
67 connectWebSocket(response.sessionId);
70 console.error('[RDS] Failed to start session:', err);
71 setError(err.message || 'Failed to start remote desktop session');
76 // Connect to WebSocket viewer
77 const connectWebSocket = (sid) => {
78 const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
79 const host = window.location.host;
80 const wsUrl = `${protocol}//${host}/api/rds/view/${sid}?session=${sid}`;
82 console.log('[RDS] Connecting to:', wsUrl);
84 const ws = new WebSocket(wsUrl);
85 ws.binaryType = 'arraybuffer';
89 console.log('[RDS] WebSocket connected');
90 setStatus('connected');
93 ws.onmessage = (event) => {
94 // Handle JSON messages (clipboard, status)
95 if (typeof event.data === 'string') {
97 const msg = JSON.parse(event.data);
100 console.error('[RDS] Failed to parse message:', err);
105 // Handle binary screen data (JPEG frames)
106 if (event.data instanceof ArrayBuffer) {
107 const blob = new Blob([event.data], { type: 'image/jpeg' });
108 const url = URL.createObjectURL(blob);
110 imageRef.current.onload = () => {
111 if (canvasRef.current && contextRef.current) {
112 const canvas = canvasRef.current;
113 const ctx = contextRef.current;
115 // Auto-resize canvas to match image
116 if (canvas.width !== imageRef.current.width || canvas.height !== imageRef.current.height) {
117 canvas.width = imageRef.current.width;
118 canvas.height = imageRef.current.height;
121 ctx.drawImage(imageRef.current, 0, 0);
123 URL.revokeObjectURL(url);
126 imageRef.current.src = url;
130 ws.onerror = (err) => {
131 console.error('[RDS] WebSocket error:', err);
132 setError('WebSocket connection error');
137 console.log('[RDS] WebSocket closed');
138 setStatus('disconnected');
142 // Handle JSON messages (clipboard, status, etc.)
143 const handleMessage = (msg) => {
144 if (msg.type === 'clipboard') {
145 setAgentClipboard(msg.data || '');
146 } else if (msg.type === 'status') {
147 console.log('[RDS] Status update:', msg);
152 const handleMouse = (event) => {
153 if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN || !sessionId) return;
155 const canvas = canvasRef.current;
156 const rect = canvas.getBoundingClientRect();
157 const x = Math.floor((event.clientX - rect.left) * (canvas.width / rect.width));
158 const y = Math.floor((event.clientY - rect.top) * (canvas.height / rect.height));
161 if (event.type === 'mousedown' || event.type === 'mouseup' || event.type === 'click') {
162 button = event.button;
165 wsRef.current.send(JSON.stringify({
167 sessionId: sessionId,
175 // Send keyboard event
176 const handleKeyboard = (event) => {
177 if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN || !sessionId) return;
179 wsRef.current.send(JSON.stringify({
181 sessionId: sessionId,
185 ctrlKey: event.ctrlKey,
186 shiftKey: event.shiftKey,
187 altKey: event.altKey,
188 metaKey: event.metaKey
192 // Send clipboard to agent
193 const sendClipboard = () => {
194 if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN || !sessionId) return;
196 wsRef.current.send(JSON.stringify({
198 sessionId: sessionId,
202 console.log('[RDS] Sent clipboard to agent');
205 // Copy agent clipboard to local
206 const copyAgentClipboard = async () => {
208 await navigator.clipboard.writeText(agentClipboard);
209 console.log('[RDS] Copied agent clipboard to local');
211 console.error('[RDS] Failed to copy to clipboard:', err);
216 const toggleFullscreen = () => {
218 containerRef.current?.requestFullscreen();
221 document.exitFullscreen();
222 setFullscreen(false);
227 const stopSession = async () => {
229 wsRef.current.close();
230 wsRef.current = null;
235 await apiFetch(`/rds/stop/${agentUuid}`, {
239 console.error('[RDS] Failed to stop session:', err);
247 // Initialize canvas context
249 if (canvasRef.current) {
250 contextRef.current = canvasRef.current.getContext('2d');
254 // Cleanup on unmount
261 const statusColors = {
265 connected: 'success',
267 disconnected: 'warning'
270 const statusLabels = {
271 idle: 'Not connected',
272 starting: 'Starting session...',
273 connecting: 'Connecting...',
274 connected: 'Connected',
276 disconnected: 'Disconnected'
280 <Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
282 <Alert severity={statusColors[status]} sx={{ mb: 2 }}>
283 Status: {statusLabels[status]}
284 {status === 'starting' && <CircularProgress size={16} sx={{ ml: 1 }} />}
288 <Alert severity="error" sx={{ mb: 2 }}>
294 <Paper sx={{ p: 1, mb: 2, display: 'flex', gap: 1, alignItems: 'center' }}>
295 {status === 'idle' && (
296 <Button variant="contained" onClick={startSession}>
301 {status !== 'idle' && status !== 'starting' && (
303 <Button variant="outlined" color="error" onClick={stopSession}>
307 <Tooltip title="Refresh">
308 <IconButton onClick={() => { stopSession(); setTimeout(startSession, 1000); }}>
313 <Tooltip title={fullscreen ? "Exit Fullscreen" : "Fullscreen"}>
314 <IconButton onClick={toggleFullscreen}>
315 {fullscreen ? <FullscreenExitIcon /> : <FullscreenIcon />}
322 {/* Viewer Canvas */}
330 alignItems: 'center',
331 justifyContent: 'center',
335 {status === 'idle' && (
336 <Typography color="text.secondary">
337 Click "Start Remote Desktop" to begin
341 {(status === 'starting' || status === 'connecting') && (
342 <Box sx={{ textAlign: 'center' }}>
344 <Typography color="text.secondary" sx={{ mt: 2 }}>
345 {statusLabels[status]}
350 {status === 'connected' && (
353 onMouseDown={handleMouse}
354 onMouseUp={handleMouse}
355 onMouseMove={handleMouse}
356 onClick={handleMouse}
357 onKeyDown={handleKeyboard}
358 onKeyUp={handleKeyboard}
370 {/* Clipboard Controls */}
371 {status === 'connected' && (
372 <Paper sx={{ p: 2, mt: 2 }}>
373 <Typography variant="h6" gutterBottom>
378 {/* Send to Agent */}
380 <Typography variant="subtitle2" gutterBottom>
381 Send to Remote Desktop
383 <Stack direction="row" spacing={1}>
390 onChange={(e) => setClipboard(e.target.value)}
391 placeholder="Type text to send to remote desktop"
395 startIcon={<PasteIcon />}
396 onClick={sendClipboard}
397 disabled={!clipboard}
404 {/* Receive from Agent */}
406 <Typography variant="subtitle2" gutterBottom>
407 Received from Remote Desktop
409 <Stack direction="row" spacing={1}>
415 value={agentClipboard}
416 InputProps={{ readOnly: true }}
417 placeholder="Clipboard content from remote desktop will appear here"
421 startIcon={<CopyIcon />}
422 onClick={copyAgentClipboard}
423 disabled={!agentClipboard}