From 07da63a68e06af1e737322b0303668daf902741c Mon Sep 17 00:00:00 2001 From: deadRabbit Date: Sun, 22 Feb 2026 13:20:51 +0100 Subject: [PATCH] Fix layout bug, tabs height, and RDP tunnel UUID handshake - MainLayout: replace inner with row-flex div so sidebar and session tabs appear side-by-side instead of stacked vertically - global.css: add Ant Design Tabs CSS overrides so tab pane content fills available height (SSH terminal and RDP canvas sized correctly) - rdp.ts: send guacd's ready-UUID as first WebSocket message so Guacamole.WebSocketTunnel completes its tunnel handshake correctly - RdpTab: add connecting/error/disconnected status overlays for visibility when RDP fails Co-Authored-By: Claude Sonnet 4.6 --- backend/src/websocket/rdp.ts | 8 +- frontend/src/components/Layout/MainLayout.tsx | 4 +- frontend/src/components/Tabs/RdpTab.tsx | 82 +++++++++++++++++-- frontend/src/global.css | 19 +++++ frontend/src/main.tsx | 1 + 5 files changed, 103 insertions(+), 11 deletions(-) create mode 100644 frontend/src/global.css diff --git a/backend/src/websocket/rdp.ts b/backend/src/websocket/rdp.ts index 0c63449..43ee8ad 100644 --- a/backend/src/websocket/rdp.ts +++ b/backend/src/websocket/rdp.ts @@ -1,4 +1,5 @@ import { createConnection, Socket } from 'net'; +import { randomUUID } from 'crypto'; import { FastifyInstance, FastifyRequest } from 'fastify'; import { SocketStream } from '@fastify/websocket'; import { WebSocket } from 'ws'; @@ -165,7 +166,12 @@ export async function rdpWebsocket(fastify: FastifyInstance) { throw new Error(`guacd handshake failed: expected 'ready', got '${readyInstruction[0]}'`); } - // 5. Flush any buffered bytes that arrived after 'ready' + // 5. Send the guacd connection UUID as the first WebSocket message. + // Guacamole.WebSocketTunnel expects this as its tunnel-UUID handshake. + const guacdUUID = readyInstruction[1] ?? randomUUID(); + socket.send(guacdUUID); + + // 6. Flush any buffered bytes that arrived after 'ready' if (tcpBuf.value.length > 0 && socket.readyState === WebSocket.OPEN) { socket.send(tcpBuf.value); tcpBuf.value = ''; diff --git a/frontend/src/components/Layout/MainLayout.tsx b/frontend/src/components/Layout/MainLayout.tsx index 7e392a8..3ee8460 100644 --- a/frontend/src/components/Layout/MainLayout.tsx +++ b/frontend/src/components/Layout/MainLayout.tsx @@ -42,7 +42,7 @@ export const MainLayout: React.FC = () => { - +
{/* Resizable sidebar */}
{ > - +
); }; diff --git a/frontend/src/components/Tabs/RdpTab.tsx b/frontend/src/components/Tabs/RdpTab.tsx index c320e61..07819c2 100644 --- a/frontend/src/components/Tabs/RdpTab.tsx +++ b/frontend/src/components/Tabs/RdpTab.tsx @@ -1,4 +1,5 @@ -import React, { useEffect, useRef } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import { Alert, Spin } from 'antd'; import Guacamole from 'guacamole-common-js'; import { useStore } from '../../store'; import { Session } from '../../types'; @@ -7,6 +8,8 @@ interface Props { session: Session; } +type Status = 'connecting' | 'connected' | 'disconnected' | 'error'; + function getWsUrl(connectionId: string, token: string): string { const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const host = window.location.host; @@ -16,11 +19,16 @@ function getWsUrl(connectionId: string, token: string): string { export const RdpTab: React.FC = ({ session }) => { const containerRef = useRef(null); const token = useStore((s) => s.token) ?? ''; + const [status, setStatus] = useState('connecting'); + const [errorMsg, setErrorMsg] = useState(''); useEffect(() => { const container = containerRef.current; if (!container) return; + setStatus('connecting'); + setErrorMsg(''); + const url = getWsUrl(session.connection.id, token); const tunnel = new Guacamole.WebSocketTunnel(url); const client = new Guacamole.Client(tunnel); @@ -30,7 +38,7 @@ export const RdpTab: React.FC = ({ session }) => { displayEl.style.cursor = 'default'; container.appendChild(displayEl); - // Mouse input — forward all mouse events to guacd + // Mouse input const mouse = new Guacamole.Mouse(displayEl); const sendMouse = (mouseState: Guacamole.Mouse.State) => client.sendMouseState(mouseState, true); @@ -52,21 +60,29 @@ export const RdpTab: React.FC = ({ session }) => { display.scale(Math.min(scaleX, scaleY)); }; - client.getDisplay().onresize = fitDisplay; + client.getDisplay().onresize = () => { + setStatus('connected'); + fitDisplay(); + }; + const resizeObserver = new ResizeObserver(fitDisplay); resizeObserver.observe(container); - // Connect - client.connect(); - tunnel.onerror = (status: Guacamole.Status) => { console.error('Guacamole tunnel error:', status.message); + setStatus('error'); + setErrorMsg(`Tunnel error: ${status.message ?? 'unknown'}`); }; client.onerror = (error: Guacamole.Status) => { console.error('Guacamole client error:', error.message); + setStatus('error'); + setErrorMsg(`Client error: ${error.message ?? 'unknown'}`); }; + // Connect + client.connect(); + return () => { keyboard.onkeydown = null; keyboard.onkeyup = null; @@ -80,16 +96,66 @@ export const RdpTab: React.FC = ({ session }) => { return (
+ > + {/* Guacamole canvas is appended here by the effect */} +
+ + {/* Status overlays */} + {status === 'connecting' && ( +
+ +
+ )} + + {status === 'error' && ( +
+ +
+ )} + + {status === 'disconnected' && ( +
+ +
+ )} +
); }; diff --git a/frontend/src/global.css b/frontend/src/global.css new file mode 100644 index 0000000..57176a2 --- /dev/null +++ b/frontend/src/global.css @@ -0,0 +1,19 @@ +/* Reset */ +* { box-sizing: border-box; } +body { margin: 0; padding: 0; } + +/* Ant Design Tabs: ensure tab content fills available height */ +.ant-tabs.ant-tabs-top { + display: flex; + flex-direction: column; +} +.ant-tabs-content-holder { + flex: 1; + overflow: hidden; +} +.ant-tabs-content { + height: 100%; +} +.ant-tabs-tabpane { + height: 100%; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 9707d82..1dc0601 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,5 +1,6 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; +import './global.css'; import App from './App'; ReactDOM.createRoot(document.getElementById('root')!).render(