From dea8aba10c41f14bbad99b6318dc6cbcbc8c3f72 Mon Sep 17 00:00:00 2001 From: pletch <17855860+pletch@users.noreply.github.com> Date: Mon, 17 Aug 2026 22:48:55 -0400 Subject: [PATCH 01/15] feat(hidpi): per-connection native resolution and RDP DPI scaling Requests the framebuffer in the browser's physical pixels rather than its CSS pixels, per connection, so text stays sharp on a HiDPI display. The browser reports its devicePixelRatio on connect and the entry decides, since only the entry knows whether the target scales its own UI. The framebuffer factor and the desktop scale are separate numbers. The framebuffer takes the browser's true ratio, capped at MAX_NATIVE_FACTOR, because the client fits whatever framebuffer arrives into the available CSS area: one framebuffer pixel lands on one physical pixel only when the two agree. Snapping it to 1.8 on a 2.0 display leaves the client stretching by 1.111, which measures as a uniformly soft picture with almost no single-pixel edges anywhere -- 0.01% of adjacent pixels differing by more than 100 levels, against a native render's 0.82%. The cost of keeping them apart is a desktop scaled 180% inside a 200% framebuffer drawing its UI about 10% small, which is legible and adjustable on the host where a resample is neither. The desktop scale goes out on two channels that are not equally capable (patch 011). At connection time only 100/140/180 survive: MS-RDPBCGR restricts deviceScaleFactor to those three, and FreeRDP transposes the pair when it synthesises the single-monitor definition, so only equal values reach the server intact. The display-control layout has no such problem -- disp.c builds it directly, nothing transposes it, and MS-RDPEDISP allows desktopScaleFactor anywhere in 100-500 -- so that layout carries the exact percentage beside the nearest legal device factor, and since the client fits the display shortly after connecting it is what the session ends up scaled by. The scale is re-sent on every display update, because a MONITOR_LAYOUT carrying zeroes resets the session to 100%. X11 behind xrdp has no per-connection DPI negotiation, so the session has to scale itself. docs/xrdp-dpi-scaling.md records that the exact scale already arrives there and is already stored, so a patch would only have to act on it. --- docs/rdp-h264.md | 48 +++++ docs/xrdp-dpi-scaling.md | 222 ++++++++++++++++++++++ patches/011-rdp-dpi-scaling.patch | 294 ++++++++++++++++++++++++++++++ patches/README.md | 67 +++++++ src/api.rs | 83 ++++++++- src/guacd.rs | 9 + src/import.rs | 1 + src/session.rs | 25 +++ src/vault.rs | 14 ++ static/client.html | 75 ++++++-- static/connections.html | 42 ++++- 11 files changed, 856 insertions(+), 24 deletions(-) create mode 100644 docs/xrdp-dpi-scaling.md create mode 100644 patches/011-rdp-dpi-scaling.patch diff --git a/docs/rdp-h264.md b/docs/rdp-h264.md index d23b823a..66130b9c 100644 --- a/docs/rdp-h264.md +++ b/docs/rdp-h264.md @@ -111,6 +111,54 @@ In the browser console, `__h264.stats()` reports decoder health — `avgDecodeLatencyMs` in the low single digits, `framesDropped` and `gcLeaks` at zero, `auxViewsDecoded` counting AVC444 auxiliary views. +## When the whole picture looks soft + +Before blaming the codec, check that the client is not resampling. In the +browser console on the client tab: + +```js +__guac_client.getDisplay().getScale() * devicePixelRatio +``` + +**1.0** means one framebuffer pixel per physical pixel. Anything else is a +resample, and it softens text, icons and images uniformly — which a codec does +not do. Measured on a Windows session at 2.016, the picture contained almost no +single-pixel edges at all: 0.01% of adjacent pixels differing by more than 100 +levels, against 0.82% for the same page rendered natively, with the sharpest +transition anywhere reaching 114 where native reached 194. + +The fix is the entry's **Native Resolution (HiDPI)** checkbox, which asks for +the framebuffer in physical pixels. Note it multiplies the pixels the host +encodes and the browser decodes by the square of the ratio. + +### Asking for the exact DPI scaling + +MS-RDPBCGR restricts `deviceScaleFactor` to 100, 140 or 180. Pairing a +framebuffer sized for a 2.0 display with 180% scaling draws the UI about 10% +smaller than nominal — sharp, but small. + +The protocol is not the whole obstacle: `desktopScaleFactor` is legal from 100 +to 500. FreeRDP transposes the pair when it synthesises the single-monitor +definition (`libfreerdp/core/settings.c`, the monitor's `desktopScaleFactor` +filled from `FreeRDP_DeviceScaleFactor` and vice versa), so any unequal pair +reaches the server backwards and an out-of-range device factor makes it discard +both. Equal values are the only ones that survive that path, which is what +`guac_rdp_normalize_desktop_scale` snaps to. + +**The display-control channel is not subject to that.** `disp.c` builds the +`DISPLAY_CONTROL_MONITOR_LAYOUT` itself and hands it straight to the channel, +so nothing transposes it, and MS-RDPEDISP 2.2.2.2.1 likewise restricts only +`DeviceScaleFactor`. The layout therefore carries the exact percentage — +`DesktopScaleFactor = 200` beside `DeviceScaleFactor = 180` — and since the +client fits the display to the browser window shortly after connecting, that +layout is what the session ends up scaled by. The connection-time core data +still goes out snapped and is overridden a moment later. + +An xrdp target is unaffected either way: xrdp parses the scale factors and +drops them unused (`docs/xrdp-dpi-scaling.md`), so a session script setting +`/Gdk/WindowScalingFactor` or `/Xft/DPI` remains the only thing scaling that +desktop. + ## Recording Session recordings capture the raw stream, so a recording of an H.264 session diff --git a/docs/xrdp-dpi-scaling.md b/docs/xrdp-dpi-scaling.md new file mode 100644 index 00000000..61ed6137 --- /dev/null +++ b/docs/xrdp-dpi-scaling.md @@ -0,0 +1,222 @@ +# Making xrdp act on the client's DPI scale factor + +Notes for a possible xrdp patch. Written against the xrdp tree at +`/home/tim/Repos/xrdp`; line numbers are from that checkout and will drift. + +This is **not** required for rustguac. A session script that reads the +framebuffer width and picks a scale factor already works (see "Current +workaround" below). The patch buys exactness — correct scaling for any client, +rather than bucketing by a width threshold — and matters mainly when clients +with different `devicePixelRatio` values connect to the same host. + +**The case is stronger than it was.** rustguac now sends the *exact* scaling +percentage rather than one of 100/140/180 (see the caveat below), and xrdp +accepts and stores it. So the number the session wants is already sitting in +`client_info` and nothing reads it — the patch is plumbing, not negotiation. + +Note also what the patch cannot do: there is no X11 equivalent of the Windows +mechanism it is named after. Windows acts on `desktopScaleFactor` because the +OS has a system-wide DPI that applications honour; X has no such thing, and +scaling is a toolkit convention applied inside the session (`Xft/DPI` via +XSETTINGS for GTK, its own for Qt). So a session-side hook is unavoidable in +principle. What the patch removes is the *guessing*, not the hook — and if the +export is paired with a change to xrdp's own shipped `startwm.sh`, the hook +stops being something a deployment has to write and maintain. + +## What already works + +xrdp **parses and validates the scale factors**; it just never uses them. + +`libxrdp/libxrdp.c` reads them from the monitor data: + +- `:2114` — `in_uint32_le(s, monitor_layout->desktop_scale_factor);` from + `[MS-RDPBCGR] 2.2.1.3.9.1 TS_MONITOR_ATTRIBUTES` +- `:2217` — the same from `[MS-RDPEDISP] 2.2.2.2.1 + DISPLAYCONTROL_MONITOR_LAYOUT` +- `:1939-1964` — validation: `desktop_scale_factor` must be 100–500, + `device_scale_factor` must be exactly 100, 140 or 180. If either is out of + range **both are reset to 100**. +- `:2137` and `:2232` — both values are logged, which makes verification free. + +They are stored per-monitor in `struct display_size_description` +(`common/xrdp_client_info.h:46-47`): + +```c +unsigned int desktop_scale_factor; +unsigned int device_scale_factor; +``` + +A grep across `sesman/` and `xrdp/` returns **no consumers**. The values are +parsed, validated, logged, stored, and dropped. + +## Verify before building anything + +The whole idea depends on the client actually sending monitor attributes. One +log line settles it: + +``` +grep -i "scale_factor" /var/log/xrdp.log | tail +``` + +- Non-100 values → the scale is arriving intact; the patch has something to read. +- `100` with a client that should be sending more → nothing is arriving, and + that is the problem to solve first. The patch would have no input. + +## What the patch needs to do + +### 1. Carry the value from xrdp to sesexec + +This is the bulk of the work. The scale lives in `client_info` inside the +**xrdp** process; the reconnect script is launched by **sesexec**. They +communicate over SCP, and the connect request carries no such field +(`libipm/scp.h:437-442`): + +```c +int +scp_send_connect_session_request(struct trans *trans, + const struct guid *guid, + const char *client_ip, + const char *client_name, + unsigned int flags); +``` + +Adding a field touches: + +- `libipm/scp.h` — the prototypes for send and parse +- `libipm/scp.c:532` — `scp_send_connect_session_request()` and its parse + counterpart +- `xrdp/xrdp_mm.c:374` — the caller, which has `client_info` in reach + +This is a **wire-format change to the sesman control protocol**, so xrdp and +sesman must be upgraded together. Worth checking whether libipm's versioning +allows an optional trailing field, which would avoid a hard lockstep. + +Use the **primary** monitor's `desktop_scale_factor`. With several monitors at +different scales there is no single right answer, and the primary is what a +session-wide DPI setting should follow. + +### 2. Export it to the session scripts + +Trivial once the value is there, and the mechanism already exists. + +For the **reconnect** script, `sesman/sesexec/ercp_server.c:135-140` builds a +NULL-terminated key/value list that `start_reconnect_script()` walks and +`g_setenv()`s (`sesman/sesexec/session.c:1376-1382`): + +```c +const char *vars[] = +{ + "XRDP_CLIENT_IP", g_client_ip, + "XRDP_CLIENT_NAME", g_client_name, + NULL // Terminator +}; +``` + +Add `"XRDP_DESKTOP_SCALE_FACTOR", buf` alongside. + +For **first connect**, follow `XRDP_START_WIDTH` / `XRDP_START_HEIGHT` at +`sesman/sesexec/session.c:383-386`: + +```c +g_snprintf(text, sizeof(text), "%d", sd->params.desktop_scale_factor); +g_setenv_log("XRDP_DESKTOP_SCALE_FACTOR", text, 1); +``` + +Note the reconnect path matters more than first connect: a session outlives its +clients, and reconnecting from a different machine should re-scale. The +`vars[]` list is per-connection, so it handles that correctly; the session-start +variable is fixed for the session's life. + +### 3. What the session script then becomes + +```sh +SCALE=${XRDP_DESKTOP_SCALE_FACTOR:-100} +xfconf-query -c xsettings -p /Xft/DPI -s $(( 96 * SCALE / 100 )) +``` + +Exact, continuous, no width threshold, no polling for the resolution to settle. + +Prefer `/Xft/DPI` over `/Gdk/WindowScalingFactor`: the latter takes only +integers, so it cannot express 140% or 180% at all, and overshoots by ~11% when +used to approximate 180%. + +### 4. Apply it in xrdp's own startwm.sh + +Exporting the variable leaves every deployment writing the same three lines. +Applying it in the `startwm.sh` xrdp ships makes HiDPI work out of the box and +is what turns this from a hook a site maintains into a feature xrdp has — which +is also the version worth proposing upstream, since the guessing it replaces is +guessing every xrdp user is currently doing. + +Guard it so it changes nothing for the clients that send no scale: with +`XRDP_DESKTOP_SCALE_FACTOR` unset or 100 the computed DPI is 96, which is the +default already. + +## Caveat: FreeRDP transposes the pair + +FreeRDP fills the synthesised single-monitor definition with the two factors +swapped (`libfreerdp/core/settings.c`, introduced in `401f81683`, present +through 3.x HEAD): + +```c +const UINT32 desktopScaleFactor = get(FreeRDP_DeviceScaleFactor); /* reads Device */ +const UINT32 deviceScaleFactor = get(FreeRDP_DesktopScaleFactor); /* reads Desktop */ +... +monitor.attributes.desktopScaleFactor = desktopScaleFactor; +monitor.attributes.deviceScaleFactor = deviceScaleFactor; +``` + +Any **unequal** pair therefore reaches xrdp backwards, and xrdp's own validation +then rejects it — a desktop factor of 200 lands in `device_scale_factor`, fails +the 100/140/180 check, and both are reset to 100. + +Equal values are immune, which is why FreeRDP's own `/scale` accepts only 100, +140 and 180 and sets both at once. + +**That is only true of the connection-time core data.** The transposition lives +in FreeRDP's synthesis of a single-monitor definition; the display-control +layout is built directly by the client and passes through untouched. So +`patches/011-rdp-dpi-scaling.patch` now snaps only the core-data pair and sends +the **exact** percentage in the `DISPLAY_CONTROL_MONITOR_LAYOUT` that follows — +`desktopScaleFactor = 200` beside `deviceScaleFactor = 180` on a 2.0 display. + +xrdp accepts that pair. Its validation (`libxrdp/libxrdp.c:1888-1914`) requires +`desktop_scale_factor` in 100–500 and `device_scale_factor` in {100, 140, 180}, +resetting **both** to 100 if either fails; 200/180 satisfies both. The value is +therefore already stored per-monitor and is exact — a patch reading it gets the +client's true ratio, not a bucketed approximation. + +An xrdp patch should still not assume well-formed input from FreeRDP clients +generally: anything arriving over the core-data path from a client that sets the +two settings independently is transposed, and the 100/100 reset above is what +that looks like from inside xrdp. + +## Current workaround (no patch) + +`/etc/xrdp/reconnectwm.sh` infers the scale from the framebuffer width. Two +things to get right: + +**Wait for the resolution to settle.** rustguac renegotiates the display size +up to 4s after connecting (`sendSize` at 800ms, 2s and 4s — a retry sequence, +because the Display Control channel is often not ready for the first attempt). +A fixed `sleep 1` samples a stale mode: + +```sh +prev="" +for _ in $(seq 1 20); do + cur=$(DISPLAY=$DISPLAY xrandr 2>/dev/null | awk '/\*/{print $1; exit}') + if [ -n "$cur" ] && [ "$cur" = "$prev" ]; then break; fi + prev=$cur + sleep 0.5 +done +WIDTH=$(echo "$prev" | cut -dx -f1) +``` + +**Beware shell syntax in the client test.** `if [ echo "$X" | grep -qi ... ]` +is not a valid test — the pipe splits the command, `[` fails with "missing +`]`", and the exit status comes from `grep` reading empty stdin, so the branch +never fires. Use `if echo "$X" | grep -qi ...` or a `case` statement. + +Also note that a branch forcing `SCALING_FACTOR=1` for Guacamole clients dates +from when rustguac sent CSS pixels. Once it sends device pixels that branch +suppresses exactly the scaling you want. diff --git a/patches/011-rdp-dpi-scaling.patch b/patches/011-rdp-dpi-scaling.patch new file mode 100644 index 00000000..17d682a1 --- /dev/null +++ b/patches/011-rdp-dpi-scaling.patch @@ -0,0 +1,294 @@ +diff --git a/src/protocols/rdp/channels/disp.c b/src/protocols/rdp/channels/disp.c +index 1acad6ed..9e182609 100644 +--- a/src/protocols/rdp/channels/disp.c ++++ b/src/protocols/rdp/channels/disp.c +@@ -388,6 +388,39 @@ void guac_rdp_disp_update_size(guac_rdp_disp* disp, + + else if (settings->resize_method == GUAC_RESIZE_DISPLAY_UPDATE) { + ++ /* Repeat the DPI scaling on every layout update. The scale factors ++ * negotiated at connection time are not sticky: a MONITOR_LAYOUT ++ * carrying zeroes (MS-RDPEDISP 2.2.2.2.1 requires 100-500 and one of ++ * 100/140/180, so zero is out of range either way) drops the session ++ * back to 100%. A client that resizes shortly after connecting -- as ++ * one fitting the remote display to a browser window does -- therefore ++ * lost its scaling a second later, having applied it correctly at ++ * first. ++ * ++ * Zero remains the value sent when no scaling was requested, leaving ++ * an unscaled session behaving exactly as before. ++ * ++ * **The two factors are not the same number here.** MS-RDPEDISP ++ * 2.2.2.2.1 allows DesktopScaleFactor anywhere in 100-500 and restricts ++ * only DeviceScaleFactor to 100, 140 or 180, so the exact scaling can ++ * be asked for on this channel even though the connection-time core ++ * data cannot carry it -- FreeRDP transposes that pair while ++ * synthesising its single-monitor definition, which is what forces ++ * both to the same snapped value there (see ++ * guac_rdp_normalize_desktop_scale). This layout is built here and ++ * handed straight to the channel, so nothing transposes it. ++ * ++ * That matters at a device pixel ratio the snapped values cannot ++ * express. A framebuffer sized for a 2.0 display paired with 180% ++ * scaling draws its UI about 10% smaller than it should be; asking for ++ * 200% here corrects it, while DeviceScaleFactor stays at the nearest ++ * legal 180 as the specification requires. */ ++ int scale = settings->desktop_scale != 0 ++ ? guac_rdp_normalize_desktop_scale(settings->desktop_scale) : 0; ++ ++ int desktop_scale = settings->desktop_scale != 0 ++ ? guac_rdp_clamp_desktop_scale(settings->desktop_scale) : 0; ++ + /* Multi-monitor: send the full client-requested monitor layout and + * republish it to the client so it can split the combined framebuffer */ + if (disp->monitor_count > 1) { +@@ -407,8 +440,8 @@ void guac_rdp_disp_update_size(guac_rdp_disp* disp, + .PhysicalWidth = 0, + .PhysicalHeight = 0, + .Orientation = 0, +- .DesktopScaleFactor = 0, +- .DeviceScaleFactor = 0 ++ .DesktopScaleFactor = desktop_scale, ++ .DeviceScaleFactor = scale + }; + } + +@@ -435,8 +468,8 @@ void guac_rdp_disp_update_size(guac_rdp_disp* disp, + .PhysicalWidth = 0, + .PhysicalHeight = 0, + .Orientation = 0, +- .DesktopScaleFactor = 0, +- .DeviceScaleFactor = 0 ++ .DesktopScaleFactor = desktop_scale, ++ .DeviceScaleFactor = scale + }}; + + /* Send display update notification if display channel is connected */ +diff --git a/src/protocols/rdp/settings.c b/src/protocols/rdp/settings.c +index 06f1ebb2..0cdc3f4b 100644 +--- a/src/protocols/rdp/settings.c ++++ b/src/protocols/rdp/settings.c +@@ -100,6 +100,7 @@ const char* GUAC_RDP_CLIENT_ARGS[] = { + "disable-glyph-caching", + "disable-gfx", + "enable-h264", ++ "desktop-scale", + "preconnection-id", + "preconnection-blob", + "timezone", +@@ -448,6 +449,12 @@ enum RDP_ARGS_IDX { + */ + IDX_ENABLE_H264, + ++ /** ++ * The DPI scaling percentage the RDP session should render its UI at, or ++ * 0 to leave the server's default (100%) alone. ++ */ ++ IDX_DESKTOP_SCALE, ++ + /** + * The preconnection ID to send within the preconnection PDU when + * initiating an RDP connection, if any. +@@ -1330,6 +1337,22 @@ guac_rdp_settings* guac_rdp_parse_args(guac_user* user, + guac_user_parse_args_boolean(user, GUAC_RDP_CLIENT_ARGS, argv, + IDX_ENABLE_H264, 0); + ++ /* DPI scaling percentage requested for the remote session. Zero leaves ++ * FreeRDP's defaults untouched, which is a plain 100% session. */ ++ settings->desktop_scale = ++ guac_user_parse_args_int(user, GUAC_RDP_CLIENT_ARGS, argv, ++ IDX_DESKTOP_SCALE, 0); ++ ++ if (settings->desktop_scale != 0 ++ && (settings->desktop_scale < GUAC_RDP_MIN_DESKTOP_SCALE ++ || settings->desktop_scale > GUAC_RDP_MAX_DESKTOP_SCALE)) { ++ guac_user_log(user, GUAC_LOG_WARNING, ++ "Ignoring out-of-range desktop scale of %i%% (must be %i-%i).", ++ settings->desktop_scale, GUAC_RDP_MIN_DESKTOP_SCALE, ++ GUAC_RDP_MAX_DESKTOP_SCALE); ++ settings->desktop_scale = 0; ++ } ++ + /* Session color depth */ + settings->color_depth = + guac_user_parse_args_int(user, GUAC_RDP_CLIENT_ARGS, argv, +@@ -1616,6 +1639,54 @@ int guac_rdp_get_depth(freerdp* rdp) { + #endif + } + ++/** ++ * Snaps a requested DPI scaling percentage to one FreeRDP can actually deliver. ++ * ++ * Only 100, 140 and 180 are usable, for two compounding reasons. MS-RDPBCGR ++ * 2.2.1.3.2 already restricts deviceScaleFactor to exactly those three, and a ++ * server discards the desktop factor along with an out-of-range device factor ++ * rather than degrading. On top of that, FreeRDP transposes the pair when it ++ * synthesises the single-monitor definition every windowed session uses ++ * (libfreerdp/core/settings.c, introduced in 401f81683 and present through ++ * 3.x): the monitor's desktopScaleFactor is filled from FreeRDP_DeviceScale- ++ * Factor and its deviceScaleFactor from FreeRDP_DesktopScaleFactor. Any pair ++ * of differing values therefore reaches the server backwards, and a request ++ * for 200% arrives as deviceScaleFactor=200 -- illegal, so silently ignored. ++ * ++ * Setting both factors to the same value is immune to the transposition, which ++ * is why FreeRDP's own /scale option accepts only these three and sets both at ++ * once. Doing the same is the only combination that survives the journey. ++ * ++ * @param desktop_scale ++ * The requested scaling percentage. ++ * ++ * @return ++ * The nearest percentage that will actually take effect. ++ */ ++int guac_rdp_normalize_desktop_scale(int desktop_scale) { ++ ++ if (desktop_scale >= 160) ++ return 180; ++ ++ if (desktop_scale >= 120) ++ return 140; ++ ++ return 100; ++ ++} ++ ++int guac_rdp_clamp_desktop_scale(int desktop_scale) { ++ ++ if (desktop_scale > GUAC_RDP_MAX_DESKTOP_SCALE) ++ return GUAC_RDP_MAX_DESKTOP_SCALE; ++ ++ if (desktop_scale < GUAC_RDP_MIN_DESKTOP_SCALE) ++ return GUAC_RDP_MIN_DESKTOP_SCALE; ++ ++ return desktop_scale; ++ ++} ++ + void guac_rdp_push_settings(guac_client* client, + guac_rdp_settings* guac_settings, freerdp* rdp) { + +@@ -1633,6 +1704,31 @@ void guac_rdp_push_settings(guac_client* client, + + /* Session */ + ++ /* DPI scaling of the remote session. Both factors travel in the client ++ * core data (MS-RDPBCGR 2.2.1.3.2) and the server ignores the pair unless ++ * desktopScaleFactor is 100-500 AND deviceScaleFactor is exactly 100, 140 ++ * or 180, so an out-of-range device factor silently disables scaling ++ * altogether rather than falling back. Mirror FreeRDP's own /scale, which ++ * sets both together for the three values it accepts, and otherwise sets ++ * only the desktop factor -- what a client at, say, 200% actually sends. */ ++ if (guac_settings->desktop_scale != 0) { ++ ++ int scale = guac_rdp_normalize_desktop_scale(guac_settings->desktop_scale); ++ ++ freerdp_settings_set_uint32(rdp_settings, FreeRDP_DesktopScaleFactor, scale); ++ freerdp_settings_set_uint32(rdp_settings, FreeRDP_DeviceScaleFactor, scale); ++ ++ if (scale != guac_settings->desktop_scale) ++ guac_client_log(client, GUAC_LOG_INFO, ++ "Requested DPI scaling of %i%% is not one the RDP " ++ "protocol can carry; using %i%% instead.", ++ guac_settings->desktop_scale, scale); ++ else ++ guac_client_log(client, GUAC_LOG_INFO, ++ "Requesting %i%% DPI scaling from the RDP server.", scale); ++ ++ } ++ + freerdp_settings_set_uint32(rdp_settings, FreeRDP_DesktopWidth, guac_settings->width); + freerdp_settings_set_uint32(rdp_settings, FreeRDP_DesktopHeight, guac_settings->height); + freerdp_settings_set_uint32(rdp_settings, FreeRDP_ColorDepth, guac_settings->color_depth); +@@ -1914,6 +2010,19 @@ void guac_rdp_push_settings(guac_client* client, + + /* Session */ + rdp_settings->ColorDepth = guac_settings->color_depth; ++ /* DPI scaling of the remote session -- see the FreeRDP 3 path above. */ ++ if (guac_settings->desktop_scale != 0) { ++ ++ int scale = guac_rdp_normalize_desktop_scale(guac_settings->desktop_scale); ++ ++ rdp_settings->DesktopScaleFactor = scale; ++ rdp_settings->DeviceScaleFactor = scale; ++ ++ guac_client_log(client, GUAC_LOG_INFO, ++ "Requesting %i%% DPI scaling from the RDP server.", scale); ++ ++ } ++ + rdp_settings->DesktopWidth = guac_settings->width; + rdp_settings->DesktopHeight = guac_settings->height; + rdp_settings->AlternateShell = guac_strdup(guac_settings->initial_program); +diff --git a/src/protocols/rdp/settings.h b/src/protocols/rdp/settings.h +index 75cc014b..2c50bccd 100644 +--- a/src/protocols/rdp/settings.h ++++ b/src/protocols/rdp/settings.h +@@ -184,6 +184,49 @@ typedef enum guac_rdp_resize_method { + /** + * All settings supported by the Guacamole RDP client. + */ ++/** ++ * The smallest DPI scaling percentage an RDP server will accept ++ * (MS-RDPBCGR 2.2.1.3.2). ++ */ ++#define GUAC_RDP_MIN_DESKTOP_SCALE 100 ++ ++/** ++ * The largest DPI scaling percentage an RDP server will accept ++ * (MS-RDPBCGR 2.2.1.3.2). ++ */ ++#define GUAC_RDP_MAX_DESKTOP_SCALE 500 ++ ++/** ++ * Clamps a requested DPI scaling percentage to the range a desktopScaleFactor ++ * may occupy, without snapping it. ++ * ++ * Unlike deviceScaleFactor, desktopScaleFactor is legal anywhere in 100-500 ++ * (MS-RDPBCGR 2.2.1.3.2, MS-RDPEDISP 2.2.2.2.1), so a channel that carries the ++ * two independently can ask for the exact scaling rather than the nearest of ++ * three. Only the display-control layout can: the connection-time core data ++ * goes through FreeRDP's monitor synthesis, which transposes the pair. ++ * ++ * @param desktop_scale ++ * The requested scaling percentage. ++ * ++ * @return ++ * The same percentage, clamped to the legal range. ++ */ ++int guac_rdp_clamp_desktop_scale(int desktop_scale); ++ ++/** ++ * Snaps a requested DPI scaling percentage to one the RDP protocol and FreeRDP ++ * can actually deliver: 100, 140 or 180. See the implementation for why no ++ * other value survives. ++ * ++ * @param desktop_scale ++ * The requested scaling percentage. ++ * ++ * @return ++ * The nearest percentage that will actually take effect. ++ */ ++int guac_rdp_normalize_desktop_scale(int desktop_scale); ++ + typedef struct guac_rdp_settings { + + /** +@@ -680,6 +723,18 @@ typedef struct guac_rdp_settings { + */ + int enable_h264; + ++ /** ++ * The DPI scaling percentage the remote session should render its UI at, ++ * sent to the server as desktopScaleFactor, or 0 to leave the server's ++ * default of 100% alone. ++ * ++ * This exists so that a client requesting a framebuffer in physical rather ++ * than logical pixels -- which is what makes text render sharply on a ++ * HiDPI display -- does not simply get a desktop whose every element is ++ * half the size it should be. ++ */ ++ int desktop_scale; ++ + /** + * Whether multi-touch support is enabled. + */ diff --git a/patches/README.md b/patches/README.md index c877a512..3b3de623 100644 --- a/patches/README.md +++ b/patches/README.md @@ -220,3 +220,70 @@ git apply --check ../rustguac/patches/001-freerdp3-debian13.patch 2>&1 || echo " 1. Make changes in the `../guacamole-server` working tree 2. Export: `cd ../guacamole-server && git diff > ../rustguac/patches/NNN-description.patch` 3. Patches are applied in numeric order by the build scripts + +## 011-rdp-dpi-scaling.patch + +**Feature:** a `desktop-scale` RDP parameter, sent to the server as +`desktopScaleFactor` so the remote session renders its UI at a matching DPI. + +Without it, asking for a framebuffer in physical rather than logical pixels — +which is what makes text render sharply on a HiDPI display — produces a desktop +whose every icon and glyph is half the size it should be. guacd had no way to +request DPI scaling at all: `channels/disp.c` pins `DesktopScaleFactor` and +`DeviceScaleFactor` to 0, and the existing `dpi` parameter only rescales the +requested width and height, which `settings.c` skips whenever an explicit width +and height are supplied. + +**The two channels differ, and the layout is the capable one.** At connection +time only 100/140/180 survive, because FreeRDP transposes the pair while +synthesising its single-monitor definition and an out-of-range +`deviceScaleFactor` makes the server discard both. The display-control layout +is built in `disp.c` and handed straight to the channel, so nothing transposes +it: it carries the **exact** `DesktopScaleFactor` (100–500) beside the nearest +legal `DeviceScaleFactor`. A client that fits the display shortly after +connecting is therefore scaled by the exact value, which is what allows 200% on +a 2.0 display rather than the 180% that leaves its UI 10% small. +`guac_rdp_clamp_desktop_scale` supplies the first, `guac_rdp_normalize_desktop_scale` +the second. + +**How it works.** `desktop-scale` is parsed as a percentage and validated +against the 100–500 range of MS-RDPBCGR 2.2.1.3.2. When non-zero, +`guac_rdp_push_settings()` sets `FreeRDP_DesktopScaleFactor` to it and +`FreeRDP_DeviceScaleFactor` to a legal companion value; FreeRDP writes both +into the client core data (`gcc.c`). Zero, the default, leaves FreeRDP's +defaults alone and the session behaves exactly as before. + +**Only 100, 140 and 180 work,** for two compounding reasons, and +`guac_rdp_normalize_desktop_scale()` snaps the request to the nearest of them. + +MS-RDPBCGR 2.2.1.3.2 already restricts `deviceScaleFactor` to those three, and +a server discards the desktop factor along with an out-of-range device factor +rather than degrading. On top of that, **FreeRDP transposes the pair** when it +synthesises the single-monitor definition that every windowed session uses +(`libfreerdp/core/settings.c`, introduced in `401f81683` and present through +3.x): + +```c +const UINT32 desktopScaleFactor = get(FreeRDP_DeviceScaleFactor); /* reads Device */ +const UINT32 deviceScaleFactor = get(FreeRDP_DesktopScaleFactor); /* reads Desktop */ +... +monitor.attributes.desktopScaleFactor = desktopScaleFactor; +monitor.attributes.deviceScaleFactor = deviceScaleFactor; +``` + +So any pair of differing values reaches the server backwards: a request for +200%/100% arrives as `deviceScaleFactor=200`, which is illegal, and the whole +pair is dropped. Setting both factors to the *same* value is immune to the +transposition — which is exactly why FreeRDP's own `/scale` accepts only these +three and sets both at once. `static/client.html` snaps its framebuffer factor +to match (1.4 or 1.8), so the extra pixels and the session scaling agree and +physical sizes stay correct. + +**Scope.** RDP only, and Windows-only in practice. An X11 desktop behind xrdp +has no per-connection DPI negotiation; scaling there has to be arranged inside +the session (for example via `xfconf-query -c xsettings -p +/Gdk/WindowScalingFactor`, or `Xft.dpi` for non-integer factors). + +**Client side.** `static/client.html` sends `desktop_scale` on the connect +request, and only when it actually asked for a device-pixel framebuffer — see +`localStorage.rgNativeRes`, which is off by default. diff --git a/src/api.rs b/src/api.rs index cfe236da..efd52197 100644 --- a/src/api.rs +++ b/src/api.rs @@ -324,6 +324,15 @@ pub async fn delete_session( } } +/// Ceiling on the devicePixelRatio a native-resolution entry will size its +/// framebuffer by. +/// +/// The factor squares into pixels the host has to encode and the browser has to +/// decode, so an unbounded value from the page is a way to ask for a +/// framebuffer neither can sustain. 3.0 covers every display in use; beyond +/// that the resample is the lesser problem. +const MAX_NATIVE_FACTOR: f64 = 3.0; + /// PUT /api/sessions/:id/thumbnail — Upload a session thumbnail (JPEG). /// Called by the client periodically to update the session preview. /// Only the session owner (or an admin) can upload. @@ -2740,6 +2749,16 @@ pub struct ConnectRequest { pub height: Option, #[serde(default)] pub dpi: Option, + /// DPI scaling percentage to request from an RDP server, paired with a + /// framebuffer sized in physical pixels. + #[serde(default)] + pub desktop_scale: Option, + /// The browser's devicePixelRatio. Reported rather than acted on: whether + /// the framebuffer is actually sized in physical pixels is the entry's + /// `native_resolution` setting to decide, since it is only safe where the + /// target scales its UI to match. + #[serde(default)] + pub device_pixel_ratio: Option, #[serde(default)] pub banner: Option, /// Override or supply credentials at connect time (never stored). @@ -2931,6 +2950,59 @@ pub async fn ab_connect_entry( // Build CreateSessionRequest from the Vault entry + connect request display params. // ConnectRequest credentials override address book values (for prompted credentials). let ab_entry_key = format!("{}/{}/{}", scope, folder, entry); + + // Physical-pixel framebuffer, when this entry asks for one. The browser + // reports its devicePixelRatio and sends CSS-pixel dimensions; scaling them + // here keeps the policy with the entry rather than in the page. + // + // **The framebuffer factor and the desktop scale are separate numbers.** + // The framebuffer takes the browser's true devicePixelRatio, because + // anything else leaves the client resampling: it fits whatever framebuffer + // arrives into the available CSS area, so one framebuffer pixel lands on + // one physical pixel only when the two agree. The RDP desktop scale is + // snapped to 140 or 180 separately, since MS-RDPBCGR permits only + // 100/140/180 -- see guac_rdp_normalize_desktop_scale in patches/011. + // + // Tying them together, as this used to, meant a 2.0 display was given a + // 1.8 framebuffer and the client stretched it by 1.111 -- measured in the + // field as a uniformly soft picture with almost no single-pixel edges + // anywhere, 0.01% against a native render's 0.82%. The cost of separating + // them is that a desktop scaled 180% inside a framebuffer scaled 200% draws + // its UI about 10% smaller than nominal. That is the better trade: 10% + // smaller is legible and adjustable on the host, while a 1.111 resample is + // neither. + let native_factor = if ab_entry.native_resolution.unwrap_or(false) { + match req.device_pixel_ratio { + // Guard against a nonsense or hostile ratio sizing a framebuffer + // the server then has to encode. + Some(dpr) if dpr.is_finite() && dpr > 1.0 => dpr.min(MAX_NATIVE_FACTOR), + _ => 1.0, + } + } else { + 1.0 + }; + + let (width, height, desktop_scale) = if native_factor > 1.0 { + ( + req.width + .map(|w| ((w as f64 * native_factor).round() as u32) & !0x7), + req.height + .map(|h| (h as f64 * native_factor).round() as u32), + // The exact percentage, not the nearest legal one. guacd snaps it + // for the connection-time core data, where FreeRDP's monitor + // synthesis transposes the pair and only equal values survive, but + // sends it verbatim in the display-control layout that follows -- + // MS-RDPEDISP allows desktopScaleFactor anywhere in 100-500 and + // restricts only deviceScaleFactor. Since the client fits the + // display shortly after connecting, that layout is what the session + // ends up scaled by, so a 2.0 display gets 200% rather than the 180% + // that leaves its UI 10% small. + Some((native_factor * 100.0).round() as u32), + ) + } else { + (req.width, req.height, req.desktop_scale) + }; + let create_req = CreateSessionRequest { session_type, hostname: ab_entry.hostname, @@ -2954,9 +3026,11 @@ pub async fn ab_connect_entry( jump_username: None, jump_password: None, jump_private_key: None, - width: req.width, - height: req.height, + width, + height, dpi: req.dpi, + desktop_scale, + display_scale: (native_factor > 1.0).then_some(native_factor), banner: req.banner.or(ab_entry.banner), enable_drive: ab_entry.enable_drive, remote_app: ab_entry.remote_app, @@ -5258,6 +5332,7 @@ pub struct QuickConnectQuery { pub width: Option, pub height: Option, pub dpi: Option, + pub desktop_scale: Option, // Address book mode pub scope: Option, pub folder: Option, @@ -5447,6 +5522,8 @@ pub async fn quick_connect( width: query.width, height: query.height, dpi: query.dpi, + desktop_scale: query.desktop_scale, + display_scale: None, banner: ab_entry.banner, enable_drive: ab_entry.enable_drive, remote_app: ab_entry.remote_app, @@ -5574,6 +5651,8 @@ pub async fn quick_connect( width: query.width, height: query.height, dpi: query.dpi, + desktop_scale: query.desktop_scale, + display_scale: None, banner: None, enable_drive: None, remote_app: None, diff --git a/src/guacd.rs b/src/guacd.rs index eb2fe44b..3d5cccc0 100644 --- a/src/guacd.rs +++ b/src/guacd.rs @@ -194,6 +194,14 @@ pub struct RdpParams { pub force_lossless: bool, /// Enable H.264 passthrough. Raw H.264 NAL units sent to browser WebCodecs decoder. pub enable_h264: bool, + /// DPI scaling percentage the remote session should render its UI at, sent + /// to the server as desktopScaleFactor. `None` leaves the server at its + /// default of 100%. + /// + /// Only meaningful alongside a framebuffer requested in physical rather + /// than logical pixels: without it, the extra pixels shrink every element + /// of the desktop instead of sharpening it. + pub desktop_scale: Option, /// Number of secondary monitors to allow (beyond the primary). guacd /// advertises this to the client as `secondary-monitors` and drives RDP /// multi-monitor via the Display Control channel. 0 = single monitor. @@ -400,6 +408,7 @@ pub async fn connect_and_handshake( "disable-gfx" => if p.enable_gfx { "false" } else { "true" }.into(), "force-lossless" => if p.force_lossless { "true" } else { "false" }.into(), "enable-h264" => if p.enable_h264 { "true" } else { "false" }.into(), + "desktop-scale" => p.desktop_scale.map_or(String::new(), |s| s.to_string()), "remote-app" => p.remote_app.clone().unwrap_or_default(), "remote-app-dir" => p.remote_app_dir.clone().unwrap_or_default(), "remote-app-args" => p.remote_app_args.clone().unwrap_or_default(), diff --git a/src/import.rs b/src/import.rs index 47cfc35b..843185a3 100644 --- a/src/import.rs +++ b/src/import.rs @@ -165,6 +165,7 @@ pub async fn cmd_import_guacamole( enable_full_window_drag: None, force_lossless: None, enable_h264: None, + native_resolution: None, container_image: None, container_cpu_limit: None, container_memory_limit: None, diff --git a/src/session.rs b/src/session.rs index ef28dcbb..17684639 100644 --- a/src/session.rs +++ b/src/session.rs @@ -72,6 +72,14 @@ pub struct CreateSessionRequest { pub width: Option, pub height: Option, pub dpi: Option, + /// DPI scaling percentage to request from an RDP server, so a framebuffer + /// asked for in physical pixels renders at the right physical size rather + /// than half of it. RDP only; ignored by other protocols. + pub desktop_scale: Option, + /// Factor by which the framebuffer was scaled past the browser's CSS + /// pixels, if it was. Surfaced in `SessionInfo` so client.html can keep + /// its own resize requests in the same units the session was created in. + pub display_scale: Option, pub banner: Option, /// Override drive/file transfer setting for this session. pub enable_drive: Option, @@ -253,6 +261,12 @@ pub struct SessionInfo { pub entry_display_name: Option, #[serde(skip_serializing_if = "Option::is_none")] pub thumbnail_url: Option, + /// Factor by which this session's framebuffer was scaled past the + /// browser's CSS pixels, when the entry asked for a physical-pixel + /// framebuffer. Read by client.html from the /api/sessions/:id fetch so + /// its resize requests stay in the same units; omitted when unscaled. + #[serde(skip_serializing_if = "Option::is_none")] + pub display_scale: Option, /// Open the client in fullscreen on connect (#154). Read by client.html /// from the /api/sessions/:id fetch; omitted when false/unset. #[serde(skip_serializing_if = "std::ops::Not::not")] @@ -321,6 +335,9 @@ pub struct Session { /// Connections card hides its Share button. Does not block admin /// shadow (`/shadow`), which has its own audit trail. pub share_allowed: bool, + /// Factor by which the framebuffer was scaled past the browser's CSS + /// pixels, if the source entry asked for a physical-pixel framebuffer. + pub display_scale: Option, /// Copied from the source entry's `fullscreen_on_connect` flag /// (#154). Surfaced verbatim in `SessionInfo` so client.html can /// trigger fullscreen on first user gesture after CONNECTED. @@ -666,6 +683,7 @@ impl Session { address_book_folder: self.address_book_folder.clone(), entry_display_name: self.entry_display_name.clone(), thumbnail_url: Some(format!("/api/sessions/{}/thumbnail", self.id)), + display_scale: self.display_scale, fullscreen_on_connect: self.fullscreen_on_connect, autohide_side_tabs: self.autohide_side_tabs, } @@ -831,6 +849,9 @@ impl SessionManager { let width = raw_width.clamp(640, 8192); let height = raw_height.clamp(480, 8192); let dpi = raw_dpi.clamp(16, 384); + // MS-RDPBCGR 2.2.1.3.2 permits 100-500; a server discards the whole + // pair if it is out of range, so clamp rather than pass it through. + let desktop_scale = req.desktop_scale.map(|s| s.clamp(100, 500)); if width != raw_width || height != raw_height || dpi != raw_dpi { tracing::warn!( session_id = %session_id, @@ -1121,6 +1142,7 @@ impl SessionManager { enable_full_window_drag: req.enable_full_window_drag.unwrap_or(false), force_lossless: req.force_lossless.unwrap_or(false), enable_h264: req.enable_h264.unwrap_or(false), + desktop_scale, secondary_monitors: req.max_monitors.unwrap_or(1).saturating_sub(1), wol: wol.clone(), })); @@ -1634,6 +1656,7 @@ impl SessionManager { enable_full_window_drag: false, force_lossless: false, enable_h264: true, + desktop_scale, secondary_monitors: req.max_monitors.unwrap_or(1).saturating_sub(1), // VDI container on the Docker host — WoL not applicable. wol: guacd::WolParams::default(), @@ -1944,6 +1967,7 @@ impl SessionManager { login_script_handle, shadow_tokens: Vec::new(), share_allowed, + display_scale: req.display_scale, fullscreen_on_connect: req.fullscreen_on_connect.unwrap_or(false), autohide_side_tabs: req.autohide_side_tabs.unwrap_or(false), }; @@ -2914,6 +2938,7 @@ mod tests { login_script_handle: None, shadow_tokens: Vec::new(), share_allowed: true, + display_scale: None, fullscreen_on_connect: false, autohide_side_tabs: false, } diff --git a/src/vault.rs b/src/vault.rs index 1cc97ab8..aa856572 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -215,6 +215,16 @@ pub struct AddressBookEntry { /// Requires GFX enabled and xrdp with x264 on the target. Default: true when GFX enabled. #[serde(default, skip_serializing_if = "Option::is_none")] pub enable_h264: Option, + /// Request the framebuffer in the browser's physical pixels rather than its + /// CSS pixels, so text renders sharply on a HiDPI display. + /// + /// Per-entry rather than global because it is only safe where the target + /// also scales its UI to match. RDP does that automatically (guacd asks via + /// desktopScaleFactor); an X11 desktop behind xrdp has no per-connection DPI + /// negotiation, so enabling it there without a session-side scaling hook + /// just makes every icon and glyph smaller. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub native_resolution: Option, /// Docker image for VDI sessions (e.g. "myregistry/desktop:latest"). #[serde(default, skip_serializing_if = "Option::is_none")] pub container_image: Option, @@ -445,6 +455,9 @@ pub struct EntryInfo { /// Enable H.264 passthrough. #[serde(skip_serializing_if = "Option::is_none")] pub enable_h264: Option, + /// Request the framebuffer in physical rather than CSS pixels. + #[serde(skip_serializing_if = "Option::is_none")] + pub native_resolution: Option, /// Docker image for VDI sessions. #[serde(skip_serializing_if = "Option::is_none")] pub container_image: Option, @@ -582,6 +595,7 @@ impl From<(&str, &AddressBookEntry)> for EntryInfo { enable_full_window_drag: e.enable_full_window_drag, force_lossless: e.force_lossless, enable_h264: e.enable_h264, + native_resolution: e.native_resolution, container_image: e.container_image.clone(), container_cpu_limit: e.container_cpu_limit, container_memory_limit: e.container_memory_limit, diff --git a/static/client.html b/static/client.html index 055d1961..428834e0 100644 --- a/static/client.html +++ b/static/client.html @@ -253,6 +253,35 @@

Session Ended

throw new Error('No session ID'); } + // ---- Native (device-pixel) framebuffer sizing ------------------- + // + // Set from the session's display_scale, which the server computed when + // the session was created: it, not this page, knows whether the + // connection entry asked for a physical-pixel framebuffer. The value + // matters here only so that resize requests stay in the same units the + // session was created in -- sending CSS pixels to a session negotiated + // in physical ones would shrink it back on the first resize. + // + // scaleDisplay() needs no knowledge of it: it fits whatever framebuffer + // arrives into the available CSS area, which lands on 1/displayScale on + // its own -- one framebuffer pixel per physical pixel, no resampling. + // Mouse input follows too, since sendMouseState(state, true) divides by + // that same scale. + var displayScale = 1; + + // Converts a CSS-pixel size into the size to ask the server for. Width + // is rounded down to a multiple of 8 (SPICE requires it, RDP prefers + // it) and both are kept positive, since a zero size reads as a + // monitor-close. + function deviceSize(cssW, cssH) { + var w = Math.round(cssW * displayScale) & ~0x7; + var h = Math.round(cssH * displayScale); + return { + width: w > 0 ? w : Math.max(8, cssW & ~0x7), + height: h > 0 ? h : Math.max(8, cssH) + }; + } + // Whether asking the server for this size would actually change // anything, keyed by monitor index. // @@ -377,6 +406,7 @@

Session Ended

}; } if (session && session.entry_display_name) entryDisplayName = session.entry_display_name; + if (session && session.display_scale > 1) displayScale = session.display_scale; if (session && session.fullscreen_on_connect) wantFullscreen = true; if (session && session.autohide_side_tabs) wantAutohideTabs = true; return showBanner(session.banner); @@ -902,8 +932,10 @@

Session Ended

// (SPICE wants width as a multiple of 8). Contiguous index. Guard // against a not-yet-laid-out popup reporting 0 (a positive size is // required or the server reads it as a monitor-close). - var reqW = ((win.innerWidth || 1280) & ~0x7) || 1280; - var reqH = win.innerHeight || 800; + var popSize = deviceSize(win.innerWidth || 1280, + win.innerHeight || 800); + var reqW = popSize.width; + var reqH = popSize.height; if (reqW < 8) reqW = 1280; if (reqH < 8) reqH = 800; client.sendSize(reqW, reqH, index, 0); @@ -947,10 +979,11 @@

Session Ended

win.addEventListener('resize', function() { if (popResizeTimer) win.clearTimeout(popResizeTimer); popResizeTimer = win.setTimeout(function() { - var w = (win.innerWidth || 0) & ~0x7; - var h = win.innerHeight || 0; - if (w >= 8 && h >= 8 && sizeWouldChange(w, h, index)) - client.sendSize(w, h, index, 0); + var rs = deviceSize(win.innerWidth || 0, + win.innerHeight || 0); + if (rs.width >= 8 && rs.height >= 8 + && sizeWouldChange(rs.width, rs.height, index)) + client.sendSize(rs.width, rs.height, index, 0); }, 500); }); @@ -1599,9 +1632,9 @@

Session Ended

var fitToWindow = function() { scaleDisplay(); var anyPanelOpen = panelOpen || filePanelOpen; - var sw = anyPanelOpen ? window.innerWidth - 380 : window.innerWidth; - var sh = window.innerHeight; - if (sw > 0 && sh > 0) { + var cssW = anyPanelOpen ? window.innerWidth - 380 : window.innerWidth; + var fit = deviceSize(cssW, window.innerHeight); + if (fit.width > 0 && fit.height > 0) { // Sent whether or not it would change // anything: the repeats above are // deliberate, covering a window where the @@ -1610,8 +1643,8 @@

Session Ended

// Recorded all the same, so that a user // resize inside the same 16px band // afterwards is correctly skipped. - sizeWouldChange(sw, sh); - client.sendSize(sw, sh); + sizeWouldChange(fit.width, fit.height); + client.sendSize(fit.width, fit.height); } }; setTimeout(fitToWindow, 800); @@ -1747,10 +1780,15 @@

Session Ended

var headers = { 'Content-Type': 'application/json' }; if (apiKey) headers['Authorization'] = 'Bearer ' + apiKey; + // CSS pixels plus the browser's pixel ratio: whether the + // framebuffer is sized in physical pixels is the connection + // entry's "Native Resolution" setting to decide, and the server + // applies it. var body = { width: window.innerWidth, height: window.innerHeight, - dpi: Math.round((window.devicePixelRatio || 1) * 96) + dpi: Math.round((window.devicePixelRatio || 1) * 96), + device_pixel_ratio: window.devicePixelRatio || 1 }; var url = '/api/addressbook/folders/' + encodeURIComponent(scope) @@ -1990,6 +2028,11 @@

Session Ended

} else { // Single display: scale the whole framebuffer to fit. + // With the framebuffer requested in device pixels this + // lands on 1/devicePixelRatio, i.e. one framebuffer pixel + // per physical pixel -- no resampling. It stays correct + // when a panel opens or the window is resized ahead of the + // debounced sendSize, just at a softer intermediate scale. var scale = Math.min(availW / dw, availH / dh); display.scale(scale); var sw = dw * scale, sh = dh * scale; @@ -2025,10 +2068,10 @@

Session Ended

clearTimeout(resizeTimer); resizeTimer = setTimeout(function() { var anyPanelOpen = panelOpen || filePanelOpen; - var sw = anyPanelOpen ? window.innerWidth - 380 : window.innerWidth; - var sh = window.innerHeight; - if (sizeWouldChange(sw, sh)) - client.sendSize(sw, sh); + var cssW = anyPanelOpen ? window.innerWidth - 380 : window.innerWidth; + var rz = deviceSize(cssW, window.innerHeight); + if (sizeWouldChange(rz.width, rz.height)) + client.sendSize(rz.width, rz.height); }, 500); }); diff --git a/static/connections.html b/static/connections.html index 8802f42e..a1f15f4c 100644 --- a/static/connections.html +++ b/static/connections.html @@ -857,6 +857,10 @@

New Entry

H.264 Passthrough
Passes H.264 video direct to browser (low latency, hardware decoded). Requires GFX enabled and xrdp with x264 on the target server.
+
Desktop appearance. Off by default to save bandwidth; enable for a normal-looking Windows desktop.
+
Desktop appearance. Off by default to save bandwidth; enable for a normal-looking Windows desktop.