import { useCallback, useEffect, useRef, useState } from "react"; import { appendAccessToken, getVncWebSocketUrl } from "../../../lib/api/gatewayConfig"; export type VncConnectionState = | "IDLE" | "CONNECTING_WS" | "CONNECTING_VNC" | "CONNECTED" | "FIRST_FRAME" | "DISCONNECTED" | "ERROR"; export type UseVncConsoleOptions = { websocketUrl?: string; accessToken?: string | null; defaultHost?: string; defaultPort?: number; }; export type ConnectVncInput = { host: string; port: number; password: string; }; const DEFAULT_PORT = 5900; export function useVncConsole(options: UseVncConsoleOptions = {}) { const websocketUrl = options.websocketUrl ?? getVncWebSocketUrl(); const accessToken = options.accessToken ?? null; const canvasRef = useRef(null); const wsRef = useRef(null); const ctxRef = useRef(null); const rgbaRef = useRef(null); const framebufferRef = useRef({ width: 0, height: 0 }); const [state, setState] = useState("IDLE"); const [error, setError] = useState(null); const [host, setHost] = useState(options.defaultHost ?? ""); const [port, setPort] = useState(options.defaultPort ?? DEFAULT_PORT); const [password, setPassword] = useState(""); const [frameSize, setFrameSize] = useState({ width: 0, height: 0 }); const [lastFrameAt, setLastFrameAt] = useState(null); const buildWebSocketUrl = useCallback(() => { if (!accessToken) { return websocketUrl; } return appendAccessToken(websocketUrl, accessToken); }, [websocketUrl, accessToken]); const clearFrame = useCallback(() => { const canvas = canvasRef.current; const ctx = ctxRef.current; if (canvas && ctx) { ctx.clearRect(0, 0, canvas.width, canvas.height); canvas.width = 0; canvas.height = 0; } ctxRef.current = null; rgbaRef.current = null; framebufferRef.current = { width: 0, height: 0 }; setFrameSize({ width: 0, height: 0 }); setLastFrameAt(null); }, []); const closeSocket = useCallback(() => { const ws = wsRef.current; wsRef.current = null; if (!ws) return; try { ws.onopen = null; ws.onmessage = null; ws.onerror = null; ws.onclose = null; if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "disconnect" })); ws.close(); return; } if (ws.readyState === WebSocket.CONNECTING) { ws.close(); } } catch { // Ignore cleanup errors. } }, []); const drawFrame = useCallback((buffer: ArrayBuffer) => { const canvas = canvasRef.current; if (!canvas) return; if (buffer.byteLength <= 8) return; const view = new DataView(buffer); const width = view.getInt32(0); const height = view.getInt32(4); console.log("[VNC] drawFrame", { byteLength: buffer.byteLength, width, height, }); if (!width || !height || width <= 0 || height <= 0) return; const pixels = new Uint8ClampedArray(buffer, 8); framebufferRef.current = { width, height }; if (!ctxRef.current) { ctxRef.current = canvas.getContext("2d", { alpha: false, desynchronized: true, }); } const ctx = ctxRef.current; if (!ctx) return; if (canvas.width !== width || canvas.height !== height) { canvas.width = width; canvas.height = height; rgbaRef.current = new Uint8ClampedArray(width * height * 4); setFrameSize({ width, height }); } if (!rgbaRef.current || rgbaRef.current.length !== width * height * 4) { rgbaRef.current = new Uint8ClampedArray(width * height * 4); } const rgba = rgbaRef.current; for (let index = 0; index < pixels.length; index += 4) { rgba[index] = pixels[index + 2]; rgba[index + 1] = pixels[index + 1]; rgba[index + 2] = pixels[index]; rgba[index + 3] = 255; } const imageData = ctx.createImageData(width, height); imageData.data.set(rgba); ctx.putImageData(imageData, 0, 0); setLastFrameAt(new Date().toLocaleTimeString()); }, []); const connect = useCallback( async (input?: Partial) => { const nextHost = input?.host ?? host; const nextPort = input?.port ?? port; const nextPassword = input?.password ?? password; if (!accessToken) { setError("Token de autenticação em falta."); setState("ERROR"); return; } if (!nextHost.trim()) { setError("Host VNC em falta."); setState("ERROR"); return; } closeSocket(); clearFrame(); setError(null); setState("CONNECTING_WS"); window.setTimeout(() => { const socket = new WebSocket(buildWebSocketUrl()); wsRef.current = socket; socket.binaryType = "arraybuffer"; socket.onopen = () => { if (wsRef.current !== socket) return; setState("CONNECTING_VNC"); socket.send( JSON.stringify({ type: "connect", host: nextHost, port: nextPort, password: nextPassword, }), ); }; socket.onmessage = (event) => { if (wsRef.current !== socket) return; if (typeof event.data === "string") { let message: { type?: string; state?: string; message?: string; }; try { message = JSON.parse(event.data); } catch { return; } if (message.type === "state") { if (message.state === "CONNECTING") { setState("CONNECTING_VNC"); return; } if (message.state === "CONNECTED") { setState("CONNECTED"); return; } if (message.state === "FIRST_FRAME") { setState("FIRST_FRAME"); return; } if (message.state === "DISCONNECTED") { clearFrame(); setState("DISCONNECTED"); return; } } if (message.type === "error") { clearFrame(); setError(message.message ?? "Erro VNC desconhecido."); setState("ERROR"); } return; } if (event.data instanceof ArrayBuffer) { console.log("[VNC] binary ArrayBuffer", event.data.byteLength); drawFrame(event.data); return; } if (event.data instanceof Blob) { console.log("[VNC] binary Blob", event.data.size); event.data.arrayBuffer().then(drawFrame); return; } console.warn("[VNC] unknown binary payload", event.data); }; socket.onerror = () => { if (wsRef.current !== socket) return; clearFrame(); setError("Erro na ligação WebSocket da consola VNC."); setState("ERROR"); }; socket.onclose = () => { if (wsRef.current !== socket) return; wsRef.current = null; clearFrame(); setState((current) => current === "ERROR" ? "ERROR" : "DISCONNECTED", ); }; }, 100); }, [ accessToken, buildWebSocketUrl, clearFrame, closeSocket, drawFrame, host, password, port, ], ); const disconnect = useCallback(() => { closeSocket(); clearFrame(); setError(null); setState("DISCONNECTED"); }, [clearFrame, closeSocket]); const sendClick = useCallback((clientX: number, clientY: number) => { const canvas = canvasRef.current; const ws = wsRef.current; const framebuffer = framebufferRef.current; if (!canvas || !ws || ws.readyState !== WebSocket.OPEN) return; if (!framebuffer.width || !framebuffer.height) return; const rect = canvas.getBoundingClientRect(); const containerAspect = rect.width / rect.height; const frameAspect = framebuffer.width / framebuffer.height; let renderedWidth: number; let renderedHeight: number; let offsetX: number; let offsetY: number; if (containerAspect > frameAspect) { renderedHeight = rect.height; renderedWidth = rect.height * frameAspect; offsetX = rect.left + (rect.width - renderedWidth) / 2; offsetY = rect.top; } else { renderedWidth = rect.width; renderedHeight = rect.width / frameAspect; offsetX = rect.left; offsetY = rect.top + (rect.height - renderedHeight) / 2; } const relativeX = (clientX - offsetX) / renderedWidth; const relativeY = (clientY - offsetY) / renderedHeight; const clampedX = Math.max(0, Math.min(1, relativeX)); const clampedY = Math.max(0, Math.min(1, relativeY)); const x = Math.round(clampedX * (framebuffer.width - 1)); const y = Math.round(clampedY * (framebuffer.height - 1)); ws.send(JSON.stringify({ type: "click", x, y })); }, []); const handleCanvasPointerDown = useCallback( (event: React.PointerEvent) => { event.preventDefault(); sendClick(event.clientX, event.clientY); }, [sendClick], ); useEffect(() => { return () => { closeSocket(); clearFrame(); }; }, [clearFrame, closeSocket]); return { canvasRef, state, error, host, setHost, port, setPort, password, setPassword, frameSize, lastFrameAt, connect, disconnect, handleCanvasPointerDown, connected: state === "CONNECTED" || state === "FIRST_FRAME", connecting: state === "CONNECTING_WS" || state === "CONNECTING_VNC", }; }