Files
litoral-central-frontend/src/features/console/hooks/useVncConsole.ts
T

379 lines
12 KiB
TypeScript

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<HTMLCanvasElement | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const rgbaRef = useRef<Uint8ClampedArray | null>(null);
const framebufferRef = useRef({ width: 0, height: 0 });
const [state, setState] = useState<VncConnectionState>("IDLE");
const [error, setError] = useState<string | null>(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<string | null>(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<ConnectVncInput>) => {
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",
};
}