import React, { useEffect, useRef, useState, useCallback } from 'react'; interface FrameHeader { frameId: number; width: number; height: number; timestamp: number; isFull: boolean; } interface VncViewerProps { serverUrl?: string; width?: number; height?: number; onConnect?: () => void; onDisconnect?: () => void; onError?: (error: Error) => void; } interface VncViewerState { isConnected: boolean; isConnecting: boolean; fps: number; latency: number; error: string | null; } const DEFAULT_PROPS = { serverUrl: '/api/vnc', width: 1280, height: 720, }; export default function VncViewer(props: VncViewerProps) { const config = { ...DEFAULT_PROPS, ...props }; const canvasRef = useRef(null); const offscreenCanvasRef = useRef(null); const workerRef = useRef(null); const lastFrameIdRef = useRef(0); const lastFrameTimeRef = useRef(0); const frameCountRef = useRef(0); const lastPingTimeRef = useRef(Date.now()); const pollControllerRef = useRef(null); const inputQueueRef = useRef }>>([]); const [state, setState] = useState({ isConnected: false, isConnecting: false, fps: 0, latency: 0, error: null, }); // Performance tracking const fpsCounterRef = useRef({ frames: 0, lastTime: Date.now(), fps: 0, }); /** * Send input event to server */ const sendInput = useCallback(async (type: string, data: Record) => { try { await fetch(`${config.serverUrl}/input`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type, data }), }); } catch (error) { console.error('Input error:', error); } }, [config.serverUrl]); /** * Process mouse event */ const handleMouseMove = useCallback((event: React.MouseEvent) => { if (!canvasRef.current) return; const rect = canvasRef.current.getBoundingClientRect(); const scaleX = config.width! / rect.width; const scaleY = config.height! / rect.height; const x = Math.floor((event.clientX - rect.left) * scaleX); const y = Math.floor((event.clientY - rect.top) * scaleY); sendInput('mousemove', { x, y }); }, [config, sendInput]); const handleMouseDown = useCallback((event: React.MouseEvent) => { const buttonMap: Record = { 0: 1, // Left button 1: 2, // Middle button 2: 3, // Right button }; const button = buttonMap[event.button] || 1; sendInput('mousedown', { button }); }, [sendInput]); const handleMouseUp = useCallback((event: React.MouseEvent) => { const buttonMap: Record = { 0: 1, 1: 2, 2: 3, }; const button = buttonMap[event.button] || 1; sendInput('mouseup', { button }); }, [sendInput]); const handleWheel = useCallback((event: React.WheelEvent) => { sendInput('wheel', { deltaX: event.deltaX, deltaY: event.deltaY, }); }, [sendInput]); /** * Process keyboard event */ const handleKeyDown = useCallback((event: React.KeyboardEvent) => { // Prevent default browser shortcuts if (event.ctrlKey || event.altKey || event.metaKey) { return; } event.preventDefault(); sendInput('keydown', { keyCode: event.which || event.keyCode, key: event.key, code: event.code, }); }, [sendInput]); const handleKeyUp = useCallback((event: React.KeyboardEvent) => { event.preventDefault(); sendInput('keyup', { keyCode: event.which || event.keyCode, key: event.key, code: event.code, }); }, [sendInput]); /** * Poll for new frames */ const pollFrames = useCallback(async () => { if (!canvasRef.current) return; const poll = async () => { try { const startTime = Date.now(); const response = await fetch( `${config.serverUrl}/poll?lastId=${lastFrameIdRef.current}&width=${config.width}&height=${config.height}`, { signal: pollControllerRef.current?.signal, } ); if (!response.ok) { throw new Error(`HTTP error: ${response.status}`); } const latency = Date.now() - startTime; lastPingTimeRef.current = Date.now(); const contentType = response.headers.get('content-type'); if (contentType?.includes('application/octet-stream')) { const buffer = await response.arrayBuffer(); const data = new Uint8Array(buffer); if (data.length > 16) { // Parse frame header const view = new DataView(data.buffer); const header: FrameHeader = { frameId: view.getUint32(0, true), width: view.getUint16(4, true), height: view.getUint16(6, true), timestamp: view.getUint32(8, true), isFull: view.getUint8(12) === 1, }; const frameData = data.slice(16); // Update canvas renderFrame(header, frameData); lastFrameIdRef.current = header.frameId; // Update FPS counter fpsCounterRef.current.frames++; const now = Date.now(); if (now - fpsCounterRef.current.lastTime >= 1000) { fpsCounterRef.current.fps = fpsCounterRef.current.frames; fpsCounterRef.current.frames = 0; fpsCounterRef.current.lastTime = now; setState(prev => ({ ...prev, fps: fpsCounterRef.current.fps, latency, })); } } } else { // JSON response (timeout or status) const json = await response.json(); if (json.timeout) { // Continue polling setTimeout(poll, 50); return; } } // Continue polling immediately for smooth updates requestAnimationFrame(poll); } catch (error) { if (error instanceof Error && error.name === 'AbortError') { return; // Stop polling } console.error('Poll error:', error); setState(prev => ({ ...prev, error: error instanceof Error ? error.message : 'Connection error', })); // Retry after delay setTimeout(poll, 1000); } }; poll(); }, [config]); /** * Render frame to canvas */ const renderFrame = (header: FrameHeader, data: Uint8Array) => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; // Create or resize offscreen canvas if needed if (!offscreenCanvasRef.current || offscreenCanvasRef.current.width !== header.width || offscreenCanvasRef.current.height !== header.height) { offscreenCanvasRef.current = new OffscreenCanvas(header.width, header.height); } const offscreenCtx = offscreenCanvasRef.current.getContext('2d'); if (!offscreenCtx) return; // Create image data const imageData = offscreenCtx.createImageData(header.width, header.height); if (header.isFull) { // Full frame - just copy data imageData.data.set(data); } else { // Diff frame - apply to existing canvas data const currentData = offscreenCtx.getImageData(0, 0, header.width, header.height); const diffView = new DataView(data.buffer); let offset = 0; while (offset < data.length) { const flags = data[offset]; const startX = diffView.getUint16(offset + 1, true); const startY = diffView.getUint16(offset + 3, true); const width = diffView.getUint16(offset + 5, true); const height = diffView.getUint16(offset + 7, true); offset += 9; if (flags === 1) { // Changed region for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const srcIdx = offset + (y * width + x) * 4; const dstIdx = ((startY + y) * header.width + (startX + x)) * 4; imageData.data[dstIdx] = data[srcIdx]; imageData.data[dstIdx + 1] = data[srcIdx + 1]; imageData.data[dstIdx + 2] = data[srcIdx + 2]; imageData.data[dstIdx + 3] = 255; } } offset += width * height * 4; } } } // Put image data to offscreen canvas offscreenCtx.putImageData(imageData, 0, 0); // Copy to main canvas with scaling canvas.width = header.width; canvas.height = header.height; ctx.imageSmoothingEnabled = true; ctx.drawImage(offscreenCanvasRef.current, 0, 0); lastFrameTimeRef.current = Date.now(); }; /** * Initialize VNC connection */ useEffect(() => { setState(prev => ({ ...prev, isConnecting: true })); // Start polling for frames pollFrames(); // Mark as connected after first frame const connectTimeout = setTimeout(() => { setState(prev => ({ ...prev, isConnected: true, isConnecting: false })); config.onConnect?.(); }, 2000); return () => { clearTimeout(connectTimeout); pollControllerRef.current?.abort(); config.onDisconnect?.(); }; }, [pollFrames, config]); return (
{state.isConnected ? 'Connected' : state.isConnecting ? 'Connecting...' : 'Disconnected'}
FPS {state.fps}
Latency {state.latency}ms
Resolution {config.width}×{config.height}
{state.error && (
Status {state.error}
)}
Click to focus • Mouse to navigate • Keyboard to type • Scroll to zoom
); }