From 39d40db8c6f3d56ffd1ba3d8b14e14d82c99a4f1 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Thu, 16 Jul 2026 11:52:37 +0800 Subject: [PATCH] fix: render RDP sessions at native pixel density (#1059) --- src/backend/guacamole/guacamole-server.ts | 2 +- src/ui/features/guacamole/GuacamoleApp.tsx | 7 ++- .../features/guacamole/GuacamoleDisplay.tsx | 52 ++++++++++++++----- .../guacamole/guacamole-display-size.test.ts | 39 ++++++++++++++ .../guacamole/guacamole-display-size.ts | 39 ++++++++++++++ 5 files changed, 123 insertions(+), 16 deletions(-) create mode 100644 src/ui/features/guacamole/guacamole-display-size.test.ts create mode 100644 src/ui/features/guacamole/guacamole-display-size.ts diff --git a/src/backend/guacamole/guacamole-server.ts b/src/backend/guacamole/guacamole-server.ts index 62bd2d8e..3d7b80bf 100644 --- a/src/backend/guacamole/guacamole-server.ts +++ b/src/backend/guacamole/guacamole-server.ts @@ -102,7 +102,7 @@ const clientOptions = { }, }, allowedUnencryptedConnectionSettings: { - rdp: ["width", "height"], + rdp: ["width", "height", "dpi"], vnc: ["width", "height"], telnet: ["width", "height"], }, diff --git a/src/ui/features/guacamole/GuacamoleApp.tsx b/src/ui/features/guacamole/GuacamoleApp.tsx index f4632bf1..5e98717e 100644 --- a/src/ui/features/guacamole/GuacamoleApp.tsx +++ b/src/ui/features/guacamole/GuacamoleApp.tsx @@ -96,7 +96,7 @@ const GuacamoleApp = React.forwardRef( interface GuacamoleAppInnerProps { hostId: number; - hostConfig: Pick; + hostConfig: Pick; hostName: string; tabId?: string; protocol?: "rdp" | "vnc" | "telnet"; @@ -213,6 +213,7 @@ const GuacamoleAppInner = React.forwardRef< | "rdp" | "vnc" | "telnet"; + const configuredDpi = Number(hostConfig.guacamoleConfig?.dpi); return (
@@ -250,6 +251,10 @@ const GuacamoleAppInner = React.forwardRef< token, protocol: resolvedProtocol, type: resolvedProtocol, + dpi: + Number.isFinite(configuredDpi) && configuredDpi > 0 + ? configuredDpi + : undefined, }} isVisible={true} onError={(err) => setConnectionError(err)} diff --git a/src/ui/features/guacamole/GuacamoleDisplay.tsx b/src/ui/features/guacamole/GuacamoleDisplay.tsx index c3e5ef27..5a7876e7 100644 --- a/src/ui/features/guacamole/GuacamoleDisplay.tsx +++ b/src/ui/features/guacamole/GuacamoleDisplay.tsx @@ -17,6 +17,7 @@ import { isPasteShortcut, pasteTextToRemote, } from "./guacamole-clipboard.ts"; +import { getGuacamoleDisplaySize } from "./guacamole-display-size.ts"; export type GuacamoleConnectionType = "rdp" | "vnc" | "telnet"; @@ -129,13 +130,14 @@ export const GuacamoleDisplay = forwardRef< ): Promise => { try { let token: string; - const protocol = connectionConfig.protocol ?? connectionConfig.type; + const connectionProtocol = + connectionConfig.protocol ?? connectionConfig.type; if (connectionConfig.token) { token = connectionConfig.token; } else { const data = await getGuacamoleToken({ - protocol: protocol ?? "rdp", + protocol: connectionProtocol ?? "rdp", hostname: String(connectionConfig.hostname ?? ""), port: connectionConfig.port, username: connectionConfig.username, @@ -156,8 +158,13 @@ export const GuacamoleDisplay = forwardRef< token = data.token; } - const width = connectionConfig.width ?? containerWidth ?? 1280; - const height = connectionConfig.height ?? containerHeight ?? 720; + const displaySize = getGuacamoleDisplaySize( + connectionConfig.width ?? containerWidth ?? 1280, + connectionConfig.height ?? containerHeight ?? 720, + connectionProtocol, + window.devicePixelRatio, + connectionConfig.dpi, + ); const wsBase = buildGuacamoleWebSocketBaseUrl({ isDev, @@ -171,9 +178,10 @@ export const GuacamoleDisplay = forwardRef< const params = new URLSearchParams({ token, - width: String(width), - height: String(height), + width: String(displaySize.width), + height: String(displaySize.height), }); + if (displaySize.dpi) params.set("dpi", String(displaySize.dpi)); return `${wsBase}?${params.toString()}`; } catch (error) { const errorMessage = @@ -434,9 +442,14 @@ export const GuacamoleDisplay = forwardRef< onConnect?.(); if (containerRef.current) { const rect = containerRef.current.getBoundingClientRect(); - const w = Math.round(rect.width); - const h = Math.round(rect.height); - if (w > 0 && h > 0) client.sendSize(w, h); + const size = getGuacamoleDisplaySize( + rect.width, + rect.height, + protocol, + window.devicePixelRatio, + connectionConfig.dpi, + ); + client.sendSize(size.width, size.height); } rescaleDisplay(false); break; @@ -515,6 +528,7 @@ export const GuacamoleDisplay = forwardRef< rescaleDisplay, connectionConfig.protocol, connectionConfig.type, + connectionConfig.dpi, t, ]); @@ -588,10 +602,15 @@ export const GuacamoleDisplay = forwardRef< resizeTimeoutRef.current = setTimeout(() => { if (clientRef.current && containerRef.current) { const rect = containerRef.current.getBoundingClientRect(); - const w = Math.round(rect.width); - const h = Math.round(rect.height); - if (w > 0 && h > 0) { - clientRef.current.sendSize(w, h); + const size = getGuacamoleDisplaySize( + rect.width, + rect.height, + connectionConfig.protocol ?? connectionConfig.type, + window.devicePixelRatio, + connectionConfig.dpi, + ); + if (rect.width > 0 && rect.height > 0) { + clientRef.current.sendSize(size.width, size.height); rescaleDisplay(true); } } @@ -603,7 +622,12 @@ export const GuacamoleDisplay = forwardRef< return () => { resizeObserver.disconnect(); }; - }, [rescaleDisplay]); + }, [ + connectionConfig.dpi, + connectionConfig.protocol, + connectionConfig.type, + rescaleDisplay, + ]); const syncClipboard = useCallback(() => { const client = clientRef.current; diff --git a/src/ui/features/guacamole/guacamole-display-size.test.ts b/src/ui/features/guacamole/guacamole-display-size.test.ts new file mode 100644 index 00000000..e2b432ff --- /dev/null +++ b/src/ui/features/guacamole/guacamole-display-size.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { getGuacamoleDisplaySize } from "./guacamole-display-size"; + +describe("getGuacamoleDisplaySize", () => { + it("requests native pixels and matching DPI for HiDPI RDP", () => { + expect(getGuacamoleDisplaySize(1280, 720, "rdp", 2)).toEqual({ + width: 2560, + height: 1440, + dpi: 192, + pixelRatio: 2, + }); + }); + + it("scales a configured RDP DPI with the device pixel ratio", () => { + expect(getGuacamoleDisplaySize(1000, 600, "rdp", 1.5, 120)).toEqual({ + width: 1500, + height: 900, + dpi: 180, + pixelRatio: 1.5, + }); + }); + + it("leaves non-RDP protocols at CSS-pixel dimensions", () => { + expect(getGuacamoleDisplaySize(1280, 720, "vnc", 2)).toEqual({ + width: 1280, + height: 720, + pixelRatio: 1, + }); + }); + + it("caps pathological pixel ratios to protect remote session size", () => { + expect(getGuacamoleDisplaySize(400, 800, "rdp", 4)).toEqual({ + width: 1200, + height: 2400, + dpi: 288, + pixelRatio: 3, + }); + }); +}); diff --git a/src/ui/features/guacamole/guacamole-display-size.ts b/src/ui/features/guacamole/guacamole-display-size.ts new file mode 100644 index 00000000..a16a3475 --- /dev/null +++ b/src/ui/features/guacamole/guacamole-display-size.ts @@ -0,0 +1,39 @@ +const DEFAULT_RDP_DPI = 96; +const MAX_DEVICE_PIXEL_RATIO = 3; + +export interface GuacamoleDisplaySize { + width: number; + height: number; + dpi?: number; + pixelRatio: number; +} + +export function getGuacamoleDisplaySize( + cssWidth: number, + cssHeight: number, + protocol: string | undefined, + devicePixelRatio: number, + configuredDpi?: number, +): GuacamoleDisplaySize { + const isRdp = protocol === "rdp"; + const pixelRatio = isRdp + ? Math.min( + MAX_DEVICE_PIXEL_RATIO, + Math.max(1, Number.isFinite(devicePixelRatio) ? devicePixelRatio : 1), + ) + : 1; + + const size = { + width: Math.max(1, Math.round(cssWidth * pixelRatio)), + height: Math.max(1, Math.round(cssHeight * pixelRatio)), + pixelRatio, + }; + + if (!isRdp) return size; + + const baseDpi = + configuredDpi && Number.isFinite(configuredDpi) && configuredDpi > 0 + ? configuredDpi + : DEFAULT_RDP_DPI; + return { ...size, dpi: Math.round(baseDpi * pixelRatio) }; +}