From 55ee774f4a7de0c0707575eebfa46693644d6125 Mon Sep 17 00:00:00 2001 From: Corentin SORIANO Date: Sat, 30 Nov 2024 15:31:40 +0100 Subject: [PATCH] GUACAMOLE-288: Add directive that handle client display element. --- .../client/controllers/clientController.js | 6 +- .../controllers/secondaryMonitorController.js | 288 ++---------------- .../client/directives/guacClientSecondary.js | 230 ++++++++++++++ .../app/client/services/guacManageMonitor.js | 208 +++++++++++-- .../client/templates/secondaryMonitor.html | 19 +- 5 files changed, 434 insertions(+), 317 deletions(-) create mode 100644 guacamole/src/main/frontend/src/app/client/directives/guacClientSecondary.js 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 cd70e677b0..d3d24f30f1 100644 --- a/guacamole/src/main/frontend/src/app/client/controllers/clientController.js +++ b/guacamole/src/main/frontend/src/app/client/controllers/clientController.js @@ -788,7 +788,11 @@ angular.module('client').controller('clientController', ['$scope', '$routeParams return; // Init guacManageMonitor - guacManageMonitor.init($scope.menu); + guacManageMonitor.init(); + guacManageMonitor.menuShown = function menuShown() { + $scope.menu.shown = !$scope.menu.shown; + $scope.$apply(); + } // Add or remove additional monitor guacManageMonitor.addMonitor(); 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 a0d91b51f8..5dc67ae0bb 100644 --- a/guacamole/src/main/frontend/src/app/client/controllers/secondaryMonitorController.js +++ b/guacamole/src/main/frontend/src/app/client/controllers/secondaryMonitorController.js @@ -23,30 +23,18 @@ angular.module('client').controller('secondaryMonitorController', ['$scope', '$injector', '$routeParams', function clientController($scope, $injector, $routeParams) { - // Required types - const ClipboardData = $injector.get('ClipboardData'); - // Required services - const $window = $injector.get('$window'); - const clipboardService = $injector.get('clipboardService'); - const guacFullscreen = $injector.get('guacFullscreen'); + const $window = $injector.get('$window'); + const guacFullscreen = $injector.get('guacFullscreen'); + const guacManageMonitor = $injector.get('guacManageMonitor'); - // ID of this monitor + /** + * ID of this monitor. + * + * @type {!String} + */ const monitorId = $routeParams.id; - // Broadcast channel - const broadcast = new BroadcastChannel('guac_monitors'); - - // Latest mouse state - const mouseState = {}; - - // Display size in pixels and position of the monitor - let displayWidth = 0; - let displayHeight = 0; - let monitorPosition = 0; - let monitorsCount = 0; - let currentScaling = 1; - /** * In order to open the guacamole menu, we need to hit ctrl-alt-shift. There are * several possible keysysms for each key. @@ -57,262 +45,19 @@ angular.module('client').controller('secondaryMonitorController', ['$scope', '$i CTRL_KEYS = {0xFFE3 : true, 0xFFE4 : true}, MENU_KEYS = angular.extend({}, SHIFT_KEYS, ALT_KEYS, CTRL_KEYS); - // Instantiate client, using an HTTP tunnel for communications. - const client = new Guacamole.Client( - new Guacamole.HTTPTunnel("tunnel") - ); - - const display = client.getDisplay(); - let displayContainer; - - setTimeout(function() { - displayContainer = document.querySelector('.display') - - // Remove any existing display - displayContainer.innerHTML = ""; - - // Add display element - displayContainer.appendChild(display.getElement()); - - // Ready for resize - pushBroadcastMessage('resize', true); - }, 1000); - - /** - * Adjust the display scaling according to the window size. - */ - $scope.scaleDisplay = function scaleDisplay() { - - // Calculate required scaling factor - const scaleX = $window.innerWidth / displayWidth; - const scaleY = $window.innerHeight / displayHeight; - - // Use the lowest scaling to avoid acreen overflow - if (scaleX <= scaleY) - currentScaling = scaleX; - else - currentScaling = scaleY; - - display.scale(currentScaling); - }; - - // Send monitor-close event to broadcast channel on window unload - $window.addEventListener('unload', function unloadWindow() { - pushBroadcastMessage('monitorClose', monitorId); - }); - - // Mouse and keyboard - const mouse = new Guacamole.Mouse(client.getDisplay().getElement()); - const keyboard = new Guacamole.Keyboard(document); - - // Move mouse on screen and send mouse events to main window - mouse.onEach(['mousedown', 'mouseup', 'mousemove'], function sendMouseEvent(e) { - - // Ensure software cursor is shown - display.showCursor(true); - - // Update client-side cursor - display.moveCursor( - Math.floor(e.state.x / currentScaling), - Math.floor(e.state.y / currentScaling) - ); - - // Limit mouse move events to reduce latency - if (mouseState.lastPush && Date.now() - mouseState.lastPush < 100 - && mouseState.down === e.state.down - && mouseState.up === e.state.up - && mouseState.left === e.state.left - && mouseState.middle === e.state.middle - && mouseState.right === e.state.right) - return; - - // Click on actual display instead of the first - const displayOffset = displayWidth * monitorPosition; - - // Convert mouse state to serializable object - mouseState.down = e.state.down; - mouseState.up = e.state.up; - mouseState.left = e.state.left; - mouseState.middle = e.state.middle; - mouseState.right = e.state.right; - mouseState.x = e.state.x / currentScaling + displayOffset; - mouseState.y = e.state.y / currentScaling; - mouseState.lastPush = Date.now(); - - // Send mouse state to main window - pushBroadcastMessage('mouseState', mouseState); - }); - - // Hide software cursor when mouse leaves display - mouse.on('mouseout', function() { - if (!display) return; - display.showCursor(false); - }); - - // Handle any received clipboard data - client.onclipboard = function clientClipboardReceived(stream, mimetype) { - - let reader; - - // If the received data is text, read it as a simple string - if (/^text\//.exec(mimetype)) { - - reader = new Guacamole.StringReader(stream); - - // Assemble received data into a single string - let data = ''; - reader.ontext = function textReceived(text) { - data += text; - }; - - // Set clipboard contents once stream is finished - reader.onend = function textComplete() { - clipboardService.setClipboard(new ClipboardData({ - source : 'secondaryMonitor', - type : mimetype, - data : data - }))['catch'](angular.noop); - }; - - } - - // Otherwise read the clipboard data as a Blob - else { - reader = new Guacamole.BlobReader(stream, mimetype); - reader.onend = function blobComplete() { - clipboardService.setClipboard(new ClipboardData({ - source : 'secondaryMonitor', - type : mimetype, - data : reader.getBlob() - }))['catch'](angular.noop); - }; - } - - }; - - // Send keydown events to main window - keyboard.onkeydown = function (keysym) { - pushBroadcastMessage('keydown', keysym); - }; - - // Send keyup events to main window - keyboard.onkeyup = function (keysym) { - pushBroadcastMessage('keyup', keysym); - }; - - /** - * Push broadcast message containing instructions that allows additional - * monitor windows to draw display, resize window and more. - * - * @param {!string} type - * The type of message (ex: handler, fullscreen, resize) - * - * @param {*} content - * The content of the message, can contain any type of serializable - * content. - */ - function pushBroadcastMessage(type, content) { - const message = { - [type]: content - }; - - broadcast.postMessage(message); - }; - - /** - * Handle messages sent by main window in guac_monitors channel. These - * messages contain instructions to draw the screen, resize window, or - * request full screen mode. - * - * @param {Event} e - * Received message event from guac_monitors channel. - */ - broadcast.onmessage = function broadcastMessage(message) { - - // Run the client handler to draw display - if (message.data.handler) - client.runHandler(message.data.handler.opcode, - message.data.handler.parameters); - - if (message.data.monitorsInfos) { - - const monitorsInfos = message.data.monitorsInfos; - - // Store new monitor count and position - monitorsCount = monitorsInfos.count; - monitorPosition = monitorsInfos.map[monitorId]; - - // Set the monitor count in display - display.updateMonitors(monitorsCount); - - } - - // Resize display and window with parameters sent by guacd in the size handler - if (message.data.handler && message.data.handler.opcode === 'size') { + guacManageMonitor.init("secondary"); + guacManageMonitor.monitorAttributes.monitorId = monitorId; - 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; - - // Set the new display size - displayWidth = parseInt(parameters[1]) / monitorsCount; - displayHeight = parseInt(parameters[2]); - - // Translate all draw actions on X to draw the current display - // instead of the first - client.offsetX = displayWidth * monitorPosition; - - // Get unusable window height and width (ex: titlebar) - const windowUnusableHeight = $window.outerHeight - $window.innerHeight; - const windowUnusableWidth = $window.outerWidth - $window.innerWidth; - - // Remove scrollbars - document.querySelector('.client-main').style.overflow = 'hidden'; - - // Resize window to the display size - $window.resizeTo( - displayWidth + windowUnusableWidth, - displayHeight + windowUnusableHeight - ); - - // Adjust scaling to new size - $scope.scaleDisplay(); - - } - - // Full screen mode instructions - if (message.data.fullscreen) { - - // setFullscreenMode require explicit user action - if (message.data.fullscreen !== false) - openConsentButton(); - - // Close fullscreen mode instantly - else - guacFullscreen.setFullscreenMode(message.data.fullscreen); - - } - - }; - - /** - * Add button to request user consent before enabling fullscreen mode to - * comply with the setFullscreenMode requirements that require explicit - * user action. The button is removed after a few seconds if the user does - * not click on it. - */ - function openConsentButton() { + guacManageMonitor.openConsentButton = function openConsentButton() { // Show button $scope.showFullscreenConsent = true; + $scope.$apply(); // Auto hide button after delay setTimeout(function() { $scope.showFullscreenConsent = false; + $scope.$apply(); }, 10000); }; @@ -367,11 +112,16 @@ angular.module('client').controller('secondaryMonitorController', ['$scope', '$i // Toggle the menu $scope.$apply(function() { - pushBroadcastMessage('guacMenu', true); + guacManageMonitor.pushBroadcastMessage('guacMenu', true); }); } }); + // Send monitor-close event to broadcast channel on window unload + $window.addEventListener('unload', function unloadWindow() { + guacManageMonitor.pushBroadcastMessage('monitorClose', monitorId); + }); + }]); diff --git a/guacamole/src/main/frontend/src/app/client/directives/guacClientSecondary.js b/guacamole/src/main/frontend/src/app/client/directives/guacClientSecondary.js new file mode 100644 index 0000000000..4f68d2717f --- /dev/null +++ b/guacamole/src/main/frontend/src/app/client/directives/guacClientSecondary.js @@ -0,0 +1,230 @@ +/* + * 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. + */ + +/** + * A directive for the guacamole client on secondary monitors. + */ +angular.module('client').directive('guacClientSecondary', [function guacClient() { + + const directive = { + restrict: 'E', + replace: true, + templateUrl: 'app/client/templates/guacClient.html' + }; + + directive.scope = { + + /** + * The client to display within this guacClient directive. + * + * @type ManagedClient + */ + client : '=', + + }; + + directive.controller = ['$scope', '$injector', '$element', + function guacClientController($scope, $injector, $element) { + + // Required types + const ClipboardData = $injector.get('ClipboardData'); + + // Required services + const $document = $injector.get('$document'); + const clipboardService = $injector.get('clipboardService'); + const guacManageMonitor = $injector.get('guacManageMonitor'); + + /** + * The current Guacamole client instance. + * + * @type Guacamole.Client + */ + const client = new Guacamole.Client(new Guacamole.Tunnel()); + + /** + * The display of the current Guacamole client instance. + * + * @type Guacamole.Display + */ + const display = client.getDisplay(); + + /** + * The element associated with the display of the current + * Guacamole client instance. + * + * @type Element + */ + const displayElement = display.getElement(); + + /** + * The element which must contain the Guacamole display element. + * + * @type Element + */ + const displayContainer = $element.find('.display')[0]; + + /** + * The tracked mouse. + * + * @type Guacamole.Mouse + */ + const mouse = new Guacamole.Mouse(displayContainer); + + /** + * The latest known mouse state. + * + * @type Object + */ + const mouseState = {}; + + /** + * The keyboard. + * + * @type Guacamole.Keyboard + */ + 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); + + // Remove any existing display + displayContainer.innerHTML = ""; + + // Add display element + displayContainer.appendChild(displayElement); + + // Do nothing when the display element is clicked on + displayElement.onclick = function(e) { + e.preventDefault(); + return false; + }; + + // Adjust the display scaling according to the window size. + $scope.mainElementResized = guacManageMonitor.mainElementResized; + + // Ready for resize + guacManageMonitor.pushBroadcastMessage('resize', true); + + // Handle any received clipboard data + client.onclipboard = function clientClipboardReceived(stream, mimetype) { + + let reader; + + // If the received data is text, read it as a simple string + if (/^text\//.exec(mimetype)) { + + reader = new Guacamole.StringReader(stream); + + // Assemble received data into a single string + let data = ''; + reader.ontext = function textReceived(text) { + data += text; + }; + + // Set clipboard contents once stream is finished + reader.onend = function textComplete() { + clipboardService.setClipboard(new ClipboardData({ + source : 'secondaryMonitor', + type : mimetype, + data : data + }))['catch'](angular.noop); + }; + + } + + // Otherwise read the clipboard data as a Blob + else { + reader = new Guacamole.BlobReader(stream, mimetype); + reader.onend = function blobComplete() { + clipboardService.setClipboard(new ClipboardData({ + source : 'secondaryMonitor', + type : mimetype, + data : reader.getBlob() + }))['catch'](angular.noop); + }; + } + + }; + + // Move mouse on screen and send mouse events to main window + mouse.onEach(['mousedown', 'mouseup', 'mousemove'], function sendMouseEvent(e) { + + // Ensure software cursor is shown + 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) + ); + + // Limit mouse move events to reduce latency + if (mouseState.lastPush && Date.now() - mouseState.lastPush < 100 + && mouseState.down === e.state.down + && mouseState.up === e.state.up + && mouseState.left === e.state.left + && mouseState.middle === e.state.middle + && mouseState.right === e.state.right) + return; + + // Click on actual display instead of the first + const displayOffset = guacManageMonitor.monitorAttributes.width * guacManageMonitor.monitorAttributes.position; + + // Convert mouse state to serializable object + mouseState.down = e.state.down; + mouseState.up = e.state.up; + 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.lastPush = Date.now(); + + // Send mouse state to main window + guacManageMonitor.pushBroadcastMessage('mouseState', mouseState); + }); + + // Hide software cursor when mouse leaves display + mouse.on('mouseout', function() { + if (!display) return; + display.showCursor(false); + }); + + // Send keydown events to main window + keyboard.onkeydown = function (keysym) { + guacManageMonitor.pushBroadcastMessage('keydown', keysym); + }; + + // Send keyup events to main window + keyboard.onkeyup = function (keysym) { + guacManageMonitor.pushBroadcastMessage('keyup', keysym); + }; + + }]; + + return directive; + +}]); 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 a568bb1f54..3633c6044f 100644 --- a/guacamole/src/main/frontend/src/app/client/services/guacManageMonitor.js +++ b/guacamole/src/main/frontend/src/app/client/services/guacManageMonitor.js @@ -26,37 +26,72 @@ angular.module('client').factory('guacManageMonitor', ['$injector', // Required services const $window = $injector.get('$window'); - const service = {}; - - // Additionals monitors windows + /** + * Additionals monitors windows opened. + * + * @type Object. + */ const monitors = {}; - // Guacamole Client + /** + * The type of this monitor (default = primary). + * + * @type String + */ + let monitorType = "primary"; + + /** + * The display of the current Guacamole client instance. + * + * @type Guacamole.Display + */ let client = null; - // Broadcast channel + /** + * The current Guacamole client instance. + * + * @type Guacamole.Client + */ + let display = null; + + /** + * The broadcast channel used for communications between all windows. + * + * @type BroadcastChannel + */ let broadcast = null; - // Store the last monitor id + /** + * Store the last additional monitor id. + * + * @type Number + */ let lastMonitorId = 0; - // Reference for the ctrl/alt/shift menu - let guacMenu; + const service = {}; /** - * Init the broadcast channel used for bidirectionnal communications between - * primary and secondary monitor windows and get the gacamole menu reference. + * Attributes of the monitor * - * @param {!Object} menu - * Reference for the guacamole menu. + * @type Object. */ - service.init = function init(menu) { + service.monitorAttributes = {}; - guacMenu = menu; + /** + * Init the monitor type and broadcast channel used for bidirectionnal + * communications between primary and secondary monitor windows. + * + * @param {String} type + * The type of the monitor. "primary" if not given. + */ + service.init = function init(type) { + + // Change the monitor type + if (type) monitorType = type; // Create broadcast channel on first launch. if (broadcast) - return; + return; broadcast = new BroadcastChannel('guac_monitors'); @@ -67,8 +102,17 @@ angular.module('client').factory('guacManageMonitor', ['$injector', * @param {Event} e * Received message event from guac_monitors channel. */ - broadcast.onmessage = function broadcastMessage(message) { + broadcast.onmessage = messageHandlers[monitorType]; + }; + + /** + * Handlers for instructions received on broadcast channel. + */ + const messageHandlers = { + + "primary": function primary(message) { + // Send monitors infos and trigger the screen resize event if (message.data.resize) sendMonitorsInfos(); @@ -90,13 +134,116 @@ angular.module('client').factory('guacManageMonitor', ['$injector', service.closeMonitor(message.data.monitorClose); // CTRL+ALT+SHIFT pressed on secondary window - if (message.data.guacMenu) - guacMenu.shown = !guacMenu.shown; + if (message.data.guacMenu && service.menuShown) + service.menuShown(); + + }, + "secondary": function secondaryMonitor(message) { + + // Run the client handler to draw display + if (message.data.handler) + client.runHandler(message.data.handler.opcode, + message.data.handler.parameters); + + if (message.data.monitorsInfos) { + + const monitorsInfos = message.data.monitorsInfos; + const monitorId = service.monitorAttributes.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); + + } + + // Resize display and window with parameters sent by guacd in the size handler + if (message.data.handler && 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; + + // 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; + + // 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 + if (message.data.fullscreen) { + + // setFullscreenMode require explicit user action + if (message.data.fullscreen !== false) + if (service.openConsentButton) service.openConsentButton(); + + // Close fullscreen mode instantly + else + guacFullscreen.setFullscreenMode(message.data.fullscreen); + + } } - + + } + + /** + * Add button to request user consent before enabling fullscreen mode to + * comply with the setFullscreenMode requirements that require explicit + * user action. The button is removed after a few seconds if the user does + * not click on it. + */ + 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). + */ + service.menuShown = null; + /** * Set the current Guacamole Client * @@ -105,10 +252,12 @@ angular.module('client').factory('guacManageMonitor', ['$injector', */ service.setClient = function setClient(guac_client) { - client = guac_client; + client = guac_client; + display = client.getDisplay(); // Close all secondary monitors on client disconnect - client.ondisconnect = service.closeAllMonitors; + if (monitorType === "primary") + client.ondisconnect = service.closeAllMonitors; } @@ -125,16 +274,17 @@ angular.module('client').factory('guacManageMonitor', ['$injector', */ service.pushBroadcastMessage = function pushBroadcastMessage(type, content) { - if (service.getMonitorCount() > 1) { + // Send only if there are other monitors to receive this message + if (monitorType === "primary" && service.getMonitorCount() <= 1) + return; - // Format message content - const message = { - [type]: content - }; + // Format message content + const message = { + [type]: content + }; - // Send message on the broadcast channel - broadcast.postMessage(message); - } + // Send message on the broadcast channel + broadcast.postMessage(message); }; diff --git a/guacamole/src/main/frontend/src/app/client/templates/secondaryMonitor.html b/guacamole/src/main/frontend/src/app/client/templates/secondaryMonitor.html index 68ce9efa51..1c0fadfb2d 100644 --- a/guacamole/src/main/frontend/src/app/client/templates/secondaryMonitor.html +++ b/guacamole/src/main/frontend/src/app/client/templates/secondaryMonitor.html @@ -14,24 +14,7 @@ {{'CLIENT.ACTION_FULLSCREEN_CONSENT' | translate}} -
- - -
- -
- -
- -
- -
- -
+