From fe63a4f75df3511d20131ecd7ed27a4dff6ff9ab Mon Sep 17 00:00:00 2001 From: Corentin SORIANO Date: Tue, 29 Oct 2024 19:06:44 +0100 Subject: [PATCH 01/22] GUACAMOLE-288: Add client-side settings for limiting usage of multi-monitors on each RDP connection. --- .../org/apache/guacamole/protocols/rdp.json | 4 +++ .../main/frontend/src/translations/en.json | 27 ++++++++++--------- .../main/frontend/src/translations/fr.json | 13 ++++----- 3 files changed, 25 insertions(+), 19 deletions(-) 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 b62aaaad83..98d82b80f1 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 f74e80a8ca..9c2b0a36ce 100644 --- a/guacamole/src/main/frontend/src/translations/en.json +++ b/guacamole/src/main/frontend/src/translations/en.json @@ -639,19 +639,20 @@ "FIELD_HEADER_PRECONNECTION_BLOB" : "Preconnection BLOB (VM ID):", "FIELD_HEADER_PRECONNECTION_ID" : "RDP source ID:", "FIELD_HEADER_READ_ONLY" : "Read-only:", - "FIELD_HEADER_RECORDING_WRITE_EXISTING" : "@:APP.FIELD_HEADER_RECORDING_WRITE_EXISTING", - "FIELD_HEADER_RECORDING_EXCLUDE_MOUSE" : "Exclude mouse:", - "FIELD_HEADER_RECORDING_EXCLUDE_OUTPUT" : "Exclude graphics/streams:", - "FIELD_HEADER_RECORDING_EXCLUDE_TOUCH" : "Exclude touch events:", - "FIELD_HEADER_RECORDING_INCLUDE_KEYS" : "Include key events:", - "FIELD_HEADER_RECORDING_NAME" : "Recording name:", - "FIELD_HEADER_RECORDING_PATH" : "Recording path:", - "FIELD_HEADER_RESIZE_METHOD" : "Resize method:", - "FIELD_HEADER_REMOTE_APP_ARGS" : "Parameters:", - "FIELD_HEADER_REMOTE_APP_DIR" : "Working directory:", - "FIELD_HEADER_REMOTE_APP" : "Program:", - "FIELD_HEADER_SECURITY" : "Security mode:", - "FIELD_HEADER_SERVER_LAYOUT" : "Keyboard layout:", + "FIELD_HEADER_RECORDING_WRITE_EXISTING" : "@:APP.FIELD_HEADER_RECORDING_WRITE_EXISTING", + "FIELD_HEADER_RECORDING_EXCLUDE_MOUSE" : "Exclude mouse:", + "FIELD_HEADER_RECORDING_EXCLUDE_OUTPUT" : "Exclude graphics/streams:", + "FIELD_HEADER_RECORDING_EXCLUDE_TOUCH" : "Exclude touch events:", + "FIELD_HEADER_RECORDING_INCLUDE_KEYS" : "Include key events:", + "FIELD_HEADER_RECORDING_NAME" : "Recording name:", + "FIELD_HEADER_RECORDING_PATH" : "Recording path:", + "FIELD_HEADER_RESIZE_METHOD" : "Resize method:", + "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:", "FIELD_HEADER_SFTP_DISABLE_DOWNLOAD" : "Disable file download:", "FIELD_HEADER_SFTP_DISABLE_UPLOAD" : "Disable file upload:", diff --git a/guacamole/src/main/frontend/src/translations/fr.json b/guacamole/src/main/frontend/src/translations/fr.json index e7beefff5b..913a0762e0 100644 --- a/guacamole/src/main/frontend/src/translations/fr.json +++ b/guacamole/src/main/frontend/src/translations/fr.json @@ -627,12 +627,13 @@ "FIELD_HEADER_RECORDING_EXCLUDE_OUTPUT" : "Exclure les graphiques/flux:", "FIELD_HEADER_RECORDING_EXCLUDE_TOUCH" : "Exclure les événements tactiles:", "FIELD_HEADER_RECORDING_INCLUDE_KEYS" : "Inclure les événements clavier:", - "FIELD_HEADER_RECORDING_NAME" : "Nom de l'enregistrement:", - "FIELD_HEADER_RECORDING_PATH" : "Chemin de l'enregistrement:", - "FIELD_HEADER_RESIZE_METHOD" : "Méthode de redimensionnement:", - "FIELD_HEADER_REMOTE_APP_ARGS" : "Paramètres:", - "FIELD_HEADER_REMOTE_APP_DIR" : "Répertoire de travail:", - "FIELD_HEADER_REMOTE_APP" : "Programme:", + "FIELD_HEADER_RECORDING_NAME" : "Nom de l'enregistrement:", + "FIELD_HEADER_RECORDING_PATH" : "Chemin de l'enregistrement:", + "FIELD_HEADER_RESIZE_METHOD" : "Méthode de redimensionnement:", + "FIELD_HEADER_REMOTE_APP_ARGS" : "Paramètres:", + "FIELD_HEADER_REMOTE_APP_DIR" : "Répertoire de travail:", + "FIELD_HEADER_REMOTE_APP" : "Programme:", + "FIELD_HEADER_SECONDARY_MONITORS" : "Nombre maximum de moniteurs secondaires (nécessite display-update):", "FIELD_HEADER_SECURITY" : "Mode de Sécurité:", "FIELD_HEADER_SERVER_LAYOUT" : "Agencement clavier:", "FIELD_HEADER_SFTP_DIRECTORY" : "Répertoire d'upload par défaut:", From 5a4f6ad4dec72ef5e73ce5924522dd4f73de0ef6 Mon Sep 17 00:00:00 2001 From: Corentin SORIANO Date: Sat, 16 Nov 2024 08:36:13 +0100 Subject: [PATCH 02/22] 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 548cf5888800afb6723f77f7d7de1c4b9ddf0641 Mon Sep 17 00:00:00 2001 From: Corentin SORIANO Date: Thu, 16 Jan 2025 11:57:51 +0100 Subject: [PATCH 10/22] 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 083616e3c6..b75c15f434 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 6a0c47ad986c8407e15037579bc7fcd8ba0b1803 Mon Sep 17 00:00:00 2001 From: Corentin SORIANO Date: Fri, 23 May 2025 17:10:19 +0200 Subject: [PATCH 11/22] 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 41f5374faa..8f10d84914 100644 --- a/guacamole-common-js/src/main/webapp/modules/Client.js +++ b/guacamole-common-js/src/main/webapp/modules/Client.js @@ -331,16 +331,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); }; @@ -1679,10 +1682,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 0458303a37..9e6101216b 100644 --- a/guacamole/src/main/frontend/src/app/client/directives/guacClient.js +++ b/guacamole/src/main/frontend/src/app/client/directives/guacClient.js @@ -462,17 +462,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, + }); } @@ -480,9 +478,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 bf062125d5..5c74ae50f2 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 ac4f6a46b6..fe27097a53 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 d4883311066aa161e85e3a2d26fed762acaaaeb8 Mon Sep 17 00:00:00 2001 From: Corentin SORIANO Date: Tue, 3 Jun 2025 17:21:43 +0200 Subject: [PATCH 12/22] 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 8f10d84914..c974718d62 100644 --- a/guacamole-common-js/src/main/webapp/modules/Client.js +++ b/guacamole-common-js/src/main/webapp/modules/Client.js @@ -163,6 +163,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 @@ -386,6 +391,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(); @@ -1099,10 +1111,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]); @@ -1246,16 +1259,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, @@ -1283,12 +1298,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]); @@ -1300,6 +1316,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]); @@ -1307,7 +1324,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); @@ -1438,13 +1455,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); @@ -1458,11 +1476,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); @@ -1484,10 +1503,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); @@ -1507,7 +1527,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); @@ -1600,11 +1620,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); @@ -1631,10 +1652,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]); @@ -1694,10 +1716,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); @@ -1740,16 +1763,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 9e6101216b..9d3971e8c4 100644 --- a/guacamole/src/main/frontend/src/app/client/directives/guacClient.js +++ b/guacamole/src/main/frontend/src/app/client/directives/guacClient.js @@ -462,6 +462,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) @@ -469,7 +471,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 934efe5d3a8b7e441ab2b1f02b4866afd04673a3 Mon Sep 17 00:00:00 2001 From: Corentin SORIANO Date: Sat, 7 Jun 2025 23:03:56 +0200 Subject: [PATCH 13/22] 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 c974718d62..e51af868be 100644 --- a/guacamole-common-js/src/main/webapp/modules/Client.js +++ b/guacamole-common-js/src/main/webapp/modules/Client.js @@ -868,6 +868,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. @@ -1070,6 +1079,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 573dfc89ded7fdce5b575d4fcd17028d11487e74 Mon Sep 17 00:00:00 2001 From: Corentin SORIANO Date: Mon, 3 Nov 2025 18:17:48 +0100 Subject: [PATCH 14/22] 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 e51af868be..8438ad5314 100644 --- a/guacamole-common-js/src/main/webapp/modules/Client.js +++ b/guacamole-common-js/src/main/webapp/modules/Client.js @@ -1404,6 +1404,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 18d3783850a44f3c661522f4e9148a7973e6bbb2 Mon Sep 17 00:00:00 2001 From: Corentin SORIANO Date: Thu, 27 Nov 2025 11:17:40 +0100 Subject: [PATCH 15/22] 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 9d3971e8c4..34ac7747f8 100644 --- a/guacamole/src/main/frontend/src/app/client/directives/guacClient.js +++ b/guacamole/src/main/frontend/src/app/client/directives/guacClient.js @@ -467,7 +467,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 7c2447af3000b52f29c2e09b32c2fa178cd46de9 Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Mon, 20 Jul 2026 10:50:37 -0300 Subject: [PATCH 16/22] GUACAMOLE-288: Namespace the monitor BroadcastChannel per connection. The multi-monitor BroadcastChannel was created with the fixed name "guac_monitors", shared by every connection open in the browser. With two connections active at once, a secondary monitor window sent its keyboard and mouse events onto the same channel the other connection's windows were listening on, so input intended for one session -- including anything typed into a credential prompt -- reached the other session's guacd stream, and display instructions were mirrored across the connection boundary. Separately, broadcast.onmessage was wired directly to the monitor type's handler table, so any same-origin page could reach client.runHandler() by posting to a channel whose name it could guess. The primary window now generates a per-connection channel id (crypto.randomUUID(), with a timestamp+random fallback for insecure contexts) and uses it both in the channel name and as a _cid tag on every message; messages whose _cid does not match are dropped before dispatch. Secondary windows inherit the id through the window URL rather than generating their own, so the route becomes /secondaryMonitor/:id/:channel?. This resolves the "ensure that there is no mixing of data between multiple connections" TODO in guacManageMonitor.js. --- .../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 f224cc3419..853f586dba 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. * @@ -112,12 +136,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); @@ -128,17 +162,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 @@ -297,9 +338,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 @@ -315,8 +358,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 8a10ae4de6d6ec6cff3e83c7035c6465e647a28d Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Mon, 20 Jul 2026 10:50:59 -0300 Subject: [PATCH 17/22] GUACAMOLE-288: Use absolute left offset and tolerate transient layout gaps. getOffsetX() derived a monitor's horizontal offset by summing the rendered widths of every monitor at a lower position. That is correct only when the monitors form a gapless left-to-right row, which is the common arrangement but not the only one: a vertical stack, a gap between monitors, or any arrangement where a monitor is not immediately adjacent to its predecessor produced a wrong offset for both draw instructions and mouse coordinates. It was also asymmetric with getOffsetY(), which already used absolute geometry. getOffsetX() now reads the monitor's absolute left coordinate and normalises it against the leftmost monitor, via a new getLowestLeftOffset() helper that mirrors the existing getLowestTopOffset(). onmultimonlayout() also closed a monitor as soon as it was absent from a single layout update. A guest that briefly reports a reduced layout while renegotiating its heads would therefore destroy a window the user had opened, with no way to recover it. Absence is now counted and closeMonitor() fires only after 8 consecutive misses, with the counter reset whenever the monitor reappears. Adds getMonitorsInfos() so the resize handlers can consult the current layout. --- .../app/client/services/guacManageMonitor.js | 74 +++++++++++++------ 1 file changed, 50 insertions(+), 24 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 853f586dba..3b93ca22e3 100644 --- a/guacamole/src/main/frontend/src/app/client/services/guacManageMonitor.js +++ b/guacamole/src/main/frontend/src/app/client/services/guacManageMonitor.js @@ -121,6 +121,14 @@ angular.module('client').factory('guacManageMonitor', ['$injector', rendered: {}, }; + /** + * Map tracking consecutive missing counts per monitor ID. + * + * @type Object. + */ + const missingCounts = {}; + + const service = {}; /** @@ -381,6 +389,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(); @@ -455,31 +466,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(); } /** @@ -566,6 +565,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. * @@ -667,13 +686,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] = {}; @@ -702,7 +728,7 @@ angular.module('client').factory('guacManageMonitor', ['$injector', } - // Close additional monitors when window is unloaded + // Close additional monitors when window is unloaded $window.addEventListener('unload', service.closeAllMonitors); return service; From bc87ff89205bf51f3ab09cdb2a40ed211287da2f Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Mon, 20 Jul 2026 10:51:13 -0300 Subject: [PATCH 18/22] GUACAMOLE-288: Crop the display when the monitor size changes. Display.setMonitorSize() recorded monitorWidth and monitorHeight but did not act on them. When the server had already sized the default layer to the full combined desktop and sent no further resize after the per-window monitor rectangle became known, the window kept rendering the entire desktop instead of its own region. setMonitorSize() now re-runs the resize of the default layer whenever the current display size differs from the monitor size, re-entering the existing clamping path so the visible area is constrained to the monitor bounds. Adds DisplaySpec.js covering both orderings: resize followed by setMonitorSize, and setMonitorSize followed by a later oversized resize. --- .../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 1fd930f7460a77b0e68b2f6f4b97fc6b9a4a5244 Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Mon, 20 Jul 2026 10:52:48 -0300 Subject: [PATCH 19/22] GUACAMOLE-288: Debounce resize requests and suppress remote resize echoes. Resizing a monitor window produced a feedback loop. A resize request sent to the server came back as a remote display resize, which rescaled the display element, which changed the measured size of the container, which fired the element-resize sensor again, which sent a further request. Because requests were sent synchronously on every sensor event, a single user drag generated a burst of requests and the combined display did not settle. Resize requests are now debounced by 250ms, and requests are suppressed for 700ms after the display is resized by the server, since element resizes in that window are echoes of the remote resize rather than user intent. A request arriving inside the suppression window is rescheduled for the remainder of that window rather than dropped, so a genuine pending resize is not lost. The primary window detects remote resizes through the existing managedDisplay.size watch; the secondary window gains a display.onresize handler for the same purpose. Both handlers also now guard against a missing client, display, or zero-size container, and the secondary no longer assumes .client-main is present in the document. --- .../src/app/client/directives/guacClient.js | 86 +++++++++++++++---- .../client/directives/guacClientSecondary.js | 76 ++++++++++++---- 2 files changed, 131 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 34ac7747f8..067d878f12 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. @@ -375,6 +376,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); }); @@ -442,6 +452,32 @@ 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; + /** * Sends the current size of the main element (the display container) * to the Guacamole server, requesting that the remote display be @@ -459,21 +495,41 @@ 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 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, + }); + } + }, 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..e4e10dc711 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,73 @@ 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; + + // 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 pixelDensity = $window.devicePixelRatio ?? 1; + const width = main.offsetWidth * pixelDensity; + const height = main.offsetHeight * pixelDensity; + 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 bb9d5389eae09734e1cd422a3c5e3e402772af77 Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Mon, 20 Jul 2026 10:53:21 -0300 Subject: [PATCH 20/22] GUACAMOLE-288: Validate the multimon-layout parameter before use. The multimon-layout layer parameter is parsed and consumed without validation. A malformed payload throws out of JSON.parse inside the instruction handler, which aborts the handler loop and tears down the client, and geometry values that are not finite numbers propagate into the offset and size calculations as NaN, leaving monitor windows positioned at NaN with no error surfaced. Client.js now guards both the parse and the callback dispatch, and requires the parsed value to be an object before invoking the layout handler, so the transport layer is self-protecting regardless of what a consumer does with the value. guacManageMonitor.js validates each entry: width, height, top and left must be finite, with width and height strictly positive. Negative top and left remain valid, since a monitor may sit above or to the left of the primary. An entry that fails validation is treated the same as an absent monitor and therefore flows into the existing missing-count tolerance rather than immediately closing the window. Adds ClientSpec.js covering the transport-level guard: malformed JSON, JSON null, non-object JSON values, a handler that throws, and an unassigned handler. Five of the seven specs fail against the unguarded parser. --- .../src/main/webapp/modules/Client.js | 19 ++- .../src/test/javascript/ClientSpec.js | 158 ++++++++++++++++++ .../app/client/services/guacManageMonitor.js | 19 ++- 3 files changed, 192 insertions(+), 4 deletions(-) create mode 100644 guacamole-common-js/src/test/javascript/ClientSpec.js diff --git a/guacamole-common-js/src/main/webapp/modules/Client.js b/guacamole-common-js/src/main/webapp/modules/Client.js index 8438ad5314..1b8a4c5eab 100644 --- a/guacamole-common-js/src/main/webapp/modules/Client.js +++ b/guacamole-common-js/src/main/webapp/modules/Client.js @@ -1083,8 +1083,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-common-js/src/test/javascript/ClientSpec.js b/guacamole-common-js/src/test/javascript/ClientSpec.js new file mode 100644 index 0000000000..c0d2a608ee --- /dev/null +++ b/guacamole-common-js/src/test/javascript/ClientSpec.js @@ -0,0 +1,158 @@ +/* + * 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. + */ + +/** + * Tests the handling of the "multimon-layout" layer parameter. The value of + * this parameter is supplied by the server and is parsed directly, so a + * malformed payload must be absorbed rather than allowed to propagate out of + * the instruction handler and tear down the client. + */ +describe("Guacamole.Client multimon-layout handling", function ClientSpec() { + + /** + * The client being tested. + * + * @type Guacamole.Client + */ + var client; + + /** + * A minimal tunnel stub. Guacamole.Client assigns its instruction handler + * to tunnel.oninstruction, which is how instructions are injected below. + * + * @type Object + */ + var tunnel; + + /** + * Sends a "set" instruction assigning the given raw value to the + * "multimon-layout" parameter of the default layer. + * + * @param {!string} value + * The raw parameter value, as it would arrive from the server. + */ + function sendLayout(value) { + tunnel.oninstruction('set', [ '0', 'multimon-layout', value ]); + } + + beforeEach(function() { + + tunnel = { + sendMessage : function() {}, + connect : function() {}, + disconnect : function() {}, + isConnected : function() { return true; }, + oninstruction : null, + onerror : null, + onstatechange : null + }; + + client = new Guacamole.Client(tunnel); + + }); + + it("should pass a well-formed layout through to the handler", function() { + + var received = null; + client.onmultimonlayout = function(layout) { + received = layout; + }; + + sendLayout('{"0":{"left":0,"top":0,"width":2560,"height":1422}}'); + + expect(received).not.toBeNull(); + expect(received['0'].width).toBe(2560); + expect(received['0'].height).toBe(1422); + + }); + + it("should not throw when the layout is not valid JSON", function() { + + client.onmultimonlayout = function() {}; + + expect(function() { + sendLayout('{"0":{"left":0,'); + }).not.toThrow(); + + }); + + it("should not invoke the handler when the layout is not valid JSON", function() { + + var called = false; + client.onmultimonlayout = function() { + called = true; + }; + + sendLayout('not json at all'); + + expect(called).toBe(false); + + }); + + it("should not invoke the handler for a JSON null", function() { + + var called = false; + client.onmultimonlayout = function() { + called = true; + }; + + sendLayout('null'); + + expect(called).toBe(false); + + }); + + it("should not invoke the handler for a non-object JSON value", function() { + + var calls = 0; + client.onmultimonlayout = function() { + calls++; + }; + + sendLayout('42'); + sendLayout('"a string"'); + sendLayout('true'); + + expect(calls).toBe(0); + + }); + + it("should absorb an exception thrown by the handler", function() { + + client.onmultimonlayout = function() { + throw new Error('consumer failure'); + }; + + expect(function() { + sendLayout('{"0":{"left":0,"top":0,"width":2560,"height":1422}}'); + }).not.toThrow(); + + }); + + it("should not require a handler to be assigned", function() { + + client.onmultimonlayout = null; + + expect(function() { + sendLayout('{"0":{"left":0,"top":0,"width":2560,"height":1422}}'); + }).not.toThrow(); + + }); + +}); 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 3b93ca22e3..3dd6fc9ee8 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); From cc15bee27f388fb48241b937d54d6a746aa08993 Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Mon, 20 Jul 2026 15:33:14 -0300 Subject: [PATCH 21/22] GUACAMOLE-288: Order monitors by screen position rather than opening order. The monitor map assigned positions by iterating the monitors object, so a monitor's position was the order its window happened to be opened. That position is what sendAllSizes() reports to guacd as the monitor index, so a user who opens a window and then moves it to the left of an existing one gets an arrangement that does not match what is on screen. It also prevented the vertical arrangement noted as outstanding in the pull request, since a stacked layout has no meaningful opening order. Secondary monitors are now ordered by their reported geometry, left then top, which orders a horizontal row, a vertical stack, and any combination of the two. The main window remains position 0. A monitor that has not yet reported a size keeps its relative opening order and sorts last, taking its place once a size arrives. This also closes a gap in the position sequence when a monitor is removed. Positions were assigned before the closing monitor's entry was deleted, so closing the middle of three left positions 1 and 3 with 2 missing. Since the closing monitor now has no geometry to sort by, it is placed last and the survivors keep a contiguous sequence. Guacd rejects a monitor index greater than the current monitor count in order to keep the layout contiguous, so the gap silently dropped a subsequent resize for the monitor that landed above it. Replaces the "use the left value to reorder monitors if needed" TODO. --- guacamole-common-js/node_modules | 1 + guacamole/src/main/frontend/package-lock.json | 32 ++++++++++++++++++- .../app/client/services/guacManageMonitor.js | 32 +++++++++++++++++-- 3 files changed, 62 insertions(+), 3 deletions(-) create mode 120000 guacamole-common-js/node_modules diff --git a/guacamole-common-js/node_modules b/guacamole-common-js/node_modules new file mode 120000 index 0000000000..6a718a35eb --- /dev/null +++ b/guacamole-common-js/node_modules @@ -0,0 +1 @@ +/home/ciro/code/guacamole-client_proto-spice/guacamole-common-js/node_modules \ No newline at end of file diff --git a/guacamole/src/main/frontend/package-lock.json b/guacamole/src/main/frontend/package-lock.json index 7ad1e32ce7..2560d4eb89 100644 --- a/guacamole/src/main/frontend/package-lock.json +++ b/guacamole/src/main/frontend/package-lock.json @@ -4,7 +4,6 @@ "requires": true, "packages": { "": { - "name": "frontend", "dependencies": { "@simonwep/pickr": "^1.8.2", "angular": "^1.8.3", @@ -5486,6 +5485,37 @@ "linux" ] }, + "node_modules/google-closure-compiler-osx": { + "version": "20240317.0.0", + "resolved": "https://registry.npmjs.org/google-closure-compiler-osx/-/google-closure-compiler-osx-20240317.0.0.tgz", + "integrity": "sha512-0mABwjD4HP11rikFd8JRIb9OgPqn9h3o3wS0otufMfmbwS7zRpnnoJkunifhORl3VoR1gFm6vcTC9YziTEFdOw==", + "cpu": [ + "x32", + "x64", + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/google-closure-compiler-windows": { + "version": "20240317.0.0", + "resolved": "https://registry.npmjs.org/google-closure-compiler-windows/-/google-closure-compiler-windows-20240317.0.0.tgz", + "integrity": "sha512-fTueVFzNOWURFlXZmrFkAB7yA+jzpA2TeDOYeBEFwVlVGHwi8PV3Q9vCIWlbkE8wLpukKEg5wfRHYrLwVPINCA==", + "cpu": [ + "x32", + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/gopd": { "version": "1.0.1", "dev": true, 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 3dd6fc9ee8..df59e239c1 100644 --- a/guacamole/src/main/frontend/src/app/client/services/guacManageMonitor.js +++ b/guacamole/src/main/frontend/src/app/client/services/guacManageMonitor.js @@ -598,10 +598,39 @@ angular.module('client').factory('guacManageMonitor', ['$injector', // The main window would represent 0 let monitorPosition = 1; + // The geometry each monitor will have once this update is applied. + // The incoming details are merged in here rather than read back from + // monitorsInfos.details, which is not written until further below, so + // that ordering reflects the position being reported now instead of + // lagging one update behind. + const geometry = Object.assign({}, monitorsInfos.details); + if (monitorDetails) { + if (monitorDetails.width === 0 || monitorDetails.height === 0) + delete geometry[monitorDetails.id]; + else + geometry[monitorDetails.id] = monitorDetails; + } + + // Order secondary monitors by where they actually sit on screen rather + // than the order their windows happened to be opened, so the positions + // sent to guacd describe the arrangement the user sees. Sorting by left + // then top orders a horizontal row, a vertical stack, and any mix of + // the two. A monitor whose geometry is not known yet keeps its relative + // opening order and sorts last; it takes its place as soon as it + // reports a size. + const positioned = [], pending = []; + for (const monitorKey in monitors) + (geometry[monitorKey] ? positioned : pending).push(monitorKey); + + positioned.sort(function compareMonitorPosition(a, b) { + return ((geometry[a].left ?? 0) - (geometry[b].left ?? 0)) + || ((geometry[a].top ?? 0) - (geometry[b].top ?? 0)); + }); + // Generate monitors map (id => position), main window is always at // position 0 monitorsInfos.map[0] = 0; - for (const monitorKey in monitors) { + for (const monitorKey of positioned.concat(pending)) { monitorsInfos.map[monitorKey] = monitorPosition++; } @@ -622,7 +651,6 @@ angular.module('client').factory('guacManageMonitor', ['$injector', width: monitorDetails.width, height: monitorDetails.height, top: monitorDetails.top, - // TODO: Use the left value to reorder monitors if needed left: monitorDetails.left, }; } From 19a33b1cf166a2f73b89e53aa6b9c819119e506c Mon Sep 17 00:00:00 2001 From: Ciro Iriarte Date: Tue, 21 Jul 2026 13:43:12 -0300 Subject: [PATCH 22/22] GUACAMOLE-288: Address multi-monitor PR feedback --- guacamole-common-js/node_modules | 1 - .../src/main/frontend/src/app/client/directives/guacClient.js | 2 +- .../main/frontend/src/app/client/services/guacManageMonitor.js | 3 +-- 3 files changed, 2 insertions(+), 4 deletions(-) delete mode 120000 guacamole-common-js/node_modules diff --git a/guacamole-common-js/node_modules b/guacamole-common-js/node_modules deleted file mode 120000 index 6a718a35eb..0000000000 --- a/guacamole-common-js/node_modules +++ /dev/null @@ -1 +0,0 @@ -/home/ciro/code/guacamole-client_proto-spice/guacamole-common-js/node_modules \ No newline at end of file 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 067d878f12..45ec8b79a3 100644 --- a/guacamole/src/main/frontend/src/app/client/directives/guacClient.js +++ b/guacamole/src/main/frontend/src/app/client/directives/guacClient.js @@ -456,7 +456,7 @@ angular.module('client').directive('guacClient', [function guacClient() { * 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 + * connected and the current size will be 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. 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 df59e239c1..f671f2e4de 100644 --- a/guacamole/src/main/frontend/src/app/client/services/guacManageMonitor.js +++ b/guacamole/src/main/frontend/src/app/client/services/guacManageMonitor.js @@ -128,7 +128,6 @@ angular.module('client').factory('guacManageMonitor', ['$injector', */ const missingCounts = {}; - const service = {}; /** @@ -771,7 +770,7 @@ angular.module('client').factory('guacManageMonitor', ['$injector', } - // Close additional monitors when window is unloaded + // Close additional monitors when window is unloaded $window.addEventListener('unload', service.closeAllMonitors); return service;