diff --git a/guacamole-common-js/src/main/webapp/modules/Client.js b/guacamole-common-js/src/main/webapp/modules/Client.js index 03dee3e1b9..1b8a4c5eab 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,28 @@ 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) + 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; + } + } }; @@ -1068,13 +1141,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 +1288,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 +1328,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 +1346,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); @@ -1332,6 +1419,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(); }, @@ -1398,15 +1487,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 +1508,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 +1535,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 +1559,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 +1652,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 +1684,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 +1737,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 +1748,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 +1794,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 +1929,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 +1941,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 +1978,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..d0a263471f 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,27 @@ 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; + + // 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. * Resizing is only attempted if the new size provided is actually different @@ -769,6 +792,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-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-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); + + }); + +}); 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/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/controllers/clientController.js b/guacamole/src/main/frontend/src/app/client/controllers/clientController.js index 6a486fe9a2..d6078f1184 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'); @@ -315,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 ... @@ -498,6 +501,9 @@ angular.module('client').controller('clientController', ['$scope', '$routeParams $scope.menu.connectionParameters = newFocusedClient ? ManagedClient.getArgumentModel(newFocusedClient) : {}; + // Set new focused client on guacManageMonitor + guacManageMonitor.setClient(newFocusedClient.client); + }); // Automatically update connection parameters that have been modified @@ -724,6 +730,71 @@ 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. + 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); + + // 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 +945,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..a347960c37 --- /dev/null +++ b/guacamole/src/main/frontend/src/app/client/controllers/secondaryMonitorController.js @@ -0,0 +1,130 @@ +/* + * 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); + + // 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() { + + // 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('beforeunload', 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..45ec8b79a3 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,13 @@ 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'); + const $timeout = $injector.get('$timeout'); /** * Whether the local, hardware mouse cursor is in use. @@ -374,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); }); @@ -441,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 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. + */ + /** + * 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 @@ -458,12 +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; - - if (display.getWidth() !== width || display.getHeight() !== height) - client.sendSize(width, height); + 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 new file mode 100644 index 0000000000..e4e10dc711 --- /dev/null +++ b/guacamole/src/main/frontend/src/app/client/directives/guacClientSecondary.js @@ -0,0 +1,286 @@ +/* + * 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'); + const $timeout = $injector.get('$timeout'); + + /** + * 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; + }; + + /** + * 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() { + + if (resizePromise) { + $timeout.cancel(resizePromise); + } + + 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); + }, 250); + + // Remove scrollbars + const clientMain = document.querySelector('.client-main'); + if (clientMain) { + clientMain.style.overflow = 'hidden'; + } + + }; + + // Ready for resize + $scope.mainElementResized(); + + // 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..f671f2e4de --- /dev/null +++ b/guacamole/src/main/frontend/src/app/client/services/guacManageMonitor.js @@ -0,0 +1,778 @@ +/* + * 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; + + /** + * 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. + * + * @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: {}, + }; + + /** + * Map tracking consecutive missing counts per monitor ID. + * + * @type Object. + */ + const missingCounts = {}; + + 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. + * + * @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, 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); + } + } + + // Create broadcast if supported + if (!service.supported()) + return; + + // 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 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 = 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 + 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 + return service.getMonitorCount() >= maxMonitors; + + }; + + /** + * 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(client, 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, tagging it with this connection's channel id + // so receivers can reject any foreign traffic. + const message = { + [type]: content, + _cid: channelId + }; + + // 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. 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'; + + // 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; + + // Clear missing count tracking + delete missingCounts[monitorId]; + + // Close monitor + if (!monitors[monitorId].closed) + monitors[monitorId].close(); + + // Delete monitor + delete monitors[monitorId]; + + // Notify guacd that a monitor has been closed + service.sendSize(client, { + 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 {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(requestedClient, 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) + requestedClient.sendSize(0, 0, monitorPosition, 0); + + // Send new size to guacd + else + sendAllSizes(requestedClient); + + // Push informations to all monitors + service.pushBroadcastMessage('monitorsInfos', monitorsInfos); + + } + + /** + * 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 currentOffset = monitorsInfos.rendered[service.monitorId]?.left ?? 0; + return currentOffset - getLowestLeftOffset(); + } + + /** + * 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. + * + * @param {Guacamole.Client} requestedClient + * The Guacamole client to send the sizes to. + */ + function sendAllSizes(requestedClient) { + // Loop through all monitors and send their sizes to guacd + for (const [id, details] of Object.entries(monitorsInfos.details)) { + requestedClient.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; + } + + /** + * 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. + * + * @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; + + // 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 of positioned.concat(pending)) { + 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, + 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(client, 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; + + // 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. 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); + 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] = {}; + + // 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}}

+ + +