Adds VNC Console page v1.0

This commit is contained in:
litoral05
2026-06-02 08:51:39 +01:00
parent f197898fbb
commit ae15b8d3f6
3 changed files with 595 additions and 167 deletions
+302
View File
@@ -0,0 +1,302 @@
import { useCallback, useEffect, useRef, useState } from "react";
export type VncConnectionState =
| "IDLE"
| "CONNECTING_WS"
| "CONNECTING_VNC"
| "CONNECTED"
| "FIRST_FRAME"
| "DISCONNECTED"
| "ERROR";
export type UseVncConsoleOptions = {
websocketUrl?: string;
defaultHost?: string;
defaultPort?: number;
};
export type ConnectVncInput = {
host: string;
port: number;
password: string;
};
const DEFAULT_WEBSOCKET_URL = "ws://localhost:18450/ws/vnc";
const DEFAULT_HOST = "198.19.0.176";
const DEFAULT_PORT = 5900;
export function useVncConsole(options: UseVncConsoleOptions = {}) {
const websocketUrl = options.websocketUrl ?? DEFAULT_WEBSOCKET_URL;
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 ?? DEFAULT_HOST);
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 closeSocket = useCallback(() => {
const ws = wsRef.current;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "disconnect" }));
ws.close();
} else if (ws) {
ws.close();
}
wsRef.current = null;
}, []);
const clearFrame = useCallback(() => {
const canvas = canvasRef.current;
const ctx = ctxRef.current;
if (canvas && ctx) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
rgbaRef.current = null;
framebufferRef.current = {
width: 0,
height: 0,
};
setFrameSize({ width: 0, height: 0 });
setLastFrameAt(null);
}, []);
const drawFrame = useCallback((buffer: ArrayBuffer) => {
const canvas = canvasRef.current;
if (!canvas) {
return;
}
const view = new DataView(buffer);
const width = view.getInt32(0);
const height = view.getInt32(4);
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 (!nextHost.trim()) {
setError("Host VNC em falta.");
setState("ERROR");
return;
}
setError(null);
setState("CONNECTING_WS");
closeSocket();
const socket = new WebSocket(websocketUrl);
wsRef.current = socket;
socket.binaryType = "arraybuffer";
socket.onopen = () => {
setState("CONNECTING_VNC");
socket.send(
JSON.stringify({
type: "connect",
host: nextHost,
port: nextPort,
password: nextPassword,
}),
);
};
socket.onmessage = (event) => {
if (typeof event.data === "string") {
const message = JSON.parse(event.data) as {
type?: string;
state?: string;
message?: string;
};
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") {
setState("DISCONNECTED");
return;
}
}
if (message.type === "error") {
setError(message.message ?? "Erro VNC desconhecido.");
setState("ERROR");
}
return;
}
drawFrame(event.data);
};
socket.onerror = () => {
setError("Erro na ligação WebSocket da consola VNC.");
setState("ERROR");
};
socket.onclose = () => {
wsRef.current = null;
clearFrame();
setState((current) =>
current === "ERROR" ? "ERROR" : "DISCONNECTED",
);
};
},
[closeSocket, drawFrame, host, password, port, websocketUrl],
);
const disconnect = useCallback(() => {
closeSocket();
clearFrame();
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 relativeX = (clientX - rect.left) / rect.width;
const relativeY = (clientY - rect.top) / rect.height;
const x = Math.max(
0,
Math.min(framebuffer.width - 1, Math.round(relativeX * framebuffer.width)),
);
const y = Math.max(
0,
Math.min(framebuffer.height - 1, Math.round(relativeY * framebuffer.height)),
);
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();
};
}, [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",
};
}