379 lines
12 KiB
TypeScript
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",
|
|
};
|
|
}
|