From 22f45960cbd00f42f130f1f9077a1e9902bbbae6 Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Thu, 2 Jul 2026 08:42:56 -0300 Subject: [PATCH 01/33] GUACAMOLE-261: Add client-side support for the SPICE protocol Ports necouchman's apache/guacamole-client#761, extended to match the full guacamole-server SPICE parameter set: adds pubkey, proxy, tls-port, swap-red-blue, preferred-compression, enable-audio-input, full file-transfer group, and the complete keyboard layout list (da-dk-qwerty through tr-tr-qwerty, 20 layouts). Registers "spice" in LocalEnvironment.KNOWN_PROTOCOLS (alphabetically between rdp and ssh), adds spice.json protocol form definition with 11 connection form groups (network, authentication, security, display, keyboard, clipboard, audio, file-transfer, sftp, recording, wol), and adds all corresponding PROTOCOL_SPICE translation keys to en.json. Co-authored-by: Virtually Nick --- .../environment/LocalEnvironment.java | 1 + .../org/apache/guacamole/protocols/spice.json | 373 ++++++++++++++++++ .../main/frontend/src/translations/en.json | 115 ++++++ 3 files changed, 489 insertions(+) create mode 100644 guacamole-ext/src/main/resources/org/apache/guacamole/protocols/spice.json diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/environment/LocalEnvironment.java b/guacamole-ext/src/main/java/org/apache/guacamole/environment/LocalEnvironment.java index d323e2bfd1..f94c698455 100644 --- a/guacamole-ext/src/main/java/org/apache/guacamole/environment/LocalEnvironment.java +++ b/guacamole-ext/src/main/java/org/apache/guacamole/environment/LocalEnvironment.java @@ -61,6 +61,7 @@ public class LocalEnvironment implements Environment { private static final String[] KNOWN_PROTOCOLS = new String[] { "kubernetes", "rdp", + "spice", "ssh", "telnet", "vnc", diff --git a/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/spice.json b/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/spice.json new file mode 100644 index 0000000000..b76cbc4f34 --- /dev/null +++ b/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/spice.json @@ -0,0 +1,373 @@ +{ + "name" : "spice", + "connectionForms" : [ + + { + "name" : "network", + "fields" : [ + { + "name" : "hostname", + "type" : "TEXT" + }, + { + "name" : "port", + "type" : "NUMERIC" + }, + { + "name" : "tls-port", + "type" : "NUMERIC" + }, + { + "name" : "tls", + "type" : "BOOLEAN", + "options" : [ "true" ] + }, + { + "name" : "proxy", + "type" : "TEXT" + } + ] + }, + + { + "name" : "authentication", + "fields" : [ + { + "name" : "username", + "type" : "USERNAME" + }, + { + "name" : "password", + "type" : "PASSWORD" + } + ] + }, + + { + "name" : "security", + "fields" : [ + { + "name" : "ca-cert", + "type" : "MULTILINE" + }, + { + "name" : "cert-subject", + "type" : "TEXT" + }, + { + "name" : "pubkey", + "type" : "MULTILINE" + }, + { + "name" : "ignore-cert", + "type" : "BOOLEAN", + "options" : [ "true" ] + } + ] + }, + + { + "name" : "display", + "fields" : [ + { + "name" : "color-depth", + "type" : "ENUM", + "options" : [ "", "8", "16", "24", "32" ] + }, + { + "name" : "swap-red-blue", + "type" : "BOOLEAN", + "options" : [ "true" ] + }, + { + "name" : "preferred-compression", + "type" : "ENUM", + "options" : [ "", "off", "auto-glz", "auto-lz", "quic", "glz", "lz", "lz4" ] + }, + { + "name" : "read-only", + "type" : "BOOLEAN", + "options" : [ "true" ] + }, + { + "name" : "disable-display-resize", + "type" : "BOOLEAN", + "options" : [ "true" ] + } + ] + }, + + { + "name" : "keyboard", + "fields" : [ + { + "name" : "server-layout", + "type" : "ENUM", + "options" : [ + "", + "da-dk-qwerty", + "de-ch-qwertz", + "de-de-qwertz", + "en-gb-qwerty", + "en-us-qwerty", + "es-es-qwerty", + "es-latam-qwerty", + "failsafe", + "fr-be-azerty", + "fr-ca-qwerty", + "fr-ch-qwertz", + "fr-fr-azerty", + "hu-hu-qwertz", + "it-it-qwerty", + "ja-jp-qwerty", + "no-no-qwerty", + "pl-pl-qwerty", + "pt-br-qwerty", + "sv-se-qwerty", + "tr-tr-qwerty" + ] + } + ] + }, + + { + "name" : "clipboard", + "fields" : [ + { + "name" : "disable-copy", + "type" : "BOOLEAN", + "options" : [ "true" ] + }, + { + "name" : "disable-paste", + "type" : "BOOLEAN", + "options" : [ "true" ] + }, + { + "name" : "disable-clipboard", + "type" : "BOOLEAN", + "options" : [ "true" ] + }, + { + "name" : "clipboard-buffer-size", + "type" : "NUMERIC" + } + ] + }, + + { + "name" : "audio", + "fields" : [ + { + "name" : "enable-audio", + "type" : "BOOLEAN", + "options" : [ "true" ] + }, + { + "name" : "enable-audio-input", + "type" : "BOOLEAN", + "options" : [ "true" ] + } + ] + }, + + { + "name" : "file-transfer", + "fields" : [ + { + "name" : "enable-drive", + "type" : "BOOLEAN", + "options" : [ "true" ] + }, + { + "name" : "drive-path", + "type" : "TEXT" + }, + { + "name" : "drive-read-only", + "type" : "BOOLEAN", + "options" : [ "true" ] + }, + { + "name" : "file-transfer", + "type" : "BOOLEAN", + "options" : [ "true" ] + }, + { + "name" : "file-directory", + "type" : "TEXT" + }, + { + "name" : "file-transfer-create-folder", + "type" : "BOOLEAN", + "options" : [ "true" ] + }, + { + "name" : "file-transfer-ro", + "type" : "BOOLEAN", + "options" : [ "true" ] + }, + { + "name" : "disable-download", + "type" : "BOOLEAN", + "options" : [ "true" ] + }, + { + "name" : "disable-upload", + "type" : "BOOLEAN", + "options" : [ "true" ] + } + ] + }, + + { + "name" : "sftp", + "fields" : [ + { + "name" : "enable-sftp", + "type" : "BOOLEAN", + "options" : [ "true" ] + }, + { + "name" : "sftp-hostname", + "type" : "TEXT" + }, + { + "name" : "sftp-host-key", + "type" : "MULTILINE" + }, + { + "name" : "sftp-port", + "type" : "NUMERIC" + }, + { + "name" : "sftp-timeout", + "type" : "NUMERIC" + }, + { + "name" : "sftp-username", + "type" : "USERNAME" + }, + { + "name" : "sftp-password", + "type" : "PASSWORD" + }, + { + "name" : "sftp-private-key", + "type" : "MULTILINE" + }, + { + "name" : "sftp-passphrase", + "type" : "PASSWORD" + }, + { + "name" : "sftp-public-key", + "type" : "MULTILINE" + }, + { + "name" : "sftp-directory", + "type" : "TEXT" + }, + { + "name" : "sftp-root-directory", + "type" : "TEXT" + }, + { + "name" : "sftp-server-alive-interval", + "type" : "NUMERIC" + }, + { + "name" : "sftp-disable-download", + "type" : "BOOLEAN", + "options" : [ "true" ] + }, + { + "name" : "sftp-disable-upload", + "type" : "BOOLEAN", + "options" : [ "true" ] + } + ] + }, + + { + "name" : "recording", + "fields" : [ + { + "name" : "recording-path", + "type" : "TEXT" + }, + { + "name" : "recording-name", + "type" : "TEXT" + }, + { + "name" : "recording-exclude-output", + "type" : "BOOLEAN", + "options" : [ "true" ] + }, + { + "name" : "recording-exclude-mouse", + "type" : "BOOLEAN", + "options" : [ "true" ] + }, + { + "name" : "recording-include-keys", + "type" : "BOOLEAN", + "options" : [ "true" ] + }, + { + "name" : "create-recording-path", + "type" : "BOOLEAN", + "options" : [ "true" ] + }, + { + "name" : "recording-write-existing", + "type" : "BOOLEAN", + "options" : [ "true" ] + } + ] + }, + + { + "name" : "wol", + "fields" : [ + { + "name" : "wol-send-packet", + "type" : "BOOLEAN", + "options" : [ "true" ] + }, + { + "name" : "wol-mac-addr", + "type" : "TEXT" + }, + { + "name" : "wol-broadcast-addr", + "type" : "TEXT" + }, + { + "name" : "wol-udp-port", + "type" : "NUMERIC" + }, + { + "name" : "wol-wait-time", + "type" : "NUMERIC" + } + ] + } + + ], + "sharingProfileForms" : [ + + { + "name" : "display", + "fields" : [ + { + "name" : "read-only", + "type" : "BOOLEAN", + "options" : [ "true" ] + } + ] + } + + ] +} diff --git a/guacamole/src/main/frontend/src/translations/en.json b/guacamole/src/main/frontend/src/translations/en.json index 27f24a9762..3ef04c4046 100644 --- a/guacamole/src/main/frontend/src/translations/en.json +++ b/guacamole/src/main/frontend/src/translations/en.json @@ -764,6 +764,121 @@ }, + "PROTOCOL_SPICE" : { + + "FIELD_HEADER_CA_CERT" : "CA certificates (Base64):", + "FIELD_HEADER_CERT_SUBJECT" : "Certificate subject:", + "FIELD_HEADER_CLIPBOARD_BUFFER_SIZE" : "Clipboard buffer size:", + "FIELD_HEADER_COLOR_DEPTH" : "Color depth:", + "FIELD_HEADER_CREATE_RECORDING_PATH" : "Automatically create recording path:", + "FIELD_HEADER_DISABLE_CLIPBOARD" : "Disable clipboard:", + "FIELD_HEADER_DISABLE_COPY" : "Disable copying from remote desktop:", + "FIELD_HEADER_DISABLE_DISPLAY_RESIZE" : "Disable resize of remote display:", + "FIELD_HEADER_DISABLE_DOWNLOAD" : "Disable file download:", + "FIELD_HEADER_DISABLE_PASTE" : "Disable pasting from client:", + "FIELD_HEADER_DISABLE_UPLOAD" : "Disable file upload:", + "FIELD_HEADER_DRIVE_PATH" : "Drive path:", + "FIELD_HEADER_DRIVE_READ_ONLY" : "Drive read-only:", + "FIELD_HEADER_ENABLE_AUDIO" : "Enable audio:", + "FIELD_HEADER_ENABLE_AUDIO_INPUT" : "Enable audio input (microphone):", + "FIELD_HEADER_ENABLE_DRIVE" : "Enable drive:", + "FIELD_HEADER_ENABLE_SFTP" : "Enable SFTP:", + "FIELD_HEADER_FILE_DIRECTORY" : "Directory to share:", + "FIELD_HEADER_FILE_TRANSFER" : "Enable file transfer:", + "FIELD_HEADER_FILE_TRANSFER_CREATE_FOLDER": "Automatically create file transfer folder:", + "FIELD_HEADER_FILE_TRANSFER_RO" : "Share directory read-only:", + "FIELD_HEADER_HOSTNAME" : "Hostname:", + "FIELD_HEADER_IGNORE_CERT" : "Ignore server certificate:", + "FIELD_HEADER_PASSWORD" : "Password:", + "FIELD_HEADER_PORT" : "Port:", + "FIELD_HEADER_PREFERRED_COMPRESSION" : "Preferred compression:", + "FIELD_HEADER_PROXY" : "Proxy server:", + "FIELD_HEADER_PUBKEY" : "Public key (Base64):", + "FIELD_HEADER_READ_ONLY" : "Read-only:", + "FIELD_HEADER_RECORDING_EXCLUDE_MOUSE" : "Exclude mouse:", + "FIELD_HEADER_RECORDING_EXCLUDE_OUTPUT" : "Exclude graphics/streams:", + "FIELD_HEADER_RECORDING_INCLUDE_KEYS" : "Include key events:", + "FIELD_HEADER_RECORDING_NAME" : "Recording name:", + "FIELD_HEADER_RECORDING_PATH" : "Recording path:", + "FIELD_HEADER_RECORDING_WRITE_EXISTING" : "@:APP.FIELD_HEADER_RECORDING_WRITE_EXISTING", + "FIELD_HEADER_SERVER_LAYOUT" : "Keyboard layout:", + "FIELD_HEADER_SFTP_DIRECTORY" : "Default upload directory:", + "FIELD_HEADER_SFTP_DISABLE_DOWNLOAD" : "Disable file download:", + "FIELD_HEADER_SFTP_DISABLE_UPLOAD" : "Disable file upload:", + "FIELD_HEADER_SFTP_HOST_KEY" : "Public host key (Base64):", + "FIELD_HEADER_SFTP_HOSTNAME" : "Hostname:", + "FIELD_HEADER_SFTP_PASSPHRASE" : "Passphrase:", + "FIELD_HEADER_SFTP_PASSWORD" : "Password:", + "FIELD_HEADER_SFTP_PORT" : "Port:", + "FIELD_HEADER_SFTP_PRIVATE_KEY" : "Private key:", + "FIELD_HEADER_SFTP_PUBLIC_KEY" : "Public key:", + "FIELD_HEADER_SFTP_ROOT_DIRECTORY" : "File browser root directory:", + "FIELD_HEADER_SFTP_SERVER_ALIVE_INTERVAL" : "SFTP keepalive interval:", + "FIELD_HEADER_SFTP_TIMEOUT" : "SFTP connection timeout:", + "FIELD_HEADER_SFTP_USERNAME" : "Username:", + "FIELD_HEADER_SWAP_RED_BLUE" : "Swap red/blue components:", + "FIELD_HEADER_TLS" : "Enable TLS connection:", + "FIELD_HEADER_TLS_PORT" : "TLS port:", + "FIELD_HEADER_USERNAME" : "Username:", + "FIELD_HEADER_WOL_BROADCAST_ADDR" : "Broadcast address for WoL packet:", + "FIELD_HEADER_WOL_MAC_ADDR" : "MAC address of the remote host:", + "FIELD_HEADER_WOL_SEND_PACKET" : "Send WoL packet:", + "FIELD_HEADER_WOL_UDP_PORT" : "UDP port for WoL packet:", + "FIELD_HEADER_WOL_WAIT_TIME" : "Host boot wait time:", + + "FIELD_OPTION_COLOR_DEPTH_8" : "256 color", + "FIELD_OPTION_COLOR_DEPTH_16" : "Low color (16-bit)", + "FIELD_OPTION_COLOR_DEPTH_24" : "True color (24-bit)", + "FIELD_OPTION_COLOR_DEPTH_32" : "True color (32-bit)", + "FIELD_OPTION_COLOR_DEPTH_EMPTY" : "", + + "FIELD_OPTION_PREFERRED_COMPRESSION_AUTO_GLZ" : "Auto GLZ", + "FIELD_OPTION_PREFERRED_COMPRESSION_AUTO_LZ" : "Auto LZ", + "FIELD_OPTION_PREFERRED_COMPRESSION_EMPTY" : "", + "FIELD_OPTION_PREFERRED_COMPRESSION_GLZ" : "GLZ", + "FIELD_OPTION_PREFERRED_COMPRESSION_LZ" : "LZ", + "FIELD_OPTION_PREFERRED_COMPRESSION_LZ4" : "LZ4", + "FIELD_OPTION_PREFERRED_COMPRESSION_OFF" : "Off", + "FIELD_OPTION_PREFERRED_COMPRESSION_QUIC" : "QUIC", + + "FIELD_OPTION_SERVER_LAYOUT_DA_DK_QWERTY" : "Danish (Qwerty)", + "FIELD_OPTION_SERVER_LAYOUT_DE_CH_QWERTZ" : "Swiss German (Qwertz)", + "FIELD_OPTION_SERVER_LAYOUT_DE_DE_QWERTZ" : "German (Qwertz)", + "FIELD_OPTION_SERVER_LAYOUT_EMPTY" : "", + "FIELD_OPTION_SERVER_LAYOUT_EN_GB_QWERTY" : "UK English (Qwerty)", + "FIELD_OPTION_SERVER_LAYOUT_EN_US_QWERTY" : "US English (Qwerty)", + "FIELD_OPTION_SERVER_LAYOUT_ES_ES_QWERTY" : "Spanish (Qwerty)", + "FIELD_OPTION_SERVER_LAYOUT_ES_LATAM_QWERTY" : "Latin American (Qwerty)", + "FIELD_OPTION_SERVER_LAYOUT_FAILSAFE" : "Unicode", + "FIELD_OPTION_SERVER_LAYOUT_FR_BE_AZERTY" : "Belgian French (Azerty)", + "FIELD_OPTION_SERVER_LAYOUT_FR_CA_QWERTY" : "Canadian French (Qwerty)", + "FIELD_OPTION_SERVER_LAYOUT_FR_CH_QWERTZ" : "Swiss French (Qwertz)", + "FIELD_OPTION_SERVER_LAYOUT_FR_FR_AZERTY" : "French (Azerty)", + "FIELD_OPTION_SERVER_LAYOUT_HU_HU_QWERTZ" : "Hungarian (Qwertz)", + "FIELD_OPTION_SERVER_LAYOUT_IT_IT_QWERTY" : "Italian (Qwerty)", + "FIELD_OPTION_SERVER_LAYOUT_JA_JP_QWERTY" : "Japanese (Qwerty)", + "FIELD_OPTION_SERVER_LAYOUT_NO_NO_QWERTY" : "Norwegian (Qwerty)", + "FIELD_OPTION_SERVER_LAYOUT_PL_PL_QWERTY" : "Polish (Qwerty)", + "FIELD_OPTION_SERVER_LAYOUT_PT_BR_QWERTY" : "Portuguese Brazilian (Qwerty)", + "FIELD_OPTION_SERVER_LAYOUT_SV_SE_QWERTY" : "Swedish (Qwerty)", + "FIELD_OPTION_SERVER_LAYOUT_TR_TR_QWERTY" : "Turkish-Q (Qwerty)", + + "NAME" : "SPICE", + + "SECTION_HEADER_AUDIO" : "Audio", + "SECTION_HEADER_AUTHENTICATION": "Authentication", + "SECTION_HEADER_CLIPBOARD" : "Clipboard", + "SECTION_HEADER_DISPLAY" : "Display", + "SECTION_HEADER_FILE_TRANSFER" : "File Transfer", + "SECTION_HEADER_KEYBOARD" : "Keyboard", + "SECTION_HEADER_NETWORK" : "Network", + "SECTION_HEADER_RECORDING" : "Screen Recording", + "SECTION_HEADER_SECURITY" : "Security", + "SECTION_HEADER_SFTP" : "SFTP", + "SECTION_HEADER_WOL" : "Wake-on-LAN (WoL)" + + }, + "PROTOCOL_SSH" : { "FIELD_HEADER_BACKSPACE" : "Backspace key sends:", From ade8f4043f95c94a46985513f5f6c2cddb641fa0 Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Thu, 2 Jul 2026 12:57:48 -0300 Subject: [PATCH 02/33] GUACAMOLE-261: add disable-audio-opus parameter to the SPICE form Adds the disable-audio-opus boolean (audio section) and its translation, matching the guacamole-server parameter that forces the legacy CELT audio codec instead of Opus. --- .../main/resources/org/apache/guacamole/protocols/spice.json | 5 +++++ guacamole/src/main/frontend/src/translations/en.json | 1 + 2 files changed, 6 insertions(+) diff --git a/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/spice.json b/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/spice.json index b76cbc4f34..8ff26aebe8 100644 --- a/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/spice.json +++ b/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/spice.json @@ -167,6 +167,11 @@ "name" : "enable-audio-input", "type" : "BOOLEAN", "options" : [ "true" ] + }, + { + "name" : "disable-audio-opus", + "type" : "BOOLEAN", + "options" : [ "true" ] } ] }, diff --git a/guacamole/src/main/frontend/src/translations/en.json b/guacamole/src/main/frontend/src/translations/en.json index 3ef04c4046..5d1b563618 100644 --- a/guacamole/src/main/frontend/src/translations/en.json +++ b/guacamole/src/main/frontend/src/translations/en.json @@ -771,6 +771,7 @@ "FIELD_HEADER_CLIPBOARD_BUFFER_SIZE" : "Clipboard buffer size:", "FIELD_HEADER_COLOR_DEPTH" : "Color depth:", "FIELD_HEADER_CREATE_RECORDING_PATH" : "Automatically create recording path:", + "FIELD_HEADER_DISABLE_AUDIO_OPUS" : "Disable Opus audio codec (use legacy CELT):", "FIELD_HEADER_DISABLE_CLIPBOARD" : "Disable clipboard:", "FIELD_HEADER_DISABLE_COPY" : "Disable copying from remote desktop:", "FIELD_HEADER_DISABLE_DISPLAY_RESIZE" : "Disable resize of remote display:", From 0462be7932d2b05faa71db7caff5ffe1aa560365 Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Thu, 2 Jul 2026 14:38:03 -0300 Subject: [PATCH 03/33] GUACAMOLE-261: clarify disable-audio-opus field label On modern SPICE servers (which dropped CELT) disabling Opus yields raw/lossless audio, not CELT; reword the label to reflect that rather than implying CELT. --- guacamole/src/main/frontend/src/translations/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/guacamole/src/main/frontend/src/translations/en.json b/guacamole/src/main/frontend/src/translations/en.json index 5d1b563618..72c15f45d4 100644 --- a/guacamole/src/main/frontend/src/translations/en.json +++ b/guacamole/src/main/frontend/src/translations/en.json @@ -771,7 +771,7 @@ "FIELD_HEADER_CLIPBOARD_BUFFER_SIZE" : "Clipboard buffer size:", "FIELD_HEADER_COLOR_DEPTH" : "Color depth:", "FIELD_HEADER_CREATE_RECORDING_PATH" : "Automatically create recording path:", - "FIELD_HEADER_DISABLE_AUDIO_OPUS" : "Disable Opus audio codec (use legacy CELT):", + "FIELD_HEADER_DISABLE_AUDIO_OPUS" : "Disable Opus audio codec (use raw/lossless where supported):", "FIELD_HEADER_DISABLE_CLIPBOARD" : "Disable clipboard:", "FIELD_HEADER_DISABLE_COPY" : "Disable copying from remote desktop:", "FIELD_HEADER_DISABLE_DISPLAY_RESIZE" : "Disable resize of remote display:", From 0e7747ad9e8245f02ec034406979b35f35f7feff Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Fri, 3 Jul 2026 22:29:14 -0300 Subject: [PATCH 04/33] GUACAMOLE-261: add preferred-video-codec parameter to the SPICE form --- .../resources/org/apache/guacamole/protocols/spice.json | 5 +++++ guacamole/src/main/frontend/src/translations/en.json | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/spice.json b/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/spice.json index 8ff26aebe8..73434ea7ae 100644 --- a/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/spice.json +++ b/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/spice.json @@ -84,6 +84,11 @@ "type" : "ENUM", "options" : [ "", "off", "auto-glz", "auto-lz", "quic", "glz", "lz", "lz4" ] }, + { + "name" : "preferred-video-codec", + "type" : "ENUM", + "options" : [ "", "h264", "vp9", "vp8", "mjpeg" ] + }, { "name" : "read-only", "type" : "BOOLEAN", diff --git a/guacamole/src/main/frontend/src/translations/en.json b/guacamole/src/main/frontend/src/translations/en.json index 72c15f45d4..de760058de 100644 --- a/guacamole/src/main/frontend/src/translations/en.json +++ b/guacamole/src/main/frontend/src/translations/en.json @@ -793,6 +793,7 @@ "FIELD_HEADER_PASSWORD" : "Password:", "FIELD_HEADER_PORT" : "Port:", "FIELD_HEADER_PREFERRED_COMPRESSION" : "Preferred compression:", + "FIELD_HEADER_PREFERRED_VIDEO_CODEC" : "Preferred video codec:", "FIELD_HEADER_PROXY" : "Proxy server:", "FIELD_HEADER_PUBKEY" : "Public key (Base64):", "FIELD_HEADER_READ_ONLY" : "Read-only:", @@ -842,6 +843,12 @@ "FIELD_OPTION_PREFERRED_COMPRESSION_OFF" : "Off", "FIELD_OPTION_PREFERRED_COMPRESSION_QUIC" : "QUIC", + "FIELD_OPTION_PREFERRED_VIDEO_CODEC_EMPTY" : "", + "FIELD_OPTION_PREFERRED_VIDEO_CODEC_H264" : "H.264", + "FIELD_OPTION_PREFERRED_VIDEO_CODEC_MJPEG" : "MJPEG", + "FIELD_OPTION_PREFERRED_VIDEO_CODEC_VP8" : "VP8", + "FIELD_OPTION_PREFERRED_VIDEO_CODEC_VP9" : "VP9", + "FIELD_OPTION_SERVER_LAYOUT_DA_DK_QWERTY" : "Danish (Qwerty)", "FIELD_OPTION_SERVER_LAYOUT_DE_CH_QWERTZ" : "Swiss German (Qwertz)", "FIELD_OPTION_SERVER_LAYOUT_DE_DE_QWERTZ" : "German (Qwertz)", From 1462bbc0255045a100e89efb676b955d1cba7acc Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Sat, 4 Jul 2026 09:03:54 -0300 Subject: [PATCH 05/33] GUACAMOLE-288: Add SPICE multi-monitor connection parameter Adds the "secondary-monitors" parameter to the SPICE protocol so the maximum number of secondary monitors can be configured per connection. This is the SPICE counterpart to the parameter added for RDP in the upstream multi-monitor work (apache/guacamole-client#1061 by @corentin-soriano); the browser-side per-monitor window handling from that PR is protocol-agnostic and applies to SPICE unchanged. --- .../main/resources/org/apache/guacamole/protocols/spice.json | 4 ++++ guacamole/src/main/frontend/src/translations/en.json | 1 + 2 files changed, 5 insertions(+) diff --git a/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/spice.json b/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/spice.json index 73434ea7ae..93249608fe 100644 --- a/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/spice.json +++ b/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/spice.json @@ -98,6 +98,10 @@ "name" : "disable-display-resize", "type" : "BOOLEAN", "options" : [ "true" ] + }, + { + "name" : "secondary-monitors", + "type" : "NUMERIC" } ] }, diff --git a/guacamole/src/main/frontend/src/translations/en.json b/guacamole/src/main/frontend/src/translations/en.json index de760058de..9188b22249 100644 --- a/guacamole/src/main/frontend/src/translations/en.json +++ b/guacamole/src/main/frontend/src/translations/en.json @@ -803,6 +803,7 @@ "FIELD_HEADER_RECORDING_NAME" : "Recording name:", "FIELD_HEADER_RECORDING_PATH" : "Recording path:", "FIELD_HEADER_RECORDING_WRITE_EXISTING" : "@:APP.FIELD_HEADER_RECORDING_WRITE_EXISTING", + "FIELD_HEADER_SECONDARY_MONITORS" : "Maximum secondary monitors:", "FIELD_HEADER_SERVER_LAYOUT" : "Keyboard layout:", "FIELD_HEADER_SFTP_DIRECTORY" : "Default upload directory:", "FIELD_HEADER_SFTP_DISABLE_DOWNLOAD" : "Disable file download:", From c5ad7939f14c5fc5f328e9bd5b0dd0a55b5aa058 Mon Sep 17 00:00:00 2001 From: Corentin SORIANO Date: Tue, 29 Oct 2024 19:06:44 +0100 Subject: [PATCH 06/33] GUACAMOLE-288: Add client-side settings for limiting usage of multi-monitors on each RDP connection. --- .../main/resources/org/apache/guacamole/protocols/rdp.json | 4 ++++ guacamole/src/main/frontend/src/translations/en.json | 1 + guacamole/src/main/frontend/src/translations/fr.json | 1 + 3 files changed, 6 insertions(+) diff --git a/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/rdp.json b/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/rdp.json index ad12241e06..840ab6cb89 100644 --- a/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/rdp.json +++ b/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/rdp.json @@ -176,6 +176,10 @@ "type" : "ENUM", "options" : [ "", "display-update", "reconnect" ] }, + { + "name" : "secondary-monitors", + "type" : "NUMERIC" + }, { "name" : "read-only", "type" : "BOOLEAN", diff --git a/guacamole/src/main/frontend/src/translations/en.json b/guacamole/src/main/frontend/src/translations/en.json index 9188b22249..7fa4e6aa9e 100644 --- a/guacamole/src/main/frontend/src/translations/en.json +++ b/guacamole/src/main/frontend/src/translations/en.json @@ -665,6 +665,7 @@ "FIELD_HEADER_REMOTE_APP_ARGS" : "Parameters:", "FIELD_HEADER_REMOTE_APP_DIR" : "Working directory:", "FIELD_HEADER_REMOTE_APP" : "Program:", + "FIELD_HEADER_SECONDARY_MONITORS" : "Maximum secondary monitors (needs display-update):", "FIELD_HEADER_SECURITY" : "Security mode:", "FIELD_HEADER_SERVER_LAYOUT" : "Keyboard layout:", "FIELD_HEADER_SFTP_DIRECTORY" : "Default upload directory:", diff --git a/guacamole/src/main/frontend/src/translations/fr.json b/guacamole/src/main/frontend/src/translations/fr.json index cd2dc821bf..1fb516380a 100644 --- a/guacamole/src/main/frontend/src/translations/fr.json +++ b/guacamole/src/main/frontend/src/translations/fr.json @@ -635,6 +635,7 @@ "FIELD_HEADER_REMOTE_APP_ARGS" : "Paramètres\u202F:", "FIELD_HEADER_REMOTE_APP_DIR" : "Répertoire de travail\u202F:", "FIELD_HEADER_REMOTE_APP" : "Programme\u202F:", + "FIELD_HEADER_SECONDARY_MONITORS" : "Nombre maximum de moniteurs secondaires (nécessite display-update)\u202F:", "FIELD_HEADER_SECURITY" : "Mode de Sécurité\u202F:", "FIELD_HEADER_SERVER_LAYOUT" : "Agencement clavier\u202F:", "FIELD_HEADER_SFTP_DIRECTORY" : "Répertoire d'upload par défaut\u202F:", From 8ea1584255f2553c4cddd0046e2d709c4c7257aa Mon Sep 17 00:00:00 2001 From: Corentin SORIANO Date: Sat, 16 Nov 2024 08:36:13 +0100 Subject: [PATCH 07/33] GUACAMOLE-288: Add UI button to open new window on RDP connections. --- .../client/controllers/clientController.js | 64 +++++++++++++++++++ .../frontend/src/app/client/styles/client.css | 10 ++- .../src/app/client/styles/guac-menu.css | 13 +++- .../src/app/client/templates/client.html | 10 +++ .../main/frontend/src/translations/en.json | 3 + .../main/frontend/src/translations/fr.json | 3 + 6 files changed, 100 insertions(+), 3 deletions(-) diff --git a/guacamole/src/main/frontend/src/app/client/controllers/clientController.js b/guacamole/src/main/frontend/src/app/client/controllers/clientController.js index 6a486fe9a2..fdad576b1b 100644 --- a/guacamole/src/main/frontend/src/app/client/controllers/clientController.js +++ b/guacamole/src/main/frontend/src/app/client/controllers/clientController.js @@ -724,6 +724,67 @@ angular.module('client').controller('clientController', ['$scope', '$routeParams // Set client-specific menu actions $scope.clientMenuActions = [ DISCONNECT_MENU_ACTION,FULLSCREEN_MENU_ACTION ]; + /** + * Show the section to add an additional monitor only on supported protocols + * and when the functionality is enabled. + * + * @returns {boolean} + * true when user can use multi monitor, false otherwise. + */ + $scope.showAddMonitor = function showAddMonitor() { + + // Multi monitor only supported with rdp protocol + if ($scope.focusedClient?.protocol !== 'rdp') + return false; + + // The maximum number of secondary monitors that can be added. + const secondaryMonitorsAllowed = parseInt( + $scope.focusedClient.arguments['secondary-monitors'] ?? 0); + + guacManageMonitor.setMaxSecondaryMonitors(secondaryMonitorsAllowed); + + // Secondary monitors disabled + if (secondaryMonitorsAllowed < 1 || !guacManageMonitor.supported()) + return false; + + // Disable button when the limit is reached (still visible) + $scope.disableAddMonitor = guacManageMonitor.monitorLimitReached(); + + return true; + + }; + + /** + * Action that adds an additional monitor on the RDP connection. Will open + * a new window to display the new monitor. + * Check that the client is in connected state and that the monitor limit + * is not reached before triggering the open. + */ + $scope.addMonitor = function addMonitor() { + + // Prevent opening an additional monitor when the client is not connected + if ($scope.focusedClient.clientState.connectionState !== 'CONNECTED') + return; + + // Prevent opening of too many monitors + if (guacManageMonitor.monitorLimitReached()) + return; + + // Add or remove additional monitor + guacManageMonitor.addMonitor(); + + // Close menu + $scope.menu.shown = false; + + }; + + // Init guacManageMonitor + guacManageMonitor.init(); + guacManageMonitor.menuShown = function menuShown() { + $scope.menu.shown = !$scope.menu.shown; + $scope.$apply(); + } + /** * @borrows Protocol.getNamespace */ @@ -874,6 +935,9 @@ angular.module('client').controller('clientController', ['$scope', '$routeParams // always unset fullscreen mode to not confuse user guacFullscreen.setFullscreenMode(false); + + // Close additional monitors + guacManageMonitor.closeAllMonitors(); }); }]); diff --git a/guacamole/src/main/frontend/src/app/client/styles/client.css b/guacamole/src/main/frontend/src/app/client/styles/client.css index 4d452980b7..4d122c65e1 100644 --- a/guacamole/src/main/frontend/src/app/client/styles/client.css +++ b/guacamole/src/main/frontend/src/app/client/styles/client.css @@ -134,4 +134,12 @@ body.client { .client .drop-pending .display > *{ opacity: 0.5; -} \ No newline at end of file +} + +#consent-fullscreen-button { + z-index: 1; + + position: fixed; + left: 50%; + transform: translateX(-50%); +} diff --git a/guacamole/src/main/frontend/src/app/client/styles/guac-menu.css b/guacamole/src/main/frontend/src/app/client/styles/guac-menu.css index 66df2cbd5b..3b38b10da1 100644 --- a/guacamole/src/main/frontend/src/app/client/styles/guac-menu.css +++ b/guacamole/src/main/frontend/src/app/client/styles/guac-menu.css @@ -115,7 +115,7 @@ text-align: center; } -#guac-menu #devices .device { +#guac-menu #devices .device, #guac-menu #multi-monitor .add-monitor { padding: 1em; border: 1px solid rgba(0, 0, 0, 0.125); @@ -132,7 +132,7 @@ } -#guac-menu #devices .device:hover { +#guac-menu #devices .device:hover, #guac-menu #multi-monitor .add-monitor:hover { cursor: pointer; border-color: black; } @@ -141,6 +141,15 @@ background-image: url('images/drive.svg'); } +#guac-menu #multi-monitor .add-monitor { + background-image: url('images/protocol-icons/guac-monitor.svg'); +} + +#guac-menu #multi-monitor .add-monitor.disabled { + pointer-events: none; + color: grey; +} + #guac-menu #share-links { padding: 1em; diff --git a/guacamole/src/main/frontend/src/app/client/templates/client.html b/guacamole/src/main/frontend/src/app/client/templates/client.html index 66dca3a770..f94b7c2d01 100644 --- a/guacamole/src/main/frontend/src/app/client/templates/client.html +++ b/guacamole/src/main/frontend/src/app/client/templates/client.html @@ -109,6 +109,16 @@

{{'CLIENT.SECTION_HEADER_CLIPBOARD' | translate}}

+ + + From 9c8caf6003389f868aedfb2d5f374b441f86af18 Mon Sep 17 00:00:00 2001 From: Corentin SORIANO Date: Thu, 16 Jan 2025 11:57:51 +0100 Subject: [PATCH 15/33] GUACAMOLE-288: Allow drag and drop from a browser window to another. --- guacamole-common-js/src/main/webapp/modules/Mouse.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/guacamole-common-js/src/main/webapp/modules/Mouse.js b/guacamole-common-js/src/main/webapp/modules/Mouse.js index 4b8d369c9a..a59ab408dd 100644 --- a/guacamole-common-js/src/main/webapp/modules/Mouse.js +++ b/guacamole-common-js/src/main/webapp/modules/Mouse.js @@ -123,6 +123,17 @@ Guacamole.Mouse = function Mouse(element) { Guacamole.Event.DOMEvent.cancelEvent(e); }, false); + // Capture mouse events outside the display element when a button is + // pressed to allow drag and drop between multiple windows. + element.addEventListener("pointerdown", function(e) { + element.setPointerCapture(e.pointerId); + }, false); + + // Stop capture when mouse button is released + element.addEventListener("pointerup", function(e) { + element.releasePointerCapture(e.pointerId); + }, false); + element.addEventListener("mousemove", function(e) { // If ignoring events, decrement counter From 18e41f7e58fe22be895aba231800d870f8a59bfb Mon Sep 17 00:00:00 2001 From: Corentin SORIANO Date: Fri, 23 May 2025 17:10:19 +0200 Subject: [PATCH 16/33] GUACAMOLE-288: Allow windows to have different dimensions. --- .../src/main/webapp/modules/Client.js | 19 +- .../src/main/webapp/modules/Display.js | 43 ++- .../controllers/secondaryMonitorController.js | 10 +- .../src/app/client/directives/guacClient.js | 19 +- .../client/directives/guacClientSecondary.js | 47 ++-- .../app/client/services/guacManageMonitor.js | 252 +++++++++++------- .../main/frontend/src/translations/en.json | 2 +- .../main/frontend/src/translations/fr.json | 2 +- 8 files changed, 228 insertions(+), 166 deletions(-) diff --git a/guacamole-common-js/src/main/webapp/modules/Client.js b/guacamole-common-js/src/main/webapp/modules/Client.js index 3a0e18621b..91eb62e1f2 100644 --- a/guacamole-common-js/src/main/webapp/modules/Client.js +++ b/guacamole-common-js/src/main/webapp/modules/Client.js @@ -348,16 +348,19 @@ Guacamole.Client = function(tunnel) { * @param {!number} height * The height of the screen. * - * @param {!number} monitors - * The count of monitors. + * @param {!number} x_position + * The x position of the screen (relative to the main window). + * + * @param {!number} top_offset + * The top offset of the screen, in pixel. */ - this.sendSize = function sendSize(width, height, monitors) { + this.sendSize = function sendSize(width, height, x_position, top_offset) { // Do not send requests if not connected if (!isConnected()) return; - tunnel.sendMessage("size", width, height, monitors); + tunnel.sendMessage("size", width, height, x_position, top_offset); }; @@ -1696,10 +1699,10 @@ Guacamole.Client = function(tunnel) { "size": function(parameters) { - var layer_index = parseInt(parameters[0]); - var layer = getLayer(layer_index); - var width = parseInt(parameters[1]); - var height = parseInt(parameters[2]); + const layer_index = parseInt(parameters[0]); + const layer = getLayer(layer_index); + const width = parseInt(parameters[1]); + const height = parseInt(parameters[2]); display.resize(layer, width, height); diff --git a/guacamole-common-js/src/main/webapp/modules/Display.js b/guacamole-common-js/src/main/webapp/modules/Display.js index 04f6f75ee8..a70fd621fc 100644 --- a/guacamole-common-js/src/main/webapp/modules/Display.js +++ b/guacamole-common-js/src/main/webapp/modules/Display.js @@ -34,15 +34,16 @@ Guacamole.Display = function() { * Reference to this Guacamole.Display. * @private */ - var guac_display = this; + const guac_display = this; - var displayWidth = 0; - var displayHeight = 0; - var displayMonitors = 1; - var displayScale = 1; + let displayWidth = 0; + let displayHeight = 0; + let monitorWidth = null; + let monitorHeight = null; + let displayScale = 1; // Create display - var display = document.createElement("div"); + const display = document.createElement("div"); display.style.position = "relative"; display.style.width = displayWidth + "px"; display.style.height = displayHeight + "px"; @@ -551,16 +552,6 @@ Guacamole.Display = function() { return displayHeight; }; - /** - * Returns the number of monitors. - * - * @return {!number} - * The number of monitors. - */ - this.getMonitors = function getMonitors() { - return displayMonitors; - }; - /** * Returns the default layer of this display. Each Guacamole display always * has at least one layer. Other layers can optionally be created within @@ -764,13 +755,16 @@ Guacamole.Display = function() { }; /** - * Change the number of monitors. + * Set the current monitor size. * - * @param {!number} monitors - * The number of monitors. + * @param {!number} width + * The width of the monitor, in pixels. + * @param {!number} height + * The height of the monitor, in pixels. */ - this.updateMonitors = function updateMonitors(monitors) { - displayMonitors = monitors; + this.setMonitorSize = function setMonitorSize(width, height) { + monitorWidth = width; + monitorHeight = height; } /** @@ -791,7 +785,12 @@ Guacamole.Display = function() { scheduleTask(function __display_resize() { // Adjust width when using multiple monitors - width = width / displayMonitors; + if (monitorWidth) + width = monitorWidth; + + // Adjust height when using multiple monitors + if (monitorHeight) + height = monitorHeight; layer.resize(width, height); diff --git a/guacamole/src/main/frontend/src/app/client/controllers/secondaryMonitorController.js b/guacamole/src/main/frontend/src/app/client/controllers/secondaryMonitorController.js index fc348d336b..40c7f8c355 100644 --- a/guacamole/src/main/frontend/src/app/client/controllers/secondaryMonitorController.js +++ b/guacamole/src/main/frontend/src/app/client/controllers/secondaryMonitorController.js @@ -46,7 +46,7 @@ angular.module('client').controller('secondaryMonitorController', ['$scope', '$i MENU_KEYS = angular.extend({}, SHIFT_KEYS, ALT_KEYS, CTRL_KEYS); guacManageMonitor.init("secondary"); - guacManageMonitor.monitorAttributes.monitorId = monitorId; + guacManageMonitor.monitorId = monitorId; guacManageMonitor.openConsentButton = function openConsentButton() { @@ -85,15 +85,15 @@ angular.module('client').controller('secondaryMonitorController', ['$scope', '$i // Ctrl+Alt+Shift has NOT been pressed if any key is currently held // down that isn't Ctrl, Alt, or Shift - if (_.findKey(keyboard.pressed, (val, keysym) => !MENU_KEYS[keysym])) + if (_.findKey(keyboard.pressed, (_, keysym) => !MENU_KEYS[keysym])) return false; // Verify that one of each required key is held, regardless of // left/right location on the keyboard return !!( - _.findKey(SHIFT_KEYS, (val, keysym) => keyboard.pressed[keysym]) - && _.findKey(ALT_KEYS, (val, keysym) => keyboard.pressed[keysym]) - && _.findKey(CTRL_KEYS, (val, keysym) => keyboard.pressed[keysym]) + _.findKey(SHIFT_KEYS, (_, keysym) => keyboard.pressed[keysym]) + && _.findKey(ALT_KEYS, (_, keysym) => keyboard.pressed[keysym]) + && _.findKey(CTRL_KEYS, (_, keysym) => keyboard.pressed[keysym]) ); }; diff --git a/guacamole/src/main/frontend/src/app/client/directives/guacClient.js b/guacamole/src/main/frontend/src/app/client/directives/guacClient.js index 8a77a617e4..cdef0bb642 100644 --- a/guacamole/src/main/frontend/src/app/client/directives/guacClient.js +++ b/guacamole/src/main/frontend/src/app/client/directives/guacClient.js @@ -511,17 +511,15 @@ angular.module('client').directive('guacClient', [function guacClient() { const pixelDensity = $window.devicePixelRatio || 1; const width = main.offsetWidth * pixelDensity; const height = main.offsetHeight * pixelDensity; - const monitors = guacManageMonitor.getMonitorCount(); - - // Monitor count changed - if (display.getMonitors() !== monitors) { - display.updateMonitors(monitors); - client.sendSize(width, height, monitors); - } // Window resized - else if (display.getWidth() !== width || display.getHeight() !== height) - client.sendSize(width, height, monitors); + if (display.getWidth() !== width || display.getHeight() !== height) + guacManageMonitor.sendSize({ + width: width, + height: height, + monitorId: 0, + top: 0, + }); } @@ -529,9 +527,6 @@ angular.module('client').directive('guacClient', [function guacClient() { }; - // Launch resize on monitor count change - $window.addEventListener('monitor-count', $scope.mainElementResized); - // Scroll client display if absolute mouse is in use (the same drag // gesture is needed for moving the mouse pointer with relative mouse) $scope.clientDrag = function clientDrag(inProgress, startX, startY, currentX, currentY, deltaX, deltaY) { diff --git a/guacamole/src/main/frontend/src/app/client/directives/guacClientSecondary.js b/guacamole/src/main/frontend/src/app/client/directives/guacClientSecondary.js index 10f671e99f..d1927eaa90 100644 --- a/guacamole/src/main/frontend/src/app/client/directives/guacClientSecondary.js +++ b/guacamole/src/main/frontend/src/app/client/directives/guacClientSecondary.js @@ -47,6 +47,7 @@ angular.module('client').directive('guacClientSecondary', [function guacClient() // Required services const $document = $injector.get('$document'); + const $window = $injector.get('$window'); const clipboardService = $injector.get('clipboardService'); const guacManageMonitor = $injector.get('guacManageMonitor'); @@ -79,6 +80,13 @@ angular.module('client').directive('guacClientSecondary', [function guacClient() */ const displayContainer = $element.find('.display')[0]; + /** + * The main containing element for the entire directive. + * + * @type Element + */ + const main = $element[0]; + /** * The tracked mouse. * @@ -100,13 +108,6 @@ angular.module('client').directive('guacClientSecondary', [function guacClient() */ const keyboard = new Guacamole.Keyboard($document[0]); - // Init monitor attributes with default values - guacManageMonitor.monitorAttributes.width = 0; - guacManageMonitor.monitorAttributes.height = 0; - guacManageMonitor.monitorAttributes.position = 0; - guacManageMonitor.monitorAttributes.count = 0; - guacManageMonitor.monitorAttributes.currentScaling = 1; - // Set client instance on guacManageMonitor service guacManageMonitor.setClient(client); @@ -123,10 +124,26 @@ angular.module('client').directive('guacClientSecondary', [function guacClient() }; // Adjust the display scaling according to the window size. - $scope.mainElementResized = guacManageMonitor.mainElementResized; + $scope.mainElementResized = function mainElementResized() { + + const pixelDensity = $window.devicePixelRatio ?? 1; + const width = main.offsetWidth * pixelDensity; + const height = main.offsetHeight * pixelDensity; + const top = 0 // TODO: $window.screenY ?? 0; + + const size = { + width: width, + height: height, + top: top > 0 ? top : 0, + monitorId: guacManageMonitor.monitorId, + }; + + // Send resize event to main window + guacManageMonitor.pushBroadcastMessage('size', size); + } // Ready for resize - guacManageMonitor.pushBroadcastMessage('resize', true); + $scope.mainElementResized(); // Handle any received clipboard data client.onclipboard = function clientClipboardReceived(stream, mimetype) { @@ -176,13 +193,11 @@ angular.module('client').directive('guacClientSecondary', [function guacClient() display.showCursor(true); // Update client-side cursor - display.moveCursor( - Math.floor(e.state.x / guacManageMonitor.monitorAttributes.currentScaling), - Math.floor(e.state.y / guacManageMonitor.monitorAttributes.currentScaling) - ); + display.moveCursor(e.state.x, e.state.y); // Click on actual display instead of the first - const displayOffset = guacManageMonitor.monitorAttributes.width * guacManageMonitor.monitorAttributes.position; + const displayOffsetX = guacManageMonitor.getOffsetX(); + const displayOffsetY = guacManageMonitor.getOffsetY(); // Convert mouse state to serializable object mouseState.down = e.state.down; @@ -190,8 +205,8 @@ angular.module('client').directive('guacClientSecondary', [function guacClient() mouseState.left = e.state.left; mouseState.middle = e.state.middle; mouseState.right = e.state.right; - mouseState.x = e.state.x / guacManageMonitor.monitorAttributes.currentScaling + displayOffset; - mouseState.y = e.state.y / guacManageMonitor.monitorAttributes.currentScaling; + mouseState.x = e.state.x + displayOffsetX; + mouseState.y = e.state.y + displayOffsetY; // Send mouse state to main window guacManageMonitor.pushBroadcastMessage('mouseState', mouseState); diff --git a/guacamole/src/main/frontend/src/app/client/services/guacManageMonitor.js b/guacamole/src/main/frontend/src/app/client/services/guacManageMonitor.js index 092f64e62c..1aa303981b 100644 --- a/guacamole/src/main/frontend/src/app/client/services/guacManageMonitor.js +++ b/guacamole/src/main/frontend/src/app/client/services/guacManageMonitor.js @@ -17,6 +17,8 @@ * under the License. */ +const { last } = require("lodash"); + /** * A service for adding additional monitors and handle instructions transfer. */ @@ -42,16 +44,16 @@ angular.module('client').factory('guacManageMonitor', ['$injector', let monitorType = "primary"; /** - * The display of the current Guacamole client instance. + * The current Guacamole client instance. * - * @type Guacamole.Display + * @type Guacamole.Client */ let client = null; /** - * The current Guacamole client instance. + * The display of the current Guacamole client instance. * - * @type Guacamole.Client + * @type Guacamole.Display */ let display = null; @@ -76,6 +78,23 @@ angular.module('client').factory('guacManageMonitor', ['$injector', */ let lastMonitorId = 0; + /** + * Object containing monitors informations. + * + * @type Object + * @property {Number} count + * The number of monitors, including the main window. + * @property {Object.} map + * A map of monitor id to position. + * @property {Object.} details + * Details of each monitor, including width, height, etc. + */ + let monitorsInfos = { + count: 1, + map: {}, + details: {}, + }; + const service = {}; /** @@ -83,7 +102,7 @@ angular.module('client').factory('guacManageMonitor', ['$injector', * * @type Object. */ - service.monitorAttributes = {}; + service.monitorId = 0; /** * Init the monitor type and broadcast channel used for bidirectionnal @@ -100,7 +119,7 @@ angular.module('client').factory('guacManageMonitor', ['$injector', if (monitorType == "primary") { guacFullscreen.onfullscreen = function onfullscreen(state) { service.pushBroadcastMessage('fullscreen', state); - } + } } // Create broadcast if supported @@ -169,9 +188,9 @@ angular.module('client').factory('guacManageMonitor', ['$injector', "primary": function primary(message) { - // Send monitors infos and trigger the screen resize event - if (message.data.resize) - sendMonitorsInfos(); + // Send size event to guacd + if (message.data.size) + service.sendSize(message.data.size); // Mouse state changed on secondary screen if (message.data.mouseState) @@ -204,20 +223,19 @@ angular.module('client').factory('guacManageMonitor', ['$injector', if (message.data.monitorsInfos) { - const monitorsInfos = message.data.monitorsInfos; - const monitorId = service.monitorAttributes.monitorId; + monitorsInfos = message.data.monitorsInfos; + const monitorId = service.monitorId; - // Store new monitor count and position - service.monitorAttributes.count = monitorsInfos.count; - service.monitorAttributes.position = monitorsInfos.map[monitorId]; - - // Set the monitor count in display - display.updateMonitors(service.monitorAttributes.count); + // Set the monitor size in the display + display.setMonitorSize( + monitorsInfos.details[monitorId].width, + monitorsInfos.details[monitorId].height, + ); } - // Resize display and window with parameters sent by guacd in the size handler - if (message.data.handler?.opcode === 'size' && !guacFullscreen.isInFullscreenMode()) { + // Handle resize event + if (message.data.handler?.opcode === 'size') { const parameters = message.data.handler.parameters; const default_layer = 0; @@ -227,30 +245,13 @@ angular.module('client').factory('guacManageMonitor', ['$injector', if (layer !== default_layer) return; - // Set the new display size - service.monitorAttributes.width = parseInt(parameters[1]) / service.monitorAttributes.count; - service.monitorAttributes.height = parseInt(parameters[2]); - - // Translate all draw actions on X to draw the current display - // instead of the first - client.offsetX = service.monitorAttributes.width * service.monitorAttributes.position; - - // Get unusable window height and width (ex: titlebar) - const windowUnusableHeight = $window.outerHeight - $window.innerHeight; - const windowUnusableWidth = $window.outerWidth - $window.innerWidth; + // Add offset to the display to not show the same content on + // all windows + client.offsetX = service.getOffsetX(); + client.offsetY = service.getOffsetY(); // Remove scrollbars document.querySelector('.client-main').style.overflow = 'hidden'; - - // Resize window to the display size - $window.resizeTo( - service.monitorAttributes.width + windowUnusableWidth, - service.monitorAttributes.height + windowUnusableHeight - ); - - // Adjust scaling to new size - service.mainElementResized(); - } // Full screen mode instructions @@ -278,24 +279,6 @@ angular.module('client').factory('guacManageMonitor', ['$injector', */ service.openConsentButton = null; - /** - * Adjust the display scaling according to the window size. - */ - service.mainElementResized = function mainElementResized() { - - // Calculate required scaling factor - const scaleX = $window.innerWidth / service.monitorAttributes.width; - const scaleY = $window.innerHeight / service.monitorAttributes.height; - - // Use the lowest scaling to avoid acreen overflow - if (scaleX <= scaleY) - service.monitorAttributes.currentScaling = scaleX; - else - service.monitorAttributes.currentScaling = scaleY; - - display.scale(service.monitorAttributes.currentScaling); - }; - /** * Open or close Guacamole menu (ctrl+alt+shift). */ @@ -346,7 +329,7 @@ angular.module('client').factory('guacManageMonitor', ['$injector', }; /** - * Open or close additional monitor window. + * Open an additional monitor window. */ service.addMonitor = function addMonitor() { @@ -356,7 +339,7 @@ angular.module('client').factory('guacManageMonitor', ['$injector', // New window parameters const windowUrl = './#/secondaryMonitor/' + lastMonitorId; const windowId = 'monitor' + lastMonitorId; - const windowSize = 'width=' + $window.innerWidth + ',height=' + $window.innerHeight; + const windowSize = 'width=800,height=600'; // Open new window monitors[lastMonitorId] = $window.open(windowUrl, windowId, windowSize); @@ -382,9 +365,13 @@ angular.module('client').factory('guacManageMonitor', ['$injector', // Delete monitor delete monitors[monitorId]; - // Send updated informations to secondary monitors and trigger the - // resize event to notify guacd of deleted monitor. - sendMonitorsInfos(); + // Notify guacd that a monitor has been closed + service.sendSize({ + width: 0, + height: 0, + top: 0, + monitorId: monitorId, + }); } @@ -394,70 +381,133 @@ angular.module('client').factory('guacManageMonitor', ['$injector', service.closeAllMonitors = function closeAllMonitors() { // Loop on all existing monitors - for (const key in monitors) { - - // Close monitor - if (!monitors[key].closed) - monitors[key].close(); - - // Delete monitor - delete monitors[key]; - - } - - // Trigger the screen resize event to notify guacd that a monitor - // has been removed. - $window.dispatchEvent(new Event('monitor-count')); + for (const key in monitors) + service.closeMonitor(key); }; /** - * Get open monitors count. Force additional monitor to close if it's - * window is closed. + * Get open monitors count. * * @returns {!number} * Actual count of monitors. */ service.getMonitorCount = function getMonitorCount() { + // Return additionals monitors count + 1 for the main window + return Object.keys(monitors).length + 1; + }; - // Loop on all existing monitors - for (const key in monitors) { + /** + * Send size event to guacd and update monitorsInfos object. + * + * @param {Object} size + * The size object containing width, height, top and monitorId. + */ + service.sendSize = function sendSize(size) { + + let monitorPosition = monitorsInfos.map[size.monitorId] + ?? service.getMonitorCount() - 1; + + updateMonitorsInfos({ + id: size.monitorId, + width: size.width, + height: size.height, + }); + + if (size.monitorId === 0) + display.setMonitorSize( + monitorsInfos.details[0].width, + monitorsInfos.details[0].height, + ); + + // Send size event to guacd + client.sendSize( + size.width, + size.height, + monitorPosition, + size.top, + ); - // Dead monitor, close it - if (monitors[key].closed) - service.closeMonitor(key); + // Push informations to all monitors + service.pushBroadcastMessage('monitorsInfos', monitorsInfos); + } + + /** + * Get the X offset of the current monitor. The X offset is the + * total width of all previous monitors. + * + * @return {number} + * The X offset of the current monitor, in pixels. + */ + service.getOffsetX = function getOffsetX() { + const monitorId = service.monitorId; + + if (monitorId === 0) + return 0; + + const thisPosition = monitorsInfos.map[monitorId]; + let offsetX = 0; + + // Loop through all monitors to add their widths as offset if they + // are before the current monitor + for (const [id, pos] of Object.entries(monitorsInfos.map)) { + if (pos < thisPosition) { + const details = monitorsInfos.details[id]; + if (details) offsetX += details.width; + } } - // Return additionals monitors count + 1 for the main window - return Object.keys(monitors).length + 1; + return offsetX; + } - }; + /** + * Get the Y offset of the current monitor. + * + * @return {number} + * The Y offset of the current monitor, in pixels. + */ + service.getOffsetY = function getOffsetY() { + return 0; + } /** - * Send monitor informations into the broadcast channel and notify guacd - * that the monitor count has changed. + * Update monitorsInfos object with current monitors count and map. + * + * @param {Object} monitorDetails + * Optional monitor details to update the monitorsInfos object. */ - function sendMonitorsInfos() { + function updateMonitorsInfos(monitorDetails) { - const monitorsInfos = { - count: service.getMonitorCount(), - map: {}, - }; + monitorsInfos.count = service.getMonitorCount(); // The main window would represent 0 let monitorPosition = 1; - // Generate monitors map (id => position) - for (const monitorKey in monitors) + // Generate monitors map (id => position), main window is always at + // position 0 + monitorsInfos.map[0] = 0; + for (const monitorKey in monitors) { monitorsInfos.map[monitorKey] = monitorPosition++; + } - // Push informations to all monitors - service.pushBroadcastMessage('monitorsInfos', monitorsInfos); + // Set monitor details if provided + if (!monitorDetails) + return; - // Trigger the screen resize event to notify guacd that a monitor - // has been added or removed. - $window.dispatchEvent(new Event('monitor-count')); + // If width or height is 0, remove monitor details + if (monitorDetails.width === 0 || monitorDetails.height === 0) { + delete monitorsInfos.details[monitorDetails.id]; + delete monitorsInfos.map[monitorDetails.id]; + } + // Update or add monitor details + else { + const monitorId = monitorDetails.id; + monitorsInfos.details[monitorId] = { + width: monitorDetails.width, + height: monitorDetails.height, + }; + } }; diff --git a/guacamole/src/main/frontend/src/translations/en.json b/guacamole/src/main/frontend/src/translations/en.json index ccef520276..979a8c058b 100644 --- a/guacamole/src/main/frontend/src/translations/en.json +++ b/guacamole/src/main/frontend/src/translations/en.json @@ -64,7 +64,7 @@ "CLIENT" : { "ACTION_ACKNOWLEDGE" : "@:APP.ACTION_ACKNOWLEDGE", - "ACTION_ADD_MONITOR" : "Add an additional monitor", + "ACTION_ADD_MONITOR" : "Add an additional screen", "ACTION_CANCEL" : "@:APP.ACTION_CANCEL", "ACTION_CLEAR_CLIENT_MESSAGES" : "@:APP.ACTION_CLEAR", "ACTION_CLEAR_COMPLETED_TRANSFERS" : "@:APP.ACTION_CLEAR", diff --git a/guacamole/src/main/frontend/src/translations/fr.json b/guacamole/src/main/frontend/src/translations/fr.json index 507cc52384..a9b6a2d0c8 100644 --- a/guacamole/src/main/frontend/src/translations/fr.json +++ b/guacamole/src/main/frontend/src/translations/fr.json @@ -61,7 +61,7 @@ "CLIENT" : { "ACTION_ACKNOWLEDGE" : "@:APP.ACTION_ACKNOWLEDGE", - "ACTION_ADD_MONITOR" : "Ajouter un moniteur supplémentaire", + "ACTION_ADD_MONITOR" : "Ajouter un écran supplémentaire", "ACTION_CANCEL" : "@:APP.ACTION_CANCEL", "ACTION_CLEAR_CLIENT_MESSAGES" : "@:APP.ACTION_CLEAR", "ACTION_CLEAR_COMPLETED_TRANSFERS" : "@:APP.ACTION_CLEAR", From 5e6272d8b35d24a7e6654673a8b82647cab91c66 Mon Sep 17 00:00:00 2001 From: Corentin SORIANO Date: Tue, 3 Jun 2025 17:21:43 +0200 Subject: [PATCH 17/33] GUACAMOLE-288: Add Y offset on monitor position. --- .../src/main/webapp/modules/Client.js | 53 +++-- .../src/app/client/directives/guacClient.js | 5 +- .../client/directives/guacClientSecondary.js | 11 +- .../app/client/services/guacManageMonitor.js | 207 +++++++++++++----- 4 files changed, 209 insertions(+), 67 deletions(-) diff --git a/guacamole-common-js/src/main/webapp/modules/Client.js b/guacamole-common-js/src/main/webapp/modules/Client.js index 91eb62e1f2..b134d3c7b3 100644 --- a/guacamole-common-js/src/main/webapp/modules/Client.js +++ b/guacamole-common-js/src/main/webapp/modules/Client.js @@ -180,6 +180,11 @@ Guacamole.Client = function(tunnel) { */ this.offsetX = 0; + /** + * Add optional Y offset on defaut layer draw actions. + */ + this.offsetY = 0; + /** * Produces an opaque representation of Guacamole.Client state which can be * later imported through a call to importState(). This object is @@ -403,6 +408,13 @@ Guacamole.Client = function(tunnel) { var x = mouseState.x; var y = mouseState.y; + // The offset is already applied when the state comes from a + // secondary monitor + if (!mouseState.offsedProcessed) { + x += guac_client.offsetX; + y += guac_client.offsetY; + } + // Translate for display units if requested if (applyDisplayScale) { x /= display.getScale(); @@ -1116,10 +1128,11 @@ Guacamole.Client = function(tunnel) { "arc": function(parameters) { const offsetX = parseInt(parameters[0]) === 0 ? guac_client.offsetX : 0; + const offsetY = parseInt(parameters[0]) === 0 ? guac_client.offsetY : 0; const layer = getLayer(parseInt(parameters[0])); const x = parseInt(parameters[1]) - offsetX; - const y = parseInt(parameters[2]); + const y = parseInt(parameters[2]) - offsetY; const radius = parseInt(parameters[3]); const startAngle = parseFloat(parameters[4]); const endAngle = parseFloat(parameters[5]); @@ -1263,16 +1276,18 @@ Guacamole.Client = function(tunnel) { const srcOffsetX = parseInt(parameters[0]) === 0 ? guac_client.offsetX : 0; const dstOffsetX = parseInt(parameters[6]) === 0 ? guac_client.offsetX : 0; + const srcOffsetY = parseInt(parameters[0]) === 0 ? guac_client.offsetY : 0; + const dstOffsetY = parseInt(parameters[6]) === 0 ? guac_client.offsetY : 0; const srcL = getLayer(parseInt(parameters[0])); const srcX = parseInt(parameters[1]) - srcOffsetX; - const srcY = parseInt(parameters[2]); + const srcY = parseInt(parameters[2]) - srcOffsetY; const srcWidth = parseInt(parameters[3]); const srcHeight = parseInt(parameters[4]); const channelMask = parseInt(parameters[5]); const dstL = getLayer(parseInt(parameters[6])); const dstX = parseInt(parameters[7]) - dstOffsetX; - const dstY = parseInt(parameters[8]); + const dstY = parseInt(parameters[8]) - dstOffsetY; display.setChannelMask(dstL, channelMask); display.copy(srcL, srcX, srcY, srcWidth, srcHeight, @@ -1300,12 +1315,13 @@ Guacamole.Client = function(tunnel) { "cursor": function(parameters) { const offsetX = parseInt(parameters[2]) === 0 ? guac_client.offsetX : 0; + const offsetY = parseInt(parameters[2]) === 0 ? guac_client.offsetY : 0; const cursorHotspotX = parseInt(parameters[0]); const cursorHotspotY = parseInt(parameters[1]); const srcL = getLayer(parseInt(parameters[2])); const srcX = parseInt(parameters[3]) - offsetX; - const srcY = parseInt(parameters[4]); + const srcY = parseInt(parameters[4]) - offsetY; const srcWidth = parseInt(parameters[5]); const srcHeight = parseInt(parameters[6]); @@ -1317,6 +1333,7 @@ Guacamole.Client = function(tunnel) { "curve": function(parameters) { const offsetX = parseInt(parameters[0]) === 0 ? guac_client.offsetX : 0; + const offsetY = parseInt(parameters[0]) === 0 ? guac_client.offsetY : 0; const layer = getLayer(parseInt(parameters[0])); const cp1x = parseInt(parameters[1]); @@ -1324,7 +1341,7 @@ Guacamole.Client = function(tunnel) { const cp2x = parseInt(parameters[3]); const cp2y = parseInt(parameters[4]); const x = parseInt(parameters[5]) - offsetX; - const y = parseInt(parameters[6]); + const y = parseInt(parameters[6]) - offsetY; display.curveTo(layer, cp1x, cp1y, cp2x, cp2y, x, y); @@ -1455,13 +1472,14 @@ Guacamole.Client = function(tunnel) { "img": function(parameters) { const offsetX = parseInt(parameters[2]) === 0 ? guac_client.offsetX : 0; + const offsetY = parseInt(parameters[2]) === 0 ? guac_client.offsetY : 0; const stream_index = parseInt(parameters[0]); const channelMask = parseInt(parameters[1]); const layer = getLayer(parseInt(parameters[2])); const mimetype = parameters[3]; const x = parseInt(parameters[4]) - offsetX; - const y = parseInt(parameters[5]); + const y = parseInt(parameters[5]) - offsetY; // Create stream const stream = streams[stream_index] = new Guacamole.InputStream(guac_client, stream_index); @@ -1475,11 +1493,12 @@ Guacamole.Client = function(tunnel) { "jpeg": function(parameters) { const offsetX = parseInt(parameters[1]) === 0 ? guac_client.offsetX : 0; + const offsetY = parseInt(parameters[1]) === 0 ? guac_client.offsetY : 0; const channelMask = parseInt(parameters[0]); const layer = getLayer(parseInt(parameters[1])); const x = parseInt(parameters[2]) - offsetX; - const y = parseInt(parameters[3]); + const y = parseInt(parameters[3]) - offsetY; const data = parameters[4]; display.setChannelMask(layer, channelMask); @@ -1501,10 +1520,11 @@ Guacamole.Client = function(tunnel) { "line": function(parameters) { const offsetX = parseInt(parameters[0]) === 0 ? guac_client.offsetX : 0; + const offsetY = parseInt(parameters[0]) === 0 ? guac_client.offsetY : 0; const layer = getLayer(parseInt(parameters[0])); const x = parseInt(parameters[1]) - offsetX; - const y = parseInt(parameters[2]); + const y = parseInt(parameters[2]) - offsetY; display.lineTo(layer, x, y); @@ -1524,7 +1544,7 @@ Guacamole.Client = function(tunnel) { "mouse" : function handleMouse(parameters) { const x = parseInt(parameters[0]) - guac_client.offsetX; - const y = parseInt(parameters[1]); + const y = parseInt(parameters[1]) - guac_client.offsetY; // Display and move software cursor to received coordinates display.showCursor(true); @@ -1617,11 +1637,12 @@ Guacamole.Client = function(tunnel) { "png": function(parameters) { const offsetX = parseInt(parameters[1]) === 0 ? guac_client.offsetX : 0; + const offsetY = parseInt(parameters[1]) === 0 ? guac_client.offsetY : 0; const channelMask = parseInt(parameters[0]); const layer = getLayer(parseInt(parameters[1])); const x = parseInt(parameters[2]) - offsetX; - const y = parseInt(parameters[3]); + const y = parseInt(parameters[3]) - offsetY; const data = parameters[4]; display.setChannelMask(layer, channelMask); @@ -1648,10 +1669,11 @@ Guacamole.Client = function(tunnel) { "rect": function(parameters) { const offsetX = parseInt(parameters[0]) === 0 ? guac_client.offsetX : 0; + const offsetY = parseInt(parameters[0]) === 0 ? guac_client.offsetY : 0; const layer = getLayer(parseInt(parameters[0])); const x = parseInt(parameters[1]) - offsetX; - const y = parseInt(parameters[2]); + const y = parseInt(parameters[2]) - offsetY; const w = parseInt(parameters[3]); const h = parseInt(parameters[4]); @@ -1711,10 +1733,11 @@ Guacamole.Client = function(tunnel) { "start": function(parameters) { const offsetX = parseInt(parameters[0]) === 0 ? guac_client.offsetX : 0; + const offsetY = parseInt(parameters[0]) === 0 ? guac_client.offsetY : 0; const layer = getLayer(parseInt(parameters[0])); const x = parseInt(parameters[0]) - offsetX; - const y = parseInt(parameters[2]); + const y = parseInt(parameters[2]) - offsetY; display.moveTo(layer, x, y); @@ -1757,16 +1780,18 @@ Guacamole.Client = function(tunnel) { const srcOffsetX = parseInt(parameters[0]) === 0 ? guac_client.offsetX : 0; const dstOffsetX = parseInt(parameters[6]) === 0 ? guac_client.offsetX : 0; + const srcOffsetY = parseInt(parameters[0]) === 0 ? guac_client.offsetY : 0; + const dstOffsetY = parseInt(parameters[6]) === 0 ? guac_client.offsetY : 0; const srcL = getLayer(parseInt(parameters[0])); const srcX = parseInt(parameters[1]) - srcOffsetX; - const srcY = parseInt(parameters[2]); + const srcY = parseInt(parameters[2]) - srcOffsetY; const srcWidth = parseInt(parameters[3]); const srcHeight = parseInt(parameters[4]); const function_index = parseInt(parameters[5]); const dstL = getLayer(parseInt(parameters[6])); const dstX = parseInt(parameters[7]) - dstOffsetX; - const dstY = parseInt(parameters[8]); + const dstY = parseInt(parameters[8]) - dstOffsetY; /* SRC */ if (function_index === 0x3) diff --git a/guacamole/src/main/frontend/src/app/client/directives/guacClient.js b/guacamole/src/main/frontend/src/app/client/directives/guacClient.js index cdef0bb642..915fec4a07 100644 --- a/guacamole/src/main/frontend/src/app/client/directives/guacClient.js +++ b/guacamole/src/main/frontend/src/app/client/directives/guacClient.js @@ -511,6 +511,8 @@ angular.module('client').directive('guacClient', [function guacClient() { const pixelDensity = $window.devicePixelRatio || 1; const width = main.offsetWidth * pixelDensity; const height = main.offsetHeight * pixelDensity; + const top = window.screenY; + const left = window.screenX; // Window resized if (display.getWidth() !== width || display.getHeight() !== height) @@ -518,7 +520,8 @@ angular.module('client').directive('guacClient', [function guacClient() { width: width, height: height, monitorId: 0, - top: 0, + top: top, + left: left, }); } diff --git a/guacamole/src/main/frontend/src/app/client/directives/guacClientSecondary.js b/guacamole/src/main/frontend/src/app/client/directives/guacClientSecondary.js index d1927eaa90..f677d72467 100644 --- a/guacamole/src/main/frontend/src/app/client/directives/guacClientSecondary.js +++ b/guacamole/src/main/frontend/src/app/client/directives/guacClientSecondary.js @@ -129,17 +129,23 @@ angular.module('client').directive('guacClientSecondary', [function guacClient() const pixelDensity = $window.devicePixelRatio ?? 1; const width = main.offsetWidth * pixelDensity; const height = main.offsetHeight * pixelDensity; - const top = 0 // TODO: $window.screenY ?? 0; + const top = window.screenY; + const left = window.screenX; const size = { width: width, height: height, - top: top > 0 ? top : 0, + top: top, + left: left, monitorId: guacManageMonitor.monitorId, }; // Send resize event to main window guacManageMonitor.pushBroadcastMessage('size', size); + + // Remove scrollbars + document.querySelector('.client-main').style.overflow = 'hidden'; + } // Ready for resize @@ -207,6 +213,7 @@ angular.module('client').directive('guacClientSecondary', [function guacClient() mouseState.right = e.state.right; mouseState.x = e.state.x + displayOffsetX; mouseState.y = e.state.y + displayOffsetY; + mouseState.offsedProcessed = true; // Send mouse state to main window guacManageMonitor.pushBroadcastMessage('mouseState', mouseState); diff --git a/guacamole/src/main/frontend/src/app/client/services/guacManageMonitor.js b/guacamole/src/main/frontend/src/app/client/services/guacManageMonitor.js index 1aa303981b..15e15de51e 100644 --- a/guacamole/src/main/frontend/src/app/client/services/guacManageMonitor.js +++ b/guacamole/src/main/frontend/src/app/client/services/guacManageMonitor.js @@ -17,8 +17,6 @@ * under the License. */ -const { last } = require("lodash"); - /** * A service for adding additional monitors and handle instructions transfer. */ @@ -126,6 +124,7 @@ angular.module('client').factory('guacManageMonitor', ['$injector', if (!service.supported()) return; + // TODO: ensure that there is no mixing of data between multiple connections broadcast = new BroadcastChannel('guac_monitors'); /** @@ -137,6 +136,10 @@ angular.module('client').factory('guacManageMonitor', ['$injector', */ broadcast.onmessage = messageHandlers[monitorType]; + // Check the window position every second and send a resize event if it + // has changed + setInterval(() => updatePosition(), 1000); + }; /** @@ -211,7 +214,7 @@ angular.module('client').factory('guacManageMonitor', ['$injector', // CTRL+ALT+SHIFT pressed on secondary window if (message.data.guacMenu && service.menuShown) service.menuShown(); - + }, "secondary": function secondaryMonitor(message) { @@ -232,26 +235,9 @@ angular.module('client').factory('guacManageMonitor', ['$injector', monitorsInfos.details[monitorId].height, ); - } - - // Handle resize event - if (message.data.handler?.opcode === 'size') { - - const parameters = message.data.handler.parameters; - const default_layer = 0; - const layer = parseInt(parameters[0]); - - // Ignore other layers (ex: mouse) that can have other size - if (layer !== default_layer) - return; - - // Add offset to the display to not show the same content on - // all windows client.offsetX = service.getOffsetX(); - client.offsetY = service.getOffsetY(); + client.offsetY = service.getOffsetY(); - // Remove scrollbars - document.querySelector('.client-main').style.overflow = 'hidden'; } // Full screen mode instructions @@ -405,41 +391,44 @@ angular.module('client').factory('guacManageMonitor', ['$injector', */ service.sendSize = function sendSize(size) { - let monitorPosition = monitorsInfos.map[size.monitorId] - ?? service.getMonitorCount() - 1; + const monitorPosition = monitorsInfos.map[size.monitorId]; updateMonitorsInfos({ - id: size.monitorId, - width: size.width, + id: size.monitorId, + width: size.width, height: size.height, + left: size.left, + top: size.top, }); - if (size.monitorId === 0) - display.setMonitorSize( - monitorsInfos.details[0].width, - monitorsInfos.details[0].height, - ); - - // Send size event to guacd - client.sendSize( - size.width, - size.height, - monitorPosition, - size.top, + display.setMonitorSize( + monitorsInfos.details[0].width, + monitorsInfos.details[0].height, ); + client.offsetX = service.getOffsetX(); + client.offsetY = service.getOffsetY(); + + // Monitor has been closed + if (size.width === 0 || size.height === 0) + client.sendSize(0, 0, monitorPosition, 0); + + // Send new size to guacd + else + sendAllSizes(); + // Push informations to all monitors service.pushBroadcastMessage('monitorsInfos', monitorsInfos); } /** - * Get the X offset of the current monitor. The X offset is the - * total width of all previous monitors. - * - * @return {number} - * The X offset of the current monitor, in pixels. - */ + * Get the X offset of the current monitor. The X offset is the + * total width of all previous monitors. + * + * @return {number} + * The X offset of the current monitor, in pixels. + */ service.getOffsetX = function getOffsetX() { const monitorId = service.monitorId; @@ -462,13 +451,84 @@ angular.module('client').factory('guacManageMonitor', ['$injector', } /** - * Get the Y offset of the current monitor. - * - * @return {number} - * The Y offset of the current monitor, in pixels. - */ + * Get the Y offset of the current monitor. The monitor displayed on the + * highest position (lowest top value) will have an offset of 0 and for + * other monitors, the offset is the top value of the monitor minus the top + * value of the highest monitor (lowest top offset). + * This is used to calculate the Y offset to draw operations and mouse + * events. + * + * @return {number} + * The Y offset of the current monitor, in pixels. + */ service.getOffsetY = function getOffsetY() { - return 0; + const currentOffset = monitorsInfos.details[service.monitorId]?.top ?? 0; + return currentOffset - getLowestTopOffset(); + } + + /** + * Send the size of all monitors to guacd. This is used to update the + * monitor sizes in guacd when a new monitor is added or updated. + * + * This function loops through all monitors and sends their sizes to guacd + * using the client.sendSize method. The size includes width, height, + * monitor position and top offset. + */ + function sendAllSizes() { + // Loop through all monitors and send their sizes to guacd + for (const [id, details] of Object.entries(monitorsInfos.details)) { + client.sendSize( + details.width, + details.height, + monitorsInfos.map[id], + getTopOffset(id, details.top) + ); + } + } + + /** + * Get the top offset of the given monitor id and top value based on the + * primary monitor's top value. The top offset is the difference between the + * top value of the monitor and the top value of the primary monitor. + * This is used to calculate the Y offset to send to guacd. + * + * @param {number} id + * The id of the monitor. + * @param {number} top + * The top value of the monitor. + * + * @return {number} + * The top offset of the monitor, in pixels. + */ + function getTopOffset(id, top) { + + const primaryMonitorId = 0; + + // If this is the primary monitor, return 0 + if (id === primaryMonitorId) + return 0; + + return top - Math.abs(monitorsInfos.details[primaryMonitorId].top ?? 0); + } + + /** + * Get the lowest top value of all monitors. This is used to calculate the + * Y offset of the current monitor. + * + * @return {number} + * The lowest top value of all monitors, in pixels. + */ + function getLowestTopOffset() { + let lowestTopValue = monitorsInfos.details[0]?.top ?? 0; + + // Loop through all monitors to find the highest monitor + for (const [_, details] of Object.entries(monitorsInfos.details)) { + if (details?.top < lowestTopValue) { + lowestTopValue = details.top; + } + } + + return lowestTopValue; } /** @@ -504,13 +564,60 @@ angular.module('client').factory('guacManageMonitor', ['$injector', else { const monitorId = monitorDetails.id; monitorsInfos.details[monitorId] = { - width: monitorDetails.width, + width: monitorDetails.width, height: monitorDetails.height, + top: monitorDetails.top, + // TODO: Use the left value to reorder monitors if needed + left: monitorDetails.left, }; } }; + /** + * Check if the window position has changed since the last check. + * This is used to avoid unnecessary updates. + * + * @returns {boolean} + * True if the position has changed, false otherwise. + */ + function positionHasChanged() { + const monitorDetails = monitorsInfos.details[service.monitorId]; + + // Monitor not initialized + if (!monitorDetails) + return false; + + return monitorDetails.left !== window.screenX + || monitorDetails.top !== window.screenY; + } + + /** + * Trigger a resize event if the window position has changed. + */ + function updatePosition() { + if (!positionHasChanged() || !client) + return; + + const monitorDetails = monitorsInfos.details[service.monitorId]; + + // Update the position of the monitor + monitorDetails.left = window.screenX ?? 0; + monitorDetails.top = window.screenY ?? 0; + monitorDetails.monitorId = service.monitorId; + + // Send size event to guacd and update monitorsInfos if this is the + // primary monitor + if (monitorType === "primary") { + service.sendSize(monitorDetails); + return; + } + + // Send broadcast message to primary monitor if this is a secondary + // monitor + service.pushBroadcastMessage('size', monitorDetails); + } + // Close additional monitors when window is unloaded $window.addEventListener('unload', service.closeAllMonitors); From 23212c9f849111d5a2579d5ebc2c67a5a0832e4f Mon Sep 17 00:00:00 2001 From: Corentin SORIANO Date: Sat, 7 Jun 2025 23:03:56 +0200 Subject: [PATCH 18/33] WIP - GUACAMOLE-288: Receive monitor layout via layer set parameter. --- .../src/main/webapp/modules/Client.js | 16 ++++ .../app/client/services/guacManageMonitor.js | 94 +++++++++++++------ 2 files changed, 80 insertions(+), 30 deletions(-) diff --git a/guacamole-common-js/src/main/webapp/modules/Client.js b/guacamole-common-js/src/main/webapp/modules/Client.js index b134d3c7b3..a6d866167e 100644 --- a/guacamole-common-js/src/main/webapp/modules/Client.js +++ b/guacamole-common-js/src/main/webapp/modules/Client.js @@ -885,6 +885,15 @@ Guacamole.Client = function(tunnel) { */ this.onmultitouch = null; + /** + * Fired when the remote client is explicitly declaring the layout of + * monitors, if any. + * + * @param {Object} layout + * An object describing the layout of monitors. + */ + this.onmultimonlayout = null; + /** * Fired when the current value of a connection parameter is being exposed * by the server. @@ -1087,6 +1096,13 @@ Guacamole.Client = function(tunnel) { if (guac_client.onmultitouch && layer instanceof Guacamole.Display.VisibleLayer) guac_client.onmultitouch(layer, parseInt(value)); + }, + + "multimon-layout": function multimonLayout(layer, value) { + + if (guac_client.onmultimonlayout) + guac_client.onmultimonlayout(JSON.parse(value)); + } }; diff --git a/guacamole/src/main/frontend/src/app/client/services/guacManageMonitor.js b/guacamole/src/main/frontend/src/app/client/services/guacManageMonitor.js index 15e15de51e..f64bca4695 100644 --- a/guacamole/src/main/frontend/src/app/client/services/guacManageMonitor.js +++ b/guacamole/src/main/frontend/src/app/client/services/guacManageMonitor.js @@ -85,12 +85,16 @@ angular.module('client').factory('guacManageMonitor', ['$injector', * @property {Object.} map * A map of monitor id to position. * @property {Object.} details - * Details of each monitor, including width, height, etc. + * Details of each browser window, including width, height, etc. + * @property {Object.} rendered + * Details of each rendered monitor, including width, height, etc. + * This is used to display what is expected by guacd. */ let monitorsInfos = { count: 1, map: {}, details: {}, + rendered: {}, }; const service = {}; @@ -224,21 +228,8 @@ angular.module('client').factory('guacManageMonitor', ['$injector', client.runHandler(message.data.handler.opcode, message.data.handler.parameters); - if (message.data.monitorsInfos) { - + if (message.data.monitorsInfos) monitorsInfos = message.data.monitorsInfos; - const monitorId = service.monitorId; - - // Set the monitor size in the display - display.setMonitorSize( - monitorsInfos.details[monitorId].width, - monitorsInfos.details[monitorId].height, - ); - - client.offsetX = service.getOffsetX(); - client.offsetY = service.getOffsetY(); - - } // Full screen mode instructions if (message.data.fullscreen !== undefined) { @@ -281,6 +272,8 @@ angular.module('client').factory('guacManageMonitor', ['$injector', client = guac_client; display = client.getDisplay(); + client.onmultimonlayout = onmultimonlayout; + // Close all secondary monitors on client disconnect if (monitorType === "primary") client.ondisconnect = service.closeAllMonitors; @@ -401,14 +394,6 @@ angular.module('client').factory('guacManageMonitor', ['$injector', top: size.top, }); - display.setMonitorSize( - monitorsInfos.details[0].width, - monitorsInfos.details[0].height, - ); - - client.offsetX = service.getOffsetX(); - client.offsetY = service.getOffsetY(); - // Monitor has been closed if (size.width === 0 || size.height === 0) client.sendSize(0, 0, monitorPosition, 0); @@ -442,8 +427,8 @@ angular.module('client').factory('guacManageMonitor', ['$injector', // are before the current monitor for (const [id, pos] of Object.entries(monitorsInfos.map)) { if (pos < thisPosition) { - const details = monitorsInfos.details[id]; - if (details) offsetX += details.width; + const rendered = monitorsInfos.rendered[id]; + if (rendered?.width) offsetX += rendered.width; } } @@ -462,7 +447,7 @@ angular.module('client').factory('guacManageMonitor', ['$injector', * The Y offset of the current monitor, in pixels. */ service.getOffsetY = function getOffsetY() { - const currentOffset = monitorsInfos.details[service.monitorId]?.top ?? 0; + const currentOffset = monitorsInfos.rendered[service.monitorId]?.top ?? 0; return currentOffset - getLowestTopOffset(); } @@ -519,12 +504,12 @@ angular.module('client').factory('guacManageMonitor', ['$injector', * The lowest top value of all monitors, in pixels. */ function getLowestTopOffset() { - let lowestTopValue = monitorsInfos.details[0]?.top ?? 0; + let lowestTopValue = monitorsInfos.rendered[0]?.top ?? 0; // Loop through all monitors to find the highest monitor - for (const [_, details] of Object.entries(monitorsInfos.details)) { - if (details?.top < lowestTopValue) { - lowestTopValue = details.top; + for (const [_, rendered] of Object.entries(monitorsInfos.rendered)) { + if (rendered?.top < lowestTopValue) { + lowestTopValue = rendered.top; } } @@ -558,6 +543,7 @@ angular.module('client').factory('guacManageMonitor', ['$injector', // If width or height is 0, remove monitor details if (monitorDetails.width === 0 || monitorDetails.height === 0) { delete monitorsInfos.details[monitorDetails.id]; + delete monitorsInfos.rendered[monitorDetails.id]; delete monitorsInfos.map[monitorDetails.id]; } // Update or add monitor details @@ -618,6 +604,54 @@ angular.module('client').factory('guacManageMonitor', ['$injector', service.pushBroadcastMessage('size', monitorDetails); } + /** + * Handle the multimonitor layout event. This is used to update the + * monitorsInfos object when the layout changes. + * + * @param {Object} layout + * An object describing the layout of monitors. + */ + function onmultimonlayout(layout) { + if (!layout) + return; + + for (const [id, pos] of Object.entries(monitorsInfos.map)) { + + // If the monitor is not in the layout, it is not known by + // guacd anymore, so we close it + if (!layout[pos]) { + service.closeMonitor(id); + continue; + } + + if (!monitorsInfos.rendered[id]) + monitorsInfos.rendered[id] = {}; + + // Update the monitor details + monitorsInfos.rendered[id].width = layout[pos].width; + monitorsInfos.rendered[id].height = layout[pos].height; + monitorsInfos.rendered[id].top = layout[pos].top; + monitorsInfos.rendered[id].left = layout[pos].left; + + // Set the monitor size in the display only if the id matches the + // current monitor id + if (id === String(service.monitorId)) { + display.setMonitorSize( + monitorsInfos.rendered[id].width, + monitorsInfos.rendered[id].height, + ); + } + + } + + // Update the offset of the client when monitorInfos is fully updated + // This is needed to ensure that the client knows the correct offset + // of each monitor + client.offsetX = service.getOffsetX(); + client.offsetY = service.getOffsetY(); + + } + // Close additional monitors when window is unloaded $window.addEventListener('unload', service.closeAllMonitors); From 2943179a12b19a246f80552a0f2a6415bd865f76 Mon Sep 17 00:00:00 2001 From: Corentin SORIANO Date: Mon, 3 Nov 2025 18:17:48 +0100 Subject: [PATCH 19/33] GUACAMOLE-288: Force disconnect on error to avoid dead browser windows. --- guacamole-common-js/src/main/webapp/modules/Client.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/guacamole-common-js/src/main/webapp/modules/Client.js b/guacamole-common-js/src/main/webapp/modules/Client.js index a6d866167e..ae69ad48cf 100644 --- a/guacamole-common-js/src/main/webapp/modules/Client.js +++ b/guacamole-common-js/src/main/webapp/modules/Client.js @@ -1421,6 +1421,8 @@ Guacamole.Client = function(tunnel) { if (guac_client.onerror) guac_client.onerror(new Guacamole.Status(code, reason)); + if (guac_client.ondisconnect) guac_client.ondisconnect(); + guac_client.disconnect(); }, From 13239285a1548a9955ad0ac8b1a0a92997992f0f Mon Sep 17 00:00:00 2001 From: Corentin SORIANO Date: Thu, 27 Nov 2025 11:17:40 +0100 Subject: [PATCH 20/33] GUACAMOLE-288: Close multi-screen mode on change in the active client group or when multiple clients are inside. --- .../client/controllers/clientController.js | 8 ++++++- .../src/app/client/directives/guacClient.js | 2 +- .../app/client/services/guacManageMonitor.js | 22 ++++++++++++------- 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/guacamole/src/main/frontend/src/app/client/controllers/clientController.js b/guacamole/src/main/frontend/src/app/client/controllers/clientController.js index 6721f42fab..d6078f1184 100644 --- a/guacamole/src/main/frontend/src/app/client/controllers/clientController.js +++ b/guacamole/src/main/frontend/src/app/client/controllers/clientController.js @@ -316,6 +316,8 @@ angular.module('client').controller('clientController', ['$scope', '$routeParams $scope.clientGroup.lastUsed = new Date().getTime(); } + // Close additional monitors when changing group + guacManageMonitor.closeAllMonitors(); }; // Init sets of clients based on current URL ... @@ -742,8 +744,12 @@ angular.module('client').controller('clientController', ['$scope', '$routeParams return false; // The maximum number of secondary monitors that can be added. - const secondaryMonitorsAllowed = parseInt( + let secondaryMonitorsAllowed = parseInt( $scope.focusedClient.arguments['secondary-monitors'] ?? 0); + + // Allow secondary monitors only if there is a single client in the group + if ($scope.clientGroup.clients.length > 1) + secondaryMonitorsAllowed = 0; guacManageMonitor.setMaxSecondaryMonitors(secondaryMonitorsAllowed); diff --git a/guacamole/src/main/frontend/src/app/client/directives/guacClient.js b/guacamole/src/main/frontend/src/app/client/directives/guacClient.js index 915fec4a07..19d2c6e61a 100644 --- a/guacamole/src/main/frontend/src/app/client/directives/guacClient.js +++ b/guacamole/src/main/frontend/src/app/client/directives/guacClient.js @@ -516,7 +516,7 @@ angular.module('client').directive('guacClient', [function guacClient() { // Window resized if (display.getWidth() !== width || display.getHeight() !== height) - guacManageMonitor.sendSize({ + guacManageMonitor.sendSize(client, { width: width, height: height, monitorId: 0, diff --git a/guacamole/src/main/frontend/src/app/client/services/guacManageMonitor.js b/guacamole/src/main/frontend/src/app/client/services/guacManageMonitor.js index f64bca4695..f224cc3419 100644 --- a/guacamole/src/main/frontend/src/app/client/services/guacManageMonitor.js +++ b/guacamole/src/main/frontend/src/app/client/services/guacManageMonitor.js @@ -197,7 +197,7 @@ angular.module('client').factory('guacManageMonitor', ['$injector', // Send size event to guacd if (message.data.size) - service.sendSize(message.data.size); + service.sendSize(client, message.data.size); // Mouse state changed on secondary screen if (message.data.mouseState) @@ -345,7 +345,7 @@ angular.module('client').factory('guacManageMonitor', ['$injector', delete monitors[monitorId]; // Notify guacd that a monitor has been closed - service.sendSize({ + service.sendSize(client, { width: 0, height: 0, top: 0, @@ -379,10 +379,13 @@ angular.module('client').factory('guacManageMonitor', ['$injector', /** * Send size event to guacd and update monitorsInfos object. * + * @param {Guacamole.Client} requestedClient + * The Guacamole client to send the size to. This is needed for + * connection groups with multiple clients. * @param {Object} size * The size object containing width, height, top and monitorId. */ - service.sendSize = function sendSize(size) { + service.sendSize = function sendSize(requestedClient, size) { const monitorPosition = monitorsInfos.map[size.monitorId]; @@ -396,11 +399,11 @@ angular.module('client').factory('guacManageMonitor', ['$injector', // Monitor has been closed if (size.width === 0 || size.height === 0) - client.sendSize(0, 0, monitorPosition, 0); + requestedClient.sendSize(0, 0, monitorPosition, 0); // Send new size to guacd else - sendAllSizes(); + sendAllSizes(requestedClient); // Push informations to all monitors service.pushBroadcastMessage('monitorsInfos', monitorsInfos); @@ -458,11 +461,14 @@ angular.module('client').factory('guacManageMonitor', ['$injector', * This function loops through all monitors and sends their sizes to guacd * using the client.sendSize method. The size includes width, height, * monitor position and top offset. + * + * @param {Guacamole.Client} requestedClient + * The Guacamole client to send the sizes to. */ - function sendAllSizes() { + function sendAllSizes(requestedClient) { // Loop through all monitors and send their sizes to guacd for (const [id, details] of Object.entries(monitorsInfos.details)) { - client.sendSize( + requestedClient.sendSize( details.width, details.height, monitorsInfos.map[id], @@ -595,7 +601,7 @@ angular.module('client').factory('guacManageMonitor', ['$injector', // Send size event to guacd and update monitorsInfos if this is the // primary monitor if (monitorType === "primary") { - service.sendSize(monitorDetails); + service.sendSize(client, monitorDetails); return; } From 2f4be7ce1a81af09ab1debf590fcfc5b9999df6a Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Sat, 4 Jul 2026 09:17:59 -0300 Subject: [PATCH 21/33] GUACAMOLE-288: Enable the multi-monitor UI for SPICE connections The browser-side multi-monitor support ported from apache/guacamole-client#1061 (by @corentin-soriano) gated the "add monitor" UI to the RDP protocol. SPICE now implements the same server-side multi-monitor protocol (secondary-monitors advertisement + multimon-layout), so allow the multi-monitor UI for SPICE connections as well. All remaining multi-monitor logic is protocol-agnostic and driven by the secondary-monitors connection parameter. --- .../frontend/src/app/client/controllers/clientController.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/guacamole/src/main/frontend/src/app/client/controllers/clientController.js b/guacamole/src/main/frontend/src/app/client/controllers/clientController.js index d6078f1184..65d7cc484a 100644 --- a/guacamole/src/main/frontend/src/app/client/controllers/clientController.js +++ b/guacamole/src/main/frontend/src/app/client/controllers/clientController.js @@ -739,8 +739,9 @@ angular.module('client').controller('clientController', ['$scope', '$routeParams */ $scope.showAddMonitor = function showAddMonitor() { - // Multi monitor only supported with rdp protocol - if ($scope.focusedClient?.protocol !== 'rdp') + // Multi monitor is currently supported with the rdp and spice protocols + if ($scope.focusedClient?.protocol !== 'rdp' + && $scope.focusedClient?.protocol !== 'spice') return false; // The maximum number of secondary monitors that can be added. From 943a870f2acd774417204883a24e72374a377a70 Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Sun, 5 Jul 2026 15:16:34 -0300 Subject: [PATCH 22/33] GUACAMOLE-288: consume monitor left offset + keep windows on transient layout absence getOffsetX now returns the monitor's absolute left offset (symmetric to getOffsetY) instead of summing prior monitors' rendered widths, so draw and mouse offsets are correct for vertical stacks, gaps, and non-tiled arrangements. onmultimonlayout no longer closes a user-opened monitor window the moment it is absent from the layout: it tolerates a short run of transient absences (guest thrash) before closing, so a momentary 1-monitor layout no longer tears down the secondary window. Adds getMonitorsInfos() for the resize handlers. --- .../app/client/services/guacManageMonitor.js | 82 +++++++++++++------ 1 file changed, 59 insertions(+), 23 deletions(-) diff --git a/guacamole/src/main/frontend/src/app/client/services/guacManageMonitor.js b/guacamole/src/main/frontend/src/app/client/services/guacManageMonitor.js index f224cc3419..3da6d0c681 100644 --- a/guacamole/src/main/frontend/src/app/client/services/guacManageMonitor.js +++ b/guacamole/src/main/frontend/src/app/client/services/guacManageMonitor.js @@ -97,6 +97,14 @@ angular.module('client').factory('guacManageMonitor', ['$injector', rendered: {}, }; + /** + * Map tracking consecutive missing counts per monitor ID. + * + * @type Object. + */ + const missingCounts = {}; + + const service = {}; /** @@ -337,6 +345,9 @@ angular.module('client').factory('guacManageMonitor', ['$injector', if (!monitors[monitorId]) return; + // Clear missing count tracking + delete missingCounts[monitorId]; + // Close monitor if (!monitors[monitorId].closed) monitors[monitorId].close(); @@ -411,31 +422,19 @@ angular.module('client').factory('guacManageMonitor', ['$injector', } /** - * Get the X offset of the current monitor. The X offset is the - * total width of all previous monitors. + * Get the X offset of the current monitor. The monitor displayed on the + * leftmost position (lowest left value) will have an offset of 0 and for + * other monitors, the offset is the left value of the monitor minus the left + * value of the leftmost monitor (lowest left offset). + * This is used to calculate the X offset to draw operations and mouse + * events. * * @return {number} * The X offset of the current monitor, in pixels. */ service.getOffsetX = function getOffsetX() { - const monitorId = service.monitorId; - - if (monitorId === 0) - return 0; - - const thisPosition = monitorsInfos.map[monitorId]; - let offsetX = 0; - - // Loop through all monitors to add their widths as offset if they - // are before the current monitor - for (const [id, pos] of Object.entries(monitorsInfos.map)) { - if (pos < thisPosition) { - const rendered = monitorsInfos.rendered[id]; - if (rendered?.width) offsetX += rendered.width; - } - } - - return offsetX; + const currentOffset = monitorsInfos.rendered[service.monitorId]?.left ?? 0; + return currentOffset - getLowestLeftOffset(); } /** @@ -522,6 +521,26 @@ angular.module('client').factory('guacManageMonitor', ['$injector', return lowestTopValue; } + /** + * Get the lowest left value of all monitors. This is used to calculate the + * X offset of the current monitor. + * + * @return {number} + * The lowest left value of all monitors, in pixels. + */ + function getLowestLeftOffset() { + let lowestLeftValue = monitorsInfos.rendered[0]?.left ?? 0; + + // Loop through all monitors to find the leftmost monitor + for (const [_, rendered] of Object.entries(monitorsInfos.rendered)) { + if (rendered?.left < lowestLeftValue) { + lowestLeftValue = rendered.left; + } + } + + return lowestLeftValue; + } + /** * Update monitorsInfos object with current monitors count and map. * @@ -623,13 +642,20 @@ angular.module('client').factory('guacManageMonitor', ['$injector', for (const [id, pos] of Object.entries(monitorsInfos.map)) { - // If the monitor is not in the layout, it is not known by - // guacd anymore, so we close it + // Track absence of the monitor. Only close it if it has been missing + // for 8 consecutive layout updates. if (!layout[pos]) { - service.closeMonitor(id); + missingCounts[id] = (missingCounts[id] || 0) + 1; + if (missingCounts[id] >= 8) { + service.closeMonitor(id); + delete missingCounts[id]; + } continue; } + // Reset the missing count when the monitor is present in the layout + missingCounts[id] = 0; + if (!monitorsInfos.rendered[id]) monitorsInfos.rendered[id] = {}; @@ -658,6 +684,16 @@ angular.module('client').factory('guacManageMonitor', ['$injector', } + /** + * Return the current monitorsInfos object. + * + * @returns {Object} + * The monitorsInfos object. + */ + service.getMonitorsInfos = function getMonitorsInfos() { + return monitorsInfos; + }; + // Close additional monitors when window is unloaded $window.addEventListener('unload', service.closeAllMonitors); From 77ee0db791ab0973d7abdf275e8e459c8994979c Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Sun, 5 Jul 2026 15:16:34 -0300 Subject: [PATCH 23/33] GUACAMOLE-288: debounce resize storm, bound DPR, suppress resize echoes Coalesce rapid element-resize events into a single size request (250ms debounce), cap the effective devicePixelRatio so a single monitor and the combined width stay within a sane bound, and ignore the element resize echoes that follow a server-driven display resize for a short window. Together these break the resize feedback loop that froze the main window and let the combined display settle instead of oscillating. --- .../src/app/client/directives/guacClient.js | 113 +++++++++++++++--- .../client/directives/guacClientSecondary.js | 100 +++++++++++++--- 2 files changed, 182 insertions(+), 31 deletions(-) diff --git a/guacamole/src/main/frontend/src/app/client/directives/guacClient.js b/guacamole/src/main/frontend/src/app/client/directives/guacClient.js index 19d2c6e61a..fe8df1d356 100644 --- a/guacamole/src/main/frontend/src/app/client/directives/guacClient.js +++ b/guacamole/src/main/frontend/src/app/client/directives/guacClient.js @@ -57,6 +57,7 @@ angular.module('client').directive('guacClient', [function guacClient() { const $rootScope = $injector.get('$rootScope'); const $window = $injector.get('$window'); const guacManageMonitor = $injector.get('guacManageMonitor'); + const $timeout = $injector.get('$timeout'); /** * Whether the local, hardware mouse cursor is in use. @@ -424,6 +425,15 @@ angular.module('client').directive('guacClient', [function guacClient() { // Update scale when display is resized $scope.$watch('client.managedDisplay.size', function setDisplaySize() { + + // A remote (server-initiated) display resize rescales the display + // element, which changes the measured size of the container and + // fires the element-resize sensor. Forwarding that echoed size back + // to the server produces a resize feedback loop (the display never + // settles, and with multiple monitors the guest thrashes). Suppress + // outbound resize requests briefly so the echo is absorbed. + suppressSendUntil = Date.now() + 700; + $scope.$evalAsync(updateDisplayScale); }); @@ -491,6 +501,44 @@ angular.module('client').directive('guacClient', [function guacClient() { $scope.client.clientProperties.scale = $scope.client.clientProperties.minScale; }); + /** + * Sends the current size of the main element (the display container) + * to the Guacamole server, requesting that the remote display be + * resized. If the Guacamole client is not yet connected, it will be + * connected and the current size will sent through the initial + * handshake. If the size of the main element is not yet known, this + * function may need to be invoked multiple times until the size is + * known and the client may be connected. + */ + /** + * Promise representing the pending resize timeout. + */ + let resizePromise = null; + + /** + * Timestamp (in milliseconds) before which outbound resize requests are + * suppressed because the remote display was just resized. Element-resize + * events fired during this window are echoes of that remote resize (the + * display rescale changes the container's measured size) rather than + * genuine user intent, and forwarding them back to the server produces a + * resize feedback loop. + * + * @type Number + */ + let suppressSendUntil = 0; + + /** + * The maximum width or height, in pixels, that will be requested for a + * single monitor. The QXL virtual GPU used by SPICE guests advertises a + * per-head EDID maximum (typically 2560x1600); requesting a larger mode + * is silently capped by the guest, so the requested and actual sizes + * never converge and the display keeps resizing. Capping the request to + * this bound keeps the requested size within what the guest can honor. + * + * @type Number + */ + const MAX_MONITOR_DIMENSION = 2560; + /** * Sends the current size of the main element (the display container) * to the Guacamole server, requesting that the remote display be @@ -508,21 +556,56 @@ angular.module('client').directive('guacClient', [function guacClient() { // Connect, if not already connected ManagedClient.connect($scope.client, main.offsetWidth, main.offsetHeight); - const pixelDensity = $window.devicePixelRatio || 1; - const width = main.offsetWidth * pixelDensity; - const height = main.offsetHeight * pixelDensity; - const top = window.screenY; - const left = window.screenX; - - // Window resized - if (display.getWidth() !== width || display.getHeight() !== height) - guacManageMonitor.sendSize(client, { - width: width, - height: height, - monitorId: 0, - top: top, - left: left, - }); + if (resizePromise) { + $timeout.cancel(resizePromise); + } + + resizePromise = $timeout(function() { + if (!client || !display || !main.offsetWidth || !main.offsetHeight) + return; + + // Ignore element-resize echoes triggered by a recent remote + // display resize; re-check once the suppression window has + // elapsed so a genuine pending resize is not lost. + const now = Date.now(); + if (now < suppressSendUntil) { + resizePromise = $timeout($scope.mainElementResized, + suppressSendUntil - now); + return; + } + + const basePixelDensity = $window.devicePixelRatio || 1; + let otherWidths = 0; + const monitorsInfos = guacManageMonitor.getMonitorsInfos(); + if (monitorsInfos && monitorsInfos.details) { + for (const [id, details] of Object.entries(monitorsInfos.details)) { + if (String(id) !== "0") { + otherWidths += details.width || 0; + } + } + } + const maxAllowedSingleDPR = MAX_MONITOR_DIMENSION / main.offsetWidth; + const maxAllowedCombinedDPR = (4096 - otherWidths) / main.offsetWidth; + const pixelDensity = Math.max(1, Math.min(basePixelDensity, maxAllowedSingleDPR, maxAllowedCombinedDPR)); + + // Cap each dimension to what the guest can actually honor so + // the requested and actual sizes converge + const width = Math.min(main.offsetWidth * pixelDensity, MAX_MONITOR_DIMENSION); + const height = Math.min(main.offsetHeight * pixelDensity, MAX_MONITOR_DIMENSION); + const top = window.screenY; + const left = window.screenX; + + // Window resized + if (display.getWidth() !== width || display.getHeight() !== height) { + guacManageMonitor.sendSize(client, { + width: width, + height: height, + monitorId: 0, + top: top, + left: left, + }); + } + }, 250); } diff --git a/guacamole/src/main/frontend/src/app/client/directives/guacClientSecondary.js b/guacamole/src/main/frontend/src/app/client/directives/guacClientSecondary.js index f677d72467..41ce7b8171 100644 --- a/guacamole/src/main/frontend/src/app/client/directives/guacClientSecondary.js +++ b/guacamole/src/main/frontend/src/app/client/directives/guacClientSecondary.js @@ -50,6 +50,7 @@ angular.module('client').directive('guacClientSecondary', [function guacClient() const $window = $injector.get('$window'); const clipboardService = $injector.get('clipboardService'); const guacManageMonitor = $injector.get('guacManageMonitor'); + const $timeout = $injector.get('$timeout'); /** * The current Guacamole client instance. @@ -123,30 +124,97 @@ angular.module('client').directive('guacClientSecondary', [function guacClient() return false; }; + /** + * Promise representing the pending resize timeout. + */ + let resizePromise = null; + + /** + * Timestamp (in milliseconds) before which outbound resize requests are + * suppressed because the display was just resized by the server. Element + * resizes during this window are echoes of that remote resize rather than + * genuine user intent; forwarding them back produces a resize feedback + * loop that prevents the (combined) display from ever settling. + * + * @type Number + */ + let suppressSendUntil = 0; + + /** + * The maximum width or height, in pixels, that will be requested for + * this monitor. Matches the QXL per-head EDID maximum so that requested + * and actual sizes converge rather than the guest silently capping an + * over-large request. See guacClient (primary) for details. + * + * @type Number + */ + const MAX_MONITOR_DIMENSION = 2560; + + // Suppress the resize echo produced when the server resizes this display + display.onresize = function displayResized() { + suppressSendUntil = Date.now() + 700; + }; + // Adjust the display scaling according to the window size. $scope.mainElementResized = function mainElementResized() { - const pixelDensity = $window.devicePixelRatio ?? 1; - const width = main.offsetWidth * pixelDensity; - const height = main.offsetHeight * pixelDensity; - const top = window.screenY; - const left = window.screenX; + if (resizePromise) { + $timeout.cancel(resizePromise); + } - const size = { - width: width, - height: height, - top: top, - left: left, - monitorId: guacManageMonitor.monitorId, - }; + resizePromise = $timeout(function() { + if (!main.offsetWidth || !main.offsetHeight) + return; + + // Ignore element-resize echoes triggered by a recent remote + // display resize; re-check once the window has elapsed so a + // genuine pending resize is not lost. + const now = Date.now(); + if (now < suppressSendUntil) { + resizePromise = $timeout($scope.mainElementResized, + suppressSendUntil - now); + return; + } + + const basePixelDensity = $window.devicePixelRatio || 1; + let otherWidths = 0; + const monitorsInfos = guacManageMonitor.getMonitorsInfos(); + const currentMonitorId = guacManageMonitor.monitorId; + if (monitorsInfos && monitorsInfos.details) { + for (const [id, details] of Object.entries(monitorsInfos.details)) { + if (String(id) !== String(currentMonitorId)) { + otherWidths += details.width || 0; + } + } + } + const maxAllowedSingleDPR = MAX_MONITOR_DIMENSION / main.offsetWidth; + const maxAllowedCombinedDPR = (4096 - otherWidths) / main.offsetWidth; + const pixelDensity = Math.max(1, Math.min(basePixelDensity, maxAllowedSingleDPR, maxAllowedCombinedDPR)); + + const width = Math.min(main.offsetWidth * pixelDensity, MAX_MONITOR_DIMENSION); + const height = Math.min(main.offsetHeight * pixelDensity, MAX_MONITOR_DIMENSION); + const top = window.screenY; + const left = window.screenX; + + const size = { + width: width, + height: height, + top: top, + left: left, + monitorId: guacManageMonitor.monitorId, + }; - // Send resize event to main window - guacManageMonitor.pushBroadcastMessage('size', size); + // Send resize event to main window + guacManageMonitor.pushBroadcastMessage('size', size); + }, 250); // Remove scrollbars - document.querySelector('.client-main').style.overflow = 'hidden'; + const clientMain = document.querySelector('.client-main'); + if (clientMain) { + clientMain.style.overflow = 'hidden'; + } - } + }; // Ready for resize $scope.mainElementResized(); From a013c243162e20fe1b9b970162eb9607d803ab22 Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Sun, 5 Jul 2026 16:25:47 -0300 Subject: [PATCH 24/33] spice: add recording-include-clipboard connection parameter Expose the SPICE recording-include-clipboard parameter (server #7) in the connection schema and add its English field label, matching RDP and VNC. --- .../main/resources/org/apache/guacamole/protocols/spice.json | 5 +++++ guacamole/src/main/frontend/src/translations/en.json | 1 + 2 files changed, 6 insertions(+) diff --git a/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/spice.json b/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/spice.json index 93249608fe..9e30ea54c1 100644 --- a/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/spice.json +++ b/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/spice.json @@ -329,6 +329,11 @@ "type" : "BOOLEAN", "options" : [ "true" ] }, + { + "name" : "recording-include-clipboard", + "type" : "BOOLEAN", + "options" : [ "true" ] + }, { "name" : "create-recording-path", "type" : "BOOLEAN", diff --git a/guacamole/src/main/frontend/src/translations/en.json b/guacamole/src/main/frontend/src/translations/en.json index 979a8c058b..5fb013fb06 100644 --- a/guacamole/src/main/frontend/src/translations/en.json +++ b/guacamole/src/main/frontend/src/translations/en.json @@ -804,6 +804,7 @@ "FIELD_HEADER_RECORDING_EXCLUDE_MOUSE" : "Exclude mouse:", "FIELD_HEADER_RECORDING_EXCLUDE_OUTPUT" : "Exclude graphics/streams:", "FIELD_HEADER_RECORDING_INCLUDE_KEYS" : "Include key events:", + "FIELD_HEADER_RECORDING_INCLUDE_CLIPBOARD" : "Include clipboard events:", "FIELD_HEADER_RECORDING_NAME" : "Recording name:", "FIELD_HEADER_RECORDING_PATH" : "Recording path:", "FIELD_HEADER_RECORDING_WRITE_EXISTING" : "@:APP.FIELD_HEADER_RECORDING_WRITE_EXISTING", From 079457a9652d4c2f0cf4f449f472fc085eff80ab Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Sun, 5 Jul 2026 19:59:17 -0300 Subject: [PATCH 25/33] spice: add file-transfer-mode selector for agent vs shared-folder uploads (#14) Expose the new file-transfer-mode connection parameter as an ENUM (none | agent | drive | both) in the SPICE connection form, with translated labels distinguishing the bidirectional shared folder from the upload-only direct-to-guest SPICE agent transfer. --- .../resources/org/apache/guacamole/protocols/spice.json | 5 +++++ guacamole/src/main/frontend/src/translations/en.json | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/spice.json b/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/spice.json index 9e30ea54c1..7e6143456b 100644 --- a/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/spice.json +++ b/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/spice.json @@ -188,6 +188,11 @@ { "name" : "file-transfer", "fields" : [ + { + "name" : "file-transfer-mode", + "type" : "ENUM", + "options" : [ "", "none", "agent", "drive", "both" ] + }, { "name" : "enable-drive", "type" : "BOOLEAN", diff --git a/guacamole/src/main/frontend/src/translations/en.json b/guacamole/src/main/frontend/src/translations/en.json index 5fb013fb06..e8067b11ca 100644 --- a/guacamole/src/main/frontend/src/translations/en.json +++ b/guacamole/src/main/frontend/src/translations/en.json @@ -791,6 +791,7 @@ "FIELD_HEADER_FILE_DIRECTORY" : "Directory to share:", "FIELD_HEADER_FILE_TRANSFER" : "Enable file transfer:", "FIELD_HEADER_FILE_TRANSFER_CREATE_FOLDER": "Automatically create file transfer folder:", + "FIELD_HEADER_FILE_TRANSFER_MODE" : "File transfer mode:", "FIELD_HEADER_FILE_TRANSFER_RO" : "Share directory read-only:", "FIELD_HEADER_HOSTNAME" : "Hostname:", "FIELD_HEADER_IGNORE_CERT" : "Ignore server certificate:", @@ -840,6 +841,12 @@ "FIELD_OPTION_COLOR_DEPTH_32" : "True color (32-bit)", "FIELD_OPTION_COLOR_DEPTH_EMPTY" : "", + "FIELD_OPTION_FILE_TRANSFER_MODE_EMPTY" : "", + "FIELD_OPTION_FILE_TRANSFER_MODE_NONE" : "Disabled", + "FIELD_OPTION_FILE_TRANSFER_MODE_AGENT" : "Direct upload to guest (SPICE agent, upload-only)", + "FIELD_OPTION_FILE_TRANSFER_MODE_DRIVE" : "Shared folder (WebDAV, bidirectional)", + "FIELD_OPTION_FILE_TRANSFER_MODE_BOTH" : "Both (shared folder for browsing, direct upload for drag-and-drop)", + "FIELD_OPTION_PREFERRED_COMPRESSION_AUTO_GLZ" : "Auto GLZ", "FIELD_OPTION_PREFERRED_COMPRESSION_AUTO_LZ" : "Auto LZ", "FIELD_OPTION_PREFERRED_COMPRESSION_EMPTY" : "", From 4a929f1e5cca8668296f782620c1cb73e5b3ddcd Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Sun, 5 Jul 2026 22:05:07 -0300 Subject: [PATCH 26/33] spice: namespace multi-monitor BroadcastChannel per connection (security) Fixes finding M1 (Medium) + L2 (Low) from the SPICE security assessment (ciroiriarte/guacamole-server#21, tracked as ciroiriarte/guacamole-client#29). The multi-monitor windows shared a single hard-coded same-origin BroadcastChannel('guac_monitors') with no per-message provenance. With two concurrent connections open, a secondary window's keystrokes (possibly typed credentials) were injected into the other connection's guacd session and its display stream was mirrored across the connection boundary. The primary now generates a per-connection channel id, passes it to secondary windows via their route (/secondaryMonitor/:id/:channel?), and namespaces the channel as 'guac_monitors_'. Every message is tagged with the id and inbound messages carrying a foreign id are dropped before dispatch (also closes L2: unauthenticated broadcast -> client.runHandler). --- .../controllers/secondaryMonitorController.js | 5 +- .../app/client/services/guacManageMonitor.js | 72 +++++++++++++++---- .../src/app/index/config/indexRouteConfig.js | 5 +- 3 files changed, 65 insertions(+), 17 deletions(-) diff --git a/guacamole/src/main/frontend/src/app/client/controllers/secondaryMonitorController.js b/guacamole/src/main/frontend/src/app/client/controllers/secondaryMonitorController.js index 40c7f8c355..a347960c37 100644 --- a/guacamole/src/main/frontend/src/app/client/controllers/secondaryMonitorController.js +++ b/guacamole/src/main/frontend/src/app/client/controllers/secondaryMonitorController.js @@ -45,7 +45,10 @@ angular.module('client').controller('secondaryMonitorController', ['$scope', '$i CTRL_KEYS = {0xFFE3 : true, 0xFFE4 : true}, MENU_KEYS = angular.extend({}, SHIFT_KEYS, ALT_KEYS, CTRL_KEYS); - guacManageMonitor.init("secondary"); + // Join the per-connection broadcast channel identified by the opener's + // primary window (passed in the route), so this secondary only exchanges + // data with its own connection. + guacManageMonitor.init("secondary", $routeParams.channel); guacManageMonitor.monitorId = monitorId; guacManageMonitor.openConsentButton = function openConsentButton() { diff --git a/guacamole/src/main/frontend/src/app/client/services/guacManageMonitor.js b/guacamole/src/main/frontend/src/app/client/services/guacManageMonitor.js index 3da6d0c681..e2a415ea44 100644 --- a/guacamole/src/main/frontend/src/app/client/services/guacManageMonitor.js +++ b/guacamole/src/main/frontend/src/app/client/services/guacManageMonitor.js @@ -57,11 +57,35 @@ angular.module('client').factory('guacManageMonitor', ['$injector', /** * The broadcast channel used for communications between all windows. - * + * * @type BroadcastChannel */ let broadcast = null; + /** + * A per-connection identifier used to namespace the broadcast channel and + * to tag/validate messages. All windows belonging to the SAME connection + * share this id; windows of other connections do not. This isolates + * multi-monitor display/input traffic to a single connection, preventing + * cross-connection leakage or injection between concurrent connections in + * the same browser origin. + * + * @type String + */ + let channelId = null; + + /** + * Generates a new, hard-to-guess per-connection channel identifier. + * + * @returns {String} + * A unique channel identifier. + */ + function generateChannelId() { + if (window.crypto && window.crypto.randomUUID) + return window.crypto.randomUUID(); + return 'c' + Date.now() + '-' + Math.floor(Math.random() * 1e9); + } + /** * The maximum number of secondary monitors allowed. * @@ -120,12 +144,22 @@ angular.module('client').factory('guacManageMonitor', ['$injector', * * @param {String} type * The type of the monitor. "primary" if not given. + * + * @param {String} [id] + * The per-connection channel identifier. Supplied to secondary windows + * (via their opener URL) so they join the same channel as their + * primary. If omitted (primary window), a new one is generated. */ - service.init = function init(type) { + service.init = function init(type, id) { // Change the monitor type if (type) monitorType = type; + // Establish the per-connection channel id (generated by the primary, + // inherited by secondaries) so only windows of the same connection + // share a broadcast channel. + channelId = id || channelId || generateChannelId(); + if (monitorType == "primary") { guacFullscreen.onfullscreen = function onfullscreen(state) { service.pushBroadcastMessage('fullscreen', state); @@ -136,17 +170,24 @@ angular.module('client').factory('guacManageMonitor', ['$injector', if (!service.supported()) return; - // TODO: ensure that there is no mixing of data between multiple connections - broadcast = new BroadcastChannel('guac_monitors'); + // Namespace the channel per connection so data is never mixed between + // multiple concurrent connections in the same browser origin. + broadcast = new BroadcastChannel('guac_monitors_' + channelId); /** - * Handle messages sent by secondary monitors windows in - * guac_monitors channel. - * - * @param {Event} e - * Received message event from guac_monitors channel. + * Handle messages sent by other windows of THIS connection on the + * broadcast channel. Messages that do not carry this connection's + * channel id are ignored (defense-in-depth against any same-origin + * sender on the channel). + * + * @param {MessageEvent} e + * Received message event from the broadcast channel. */ - broadcast.onmessage = messageHandlers[monitorType]; + broadcast.onmessage = function onmessage(e) { + if (!e || !e.data || e.data._cid !== channelId) + return; + messageHandlers[monitorType](e); + }; // Check the window position every second and send a resize event if it // has changed @@ -305,9 +346,11 @@ angular.module('client').factory('guacManageMonitor', ['$injector', if (monitorType === "primary" && service.getMonitorCount() <= 1) return; - // Format message content + // Format message content, tagging it with this connection's channel id + // so receivers can reject any foreign traffic. const message = { - [type]: content + [type]: content, + _cid: channelId }; // Send message on the broadcast channel @@ -323,8 +366,9 @@ angular.module('client').factory('guacManageMonitor', ['$injector', // New monitor id lastMonitorId++; - // New window parameters - const windowUrl = './#/secondaryMonitor/' + lastMonitorId; + // New window parameters. The channel id is carried in the URL so the + // secondary window joins this connection's channel (and no other). + const windowUrl = './#/secondaryMonitor/' + lastMonitorId + '/' + channelId; const windowId = 'monitor' + lastMonitorId; const windowSize = 'width=800,height=600'; diff --git a/guacamole/src/main/frontend/src/app/index/config/indexRouteConfig.js b/guacamole/src/main/frontend/src/app/index/config/indexRouteConfig.js index b138c26e31..566cb112b1 100644 --- a/guacamole/src/main/frontend/src/app/index/config/indexRouteConfig.js +++ b/guacamole/src/main/frontend/src/app/index/config/indexRouteConfig.js @@ -215,8 +215,9 @@ angular.module('index').config(['$routeProvider', '$locationProvider', resolve : { updateCurrentToken: updateCurrentToken } }) - // Secondary monitor view - .when('/secondaryMonitor/:id', { + // Secondary monitor view (:channel namespaces the per-connection + // broadcast channel shared with the opening primary window) + .when('/secondaryMonitor/:id/:channel?', { bodyClassName : 'secondaryMonitor', templateUrl : 'app/client/templates/secondaryMonitor.html', controller : 'secondaryMonitorController', From 9d4bea78da52aaa11975c0cd1008e7b02088b915 Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Sun, 5 Jul 2026 23:46:39 -0300 Subject: [PATCH 27/33] Crop display immediately on monitor size changes --- .../src/main/webapp/modules/Display.js | 10 ++- .../src/test/javascript/DisplaySpec.js | 66 +++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 guacamole-common-js/src/test/javascript/DisplaySpec.js diff --git a/guacamole-common-js/src/main/webapp/modules/Display.js b/guacamole-common-js/src/main/webapp/modules/Display.js index a70fd621fc..d0a263471f 100644 --- a/guacamole-common-js/src/main/webapp/modules/Display.js +++ b/guacamole-common-js/src/main/webapp/modules/Display.js @@ -765,7 +765,15 @@ Guacamole.Display = function() { this.setMonitorSize = function setMonitorSize(width, height) { monitorWidth = width; monitorHeight = height; - } + + // A monitor layout update must take effect immediately. The Guacamole + // server may have already resized the default layer to the size of the + // overall desktop, and may not send another resize after the per-window + // monitor rectangle is known. Re-run the resize path to authoritatively + // clamp the visible display to the current monitor bounds. + if (displayWidth !== monitorWidth || displayHeight !== monitorHeight) + guac_display.resize(default_layer, displayWidth, displayHeight); + }; /** * Changes the size of the given Layer to the given width and height. diff --git a/guacamole-common-js/src/test/javascript/DisplaySpec.js b/guacamole-common-js/src/test/javascript/DisplaySpec.js new file mode 100644 index 0000000000..0388e08797 --- /dev/null +++ b/guacamole-common-js/src/test/javascript/DisplaySpec.js @@ -0,0 +1,66 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/* global Guacamole, expect */ + +describe("Guacamole.Display", function DisplaySpec() { + + /** + * The display under test. + * + * @type {!Guacamole.Display} + */ + var display; + + beforeEach(function() { + display = new Guacamole.Display(); + }); + + it("should re-crop the default layer when monitor size changes", function() { + + display.resize(display.getDefaultLayer(), 5120, 1598); + display.flush(); + + expect(display.getWidth()).toBe(5120); + expect(display.getHeight()).toBe(1598); + + display.setMonitorSize(2560, 1422); + display.flush(); + + expect(display.getDefaultLayer().width).toBe(2560); + expect(display.getDefaultLayer().height).toBe(1422); + expect(display.getWidth()).toBe(2560); + expect(display.getHeight()).toBe(1422); + + }); + + it("should constrain later default layer resizes to current monitor size", function() { + + display.setMonitorSize(2560, 1422); + display.resize(display.getDefaultLayer(), 5120, 1598); + display.flush(); + + expect(display.getDefaultLayer().width).toBe(2560); + expect(display.getDefaultLayer().height).toBe(1422); + expect(display.getWidth()).toBe(2560); + expect(display.getHeight()).toBe(1422); + + }); + +}); From 3f991a4f4993d696adfbf63619a699b3d4ba16b2 Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Tue, 7 Jul 2026 23:04:01 -0300 Subject: [PATCH 28/33] spice: show image clipboard as thumbnails in recording playback Image clipboard events in a session recording previously rendered as the literal text "[Clipboard: [Binary data]]": ClipboardEventInterpreter ran a UTF-8 text decode on every stream regardless of mimetype, and the display service discarded the mimetype before rendering. Handle image/* clipboard streams distinctly: keep the base64 payload and expose it as a (lowercased-scheme) data: URL plus a byte size, instead of forcing it through the text decode. The player now renders image clipboard events as an inline thumbnail chip showing mimetype and size, with a click-to-enlarge lightbox (Escape or click-out to dismiss). A searchable synthetic label ("[Clipboard image: mimetype size]") keeps image events matchable by the existing key-log text filter. Text clipboard events are unchanged. Also document, in SessionRecording.js, that onkeyevents/onclipboardevents are delivered only on the tunnel-CLOSED path; a Blob-sourced recording never fires them (the bundled player feeds a StaticHTTPTunnel). Direction annotation (guest-to-client vs client-to-guest), recorded by the server as a log instruction, is not yet surfaced during playback and remains a follow-up. --- .../modules/ClipboardEventInterpreter.js | 91 +++++++++++++++++-- .../main/webapp/modules/SessionRecording.js | 14 ++- .../src/app/player/directives/textView.js | 49 ++++++++++ .../player/services/keyEventDisplayService.js | 78 ++++++++++++++-- .../src/app/player/styles/textView.css | 75 +++++++++++++++ .../src/app/player/templates/textView.html | 20 +++- 6 files changed, 306 insertions(+), 21 deletions(-) diff --git a/guacamole-common-js/src/main/webapp/modules/ClipboardEventInterpreter.js b/guacamole-common-js/src/main/webapp/modules/ClipboardEventInterpreter.js index 2376413593..000e6e1f20 100644 --- a/guacamole-common-js/src/main/webapp/modules/ClipboardEventInterpreter.js +++ b/guacamole-common-js/src/main/webapp/modules/ClipboardEventInterpreter.js @@ -103,6 +103,28 @@ Guacamole.ClipboardEventInterpreter = function ClipboardEventInterpreter(startTi stream.data += base64Data; }; + /** + * Returns the number of decoded bytes represented by the given base64 + * string, without actually decoding it. Handles both padded and unpadded + * base64; Guacamole blob payloads are newline-free, so length arithmetic + * is exact. + * + * @private + * @param {string} base64 + * The base64-encoded string to measure. + * + * @returns {number} + * The size, in bytes, of the decoded data. + */ + var base64ByteLength = function base64ByteLength(base64) { + if (!base64) + return 0; + var padding = 0; + if (base64.charAt(base64.length - 1) === '=') padding++; + if (base64.charAt(base64.length - 2) === '=') padding++; + return Math.max(0, Math.floor(base64.length * 3 / 4) - padding); + }; + /** * Handles an end instruction, which completes a clipboard stream * and creates the final clipboard event. @@ -115,18 +137,39 @@ Guacamole.ClipboardEventInterpreter = function ClipboardEventInterpreter(startTi var stream = activeStreams[streamIndex]; if (stream) { - // Decode the base64 data + + var isImage = /^image\//i.test(stream.mimetype || ''); + var decodedData = ''; - try { - decodedData = atob(stream.data); - // Handle UTF-8 decoding - decodedData = decodeURIComponent(escape(decodedData)); - } catch (e) { - // If decoding fails, use raw decoded data or mark as binary + var dataURL = null; + var size = 0; + + // Image clipboard: keep the base64 payload intact and expose it + // as a data: URL for inline preview. Do NOT run the text/UTF-8 + // decode used below - binary image bytes are not valid UTF-8 and + // would otherwise collapse to the literal string '[Binary data]'. + if (isImage) { + // Lowercase the mimetype in the data: scheme - AngularJS's + // img-src sanitizer whitelists "data:image/" case-sensitively, + // so an uppercase mimetype would be rewritten to "unsafe:" and + // silently fail to render. + dataURL = 'data:' + (stream.mimetype || '').toLowerCase() + + ';base64,' + stream.data; + size = base64ByteLength(stream.data); + } + + // Text clipboard: decode the base64 data, then interpret as UTF-8 + else { try { decodedData = atob(stream.data); - } catch (e2) { - decodedData = '[Binary data]'; + decodedData = decodeURIComponent(escape(decodedData)); + } catch (e) { + // If decoding fails, use raw decoded data or mark as binary + try { + decodedData = atob(stream.data); + } catch (e2) { + decodedData = '[Binary data]'; + } } } @@ -139,6 +182,9 @@ Guacamole.ClipboardEventInterpreter = function ClipboardEventInterpreter(startTi parsedEvents.push(new Guacamole.ClipboardEventInterpreter.ClipboardEvent({ mimetype: stream.mimetype, data: decodedData, + isImage: isImage, + dataURL: dataURL, + size: size, timestamp: Math.max(0, stream.timestamp - startTimestamp) })); @@ -178,12 +224,37 @@ Guacamole.ClipboardEventInterpreter.ClipboardEvent = function ClipboardEvent(tem this.mimetype = template.mimetype || 'text/plain'; /** - * The clipboard content (decoded from base64). + * The clipboard content (decoded from base64). Empty for image clipboard + * events, whose payload is exposed via dataURL instead. * * @type {!string} */ this.data = template.data || ''; + /** + * Whether this clipboard event carries image data (mimetype image/*) + * rather than text. + * + * @type {!boolean} + */ + this.isImage = template.isImage || false; + + /** + * For image clipboard events, a data: URL suitable for direct use as an + * source. Null for text clipboard events. + * + * @type {string} + */ + this.dataURL = template.dataURL || null; + + /** + * The size, in bytes, of the clipboard payload. Only meaningful for + * image clipboard events; 0 for text. + * + * @type {!number} + */ + this.size = template.size || 0; + /** * The timestamp when this clipboard event occurred, relative to * the start of the recording. diff --git a/guacamole-common-js/src/main/webapp/modules/SessionRecording.js b/guacamole-common-js/src/main/webapp/modules/SessionRecording.js index 1dc2e9acd2..7504cb59fb 100644 --- a/guacamole-common-js/src/main/webapp/modules/SessionRecording.js +++ b/guacamole-common-js/src/main/webapp/modules/SessionRecording.js @@ -541,6 +541,15 @@ Guacamole.SessionRecording = function SessionRecording(source, refreshInterval) }; // Read instructions from provided blob, extracting each frame + // + // NOTE: The onkeyevents and onclipboardevents callbacks are only fired + // from the tunnel branch below (on tunnel CLOSED). When the recording is + // supplied as a Blob, only onload (via notifyLoaded) fires — the extracted + // key-event and clipboard-event logs are NOT delivered. The bundled player + // avoids this by feeding a StaticHTTPTunnel (see settings/.../ + // connectionHistoryPlayerController.js), so both logs work today. If a + // future caller loads a recording from a Blob and expects the key/clipboard + // logs, fire those callbacks from the parseBlob completion handler here too. if (source instanceof Blob) { recordingBlob = source; parseBlob(recordingBlob, loadInstruction, notifyLoaded); @@ -594,7 +603,10 @@ Guacamole.SessionRecording = function SessionRecording(source, refreshInterval) } // Now that the recording is fully processed, and all key events - // have been extracted, call the onkeyevents handler if defined + // have been extracted, call the onkeyevents handler if defined. + // NOTE: This (and the onclipboardevents call below) fire ONLY on + // this tunnel-CLOSED path — a Blob source never reaches here. + // See the Blob branch above before changing how recordings load. if (recording.onkeyevents && keyEventInterpreter) recording.onkeyevents(keyEventInterpreter.getEvents()); diff --git a/guacamole/src/main/frontend/src/app/player/directives/textView.js b/guacamole/src/main/frontend/src/app/player/directives/textView.js index 6e0ea060c9..b7e3885af6 100644 --- a/guacamole/src/main/frontend/src/app/player/directives/textView.js +++ b/guacamole/src/main/frontend/src/app/player/directives/textView.js @@ -123,6 +123,55 @@ angular.module('player').directive('guacPlayerTextView', // Reapply the filter whenever the search phrase is updated $scope.$watch('searchPhrase', applyFilter); + /** + * The clipboard image currently shown enlarged in the lightbox, or + * null if the lightbox is closed. Objects here are the `clipboard` + * metadata attached to an image clipboard event (see + * keyEventDisplayService). + * + * @type {Object} + */ + $scope.lightboxImage = null; + + /** + * Handler which dismisses the lightbox when the Escape key is pressed. + * Bound to the document only while the lightbox is open. + * + * @param {KeyboardEvent} e + * The keydown event. + */ + const dismissOnEscape = function dismissOnEscape(e) { + if (e.keyCode === 27) // Escape + $scope.$apply(function applyClose() { + $scope.closeImage(); + }); + }; + + /** + * Opens the clipboard-image lightbox for the given image clipboard + * event metadata. + * + * @param {Object} clipboard + * The clipboard metadata (including dataURL) to display enlarged. + */ + $scope.openImage = function openImage(clipboard) { + $scope.lightboxImage = clipboard; + angular.element(document).on('keydown', dismissOnEscape); + }; + + /** + * Closes the clipboard-image lightbox, if open. + */ + $scope.closeImage = function closeImage() { + $scope.lightboxImage = null; + angular.element(document).off('keydown', dismissOnEscape); + }; + + // Ensure the document-level listener never outlives the directive + $scope.$on('$destroy', function unbindEscape() { + angular.element(document).off('keydown', dismissOnEscape); + }); + /** * @borrows playerTimeService.formatTime */ diff --git a/guacamole/src/main/frontend/src/app/player/services/keyEventDisplayService.js b/guacamole/src/main/frontend/src/app/player/services/keyEventDisplayService.js index 0a9ae6b439..934741a608 100644 --- a/guacamole/src/main/frontend/src/app/player/services/keyEventDisplayService.js +++ b/guacamole/src/main/frontend/src/app/player/services/keyEventDisplayService.js @@ -112,6 +112,26 @@ angular.module('player').factory('keyEventDisplayService', */ const formatKeyName = name => ('<' + name + '>'); + /** + * Format a raw byte count into a compact human-readable string + * (e.g. "24 KB", "1.4 MB"). + * + * @param {Number} bytes + * The number of bytes. + * + * @returns {String} + * A compact, human-readable representation of the given size. + */ + const formatBytes = bytes => { + if (!bytes && bytes !== 0) + return ''; + if (bytes < 1024) + return bytes + ' B'; + if (bytes < 1024 * 1024) + return Math.round(bytes / 1024) + ' KB'; + return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; + }; + const service = {}; /** @@ -187,6 +207,18 @@ angular.module('player').factory('keyEventDisplayService', */ this.timestamp = template.timestamp; + /** + * Clipboard presentation metadata, present only when this event + * represents a clipboard transfer rather than a keystroke. For image + * clipboard events this carries the preview data URL, mimetype, and a + * human-readable size; for text clipboard events it simply flags the + * event so it can be styled distinctly. + * + * @type {{isImage: !boolean, isText: !boolean, mimetype: String, + * dataURL: String, sizeLabel: String}} + */ + this.clipboard = template.clipboard || null; + }; /** @@ -220,11 +252,17 @@ angular.module('player').factory('keyEventDisplayService', if (batchSeparation === undefined || batchSeparation === null) batchSeparation = 5000; - // Convert clipboard events to unified format + // Convert clipboard events to unified format, preserving the mimetype + // and (for images) the preview payload so they can be rendered as + // something richer than truncated text downstream const clipboardAsEvents = (clipboardEvents || []).map(event => ({ isClipboard: true, timestamp: event.timestamp, - data: event.data + data: event.data, + mimetype: event.mimetype, + isImage: event.isImage, + dataURL: event.dataURL, + size: event.size })); // Merge and sort all events by timestamp @@ -274,12 +312,15 @@ angular.module('player').factory('keyEventDisplayService', * Whether the text value would be literally produced by typing * the key that produced the event. */ - const pushEvent = (text, typed) => { + const pushEvent = (text, typed, clipboard) => { const latestEvent = _.last(currentBatch.events); // Only consolidate the event if configured to do so and it - // matches the type of the previous event - if (consolidateEvents && latestEvent && latestEvent.typed === typed) { + // matches the type of the previous event. Clipboard events are + // never consolidated - each is a distinct object with its own + // metadata (and, for images, its own preview). + if (consolidateEvents && latestEvent && latestEvent.typed === typed + && !clipboard && !latestEvent.clipboard) { latestEvent.text += text; currentBatch.simpleValue += text; } @@ -287,7 +328,7 @@ angular.module('player').factory('keyEventDisplayService', // Otherwise, push a new event else { currentBatch.events.push(new service.ConsolidatedKeyEvent({ - text, typed, timestamp + text, typed, timestamp, clipboard })); currentBatch.simpleValue += text; } @@ -295,8 +336,29 @@ angular.module('player').factory('keyEventDisplayService', // Handle clipboard events if (event.isClipboard) { - const preview = (event.data || '').substring(0, 50); - pushEvent('[Clipboard: ' + preview + ']', false); + + // Image clipboard: render as a preview chip rather than text. + // The text still carries a searchable synthetic label so the + // (text-based) key-log filter continues to match it. + if (event.isImage && event.dataURL) { + const sizeLabel = formatBytes(event.size); + pushEvent( + '[Clipboard image: ' + (event.mimetype || 'image') + + ' ' + sizeLabel + ']', + false, + { + isImage: true, + mimetype: event.mimetype, + dataURL: event.dataURL, + sizeLabel: sizeLabel + }); + } + + // Text clipboard: inline truncated preview (unchanged behaviour) + else { + const preview = (event.data || '').substring(0, 50); + pushEvent('[Clipboard: ' + preview + ']', false, { isText: true }); + } } // Handle key events diff --git a/guacamole/src/main/frontend/src/app/player/styles/textView.css b/guacamole/src/main/frontend/src/app/player/styles/textView.css index 0348339ab6..bc304fda9c 100644 --- a/guacamole/src/main/frontend/src/app/player/styles/textView.css +++ b/guacamole/src/main/frontend/src/app/player/styles/textView.css @@ -114,3 +114,78 @@ color: dimgray; } + +/* Clipboard events (both text and image) are rendered as distinct chips + rather than blending into the surrounding typed text. */ +.text-batches .text .clipboard-event { + + color: #555; + font-style: italic; + +} + +/* Image clipboard chip: a small inline thumbnail with type/size metadata. */ +.text-batches .text .clipboard-image-event { + + font-style: normal; + +} + +.text-batches .text .clipboard-image { + + display: inline-flex; + align-items: center; + vertical-align: middle; + gap: 0.4em; + margin: 0.1em 0.2em; + padding: 0.15em 0.35em; + border: 1px solid #bbb; + border-radius: 4px; + background-color: #f4f4f4; + cursor: zoom-in; + +} + +.text-batches .text .clipboard-image .clipboard-thumb { + + max-height: 48px; + max-width: 96px; + display: block; + border: 1px solid #ccc; + background-color: #fff; + +} + +.text-batches .text .clipboard-image .clipboard-meta { + + font-size: 0.85em; + color: #333; + white-space: nowrap; + +} + +/* Full-size preview overlay shown when a thumbnail is clicked. */ +.clipboard-lightbox { + + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background-color: rgba(0, 0, 0, 0.8); + cursor: zoom-out; + z-index: 1000; + +} + +.clipboard-lightbox img { + + max-width: 90%; + max-height: 90%; + box-shadow: 0 0 20px rgba(0, 0, 0, 0.5); + background-color: #fff; + +} diff --git a/guacamole/src/main/frontend/src/app/player/templates/textView.html b/guacamole/src/main/frontend/src/app/player/templates/textView.html index 4cef97639e..57bc0c1823 100644 --- a/guacamole/src/main/frontend/src/app/player/templates/textView.html +++ b/guacamole/src/main/frontend/src/app/player/templates/textView.html @@ -21,9 +21,25 @@ {{ event.text }} + ng-class="{ + 'not-typed' : !event.typed, + 'future': event.timestamp >= currentPosition, + 'clipboard-event': event.clipboard, + 'clipboard-image-event': event.clipboard.isImage + }" + >{{ event.text }}Clipboard image{{ event.clipboard.mimetype }} · {{ event.clipboard.sizeLabel }} + + From 80e478834fdc89cf45b4efb083be569f82c0f576 Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Tue, 7 Jul 2026 23:15:15 -0300 Subject: [PATCH 29/33] spice: surface clipboard direction and downscale image thumbnails in playback Two follow-ups to the image-clipboard playback support. Direction annotation: the server emits a "log" instruction immediately before each clipboard stream recording its direction ("clipboard stream=N direction=guest-to-client|client-to-guest ..."). SessionRecording now routes the log opcode to the clipboard interpreter, which buffers the direction by stream index and attaches it to the resulting clipboard event. The player renders it as a colour-coded badge before each clipboard event (both text and image) - amber "from guest" for data leaving the guest (the exfiltration-relevant case) and neutral "to guest" for pastes into it. Unannotated recordings render exactly as before. Thumbnails: the inline image chip previously embedded the full-resolution data URL and merely scaled it down with CSS, inflating the key-log DOM for recordings with many or large clipboard images. The chip now renders a canvas-downscaled thumbnail (max 96px) and shows the image's natural dimensions, while the click-to-enlarge lightbox keeps the full-resolution image. Thumbnail generation is idempotent, uses $evalAsync from the async image load, and aborts in-flight loads on scope destroy. --- .../modules/ClipboardEventInterpreter.js | 47 ++++++++ .../main/webapp/modules/SessionRecording.js | 7 ++ .../src/app/player/directives/textView.js | 107 +++++++++++++++++- .../player/services/keyEventDisplayService.js | 39 ++++++- .../src/app/player/styles/textView.css | 31 +++++ .../src/app/player/templates/textView.html | 13 ++- 6 files changed, 234 insertions(+), 10 deletions(-) diff --git a/guacamole-common-js/src/main/webapp/modules/ClipboardEventInterpreter.js b/guacamole-common-js/src/main/webapp/modules/ClipboardEventInterpreter.js index 000e6e1f20..eedbcbeeb1 100644 --- a/guacamole-common-js/src/main/webapp/modules/ClipboardEventInterpreter.js +++ b/guacamole-common-js/src/main/webapp/modules/ClipboardEventInterpreter.js @@ -51,6 +51,18 @@ Guacamole.ClipboardEventInterpreter = function ClipboardEventInterpreter(startTi */ var activeStreams = {}; + /** + * Clipboard transfer directions parsed from server-emitted "log" + * instructions, keyed by stream index, awaiting the clipboard stream they + * annotate. The server writes the direction log immediately before the + * corresponding clipboard stream, so it is buffered here until the + * matching clipboard instruction arrives. + * + * @private + * @type {Object.} + */ + var pendingDirections = {}; + /** * The timestamp of the most recent instruction, used for events * that don't have their own timestamp. @@ -83,8 +95,32 @@ Guacamole.ClipboardEventInterpreter = function ClipboardEventInterpreter(startTi activeStreams[streamIndex] = { mimetype: mimetype, data: '', + direction: pendingDirections[streamIndex] || null, timestamp: lastTimestamp }; + + // The buffered direction, if any, has now been consumed + delete pendingDirections[streamIndex]; + }; + + /** + * Handles a log instruction. When the log carries a clipboard direction + * annotation ("clipboard stream=N direction=... mimetype=... [bytes=...]"), + * the direction is buffered against its stream index so it can be attached + * to the clipboard event once that stream arrives. All other log + * instructions are ignored. + * + * @param {!string[]} args + * The arguments: [message] + */ + this.handleLog = function handleLog(args) { + var message = args[0]; + if (!message) + return; + + var match = /^clipboard stream=(\d+) direction=(\S+)/.exec(message); + if (match) + pendingDirections[match[1]] = match[2]; }; /** @@ -185,6 +221,7 @@ Guacamole.ClipboardEventInterpreter = function ClipboardEventInterpreter(startTi isImage: isImage, dataURL: dataURL, size: size, + direction: stream.direction || null, timestamp: Math.max(0, stream.timestamp - startTimestamp) })); @@ -255,6 +292,16 @@ Guacamole.ClipboardEventInterpreter.ClipboardEvent = function ClipboardEvent(tem */ this.size = template.size || 0; + /** + * The direction of this clipboard transfer as annotated by the server, + * either "guest-to-client" (data copied out of the guest) or + * "client-to-guest" (data pasted into the guest), or null if the + * recording carries no direction annotation. + * + * @type {string} + */ + this.direction = template.direction || null; + /** * The timestamp when this clipboard event occurred, relative to * the start of the recording. diff --git a/guacamole-common-js/src/main/webapp/modules/SessionRecording.js b/guacamole-common-js/src/main/webapp/modules/SessionRecording.js index 7504cb59fb..cc8f24bc42 100644 --- a/guacamole-common-js/src/main/webapp/modules/SessionRecording.js +++ b/guacamole-common-js/src/main/webapp/modules/SessionRecording.js @@ -527,6 +527,13 @@ Guacamole.SessionRecording = function SessionRecording(source, refreshInterval) // Handle stream end (may complete clipboard stream) else if (opcode === 'end' && clipboardEventInterpreter) clipboardEventInterpreter.handleEnd(args); + + // Handle log instructions, which may carry the clipboard direction + // annotation emitted by the server immediately before each clipboard + // stream (e.g. "clipboard stream=N direction=guest-to-client ..."). + // Non-clipboard logs are ignored by the interpreter. + else if (opcode === 'log' && clipboardEventInterpreter) + clipboardEventInterpreter.handleLog(args); }; /** diff --git a/guacamole/src/main/frontend/src/app/player/directives/textView.js b/guacamole/src/main/frontend/src/app/player/directives/textView.js index b7e3885af6..f3a2e3320a 100644 --- a/guacamole/src/main/frontend/src/app/player/directives/textView.js +++ b/guacamole/src/main/frontend/src/app/player/directives/textView.js @@ -117,8 +117,111 @@ angular.module('player').directive('guacPlayerTextView', }; - // Reapply the current filter to the updated text batches - $scope.$watch('textBatches', () => applyFilter($scope.searchPhrase)); + /** + * The maximum width/height, in pixels, of a generated clipboard image + * thumbnail. The full-resolution image is retained only for the + * lightbox; the inline chip uses this downscaled copy to keep the + * key-log DOM light even when a recording contains many or large + * clipboard images. + * + * @type {!Number} + */ + const THUMBNAIL_MAX_DIMENSION = 96; + + /** + * Generate a downscaled thumbnail, and capture the natural dimensions, + * for the given image clipboard metadata - asynchronously populating + * its thumbURL, width, and height fields. Falls back to the full data + * URL if the browser cannot rasterize a thumbnail. No-op if the + * thumbnail already exists or is in progress. + * + * @param {Object} clipboard + * The image clipboard metadata to enrich in place. + */ + const pendingImages = new Set(); + + const generateThumbnail = clipboard => { + + // Only process each image event once + if (!clipboard || !clipboard.isImage || !clipboard.dataURL + || clipboard.thumbURL || clipboard.thumbPending) + return; + + clipboard.thumbPending = true; + + const image = new Image(); + pendingImages.add(image); + + image.onload = function thumbnailLoaded() { + + pendingImages.delete(image); + + const w = image.naturalWidth; + const h = image.naturalHeight; + const scale = Math.min(1, THUMBNAIL_MAX_DIMENSION / Math.max(w, h)); + const tw = Math.max(1, Math.round(w * scale)); + const th = Math.max(1, Math.round(h * scale)); + + let thumb = clipboard.dataURL; + try { + const canvas = document.createElement('canvas'); + canvas.width = tw; + canvas.height = th; + canvas.getContext('2d').drawImage(image, 0, 0, tw, th); + thumb = canvas.toDataURL('image/png'); + } + catch (ignore) { + // Keep the full data URL as the thumbnail fallback + } + + $scope.$evalAsync(function applyThumbnail() { + clipboard.width = w; + clipboard.height = h; + clipboard.thumbURL = thumb; + clipboard.thumbPending = false; + }); + }; + + image.onerror = function thumbnailFailed() { + pendingImages.delete(image); + $scope.$evalAsync(function applyFailure() { + clipboard.thumbPending = false; + }); + }; + + image.src = clipboard.dataURL; + + }; + + // Abort any in-flight thumbnail loads when the directive is destroyed + // so their callbacks don't run against a detached scope and their + // closures don't outlive the viewer + $scope.$on('$destroy', function abortPendingThumbnails() { + pendingImages.forEach(function detach(image) { + image.onload = null; + image.onerror = null; + image.src = ''; + }); + pendingImages.clear(); + }); + + /** + * Kick off thumbnail generation for every image clipboard event across + * all current text batches that has not yet been processed. + */ + const generateThumbnails = () => { + ($scope.textBatches || []).forEach(batch => + (batch.events || []).forEach(event => { + if (event.clipboard && event.clipboard.isImage) + generateThumbnail(event.clipboard); + })); + }; + + // Reapply the current filter and refresh thumbnails when batches change + $scope.$watch('textBatches', () => { + applyFilter($scope.searchPhrase); + generateThumbnails(); + }); // Reapply the filter whenever the search phrase is updated $scope.$watch('searchPhrase', applyFilter); diff --git a/guacamole/src/main/frontend/src/app/player/services/keyEventDisplayService.js b/guacamole/src/main/frontend/src/app/player/services/keyEventDisplayService.js index 934741a608..35876e9dde 100644 --- a/guacamole/src/main/frontend/src/app/player/services/keyEventDisplayService.js +++ b/guacamole/src/main/frontend/src/app/player/services/keyEventDisplayService.js @@ -132,6 +132,35 @@ angular.module('player').factory('keyEventDisplayService', return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; }; + /** + * Translate a raw server-annotated clipboard direction into display + * metadata (short label, tooltip, and a CSS class for colour-coding). + * Returns an empty object when the direction is unknown/unannotated. + * + * @param {String} direction + * The raw direction ("guest-to-client" or "client-to-guest"). + * + * @returns {Object} + * Display metadata for the direction, or {} if none. + */ + const directionMeta = direction => { + if (direction === 'guest-to-client') + return { + direction: direction, + directionLabel: 'from guest', + directionTitle: 'Copied from guest (data leaving the guest)', + directionClass: 'from-guest' + }; + if (direction === 'client-to-guest') + return { + direction: direction, + directionLabel: 'to guest', + directionTitle: 'Pasted into guest', + directionClass: 'to-guest' + }; + return {}; + }; + const service = {}; /** @@ -262,7 +291,8 @@ angular.module('player').factory('keyEventDisplayService', mimetype: event.mimetype, isImage: event.isImage, dataURL: event.dataURL, - size: event.size + size: event.size, + direction: event.direction })); // Merge and sort all events by timestamp @@ -346,18 +376,19 @@ angular.module('player').factory('keyEventDisplayService', '[Clipboard image: ' + (event.mimetype || 'image') + ' ' + sizeLabel + ']', false, - { + Object.assign({ isImage: true, mimetype: event.mimetype, dataURL: event.dataURL, sizeLabel: sizeLabel - }); + }, directionMeta(event.direction))); } // Text clipboard: inline truncated preview (unchanged behaviour) else { const preview = (event.data || '').substring(0, 50); - pushEvent('[Clipboard: ' + preview + ']', false, { isText: true }); + pushEvent('[Clipboard: ' + preview + ']', false, + Object.assign({ isText: true }, directionMeta(event.direction))); } } diff --git a/guacamole/src/main/frontend/src/app/player/styles/textView.css b/guacamole/src/main/frontend/src/app/player/styles/textView.css index bc304fda9c..b25aa75800 100644 --- a/guacamole/src/main/frontend/src/app/player/styles/textView.css +++ b/guacamole/src/main/frontend/src/app/player/styles/textView.css @@ -124,6 +124,37 @@ } +/* Directional annotation badge shown before a clipboard event. */ +.text-batches .text .clipboard-direction { + + display: inline-block; + vertical-align: middle; + font-size: 0.75em; + font-style: normal; + font-weight: bold; + text-transform: uppercase; + letter-spacing: 0.03em; + padding: 0.05em 0.4em; + margin-right: 0.35em; + border-radius: 3px; + color: #fff; + +} + +/* Copied out of the guest - highlighted as the exfiltration-relevant case. */ +.text-batches .text .clipboard-direction.from-guest { + + background-color: #b25f00; + +} + +/* Pasted into the guest - neutral. */ +.text-batches .text .clipboard-direction.to-guest { + + background-color: #4a6f8a; + +} + /* Image clipboard chip: a small inline thumbnail with type/size metadata. */ .text-batches .text .clipboard-image-event { diff --git a/guacamole/src/main/frontend/src/app/player/templates/textView.html b/guacamole/src/main/frontend/src/app/player/templates/textView.html index 57bc0c1823..0563b67a10 100644 --- a/guacamole/src/main/frontend/src/app/player/templates/textView.html +++ b/guacamole/src/main/frontend/src/app/player/templates/textView.html @@ -27,13 +27,18 @@ 'clipboard-event': event.clipboard, 'clipboard-image-event': event.clipboard.isImage }" - >{{ event.text }}{{ event.clipboard.directionLabel }}{{ event.text }}Clipboard image{{ event.clipboard.mimetype }} · {{ event.clipboard.sizeLabel }} + class="clipboard-meta">{{ event.clipboard.mimetype }} · {{ event.clipboard.sizeLabel }} · {{ event.clipboard.width }}×{{ event.clipboard.height }} From fe31a444b8cfb7c468fc74372cd5e6b2e19f5517 Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Wed, 8 Jul 2026 00:01:23 -0300 Subject: [PATCH 30/33] spice: harden multimon-layout parsing and hide sensitive argv params Two Low-severity fixes from the SPICE security assessment (ciroiriarte/guacamole-server#21). L3 (CWE-20): the server-supplied `multimon-layout` JSON was fed straight through `JSON.parse` into offset/size math. A malformed payload threw out of the instruction handler loop (client teardown) and non-finite geometry poisoned the offsets with NaN. Client.js now parses and dispatches inside a try/catch and requires a non-null object; guacManageMonitor validates each entry's geometry (finite width/height/top/left, positive dimensions) and treats a malformed entry as an absent monitor. Closes #30. L4 (CWE-1230): values streamed back via `argv` for immutable arguments were reflected into the UI-visible arguments model, which could surface a sensitive parameter (e.g. a password) in the connection-parameters panel. ManagedClient now skips reflecting parameters whose name matches password/passphrase/private-key/client-key/secret/token, keeping secret material out of the UI while leaving public material (host-key, public-key, pubkey) visible. Mutation toward guacd was already rejected server-side. Closes #31. --- .../src/main/webapp/modules/Client.js | 19 ++++++++++++++++-- .../app/client/services/guacManageMonitor.js | 19 ++++++++++++++++-- .../src/app/client/types/ManagedClient.js | 20 +++++++++++++++++++ 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/guacamole-common-js/src/main/webapp/modules/Client.js b/guacamole-common-js/src/main/webapp/modules/Client.js index ae69ad48cf..eae550e366 100644 --- a/guacamole-common-js/src/main/webapp/modules/Client.js +++ b/guacamole-common-js/src/main/webapp/modules/Client.js @@ -1100,8 +1100,23 @@ Guacamole.Client = function(tunnel) { "multimon-layout": function multimonLayout(layer, value) { - if (guac_client.onmultimonlayout) - guac_client.onmultimonlayout(JSON.parse(value)); + if (!guac_client.onmultimonlayout) + return; + + // The layout is supplied by the server (guacd). A malformed payload + // must not throw out of the instruction handler and tear down the + // client, so both the parse and the dispatch are guarded here (the + // transport layer stays self-protecting regardless of what the + // consumer does). Anything that is not a usable object is ignored; + // per-field (finite geometry) validation is handled by the consumer. + try { + var layout = JSON.parse(value); + if (layout && typeof layout === 'object') + guac_client.onmultimonlayout(layout); + } + catch (e) { + return; + } } diff --git a/guacamole/src/main/frontend/src/app/client/services/guacManageMonitor.js b/guacamole/src/main/frontend/src/app/client/services/guacManageMonitor.js index e2a415ea44..08c888cf0d 100644 --- a/guacamole/src/main/frontend/src/app/client/services/guacManageMonitor.js +++ b/guacamole/src/main/frontend/src/app/client/services/guacManageMonitor.js @@ -684,11 +684,26 @@ angular.module('client').factory('guacManageMonitor', ['$injector', if (!layout) return; + // Guard the offset/size math against a malformed or compromised layout + // from guacd: every geometry field must be a finite number, otherwise + // the entry would poison setMonitorSize() and the client offsets with + // NaN. Dimensions must additionally be positive (a zero/negative + // width or height yields a degenerate surface). Offsets (top/left) may + // legitimately be negative for monitors placed above/left of primary. + // An entry that fails validation is treated as an absent monitor. + const isFiniteNumber = value => + typeof value === 'number' && Number.isFinite(value); + const isValidGeometry = geom => !!geom + && isFiniteNumber(geom.width) && geom.width > 0 + && isFiniteNumber(geom.height) && geom.height > 0 + && isFiniteNumber(geom.top) && isFiniteNumber(geom.left); + for (const [id, pos] of Object.entries(monitorsInfos.map)) { // Track absence of the monitor. Only close it if it has been missing - // for 8 consecutive layout updates. - if (!layout[pos]) { + // for 8 consecutive layout updates. A malformed geometry entry is + // treated exactly like an absent monitor. + if (!isValidGeometry(layout[pos])) { missingCounts[id] = (missingCounts[id] || 0) + 1; if (missingCounts[id] >= 8) { service.closeMonitor(id); diff --git a/guacamole/src/main/frontend/src/app/client/types/ManagedClient.js b/guacamole/src/main/frontend/src/app/client/types/ManagedClient.js index 99efdff5b8..b02d5c8b89 100644 --- a/guacamole/src/main/frontend/src/app/client/types/ManagedClient.js +++ b/guacamole/src/main/frontend/src/app/client/types/ManagedClient.js @@ -63,6 +63,19 @@ angular.module('client').factory('ManagedClient', ['$rootScope', '$injector', */ var THUMBNAIL_UPDATE_FREQUENCY = 5000; + /** + * Pattern matching connection parameter names whose values are sensitive + * (passwords, passphrases, private keys) and must therefore never be + * reflected into the UI-visible arguments model when streamed back by the + * server via "argv". Mutating such a parameter toward guacd is already + * rejected server-side; this keeps the value out of the connection- + * parameters panel as well (security finding L4, guacamole-server#21). + * + * @constant + * @type {!RegExp} + */ + var SENSITIVE_ARGUMENT = /password|passphrase|private-key|client-key|secret|token/i; + /** * A deferred pipe stream, that has yet to be consumed, as well as all * axuilary information needed to pull data from the stream. @@ -646,6 +659,13 @@ angular.module('client').factory('ManagedClient', ['$rootScope', '$injector', // Test mutability once stream is finished, storing the current // value for the argument only if it is mutable reader.onend = function textComplete() { + + // Never reflect a sensitive parameter value (password, + // passphrase, private key) streamed back via "argv" into the + // UI-visible arguments model (security finding L4). + if (SENSITIVE_ARGUMENT.test(name)) + return; + ManagedArgument.getInstance(managedClient, name, value).then(function argumentIsMutable(argument) { managedClient.arguments[name] = argument; }, function immutableArguments() { From 7e1fe18cb98ef46f3105f181df673add0ff79ca4 Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Wed, 8 Jul 2026 23:35:56 -0300 Subject: [PATCH 31/33] spice: add Clipboard Activity panel to the recording player (#34) First increment of the clipboard audit cockpit (guacclip Phase 2). Adds a dedicated "Clipboard Activity" panel to the session-recording player, alongside the existing key-log, toggled from the player controls. The panel lists clipboard transfers as chronological cards: click (or keyboard Enter/Space) seeks to the event; a text-labelled, colour-coded direction badge marks each as copied-out-of-guest (exfiltration) or pasted-in; text is shown in a code block with show-more, images as a lazy downscaled thumbnail with a click-to-enlarge lightbox; each card offers a Download of the original artifact. A header button exports the whole activity list to CSV (formula-injection-neutralized and UTF-8-BOM-prefixed for safe spreadsheet import). An empty state explains when no clipboard was recorded. The canvas-thumbnail logic is factored out of textView into a shared clipboardMediaService used by both the key-log and the new panel, with textView behaviour preserved (pending-image tracking + $destroy abort). Reviewed (0 critical/high); CSV formula-injection + BOM and a download decode guard applied from the review. Builds clean (webpack). Deferred to #34 follow-ups: seek-bar timeline tick-marks and rapid-repeat event clustering. --- .../app/player/directives/clipboardView.js | 436 ++++++++++++++++++ .../src/app/player/directives/player.js | 38 ++ .../src/app/player/directives/textView.js | 77 +--- .../player/services/clipboardMediaService.js | 127 +++++ .../src/app/player/styles/clipboardView.css | 250 ++++++++++ .../frontend/src/app/player/styles/player.css | 5 + .../app/player/templates/clipboardView.html | 66 +++ .../src/app/player/templates/player.html | 15 + .../main/frontend/src/translations/en.json | 11 + 9 files changed, 961 insertions(+), 64 deletions(-) create mode 100644 guacamole/src/main/frontend/src/app/player/directives/clipboardView.js create mode 100644 guacamole/src/main/frontend/src/app/player/services/clipboardMediaService.js create mode 100644 guacamole/src/main/frontend/src/app/player/styles/clipboardView.css create mode 100644 guacamole/src/main/frontend/src/app/player/templates/clipboardView.html diff --git a/guacamole/src/main/frontend/src/app/player/directives/clipboardView.js b/guacamole/src/main/frontend/src/app/player/directives/clipboardView.js new file mode 100644 index 0000000000..27a1bedc6c --- /dev/null +++ b/guacamole/src/main/frontend/src/app/player/directives/clipboardView.js @@ -0,0 +1,436 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Directive which displays the clipboard activity captured within a session + * recording as a scrollable, chronological list of cards. Each card seeks the + * recording to the moment the clipboard event occurred, annotates the transfer + * direction, and allows the underlying text or image payload to be inspected + * and downloaded. The whole log can additionally be exported as CSV. + */ +angular.module('player').directive('guacPlayerClipboardView', + ['$injector', function guacPlayerClipboardView($injector) { + + // Required services + const playerTimeService = $injector.get('playerTimeService'); + const clipboardMediaService = $injector.get('clipboardMediaService'); + + const config = { + restrict : 'E', + templateUrl : 'app/player/templates/clipboardView.html' + }; + + config.scope = { + + /** + * All clipboard events extracted from this recording. + * + * @type {!Guacamole.ClipboardEventInterpreter.ClipboardEvent[]} + */ + clipboardEvents : '=', + + /** + * A callback that accepts a timestamp, and seeks the recording to + * that provided timestamp. + * + * @type {!Function} + */ + seek: '&', + + /** + * The current position within the recording. + * + * @type {!Number} + */ + currentPosition: '=' + + }; + + config.controller = ['$scope', '$element', '$injector', + function guacPlayerClipboardController($scope, $element) { + + /** + * The maximum number of characters of a text clipboard event shown + * before it is truncated behind a "show more" toggle. + * + * @type {!Number} + */ + const TEXT_TRUNCATE_LENGTH = 500; + + /** + * Display metadata describing how each recognized clipboard transfer + * direction should be presented, keyed by the raw server-annotated + * direction. Every entry carries a text label (never colour alone) and + * a CSS class reused from the key-log viewer's colour treatment. + * + * @type {!Object.} + */ + const DIRECTION_META = { + 'guest-to-client' : { + labelKey : 'PLAYER.LABEL_CLIPBOARD_COPIED_OUT', + directionClass : 'from-guest' + }, + 'client-to-guest' : { + labelKey : 'PLAYER.LABEL_CLIPBOARD_PASTED_IN', + directionClass : 'to-guest' + } + }; + + /** + * The clipboard events, sorted chronologically and enriched with + * display metadata for rendering. + * + * @type {!Object[]} + */ + $scope.sortedEvents = []; + + /** + * The set of image loads currently in flight for thumbnail generation, + * tracked so their callbacks can be detached on teardown. + * + * @type {!Set.} + */ + const pendingImages = new Set(); + + /** + * Returns the number of bytes required to encode the given string as + * UTF-8. + * + * @param {String} text + * The string to measure. + * + * @returns {!Number} + * The size, in bytes, of the UTF-8 encoding of the string. + */ + const byteLength = text => text ? new Blob([text]).size : 0; + + /** + * Formats a size in bytes as a short human-readable string. + * + * @param {Number} bytes + * The size, in bytes. + * + * @returns {!String} + * A human-readable representation of the size. + */ + const formatBytes = bytes => { + if (!bytes && bytes !== 0) + return ''; + if (bytes < 1024) + return bytes + ' B'; + if (bytes < 1024 * 1024) + return Math.round(bytes / 1024) + ' KB'; + return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; + }; + + /** + * Builds the sorted, display-ready list of clipboard events from the + * raw events bound to this directive, generating image thumbnails as + * needed. + */ + const rebuild = () => { + + const events = ($scope.clipboardEvents || []).slice() + .sort((a, b) => a.timestamp - b.timestamp); + + $scope.sortedEvents = events.map((event, index) => { + + const meta = (event.direction && DIRECTION_META[event.direction]) + || { labelKey : 'PLAYER.LABEL_CLIPBOARD', directionClass : '' }; + + const bytes = event.isImage ? event.size : byteLength(event.data); + + return { + raw : event, + index : index, + timestamp : event.timestamp, + formattedTime : playerTimeService.formatTime(event.timestamp), + isImage : event.isImage, + mimetype : event.mimetype, + text : event.data, + truncatable : !event.isImage && event.data + && event.data.length > TEXT_TRUNCATE_LENGTH, + expanded : false, + bytes : bytes, + sizeLabel : formatBytes(bytes), + directionLabelKey : meta.labelKey, + directionClass : meta.directionClass + }; + }); + + // Kick off thumbnail generation for every image event + $scope.sortedEvents.forEach(item => { + if (item.isImage) { + const image = clipboardMediaService.generateThumbnail(item.raw, + fn => $scope.$evalAsync(fn), + loaded => pendingImages.delete(loaded)); + if (image) + pendingImages.add(image); + } + }); + + }; + + $scope.$watch('clipboardEvents', rebuild); + + /** + * Returns the portion of a text clipboard event's content that should + * be shown, honoring the card's current expanded state. + * + * @param {!Object} item + * The display item for the clipboard event. + * + * @returns {!String} + * The text to display for the item. + */ + $scope.displayText = function displayText(item) { + if (!item.truncatable || item.expanded) + return item.text; + return item.text.substring(0, TEXT_TRUNCATE_LENGTH); + }; + + /** + * Toggles whether a truncated text clipboard event shows its full + * content. + * + * @param {!Object} item + * The display item to toggle. + */ + $scope.toggleExpanded = function toggleExpanded(item) { + item.expanded = !item.expanded; + }; + + /** + * Seeks the recording to the moment the given clipboard event occurred. + * + * @param {!Object} item + * The display item to seek to. + */ + $scope.seekToEvent = function seekToEvent(item) { + $scope.seek({ timestamp: item.timestamp }); + }; + + /** + * Handles keyboard activation (Enter or Space) of a focusable card, + * seeking to the associated clipboard event. + * + * @param {!KeyboardEvent} event + * The keydown event. + * + * @param {!Object} item + * The display item associated with the card. + */ + $scope.cardKeydown = function cardKeydown(event, item) { + if (event.keyCode === 13 || event.keyCode === 32) { + event.preventDefault(); + $scope.seekToEvent(item); + } + }; + + /** + * The clipboard image currently shown enlarged in the lightbox, or + * null if the lightbox is closed. + * + * @type {Object} + */ + $scope.lightboxImage = null; + + /** + * Handler which dismisses the lightbox when the Escape key is pressed. + * Bound to the document only while the lightbox is open. + * + * @param {KeyboardEvent} e + * The keydown event. + */ + const dismissOnEscape = function dismissOnEscape(e) { + if (e.keyCode === 27) // Escape + $scope.$apply(function applyClose() { + $scope.closeImage(); + }); + }; + + /** + * Opens the clipboard-image lightbox for the given image clipboard + * event metadata. + * + * @param {Object} clipboard + * The clipboard metadata (including dataURL) to display enlarged. + */ + $scope.openImage = function openImage(clipboard) { + $scope.lightboxImage = clipboard; + angular.element(document).on('keydown', dismissOnEscape); + }; + + /** + * Closes the clipboard-image lightbox, if open. + */ + $scope.closeImage = function closeImage() { + $scope.lightboxImage = null; + angular.element(document).off('keydown', dismissOnEscape); + }; + + /** + * Maps an image mimetype to a file extension for downloads. + * + * @param {String} mimetype + * The image mimetype. + * + * @returns {!String} + * A file extension (without the leading dot). + */ + const imageExtension = mimetype => { + const subtype = (mimetype || '').toLowerCase().replace(/^image\//, ''); + if (subtype === 'jpeg' || subtype === 'jpg') + return 'jpg'; + if (subtype === 'png') + return 'png'; + if (subtype === 'bmp') + return 'bmp'; + if (subtype === 'tiff' || subtype === 'tif') + return 'tiff'; + return subtype || 'bin'; + }; + + /** + * Converts a data: URL into a Blob suitable for download. + * + * @param {!String} dataURL + * The data: URL to convert. + * + * @returns {!Blob} + * A Blob containing the decoded payload. + */ + const dataURLToBlob = dataURL => { + const parts = dataURL.split(','); + const meta = parts[0]; + const mimeMatch = /data:([^;]+)/.exec(meta); + const mimetype = mimeMatch ? mimeMatch[1] : 'application/octet-stream'; + const binary = atob(parts[1] || ''); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) + bytes[i] = binary.charCodeAt(i); + return new Blob([bytes], { type: mimetype }); + }; + + /** + * Downloads the payload of the given clipboard event as a file: text + * events download a .txt file, image events download the image in its + * original format. + * + * @param {!Object} item + * The display item whose payload should be downloaded. + */ + $scope.download = function download(item) { + if (item.isImage && item.raw.dataURL) { + + // A corrupt/truncated base64 payload in the recording would + // make atob() throw; guard so the button degrades to a no-op + // rather than throwing out of the click handler. + let blob; + try { + blob = dataURLToBlob(item.raw.dataURL); + } + catch (e) { + return; + } + + const ext = imageExtension(item.mimetype); + saveAs(blob, 'clipboard-' + (item.index + 1) + '.' + ext); + } + else { + const blob = new Blob([item.text || ''], + { type: 'text/plain;charset=utf-8' }); + saveAs(blob, 'clipboard-' + (item.index + 1) + '.txt'); + } + }; + + /** + * Escapes a single value for inclusion in a CSV field, quoting it when + * it contains a comma, quote, or newline. + * + * @param {*} value + * The value to escape. + * + * @returns {!String} + * The CSV-safe representation of the value. + */ + const csvEscape = value => { + let str = (value === null || value === undefined) ? '' : String(value); + + // Neutralize spreadsheet formula injection: a leading =, +, -, @, + // tab, or CR makes Excel/Sheets evaluate the (attacker-controlled) + // clipboard value as a formula. Prefix with a single quote so it is + // always treated as text. Quoting alone does not prevent this. + if (/^[=+\-@\t\r]/.test(str)) + str = "'" + str; + + if (/[",\r\n]/.test(str)) + return '"' + str.replace(/"/g, '""') + '"'; + return str; + }; + + /** + * Builds and downloads a CSV export of all clipboard events. Columns: + * index, time (formatted), timestamp_ms, direction, mimetype, bytes, + * text. + */ + $scope.exportCsv = function exportCsv() { + + const header = ['index', 'time', 'timestamp_ms', 'direction', + 'mimetype', 'bytes', 'text']; + + const rows = $scope.sortedEvents.map(item => [ + item.index + 1, + item.formattedTime, + item.timestamp, + item.raw.direction || '', + item.mimetype, + item.bytes, + item.isImage ? '' : (item.text || '') + ].map(csvEscape).join(',')); + + // Prepend a UTF-8 BOM so Excel opens the file as UTF-8 rather than + // the OS ANSI codepage (otherwise non-ASCII clipboard text mojibakes). + const csv = '\uFEFF' + [header.join(','), ...rows].join('\r\n'); + saveAs(new Blob([csv], { type: 'text/csv;charset=utf-8' }), + 'clipboard-activity.csv'); + + }; + + // Abort any in-flight thumbnail loads, and unbind the lightbox key + // handler, when the directive is destroyed + $scope.$on('$destroy', function clipboardViewDestroyed() { + pendingImages.forEach(function detach(image) { + image.onload = null; + image.onerror = null; + image.src = ''; + }); + pendingImages.clear(); + angular.element(document).off('keydown', dismissOnEscape); + }); + + /** + * @borrows playerTimeService.formatTime + */ + $scope.formatTime = playerTimeService.formatTime; + + }]; + + return config; +}]); diff --git a/guacamole/src/main/frontend/src/app/player/directives/player.js b/guacamole/src/main/frontend/src/app/player/directives/player.js index fe2a0228b9..a1919e3dd8 100644 --- a/guacamole/src/main/frontend/src/app/player/directives/player.js +++ b/guacamole/src/main/frontend/src/app/player/directives/player.js @@ -182,6 +182,14 @@ angular.module('player').directive('guacPlayer', ['$injector', function guacPlay */ $scope.textBatches = []; + /** + * All clipboard events extracted from the recording, exposed for the + * clipboard activity viewer. + * + * @type {!Guacamole.ClipboardEventInterpreter.ClipboardEvent[]} + */ + $scope.clipboardEvents = []; + /** * Whether or not the key log viewer should be displayed. False by * default unless explicitly enabled by user interaction. @@ -190,6 +198,14 @@ angular.module('player').directive('guacPlayer', ['$injector', function guacPlay */ $scope.showKeyLog = false; + /** + * Whether or not the clipboard activity viewer should be displayed. + * False by default unless explicitly enabled by user interaction. + * + * @type {boolean} + */ + $scope.showClipboardLog = false; + /** * The height, in pixels, of the SVG heatmap paths. Note that this is not * necessarily the actual rendered height, just the initial size of the @@ -326,6 +342,25 @@ angular.module('player').directive('guacPlayer', ['$injector', function guacPlay $scope.showKeyLog = !$scope.showKeyLog; }; + /** + * Return true if any clipboard events are available for this recording, + * or false otherwise. + * + * @return {boolean} + * True if any clipboard events are available for this recording, or + * false otherwise. + */ + $scope.hasClipboardEvents = function hasClipboardEvents() { + return $scope.clipboardEvents.length > 0; + }; + + /** + * Toggle the visibility of the clipboard activity viewer. + */ + $scope.toggleClipboardLogView = function toggleClipboardLogView() { + $scope.showClipboardLog = !$scope.showClipboardLog; + }; + /** * @borrows playerTimeService.formatTime */ @@ -526,6 +561,9 @@ angular.module('player').directive('guacPlayer', ['$injector', function guacPlay clipboardEvents = events; + // Expose clipboard events for the clipboard activity viewer + $scope.clipboardEvents = events; + // Convert to a display-optimized format $scope.textBatches = keyEventDisplayService.parseEventsWithClipboard( keyEvents, clipboardEvents diff --git a/guacamole/src/main/frontend/src/app/player/directives/textView.js b/guacamole/src/main/frontend/src/app/player/directives/textView.js index f3a2e3320a..219eea0d6a 100644 --- a/guacamole/src/main/frontend/src/app/player/directives/textView.js +++ b/guacamole/src/main/frontend/src/app/player/directives/textView.js @@ -27,6 +27,7 @@ angular.module('player').directive('guacPlayerTextView', // Required services const playerTimeService = $injector.get('playerTimeService'); + const clipboardMediaService = $injector.get('clipboardMediaService'); const config = { restrict : 'E', @@ -118,79 +119,27 @@ angular.module('player').directive('guacPlayerTextView', }; /** - * The maximum width/height, in pixels, of a generated clipboard image - * thumbnail. The full-resolution image is retained only for the - * lightbox; the inline chip uses this downscaled copy to keep the - * key-log DOM light even when a recording contains many or large - * clipboard images. + * The set of image loads currently in flight for thumbnail generation, + * tracked so their callbacks can be detached on teardown. * - * @type {!Number} + * @type {!Set.} */ - const THUMBNAIL_MAX_DIMENSION = 96; + const pendingImages = new Set(); /** - * Generate a downscaled thumbnail, and capture the natural dimensions, - * for the given image clipboard metadata - asynchronously populating - * its thumbURL, width, and height fields. Falls back to the full data - * URL if the browser cannot rasterize a thumbnail. No-op if the - * thumbnail already exists or is in progress. + * Generate a downscaled thumbnail for the given image clipboard + * metadata via the shared clipboardMediaService, tracking the in-flight + * image load so it can be aborted when the directive is destroyed. * * @param {Object} clipboard * The image clipboard metadata to enrich in place. */ - const pendingImages = new Set(); - const generateThumbnail = clipboard => { - - // Only process each image event once - if (!clipboard || !clipboard.isImage || !clipboard.dataURL - || clipboard.thumbURL || clipboard.thumbPending) - return; - - clipboard.thumbPending = true; - - const image = new Image(); - pendingImages.add(image); - - image.onload = function thumbnailLoaded() { - - pendingImages.delete(image); - - const w = image.naturalWidth; - const h = image.naturalHeight; - const scale = Math.min(1, THUMBNAIL_MAX_DIMENSION / Math.max(w, h)); - const tw = Math.max(1, Math.round(w * scale)); - const th = Math.max(1, Math.round(h * scale)); - - let thumb = clipboard.dataURL; - try { - const canvas = document.createElement('canvas'); - canvas.width = tw; - canvas.height = th; - canvas.getContext('2d').drawImage(image, 0, 0, tw, th); - thumb = canvas.toDataURL('image/png'); - } - catch (ignore) { - // Keep the full data URL as the thumbnail fallback - } - - $scope.$evalAsync(function applyThumbnail() { - clipboard.width = w; - clipboard.height = h; - clipboard.thumbURL = thumb; - clipboard.thumbPending = false; - }); - }; - - image.onerror = function thumbnailFailed() { - pendingImages.delete(image); - $scope.$evalAsync(function applyFailure() { - clipboard.thumbPending = false; - }); - }; - - image.src = clipboard.dataURL; - + const image = clipboardMediaService.generateThumbnail(clipboard, + fn => $scope.$evalAsync(fn), + loaded => pendingImages.delete(loaded)); + if (image) + pendingImages.add(image); }; // Abort any in-flight thumbnail loads when the directive is destroyed diff --git a/guacamole/src/main/frontend/src/app/player/services/clipboardMediaService.js b/guacamole/src/main/frontend/src/app/player/services/clipboardMediaService.js new file mode 100644 index 0000000000..a7f090a6c0 --- /dev/null +++ b/guacamole/src/main/frontend/src/app/player/services/clipboardMediaService.js @@ -0,0 +1,127 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Service which generates downscaled thumbnails for image clipboard events, + * shared by the key-log text viewer and the clipboard activity viewer so that + * both render identical thumbnails without duplicating the canvas-downscale + * logic. + */ +angular.module('player').factory('clipboardMediaService', [ + function clipboardMediaService() { + + /** + * The maximum width/height, in pixels, of a generated clipboard image + * thumbnail. The full-resolution image is retained only for the lightbox; + * inline previews use this downscaled copy to keep the DOM light even when + * a recording contains many or large clipboard images. + * + * @type {!Number} + */ + const THUMBNAIL_MAX_DIMENSION = 96; + + const service = {}; + + /** + * Generate a downscaled thumbnail, and capture the natural dimensions, for + * the given image clipboard metadata - asynchronously populating its + * thumbURL, width, and height fields. Falls back to the full data URL if + * the browser cannot rasterize a thumbnail. No-op (returns null) if the + * thumbnail already exists or is in progress, or if the metadata does not + * describe an image. + * + * @param {Object} clipboard + * The image clipboard metadata to enrich in place. + * + * @param {!Function} apply + * A callback which schedules the provided mutation function to run + * within an AngularJS digest (typically a wrapper around + * $scope.$evalAsync). + * + * @param {Function} [onSettled] + * An optional callback invoked with the underlying Image object once + * its load has resolved or failed, allowing the caller to release any + * tracking it holds for cleanup. + * + * @returns {Image} + * The Image object used to load the source, so the caller can track it + * and abort it on teardown, or null if no work was scheduled. + */ + service.generateThumbnail = function generateThumbnail(clipboard, apply, onSettled) { + + // Only process each image event once + if (!clipboard || !clipboard.isImage || !clipboard.dataURL + || clipboard.thumbURL || clipboard.thumbPending) + return null; + + clipboard.thumbPending = true; + + const image = new Image(); + + const settle = function settle() { + if (onSettled) + onSettled(image); + }; + + image.onload = function thumbnailLoaded() { + + settle(); + + const w = image.naturalWidth; + const h = image.naturalHeight; + const scale = Math.min(1, THUMBNAIL_MAX_DIMENSION / Math.max(w, h)); + const tw = Math.max(1, Math.round(w * scale)); + const th = Math.max(1, Math.round(h * scale)); + + let thumb = clipboard.dataURL; + try { + const canvas = document.createElement('canvas'); + canvas.width = tw; + canvas.height = th; + canvas.getContext('2d').drawImage(image, 0, 0, tw, th); + thumb = canvas.toDataURL('image/png'); + } + catch (ignore) { + // Keep the full data URL as the thumbnail fallback + } + + apply(function applyThumbnail() { + clipboard.width = w; + clipboard.height = h; + clipboard.thumbURL = thumb; + clipboard.thumbPending = false; + }); + }; + + image.onerror = function thumbnailFailed() { + settle(); + apply(function applyFailure() { + clipboard.thumbPending = false; + }); + }; + + image.src = clipboard.dataURL; + + return image; + + }; + + return service; + +}]); diff --git a/guacamole/src/main/frontend/src/app/player/styles/clipboardView.css b/guacamole/src/main/frontend/src/app/player/styles/clipboardView.css new file mode 100644 index 0000000000..4107167b3a --- /dev/null +++ b/guacamole/src/main/frontend/src/app/player/styles/clipboardView.css @@ -0,0 +1,250 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +guac-player-clipboard-view { + + margin-top: 40px; + min-width: 25em; + flex-basis: 0; + + /* Make room for the control bar at the bottom and close button at the top */ + height: calc(100% - 88px); + +} + +.guac-player-clipboard-container { + + height: 100%; + display: flex; + flex-direction: column; + background-color: white; + color: black; + +} + +.guac-player-clipboard-container .clipboard-controls { + + display: flex; + flex-direction: row; + align-items: center; + padding: 0.4em 0.5em; + border-bottom: 1px solid #ddd; + +} + +.guac-player-clipboard-container .clipboard-controls .clipboard-title { + + font-weight: bold; + flex-grow: 1; + +} + +.guac-player-clipboard-container .clipboard-export { + + cursor: pointer; + +} + +.guac-player-clipboard-container .clipboard-empty { + + margin: 1em 0.75em; + color: #555; + font-style: italic; + +} + +.guac-player-clipboard-container .clipboard-cards { + + display: flex; + flex-direction: column; + overflow-y: auto; + padding: 0.5em; + gap: 0.6em; + +} + +.clipboard-cards .clipboard-card { + + border: 1px solid #ccc; + border-radius: 5px; + padding: 0.5em 0.6em; + cursor: pointer; + background-color: #fafafa; + +} + +.clipboard-cards .clipboard-card:focus { + + outline: 2px solid #4a6f8a; + outline-offset: 1px; + +} + +.clipboard-cards .clipboard-card.future { + + opacity: 0.55; + +} + +.clipboard-cards .clipboard-card .clipboard-card-header { + + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + margin-bottom: 0.4em; + +} + +.clipboard-cards .clipboard-card .timestamp { + + color: blue; + white-space: pre-wrap; + +} + +/* Directional annotation badge. Always carries a text label - colour is + supplementary, never the sole signal. Colours reused from textView.css. */ +.clipboard-cards .clipboard-card .clipboard-direction { + + display: inline-block; + font-size: 0.75em; + font-weight: bold; + text-transform: uppercase; + letter-spacing: 0.03em; + padding: 0.1em 0.5em; + border-radius: 3px; + color: #fff; + background-color: #777; + +} + +/* Copied out of the guest - highlighted as the exfiltration-relevant case. */ +.clipboard-cards .clipboard-card .clipboard-direction.from-guest { + + background-color: #b25f00; + +} + +/* Pasted into the guest - neutral. */ +.clipboard-cards .clipboard-card .clipboard-direction.to-guest { + + background-color: #4a6f8a; + +} + +.clipboard-cards .clipboard-card .clipboard-card-body { + + margin: 0.2em 0; + +} + +.clipboard-cards .clipboard-card .clipboard-text { + + margin: 0; + max-width: 100%; + max-height: 16em; + overflow: auto; + white-space: pre-wrap; + overflow-wrap: break-word; + font-size: 0.9em; + background-color: #f4f4f4; + border: 1px solid #e0e0e0; + border-radius: 3px; + padding: 0.4em; + +} + +.clipboard-cards .clipboard-card .clipboard-show-more { + + margin-top: 0.3em; + cursor: pointer; + +} + +.clipboard-cards .clipboard-card .clipboard-image { + + display: inline-flex; + align-items: center; + padding: 0.15em 0.35em; + border: 1px solid #bbb; + border-radius: 4px; + background-color: #f4f4f4; + cursor: zoom-in; + +} + +.clipboard-cards .clipboard-card .clipboard-image .clipboard-thumb { + + max-height: 96px; + max-width: 96px; + display: block; + border: 1px solid #ccc; + background-color: #fff; + +} + +.clipboard-cards .clipboard-card .clipboard-card-footer { + + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + margin-top: 0.4em; + +} + +.clipboard-cards .clipboard-card .clipboard-meta { + + font-size: 0.85em; + color: #333; + +} + +.clipboard-cards .clipboard-card .clipboard-download { + + cursor: pointer; + +} + +/* Full-size preview overlay shown when a thumbnail is clicked. */ +.guac-player-clipboard-container .clipboard-lightbox { + + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background-color: rgba(0, 0, 0, 0.8); + cursor: zoom-out; + z-index: 1000; + +} + +.guac-player-clipboard-container .clipboard-lightbox img { + + max-width: 90%; + max-height: 90%; + box-shadow: 0 0 20px rgba(0, 0, 0, 0.5); + background-color: #fff; + +} diff --git a/guacamole/src/main/frontend/src/app/player/styles/player.css b/guacamole/src/main/frontend/src/app/player/styles/player.css index aeb6d0ad82..8af056c9fe 100644 --- a/guacamole/src/main/frontend/src/app/player/styles/player.css +++ b/guacamole/src/main/frontend/src/app/player/styles/player.css @@ -145,6 +145,11 @@ guac-player .guac-player-controls { cursor: pointer; } +.guac-player-controls .guac-player-clipboard { + padding-right: 0.5em; + cursor: pointer; +} + guac-player .guac-player-status { position: fixed; diff --git a/guacamole/src/main/frontend/src/app/player/templates/clipboardView.html b/guacamole/src/main/frontend/src/app/player/templates/clipboardView.html new file mode 100644 index 0000000000..e37792fd0d --- /dev/null +++ b/guacamole/src/main/frontend/src/app/player/templates/clipboardView.html @@ -0,0 +1,66 @@ +
+ +
+ {{ 'PLAYER.ACTION_SHOW_CLIPBOARD_LOG' | translate }} + +
+ + +
+ {{ 'PLAYER.INFO_NO_CLIPBOARD_ACTIVITY' | translate }} +
+ + +
+
+ +
+ {{ item.formattedTime }} + {{ item.directionLabelKey | translate }} +
+ + +
+
{{ displayText(item) }}
+ +
+ + +
+ + Clipboard image + +
+ + + +
+
+ + +
diff --git a/guacamole/src/main/frontend/src/app/player/templates/player.html b/guacamole/src/main/frontend/src/app/player/templates/player.html index 15bac8553e..5ea134bd36 100644 --- a/guacamole/src/main/frontend/src/app/player/templates/player.html +++ b/guacamole/src/main/frontend/src/app/player/templates/player.html @@ -12,6 +12,13 @@ duration="recording.getDuration()" > + + + @@ -74,6 +81,14 @@ {{ 'PLAYER.INFO_NO_KEY_LOG' | translate }} + + + {{ 'PLAYER.ACTION_SHOW_CLIPBOARD_LOG' | translate }} + + + + {{ 'PLAYER.INFO_NO_CLIPBOARD_LOG' | translate }} + diff --git a/guacamole/src/main/frontend/src/translations/en.json b/guacamole/src/main/frontend/src/translations/en.json index e8067b11ca..94ef3d9ffe 100644 --- a/guacamole/src/main/frontend/src/translations/en.json +++ b/guacamole/src/main/frontend/src/translations/en.json @@ -493,17 +493,28 @@ "ACTION_CANCEL" : "@:APP.ACTION_CANCEL", "ACTION_CLOSE" : "Close", + "ACTION_DOWNLOAD" : "Download", + "ACTION_EXPORT_CSV" : "Export CSV", "ACTION_PAUSE" : "@:APP.ACTION_PAUSE", "ACTION_PLAY" : "@:APP.ACTION_PLAY", + "ACTION_SHOW_CLIPBOARD_LOG" : "Clipboard Activity", "ACTION_SHOW_KEY_LOG" : "Keystroke Log", + "ACTION_SHOW_LESS" : "Show less", + "ACTION_SHOW_MORE" : "Show more", "INFO_FRAME_EVENTS_LEGEND" : "On-screen Activity", "INFO_KEY_EVENTS_LEGEND" : "Keyboard Activity", "INFO_LOADING_RECORDING" : "Your recording is now being loaded. Please wait...", + "INFO_NO_CLIPBOARD_ACTIVITY" : "No clipboard activity was recorded. Enable clipboard recording with recording-include-clipboard to capture it.", + "INFO_NO_CLIPBOARD_LOG" : "Clipboard Activity Unavailable", "INFO_NO_KEY_LOG" : "Keystroke Log Unavailable", "INFO_NUMBER_OF_RESULTS" : "{RESULTS} {RESULTS, plural, one{Match} other{Matches}}", "INFO_SEEK_IN_PROGRESS" : "Seeking to the requested position. Please wait...", + "LABEL_CLIPBOARD" : "Clipboard", + "LABEL_CLIPBOARD_COPIED_OUT" : "Data Copied Out", + "LABEL_CLIPBOARD_PASTED_IN" : "Data Pasted In", + "FIELD_PLACEHOLDER_TEXT_BATCH_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER" }, From 81463cc70dda74a6229a8de917129af0f9057b0d Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Thu, 9 Jul 2026 00:04:02 -0300 Subject: [PATCH 32/33] spice: clipboard timeline ticks, clustering, and truncation banner (#34) Second increment of the clipboard audit cockpit, completing the remaining Phase 2 items. Timeline tick-marks: each clipboard transfer is marked on the player's seek/heatmap bar, colour-coded by direction (amber copied-out / slate pasted-in), positioned by timestamp. Clicking a tick seeks to ~5s before the transfer (context lead-in); hovering shows the direction plus a thumbnail (images) or text preview. The tick layer ignores pointer events except on the 2px marks, so the seek slider stays draggable across its full width; ticks are a mouse affordance only (the panel cards remain the keyboard-accessible seek path), and positions are clamped to the bar. Event clustering: the Clipboard Activity panel collapses runs of consecutive same-direction, identical-content transfers into one card with a repeat count, time range, and expand/collapse. The flat event list is preserved, so CSV export and per-item download remain un-clustered. Truncation banner: ClipboardEventInterpreter.getIncompleteCount() reports streams that opened but never ended (truncated recording); SessionRecording passes it via a new, backward-compatible second onclipboardevents argument, and the panel shows a warning banner when transfers were recorded incompletely. Reviewed (0 critical/high); accessibility (tick tab-stop flood) and overflow fixes applied. Builds clean (webpack). Closes the timeline-ticks and clustering follow-ups on #34. --- .../modules/ClipboardEventInterpreter.js | 14 +++ .../main/webapp/modules/SessionRecording.js | 3 +- .../app/player/directives/clipboardView.js | 80 +++++++++++++- .../src/app/player/directives/player.js | 101 +++++++++++++++++- .../src/app/player/styles/clipboardView.css | 70 ++++++++++++ .../frontend/src/app/player/styles/player.css | 83 ++++++++++++++ .../app/player/templates/clipboardView.html | 28 ++++- .../src/app/player/templates/player.html | 26 +++++ .../app/settings/styles/history-player.css | 5 + .../main/frontend/src/translations/en.json | 5 + 10 files changed, 410 insertions(+), 5 deletions(-) diff --git a/guacamole-common-js/src/main/webapp/modules/ClipboardEventInterpreter.js b/guacamole-common-js/src/main/webapp/modules/ClipboardEventInterpreter.js index eedbcbeeb1..8d4b2a2302 100644 --- a/guacamole-common-js/src/main/webapp/modules/ClipboardEventInterpreter.js +++ b/guacamole-common-js/src/main/webapp/modules/ClipboardEventInterpreter.js @@ -240,6 +240,20 @@ Guacamole.ClipboardEventInterpreter = function ClipboardEventInterpreter(startTi return parsedEvents; }; + /** + * Returns the number of clipboard streams that were opened but never + * terminated by an "end" instruction. Such streams indicate clipboard + * transfers that were only partially recorded (e.g. a truncated + * recording), and whose data is therefore missing. + * + * @returns {!number} + * The number of incomplete clipboard streams still open at the end of + * parsing. + */ + this.getIncompleteCount = function getIncompleteCount() { + return Object.keys(activeStreams).length; + }; + }; /** diff --git a/guacamole-common-js/src/main/webapp/modules/SessionRecording.js b/guacamole-common-js/src/main/webapp/modules/SessionRecording.js index cc8f24bc42..6bfce538c5 100644 --- a/guacamole-common-js/src/main/webapp/modules/SessionRecording.js +++ b/guacamole-common-js/src/main/webapp/modules/SessionRecording.js @@ -620,7 +620,8 @@ Guacamole.SessionRecording = function SessionRecording(source, refreshInterval) // call the onclipboardevents handler if defined with extracted // clipboard events if (recording.onclipboardevents && clipboardEventInterpreter) - recording.onclipboardevents(clipboardEventInterpreter.getEvents()); + recording.onclipboardevents(clipboardEventInterpreter.getEvents(), + { incomplete: clipboardEventInterpreter.getIncompleteCount() }); // Consider recording loaded if tunnel has closed without errors if (!errorEncountered) diff --git a/guacamole/src/main/frontend/src/app/player/directives/clipboardView.js b/guacamole/src/main/frontend/src/app/player/directives/clipboardView.js index 27a1bedc6c..39a7f4e7a5 100644 --- a/guacamole/src/main/frontend/src/app/player/directives/clipboardView.js +++ b/guacamole/src/main/frontend/src/app/player/directives/clipboardView.js @@ -58,7 +58,16 @@ angular.module('player').directive('guacPlayerClipboardView', * * @type {!Number} */ - currentPosition: '=' + currentPosition: '=', + + /** + * The number of clipboard transfers that were recorded incompletely + * (streams opened but never terminated). When greater than zero, a + * warning banner is shown. + * + * @type {Number} + */ + incompleteCount: '=' }; @@ -100,6 +109,17 @@ angular.module('player').directive('guacPlayerClipboardView', */ $scope.sortedEvents = []; + /** + * The clustered, display-ready list derived from $scope.sortedEvents. + * Runs of consecutive events sharing the same direction and identical + * content are collapsed into a single cluster card. This is the list + * rendered in the template; $scope.sortedEvents remains the canonical + * flat list used for CSV export and per-item download. + * + * @type {!Object[]} + */ + $scope.displayItems = []; + /** * The set of image loads currently in flight for thumbnail generation, * tracked so their callbacks can be detached on teardown. @@ -185,6 +205,64 @@ angular.module('player').directive('guacPlayerClipboardView', } }); + buildDisplayItems(); + + }; + + /** + * Collapses runs of consecutive clipboard events sharing the same + * transfer direction and identical content into cluster entries, and + * stores the result in $scope.displayItems. A run of length one becomes + * a singleton cluster (clusterCount === 1). The canonical flat list in + * $scope.sortedEvents is left untouched. + */ + const buildDisplayItems = () => { + + const clusters = []; + + $scope.sortedEvents.forEach(item => { + + const last = clusters.length ? clusters[clusters.length - 1] : null; + + const sameContent = last + && last.raw.direction === item.raw.direction + && last.isImage === item.isImage + && (item.isImage + ? last.raw.dataURL === item.raw.dataURL + : last.text === item.text); + + if (sameContent) { + last.clusterCount++; + last.clusterMembers.push(item); + last.lastTime = item.timestamp; + last.formattedLastTime = item.formattedTime; + } + else { + clusters.push(angular.extend({}, item, { + clusterCount : 1, + clusterMembers : [item], + clusterExpanded : false, + firstTime : item.timestamp, + lastTime : item.timestamp, + formattedLastTime : item.formattedTime + })); + } + + }); + + $scope.displayItems = clusters; + + }; + + /** + * Toggles whether the individual member timestamps of a clustered card + * are revealed. + * + * @param {!Object} item + * The cluster display item to toggle. + */ + $scope.toggleCluster = function toggleCluster(item) { + item.clusterExpanded = !item.clusterExpanded; }; $scope.$watch('clipboardEvents', rebuild); diff --git a/guacamole/src/main/frontend/src/app/player/directives/player.js b/guacamole/src/main/frontend/src/app/player/directives/player.js index a1919e3dd8..561756549f 100644 --- a/guacamole/src/main/frontend/src/app/player/directives/player.js +++ b/guacamole/src/main/frontend/src/app/player/directives/player.js @@ -190,6 +190,25 @@ angular.module('player').directive('guacPlayer', ['$injector', function guacPlay */ $scope.clipboardEvents = []; + /** + * The number of clipboard transfers that were recorded incompletely + * (streams opened but never terminated), surfaced to the clipboard + * activity viewer so it can warn that some transfers may be missing. + * + * @type {!number} + */ + $scope.clipboardIncompleteCount = 0; + + /** + * Timeline tick-marks for the seek bar, one per clipboard event, + * positioned along the same 0..HEATMAP_WIDTH coordinate space used by + * the heatmap SVGs. Rebuilt whenever the clipboard events or the + * recording duration become known. + * + * @type {!Object[]} + */ + $scope.clipboardTicks = []; + /** * Whether or not the key log viewer should be displayed. False by * default unless explicitly enabled by user interaction. @@ -420,6 +439,76 @@ angular.module('player').directive('guacPlayer', ['$injector', function guacPlay }; + /** + * Seeks the recording to a point roughly five seconds before the given + * clipboard event, providing lead-in context so the moments preceding + * the transfer are visible rather than starting mid-event. + * + * @param {!number} timestamp + * The recording-relative timestamp of the clipboard event, in + * milliseconds. + */ + $scope.seekWithLeadIn = function seekWithLeadIn(timestamp) { + $scope.seekToTimestamp(Math.max(0, timestamp - 5000)); + }; + + /** + * Display metadata for clipboard tick-marks, keyed by the raw + * server-annotated transfer direction. Mirrors the mapping used by the + * clipboard activity panel so colours and labels stay consistent. + * + * @type {!Object.} + */ + const CLIPBOARD_TICK_META = { + 'guest-to-client' : { + labelKey : 'PLAYER.LABEL_CLIPBOARD_COPIED_OUT', + directionClass : 'from-guest' + }, + 'client-to-guest' : { + labelKey : 'PLAYER.LABEL_CLIPBOARD_PASTED_IN', + directionClass : 'to-guest' + } + }; + + /** + * Rebuilds the clipboard timeline tick-marks from the current clipboard + * events and recording duration. Does nothing meaningful until both are + * available, so it is safe to call from either the clipboard-events or + * the load handler regardless of ordering. + */ + const updateClipboardTicks = function updateClipboardTicks() { + + const duration = $scope.recording ? $scope.recording.getDuration() : 0; + const events = $scope.clipboardEvents || []; + + if (!duration || !events.length) { + $scope.clipboardTicks = []; + return; + } + + $scope.clipboardTicks = events.map(function toTick(event) { + + const meta = CLIPBOARD_TICK_META[event.direction] + || { labelKey : 'PLAYER.LABEL_CLIPBOARD', directionClass : '' }; + + return { + // Clamp so an event timestamped at/after the last frame + // cannot position the tick past the end of the bar + x : Math.max(0, Math.min($scope.HEATMAP_WIDTH, + (event.timestamp / duration) * $scope.HEATMAP_WIDTH)), + timestamp : event.timestamp, + direction : event.direction, + directionClass : meta.directionClass, + directionLabelKey : meta.labelKey, + isImage : event.isImage, + dataURL : event.dataURL, + textPreview : (!event.isImage && event.data) + ? event.data.substring(0, 80) : '' + }; + }); + + }; + /** * Seek the recording to the current playback position value. */ @@ -511,6 +600,10 @@ angular.module('player').directive('guacPlayer', ['$injector', function guacPlay keyTimestamps, recordingDuration, KEY_EVENT_RATE_CAP, $scope.HEATMAP_HEIGHT, $scope.HEATMAP_WIDTH)); + // The recording duration is only known here; rebuild the + // clipboard tick-marks now that it is available + updateClipboardTicks(); + }; // Notify listeners if an error occurs @@ -557,18 +650,24 @@ angular.module('player').directive('guacPlayer', ['$injector', function guacPlay }; // Extract clipboard events from the recording - $scope.recording.onclipboardevents = function clipboardEventsReceived(events) { + $scope.recording.onclipboardevents = function clipboardEventsReceived(events, meta) { clipboardEvents = events; // Expose clipboard events for the clipboard activity viewer $scope.clipboardEvents = events; + // Surface any incompletely-recorded clipboard transfers + $scope.clipboardIncompleteCount = (meta && meta.incomplete) || 0; + // Convert to a display-optimized format $scope.textBatches = keyEventDisplayService.parseEventsWithClipboard( keyEvents, clipboardEvents ); + // Rebuild the seek-bar tick-marks for the new events + updateClipboardTicks(); + }; // Notify listeners when current position within the recording diff --git a/guacamole/src/main/frontend/src/app/player/styles/clipboardView.css b/guacamole/src/main/frontend/src/app/player/styles/clipboardView.css index 4107167b3a..4a14578c3c 100644 --- a/guacamole/src/main/frontend/src/app/player/styles/clipboardView.css +++ b/guacamole/src/main/frontend/src/app/player/styles/clipboardView.css @@ -61,6 +61,21 @@ guac-player-clipboard-view { } +/* Warning shown when one or more clipboard transfers were only partially + recorded and their captured data is therefore missing. */ +.guac-player-clipboard-container .clipboard-truncation-banner { + + margin: 0.5em; + padding: 0.5em 0.6em; + border: 1px solid #e0b000; + border-left: 4px solid #b25f00; + border-radius: 4px; + background-color: #fff7e0; + color: #5a3d00; + font-size: 0.9em; + +} + .guac-player-clipboard-container .clipboard-empty { margin: 1em 0.75em; @@ -149,12 +164,67 @@ guac-player-clipboard-view { } +/* Count badge on a clustered card summarizing how many identical, consecutive + transfers were collapsed into it. */ +.clipboard-cards .clipboard-card .clipboard-cluster-count { + + display: inline-block; + font-size: 0.75em; + font-weight: bold; + padding: 0.1em 0.5em; + margin: 0 0.4em; + border-radius: 3px; + color: #fff; + background-color: #555; + +} + .clipboard-cards .clipboard-card .clipboard-card-body { margin: 0.2em 0; } +/* Expand/collapse control and revealed member timestamps for a cluster. */ +.clipboard-cards .clipboard-card .clipboard-cluster { + + margin-top: 0.3em; + +} + +.clipboard-cards .clipboard-card .clipboard-cluster-toggle { + + cursor: pointer; + font-size: 0.85em; + +} + +.clipboard-cards .clipboard-card .clipboard-cluster-members { + + list-style: none; + margin: 0.3em 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.15em; + +} + +.clipboard-cards .clipboard-card .clipboard-cluster-members li { + + color: blue; + cursor: pointer; + font-size: 0.85em; + +} + +.clipboard-cards .clipboard-card .clipboard-cluster-members li:focus { + + outline: 2px solid #4a6f8a; + outline-offset: 1px; + +} + .clipboard-cards .clipboard-card .clipboard-text { margin: 0; diff --git a/guacamole/src/main/frontend/src/app/player/styles/player.css b/guacamole/src/main/frontend/src/app/player/styles/player.css index 8af056c9fe..13ed6f35b2 100644 --- a/guacamole/src/main/frontend/src/app/player/styles/player.css +++ b/guacamole/src/main/frontend/src/app/player/styles/player.css @@ -230,6 +230,89 @@ guac-player-text-view { } +/* + * Clipboard event tick-marks overlaid on the seek bar / heatmap area. The + * layer aligns with the heatmap SVGs (same bottom offset and height) but + * ignores pointer events so the seek slider beneath remains draggable; only + * the thin, individually-positioned ticks capture clicks. + */ +.guac-player-controls .heat-map .clipboard-ticks { + position: absolute; + bottom: 7px; + height: 50px; + width: 100%; + z-index: 101; + pointer-events: none; +} + +.guac-player-controls .heat-map .clipboard-ticks .clipboard-tick { + position: absolute; + top: 0; + bottom: 0; + width: 2px; + margin-left: -1px; + background-color: #777; + cursor: pointer; + pointer-events: auto; +} + +.guac-player-controls .heat-map .clipboard-ticks .clipboard-tick.from-guest { + background-color: #b25f00; +} + +.guac-player-controls .heat-map .clipboard-ticks .clipboard-tick.to-guest { + background-color: #4a6f8a; +} + +.guac-player-controls .heat-map .clipboard-ticks .clipboard-tick:focus { + outline: 2px solid #fff; + outline-offset: 1px; +} + +/* Hover/focus tooltip previewing the transfer direction and payload. */ +.guac-player-controls .heat-map .clipboard-ticks .clipboard-tick .clipboard-tick-tooltip { + display: none; + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + margin-bottom: 6px; + max-width: 220px; + padding: 5px 7px; + background-color: rgba(0, 0, 0, 0.9); + color: #fff; + border-radius: 4px; + font-size: 0.75em; + white-space: normal; + pointer-events: none; + z-index: 200; +} + +.guac-player-controls .heat-map .clipboard-ticks .clipboard-tick:hover .clipboard-tick-tooltip, +.guac-player-controls .heat-map .clipboard-ticks .clipboard-tick:focus .clipboard-tick-tooltip { + display: block; +} + +.guac-player-controls .heat-map .clipboard-ticks .clipboard-tick .clipboard-tick-direction { + font-weight: bold; + text-transform: uppercase; + letter-spacing: 0.03em; + margin-bottom: 3px; +} + +.guac-player-controls .heat-map .clipboard-ticks .clipboard-tick .clipboard-tick-thumb { + display: block; + max-width: 200px; + max-height: 120px; + background-color: #fff; + border: 1px solid #ccc; +} + +.guac-player-controls .heat-map .clipboard-ticks .clipboard-tick .clipboard-tick-text { + word-break: break-word; + overflow-wrap: break-word; +} + guac-player-text-view.fullscreen { min-width: 100%; diff --git a/guacamole/src/main/frontend/src/app/player/templates/clipboardView.html b/guacamole/src/main/frontend/src/app/player/templates/clipboardView.html index e37792fd0d..86cc32a508 100644 --- a/guacamole/src/main/frontend/src/app/player/templates/clipboardView.html +++ b/guacamole/src/main/frontend/src/app/player/templates/clipboardView.html @@ -7,6 +7,12 @@ ng-click="exportCsv()">{{ 'PLAYER.ACTION_EXPORT_CSV' | translate }} + +
+
{{ 'PLAYER.INFO_NO_CLIPBOARD_ACTIVITY' | translate }} @@ -15,14 +21,18 @@
- {{ item.formattedTime }} + {{ item.formattedTime }} – {{ item.formattedLastTime }} +
+ +
+ +
    +
  • {{ member.formattedTime }}
  • +
+
+ diff --git a/guacamole/src/main/frontend/src/app/settings/styles/history-player.css b/guacamole/src/main/frontend/src/app/settings/styles/history-player.css index 55458dd511..6bf0046e83 100644 --- a/guacamole/src/main/frontend/src/app/settings/styles/history-player.css +++ b/guacamole/src/main/frontend/src/app/settings/styles/history-player.css @@ -157,6 +157,7 @@ } .settings.connectionHistoryPlayer .guac-player-controls .heat-map .legend .key-events::after, +.settings.connectionHistoryPlayer .guac-player-controls .heat-map .legend .clipboard-events::after, .settings.connectionHistoryPlayer .guac-player-controls .heat-map .legend .frame-events::after { display: inline-block; content: ''; @@ -175,6 +176,10 @@ background-color: #888888; } +.settings.connectionHistoryPlayer .guac-player-controls .heat-map .legend .clipboard-events::after { + background-color: #b25f00; +} + .settings.connectionHistoryPlayer .heat-map svg.key-events path { /* #5BA300 color at 50% opacity */ diff --git a/guacamole/src/main/frontend/src/translations/en.json b/guacamole/src/main/frontend/src/translations/en.json index 94ef3d9ffe..597cc1933c 100644 --- a/guacamole/src/main/frontend/src/translations/en.json +++ b/guacamole/src/main/frontend/src/translations/en.json @@ -493,7 +493,9 @@ "ACTION_CANCEL" : "@:APP.ACTION_CANCEL", "ACTION_CLOSE" : "Close", + "ACTION_COLLAPSE_REPEATS" : "Hide repeats", "ACTION_DOWNLOAD" : "Download", + "ACTION_EXPAND_REPEATS" : "Show repeats", "ACTION_EXPORT_CSV" : "Export CSV", "ACTION_PAUSE" : "@:APP.ACTION_PAUSE", "ACTION_PLAY" : "@:APP.ACTION_PLAY", @@ -502,6 +504,8 @@ "ACTION_SHOW_LESS" : "Show less", "ACTION_SHOW_MORE" : "Show more", + "INFO_CLIPBOARD_EVENTS_LEGEND" : "Clipboard Activity", + "INFO_CLIPBOARD_INCOMPLETE" : "{COUNT} clipboard {COUNT, plural, one{transfer was} other{transfers were}} recorded incompletely and may be missing.", "INFO_FRAME_EVENTS_LEGEND" : "On-screen Activity", "INFO_KEY_EVENTS_LEGEND" : "Keyboard Activity", "INFO_LOADING_RECORDING" : "Your recording is now being loaded. Please wait...", @@ -514,6 +518,7 @@ "LABEL_CLIPBOARD" : "Clipboard", "LABEL_CLIPBOARD_COPIED_OUT" : "Data Copied Out", "LABEL_CLIPBOARD_PASTED_IN" : "Data Pasted In", + "LABEL_CLIPBOARD_REPEATS" : "×{COUNT} {COUNT, plural, one{repeat} other{repeats}}", "FIELD_PLACEHOLDER_TEXT_BATCH_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER" From d9726e0ba23889bd86edce062f30950c4d2f299a Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Thu, 9 Jul 2026 00:41:23 -0300 Subject: [PATCH 33/33] spice: harden and de-duplicate the clipboard cockpit (#34 assessment) Follow-ups from a quality/security assessment of the clipboard audit cockpit. Security: image Download no longer echoes the attacker-controlled recording mimetype. imageExtension is now a closed allowlist (unknown subtypes, e.g. a forged image/svg, map to an inert .bin), and the downloaded Blob is typed application/octet-stream rather than the recording mimetype - so a hostile recording can no longer produce a script-capable file that runs when the auditor opens it. Performance: seek-bar tick tooltips now use the downscaled thumbnail (tick.raw.thumbURL) instead of the full-resolution data URL, and ticks are capped at 500 (the panel and CSV still retain every event) so a recording with thousands of clipboard transfers can't bloat the transport bar. De-duplication: the byte-identical direction-metadata maps in clipboardView and player were hoisted into clipboardMediaService.getDirectionMeta, and the verbatim-duplicated lightbox controller (open/close/Escape) was extracted into clipboardMediaService.attachLightbox, now shared by the key-log and clipboard viewers. attachLightbox also adds focus management - focus moves into the dialog on open (via $timeout, after the ng-if overlay renders) and is restored on close - closing an accessibility gap. Hardcoded English alt/aria-label strings moved into en.json. Reviewed (APPROVE, 0 critical/high/medium); builds clean. --- .../app/player/directives/clipboardView.js | 101 ++++------------- .../src/app/player/directives/player.js | 62 ++++++---- .../src/app/player/directives/textView.js | 52 +-------- .../player/services/clipboardMediaService.js | 107 +++++++++++++++++- .../app/player/templates/clipboardView.html | 9 +- .../src/app/player/templates/player.html | 5 +- .../src/app/player/templates/textView.html | 8 +- .../main/frontend/src/translations/en.json | 2 + 8 files changed, 190 insertions(+), 156 deletions(-) diff --git a/guacamole/src/main/frontend/src/app/player/directives/clipboardView.js b/guacamole/src/main/frontend/src/app/player/directives/clipboardView.js index 39a7f4e7a5..ecf5cdb72e 100644 --- a/guacamole/src/main/frontend/src/app/player/directives/clipboardView.js +++ b/guacamole/src/main/frontend/src/app/player/directives/clipboardView.js @@ -82,25 +82,6 @@ angular.module('player').directive('guacPlayerClipboardView', */ const TEXT_TRUNCATE_LENGTH = 500; - /** - * Display metadata describing how each recognized clipboard transfer - * direction should be presented, keyed by the raw server-annotated - * direction. Every entry carries a text label (never colour alone) and - * a CSS class reused from the key-log viewer's colour treatment. - * - * @type {!Object.} - */ - const DIRECTION_META = { - 'guest-to-client' : { - labelKey : 'PLAYER.LABEL_CLIPBOARD_COPIED_OUT', - directionClass : 'from-guest' - }, - 'client-to-guest' : { - labelKey : 'PLAYER.LABEL_CLIPBOARD_PASTED_IN', - directionClass : 'to-guest' - } - }; - /** * The clipboard events, sorted chronologically and enriched with * display metadata for rendering. @@ -171,8 +152,7 @@ angular.module('player').directive('guacPlayerClipboardView', $scope.sortedEvents = events.map((event, index) => { - const meta = (event.direction && DIRECTION_META[event.direction]) - || { labelKey : 'PLAYER.LABEL_CLIPBOARD', directionClass : '' }; + const meta = clipboardMediaService.getDirectionMeta(event.direction); const bytes = event.isImage ? event.size : byteLength(event.data); @@ -321,47 +301,10 @@ angular.module('player').directive('guacPlayerClipboardView', } }; - /** - * The clipboard image currently shown enlarged in the lightbox, or - * null if the lightbox is closed. - * - * @type {Object} - */ - $scope.lightboxImage = null; - - /** - * Handler which dismisses the lightbox when the Escape key is pressed. - * Bound to the document only while the lightbox is open. - * - * @param {KeyboardEvent} e - * The keydown event. - */ - const dismissOnEscape = function dismissOnEscape(e) { - if (e.keyCode === 27) // Escape - $scope.$apply(function applyClose() { - $scope.closeImage(); - }); - }; - - /** - * Opens the clipboard-image lightbox for the given image clipboard - * event metadata. - * - * @param {Object} clipboard - * The clipboard metadata (including dataURL) to display enlarged. - */ - $scope.openImage = function openImage(clipboard) { - $scope.lightboxImage = clipboard; - angular.element(document).on('keydown', dismissOnEscape); - }; - - /** - * Closes the clipboard-image lightbox, if open. - */ - $scope.closeImage = function closeImage() { - $scope.lightboxImage = null; - angular.element(document).off('keydown', dismissOnEscape); - }; + // Wire up the clipboard-image lightbox (openImage/closeImage/ + // lightboxImage + Escape handling + focus management), shared with the + // key-log viewer via clipboardMediaService. + clipboardMediaService.attachLightbox($scope, $element); /** * Maps an image mimetype to a file extension for downloads. @@ -374,15 +317,17 @@ angular.module('player').directive('guacPlayerClipboardView', */ const imageExtension = mimetype => { const subtype = (mimetype || '').toLowerCase().replace(/^image\//, ''); - if (subtype === 'jpeg' || subtype === 'jpg') - return 'jpg'; - if (subtype === 'png') - return 'png'; - if (subtype === 'bmp') - return 'bmp'; - if (subtype === 'tiff' || subtype === 'tif') - return 'tiff'; - return subtype || 'bin'; + + // Closed allowlist: the mimetype is attacker-controlled recording + // data, so an unknown subtype must NOT be echoed as the file + // extension (e.g. image/svg -> "clipboard-1.svg" containing a + // script that runs when the auditor opens it). Anything unrecognized + // gets an inert ".bin". + const KNOWN = { + jpeg : 'jpg', jpg : 'jpg', png : 'png', gif : 'gif', + bmp : 'bmp', tiff : 'tiff', tif : 'tiff', webp : 'webp' + }; + return KNOWN[subtype] || 'bin'; }; /** @@ -396,14 +341,15 @@ angular.module('player').directive('guacPlayerClipboardView', */ const dataURLToBlob = dataURL => { const parts = dataURL.split(','); - const meta = parts[0]; - const mimeMatch = /data:([^;]+)/.exec(meta); - const mimetype = mimeMatch ? mimeMatch[1] : 'application/octet-stream'; const binary = atob(parts[1] || ''); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); - return new Blob([bytes], { type: mimetype }); + + // Force a benign type for the saved file rather than trusting the + // attacker-controlled recording mimetype (a forged image/svg or + // image/html would otherwise produce a script-capable download). + return new Blob([bytes], { type: 'application/octet-stream' }); }; /** @@ -491,8 +437,8 @@ angular.module('player').directive('guacPlayerClipboardView', }; - // Abort any in-flight thumbnail loads, and unbind the lightbox key - // handler, when the directive is destroyed + // Abort any in-flight thumbnail loads when the directive is destroyed + // (the lightbox's Escape listener is torn down by attachLightbox) $scope.$on('$destroy', function clipboardViewDestroyed() { pendingImages.forEach(function detach(image) { image.onload = null; @@ -500,7 +446,6 @@ angular.module('player').directive('guacPlayerClipboardView', image.src = ''; }); pendingImages.clear(); - angular.element(document).off('keydown', dismissOnEscape); }); /** diff --git a/guacamole/src/main/frontend/src/app/player/directives/player.js b/guacamole/src/main/frontend/src/app/player/directives/player.js index 561756549f..94e7b71892 100644 --- a/guacamole/src/main/frontend/src/app/player/directives/player.js +++ b/guacamole/src/main/frontend/src/app/player/directives/player.js @@ -126,8 +126,8 @@ angular.module('player').directive('guacPlayer', ['$injector', function guacPlay }; - config.controller = ['$scope', '$element', '$window', - function guacPlayerController($scope, $element, $window) { + config.controller = ['$scope', '$element', '$window', 'clipboardMediaService', + function guacPlayerController($scope, $element, $window, clipboardMediaService) { /** * Guacamole.SessionRecording instance to be used to playback the @@ -453,22 +453,22 @@ angular.module('player').directive('guacPlayer', ['$injector', function guacPlay }; /** - * Display metadata for clipboard tick-marks, keyed by the raw - * server-annotated transfer direction. Mirrors the mapping used by the - * clipboard activity panel so colours and labels stay consistent. + * The maximum number of clipboard tick-marks rendered on the seek bar. + * Only the visual ticks are capped - the Clipboard Activity panel and + * CSV export retain every event - since a transport bar with more than + * a few hundred ticks is unreadable and would bloat the DOM. * - * @type {!Object.} + * @type {!Number} */ - const CLIPBOARD_TICK_META = { - 'guest-to-client' : { - labelKey : 'PLAYER.LABEL_CLIPBOARD_COPIED_OUT', - directionClass : 'from-guest' - }, - 'client-to-guest' : { - labelKey : 'PLAYER.LABEL_CLIPBOARD_PASTED_IN', - directionClass : 'to-guest' - } - }; + const MAX_CLIPBOARD_TICKS = 500; + + /** + * The set of image loads currently in flight for tick-tooltip thumbnail + * generation, tracked so their callbacks can be detached on teardown. + * + * @type {!Set.} + */ + const pendingTickImages = new Set(); /** * Rebuilds the clipboard timeline tick-marks from the current clipboard @@ -486,10 +486,22 @@ angular.module('player').directive('guacPlayer', ['$injector', function guacPlay return; } - $scope.clipboardTicks = events.map(function toTick(event) { + $scope.clipboardTicks = events.slice(0, MAX_CLIPBOARD_TICKS) + .map(function toTick(event) { + + const meta = clipboardMediaService.getDirectionMeta(event.direction); - const meta = CLIPBOARD_TICK_META[event.direction] - || { labelKey : 'PLAYER.LABEL_CLIPBOARD', directionClass : '' }; + // Generate a downscaled thumbnail so the (potentially many) tick + // tooltips hold a small image rather than the full-resolution + // data URL. Idempotent, and shared with the panel via the same + // clipboard event object. + if (event.isImage) { + const image = clipboardMediaService.generateThumbnail(event, + fn => $scope.$evalAsync(fn), + loaded => pendingTickImages.delete(loaded)); + if (image) + pendingTickImages.add(image); + } return { // Clamp so an event timestamped at/after the last frame @@ -501,7 +513,7 @@ angular.module('player').directive('guacPlayer', ['$injector', function guacPlay directionClass : meta.directionClass, directionLabelKey : meta.labelKey, isImage : event.isImage, - dataURL : event.dataURL, + raw : event, textPreview : (!event.isImage && event.data) ? event.data.substring(0, 80) : '' }; @@ -509,6 +521,16 @@ angular.module('player').directive('guacPlayer', ['$injector', function guacPlay }; + // Abort any in-flight tick-thumbnail loads when the player is destroyed + $scope.$on('$destroy', function abortPendingTickImages() { + pendingTickImages.forEach(function detach(image) { + image.onload = null; + image.onerror = null; + image.src = ''; + }); + pendingTickImages.clear(); + }); + /** * Seek the recording to the current playback position value. */ diff --git a/guacamole/src/main/frontend/src/app/player/directives/textView.js b/guacamole/src/main/frontend/src/app/player/directives/textView.js index 219eea0d6a..5ddf8f1010 100644 --- a/guacamole/src/main/frontend/src/app/player/directives/textView.js +++ b/guacamole/src/main/frontend/src/app/player/directives/textView.js @@ -175,54 +175,10 @@ angular.module('player').directive('guacPlayerTextView', // Reapply the filter whenever the search phrase is updated $scope.$watch('searchPhrase', applyFilter); - /** - * The clipboard image currently shown enlarged in the lightbox, or - * null if the lightbox is closed. Objects here are the `clipboard` - * metadata attached to an image clipboard event (see - * keyEventDisplayService). - * - * @type {Object} - */ - $scope.lightboxImage = null; - - /** - * Handler which dismisses the lightbox when the Escape key is pressed. - * Bound to the document only while the lightbox is open. - * - * @param {KeyboardEvent} e - * The keydown event. - */ - const dismissOnEscape = function dismissOnEscape(e) { - if (e.keyCode === 27) // Escape - $scope.$apply(function applyClose() { - $scope.closeImage(); - }); - }; - - /** - * Opens the clipboard-image lightbox for the given image clipboard - * event metadata. - * - * @param {Object} clipboard - * The clipboard metadata (including dataURL) to display enlarged. - */ - $scope.openImage = function openImage(clipboard) { - $scope.lightboxImage = clipboard; - angular.element(document).on('keydown', dismissOnEscape); - }; - - /** - * Closes the clipboard-image lightbox, if open. - */ - $scope.closeImage = function closeImage() { - $scope.lightboxImage = null; - angular.element(document).off('keydown', dismissOnEscape); - }; - - // Ensure the document-level listener never outlives the directive - $scope.$on('$destroy', function unbindEscape() { - angular.element(document).off('keydown', dismissOnEscape); - }); + // Wire up the clipboard-image lightbox (openImage/closeImage/ + // lightboxImage + Escape handling + focus management), shared with the + // clipboard-activity viewer via clipboardMediaService. + clipboardMediaService.attachLightbox($scope, $element); /** * @borrows playerTimeService.formatTime diff --git a/guacamole/src/main/frontend/src/app/player/services/clipboardMediaService.js b/guacamole/src/main/frontend/src/app/player/services/clipboardMediaService.js index a7f090a6c0..179ccc3614 100644 --- a/guacamole/src/main/frontend/src/app/player/services/clipboardMediaService.js +++ b/guacamole/src/main/frontend/src/app/player/services/clipboardMediaService.js @@ -23,8 +23,8 @@ * both render identical thumbnails without duplicating the canvas-downscale * logic. */ -angular.module('player').factory('clipboardMediaService', [ - function clipboardMediaService() { +angular.module('player').factory('clipboardMediaService', ['$timeout', + function clipboardMediaService($timeout) { /** * The maximum width/height, in pixels, of a generated clipboard image @@ -36,8 +36,46 @@ angular.module('player').factory('clipboardMediaService', [ */ const THUMBNAIL_MAX_DIMENSION = 96; + /** + * Canonical display metadata for clipboard transfer directions, keyed by the + * raw server-annotated direction. Each entry carries a translation key for a + * text label (never colour alone) and a CSS class for the colour treatment. + * This is the single source of truth shared by the clipboard activity panel + * and the seek-bar tick-marks. (keyEventDisplayService keeps a separate, + * intentionally terser inline-chip variant with different label wording.) + * + * @type {!Object.} + */ + const DIRECTION_META = { + 'guest-to-client' : { + labelKey : 'PLAYER.LABEL_CLIPBOARD_COPIED_OUT', + directionClass : 'from-guest' + }, + 'client-to-guest' : { + labelKey : 'PLAYER.LABEL_CLIPBOARD_PASTED_IN', + directionClass : 'to-guest' + } + }; + const service = {}; + /** + * Returns the display metadata (label translation key + CSS colour class) + * for the given clipboard transfer direction, falling back to a neutral + * label for unknown/unannotated directions. + * + * @param {String} direction + * The raw server-annotated direction ("guest-to-client" / + * "client-to-guest"), or null. + * + * @returns {!{labelKey: String, directionClass: String}} + * The display metadata for that direction. + */ + service.getDirectionMeta = function getDirectionMeta(direction) { + return DIRECTION_META[direction] + || { labelKey : 'PLAYER.LABEL_CLIPBOARD', directionClass : '' }; + }; + /** * Generate a downscaled thumbnail, and capture the natural dimensions, for * the given image clipboard metadata - asynchronously populating its @@ -122,6 +160,71 @@ angular.module('player').factory('clipboardMediaService', [ }; + /** + * Wires a clipboard-image lightbox onto the given directive scope: defines + * $scope.lightboxImage, $scope.openImage(clipboard), and + * $scope.closeImage(), manages the Escape-to-dismiss document listener, and + * performs focus management (focus moves into the overlay on open and is + * restored to the previously focused element on close). Shared by the + * key-log and clipboard-activity viewers so the behaviour — and any future + * fix — lives in one place. + * + * @param {!Object} $scope + * The directive scope to attach the lightbox handlers to. + * + * @param {!Object} $element + * The directive's jqLite element, used to locate the overlay for focus + * management. + */ + service.attachLightbox = function attachLightbox($scope, $element) { + + // The element focused before the lightbox opened, restored on close + let previousFocus = null; + + const dismissOnEscape = function dismissOnEscape(e) { + if (e.keyCode === 27) // Escape + $scope.$apply(function applyClose() { + $scope.closeImage(); + }); + }; + + $scope.lightboxImage = null; + + $scope.openImage = function openImage(clipboard) { + previousFocus = document.activeElement; + $scope.lightboxImage = clipboard; + angular.element(document).on('keydown', dismissOnEscape); + + // Move focus into the overlay once it has rendered. $timeout (not + // $evalAsync) is required: the overlay is created by an ng-if + // watcher during this same digest, so it is not yet in the DOM when + // the $evalAsync queue drains - $timeout runs after the digest, once + // the element exists. + $timeout(function focusOverlay() { + const overlay = $element && $element[0] + && $element[0].querySelector('.clipboard-lightbox'); + if (overlay) + overlay.focus(); + }); + }; + + $scope.closeImage = function closeImage() { + $scope.lightboxImage = null; + angular.element(document).off('keydown', dismissOnEscape); + + // Restore focus to wherever it was before the lightbox opened + if (previousFocus && previousFocus.focus) + previousFocus.focus(); + previousFocus = null; + }; + + // Ensure the document-level listener never outlives the directive + $scope.$on('$destroy', function unbindEscape() { + angular.element(document).off('keydown', dismissOnEscape); + }); + + }; + return service; }]); diff --git a/guacamole/src/main/frontend/src/app/player/templates/clipboardView.html b/guacamole/src/main/frontend/src/app/player/templates/clipboardView.html index 86cc32a508..10cb24520e 100644 --- a/guacamole/src/main/frontend/src/app/player/templates/clipboardView.html +++ b/guacamole/src/main/frontend/src/app/player/templates/clipboardView.html @@ -55,7 +55,8 @@ ng-click="$event.stopPropagation(); openImage(item.raw)"> Clipboard image + loading="lazy" + alt="{{ 'PLAYER.INFO_CLIPBOARD_IMAGE_ALT' | translate }}" />
@@ -84,7 +85,9 @@
diff --git a/guacamole/src/main/frontend/src/app/player/templates/player.html b/guacamole/src/main/frontend/src/app/player/templates/player.html index 9c98bcb9dd..2e4088d851 100644 --- a/guacamole/src/main/frontend/src/app/player/templates/player.html +++ b/guacamole/src/main/frontend/src/app/player/templates/player.html @@ -62,8 +62,9 @@ aria-hidden="true">
{{ tick.directionLabelKey | translate }}
- +
{{ tick.textPreview }}
diff --git a/guacamole/src/main/frontend/src/app/player/templates/textView.html b/guacamole/src/main/frontend/src/app/player/templates/textView.html index 0563b67a10..80feb133c4 100644 --- a/guacamole/src/main/frontend/src/app/player/templates/textView.html +++ b/guacamole/src/main/frontend/src/app/player/templates/textView.html @@ -36,7 +36,7 @@ title="{{ event.clipboard.mimetype }} · {{ event.clipboard.sizeLabel }}{{ event.clipboard.width ? ' · ' + event.clipboard.width + '×' + event.clipboard.height : '' }}" ng-click="$event.stopPropagation(); openImage(event.clipboard)" >Clipboard image{{ event.clipboard.mimetype }} · {{ event.clipboard.sizeLabel }} · {{ event.clipboard.width }}×{{ event.clipboard.height }} @@ -44,7 +44,9 @@ diff --git a/guacamole/src/main/frontend/src/translations/en.json b/guacamole/src/main/frontend/src/translations/en.json index 597cc1933c..5cffd19c4c 100644 --- a/guacamole/src/main/frontend/src/translations/en.json +++ b/guacamole/src/main/frontend/src/translations/en.json @@ -505,6 +505,8 @@ "ACTION_SHOW_MORE" : "Show more", "INFO_CLIPBOARD_EVENTS_LEGEND" : "Clipboard Activity", + "INFO_CLIPBOARD_IMAGE_ALT" : "Clipboard image", + "INFO_CLIPBOARD_IMAGE_PREVIEW" : "Clipboard image preview", "INFO_CLIPBOARD_INCOMPLETE" : "{COUNT} clipboard {COUNT, plural, one{transfer was} other{transfers were}} recorded incompletely and may be missing.", "INFO_FRAME_EVENTS_LEGEND" : "On-screen Activity", "INFO_KEY_EVENTS_LEGEND" : "Keyboard Activity",