diff --git a/guacamole-common-js/src/main/webapp/modules/Client.js b/guacamole-common-js/src/main/webapp/modules/Client.js index 03dee3e1b9..e51af868be 100644 --- a/guacamole-common-js/src/main/webapp/modules/Client.js +++ b/guacamole-common-js/src/main/webapp/modules/Client.js @@ -158,6 +158,16 @@ Guacamole.Client = function(tunnel) { || currentState == Guacamole.Client.State.WAITING; } + /** + * Add optional X offset on defaut layer draw actions. + */ + 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 @@ -325,14 +335,20 @@ Guacamole.Client = function(tunnel) { * * @param {!number} height * The height of the screen. + * + * @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(width, height) { + 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); + tunnel.sendMessage("size", width, height, x_position, top_offset); }; @@ -375,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(); @@ -735,6 +758,25 @@ Guacamole.Client = function(tunnel) { */ this.onmsg = null; + /** + * Fired when the client is disconnected to close all secondary monitor + * windows. + */ + this.ondisconnect = null; + + /** + * Fired when guacd send instructions to transfer them on additional + * monitors windows. + * + * @event + * @param {!string} opcode + * The current operation code. + * + * @param {*} parameters + * Operation parameters. + */ + this.ondisplayupdate = null; + /** * Fired when a user joins a shared connection. * @@ -826,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. @@ -1028,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)); + } }; @@ -1068,13 +1126,16 @@ Guacamole.Client = function(tunnel) { "arc": function(parameters) { - var layer = getLayer(parseInt(parameters[0])); - var x = parseInt(parameters[1]); - var y = parseInt(parameters[2]); - var radius = parseInt(parameters[3]); - var startAngle = parseFloat(parameters[4]); - var endAngle = parseFloat(parameters[5]); - var negative = parseInt(parameters[6]); + 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]) - offsetY; + const radius = parseInt(parameters[3]); + const startAngle = parseFloat(parameters[4]); + const endAngle = parseFloat(parameters[5]); + const negative = parseInt(parameters[6]); display.arc(layer, x, y, radius, startAngle, endAngle, negative != 0); @@ -1212,15 +1273,20 @@ Guacamole.Client = function(tunnel) { "copy": function(parameters) { - var srcL = getLayer(parseInt(parameters[0])); - var srcX = parseInt(parameters[1]); - var srcY = parseInt(parameters[2]); - var srcWidth = parseInt(parameters[3]); - var srcHeight = parseInt(parameters[4]); - var channelMask = parseInt(parameters[5]); - var dstL = getLayer(parseInt(parameters[6])); - var dstX = parseInt(parameters[7]); - var dstY = parseInt(parameters[8]); + 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]) - 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]) - dstOffsetY; display.setChannelMask(dstL, channelMask); display.copy(srcL, srcX, srcY, srcWidth, srcHeight, @@ -1247,13 +1313,16 @@ Guacamole.Client = function(tunnel) { "cursor": function(parameters) { - var cursorHotspotX = parseInt(parameters[0]); - var cursorHotspotY = parseInt(parameters[1]); - var srcL = getLayer(parseInt(parameters[2])); - var srcX = parseInt(parameters[3]); - var srcY = parseInt(parameters[4]); - var srcWidth = parseInt(parameters[5]); - var srcHeight = parseInt(parameters[6]); + 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]) - offsetY; + const srcWidth = parseInt(parameters[5]); + const srcHeight = parseInt(parameters[6]); display.setCursor(cursorHotspotX, cursorHotspotY, srcL, srcX, srcY, srcWidth, srcHeight); @@ -1262,13 +1331,16 @@ Guacamole.Client = function(tunnel) { "curve": function(parameters) { - var layer = getLayer(parseInt(parameters[0])); - var cp1x = parseInt(parameters[1]); - var cp1y = parseInt(parameters[2]); - var cp2x = parseInt(parameters[3]); - var cp2y = parseInt(parameters[4]); - var x = parseInt(parameters[5]); - var y = parseInt(parameters[6]); + 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]); + const cp1y = parseInt(parameters[2]); + const cp2x = parseInt(parameters[3]); + const cp2y = parseInt(parameters[4]); + const x = parseInt(parameters[5]) - offsetX; + const y = parseInt(parameters[6]) - offsetY; display.curveTo(layer, cp1x, cp1y, cp2x, cp2y, x, y); @@ -1398,15 +1470,18 @@ Guacamole.Client = function(tunnel) { "img": function(parameters) { - var stream_index = parseInt(parameters[0]); - var channelMask = parseInt(parameters[1]); - var layer = getLayer(parseInt(parameters[2])); - var mimetype = parameters[3]; - var x = parseInt(parameters[4]); - var y = parseInt(parameters[5]); + 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]) - offsetY; // Create stream - var stream = streams[stream_index] = new Guacamole.InputStream(guac_client, stream_index); + const stream = streams[stream_index] = new Guacamole.InputStream(guac_client, stream_index); // Draw received contents once decoded display.setChannelMask(layer, channelMask); @@ -1416,11 +1491,14 @@ Guacamole.Client = function(tunnel) { "jpeg": function(parameters) { - var channelMask = parseInt(parameters[0]); - var layer = getLayer(parseInt(parameters[1])); - var x = parseInt(parameters[2]); - var y = parseInt(parameters[3]); - var data = parameters[4]; + 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]) - offsetY; + const data = parameters[4]; display.setChannelMask(layer, channelMask); display.draw(layer, x, y, "data:image/jpeg;base64," + data); @@ -1440,9 +1518,12 @@ Guacamole.Client = function(tunnel) { "line": function(parameters) { - var layer = getLayer(parseInt(parameters[0])); - var x = parseInt(parameters[1]); - var y = parseInt(parameters[2]); + 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]) - offsetY; display.lineTo(layer, x, y); @@ -1461,8 +1542,8 @@ Guacamole.Client = function(tunnel) { "mouse" : function handleMouse(parameters) { - var x = parseInt(parameters[0]); - var y = parseInt(parameters[1]); + const x = parseInt(parameters[0]) - guac_client.offsetX; + const y = parseInt(parameters[1]) - guac_client.offsetY; // Display and move software cursor to received coordinates display.showCursor(true); @@ -1554,11 +1635,14 @@ Guacamole.Client = function(tunnel) { "png": function(parameters) { - var channelMask = parseInt(parameters[0]); - var layer = getLayer(parseInt(parameters[1])); - var x = parseInt(parameters[2]); - var y = parseInt(parameters[3]); - var data = parameters[4]; + 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]) - offsetY; + const data = parameters[4]; display.setChannelMask(layer, channelMask); display.draw(layer, x, y, "data:image/png;base64," + data); @@ -1583,11 +1667,14 @@ Guacamole.Client = function(tunnel) { "rect": function(parameters) { - var layer = getLayer(parseInt(parameters[0])); - var x = parseInt(parameters[1]); - var y = parseInt(parameters[2]); - var w = parseInt(parameters[3]); - var h = parseInt(parameters[4]); + 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]) - offsetY; + const w = parseInt(parameters[3]); + const h = parseInt(parameters[4]); display.rect(layer, x, y, w, h); @@ -1633,10 +1720,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); @@ -1644,9 +1731,12 @@ Guacamole.Client = function(tunnel) { "start": function(parameters) { - var layer = getLayer(parseInt(parameters[0])); - var x = parseInt(parameters[1]); - var y = parseInt(parameters[2]); + 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]) - offsetY; display.moveTo(layer, x, y); @@ -1687,15 +1777,20 @@ Guacamole.Client = function(tunnel) { "transfer": function(parameters) { - var srcL = getLayer(parseInt(parameters[0])); - var srcX = parseInt(parameters[1]); - var srcY = parseInt(parameters[2]); - var srcWidth = parseInt(parameters[3]); - var srcHeight = parseInt(parameters[4]); - var function_index = parseInt(parameters[5]); - var dstL = getLayer(parseInt(parameters[6])); - var dstX = parseInt(parameters[7]); - var dstY = parseInt(parameters[8]); + 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]) - 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]) - dstOffsetY; /* SRC */ if (function_index === 0x3) @@ -1817,9 +1912,11 @@ Guacamole.Client = function(tunnel) { tunnel.oninstruction = function(opcode, parameters) { - var handler = instructionHandlers[opcode]; - if (handler) - handler(parameters); + // Send instruction to other monitors windows + if (guac_client.ondisplayupdate) guac_client.ondisplayupdate(opcode, parameters); + + // Run requested handler + guac_client.runHandler(opcode, parameters); // Leverage network activity to ensure the next keep-alive ping is // sent, even if the browser is currently throttling timers @@ -1827,6 +1924,24 @@ Guacamole.Client = function(tunnel) { }; + /** + * Run operations requested by guacd. + * + * @param {!string} opcode + * The current operation code. + * + * @param {*} parameters + * Operation parameters. + */ + this.runHandler = function runHandler(opcode, parameters) { + + const handler = instructionHandlers[opcode]; + + if (handler) + handler(parameters); + + }; + /** * Sends a disconnect instruction to the server and closes the tunnel. */ @@ -1846,6 +1961,8 @@ Guacamole.Client = function(tunnel) { tunnel.disconnect(); setState(Guacamole.Client.State.DISCONNECTED); + if (guac_client.ondisconnect) guac_client.ondisconnect(); + } }; diff --git a/guacamole-common-js/src/main/webapp/modules/Display.js b/guacamole-common-js/src/main/webapp/modules/Display.js index dcb1e320c1..a70fd621fc 100644 --- a/guacamole-common-js/src/main/webapp/modules/Display.js +++ b/guacamole-common-js/src/main/webapp/modules/Display.js @@ -34,14 +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 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"; @@ -740,7 +742,7 @@ Guacamole.Display = function() { * @param {!number} y * The Y coordinate to move the cursor to. */ - this.moveCursor = function(x, y) { + this.moveCursor = function moveCursor(x, y) { // Move cursor layer cursor.translate(x - guac_display.cursorHotspotX, @@ -752,6 +754,19 @@ Guacamole.Display = function() { }; + /** + * Set the current monitor size. + * + * @param {!number} width + * The width of the monitor, in pixels. + * @param {!number} height + * The height of the monitor, in pixels. + */ + this.setMonitorSize = function setMonitorSize(width, height) { + monitorWidth = width; + monitorHeight = height; + } + /** * Changes the size of the given Layer to the given width and height. * Resizing is only attempted if the new size provided is actually different @@ -769,6 +784,14 @@ Guacamole.Display = function() { this.resize = function(layer, width, height) { scheduleTask(function __display_resize() { + // Adjust width when using multiple monitors + if (monitorWidth) + width = monitorWidth; + + // Adjust height when using multiple monitors + if (monitorHeight) + height = monitorHeight; + layer.resize(width, height); // Resize display if default layer is resized 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 diff --git a/guacamole-common-js/src/main/webapp/modules/Tunnel.js b/guacamole-common-js/src/main/webapp/modules/Tunnel.js index 7f2ee0fa5a..3021626d96 100644 --- a/guacamole-common-js/src/main/webapp/modules/Tunnel.js +++ b/guacamole-common-js/src/main/webapp/modules/Tunnel.js @@ -429,7 +429,7 @@ Guacamole.HTTPTunnel = function(tunnelURL, crossDomain, extraTunnelHeaders) { } - this.sendMessage = function() { + this.sendMessage = function sendMessage() { // Do not attempt to send messages if not connected if (!tunnel.isConnected()) 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/app/client/controllers/clientController.js b/guacamole/src/main/frontend/src/app/client/controllers/clientController.js index 6a486fe9a2..c0e4f24ba4 100644 --- a/guacamole/src/main/frontend/src/app/client/controllers/clientController.js +++ b/guacamole/src/main/frontend/src/app/client/controllers/clientController.js @@ -40,6 +40,7 @@ angular.module('client').controller('clientController', ['$scope', '$routeParams const dataSourceService = $injector.get('dataSourceService'); const guacClientManager = $injector.get('guacClientManager'); const guacFullscreen = $injector.get('guacFullscreen'); + const guacManageMonitor = $injector.get('guacManageMonitor'); const iconService = $injector.get('iconService'); const preferenceService = $injector.get('preferenceService'); const requestService = $injector.get('requestService'); @@ -724,6 +725,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 +936,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/controllers/secondaryMonitorController.js b/guacamole/src/main/frontend/src/app/client/controllers/secondaryMonitorController.js new file mode 100644 index 0000000000..a955f1c0eb --- /dev/null +++ b/guacamole/src/main/frontend/src/app/client/controllers/secondaryMonitorController.js @@ -0,0 +1,127 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * The controller for the page used to display secondary monitors. + */ +angular.module('client').controller('secondaryMonitorController', ['$scope', '$injector', '$routeParams', + function clientController($scope, $injector, $routeParams) { + + // Required services + const $window = $injector.get('$window'); + const guacFullscreen = $injector.get('guacFullscreen'); + const guacManageMonitor = $injector.get('guacManageMonitor'); + + /** + * ID of this monitor. + * + * @type {!String} + */ + const monitorId = $routeParams.id; + + /** + * In order to open the guacamole menu, we need to hit ctrl-alt-shift. There are + * several possible keysysms for each key. + */ + const SHIFT_KEYS = {0xFFE1 : true, 0xFFE2 : true}, + ALT_KEYS = {0xFFE9 : true, 0xFFEA : true, 0xFE03 : true, + 0xFFE7 : true, 0xFFE8 : true}, + CTRL_KEYS = {0xFFE3 : true, 0xFFE4 : true}, + MENU_KEYS = angular.extend({}, SHIFT_KEYS, ALT_KEYS, CTRL_KEYS); + + guacManageMonitor.init("secondary"); + guacManageMonitor.monitorId = monitorId; + + guacManageMonitor.openConsentButton = function openConsentButton() { + + // Show button + $scope.showFullscreenConsent = true; + $scope.$apply(); + + // Auto hide button after delay + setTimeout(function() { + $scope.showFullscreenConsent = false; + $scope.$apply(); + }, 10000); + + }; + + /** + * User clicked on the consent button : switch to fullscreen mode and hide + * the button. + */ + $scope.enableFullscreenMode = function enableFullscreenMode() { + guacFullscreen.setFullscreenMode(true); + $scope.showFullscreenConsent = false; + }; + + /** + * Returns whether the shortcut for showing/hiding the Guacamole menu + * (Ctrl+Alt+Shift) has been pressed. + * + * @param {Guacamole.Keyboard} keyboard + * The Guacamole.Keyboard object tracking the local keyboard state. + * + * @returns {boolean} + * true if Ctrl+Alt+Shift has been pressed, false otherwise. + */ + const isMenuShortcutPressed = function isMenuShortcutPressed(keyboard) { + + // 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, (_, 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, (_, keysym) => keyboard.pressed[keysym]) + && _.findKey(ALT_KEYS, (_, keysym) => keyboard.pressed[keysym]) + && _.findKey(CTRL_KEYS, (_, keysym) => keyboard.pressed[keysym]) + ); + + }; + + // Opening the Guacamole menu after Ctrl+Alt+Shift, preventing those + // keypresses from reaching any Guacamole client + $scope.$on('guacBeforeKeydown', function incomingKeydown(event, keysym, keyboard) { + + // Toggle menu if menu shortcut (Ctrl+Alt+Shift) is pressed + if (isMenuShortcutPressed(keyboard)) { + + // Don't send this key event through to the client, and release + // all other keys involved in performing this shortcut + event.preventDefault(); + keyboard.reset(); + + // Toggle the menu + $scope.$apply(function() { + 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/guacClient.js b/guacamole/src/main/frontend/src/app/client/directives/guacClient.js index 18a1b080c5..9d3971e8c4 100644 --- a/guacamole/src/main/frontend/src/app/client/directives/guacClient.js +++ b/guacamole/src/main/frontend/src/app/client/directives/guacClient.js @@ -51,11 +51,12 @@ angular.module('client').directive('guacClient', [function guacClient() { function guacClientController($scope, $injector, $element) { // Required types - const ManagedClient = $injector.get('ManagedClient'); + const ManagedClient = $injector.get('ManagedClient'); // Required services - const $rootScope = $injector.get('$rootScope'); - const $window = $injector.get('$window'); + const $rootScope = $injector.get('$rootScope'); + const $window = $injector.get('$window'); + const guacManageMonitor = $injector.get('guacManageMonitor'); /** * Whether the local, hardware mouse cursor is in use. @@ -459,11 +460,20 @@ angular.module('client').directive('guacClient', [function guacClient() { ManagedClient.connect($scope.client, main.offsetWidth, main.offsetHeight); const pixelDensity = $window.devicePixelRatio || 1; - const width = main.offsetWidth * pixelDensity; - const height = main.offsetHeight * pixelDensity; + 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) - client.sendSize(width, height); + guacManageMonitor.sendSize({ + width: width, + height: height, + monitorId: 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 new file mode 100644 index 0000000000..d228d3ced0 --- /dev/null +++ b/guacamole/src/main/frontend/src/app/client/directives/guacClientSecondary.js @@ -0,0 +1,239 @@ +/* + * 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 $window = $injector.get('$window'); + 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 main containing element for the entire directive. + * + * @type Element + */ + const main = $element[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]); + + // 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 = 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; + + const size = { + width: width, + height: height, + 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'; + + } + + // 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(e.state.x, e.state.y); + + // Click on actual display instead of the first + const displayOffsetX = guacManageMonitor.getOffsetX(); + const displayOffsetY = guacManageMonitor.getOffsetY(); + + // 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 + displayOffsetX; + mouseState.y = e.state.y + displayOffsetY; + mouseState.offsedProcessed = true; + + // 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/guacFullscreen.js b/guacamole/src/main/frontend/src/app/client/services/guacFullscreen.js index cf04b397a7..d5faf4af10 100644 --- a/guacamole/src/main/frontend/src/app/client/services/guacFullscreen.js +++ b/guacamole/src/main/frontend/src/app/client/services/guacFullscreen.js @@ -39,6 +39,9 @@ angular.module('client').factory('guacFullscreen', ['$injector', document.documentElement.requestFullscreen(); else if (!state && service.isInFullscreenMode()) document.exitFullscreen(); + + // Send instruction to other monitors + if (service.onfullscreen) service.onfullscreen(state); } } @@ -50,6 +53,13 @@ angular.module('client').factory('guacFullscreen', ['$injector', service.setFullscreenMode(false); } + /** + * Handle the full screen mode change event. + * + * @param {!boolean} state + */ + service.onfullscreen = null; + // If the browser supports keyboard lock, lock the keyboard when entering // fullscreen mode and unlock it when exiting fullscreen mode. if (navigator.keyboard?.lock) { diff --git a/guacamole/src/main/frontend/src/app/client/services/guacManageMonitor.js b/guacamole/src/main/frontend/src/app/client/services/guacManageMonitor.js new file mode 100644 index 0000000000..3668d3300d --- /dev/null +++ b/guacamole/src/main/frontend/src/app/client/services/guacManageMonitor.js @@ -0,0 +1,663 @@ +/* + * 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 service for adding additional monitors and handle instructions transfer. + */ +angular.module('client').factory('guacManageMonitor', ['$injector', + function guacManageMonitor($injector) { + + // Required services + const $window = $injector.get('$window'); + const guacFullscreen = $injector.get('guacFullscreen'); + + /** + * Additionals monitors windows opened. + * + * @type Object. + */ + const monitors = {}; + + /** + * The type of this monitor (default = primary). + * + * @type String + */ + let monitorType = "primary"; + + /** + * The current Guacamole client instance. + * + * @type Guacamole.Client + */ + let client = null; + + /** + * The display of the current Guacamole client instance. + * + * @type Guacamole.Display + */ + let display = null; + + /** + * The broadcast channel used for communications between all windows. + * + * @type BroadcastChannel + */ + let broadcast = null; + + /** + * The maximum number of secondary monitors allowed. + * + * @type Number + */ + let maxSecondaryMonitors = 0; + + /** + * Store the last additional monitor id. + * + * @type Number + */ + 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 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 = {}; + + /** + * Attributes of the monitor + * + * @type Object. + */ + service.monitorId = 0; + + /** + * 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; + + if (monitorType == "primary") { + guacFullscreen.onfullscreen = function onfullscreen(state) { + service.pushBroadcastMessage('fullscreen', state); + } + } + + // Create broadcast if supported + if (!service.supported()) + return; + + // TODO: ensure that there is no mixing of data between multiple connections + broadcast = new BroadcastChannel('guac_monitors'); + + /** + * Handle messages sent by secondary monitors windows in + * guac_monitors channel. + * + * @param {Event} e + * Received message event from guac_monitors channel. + */ + broadcast.onmessage = messageHandlers[monitorType]; + + // Check the window position every second and send a resize event if it + // has changed + setInterval(() => updatePosition(), 1000); + + }; + + /** + * Set the maximum number of secondary monitors allowed. + * + * @param {Number} secondaryMonitorsAllowed + * The maximum number of secondary monitors allowed. + */ + service.setMaxSecondaryMonitors = function setMaxSecondaryMonitors(amount) { + maxSecondaryMonitors = amount; + } + + /** + * Ensure that the limit of open monitors is not reached. + * + * @returns {boolean} + * true when the limit of opened monitors is reached, false otherwise. + */ + service.monitorLimitReached = function monitorLimitReached() { + + // Max open monitors allowed (add 1 for the primary monitor) + const maxMonitors = maxSecondaryMonitors + 1; + + // Prevent opening of too many monitors + if (service.getMonitorCount() >= maxMonitors) + return true; + + return false; + + }; + + /** + * Check if the browser supports the BroadcastChannel API. + * + * @returns {boolean} + * true if the BroadcastChannel API is supported, false otherwise. + */ + service.supported = function supported() { + + if (!window.BroadcastChannel) { + console.warn("BroadcastChannel is not supported by this browser."); + return false; + } + + return true; + } + + /** + * Handlers for instructions received on broadcast channel. + */ + const messageHandlers = { + + "primary": function primary(message) { + + // Send size event to guacd + if (message.data.size) + service.sendSize(message.data.size); + + // Mouse state changed on secondary screen + if (message.data.mouseState) + client.sendMouseState(message.data.mouseState); + + // Key down on secondary screen + if (message.data.keydown) + client.sendKeyEvent(1, message.data.keydown); + + // Key up on secondary screen + if (message.data.keyup) + client.sendKeyEvent(0, message.data.keyup); + + // Additional window unloaded + if (message.data.monitorClose) + service.closeMonitor(message.data.monitorClose); + + // CTRL+ALT+SHIFT pressed on secondary window + 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) + monitorsInfos = message.data.monitorsInfos; + + // Full screen mode instructions + if (message.data.fullscreen !== undefined) { + + // setFullscreenMode require explicit user action + if (message.data.fullscreen) { + if (service.openConsentButton) service.openConsentButton(); + } + + // Close fullscreen mode instantly + else + guacFullscreen.setFullscreenMode(false); + + } + } + + } + + /** + * 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; + + /** + * Open or close Guacamole menu (ctrl+alt+shift). + */ + service.menuShown = null; + + /** + * Set the current Guacamole Client + * + * @param {Guacamole.Client} guac_client + * The guacamole client where to send instructions. + */ + service.setClient = function setClient(guac_client) { + + client = guac_client; + display = client.getDisplay(); + + client.onmultimonlayout = onmultimonlayout; + + // Close all secondary monitors on client disconnect + if (monitorType === "primary") + client.ondisconnect = service.closeAllMonitors; + + } + + /** + * 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. + */ + service.pushBroadcastMessage = function pushBroadcastMessage(type, content) { + + // 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 + }; + + // Send message on the broadcast channel + broadcast.postMessage(message); + + }; + + /** + * Open an additional monitor window. + */ + service.addMonitor = function addMonitor() { + + // New monitor id + lastMonitorId++; + + // New window parameters + const windowUrl = './#/secondaryMonitor/' + lastMonitorId; + const windowId = 'monitor' + lastMonitorId; + const windowSize = 'width=800,height=600'; + + // Open new window + monitors[lastMonitorId] = $window.open(windowUrl, windowId, windowSize); + + }; + + /** + * Close an additional monitor based on its id. + * + * @param {!number} monitorId + * The monitor ID to close. + */ + service.closeMonitor = function closeMonitor(monitorId) { + + // Monitor not found or already closed + if (!monitors[monitorId]) + return; + + // Close monitor + if (!monitors[monitorId].closed) + monitors[monitorId].close(); + + // Delete monitor + delete monitors[monitorId]; + + // Notify guacd that a monitor has been closed + service.sendSize({ + width: 0, + height: 0, + top: 0, + monitorId: monitorId, + }); + + } + + /** + * Close all additional monitors. + */ + service.closeAllMonitors = function closeAllMonitors() { + + // Loop on all existing monitors + for (const key in monitors) + service.closeMonitor(key); + + }; + + /** + * 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; + }; + + /** + * 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) { + + const monitorPosition = monitorsInfos.map[size.monitorId]; + + updateMonitorsInfos({ + id: size.monitorId, + width: size.width, + height: size.height, + left: size.left, + top: size.top, + }); + + // 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. + */ + 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; + } + + /** + * 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() { + const currentOffset = monitorsInfos.rendered[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.rendered[0]?.top ?? 0; + + // Loop through all monitors to find the highest monitor + for (const [_, rendered] of Object.entries(monitorsInfos.rendered)) { + if (rendered?.top < lowestTopValue) { + lowestTopValue = rendered.top; + } + } + + return lowestTopValue; + } + + /** + * Update monitorsInfos object with current monitors count and map. + * + * @param {Object} monitorDetails + * Optional monitor details to update the monitorsInfos object. + */ + function updateMonitorsInfos(monitorDetails) { + + monitorsInfos.count = service.getMonitorCount(); + + // The main window would represent 0 + let monitorPosition = 1; + + // 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++; + } + + // Set monitor details if provided + if (!monitorDetails) + return; + + // 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 + else { + const monitorId = monitorDetails.id; + monitorsInfos.details[monitorId] = { + 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); + } + + /** + * 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); + + return service; + +}]); 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}}

+ + +