diff --git a/src/features/console/hooks/useVncConsole.ts b/src/features/console/hooks/useVncConsole.ts index 7203b11..de22e1e 100644 --- a/src/features/console/hooks/useVncConsole.ts +++ b/src/features/console/hooks/useVncConsole.ts @@ -34,6 +34,9 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) { const ctxRef = useRef(null); const rgbaRef = useRef(null); + const tmpCanvasRef = useRef(null); + const tmpCtxRef = useRef(null); + const framebufferRef = useRef({ width: 0, height: 0 }); const [state, setState] = useState("IDLE"); @@ -45,12 +48,49 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) { const [lastFrameAt, setLastFrameAt] = useState(null); const buildWebSocketUrl = useCallback(() => { - if (!accessToken) { - return websocketUrl; + return accessToken ? appendAccessToken(websocketUrl, accessToken) : websocketUrl; + }, [websocketUrl, accessToken]); + + const renderScaledFrame = useCallback(() => { + const canvas = canvasRef.current; + const tmpCanvas = tmpCanvasRef.current; + + if (!canvas || !tmpCanvas) return; + + const rect = canvas.getBoundingClientRect(); + const displayWidth = Math.max(1, Math.round(rect.width)); + const displayHeight = Math.max(1, Math.round(rect.height)); + + if (canvas.width !== displayWidth || canvas.height !== displayHeight) { + canvas.width = displayWidth; + canvas.height = displayHeight; } - return appendAccessToken(websocketUrl, accessToken); - }, [websocketUrl, accessToken]); + if (!ctxRef.current) { + ctxRef.current = canvas.getContext("2d", { + alpha: false, + desynchronized: true, + }); + } + + const ctx = ctxRef.current; + if (!ctx) return; + + ctx.imageSmoothingEnabled = false; + ctx.clearRect(0, 0, canvas.width, canvas.height); + + ctx.drawImage( + tmpCanvas, + 0, + 0, + tmpCanvas.width, + tmpCanvas.height, + 0, + 0, + canvas.width, + canvas.height, + ); + }, []); const clearFrame = useCallback(() => { const canvas = canvasRef.current; @@ -64,6 +104,8 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) { ctxRef.current = null; rgbaRef.current = null; + tmpCanvasRef.current = null; + tmpCtxRef.current = null; framebufferRef.current = { width: 0, height: 0 }; setFrameSize({ width: 0, height: 0 }); @@ -96,63 +138,66 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) { } }, []); - const drawFrame = useCallback((buffer: ArrayBuffer) => { - const canvas = canvasRef.current; - if (!canvas) return; + const drawFrame = useCallback( + (buffer: ArrayBuffer) => { + if (buffer.byteLength <= 8) return; - if (buffer.byteLength <= 8) return; + const view = new DataView(buffer); + const width = view.getInt32(0); + const height = view.getInt32(4); - const view = new DataView(buffer); - const width = view.getInt32(0); - const height = view.getInt32(4); + if (!width || !height || width <= 0 || height <= 0) return; - console.log("[VNC] drawFrame", { - byteLength: buffer.byteLength, - width, - height, - }); + const pixels = new Uint8ClampedArray(buffer, 8); - if (!width || !height || width <= 0 || height <= 0) return; + if ( + framebufferRef.current.width !== width || + framebufferRef.current.height !== height + ) { + framebufferRef.current = { width, height }; + setFrameSize({ width, height }); + } - const pixels = new Uint8ClampedArray(buffer, 8); - framebufferRef.current = { width, height }; + if (!rgbaRef.current || rgbaRef.current.length !== width * height * 4) { + rgbaRef.current = new Uint8ClampedArray(width * height * 4); + } - if (!ctxRef.current) { - ctxRef.current = canvas.getContext("2d", { - alpha: false, - desynchronized: true, - }); - } + const rgba = rgbaRef.current; - const ctx = ctxRef.current; - if (!ctx) return; + 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; + } - if (canvas.width !== width || canvas.height !== height) { - canvas.width = width; - canvas.height = height; - rgbaRef.current = new Uint8ClampedArray(width * height * 4); - setFrameSize({ width, height }); - } + if (!tmpCanvasRef.current) { + tmpCanvasRef.current = document.createElement("canvas"); + } - if (!rgbaRef.current || rgbaRef.current.length !== width * height * 4) { - rgbaRef.current = new Uint8ClampedArray(width * height * 4); - } + const tmpCanvas = tmpCanvasRef.current; - const rgba = rgbaRef.current; + if (tmpCanvas.width !== width || tmpCanvas.height !== height) { + tmpCanvas.width = width; + tmpCanvas.height = height; + tmpCtxRef.current = tmpCanvas.getContext("2d", { + alpha: false, + desynchronized: true, + }); + } - 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 tmpCtx = tmpCtxRef.current; + if (!tmpCtx) return; - const imageData = ctx.createImageData(width, height); - imageData.data.set(rgba); + const imageData = tmpCtx.createImageData(width, height); + imageData.data.set(rgba); + tmpCtx.putImageData(imageData, 0, 0); - ctx.putImageData(imageData, 0, 0); - setLastFrameAt(new Date().toLocaleTimeString()); - }, []); + renderScaledFrame(); + setLastFrameAt(new Date().toLocaleTimeString()); + }, + [renderScaledFrame], + ); const connect = useCallback( async (input?: Partial) => { @@ -246,19 +291,15 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) { 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 = () => { @@ -310,28 +351,8 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) { 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 relativeX = (clientX - rect.left) / rect.width; + const relativeY = (clientY - rect.top) / rect.height; const clampedX = Math.max(0, Math.min(1, relativeX)); const clampedY = Math.max(0, Math.min(1, relativeY)); @@ -350,6 +371,21 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) { [sendClick], ); + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const observer = new ResizeObserver(() => { + renderScaledFrame(); + }); + + observer.observe(canvas); + + return () => { + observer.disconnect(); + }; + }, [renderScaledFrame]); + useEffect(() => { return () => { closeSocket(); @@ -375,4 +411,4 @@ export function useVncConsole(options: UseVncConsoleOptions = {}) { connected: state === "CONNECTED" || state === "FIRST_FRAME", connecting: state === "CONNECTING_WS" || state === "CONNECTING_VNC", }; -} +} \ No newline at end of file diff --git a/src/features/console/pages/ConsolePage.tsx b/src/features/console/pages/ConsolePage.tsx index a6fa80f..c9e93ce 100644 --- a/src/features/console/pages/ConsolePage.tsx +++ b/src/features/console/pages/ConsolePage.tsx @@ -30,19 +30,17 @@ export function ConsolePage({ theme }: ConsolePageProps) { defaultHost: runtimeConfig.vnc.defaultHost, defaultPort: runtimeConfig.vnc.defaultPort, }); + const [passwordVisible, setPasswordVisible] = useState(false); const connectionLabel = getConnectionLabel(vnc.state); const hasFrame = vnc.frameSize.width > 0 && vnc.frameSize.height > 0; - const frameAspectRatio = hasFrame - ? `${vnc.frameSize.width} / ${vnc.frameSize.height}` - : "800 / 480"; return (
-
+
-
-
+
+

Ecrã do controlador

-

+

{vnc.lastFrameAt ? `Último frame: ${vnc.lastFrameAt}` : "A aguardar imagem da consola"}

@@ -147,12 +143,12 @@ export function ConsolePage({ theme }: ConsolePageProps) {
{!hasFrame && ( -
-
- +
+
+
-

+

{vnc.state === "DISCONNECTED" ? "Sessão terminada" : "Consola indisponível"}

@@ -174,12 +170,7 @@ export function ConsolePage({ theme }: ConsolePageProps) { >

@@ -191,14 +182,14 @@ export function ConsolePage({ theme }: ConsolePageProps) { function panelClass(isDark: boolean) { return isDark - ? `${RADIUS} flex h-full min-h-0 flex-col border border-white/10 bg-[#071421] p-3 shadow-[0_14px_34px_rgba(0,0,0,0.22)]` - : `${RADIUS} flex h-full min-h-0 flex-col border border-slate-200 bg-white p-3 shadow-[0_10px_26px_rgba(15,23,42,0.06)]`; + ? `${RADIUS} flex h-full min-h-0 flex-col border border-white/10 bg-[#071421] p-2 shadow-[0_14px_34px_rgba(0,0,0,0.22)]` + : `${RADIUS} flex h-full min-h-0 flex-col border border-slate-200 bg-white p-2 shadow-[0_10px_26px_rgba(15,23,42,0.06)]`; } function panelTitleClass(isDark: boolean) { return isDark - ? "text-base font-black text-slate-100" - : "text-base font-black text-slate-950"; + ? "text-sm font-black text-slate-100" + : "text-sm font-black text-slate-950"; } function getConnectionLabel(state: VncConnectionState) { @@ -245,14 +236,14 @@ function Field({ {label} -
+
onChange(event.target.value)} className={ isDark - ? `${RADIUS} h-10 w-full border border-[#263247] bg-[#07101B] px-3 ${rightElement ? "pr-11" : ""} text-sm font-bold text-white outline-none transition placeholder:text-[#526074] focus:border-[#4FD1C5] disabled:cursor-not-allowed disabled:opacity-60` - : `${RADIUS} h-10 w-full border border-slate-200 bg-white px-3 ${rightElement ? "pr-11" : ""} text-sm font-bold text-[#0F172A] outline-none transition placeholder:text-slate-400 focus:border-[#0F766E] disabled:cursor-not-allowed disabled:opacity-60` + ? `${RADIUS} h-9 w-full border border-[#263247] bg-[#07101B] px-3 ${rightElement ? "pr-11" : ""} text-sm font-bold text-white outline-none transition placeholder:text-[#526074] focus:border-[#4FD1C5] disabled:cursor-not-allowed disabled:opacity-60` + : `${RADIUS} h-9 w-full border border-slate-200 bg-white px-3 ${rightElement ? "pr-11" : ""} text-sm font-bold text-[#0F172A] outline-none transition placeholder:text-slate-400 focus:border-[#0F766E] disabled:cursor-not-allowed disabled:opacity-60` } /> @@ -308,13 +299,13 @@ function StatusCard({
-
+
{icon}
@@ -323,8 +314,8 @@ function StatusCard({

{title} @@ -333,8 +324,8 @@ function StatusCard({

{value} @@ -394,4 +385,4 @@ function SmallInfo({ ); } -export default ConsolePage; +export default ConsolePage; \ No newline at end of file