feat: enhance Guacamole support with RDP and VNC connection settings and UI updates

This commit is contained in:
starhound
2025-12-17 19:14:19 -05:00
parent 42e27e7389
commit 2f092bd367
7 changed files with 145 additions and 63 deletions

View File

@@ -36,6 +36,13 @@ const clientOptions = {
guacLogger.error(args.join(" "), { operation: "guac_error" }); guacLogger.error(args.join(" "), { operation: "guac_error" });
}, },
}, },
// Allow width, height, and dpi to be passed as query parameters
// This allows the client to request the appropriate resolution at connection time
allowedUnencryptedConnectionSettings: {
rdp: ["width", "height", "dpi"],
vnc: ["width", "height", "dpi"],
telnet: ["width", "height"],
},
connectionDefaultSettings: { connectionDefaultSettings: {
rdp: { rdp: {
security: "any", security: "any",
@@ -46,10 +53,15 @@ const clientOptions = {
"disable-audio": false, "disable-audio": false,
"enable-drive": false, "enable-drive": false,
"resize-method": "display-update", "resize-method": "display-update",
width: 1280,
height: 720,
dpi: 96,
}, },
vnc: { vnc: {
"swap-red-blue": false, "swap-red-blue": false,
"cursor": "remote", "cursor": "remote",
width: 1280,
height: 720,
}, },
telnet: { telnet: {
"terminal-type": "xterm-256color", "terminal-type": "xterm-256color",

View File

@@ -345,11 +345,26 @@ export interface TabContextTab {
| "server" | "server"
| "admin" | "admin"
| "file_manager" | "file_manager"
| "user_profile"; | "user_profile"
| "rdp"
| "vnc";
title: string; title: string;
hostConfig?: SSHHost; hostConfig?: SSHHost;
terminalRef?: any; terminalRef?: any;
initialTab?: string; initialTab?: string;
connectionConfig?: {
type: "rdp" | "vnc" | "telnet";
hostname: string;
port: number;
username?: string;
password?: string;
domain?: string;
security?: string;
"ignore-cert"?: boolean;
width?: number;
height?: number;
dpi?: number;
};
} }
export type SplitLayout = "2h" | "2v" | "3l" | "3r" | "3t" | "4grid"; export type SplitLayout = "2h" | "2v" | "3l" | "3r" | "3t" | "4grid";

View File

@@ -155,7 +155,9 @@ function AppContent() {
const showTerminalView = const showTerminalView =
currentTabData?.type === "terminal" || currentTabData?.type === "terminal" ||
currentTabData?.type === "server" || currentTabData?.type === "server" ||
currentTabData?.type === "file_manager"; currentTabData?.type === "file_manager" ||
currentTabData?.type === "rdp" ||
currentTabData?.type === "vnc";
const showHome = currentTabData?.type === "home"; const showHome = currentTabData?.type === "home";
const showSshManager = currentTabData?.type === "ssh_manager"; const showSshManager = currentTabData?.type === "ssh_manager";
const showAdmin = currentTabData?.type === "admin"; const showAdmin = currentTabData?.type === "admin";

View File

@@ -52,7 +52,8 @@ export const GuacamoleDisplay = forwardRef<GuacamoleDisplayHandle, GuacamoleDisp
ref ref
) { ) {
const { t } = useTranslation(); const { t } = useTranslation();
const displayRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null); // Outer container for measuring size
const displayRef = useRef<HTMLDivElement>(null); // Inner div for guacamole canvas
const clientRef = useRef<Guacamole.Client | null>(null); const clientRef = useRef<Guacamole.Client | null>(null);
const [isConnected, setIsConnected] = useState(false); const [isConnected, setIsConnected] = useState(false);
const [isConnecting, setIsConnecting] = useState(false); const [isConnecting, setIsConnecting] = useState(false);
@@ -86,7 +87,7 @@ export const GuacamoleDisplay = forwardRef<GuacamoleDisplayHandle, GuacamoleDisp
}, },
})); }));
const getWebSocketUrl = useCallback(async (): Promise<string | null> => { const getWebSocketUrl = useCallback(async (containerWidth: number, containerHeight: number): Promise<string | null> => {
const jwtToken = getCookie("jwt"); const jwtToken = getCookie("jwt");
if (!jwtToken) { if (!jwtToken) {
setConnectionError("Authentication required"); setConnectionError("Authentication required");
@@ -118,7 +119,13 @@ export const GuacamoleDisplay = forwardRef<GuacamoleDisplayHandle, GuacamoleDisp
const { token } = await response.json(); const { token } = await response.json();
// Build WebSocket URL // Build WebSocket URL with width/height/dpi as query parameters
// These are passed as unencrypted settings to guacamole-lite
// Use actual container dimensions, fall back to 720p
const width = connectionConfig.width || containerWidth || 1280;
const height = connectionConfig.height || containerHeight || 720;
const dpi = connectionConfig.dpi || 96;
const wsBase = isDev const wsBase = isDev
? `ws://localhost:30007` ? `ws://localhost:30007`
: isElectron() : isElectron()
@@ -128,7 +135,7 @@ export const GuacamoleDisplay = forwardRef<GuacamoleDisplayHandle, GuacamoleDisp
})() })()
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/guacamole/websocket/`; : `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/guacamole/websocket/`;
return `${wsBase}?token=${encodeURIComponent(token)}`; return `${wsBase}?token=${encodeURIComponent(token)}&width=${width}&height=${height}&dpi=${dpi}`;
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error"; const errorMessage = error instanceof Error ? error.message : "Unknown error";
setConnectionError(errorMessage); setConnectionError(errorMessage);
@@ -142,7 +149,21 @@ export const GuacamoleDisplay = forwardRef<GuacamoleDisplayHandle, GuacamoleDisp
setIsConnecting(true); setIsConnecting(true);
setConnectionError(null); setConnectionError(null);
const wsUrl = await getWebSocketUrl(); // Get container dimensions for the WebSocket URL
// Use the outer container ref which has h-full w-full
let containerWidth = containerRef.current?.clientWidth || 0;
let containerHeight = containerRef.current?.clientHeight || 0;
console.log(`[Guacamole] Container size: ${containerWidth}x${containerHeight}`);
// If container size is too small or unavailable, use 720p default
if (containerWidth < 100 || containerHeight < 100) {
console.log(`[Guacamole] Container too small, using 720p default`);
containerWidth = 1280;
containerHeight = 720;
}
const wsUrl = await getWebSocketUrl(containerWidth, containerHeight);
if (!wsUrl) { if (!wsUrl) {
setIsConnecting(false); setIsConnecting(false);
return; return;
@@ -154,26 +175,35 @@ export const GuacamoleDisplay = forwardRef<GuacamoleDisplayHandle, GuacamoleDisp
// Set up display // Set up display
const display = client.getDisplay(); const display = client.getDisplay();
const displayElement = display.getElement();
if (displayRef.current) { if (displayRef.current) {
displayRef.current.innerHTML = ""; displayRef.current.innerHTML = "";
const displayElement = display.getElement();
displayElement.style.width = "100%";
displayElement.style.height = "100%";
displayRef.current.appendChild(displayElement); displayRef.current.appendChild(displayElement);
} }
// Handle display sync (when frames arrive) - scale to fit container // Function to rescale display to fit container
display.onresize = (width: number, height: number) => { const rescaleDisplay = () => {
if (displayRef.current) { if (!containerRef.current) return;
const containerWidth = displayRef.current.clientWidth;
const containerHeight = displayRef.current.clientHeight; const cWidth = containerRef.current.clientWidth;
const scale = Math.min(containerWidth / width, containerHeight / height); const cHeight = containerRef.current.clientHeight;
const displayWidth = display.getWidth();
const displayHeight = display.getHeight();
if (displayWidth > 0 && displayHeight > 0 && cWidth > 0 && cHeight > 0) {
const scale = Math.min(cWidth / displayWidth, cHeight / displayHeight);
display.scale(scale); display.scale(scale);
} }
}; };
// Set up mouse input // Handle display sync (when frames arrive)
const mouse = new Guacamole.Mouse(displayRef.current!); display.onresize = () => {
rescaleDisplay();
};
// Set up mouse input on the display element (not the container)
const mouse = new Guacamole.Mouse(displayElement);
mouse.onmousedown = mouse.onmouseup = mouse.onmousemove = (state: Guacamole.Mouse.State) => { mouse.onmousedown = mouse.onmouseup = mouse.onmousemove = (state: Guacamole.Mouse.State) => {
client.sendMouseState(state); client.sendMouseState(state);
}; };
@@ -237,12 +267,8 @@ export const GuacamoleDisplay = forwardRef<GuacamoleDisplayHandle, GuacamoleDisp
} }
}; };
// Connect with display size // Connect - the width/height/dpi are already in the WebSocket URL
const width = connectionConfig.width || displayRef.current?.clientWidth || 1024; client.connect();
const height = connectionConfig.height || displayRef.current?.clientHeight || 768;
const dpi = connectionConfig.dpi || 96;
client.connect(`width=${width}&height=${height}&dpi=${dpi}`);
}, [isConnecting, isConnected, getWebSocketUrl, connectionConfig, onConnect, onDisconnect, onError, t]); }, [isConnecting, isConnected, getWebSocketUrl, connectionConfig, onConnect, onDisconnect, onError, t]);
// Track if we've initiated a connection to prevent re-triggering // Track if we've initiated a connection to prevent re-triggering
@@ -264,26 +290,40 @@ export const GuacamoleDisplay = forwardRef<GuacamoleDisplayHandle, GuacamoleDisp
}; };
}, []); }, []);
// Handle window resize // Handle window resize - rescale display to fit container
useEffect(() => { useEffect(() => {
const handleResize = () => { const handleResize = () => {
if (clientRef.current && displayRef.current) { if (clientRef.current && containerRef.current) {
const display = clientRef.current.getDisplay(); const display = clientRef.current.getDisplay();
const width = displayRef.current.clientWidth; const cWidth = containerRef.current.clientWidth;
const height = displayRef.current.clientHeight; const cHeight = containerRef.current.clientHeight;
display.scale(Math.min(width / display.getWidth(), height / display.getHeight())); const displayWidth = display.getWidth();
const displayHeight = display.getHeight();
if (displayWidth > 0 && displayHeight > 0 && cWidth > 0 && cHeight > 0) {
const scale = Math.min(cWidth / displayWidth, cHeight / displayHeight);
display.scale(scale);
}
} }
}; };
window.addEventListener("resize", handleResize); window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize); // Also trigger on initial render after a short delay
const initialTimeout = setTimeout(handleResize, 100);
return () => {
window.removeEventListener("resize", handleResize);
clearTimeout(initialTimeout);
};
}, []); }, []);
return ( return (
<div className="h-full w-full relative bg-black"> <div
ref={containerRef}
className="h-full w-full relative bg-black flex items-center justify-center overflow-hidden"
>
<div <div
ref={displayRef} ref={displayRef}
className="h-full w-full" className="relative"
style={{ cursor: isConnected ? "none" : "default" }} style={{ cursor: isConnected ? "none" : "default" }}
/> />

View File

@@ -13,7 +13,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { PasswordInput } from "@/components/ui/password-input"; import { PasswordInput } from "@/components/ui/password-input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Monitor, MonitorPlay, Terminal } from "lucide-react"; import { Monitor, MonitorPlay, Terminal } from "lucide-react";
import { GuacamoleDisplay, GuacamoleConnectionConfig } from "./GuacamoleDisplay"; import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext";
import type { GuacamoleConnectionConfig } from "./GuacamoleDisplay";
interface GuacamoleTestDialogProps { interface GuacamoleTestDialogProps {
trigger?: React.ReactNode; trigger?: React.ReactNode;
@@ -21,8 +22,7 @@ interface GuacamoleTestDialogProps {
export function GuacamoleTestDialog({ trigger }: GuacamoleTestDialogProps) { export function GuacamoleTestDialog({ trigger }: GuacamoleTestDialogProps) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isConnecting, setIsConnecting] = useState(false); const { addTab } = useTabs();
const [connectionConfig, setConnectionConfig] = useState<GuacamoleConnectionConfig | null>(null);
const [connectionType, setConnectionType] = useState<"rdp" | "vnc" | "telnet">("rdp"); const [connectionType, setConnectionType] = useState<"rdp" | "vnc" | "telnet">("rdp");
const [hostname, setHostname] = useState(""); const [hostname, setHostname] = useState("");
@@ -48,22 +48,22 @@ export function GuacamoleTestDialog({ trigger }: GuacamoleTestDialogProps) {
"ignore-cert": true, "ignore-cert": true,
}; };
setConnectionConfig(config); // Add a new tab for the remote desktop connection
setIsConnecting(true); const tabType = connectionType === "rdp" ? "rdp" : connectionType === "vnc" ? "vnc" : "rdp";
}; const title = `${connectionType.toUpperCase()} - ${hostname}`;
const handleDisconnect = () => { addTab({
setConnectionConfig(null); type: tabType,
setIsConnecting(false); title,
}; connectionConfig: config,
});
const handleClose = () => { // Close the dialog
handleDisconnect();
setIsOpen(false); setIsOpen(false);
}; };
return ( return (
<Dialog open={isOpen} onOpenChange={(open) => open ? setIsOpen(true) : handleClose()}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
{trigger || ( {trigger || (
<Button variant="outline" className="gap-2"> <Button variant="outline" className="gap-2">
@@ -72,16 +72,15 @@ export function GuacamoleTestDialog({ trigger }: GuacamoleTestDialogProps) {
</Button> </Button>
)} )}
</DialogTrigger> </DialogTrigger>
<DialogContent className={isConnecting ? "sm:max-w-4xl h-[80vh]" : "sm:max-w-md"}> <DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Monitor className="w-5 h-5" /> <Monitor className="w-5 h-5" />
{isConnecting ? `Connected to ${hostname}` : "Test Remote Connection"} Remote Connection
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
{!isConnecting ? ( <div className="space-y-4">
<div className="space-y-4">
<Tabs value={connectionType} onValueChange={(v) => { <Tabs value={connectionType} onValueChange={(v) => {
setConnectionType(v as "rdp" | "vnc" | "telnet"); setConnectionType(v as "rdp" | "vnc" | "telnet");
setPort(""); setPort("");
@@ -177,16 +176,6 @@ export function GuacamoleTestDialog({ trigger }: GuacamoleTestDialogProps) {
Connect Connect
</Button> </Button>
</div> </div>
) : (
<div className="flex-1 h-full min-h-[500px]">
<GuacamoleDisplay
connectionConfig={connectionConfig!}
isVisible={true}
onDisconnect={handleDisconnect}
onError={(err) => console.error("Guacamole error:", err)}
/>
</div>
)}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState, useMemo } from "react";
import { Terminal } from "@/ui/desktop/apps/terminal/Terminal.tsx"; import { Terminal } from "@/ui/desktop/apps/terminal/Terminal.tsx";
import { Server as ServerView } from "@/ui/desktop/apps/server/Server.tsx"; import { Server as ServerView } from "@/ui/desktop/apps/server/Server.tsx";
import { FileManager } from "@/ui/desktop/apps/file-manager/FileManager.tsx"; import { FileManager } from "@/ui/desktop/apps/file-manager/FileManager.tsx";
import { GuacamoleDisplay, type GuacamoleConnectionConfig } from "@/ui/desktop/apps/guacamole/GuacamoleDisplay.tsx";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx"; import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
import { import {
ResizablePanelGroup, ResizablePanelGroup,
@@ -16,7 +17,6 @@ import {
TERMINAL_THEMES, TERMINAL_THEMES,
DEFAULT_TERMINAL_CONFIG, DEFAULT_TERMINAL_CONFIG,
} from "@/constants/terminal-themes"; } from "@/constants/terminal-themes";
import { SSHAuthDialog } from "@/ui/desktop/navigation/SSHAuthDialog.tsx";
interface TabData { interface TabData {
id: number; id: number;
@@ -30,6 +30,7 @@ interface TabData {
}; };
}; };
hostConfig?: any; hostConfig?: any;
connectionConfig?: GuacamoleConnectionConfig;
[key: string]: unknown; [key: string]: unknown;
} }
@@ -58,7 +59,9 @@ export function AppView({
(tab: TabData) => (tab: TabData) =>
tab.type === "terminal" || tab.type === "terminal" ||
tab.type === "server" || tab.type === "server" ||
tab.type === "file_manager", tab.type === "file_manager" ||
tab.type === "rdp" ||
tab.type === "vnc",
), ),
[tabs], [tabs],
); );
@@ -317,6 +320,19 @@ export function AppView({
isTopbarOpen={isTopbarOpen} isTopbarOpen={isTopbarOpen}
embedded embedded
/> />
) : t.type === "rdp" || t.type === "vnc" ? (
t.connectionConfig ? (
<GuacamoleDisplay
connectionConfig={t.connectionConfig}
isVisible={effectiveVisible}
onDisconnect={() => removeTab(t.id)}
onError={(err) => console.error("Guacamole error:", err)}
/>
) : (
<div className="flex items-center justify-center h-full text-red-500">
Missing connection configuration
</div>
)
) : ( ) : (
<FileManager <FileManager
embedded embedded

View File

@@ -10,6 +10,7 @@ import {
Server as ServerIcon, Server as ServerIcon,
Folder as FolderIcon, Folder as FolderIcon,
User as UserIcon, User as UserIcon,
Monitor as MonitorIcon,
} from "lucide-react"; } from "lucide-react";
interface TabProps { interface TabProps {
@@ -119,11 +120,14 @@ export function Tab({
tabType === "terminal" || tabType === "terminal" ||
tabType === "server" || tabType === "server" ||
tabType === "file_manager" || tabType === "file_manager" ||
tabType === "user_profile" tabType === "user_profile" ||
tabType === "rdp" ||
tabType === "vnc"
) { ) {
const isServer = tabType === "server"; const isServer = tabType === "server";
const isFileManager = tabType === "file_manager"; const isFileManager = tabType === "file_manager";
const isUserProfile = tabType === "user_profile"; const isUserProfile = tabType === "user_profile";
const isRemoteDesktop = tabType === "rdp" || tabType === "vnc";
const displayTitle = const displayTitle =
title || title ||
@@ -133,7 +137,9 @@ export function Tab({
? t("nav.fileManager") ? t("nav.fileManager")
: isUserProfile : isUserProfile
? t("nav.userProfile") ? t("nav.userProfile")
: t("nav.terminal")); : isRemoteDesktop
? tabType.toUpperCase()
: t("nav.terminal"));
const { base, suffix } = splitTitle(displayTitle); const { base, suffix } = splitTitle(displayTitle);
@@ -153,6 +159,8 @@ export function Tab({
<FolderIcon className="h-4 w-4 flex-shrink-0" /> <FolderIcon className="h-4 w-4 flex-shrink-0" />
) : isUserProfile ? ( ) : isUserProfile ? (
<UserIcon className="h-4 w-4 flex-shrink-0" /> <UserIcon className="h-4 w-4 flex-shrink-0" />
) : isRemoteDesktop ? (
<MonitorIcon className="h-4 w-4 flex-shrink-0" />
) : ( ) : (
<TerminalIcon className="h-4 w-4 flex-shrink-0" /> <TerminalIcon className="h-4 w-4 flex-shrink-0" />
)} )}