Spaces:
Paused
Paused
| 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<HTMLCanvasElement>(null); | |
| const offscreenCanvasRef = useRef<OffscreenCanvas | null>(null); | |
| const workerRef = useRef<Worker | null>(null); | |
| const lastFrameIdRef = useRef(0); | |
| const lastFrameTimeRef = useRef(0); | |
| const frameCountRef = useRef(0); | |
| const lastPingTimeRef = useRef(Date.now()); | |
| const pollControllerRef = useRef<AbortController | null>(null); | |
| const inputQueueRef = useRef<Array<{ type: string; data: Record<string, unknown> }>>([]); | |
| const [state, setState] = useState<VncViewerState>({ | |
| 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<string, unknown>) => { | |
| 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<HTMLCanvasElement>) => { | |
| 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<HTMLCanvasElement>) => { | |
| const buttonMap: Record<number, number> = { | |
| 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<HTMLCanvasElement>) => { | |
| const buttonMap: Record<number, number> = { | |
| 0: 1, | |
| 1: 2, | |
| 2: 3, | |
| }; | |
| const button = buttonMap[event.button] || 1; | |
| sendInput('mouseup', { button }); | |
| }, [sendInput]); | |
| const handleWheel = useCallback((event: React.WheelEvent<HTMLCanvasElement>) => { | |
| sendInput('wheel', { | |
| deltaX: event.deltaX, | |
| deltaY: event.deltaY, | |
| }); | |
| }, [sendInput]); | |
| /** | |
| * Process keyboard event | |
| */ | |
| const handleKeyDown = useCallback((event: React.KeyboardEvent<HTMLCanvasElement>) => { | |
| // 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<HTMLCanvasElement>) => { | |
| 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 ( | |
| <div className="vnc-viewer"> | |
| <style jsx>{` | |
| .vnc-viewer { | |
| position: relative; | |
| width: 100%; | |
| height: 100vh; | |
| background: #0a0a0f; | |
| overflow: hidden; | |
| } | |
| .canvas-container { | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| width: 100%; | |
| height: 100%; | |
| } | |
| .vnc-canvas { | |
| max-width: 100%; | |
| max-height: 100%; | |
| object-fit: contain; | |
| cursor: crosshair; | |
| } | |
| .status-overlay { | |
| position: absolute; | |
| top: 16px; | |
| right: 16px; | |
| background: rgba(0, 0, 0, 0.75); | |
| padding: 12px 16px; | |
| border-radius: 8px; | |
| font-family: 'SF Mono', 'Consolas', monospace; | |
| font-size: 12px; | |
| color: #10b981; | |
| backdrop-filter: blur(8px); | |
| border: 1px solid rgba(16, 185, 129, 0.2); | |
| } | |
| .status-row { | |
| display: flex; | |
| justify-content: space-between; | |
| gap: 24px; | |
| margin-bottom: 4px; | |
| } | |
| .status-row:last-child { | |
| margin-bottom: 0; | |
| } | |
| .status-label { | |
| color: #6b7280; | |
| } | |
| .status-value { | |
| color: #10b981; | |
| font-weight: 600; | |
| } | |
| .status-value.error { | |
| color: #ef4444; | |
| } | |
| .status-value.connecting { | |
| color: #f59e0b; | |
| animation: pulse 1s infinite; | |
| } | |
| @keyframes pulse { | |
| 0%, 100% { opacity: 1; } | |
| 50% { opacity: 0.5; } | |
| } | |
| .connection-indicator { | |
| position: absolute; | |
| top: 16px; | |
| left: 16px; | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| background: rgba(0, 0, 0, 0.75); | |
| padding: 8px 12px; | |
| border-radius: 6px; | |
| backdrop-filter: blur(8px); | |
| } | |
| .indicator-dot { | |
| width: 8px; | |
| height: 8px; | |
| border-radius: 50%; | |
| background: #6b7280; | |
| } | |
| .indicator-dot.connected { | |
| background: #10b981; | |
| box-shadow: 0 0 8px #10b981; | |
| } | |
| .indicator-dot.connecting { | |
| background: #f59e0b; | |
| animation: pulse 1s infinite; | |
| } | |
| .indicator-dot.error { | |
| background: #ef4444; | |
| } | |
| .indicator-text { | |
| font-family: 'SF Mono', 'Consolas', monospace; | |
| font-size: 12px; | |
| color: #9ca3af; | |
| } | |
| .help-text { | |
| position: absolute; | |
| bottom: 16px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| background: rgba(0, 0, 0, 0.75); | |
| padding: 8px 16px; | |
| border-radius: 6px; | |
| font-family: 'SF Mono', 'Consolas', monospace; | |
| font-size: 11px; | |
| color: #6b7280; | |
| backdrop-filter: blur(8px); | |
| } | |
| `}</style> | |
| <div className="canvas-container"> | |
| <canvas | |
| ref={canvasRef} | |
| className="vnc-canvas" | |
| width={config.width} | |
| height={config.height} | |
| onMouseMove={handleMouseMove} | |
| onMouseDown={handleMouseDown} | |
| onMouseUp={handleMouseUp} | |
| onMouseLeave={handleMouseUp} | |
| onWheel={handleWheel} | |
| onKeyDown={handleKeyDown} | |
| onKeyUp={handleKeyUp} | |
| tabIndex={0} | |
| /> | |
| </div> | |
| <div className="connection-indicator"> | |
| <div className={`indicator-dot ${ | |
| state.isConnected ? 'connected' : state.isConnecting ? 'connecting' : 'error' | |
| }`} /> | |
| <span className="indicator-text"> | |
| {state.isConnected ? 'Connected' : state.isConnecting ? 'Connecting...' : 'Disconnected'} | |
| </span> | |
| </div> | |
| <div className="status-overlay"> | |
| <div className="status-row"> | |
| <span className="status-label">FPS</span> | |
| <span className="status-value">{state.fps}</span> | |
| </div> | |
| <div className="status-row"> | |
| <span className="status-label">Latency</span> | |
| <span className="status-value">{state.latency}ms</span> | |
| </div> | |
| <div className="status-row"> | |
| <span className="status-label">Resolution</span> | |
| <span className="status-value">{config.width}×{config.height}</span> | |
| </div> | |
| {state.error && ( | |
| <div className="status-row"> | |
| <span className="status-label">Status</span> | |
| <span className="status-value error">{state.error}</span> | |
| </div> | |
| )} | |
| </div> | |
| <div className="help-text"> | |
| Click to focus • Mouse to navigate • Keyboard to type • Scroll to zoom | |
| </div> | |
| </div> | |
| ); | |
| } | |