From 799cdefd57e5f55e4869b82f0474d0fcee3445db Mon Sep 17 00:00:00 2001 From: Dave Kempe Date: Sat, 28 Mar 2026 21:52:28 +1100 Subject: [PATCH] v1.0.0: H.264 passthrough, per-entry toggle, Docker fix H.264 passthrough for RDP via guac_display worker integration: - Raw H.264 NAL units from xrdp x264 pass through to browser WebCodecs VideoDecoder, bypassing server-side decode/re-encode - Frame queue preserves all H.264 frames across deferred flushes - Per-entry toggle in address book (enable_h264 field) - Keyframe gate ensures decoder initializes correctly - Per-connection callback (no static global concurrency bug) - Queue capped at 120 frames to prevent unbounded growth guacd patch (004-h264-display-worker): - display-priv.h: h264 frame queue on guac_display_layer - display-plan.c: flush h264 queue during plan_apply, skip IMG ops - display-layer.c: set_h264 API with queue append and cap - rdpgfx.c: SurfaceCommand wrapper saves NAL before GDI handler - settings.c/h: enable-h264 connection parameter, conditional GfxH264 Other changes: - Docker: fix FreeRDP plugin path for drive/audio channels (#87) - contrib/setup-xrdp-gfx.sh: full xrdp x264 rebuild from Debian sid - docs/rdp-video-performance.md: H.264 passthrough documentation - Address book UI: H.264 checkbox with dependency descriptions --- Cargo.lock | 2 +- Cargo.toml | 2 +- Dockerfile | 2 +- contrib/setup-xrdp-gfx.sh | 245 +++++++--- docs/rdp-video-performance.md | 111 ++++- patches/004-h264-display-worker.patch | 643 ++++++++++++++++++++++++++ patches/004-h264-passthrough.patch | 172 ------- patches/README.md | 22 + src/api.rs | 3 + src/guacd.rs | 26 +- src/import.rs | 2 + src/session.rs | 3 + src/vault.rs | 28 ++ static/addressbook.html | 17 +- static/guac/H264Decoder.js | 4 +- 15 files changed, 1012 insertions(+), 270 deletions(-) create mode 100644 patches/004-h264-display-worker.patch delete mode 100644 patches/004-h264-passthrough.patch diff --git a/Cargo.lock b/Cargo.lock index fed21a5..2b6445b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2911,7 +2911,7 @@ dependencies = [ [[package]] name = "rustguac" -version = "0.9.2" +version = "1.0.0" dependencies = [ "aes", "axum", diff --git a/Cargo.toml b/Cargo.toml index b9640b3..3272a50 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustguac" -version = "0.9.2" +version = "1.0.0" edition = "2021" description = "Lightweight Rust replacement for Apache Guacamole client" diff --git a/Dockerfile b/Dockerfile index 3048b1a..e94c2cd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -58,7 +58,7 @@ RUN /build/guacamole-server/configure \ && make -j"$(nproc)" \ && make install \ && mkdir -p /opt/rustguac/lib/freerdp3 \ - && find /usr/lib -path "*/freerdp3/libguac*" -exec cp {} /opt/rustguac/lib/freerdp3/ \; + && find /usr/lib /opt/rustguac/lib -path "*/freerdp3/libguac*" -exec cp {} /opt/rustguac/lib/freerdp3/ \; # --------------------------------------------------------------------------- # Stage 2: Build rustguac diff --git a/contrib/setup-xrdp-gfx.sh b/contrib/setup-xrdp-gfx.sh index e329bd1..23a28cd 100755 --- a/contrib/setup-xrdp-gfx.sh +++ b/contrib/setup-xrdp-gfx.sh @@ -1,13 +1,22 @@ #!/bin/bash # Setup GFX pipeline with H.264 encoding for xrdp on Debian 13 (trixie). # -# Configures xrdp to use the Xorg backend with the GFX graphics pipeline -# and H.264 encoding via x264 for better video performance. +# This script rebuilds xrdp from the Debian sid source package with x264 +# support enabled, installs matching xorgxrdp, configures the Xorg backend +# and GFX pipeline with H.264 encoding. +# +# The stock Debian 13 xrdp package does NOT include x264 support. +# This script adds the Debian sid (unstable) repo, rebuilds xrdp with +# --enable-x264, and pins trixie as the default to prevent accidental +# upgrades from sid. # # Run as root on the xrdp target machine (not the rustguac server). -# Requires: xrdp >= 0.10, xorgxrdp, libx264 +# Requires: Debian 13 (trixie), ~10 minutes for the rebuild. # # Usage: sudo bash setup-xrdp-gfx.sh +# +# After running, also run setup-xrdp-audio.sh for audio redirection. + set -e if [ "$(id -u)" -ne 0 ]; then @@ -15,49 +24,152 @@ if [ "$(id -u)" -ne 0 ]; then exit 1 fi -echo "=== Checking prerequisites ===" -if ! dpkg -l xrdp >/dev/null 2>&1; then - echo "Error: xrdp is not installed" - exit 1 -fi - -XRDP_VERSION=$(dpkg -l xrdp | awk '/^ii/{print $3}') -echo " xrdp version: $XRDP_VERSION" - -if ! dpkg -l xorgxrdp >/dev/null 2>&1; then - echo "Error: xorgxrdp is not installed. Install with: apt install xorgxrdp" - exit 1 -fi -echo " xorgxrdp: installed" - -# Install x264 if not present -if ! dpkg -l libx264-164 >/dev/null 2>&1; then - echo "" - echo "=== Installing libx264 ===" - apt-get update -qq - apt-get install -y libx264-164 -fi -echo " libx264: installed" - +echo "============================================" +echo " xrdp GFX + H.264 Setup for Debian 13" +echo "============================================" echo "" -echo "=== Configuring Xorg as default session ===" -if grep -q "^autorun=$" /etc/xrdp/xrdp.ini; then - sed -i "s/^autorun=$/autorun=Xorg/" /etc/xrdp/xrdp.ini - echo " Set autorun=Xorg" -elif grep -q "^autorun=Xorg" /etc/xrdp/xrdp.ini; then - echo " Already set to Xorg" + +# ---------- Step 1: Add sid repo with pinning ---------- + +echo "=== Step 1: Configuring apt repos ===" +SID_LIST="/etc/apt/sources.list.d/sid.list" +PIN_FILE="/etc/apt/preferences.d/pin-trixie" + +if [ ! -f "$SID_LIST" ]; then + echo "deb http://deb.debian.org/debian sid main" > "$SID_LIST" + echo " Added sid repo to $SID_LIST" else - CURRENT=$(grep "^autorun=" /etc/xrdp/xrdp.ini) - echo " Warning: autorun is set to '$CURRENT' — not changing." - echo " For GFX support, set autorun=Xorg in /etc/xrdp/xrdp.ini" + echo " Sid repo already configured" fi +if [ ! -f "$PIN_FILE" ]; then + cat > "$PIN_FILE" << 'PINEOF' +Package: * +Pin: release a=trixie +Pin-Priority: 900 + +Package: * +Pin: release a=unstable +Pin-Priority: 100 +PINEOF + echo " Created apt pinning (trixie=900, sid=100)" +else + echo " Apt pinning already configured" +fi + +apt-get update -qq echo "" -echo "=== Creating GFX configuration ===" + +# ---------- Step 2: Install xorgxrdp from sid ---------- + +echo "=== Step 2: Installing xorgxrdp from sid ===" +apt-get install -y -t unstable xorgxrdp +XORGXRDP_VER=$(dpkg -l xorgxrdp | awk '/^ii/{print $3}') +echo " xorgxrdp version: $XORGXRDP_VER" +echo "" + +# ---------- Step 3: Rebuild xrdp with x264 ---------- + +echo "=== Step 3: Rebuilding xrdp with x264 support ===" + +# Install build dependencies +apt-get install -y libx264-dev build-essential devscripts +apt-get build-dep -y xrdp 2>/dev/null || apt-get build-dep -y -t unstable xrdp + +# Get xrdp source from sid +BUILD_DIR=$(mktemp -d /tmp/xrdp-build.XXXXXX) +echo " Build directory: $BUILD_DIR" +cd "$BUILD_DIR" + +# Find the version in sid +XRDP_SID_VER=$(apt-cache showsrc -t unstable xrdp 2>/dev/null | grep "^Version:" | head -1 | awk '{print $2}') +if [ -z "$XRDP_SID_VER" ]; then + echo "Error: cannot find xrdp source in sid" + exit 1 +fi +echo " Building xrdp $XRDP_SID_VER from sid source" + +apt-get source "xrdp=$XRDP_SID_VER" +XRDP_DIR=$(ls -d xrdp-* | head -1) +cd "$XRDP_DIR" + +# Patch debian/rules to add --enable-x264 +if grep -q -- '--enable-x264' debian/rules; then + echo " debian/rules already has --enable-x264" +else + sed -i "s|--enable-opus|--enable-opus --enable-x264|" debian/rules + echo " Patched debian/rules: added --enable-x264" +fi + +# Ensure libx264-dev is in Build-Depends +if grep -q 'libx264-dev' debian/control; then + echo " debian/control already has libx264-dev" +else + sed -i "s|^ autoconf,| libx264-dev,\n autoconf,|" debian/control + echo " Patched debian/control: added libx264-dev" +fi + +# Build +echo " Building (this takes a few minutes)..." +dpkg-buildpackage -b -uc -us -j"$(nproc)" > /tmp/xrdp-build.log 2>&1 +if [ $? -ne 0 ]; then + echo " Build failed! See /tmp/xrdp-build.log" + exit 1 +fi + +# Install +DEB=$(ls "$BUILD_DIR"/xrdp_*.deb | head -1) +echo " Installing $DEB" +dpkg -i "$DEB" + +# Verify x264 is linked +if ldd /usr/sbin/xrdp 2>/dev/null | grep -q libx264; then + echo " Verified: xrdp linked to libx264" +else + echo " WARNING: xrdp does not appear to be linked to libx264" +fi + +# Cleanup +rm -rf "$BUILD_DIR" +echo "" + +# ---------- Step 4: Configure Xorg backend ---------- + +echo "=== Step 4: Configuring Xorg backend ===" +XRDP_INI="/etc/xrdp/xrdp.ini" + +if grep -q "^autorun=Xorg" "$XRDP_INI"; then + echo " Already set to Xorg" +elif grep -q "^autorun=" "$XRDP_INI"; then + sed -i "s/^autorun=.*/autorun=Xorg/" "$XRDP_INI" + echo " Set autorun=Xorg" +else + echo "autorun=Xorg" >> "$XRDP_INI" + echo " Added autorun=Xorg" +fi + +# Allow non-root to start Xorg +XWRAPPER="/etc/X11/Xwrapper.config" +if [ -f "$XWRAPPER" ]; then + if grep -q "allowed_users=anybody" "$XWRAPPER"; then + echo " Xwrapper already allows anybody" + else + sed -i "s/^allowed_users=.*/allowed_users=anybody/" "$XWRAPPER" + echo " Set Xwrapper allowed_users=anybody" + fi +else + echo "allowed_users=anybody" > "$XWRAPPER" + echo " Created Xwrapper with allowed_users=anybody" +fi +echo "" + +# ---------- Step 5: Configure GFX pipeline ---------- + +echo "=== Step 5: Creating GFX configuration ===" GFX_CONF="/etc/xrdp/gfx.toml" if [ -f "$GFX_CONF" ]; then cp "$GFX_CONF" "${GFX_CONF}.bak" - echo " Backed up existing $GFX_CONF to ${GFX_CONF}.bak" + echo " Backed up existing $GFX_CONF" fi cat > "$GFX_CONF" << 'EOF' @@ -71,7 +183,7 @@ cat > "$GFX_CONF" << 'EOF' order = ["H.264", "RFX"] h264_encoder = "x264" -[x264] +[x264.default] preset = "ultrafast" tune = "zerolatency" profile = "main" @@ -79,37 +191,52 @@ vbv_max_bitrate = 0 vbv_buffer_size = 0 fps_num = 60 fps_den = 1 -threads = 0 +threads = 1 -[x264.connection.lan] -preset = "ultrafast" -tune = "zerolatency" -vbv_max_bitrate = 0 -fps_num = 60 -fps_den = 1 +[x264.lan] +# inherits default — uncapped bitrate, 60fps -[x264.connection.broadband_high] -preset = "ultrafast" -tune = "zerolatency" -vbv_max_bitrate = 20000 -fps_num = 30 -fps_den = 1 +[x264.wan] +vbv_max_bitrate = 15000 +vbv_buffer_size = 1500 + +[x264.broadband_high] +preset = "superfast" +vbv_max_bitrate = 8000 +vbv_buffer_size = 800 + +[x264.broadband_low] +preset = "veryfast" +vbv_max_bitrate = 1600 +vbv_buffer_size = 66 EOF echo " Created $GFX_CONF" - echo "" -echo "=== Restarting xrdp ===" + +# ---------- Step 6: Restart ---------- + +echo "=== Step 6: Restarting xrdp ===" systemctl restart xrdp echo " xrdp restarted" echo "" -echo "=== Done ===" +echo "============================================" +echo " Setup complete!" +echo "============================================" echo "" echo "xrdp is now configured with:" +echo " - xrdp $(dpkg -l xrdp | awk '/^ii/{print $3}') rebuilt with x264 support" +echo " - xorgxrdp $XORGXRDP_VER (from sid)" echo " - Xorg backend (required for GFX pipeline)" -echo " - H.264 encoding via x264 (60fps LAN, 30fps broadband)" +echo " - H.264 encoding via x264 (60fps LAN)" echo " - RemoteFX as fallback codec" +echo " - Xwrapper allows non-root Xorg" echo "" -echo "In rustguac, enable 'Graphics Pipeline (GFX)' and" -echo "'Desktop Composition' on RDP address book entries" -echo "for best video performance." +echo "Next steps:" +echo " 1. Run contrib/setup-xrdp-audio.sh for audio support" +echo " 2. Install a desktop environment (e.g. apt install task-xfce-desktop)" +echo " 3. In rustguac, enable 'Graphics Pipeline (GFX)' on RDP entries" +echo "" +echo "When connecting from rustguac with H.264 passthrough enabled," +echo "the browser's WebCodecs VideoDecoder will decode H.264 directly" +echo "for low-latency, high-quality video." diff --git a/docs/rdp-video-performance.md b/docs/rdp-video-performance.md index 5e2c8e2..4eb2ac9 100644 --- a/docs/rdp-video-performance.md +++ b/docs/rdp-video-performance.md @@ -71,36 +71,89 @@ Check Windows Event Viewer at `Applications and Services Logs > Microsoft > Wind ## Linux xrdp Tuning (Debian 13) -Debian 13 (trixie) ships xrdp 0.10.1 with GFX pipeline and H.264 support. +Debian 13 (trixie) ships xrdp 0.10.x, but the stock package does **not** include x264 H.264 encoding support. The `contrib/setup-xrdp-gfx.sh` script rebuilds xrdp from the Debian sid source package with `--enable-x264` and configures the GFX pipeline. ### Quick Setup with Scripts Helper scripts are provided in the `contrib/` directory of the rustguac repository. Run these **on the xrdp target machine** (not the rustguac server): ```bash -# 1. Install GFX pipeline with H.264 encoding +# 1. Rebuild xrdp with x264 + configure GFX pipeline (~10 minutes) sudo bash contrib/setup-xrdp-gfx.sh # 2. Install audio redirection (builds PulseAudio module from source) sudo bash contrib/setup-xrdp-audio.sh + +# 3. Install a desktop environment +sudo apt install task-xfce-desktop ``` +The GFX script: +1. Adds Debian sid repo with pinning (trixie stays default) +2. Installs matching `xorgxrdp` from sid +3. Rebuilds `xrdp` from sid source with `--enable-x264` +4. Configures Xorg backend (`autorun=Xorg`) +5. Allows non-root Xorg via Xwrapper +6. Creates `/etc/xrdp/gfx.toml` with H.264 + x264 encoder + ### Manual Setup #### Prerequisites ```bash -apt install xrdp xorgxrdp libx264-164 libavcodec61 pulseaudio +# Add sid repo for newer xrdp source +echo "deb http://deb.debian.org/debian sid main" > /etc/apt/sources.list.d/sid.list + +# Pin trixie as default (prevent accidental sid upgrades) +cat > /etc/apt/preferences.d/pin-trixie << 'EOF' +Package: * +Pin: release a=trixie +Pin-Priority: 900 + +Package: * +Pin: release a=unstable +Pin-Priority: 100 +EOF + +apt-get update + +# Install xorgxrdp from sid (must match xrdp version) +apt-get install -y -t unstable xorgxrdp + +# Install x264 and build dependencies +apt-get install -y libx264-dev build-essential devscripts +apt-get build-dep -y xrdp ``` +#### Rebuild xrdp with x264 + +The stock Debian xrdp package is built without `--enable-x264`. Rebuild from sid source: + +```bash +cd /tmp +apt-get source xrdp= +cd xrdp-* +sed -i "s|--enable-opus|--enable-opus --enable-x264|" debian/rules +sed -i "s|^ autoconf,| libx264-dev,\n autoconf,|" debian/control +dpkg-buildpackage -b -uc -us -j$(nproc) +dpkg -i ../xrdp_*.deb +``` + +Verify x264 is linked: `ldd /usr/sbin/xrdp | grep libx264` + #### GFX Pipeline (Video) -The GFX pipeline requires the Xorg backend (`libxup.so`), not Xvnc. Set in `/etc/xrdp/xrdp.ini`: +The GFX pipeline requires the Xorg backend, not Xvnc. Set in `/etc/xrdp/xrdp.ini`: ```ini autorun=Xorg ``` +Allow non-root Xorg in `/etc/X11/Xwrapper.config`: +``` +allowed_users=anybody +``` + Create `/etc/xrdp/gfx.toml`: ```toml @@ -108,7 +161,7 @@ Create `/etc/xrdp/gfx.toml`: order = ["H.264", "RFX"] h264_encoder = "x264" -[x264] +[x264.default] preset = "ultrafast" tune = "zerolatency" profile = "main" @@ -116,21 +169,19 @@ vbv_max_bitrate = 0 vbv_buffer_size = 0 fps_num = 60 fps_den = 1 -threads = 0 +threads = 1 -[x264.connection.lan] -preset = "ultrafast" -tune = "zerolatency" -vbv_max_bitrate = 0 -fps_num = 60 -fps_den = 1 +[x264.lan] +# inherits default — uncapped bitrate, 60fps -[x264.connection.broadband_high] -preset = "ultrafast" -tune = "zerolatency" -vbv_max_bitrate = 20000 -fps_num = 30 -fps_den = 1 +[x264.wan] +vbv_max_bitrate = 15000 +vbv_buffer_size = 1500 + +[x264.broadband_high] +preset = "superfast" +vbv_max_bitrate = 8000 +vbv_buffer_size = 800 ``` #### Audio Redirection @@ -188,9 +239,9 @@ For video monitoring workloads, a minimum of 20 Mbps per session is recommended. ## How It Works -The video pipeline through rustguac: +### Standard Pipeline (non-H.264 servers) -1. **RDP Server** encodes screen updates (H.264/AVC444 if enabled) +1. **RDP Server** sends screen updates (Planar/RemoteFX codec) 2. **FreeRDP** (inside guacd) decodes to bitmaps 3. **guacd** re-encodes dirty regions as JPEG, WebP, or PNG based on content type and network conditions 4. **rustguac** relays over WebSocket to the browser @@ -201,4 +252,22 @@ guacd automatically adapts encoding quality based on network lag: - Medium lag (50ms): quality 70 (balanced) - High lag (80ms): quality 30 (aggressive compression) -The GFX pipeline enables RemoteFX codec between the RDP server and FreeRDP, which provides better compression than legacy bitmap updates. +### H.264 Passthrough Pipeline (xrdp with x264) + +When the RDP server sends H.264 (AVC420/AVC444), guacd passes the raw H.264 NAL units directly to the browser, bypassing the server-side decode and re-encode: + +1. **xrdp** encodes the screen as H.264 via x264 +2. **FreeRDP** (inside guacd) receives the H.264 SurfaceCommand +3. **guacd** copies the raw H.264 NAL data and also runs the normal GDI decode (for frame sync) +4. During frame flush, guacd sends the raw H.264 data as a custom `h264` instruction +5. **rustguac** relays over WebSocket to the browser +6. **Browser** decodes H.264 using the [WebCodecs VideoDecoder API](https://developer.mozilla.org/en-US/docs/Web/API/VideoDecoder) (hardware-accelerated) + +Benefits: +- **Lower server CPU** — no decode + re-encode cycle on the server +- **Lower latency** — one fewer encoding pass +- **Consistent quality** — single lossy encoding pass (x264) instead of H.264 → bitmap → JPEG/WebP + +H.264 passthrough activates automatically when the RDP server sends AVC420/AVC444 codec data. Servers that don't support H.264 (stock Debian xrdp, Windows without GPU) use the standard pipeline automatically. + +Browser requirements: Chrome/Edge 94+, Firefox 130+ (WebCodecs support). diff --git a/patches/004-h264-display-worker.patch b/patches/004-h264-display-worker.patch new file mode 100644 index 0000000..4999930 --- /dev/null +++ b/patches/004-h264-display-worker.patch @@ -0,0 +1,643 @@ +diff --git a/src/libguac/display-layer-list.c b/src/libguac/display-layer-list.c +index f80aa3a4..3b891107 100644 +--- a/src/libguac/display-layer-list.c ++++ b/src/libguac/display-layer-list.c +@@ -363,6 +363,14 @@ void guac_display_remove_layer(guac_display_layer* display_layer) { + + guac_mem_free(display_layer->last_frame.buffer); + guac_mem_free(display_layer->pending_frame_cells); ++ /* Free any queued H.264 frames */ ++ struct guac_h264_frame* h264_frame = display_layer->h264_queue; ++ while (h264_frame != NULL) { ++ struct guac_h264_frame* next = h264_frame->next; ++ guac_mem_free(h264_frame->data); ++ guac_mem_free(h264_frame); ++ h264_frame = next; ++ } + + guac_mem_free(display_layer); + +diff --git a/src/libguac/display-layer.c b/src/libguac/display-layer.c +index 2c983a15..410e42ac 100644 +--- a/src/libguac/display-layer.c ++++ b/src/libguac/display-layer.c +@@ -20,6 +20,7 @@ + #include "display-priv.h" + #include "guacamole/assert.h" + #include "guacamole/display.h" ++#include "guacamole/mem.h" + #include "guacamole/rect.h" + #include "guacamole/rwlock.h" + +@@ -322,3 +323,43 @@ void guac_display_layer_close_cairo(guac_display_layer* layer, guac_display_laye + guac_rwlock_release_lock(&display->pending_frame.lock); + + } ++ ++void guac_display_layer_set_h264(guac_display_layer* layer, ++ const unsigned char* data, uint32_t length, int is_keyframe, ++ int x, int y, int width, int height) { ++ ++ guac_display* display = layer->display; ++ guac_rwlock_acquire_write_lock(&display->pending_frame.lock); ++ ++ /* Allocate a new queue entry and copy NAL unit data */ ++ struct guac_h264_frame* frame = guac_mem_alloc(sizeof(struct guac_h264_frame)); ++ frame->data = guac_mem_alloc(length); ++ memcpy(frame->data, data, length); ++ frame->length = length; ++ frame->is_keyframe = is_keyframe; ++ guac_rect_init(&frame->rect, x, y, width, height); ++ frame->next = NULL; ++ ++ /* Append to queue */ ++ if (layer->h264_queue_tail != NULL) ++ layer->h264_queue_tail->next = frame; ++ else ++ layer->h264_queue = frame; ++ layer->h264_queue_tail = frame; ++ layer->h264_queue_length++; ++ ++ /* Cap queue at 120 frames (~2 seconds at 60fps) to prevent unbounded ++ * growth if frame flushes are stalled. Drop oldest frame from head. */ ++ while (layer->h264_queue_length > 120 && layer->h264_queue != NULL) { ++ struct guac_h264_frame* old = layer->h264_queue; ++ layer->h264_queue = old->next; ++ if (layer->h264_queue == NULL) ++ layer->h264_queue_tail = NULL; ++ guac_mem_free(old->data); ++ guac_mem_free(old); ++ layer->h264_queue_length--; ++ } ++ ++ guac_rwlock_release_lock(&display->pending_frame.lock); ++ ++} +diff --git a/src/libguac/display-plan.c b/src/libguac/display-plan.c +index 6cd726bb..ff913da4 100644 +--- a/src/libguac/display-plan.c ++++ b/src/libguac/display-plan.c +@@ -23,11 +23,15 @@ + #include "guacamole/client.h" + #include "guacamole/display.h" + #include "guacamole/fifo.h" ++#include "guacamole/layer.h" + #include "guacamole/mem.h" + #include "guacamole/protocol.h" ++#include "guacamole/rect.h" + #include "guacamole/socket.h" ++#include "guacamole/stream.h" + #include "guacamole/timestamp.h" + ++#include + #include + #include + +@@ -368,6 +372,120 @@ void guac_display_plan_free(guac_display_plan* plan) { + guac_mem_free(plan); + } + ++/** ++ * Sends raw H.264 NAL unit data from the given layer to all connected clients ++ * as a custom "h264" instruction followed by blob data and an end instruction. ++ * After sending, the layer's H.264 data is freed and set to NULL. ++ * ++ * @param client ++ * The Guacamole client to send to. ++ * ++ * @param socket ++ * The socket to write instructions to. ++ * ++ * @param layer ++ * The display layer containing H.264 data to send. ++ */ ++/** ++ * Sends a single H.264 frame to all connected clients as a custom "h264" ++ * instruction followed by blob data and an end instruction. ++ */ ++static void guac_display_plan_send_h264_frame(guac_client* client, ++ guac_socket* socket, guac_display_layer* layer, ++ struct guac_h264_frame* frame) { ++ ++ guac_stream* stream = guac_client_alloc_stream(client); ++ ++ /* Build h264 instruction: ++ * h264 */ ++ char arg_bufs[7][16]; ++ int vals[] = { ++ stream->index, ++ layer->layer->index, ++ frame->is_keyframe, ++ frame->rect.left, ++ frame->rect.top, ++ guac_rect_width(&frame->rect), ++ guac_rect_height(&frame->rect) ++ }; ++ ++ guac_socket_instruction_begin(socket); ++ guac_socket_write_string(socket, "4.h264"); ++ for (int i = 0; i < 7; i++) { ++ int n = snprintf(arg_bufs[i], sizeof(arg_bufs[i]), "%d", vals[i]); ++ char len_buf[8]; ++ snprintf(len_buf, sizeof(len_buf), ",%d.", n); ++ guac_socket_write_string(socket, len_buf); ++ guac_socket_write_string(socket, arg_bufs[i]); ++ } ++ guac_socket_write_string(socket, ";"); ++ guac_socket_instruction_end(socket); ++ ++ /* Send H.264 NAL unit data as blob stream */ ++ guac_protocol_send_blobs(socket, stream, frame->data, frame->length); ++ guac_protocol_send_end(socket, stream); ++ guac_client_free_stream(client, stream); ++ ++} ++ ++/** ++ * Sends all queued H.264 frames from the given layer to connected clients, ++ * freeing each frame after sending. Frames are sent in order to preserve ++ * the H.264 reference chain. ++ * ++ * @return ++ * The number of frames sent. ++ */ ++static int guac_display_plan_flush_h264(guac_client* client, ++ guac_socket* socket, guac_display_layer* layer) { ++ ++ int sent = 0; ++ struct guac_h264_frame* frame = layer->h264_queue; ++ ++ while (frame != NULL) { ++ struct guac_h264_frame* next = frame->next; ++ ++ /* Skip P-frames until first keyframe has been sent */ ++ if (!frame->is_keyframe && !layer->h264_keyframe_sent) { ++ guac_client_log(client, GUAC_LOG_DEBUG, ++ "H.264: dropping %u byte delta (no keyframe yet)", ++ frame->length); ++ guac_mem_free(frame->data); ++ guac_mem_free(frame); ++ frame = next; ++ continue; ++ } ++ ++ guac_display_plan_send_h264_frame(client, socket, layer, frame); ++ ++ if (frame->is_keyframe) ++ layer->h264_keyframe_sent = 1; ++ ++ guac_client_log(client, GUAC_LOG_DEBUG, ++ "H.264: sent %u bytes, %s, %dx%d", ++ frame->length, ++ frame->is_keyframe ? "KEYFRAME" : "delta", ++ guac_rect_width(&frame->rect), ++ guac_rect_height(&frame->rect)); ++ ++ guac_mem_free(frame->data); ++ guac_mem_free(frame); ++ frame = next; ++ sent++; ++ } ++ ++ layer->h264_queue = NULL; ++ layer->h264_queue_tail = NULL; ++ layer->h264_queue_length = 0; ++ ++ if (sent > 0) ++ guac_client_log(client, GUAC_LOG_DEBUG, ++ "H.264 passthrough: flushed %d frames", sent); ++ ++ return sent; ++ ++} ++ + void guac_display_plan_apply(guac_display_plan* plan) { + + guac_display* display = plan->display; +@@ -378,6 +496,40 @@ void guac_display_plan_apply(guac_display_plan* plan) { + * AFTER the non-image instructions have finished being written */ + guac_fifo_lock(&display->ops); + ++ /* Send any pending H.264 passthrough data before dispatching operations ++ * to worker threads. This runs while the ops FIFO is locked, so worker ++ * threads have not started yet and there is no socket contention. ++ * ++ * We track up to 8 unique layers that had H.264 data sent, so we can ++ * skip their IMG operations below. In practice there is usually only ++ * one (the default layer). */ ++ guac_display_layer* h264_layers[8] = { NULL }; ++ int h264_layer_count = 0; ++ ++ for (int i = 0; i < plan->length && h264_layer_count < 8; i++) { ++ ++ guac_display_layer* layer = plan->ops[i].layer; ++ if (layer->h264_queue == NULL) ++ continue; ++ ++ /* Check if we already flushed H.264 for this layer */ ++ int already_sent = 0; ++ for (int j = 0; j < h264_layer_count; j++) { ++ if (h264_layers[j] == layer) { ++ already_sent = 1; ++ break; ++ } ++ } ++ ++ if (already_sent) ++ continue; ++ ++ /* Flush all queued H.264 frames for this layer */ ++ if (guac_display_plan_flush_h264(client, client->socket, layer) > 0) ++ h264_layers[h264_layer_count++] = layer; ++ ++ } ++ + /* Immediately send instructions for all updates that do not involve + * significant processing (do not involve encoding anything). This allows + * us to use the worker threads solely for encoding, reducing contention +@@ -385,6 +537,22 @@ void guac_display_plan_apply(guac_display_plan* plan) { + for (int i = 0; i < plan->length; i++) { + + guac_display_layer* display_layer = op->layer; ++ ++ /* Skip all operations for layers that had H.264 data sent — the ++ * H.264 stream replaces JPEG/PNG/WebP encoding entirely */ ++ int is_h264_layer = 0; ++ for (int j = 0; j < h264_layer_count; j++) { ++ if (h264_layers[j] == display_layer) { ++ is_h264_layer = 1; ++ break; ++ } ++ } ++ ++ if (is_h264_layer) { ++ op++; ++ continue; ++ } ++ + switch (op->type) { + + case GUAC_DISPLAY_PLAN_OPERATION_COPY: +diff --git a/src/libguac/display-priv.h b/src/libguac/display-priv.h +index 255fb897..b80fd5fe 100644 +--- a/src/libguac/display-priv.h ++++ b/src/libguac/display-priv.h +@@ -317,6 +317,19 @@ struct guac_display_render_thread { + + }; + ++/** ++ * A queued H.264 frame awaiting delivery to clients. Frames are queued ++ * because guac_display may defer frame flushes, and H.264 requires ALL ++ * frames in sequence (losing a P-frame breaks the reference chain). ++ */ ++struct guac_h264_frame { ++ unsigned char* data; ++ uint32_t length; ++ int is_keyframe; ++ guac_rect rect; ++ struct guac_h264_frame* next; ++}; ++ + /** + * Approximation of how often a region of a layer is modified, as well as what + * changes have been made to that region since the last frame. This information +@@ -603,6 +616,37 @@ struct guac_display_layer { + */ + size_t pending_frame_cells_height; + ++ /* ---------------- H.264 PASSTHROUGH DATA ---------------- */ ++ ++ /** ++ * Head of the queue of pending H.264 frames. Frames are appended by ++ * guac_display_layer_set_h264() and consumed during plan application. ++ * Frames are queued because guac_display may defer frame flushes, and ++ * H.264 requires ALL frames in sequence (losing a P-frame breaks the ++ * reference chain). ++ * ++ * IMPORTANT: The display-level pending_frame.lock MUST be acquired before ++ * modifying or reading this member. ++ */ ++ struct guac_h264_frame* h264_queue; ++ ++ /** ++ * Tail of the H.264 frame queue for O(1) append. ++ */ ++ struct guac_h264_frame* h264_queue_tail; ++ ++ /** ++ * Number of frames currently in the H.264 queue. ++ */ ++ int h264_queue_length; ++ ++ /** ++ * Non-zero if at least one H.264 keyframe has been sent to clients for ++ * this layer. P-frames are only sent after a keyframe has established ++ * the decoder reference state. ++ */ ++ int h264_keyframe_sent; ++ + }; + + typedef struct guac_display_state { +diff --git a/src/libguac/guacamole/display.h b/src/libguac/guacamole/display.h +index 9894042a..d1a990fe 100644 +--- a/src/libguac/guacamole/display.h ++++ b/src/libguac/guacamole/display.h +@@ -417,6 +417,43 @@ guac_display_layer* guac_display_alloc_buffer(guac_display* display, int opaque) + */ + void guac_display_free_layer(guac_display_layer* display_layer); + ++/** ++ * Stores raw H.264 NAL unit data on the given layer for passthrough to ++ * clients during the next frame flush. When H.264 data is set, the display ++ * will send it directly to clients instead of encoding the layer's pixel data ++ * as JPEG/PNG/WebP for the affected region. ++ * ++ * This function acquires the display's pending_frame lock internally and must ++ * NOT be called while that lock is already held by the calling thread. ++ * ++ * @param layer ++ * The layer to set H.264 data on. ++ * ++ * @param data ++ * The raw H.264 NAL unit data (Annex B format). ++ * ++ * @param length ++ * The length of the H.264 data in bytes. ++ * ++ * @param is_keyframe ++ * Non-zero if this data contains an IDR (keyframe), zero otherwise. ++ * ++ * @param x ++ * The X coordinate of the region this data covers. ++ * ++ * @param y ++ * The Y coordinate of the region this data covers. ++ * ++ * @param width ++ * The width of the region this data covers, in pixels. ++ * ++ * @param height ++ * The height of the region this data covers, in pixels. ++ */ ++void guac_display_layer_set_h264(guac_display_layer* layer, ++ const unsigned char* data, uint32_t length, int is_keyframe, ++ int x, int y, int width, int height); ++ + /** + * Returns a layer representing the current mouse cursor icon. Changes to the + * contents of this layer will affect the remote mouse cursor after the current +diff --git a/src/protocols/rdp/channels/rdpgfx.c b/src/protocols/rdp/channels/rdpgfx.c +index 327e7c2b..55cd8cfe 100644 +--- a/src/protocols/rdp/channels/rdpgfx.c ++++ b/src/protocols/rdp/channels/rdpgfx.c +@@ -24,14 +24,122 @@ + #include "settings.h" + + #include ++#include + #include + #include + #include + #include ++#include + + #include + #include + ++/** ++ * Check if a H.264 NAL unit buffer contains a keyframe (IDR slice or SPS). ++ */ ++static int guac_rdp_h264_is_keyframe(const BYTE* data, UINT32 length) { ++ for (UINT32 i = 0; i + 3 < length; i++) { ++ if (data[i] == 0 && data[i+1] == 0) { ++ int offset = -1; ++ if (data[i+2] == 1) ++ offset = i + 3; ++ else if (i + 4 < length && data[i+2] == 0 && data[i+3] == 1) ++ offset = i + 4; ++ if (offset >= 0 && (UINT32) offset < length) { ++ int nal_type = data[offset] & 0x1F; ++ if (nal_type == 5 || nal_type == 7) ++ return 1; ++ } ++ } ++ } ++ return 0; ++} ++ ++/** ++ * Wrapper for the FreeRDP GDI SurfaceCommand callback. Intercepts AVC420 ++ * and AVC444 commands to store raw H.264 NAL units on the display layer ++ * for passthrough to clients during the next frame flush. ++ * ++ * The original handler is always called to perform GDI decoding, which ++ * updates the pixel buffer and dirty tracking. The H.264 data is sent ++ * to clients by the display worker pipeline (in guac_display_plan_apply) ++ * instead of encoding the pixel data as JPEG/PNG/WebP. ++ */ ++static UINT guac_rdp_gfx_surface_command(RdpgfxClientContext* context, ++ const RDPGFX_SURFACE_COMMAND* cmd) { ++ ++ /* gfx->custom points to rdpGdi (set by gdi_graphics_pipeline_init) */ ++ rdpGdi* gdi = (rdpGdi*) context->custom; ++ rdpContext* rdp_context = gdi->context; ++ guac_client* client = ((rdp_freerdp_context*) rdp_context)->client; ++ guac_rdp_client* rdp_client = (guac_rdp_client*) client->data; ++ ++ /* Save H.264 NAL data BEFORE calling the original handler. The original ++ * GDI handler may free or modify cmd->extra data during AVC decoding, ++ * so we must copy it while it's still valid. */ ++ unsigned char* saved_h264 = NULL; ++ UINT32 saved_length = 0; ++ int saved_keyframe = 0; ++ ++ if (cmd->codecId == RDPGFX_CODECID_AVC420 && cmd->extra != NULL) { ++ RDPGFX_AVC420_BITMAP_STREAM* avc420 = ++ (RDPGFX_AVC420_BITMAP_STREAM*) cmd->extra; ++ if (avc420->data != NULL && avc420->length > 0) { ++ saved_length = avc420->length; ++ saved_h264 = malloc(saved_length); ++ if (saved_h264 != NULL) { ++ memcpy(saved_h264, avc420->data, saved_length); ++ saved_keyframe = guac_rdp_h264_is_keyframe( ++ saved_h264, saved_length); ++ } ++ } ++ } ++ else if ((cmd->codecId == RDPGFX_CODECID_AVC444 ++ || cmd->codecId == RDPGFX_CODECID_AVC444v2) ++ && cmd->extra != NULL) { ++ RDPGFX_AVC444_BITMAP_STREAM* avc444 = ++ (RDPGFX_AVC444_BITMAP_STREAM*) cmd->extra; ++ if (avc444->bitstream[0].data != NULL ++ && avc444->bitstream[0].length > 0) { ++ saved_length = avc444->bitstream[0].length; ++ saved_h264 = malloc(saved_length); ++ if (saved_h264 != NULL) { ++ memcpy(saved_h264, avc444->bitstream[0].data, saved_length); ++ saved_keyframe = guac_rdp_h264_is_keyframe( ++ saved_h264, saved_length); ++ } ++ } ++ } ++ ++ /* Run normal GDI decode — this calls BeginPaint/EndPaint internally, ++ * updates the pixel buffer, and marks the dirty region. Must happen ++ * after we've saved the H.264 data above. */ ++ UINT result = CHANNEL_RC_OK; ++ pcRdpgfxSurfaceCommand orig = ++ (pcRdpgfxSurfaceCommand) rdp_client->orig_surface_command; ++ if (orig != NULL) ++ result = orig(context, cmd); ++ ++ /* Store saved H.264 data on the display layer for passthrough. ++ * guac_display_layer_set_h264 acquires pending_frame.lock internally, ++ * which is safe since EndPaint has already released it above. */ ++ if (saved_h264 != NULL) { ++ guac_display_layer* default_layer = ++ guac_display_default_layer(rdp_client->display); ++ guac_display_layer_set_h264(default_layer, ++ saved_h264, saved_length, saved_keyframe, ++ cmd->left, cmd->top, cmd->width, cmd->height); ++ guac_client_log(client, GUAC_LOG_TRACE, ++ "H.264 passthrough: %u bytes, %s, %ux%u at %u,%u", ++ saved_length, ++ saved_keyframe ? "keyframe" : "delta", ++ cmd->width, cmd->height, cmd->left, cmd->top); ++ free(saved_h264); ++ } ++ ++ return result; ++} ++ + /** + * Callback which associates handlers specific to Guacamole with the + * RdpgfxClientContext instance allocated by FreeRDP to deal with received +@@ -63,13 +171,25 @@ static void guac_rdp_rdpgfx_channel_connected(rdpContext* context, + RdpgfxClientContext* rdpgfx = (RdpgfxClientContext*) args->pInterface; + rdpGdi* gdi = context->gdi; + +- if (!gdi_graphics_pipeline_init(gdi, rdpgfx)) ++ if (!gdi_graphics_pipeline_init(gdi, rdpgfx)) { + guac_client_log(client, GUAC_LOG_WARNING, "Rendering backend for RDPGFX " + "channel could not be loaded. Graphics may not render at all!"); +- else ++ } ++ else { + guac_client_log(client, GUAC_LOG_DEBUG, "RDPGFX channel will be used for " + "the RDP Graphics Pipeline Extension."); + ++ /* Wrap SurfaceCommand to intercept H.264 data after GDI decode. ++ * Store the original callback per-connection to avoid clobbering ++ * when multiple RDP sessions are active concurrently. */ ++ guac_rdp_client* rdp_client = (guac_rdp_client*) client->data; ++ rdp_client->orig_surface_command = (void*) rdpgfx->SurfaceCommand; ++ rdpgfx->SurfaceCommand = guac_rdp_gfx_surface_command; ++ ++ guac_client_log(client, GUAC_LOG_INFO, ++ "H.264 passthrough enabled for RDPGFX channel."); ++ } ++ + } + + /** +@@ -120,4 +240,3 @@ void guac_rdp_rdpgfx_load_plugin(rdpContext* context) { + guac_freerdp_dynamic_channel_collection_add(context->settings, "rdpgfx", NULL); + + } +- +diff --git a/src/protocols/rdp/rdp.h b/src/protocols/rdp/rdp.h +index 065080a3..a0893ffb 100644 +--- a/src/protocols/rdp/rdp.h ++++ b/src/protocols/rdp/rdp.h +@@ -132,6 +132,13 @@ typedef struct guac_rdp_client { + */ + guac_display_render_thread* render_thread; + ++ /** ++ * The original GFX SurfaceCommand handler installed by ++ * gdi_graphics_pipeline_init(). Stored per-connection so multiple ++ * concurrent RDP sessions don't clobber each other's callback. ++ */ ++ void* orig_surface_command; ++ + /** + * Queue of mouse, keyboard, and touch events. These events are accumulated + * and flushed within the RDP client thread to avoid spending excessive +diff --git a/src/protocols/rdp/settings.c b/src/protocols/rdp/settings.c +index d97d98e1..26648d09 100644 +--- a/src/protocols/rdp/settings.c ++++ b/src/protocols/rdp/settings.c +@@ -100,6 +100,7 @@ const char* GUAC_RDP_CLIENT_ARGS[] = { + "disable-offscreen-caching", + "disable-glyph-caching", + "disable-gfx", ++ "enable-h264", + "preconnection-id", + "preconnection-blob", + "timezone", +@@ -440,6 +441,12 @@ enum RDP_ARGS_IDX { + */ + IDX_DISABLE_GFX, + ++ /** ++ * Whether H.264 passthrough should be enabled, negotiating GfxH264 and ++ * GfxAVC444 with the RDP server. ++ */ ++ IDX_ENABLE_H264, ++ + /** + * The preconnection ID to send within the preconnection PDU when + * initiating an RDP connection, if any. +@@ -1290,6 +1297,11 @@ guac_rdp_settings* guac_rdp_parse_args(guac_user* user, + !guac_user_parse_args_boolean(user, GUAC_RDP_CLIENT_ARGS, argv, + IDX_DISABLE_GFX, 0); + ++ /* H.264 passthrough enable/disable */ ++ settings->enable_h264 = ++ guac_user_parse_args_boolean(user, GUAC_RDP_CLIENT_ARGS, argv, ++ IDX_ENABLE_H264, 0); ++ + /* Session color depth */ + settings->color_depth = + guac_user_parse_args_int(user, GUAC_RDP_CLIENT_ARGS, argv, +@@ -1619,6 +1631,10 @@ void guac_rdp_push_settings(guac_client* client, + + freerdp_settings_set_bool(rdp_settings, FreeRDP_SupportGraphicsPipeline, TRUE); + freerdp_settings_set_bool(rdp_settings, FreeRDP_RemoteFxCodec, TRUE); ++ if (guac_settings->enable_h264) { ++ freerdp_settings_set_bool(rdp_settings, FreeRDP_GfxH264, TRUE); ++ freerdp_settings_set_bool(rdp_settings, FreeRDP_GfxAVC444, TRUE); ++ } + + if (freerdp_settings_get_uint32(rdp_settings, FreeRDP_ColorDepth) != RDP_GFX_REQUIRED_DEPTH) { + guac_client_log(client, GUAC_LOG_WARNING, "Ignoring requested " +@@ -1878,6 +1894,10 @@ void guac_rdp_push_settings(guac_client* client, + + rdp_settings->SupportGraphicsPipeline = TRUE; + rdp_settings->RemoteFxCodec = TRUE; ++ if (settings->enable_h264) { ++ rdp_settings->GfxH264 = TRUE; ++ rdp_settings->GfxAVC444 = TRUE; ++ } + + if (rdp_settings->ColorDepth != RDP_GFX_REQUIRED_DEPTH) { + guac_client_log(client, GUAC_LOG_WARNING, "Ignoring requested " +diff --git a/src/protocols/rdp/settings.h b/src/protocols/rdp/settings.h +index 87745cf5..e752b565 100644 +--- a/src/protocols/rdp/settings.h ++++ b/src/protocols/rdp/settings.h +@@ -652,6 +652,11 @@ typedef struct guac_rdp_settings { + */ + int enable_gfx; + ++ /** ++ * Whether H.264 passthrough is enabled (GfxH264/GfxAVC444 negotiation). ++ */ ++ int enable_h264; ++ + /** + * Whether multi-touch support is enabled. + */ diff --git a/patches/004-h264-passthrough.patch b/patches/004-h264-passthrough.patch deleted file mode 100644 index 707b1ef..0000000 --- a/patches/004-h264-passthrough.patch +++ /dev/null @@ -1,172 +0,0 @@ -diff --git a/src/protocols/rdp/channels/rdpgfx.c b/src/protocols/rdp/channels/rdpgfx.c -index 327e7c2b..260681f2 100644 ---- a/src/protocols/rdp/channels/rdpgfx.c -+++ b/src/protocols/rdp/channels/rdpgfx.c -@@ -24,14 +24,120 @@ - #include "settings.h" - - #include -+#include - #include - #include - #include - #include -+#include -+#include -+#include - -+#include - #include - #include - -+/** -+ * The original SurfaceCommand handler installed by gdi_graphics_pipeline_init(). -+ */ -+static pcRdpgfxSurfaceCommand guac_rdpgfx_orig_surface_command = NULL; -+ -+/** -+ * Check if a H.264 NAL unit buffer contains a keyframe (IDR slice). -+ */ -+static int guac_rdp_h264_is_keyframe(const BYTE* data, UINT32 length) { -+ for (UINT32 i = 0; i + 3 < length; i++) { -+ if (data[i] == 0 && data[i+1] == 0) { -+ int offset = -1; -+ if (data[i+2] == 1) -+ offset = i + 3; -+ else if (i + 4 < length && data[i+2] == 0 && data[i+3] == 1) -+ offset = i + 4; -+ if (offset >= 0 && (UINT32) offset < length) { -+ int nal_type = data[offset] & 0x1F; -+ if (nal_type == 5 || nal_type == 7) -+ return 1; -+ } -+ } -+ } -+ return 0; -+} -+ -+/** -+ * Send raw H.264 NAL units to the Guacamole client as a custom "h264" -+ * instruction followed by blob data and an end instruction. -+ */ -+static void guac_rdp_h264_send_passthrough(guac_client* client, -+ const RDPGFX_SURFACE_COMMAND* cmd, -+ const BYTE* h264_data, UINT32 h264_length) { -+ -+ guac_socket* socket = client->socket; -+ guac_stream* stream = guac_client_alloc_stream(client); -+ int keyframe = guac_rdp_h264_is_keyframe(h264_data, h264_length); -+ -+ /* Build and send h264 instruction header */ -+ char arg_bufs[7][16]; -+ int vals[] = { stream->index, 0, keyframe, -+ (int) cmd->left, (int) cmd->top, -+ (int) cmd->width, (int) cmd->height }; -+ -+ guac_socket_instruction_begin(socket); -+ guac_socket_write_string(socket, "4.h264"); -+ for (int i = 0; i < 7; i++) { -+ int n = snprintf(arg_bufs[i], sizeof(arg_bufs[i]), "%d", vals[i]); -+ char len_buf[8]; -+ snprintf(len_buf, sizeof(len_buf), ",%d.", n); -+ guac_socket_write_string(socket, len_buf); -+ guac_socket_write_string(socket, arg_bufs[i]); -+ } -+ guac_socket_write_string(socket, ";"); -+ guac_socket_instruction_end(socket); -+ -+ guac_protocol_send_blobs(socket, stream, h264_data, h264_length); -+ guac_protocol_send_end(socket, stream); -+ guac_client_free_stream(client, stream); -+ -+ guac_client_log(client, GUAC_LOG_TRACE, -+ "H.264 passthrough: %u bytes, %s, %ux%u at (%u,%u)", -+ h264_length, keyframe ? "keyframe" : "delta", -+ cmd->width, cmd->height, cmd->left, cmd->top); -+} -+ -+/** -+ * Wrapper for the FreeRDP GDI SurfaceCommand callback. Intercepts AVC420 -+ * and AVC444 commands to extract raw H.264 NAL units for passthrough. -+ */ -+static UINT guac_rdp_gfx_surface_command(RdpgfxClientContext* context, -+ const RDPGFX_SURFACE_COMMAND* cmd) { -+ -+ rdpContext* rdp_context = (rdpContext*) context->custom; -+ guac_client* client = ((rdp_freerdp_context*) rdp_context)->client; -+ -+ if (cmd->codecId == RDPGFX_CODECID_AVC420 && cmd->extra != NULL) { -+ RDPGFX_AVC420_BITMAP_STREAM* avc420 = -+ (RDPGFX_AVC420_BITMAP_STREAM*) cmd->extra; -+ if (avc420->data != NULL && avc420->length > 0) -+ guac_rdp_h264_send_passthrough(client, cmd, -+ avc420->data, avc420->length); -+ } -+ else if ((cmd->codecId == RDPGFX_CODECID_AVC444 -+ || cmd->codecId == RDPGFX_CODECID_AVC444v2) -+ && cmd->extra != NULL) { -+ RDPGFX_AVC444_BITMAP_STREAM* avc444 = -+ (RDPGFX_AVC444_BITMAP_STREAM*) cmd->extra; -+ if (avc444->bitstream[0].data != NULL -+ && avc444->bitstream[0].length > 0) -+ guac_rdp_h264_send_passthrough(client, cmd, -+ avc444->bitstream[0].data, -+ avc444->bitstream[0].length); -+ } -+ -+ if (guac_rdpgfx_orig_surface_command != NULL) -+ return guac_rdpgfx_orig_surface_command(context, cmd); -+ -+ return CHANNEL_RC_OK; -+} -+ - /** - * Callback which associates handlers specific to Guacamole with the - * RdpgfxClientContext instance allocated by FreeRDP to deal with received -@@ -63,13 +169,22 @@ static void guac_rdp_rdpgfx_channel_connected(rdpContext* context, - RdpgfxClientContext* rdpgfx = (RdpgfxClientContext*) args->pInterface; - rdpGdi* gdi = context->gdi; - -- if (!gdi_graphics_pipeline_init(gdi, rdpgfx)) -+ if (!gdi_graphics_pipeline_init(gdi, rdpgfx)) { - guac_client_log(client, GUAC_LOG_WARNING, "Rendering backend for RDPGFX " - "channel could not be loaded. Graphics may not render at all!"); -- else -+ } -+ else { - guac_client_log(client, GUAC_LOG_DEBUG, "RDPGFX channel will be used for " - "the RDP Graphics Pipeline Extension."); - -+ /* Wrap SurfaceCommand to intercept H.264 data before GDI decode */ -+ guac_rdpgfx_orig_surface_command = rdpgfx->SurfaceCommand; -+ rdpgfx->SurfaceCommand = guac_rdp_gfx_surface_command; -+ -+ guac_client_log(client, GUAC_LOG_INFO, -+ "H.264 passthrough enabled for RDPGFX channel."); -+ } -+ - } - - /** -diff --git a/src/protocols/rdp/settings.c b/src/protocols/rdp/settings.c -index d97d98e1..bd11f6da 100644 ---- a/src/protocols/rdp/settings.c -+++ b/src/protocols/rdp/settings.c -@@ -1619,6 +1619,8 @@ void guac_rdp_push_settings(guac_client* client, - - freerdp_settings_set_bool(rdp_settings, FreeRDP_SupportGraphicsPipeline, TRUE); - freerdp_settings_set_bool(rdp_settings, FreeRDP_RemoteFxCodec, TRUE); -+ freerdp_settings_set_bool(rdp_settings, FreeRDP_GfxH264, TRUE); -+ freerdp_settings_set_bool(rdp_settings, FreeRDP_GfxAVC444, TRUE); - - if (freerdp_settings_get_uint32(rdp_settings, FreeRDP_ColorDepth) != RDP_GFX_REQUIRED_DEPTH) { - guac_client_log(client, GUAC_LOG_WARNING, "Ignoring requested " -@@ -1878,6 +1880,8 @@ void guac_rdp_push_settings(guac_client* client, - - rdp_settings->SupportGraphicsPipeline = TRUE; - rdp_settings->RemoteFxCodec = TRUE; -+ rdp_settings->GfxH264 = TRUE; -+ rdp_settings->GfxAVC444 = TRUE; - - if (rdp_settings->ColorDepth != RDP_GFX_REQUIRED_DEPTH) { - guac_client_log(client, GUAC_LOG_WARNING, "Ignoring requested " diff --git a/patches/README.md b/patches/README.md index a647593..8734d19 100644 --- a/patches/README.md +++ b/patches/README.md @@ -68,6 +68,28 @@ Three new connection parameters: | `src/protocols/rdp/channels/rdpgfx.c` | Add `#include "config.h"` | | `src/protocols/rdp/input.c` | Add `#include "config.h"`, add NULL guard in `guac_rdp_user_size_handler()` | +## 004-h264-display-worker.patch + +**Feature:** H.264 passthrough via guac_display worker integration. When the RDP server sends AVC420/AVC444 encoded frames (H.264), the raw NAL units are passed through to the browser's WebCodecs VideoDecoder instead of being decoded server-side and re-encoded as JPEG/PNG/WebP. + +**Architecture:** The SurfaceCommand callback intercepts H.264 data and stores it on the display layer. During the normal frame flush cycle (`guac_display_plan_apply`), the H.264 data is sent to clients as a custom `h264` instruction before worker threads start encoding. All IMG operations for the H.264 layer are skipped, eliminating the decode→re-encode overhead. + +This approach avoids the socket contention issue that occurred when H.264 was sent directly from FreeRDP's SurfaceCommand callback thread, which raced with guac_display's worker threads writing to the same socket. + +**Files patched:** + +| File | Fix | +|------|-----| +| `src/libguac/display-priv.h` | Add H.264 buffer fields to `guac_display_layer` (data, length, keyframe, rect) | +| `src/libguac/guacamole/display.h` | Add `guac_display_layer_set_h264()` public API | +| `src/libguac/display-layer.c` | Implement `guac_display_layer_set_h264()` with lock management | +| `src/libguac/display-layer-list.c` | Free H.264 data in layer cleanup | +| `src/libguac/display-plan.c` | Send H.264 data during plan apply, skip IMG ops for H.264 layers | +| `src/protocols/rdp/channels/rdpgfx.c` | Wrap SurfaceCommand to store H.264 on display layer after GDI decode | +| `src/protocols/rdp/settings.c` | Enable GfxH264 and GfxAVC444 in FreeRDP settings | + +**Requires:** RDP server with H.264 support (xrdp with x264, or Windows with AVC hardware encoder). Browser must support WebCodecs VideoDecoder (Chrome/Edge 94+, Firefox 130+). + ## Applying patches Patches are applied automatically by all build scripts (`build-deb.sh`, `build-rpm.sh`, `install.sh`, `dev.sh`, `Dockerfile`). To apply manually: diff --git a/src/api.rs b/src/api.rs index 90617ea..fc023e7 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1688,6 +1688,7 @@ pub async fn ab_connect_entry( enable_gfx: ab_entry.enable_gfx, enable_desktop_composition: ab_entry.enable_desktop_composition, force_lossless: ab_entry.force_lossless, + enable_h264: ab_entry.enable_h264, }; let proxies = trusted.map(|Extension(t)| t.0).unwrap_or_default(); @@ -3060,6 +3061,7 @@ pub async fn quick_connect( enable_gfx: ab_entry.enable_gfx, enable_desktop_composition: ab_entry.enable_desktop_composition, force_lossless: ab_entry.force_lossless, + enable_h264: ab_entry.enable_h264, }; tracing::info!( @@ -3151,6 +3153,7 @@ pub async fn quick_connect( enable_gfx: None, enable_desktop_composition: None, force_lossless: None, + enable_h264: None, }; match manager.create_session(create_req, admin_name).await { diff --git a/src/guacd.rs b/src/guacd.rs index f808d03..3439d29 100644 --- a/src/guacd.rs +++ b/src/guacd.rs @@ -79,6 +79,8 @@ pub struct RdpParams { pub enable_desktop_composition: bool, /// Force lossless encoding (PNG only). Better for text-heavy workloads. pub force_lossless: bool, + /// Enable H.264 passthrough. Raw H.264 NAL units sent to browser WebCodecs decoder. + pub enable_h264: bool, } /// Connection parameters — SSH, VNC, or RDP. @@ -260,6 +262,7 @@ pub async fn connect_and_handshake( "kerberos-cache" => p.kerberos_cache.clone().unwrap_or_default(), "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(), "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(), @@ -272,12 +275,12 @@ pub async fn connect_and_handshake( .collect(); // Send handshake instructions: size, audio, video, image, timezone, connect - let (width, height, dpi) = match params { - ConnectionParams::Ssh(p) => (p.width, p.height, p.dpi), - ConnectionParams::Vnc(p) => (p.width, p.height, p.dpi), - ConnectionParams::Rdp(p) => (p.width, p.height, p.dpi), + let (width, height, dpi, h264) = match ¶ms { + ConnectionParams::Ssh(p) => (p.width, p.height, p.dpi, false), + ConnectionParams::Vnc(p) => (p.width, p.height, p.dpi, false), + ConnectionParams::Rdp(p) => (p.width, p.height, p.dpi, p.enable_h264), }; - send_handshake(&mut stream, width, height, dpi).await?; + send_handshake(&mut stream, width, height, dpi, h264).await?; let connect = Instruction::new("connect", arg_values); stream @@ -367,8 +370,8 @@ pub async fn join_connection( }) .collect(); - // Send handshake instructions - send_handshake(&mut stream, width, height, dpi).await?; + // Send handshake instructions (joining user — h264 inherited from session) + send_handshake(&mut stream, width, height, dpi, false).await?; let connect = Instruction::new("connect", arg_values); stream @@ -432,14 +435,21 @@ async fn send_handshake( width: u32, height: u32, dpi: u32, + enable_h264: bool, ) -> Result<(), GuacdError> { + let video_args = if enable_h264 { + vec!["video/h264".into()] + } else { + vec![] + }; + let instructions = [ Instruction::new( "size", vec![width.to_string(), height.to_string(), dpi.to_string()], ), Instruction::new("audio", vec!["audio/L16".into(), "audio/L8".into()]), - Instruction::new("video", vec!["video/h264".into()]), + Instruction::new("video", video_args), Instruction::new( "image", vec!["image/png".into(), "image/jpeg".into(), "image/webp".into()], diff --git a/src/import.rs b/src/import.rs index 2f2ff6a..853b567 100644 --- a/src/import.rs +++ b/src/import.rs @@ -108,6 +108,7 @@ pub async fn cmd_import_guacamole( enable_gfx: None, enable_desktop_composition: None, force_lossless: None, + enable_h264: None, }; // Build entry name: group prefix + sanitized connection name @@ -644,6 +645,7 @@ mod tests { enable_gfx: None, enable_desktop_composition: None, force_lossless: None, + enable_h264: None, }; let mut entries = vec![ ("web".into(), entry()), diff --git a/src/session.rs b/src/session.rs index c213925..f63b709 100644 --- a/src/session.rs +++ b/src/session.rs @@ -101,6 +101,8 @@ pub struct CreateSessionRequest { pub enable_desktop_composition: Option, /// Force lossless encoding (PNG only) for RDP. pub force_lossless: Option, + /// Enable H.264 passthrough for RDP. + pub enable_h264: Option, } /// Session status in the lifecycle. @@ -513,6 +515,7 @@ impl SessionManager { enable_gfx: req.enable_gfx.unwrap_or(false), enable_desktop_composition: req.enable_desktop_composition.unwrap_or(false), force_lossless: req.force_lossless.unwrap_or(false), + enable_h264: req.enable_h264.unwrap_or(false), })); ( params, diff --git a/src/vault.rs b/src/vault.rs index ea32124..5497dea 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -147,6 +147,10 @@ pub struct AddressBookEntry { /// Force lossless encoding (PNG only). Better for text-heavy, low-bandwidth sessions. #[serde(default, skip_serializing_if = "Option::is_none")] pub force_lossless: Option, + /// Enable H.264 passthrough. Passes raw H.264 from xrdp to browser WebCodecs decoder. + /// 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, } impl AddressBookEntry { @@ -248,6 +252,9 @@ pub struct EntryInfo { /// Force lossless encoding (PNG only). #[serde(skip_serializing_if = "Option::is_none")] pub force_lossless: Option, + /// Enable H.264 passthrough. + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_h264: Option, } impl From<(&str, &AddressBookEntry)> for EntryInfo { @@ -295,6 +302,7 @@ impl From<(&str, &AddressBookEntry)> for EntryInfo { enable_gfx: e.enable_gfx, enable_desktop_composition: e.enable_desktop_composition, force_lossless: e.force_lossless, + enable_h264: e.enable_h264, } } } @@ -1618,4 +1626,24 @@ mod tests { assert_eq!(resolved.username.as_deref(), Some("alice")); assert_eq!(resolved.password.as_deref(), Some("literal_pass")); } + + #[test] + fn test_enable_h264_serde_roundtrip() { + // With enable_h264 set + let json = r#"{"type":"rdp","hostname":"test","enable_gfx":true,"enable_h264":true}"#; + let entry: AddressBookEntry = serde_json::from_str(json).unwrap(); + assert_eq!(entry.enable_h264, Some(true)); + let out = serde_json::to_string(&entry).unwrap(); + assert!(out.contains("\"enable_h264\":true")); + + // Without enable_h264 (defaults to None) + let json2 = r#"{"type":"rdp","hostname":"test","enable_gfx":true}"#; + let entry2: AddressBookEntry = serde_json::from_str(json2).unwrap(); + assert_eq!(entry2.enable_h264, None); + + // Explicit false + let json3 = r#"{"type":"rdp","hostname":"test","enable_h264":false}"#; + let entry3: AddressBookEntry = serde_json::from_str(json3).unwrap(); + assert_eq!(entry3.enable_h264, Some(false)); + } } diff --git a/static/addressbook.html b/static/addressbook.html index e8d289d..e8240d6 100644 --- a/static/addressbook.html +++ b/static/addressbook.html @@ -548,18 +548,22 @@ Video Performance @@ -1709,6 +1713,7 @@ document.getElementById('em-enable-gfx').checked = false; document.getElementById('em-enable-desktop-comp').checked = false; document.getElementById('em-force-lossless').checked = false; + document.getElementById('em-enable-h264').checked = false; document.getElementById('em-video-perf-fields').style.display = 'none'; document.getElementById('em-video-perf-arrow').innerHTML = '▶'; document.getElementById('em-disable-copy').checked = false; @@ -1860,7 +1865,8 @@ document.getElementById('em-enable-gfx').checked = !!entryData.enable_gfx; document.getElementById('em-enable-desktop-comp').checked = !!entryData.enable_desktop_comp; document.getElementById('em-force-lossless').checked = !!entryData.force_lossless; - if (entryData.enable_gfx || entryData.enable_desktop_comp || entryData.force_lossless) { + document.getElementById('em-enable-h264').checked = !!entryData.enable_h264; + if (entryData.enable_gfx || entryData.enable_desktop_comp || entryData.force_lossless || entryData.enable_h264) { document.getElementById('em-video-perf-fields').style.display = ''; document.getElementById('em-video-perf-arrow').innerHTML = '▼'; } @@ -1993,6 +1999,7 @@ if (document.getElementById('em-enable-gfx').checked) entry.enable_gfx = true; if (document.getElementById('em-enable-desktop-comp').checked) entry.enable_desktop_comp = true; if (document.getElementById('em-force-lossless').checked) entry.force_lossless = true; + if (document.getElementById('em-enable-h264').checked) entry.enable_h264 = true; } // Clipboard settings (all types) diff --git a/static/guac/H264Decoder.js b/static/guac/H264Decoder.js index fe6aab8..2f5d279 100644 --- a/static/guac/H264Decoder.js +++ b/static/guac/H264Decoder.js @@ -116,8 +116,8 @@ Guacamole.H264Decoder = function H264Decoder(display) { } }); - // Configure for H.264 Constrained Baseline (most compatible) - // The actual profile/level will be negotiated by the RDP server + // Configure for H.264 Constrained Baseline + // Let the decoder auto-detect level from the SPS NAL in the stream decoder.configure({ codec: 'avc1.42001f', // Baseline profile, level 3.1 optimizeForLatency: true