diff --git a/guacamole-common-js/src/main/webapp/modules/Client.js b/guacamole-common-js/src/main/webapp/modules/Client.js index 14dab6fd32..eae550e366 100644 --- a/guacamole-common-js/src/main/webapp/modules/Client.js +++ b/guacamole-common-js/src/main/webapp/modules/Client.js @@ -175,6 +175,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 @@ -342,14 +352,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); }; @@ -392,6 +408,13 @@ Guacamole.Client = function(tunnel) { var x = mouseState.x; var y = mouseState.y; + // The offset is already applied when the state comes from a + // secondary monitor + if (!mouseState.offsedProcessed) { + x += guac_client.offsetX; + y += guac_client.offsetY; + } + // Translate for display units if requested if (applyDisplayScale) { x /= display.getScale(); @@ -752,6 +775,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. * @@ -843,6 +885,15 @@ Guacamole.Client = function(tunnel) { */ this.onmultitouch = null; + /** + * Fired when the remote client is explicitly declaring the layout of + * monitors, if any. + * + * @param {Object} layout + * An object describing the layout of monitors. + */ + this.onmultimonlayout = null; + /** * Fired when the current value of a connection parameter is being exposed * by the server. @@ -1045,6 +1096,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; + } + } }; @@ -1085,13 +1158,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); @@ -1229,15 +1305,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, @@ -1264,13 +1345,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); @@ -1279,13 +1363,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); @@ -1349,6 +1436,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(); }, @@ -1415,15 +1504,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); @@ -1433,11 +1525,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); @@ -1457,9 +1552,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); @@ -1478,8 +1576,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); @@ -1571,11 +1669,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); @@ -1600,11 +1701,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); @@ -1650,10 +1754,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); @@ -1661,9 +1765,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); @@ -1704,15 +1811,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) @@ -1834,9 +1946,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 @@ -1844,6 +1958,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. */ @@ -1863,6 +1995,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/ClipboardEventInterpreter.js b/guacamole-common-js/src/main/webapp/modules/ClipboardEventInterpreter.js index 2376413593..8d4b2a2302 100644 --- a/guacamole-common-js/src/main/webapp/modules/ClipboardEventInterpreter.js +++ b/guacamole-common-js/src/main/webapp/modules/ClipboardEventInterpreter.js @@ -51,6 +51,18 @@ Guacamole.ClipboardEventInterpreter = function ClipboardEventInterpreter(startTi */ var activeStreams = {}; + /** + * Clipboard transfer directions parsed from server-emitted "log" + * instructions, keyed by stream index, awaiting the clipboard stream they + * annotate. The server writes the direction log immediately before the + * corresponding clipboard stream, so it is buffered here until the + * matching clipboard instruction arrives. + * + * @private + * @type {Object.} + */ + var pendingDirections = {}; + /** * The timestamp of the most recent instruction, used for events * that don't have their own timestamp. @@ -83,8 +95,32 @@ Guacamole.ClipboardEventInterpreter = function ClipboardEventInterpreter(startTi activeStreams[streamIndex] = { mimetype: mimetype, data: '', + direction: pendingDirections[streamIndex] || null, timestamp: lastTimestamp }; + + // The buffered direction, if any, has now been consumed + delete pendingDirections[streamIndex]; + }; + + /** + * Handles a log instruction. When the log carries a clipboard direction + * annotation ("clipboard stream=N direction=... mimetype=... [bytes=...]"), + * the direction is buffered against its stream index so it can be attached + * to the clipboard event once that stream arrives. All other log + * instructions are ignored. + * + * @param {!string[]} args + * The arguments: [message] + */ + this.handleLog = function handleLog(args) { + var message = args[0]; + if (!message) + return; + + var match = /^clipboard stream=(\d+) direction=(\S+)/.exec(message); + if (match) + pendingDirections[match[1]] = match[2]; }; /** @@ -103,6 +139,28 @@ Guacamole.ClipboardEventInterpreter = function ClipboardEventInterpreter(startTi stream.data += base64Data; }; + /** + * Returns the number of decoded bytes represented by the given base64 + * string, without actually decoding it. Handles both padded and unpadded + * base64; Guacamole blob payloads are newline-free, so length arithmetic + * is exact. + * + * @private + * @param {string} base64 + * The base64-encoded string to measure. + * + * @returns {number} + * The size, in bytes, of the decoded data. + */ + var base64ByteLength = function base64ByteLength(base64) { + if (!base64) + return 0; + var padding = 0; + if (base64.charAt(base64.length - 1) === '=') padding++; + if (base64.charAt(base64.length - 2) === '=') padding++; + return Math.max(0, Math.floor(base64.length * 3 / 4) - padding); + }; + /** * Handles an end instruction, which completes a clipboard stream * and creates the final clipboard event. @@ -115,18 +173,39 @@ Guacamole.ClipboardEventInterpreter = function ClipboardEventInterpreter(startTi var stream = activeStreams[streamIndex]; if (stream) { - // Decode the base64 data + + var isImage = /^image\//i.test(stream.mimetype || ''); + var decodedData = ''; - try { - decodedData = atob(stream.data); - // Handle UTF-8 decoding - decodedData = decodeURIComponent(escape(decodedData)); - } catch (e) { - // If decoding fails, use raw decoded data or mark as binary + var dataURL = null; + var size = 0; + + // Image clipboard: keep the base64 payload intact and expose it + // as a data: URL for inline preview. Do NOT run the text/UTF-8 + // decode used below - binary image bytes are not valid UTF-8 and + // would otherwise collapse to the literal string '[Binary data]'. + if (isImage) { + // Lowercase the mimetype in the data: scheme - AngularJS's + // img-src sanitizer whitelists "data:image/" case-sensitively, + // so an uppercase mimetype would be rewritten to "unsafe:" and + // silently fail to render. + dataURL = 'data:' + (stream.mimetype || '').toLowerCase() + + ';base64,' + stream.data; + size = base64ByteLength(stream.data); + } + + // Text clipboard: decode the base64 data, then interpret as UTF-8 + else { try { decodedData = atob(stream.data); - } catch (e2) { - decodedData = '[Binary data]'; + decodedData = decodeURIComponent(escape(decodedData)); + } catch (e) { + // If decoding fails, use raw decoded data or mark as binary + try { + decodedData = atob(stream.data); + } catch (e2) { + decodedData = '[Binary data]'; + } } } @@ -139,6 +218,10 @@ Guacamole.ClipboardEventInterpreter = function ClipboardEventInterpreter(startTi parsedEvents.push(new Guacamole.ClipboardEventInterpreter.ClipboardEvent({ mimetype: stream.mimetype, data: decodedData, + isImage: isImage, + dataURL: dataURL, + size: size, + direction: stream.direction || null, timestamp: Math.max(0, stream.timestamp - startTimestamp) })); @@ -157,6 +240,20 @@ Guacamole.ClipboardEventInterpreter = function ClipboardEventInterpreter(startTi return parsedEvents; }; + /** + * Returns the number of clipboard streams that were opened but never + * terminated by an "end" instruction. Such streams indicate clipboard + * transfers that were only partially recorded (e.g. a truncated + * recording), and whose data is therefore missing. + * + * @returns {!number} + * The number of incomplete clipboard streams still open at the end of + * parsing. + */ + this.getIncompleteCount = function getIncompleteCount() { + return Object.keys(activeStreams).length; + }; + }; /** @@ -178,12 +275,47 @@ Guacamole.ClipboardEventInterpreter.ClipboardEvent = function ClipboardEvent(tem this.mimetype = template.mimetype || 'text/plain'; /** - * The clipboard content (decoded from base64). + * The clipboard content (decoded from base64). Empty for image clipboard + * events, whose payload is exposed via dataURL instead. * * @type {!string} */ this.data = template.data || ''; + /** + * Whether this clipboard event carries image data (mimetype image/*) + * rather than text. + * + * @type {!boolean} + */ + this.isImage = template.isImage || false; + + /** + * For image clipboard events, a data: URL suitable for direct use as an + * source. Null for text clipboard events. + * + * @type {string} + */ + this.dataURL = template.dataURL || null; + + /** + * The size, in bytes, of the clipboard payload. Only meaningful for + * image clipboard events; 0 for text. + * + * @type {!number} + */ + this.size = template.size || 0; + + /** + * The direction of this clipboard transfer as annotated by the server, + * either "guest-to-client" (data copied out of the guest) or + * "client-to-guest" (data pasted into the guest), or null if the + * recording carries no direction annotation. + * + * @type {string} + */ + this.direction = template.direction || null; + /** * The timestamp when this clipboard event occurred, relative to * the start of the recording. diff --git a/guacamole-common-js/src/main/webapp/modules/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 4b8d369c9a..a59ab408dd 100644 --- a/guacamole-common-js/src/main/webapp/modules/Mouse.js +++ b/guacamole-common-js/src/main/webapp/modules/Mouse.js @@ -123,6 +123,17 @@ Guacamole.Mouse = function Mouse(element) { Guacamole.Event.DOMEvent.cancelEvent(e); }, false); + // Capture mouse events outside the display element when a button is + // pressed to allow drag and drop between multiple windows. + element.addEventListener("pointerdown", function(e) { + element.setPointerCapture(e.pointerId); + }, false); + + // Stop capture when mouse button is released + element.addEventListener("pointerup", function(e) { + element.releasePointerCapture(e.pointerId); + }, false); + element.addEventListener("mousemove", function(e) { // If ignoring events, decrement counter diff --git a/guacamole-common-js/src/main/webapp/modules/SessionRecording.js b/guacamole-common-js/src/main/webapp/modules/SessionRecording.js index 1dc2e9acd2..6bfce538c5 100644 --- a/guacamole-common-js/src/main/webapp/modules/SessionRecording.js +++ b/guacamole-common-js/src/main/webapp/modules/SessionRecording.js @@ -527,6 +527,13 @@ Guacamole.SessionRecording = function SessionRecording(source, refreshInterval) // Handle stream end (may complete clipboard stream) else if (opcode === 'end' && clipboardEventInterpreter) clipboardEventInterpreter.handleEnd(args); + + // Handle log instructions, which may carry the clipboard direction + // annotation emitted by the server immediately before each clipboard + // stream (e.g. "clipboard stream=N direction=guest-to-client ..."). + // Non-clipboard logs are ignored by the interpreter. + else if (opcode === 'log' && clipboardEventInterpreter) + clipboardEventInterpreter.handleLog(args); }; /** @@ -541,6 +548,15 @@ Guacamole.SessionRecording = function SessionRecording(source, refreshInterval) }; // Read instructions from provided blob, extracting each frame + // + // NOTE: The onkeyevents and onclipboardevents callbacks are only fired + // from the tunnel branch below (on tunnel CLOSED). When the recording is + // supplied as a Blob, only onload (via notifyLoaded) fires — the extracted + // key-event and clipboard-event logs are NOT delivered. The bundled player + // avoids this by feeding a StaticHTTPTunnel (see settings/.../ + // connectionHistoryPlayerController.js), so both logs work today. If a + // future caller loads a recording from a Blob and expects the key/clipboard + // logs, fire those callbacks from the parseBlob completion handler here too. if (source instanceof Blob) { recordingBlob = source; parseBlob(recordingBlob, loadInstruction, notifyLoaded); @@ -594,14 +610,18 @@ Guacamole.SessionRecording = function SessionRecording(source, refreshInterval) } // Now that the recording is fully processed, and all key events - // have been extracted, call the onkeyevents handler if defined + // have been extracted, call the onkeyevents handler if defined. + // NOTE: This (and the onclipboardevents call below) fire ONLY on + // this tunnel-CLOSED path — a Blob source never reaches here. + // See the Blob branch above before changing how recordings load. if (recording.onkeyevents && keyEventInterpreter) recording.onkeyevents(keyEventInterpreter.getEvents()); // call the onclipboardevents handler if defined with extracted // clipboard events if (recording.onclipboardevents && clipboardEventInterpreter) - recording.onclipboardevents(clipboardEventInterpreter.getEvents()); + recording.onclipboardevents(clipboardEventInterpreter.getEvents(), + { incomplete: clipboardEventInterpreter.getIncompleteCount() }); // Consider recording loaded if tunnel has closed without errors if (!errorEncountered) diff --git a/guacamole-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/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/java/org/apache/guacamole/environment/LocalEnvironment.java b/guacamole-ext/src/main/java/org/apache/guacamole/environment/LocalEnvironment.java index d323e2bfd1..f94c698455 100644 --- a/guacamole-ext/src/main/java/org/apache/guacamole/environment/LocalEnvironment.java +++ b/guacamole-ext/src/main/java/org/apache/guacamole/environment/LocalEnvironment.java @@ -61,6 +61,7 @@ public class LocalEnvironment implements Environment { private static final String[] KNOWN_PROTOCOLS = new String[] { "kubernetes", "rdp", + "spice", "ssh", "telnet", "vnc", diff --git a/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/rdp.json b/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/rdp.json index ad12241e06..840ab6cb89 100644 --- a/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/rdp.json +++ b/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/rdp.json @@ -176,6 +176,10 @@ "type" : "ENUM", "options" : [ "", "display-update", "reconnect" ] }, + { + "name" : "secondary-monitors", + "type" : "NUMERIC" + }, { "name" : "read-only", "type" : "BOOLEAN", diff --git a/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/spice.json b/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/spice.json new file mode 100644 index 0000000000..7e6143456b --- /dev/null +++ b/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/spice.json @@ -0,0 +1,397 @@ +{ + "name" : "spice", + "connectionForms" : [ + + { + "name" : "network", + "fields" : [ + { + "name" : "hostname", + "type" : "TEXT" + }, + { + "name" : "port", + "type" : "NUMERIC" + }, + { + "name" : "tls-port", + "type" : "NUMERIC" + }, + { + "name" : "tls", + "type" : "BOOLEAN", + "options" : [ "true" ] + }, + { + "name" : "proxy", + "type" : "TEXT" + } + ] + }, + + { + "name" : "authentication", + "fields" : [ + { + "name" : "username", + "type" : "USERNAME" + }, + { + "name" : "password", + "type" : "PASSWORD" + } + ] + }, + + { + "name" : "security", + "fields" : [ + { + "name" : "ca-cert", + "type" : "MULTILINE" + }, + { + "name" : "cert-subject", + "type" : "TEXT" + }, + { + "name" : "pubkey", + "type" : "MULTILINE" + }, + { + "name" : "ignore-cert", + "type" : "BOOLEAN", + "options" : [ "true" ] + } + ] + }, + + { + "name" : "display", + "fields" : [ + { + "name" : "color-depth", + "type" : "ENUM", + "options" : [ "", "8", "16", "24", "32" ] + }, + { + "name" : "swap-red-blue", + "type" : "BOOLEAN", + "options" : [ "true" ] + }, + { + "name" : "preferred-compression", + "type" : "ENUM", + "options" : [ "", "off", "auto-glz", "auto-lz", "quic", "glz", "lz", "lz4" ] + }, + { + "name" : "preferred-video-codec", + "type" : "ENUM", + "options" : [ "", "h264", "vp9", "vp8", "mjpeg" ] + }, + { + "name" : "read-only", + "type" : "BOOLEAN", + "options" : [ "true" ] + }, + { + "name" : "disable-display-resize", + "type" : "BOOLEAN", + "options" : [ "true" ] + }, + { + "name" : "secondary-monitors", + "type" : "NUMERIC" + } + ] + }, + + { + "name" : "keyboard", + "fields" : [ + { + "name" : "server-layout", + "type" : "ENUM", + "options" : [ + "", + "da-dk-qwerty", + "de-ch-qwertz", + "de-de-qwertz", + "en-gb-qwerty", + "en-us-qwerty", + "es-es-qwerty", + "es-latam-qwerty", + "failsafe", + "fr-be-azerty", + "fr-ca-qwerty", + "fr-ch-qwertz", + "fr-fr-azerty", + "hu-hu-qwertz", + "it-it-qwerty", + "ja-jp-qwerty", + "no-no-qwerty", + "pl-pl-qwerty", + "pt-br-qwerty", + "sv-se-qwerty", + "tr-tr-qwerty" + ] + } + ] + }, + + { + "name" : "clipboard", + "fields" : [ + { + "name" : "disable-copy", + "type" : "BOOLEAN", + "options" : [ "true" ] + }, + { + "name" : "disable-paste", + "type" : "BOOLEAN", + "options" : [ "true" ] + }, + { + "name" : "disable-clipboard", + "type" : "BOOLEAN", + "options" : [ "true" ] + }, + { + "name" : "clipboard-buffer-size", + "type" : "NUMERIC" + } + ] + }, + + { + "name" : "audio", + "fields" : [ + { + "name" : "enable-audio", + "type" : "BOOLEAN", + "options" : [ "true" ] + }, + { + "name" : "enable-audio-input", + "type" : "BOOLEAN", + "options" : [ "true" ] + }, + { + "name" : "disable-audio-opus", + "type" : "BOOLEAN", + "options" : [ "true" ] + } + ] + }, + + { + "name" : "file-transfer", + "fields" : [ + { + "name" : "file-transfer-mode", + "type" : "ENUM", + "options" : [ "", "none", "agent", "drive", "both" ] + }, + { + "name" : "enable-drive", + "type" : "BOOLEAN", + "options" : [ "true" ] + }, + { + "name" : "drive-path", + "type" : "TEXT" + }, + { + "name" : "drive-read-only", + "type" : "BOOLEAN", + "options" : [ "true" ] + }, + { + "name" : "file-transfer", + "type" : "BOOLEAN", + "options" : [ "true" ] + }, + { + "name" : "file-directory", + "type" : "TEXT" + }, + { + "name" : "file-transfer-create-folder", + "type" : "BOOLEAN", + "options" : [ "true" ] + }, + { + "name" : "file-transfer-ro", + "type" : "BOOLEAN", + "options" : [ "true" ] + }, + { + "name" : "disable-download", + "type" : "BOOLEAN", + "options" : [ "true" ] + }, + { + "name" : "disable-upload", + "type" : "BOOLEAN", + "options" : [ "true" ] + } + ] + }, + + { + "name" : "sftp", + "fields" : [ + { + "name" : "enable-sftp", + "type" : "BOOLEAN", + "options" : [ "true" ] + }, + { + "name" : "sftp-hostname", + "type" : "TEXT" + }, + { + "name" : "sftp-host-key", + "type" : "MULTILINE" + }, + { + "name" : "sftp-port", + "type" : "NUMERIC" + }, + { + "name" : "sftp-timeout", + "type" : "NUMERIC" + }, + { + "name" : "sftp-username", + "type" : "USERNAME" + }, + { + "name" : "sftp-password", + "type" : "PASSWORD" + }, + { + "name" : "sftp-private-key", + "type" : "MULTILINE" + }, + { + "name" : "sftp-passphrase", + "type" : "PASSWORD" + }, + { + "name" : "sftp-public-key", + "type" : "MULTILINE" + }, + { + "name" : "sftp-directory", + "type" : "TEXT" + }, + { + "name" : "sftp-root-directory", + "type" : "TEXT" + }, + { + "name" : "sftp-server-alive-interval", + "type" : "NUMERIC" + }, + { + "name" : "sftp-disable-download", + "type" : "BOOLEAN", + "options" : [ "true" ] + }, + { + "name" : "sftp-disable-upload", + "type" : "BOOLEAN", + "options" : [ "true" ] + } + ] + }, + + { + "name" : "recording", + "fields" : [ + { + "name" : "recording-path", + "type" : "TEXT" + }, + { + "name" : "recording-name", + "type" : "TEXT" + }, + { + "name" : "recording-exclude-output", + "type" : "BOOLEAN", + "options" : [ "true" ] + }, + { + "name" : "recording-exclude-mouse", + "type" : "BOOLEAN", + "options" : [ "true" ] + }, + { + "name" : "recording-include-keys", + "type" : "BOOLEAN", + "options" : [ "true" ] + }, + { + "name" : "recording-include-clipboard", + "type" : "BOOLEAN", + "options" : [ "true" ] + }, + { + "name" : "create-recording-path", + "type" : "BOOLEAN", + "options" : [ "true" ] + }, + { + "name" : "recording-write-existing", + "type" : "BOOLEAN", + "options" : [ "true" ] + } + ] + }, + + { + "name" : "wol", + "fields" : [ + { + "name" : "wol-send-packet", + "type" : "BOOLEAN", + "options" : [ "true" ] + }, + { + "name" : "wol-mac-addr", + "type" : "TEXT" + }, + { + "name" : "wol-broadcast-addr", + "type" : "TEXT" + }, + { + "name" : "wol-udp-port", + "type" : "NUMERIC" + }, + { + "name" : "wol-wait-time", + "type" : "NUMERIC" + } + ] + } + + ], + "sharingProfileForms" : [ + + { + "name" : "display", + "fields" : [ + { + "name" : "read-only", + "type" : "BOOLEAN", + "options" : [ "true" ] + } + ] + } + + ] +} diff --git a/guacamole/src/main/frontend/src/app/client/controllers/clientController.js b/guacamole/src/main/frontend/src/app/client/controllers/clientController.js index 6a486fe9a2..65d7cc484a 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,72 @@ 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 is currently supported with the rdp and spice protocols + if ($scope.focusedClient?.protocol !== 'rdp' + && $scope.focusedClient?.protocol !== 'spice') + return false; + + // The maximum number of secondary monitors that can be added. + 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 +946,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 c5ac9d89c2..fe8df1d356 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. @@ -423,6 +425,15 @@ angular.module('client').directive('guacClient', [function guacClient() { // Update scale when display is resized $scope.$watch('client.managedDisplay.size', function setDisplaySize() { + + // A remote (server-initiated) display resize rescales the display + // element, which changes the measured size of the container and + // fires the element-resize sensor. Forwarding that echoed size back + // to the server produces a resize feedback loop (the display never + // settles, and with multiple monitors the guest thrashes). Suppress + // outbound resize requests briefly so the echo is absorbed. + suppressSendUntil = Date.now() + 700; + $scope.$evalAsync(updateDisplayScale); }); @@ -490,6 +501,44 @@ angular.module('client').directive('guacClient', [function guacClient() { $scope.client.clientProperties.scale = $scope.client.clientProperties.minScale; }); + /** + * Sends the current size of the main element (the display container) + * to the Guacamole server, requesting that the remote display be + * resized. If the Guacamole client is not yet connected, it will be + * connected and the current size will sent through the initial + * handshake. If the size of the main element is not yet known, this + * function may need to be invoked multiple times until the size is + * known and the client may be connected. + */ + /** + * Promise representing the pending resize timeout. + */ + let resizePromise = null; + + /** + * Timestamp (in milliseconds) before which outbound resize requests are + * suppressed because the remote display was just resized. Element-resize + * events fired during this window are echoes of that remote resize (the + * display rescale changes the container's measured size) rather than + * genuine user intent, and forwarding them back to the server produces a + * resize feedback loop. + * + * @type Number + */ + let suppressSendUntil = 0; + + /** + * The maximum width or height, in pixels, that will be requested for a + * single monitor. The QXL virtual GPU used by SPICE guests advertises a + * per-head EDID maximum (typically 2560x1600); requesting a larger mode + * is silently capped by the guest, so the requested and actual sizes + * never converge and the display keeps resizing. Capping the request to + * this bound keeps the requested size within what the guest can honor. + * + * @type Number + */ + const MAX_MONITOR_DIMENSION = 2560; + /** * Sends the current size of the main element (the display container) * to the Guacamole server, requesting that the remote display be @@ -507,12 +556,56 @@ angular.module('client').directive('guacClient', [function guacClient() { // Connect, if not already connected ManagedClient.connect($scope.client, main.offsetWidth, main.offsetHeight); - const pixelDensity = $window.devicePixelRatio || 1; - const width = main.offsetWidth * pixelDensity; - const height = main.offsetHeight * pixelDensity; + 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; + } - if (display.getWidth() !== width || display.getHeight() !== height) - client.sendSize(width, height); + const basePixelDensity = $window.devicePixelRatio || 1; + let otherWidths = 0; + const monitorsInfos = guacManageMonitor.getMonitorsInfos(); + if (monitorsInfos && monitorsInfos.details) { + for (const [id, details] of Object.entries(monitorsInfos.details)) { + if (String(id) !== "0") { + otherWidths += details.width || 0; + } + } + } + const maxAllowedSingleDPR = MAX_MONITOR_DIMENSION / main.offsetWidth; + const maxAllowedCombinedDPR = (4096 - otherWidths) / main.offsetWidth; + const pixelDensity = Math.max(1, Math.min(basePixelDensity, maxAllowedSingleDPR, maxAllowedCombinedDPR)); + + // Cap each dimension to what the guest can actually honor so + // the requested and actual sizes converge + const width = Math.min(main.offsetWidth * pixelDensity, MAX_MONITOR_DIMENSION); + const height = Math.min(main.offsetHeight * pixelDensity, MAX_MONITOR_DIMENSION); + const top = window.screenY; + const left = window.screenX; + + // Window resized + if (display.getWidth() !== width || display.getHeight() !== height) { + guacManageMonitor.sendSize(client, { + width: width, + height: height, + monitorId: 0, + top: top, + left: left, + }); + } + }, 250); } diff --git a/guacamole/src/main/frontend/src/app/client/directives/guacClientSecondary.js b/guacamole/src/main/frontend/src/app/client/directives/guacClientSecondary.js new file mode 100644 index 0000000000..41ce7b8171 --- /dev/null +++ b/guacamole/src/main/frontend/src/app/client/directives/guacClientSecondary.js @@ -0,0 +1,310 @@ +/* + * 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; + + /** + * The maximum width or height, in pixels, that will be requested for + * this monitor. Matches the QXL per-head EDID maximum so that requested + * and actual sizes converge rather than the guest silently capping an + * over-large request. See guacClient (primary) for details. + * + * @type Number + */ + const MAX_MONITOR_DIMENSION = 2560; + + // Suppress the resize echo produced when the server resizes this display + display.onresize = function displayResized() { + suppressSendUntil = Date.now() + 700; + }; + + // Adjust the display scaling according to the window size. + $scope.mainElementResized = function mainElementResized() { + + 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 basePixelDensity = $window.devicePixelRatio || 1; + let otherWidths = 0; + const monitorsInfos = guacManageMonitor.getMonitorsInfos(); + const currentMonitorId = guacManageMonitor.monitorId; + if (monitorsInfos && monitorsInfos.details) { + for (const [id, details] of Object.entries(monitorsInfos.details)) { + if (String(id) !== String(currentMonitorId)) { + otherWidths += details.width || 0; + } + } + } + const maxAllowedSingleDPR = MAX_MONITOR_DIMENSION / main.offsetWidth; + const maxAllowedCombinedDPR = (4096 - otherWidths) / main.offsetWidth; + const pixelDensity = Math.max(1, Math.min(basePixelDensity, maxAllowedSingleDPR, maxAllowedCombinedDPR)); + + const width = Math.min(main.offsetWidth * pixelDensity, MAX_MONITOR_DIMENSION); + const height = Math.min(main.offsetHeight * pixelDensity, MAX_MONITOR_DIMENSION); + const top = window.screenY; + const left = window.screenX; + + const size = { + width: width, + height: height, + top: top, + left: left, + monitorId: guacManageMonitor.monitorId, + }; + + // Send resize event to main window + guacManageMonitor.pushBroadcastMessage('size', size); + }, 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..08c888cf0d --- /dev/null +++ b/guacamole/src/main/frontend/src/app/client/services/guacManageMonitor.js @@ -0,0 +1,761 @@ +/* + * 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; + + // 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(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(); + + } + + /** + * Return the current monitorsInfos object. + * + * @returns {Object} + * The monitorsInfos object. + */ + service.getMonitorsInfos = function getMonitorsInfos() { + return monitorsInfos; + }; + + // Close additional monitors when window is unloaded + $window.addEventListener('unload', service.closeAllMonitors); + + 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}}

+ + + @@ -37,9 +45,35 @@ + +
+ + +
+
{{ 'PLAYER.INFO_FRAME_EVENTS_LEGEND' | translate }} {{ 'PLAYER.INFO_KEY_EVENTS_LEGEND' | translate }} + {{ 'PLAYER.INFO_CLIPBOARD_EVENTS_LEGEND' | translate }}
@@ -74,6 +108,14 @@ {{ 'PLAYER.INFO_NO_KEY_LOG' | translate }} + + + {{ 'PLAYER.ACTION_SHOW_CLIPBOARD_LOG' | translate }} + + + + {{ 'PLAYER.INFO_NO_CLIPBOARD_LOG' | translate }} + diff --git a/guacamole/src/main/frontend/src/app/player/templates/textView.html b/guacamole/src/main/frontend/src/app/player/templates/textView.html index 4cef97639e..80feb133c4 100644 --- a/guacamole/src/main/frontend/src/app/player/templates/textView.html +++ b/guacamole/src/main/frontend/src/app/player/templates/textView.html @@ -21,9 +21,32 @@ {{ event.text }} + ng-class="{ + 'not-typed' : !event.typed, + 'future': event.timestamp >= currentPosition, + 'clipboard-event': event.clipboard, + 'clipboard-image-event': event.clipboard.isImage + }" + >{{ event.clipboard.directionLabel }}{{ event.text }}{{ 'PLAYER.INFO_CLIPBOARD_IMAGE_ALT' | translate }}{{ event.clipboard.mimetype }} · {{ event.clipboard.sizeLabel }} · {{ event.clipboard.width }}×{{ event.clipboard.height }} + + diff --git a/guacamole/src/main/frontend/src/app/settings/styles/history-player.css b/guacamole/src/main/frontend/src/app/settings/styles/history-player.css index 55458dd511..6bf0046e83 100644 --- a/guacamole/src/main/frontend/src/app/settings/styles/history-player.css +++ b/guacamole/src/main/frontend/src/app/settings/styles/history-player.css @@ -157,6 +157,7 @@ } .settings.connectionHistoryPlayer .guac-player-controls .heat-map .legend .key-events::after, +.settings.connectionHistoryPlayer .guac-player-controls .heat-map .legend .clipboard-events::after, .settings.connectionHistoryPlayer .guac-player-controls .heat-map .legend .frame-events::after { display: inline-block; content: ''; @@ -175,6 +176,10 @@ background-color: #888888; } +.settings.connectionHistoryPlayer .guac-player-controls .heat-map .legend .clipboard-events::after { + background-color: #b25f00; +} + .settings.connectionHistoryPlayer .heat-map svg.key-events path { /* #5BA300 color at 50% opacity */ diff --git a/guacamole/src/main/frontend/src/translations/en.json b/guacamole/src/main/frontend/src/translations/en.json index 27f24a9762..5cffd19c4c 100644 --- a/guacamole/src/main/frontend/src/translations/en.json +++ b/guacamole/src/main/frontend/src/translations/en.json @@ -64,12 +64,14 @@ "CLIENT" : { "ACTION_ACKNOWLEDGE" : "@:APP.ACTION_ACKNOWLEDGE", + "ACTION_ADD_MONITOR" : "Add an additional screen", "ACTION_CANCEL" : "@:APP.ACTION_CANCEL", "ACTION_CLEAR_CLIENT_MESSAGES" : "@:APP.ACTION_CLEAR", "ACTION_CLEAR_COMPLETED_TRANSFERS" : "@:APP.ACTION_CLEAR", "ACTION_CONTINUE" : "@:APP.ACTION_CONTINUE", "ACTION_DISCONNECT" : "Disconnect", "ACTION_FULLSCREEN" : "Fullscreen", + "ACTION_FULLSCREEN_CONSENT" : "Would you like to enable full screen?", "ACTION_LOGOUT" : "@:APP.ACTION_LOGOUT", "ACTION_NAVIGATE_BACK" : "@:APP.ACTION_NAVIGATE_BACK", "ACTION_NAVIGATE_HOME" : "@:APP.ACTION_NAVIGATE_HOME", @@ -157,6 +159,7 @@ "SECTION_HEADER_INPUT_METHOD" : "Input method", "SECTION_HEADER_MOUSE_MODE" : "Mouse emulation mode", + "SECTION_HEADER_MULTI_MONITOR" : "Multi-screen", "TEXT_ANONYMOUS_USER_JOINED" : "An anonymous user has joined the connection.", "TEXT_ANONYMOUS_USER_LEFT" : "An anonymous user has left the connection.", @@ -490,17 +493,35 @@ "ACTION_CANCEL" : "@:APP.ACTION_CANCEL", "ACTION_CLOSE" : "Close", + "ACTION_COLLAPSE_REPEATS" : "Hide repeats", + "ACTION_DOWNLOAD" : "Download", + "ACTION_EXPAND_REPEATS" : "Show repeats", + "ACTION_EXPORT_CSV" : "Export CSV", "ACTION_PAUSE" : "@:APP.ACTION_PAUSE", "ACTION_PLAY" : "@:APP.ACTION_PLAY", + "ACTION_SHOW_CLIPBOARD_LOG" : "Clipboard Activity", "ACTION_SHOW_KEY_LOG" : "Keystroke Log", + "ACTION_SHOW_LESS" : "Show less", + "ACTION_SHOW_MORE" : "Show more", + "INFO_CLIPBOARD_EVENTS_LEGEND" : "Clipboard Activity", + "INFO_CLIPBOARD_IMAGE_ALT" : "Clipboard image", + "INFO_CLIPBOARD_IMAGE_PREVIEW" : "Clipboard image preview", + "INFO_CLIPBOARD_INCOMPLETE" : "{COUNT} clipboard {COUNT, plural, one{transfer was} other{transfers were}} recorded incompletely and may be missing.", "INFO_FRAME_EVENTS_LEGEND" : "On-screen Activity", "INFO_KEY_EVENTS_LEGEND" : "Keyboard Activity", "INFO_LOADING_RECORDING" : "Your recording is now being loaded. Please wait...", + "INFO_NO_CLIPBOARD_ACTIVITY" : "No clipboard activity was recorded. Enable clipboard recording with recording-include-clipboard to capture it.", + "INFO_NO_CLIPBOARD_LOG" : "Clipboard Activity Unavailable", "INFO_NO_KEY_LOG" : "Keystroke Log Unavailable", "INFO_NUMBER_OF_RESULTS" : "{RESULTS} {RESULTS, plural, one{Match} other{Matches}}", "INFO_SEEK_IN_PROGRESS" : "Seeking to the requested position. Please wait...", + "LABEL_CLIPBOARD" : "Clipboard", + "LABEL_CLIPBOARD_COPIED_OUT" : "Data Copied Out", + "LABEL_CLIPBOARD_PASTED_IN" : "Data Pasted In", + "LABEL_CLIPBOARD_REPEATS" : "×{COUNT} {COUNT, plural, one{repeat} other{repeats}}", + "FIELD_PLACEHOLDER_TEXT_BATCH_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER" }, @@ -665,6 +686,7 @@ "FIELD_HEADER_REMOTE_APP_ARGS" : "Parameters:", "FIELD_HEADER_REMOTE_APP_DIR" : "Working directory:", "FIELD_HEADER_REMOTE_APP" : "Program:", + "FIELD_HEADER_SECONDARY_MONITORS" : "Maximum secondary monitors (needs display-update):", "FIELD_HEADER_SECURITY" : "Security mode:", "FIELD_HEADER_SERVER_LAYOUT" : "Keyboard layout:", "FIELD_HEADER_SFTP_DIRECTORY" : "Default upload directory:", @@ -764,6 +786,138 @@ }, + "PROTOCOL_SPICE" : { + + "FIELD_HEADER_CA_CERT" : "CA certificates (Base64):", + "FIELD_HEADER_CERT_SUBJECT" : "Certificate subject:", + "FIELD_HEADER_CLIPBOARD_BUFFER_SIZE" : "Clipboard buffer size:", + "FIELD_HEADER_COLOR_DEPTH" : "Color depth:", + "FIELD_HEADER_CREATE_RECORDING_PATH" : "Automatically create recording path:", + "FIELD_HEADER_DISABLE_AUDIO_OPUS" : "Disable Opus audio codec (use raw/lossless where supported):", + "FIELD_HEADER_DISABLE_CLIPBOARD" : "Disable clipboard:", + "FIELD_HEADER_DISABLE_COPY" : "Disable copying from remote desktop:", + "FIELD_HEADER_DISABLE_DISPLAY_RESIZE" : "Disable resize of remote display:", + "FIELD_HEADER_DISABLE_DOWNLOAD" : "Disable file download:", + "FIELD_HEADER_DISABLE_PASTE" : "Disable pasting from client:", + "FIELD_HEADER_DISABLE_UPLOAD" : "Disable file upload:", + "FIELD_HEADER_DRIVE_PATH" : "Drive path:", + "FIELD_HEADER_DRIVE_READ_ONLY" : "Drive read-only:", + "FIELD_HEADER_ENABLE_AUDIO" : "Enable audio:", + "FIELD_HEADER_ENABLE_AUDIO_INPUT" : "Enable audio input (microphone):", + "FIELD_HEADER_ENABLE_DRIVE" : "Enable drive:", + "FIELD_HEADER_ENABLE_SFTP" : "Enable SFTP:", + "FIELD_HEADER_FILE_DIRECTORY" : "Directory to share:", + "FIELD_HEADER_FILE_TRANSFER" : "Enable file transfer:", + "FIELD_HEADER_FILE_TRANSFER_CREATE_FOLDER": "Automatically create file transfer folder:", + "FIELD_HEADER_FILE_TRANSFER_MODE" : "File transfer mode:", + "FIELD_HEADER_FILE_TRANSFER_RO" : "Share directory read-only:", + "FIELD_HEADER_HOSTNAME" : "Hostname:", + "FIELD_HEADER_IGNORE_CERT" : "Ignore server certificate:", + "FIELD_HEADER_PASSWORD" : "Password:", + "FIELD_HEADER_PORT" : "Port:", + "FIELD_HEADER_PREFERRED_COMPRESSION" : "Preferred compression:", + "FIELD_HEADER_PREFERRED_VIDEO_CODEC" : "Preferred video codec:", + "FIELD_HEADER_PROXY" : "Proxy server:", + "FIELD_HEADER_PUBKEY" : "Public key (Base64):", + "FIELD_HEADER_READ_ONLY" : "Read-only:", + "FIELD_HEADER_RECORDING_EXCLUDE_MOUSE" : "Exclude mouse:", + "FIELD_HEADER_RECORDING_EXCLUDE_OUTPUT" : "Exclude graphics/streams:", + "FIELD_HEADER_RECORDING_INCLUDE_KEYS" : "Include key events:", + "FIELD_HEADER_RECORDING_INCLUDE_CLIPBOARD" : "Include clipboard events:", + "FIELD_HEADER_RECORDING_NAME" : "Recording name:", + "FIELD_HEADER_RECORDING_PATH" : "Recording path:", + "FIELD_HEADER_RECORDING_WRITE_EXISTING" : "@:APP.FIELD_HEADER_RECORDING_WRITE_EXISTING", + "FIELD_HEADER_SECONDARY_MONITORS" : "Maximum secondary monitors:", + "FIELD_HEADER_SERVER_LAYOUT" : "Keyboard layout:", + "FIELD_HEADER_SFTP_DIRECTORY" : "Default upload directory:", + "FIELD_HEADER_SFTP_DISABLE_DOWNLOAD" : "Disable file download:", + "FIELD_HEADER_SFTP_DISABLE_UPLOAD" : "Disable file upload:", + "FIELD_HEADER_SFTP_HOST_KEY" : "Public host key (Base64):", + "FIELD_HEADER_SFTP_HOSTNAME" : "Hostname:", + "FIELD_HEADER_SFTP_PASSPHRASE" : "Passphrase:", + "FIELD_HEADER_SFTP_PASSWORD" : "Password:", + "FIELD_HEADER_SFTP_PORT" : "Port:", + "FIELD_HEADER_SFTP_PRIVATE_KEY" : "Private key:", + "FIELD_HEADER_SFTP_PUBLIC_KEY" : "Public key:", + "FIELD_HEADER_SFTP_ROOT_DIRECTORY" : "File browser root directory:", + "FIELD_HEADER_SFTP_SERVER_ALIVE_INTERVAL" : "SFTP keepalive interval:", + "FIELD_HEADER_SFTP_TIMEOUT" : "SFTP connection timeout:", + "FIELD_HEADER_SFTP_USERNAME" : "Username:", + "FIELD_HEADER_SWAP_RED_BLUE" : "Swap red/blue components:", + "FIELD_HEADER_TLS" : "Enable TLS connection:", + "FIELD_HEADER_TLS_PORT" : "TLS port:", + "FIELD_HEADER_USERNAME" : "Username:", + "FIELD_HEADER_WOL_BROADCAST_ADDR" : "Broadcast address for WoL packet:", + "FIELD_HEADER_WOL_MAC_ADDR" : "MAC address of the remote host:", + "FIELD_HEADER_WOL_SEND_PACKET" : "Send WoL packet:", + "FIELD_HEADER_WOL_UDP_PORT" : "UDP port for WoL packet:", + "FIELD_HEADER_WOL_WAIT_TIME" : "Host boot wait time:", + + "FIELD_OPTION_COLOR_DEPTH_8" : "256 color", + "FIELD_OPTION_COLOR_DEPTH_16" : "Low color (16-bit)", + "FIELD_OPTION_COLOR_DEPTH_24" : "True color (24-bit)", + "FIELD_OPTION_COLOR_DEPTH_32" : "True color (32-bit)", + "FIELD_OPTION_COLOR_DEPTH_EMPTY" : "", + + "FIELD_OPTION_FILE_TRANSFER_MODE_EMPTY" : "", + "FIELD_OPTION_FILE_TRANSFER_MODE_NONE" : "Disabled", + "FIELD_OPTION_FILE_TRANSFER_MODE_AGENT" : "Direct upload to guest (SPICE agent, upload-only)", + "FIELD_OPTION_FILE_TRANSFER_MODE_DRIVE" : "Shared folder (WebDAV, bidirectional)", + "FIELD_OPTION_FILE_TRANSFER_MODE_BOTH" : "Both (shared folder for browsing, direct upload for drag-and-drop)", + + "FIELD_OPTION_PREFERRED_COMPRESSION_AUTO_GLZ" : "Auto GLZ", + "FIELD_OPTION_PREFERRED_COMPRESSION_AUTO_LZ" : "Auto LZ", + "FIELD_OPTION_PREFERRED_COMPRESSION_EMPTY" : "", + "FIELD_OPTION_PREFERRED_COMPRESSION_GLZ" : "GLZ", + "FIELD_OPTION_PREFERRED_COMPRESSION_LZ" : "LZ", + "FIELD_OPTION_PREFERRED_COMPRESSION_LZ4" : "LZ4", + "FIELD_OPTION_PREFERRED_COMPRESSION_OFF" : "Off", + "FIELD_OPTION_PREFERRED_COMPRESSION_QUIC" : "QUIC", + + "FIELD_OPTION_PREFERRED_VIDEO_CODEC_EMPTY" : "", + "FIELD_OPTION_PREFERRED_VIDEO_CODEC_H264" : "H.264", + "FIELD_OPTION_PREFERRED_VIDEO_CODEC_MJPEG" : "MJPEG", + "FIELD_OPTION_PREFERRED_VIDEO_CODEC_VP8" : "VP8", + "FIELD_OPTION_PREFERRED_VIDEO_CODEC_VP9" : "VP9", + + "FIELD_OPTION_SERVER_LAYOUT_DA_DK_QWERTY" : "Danish (Qwerty)", + "FIELD_OPTION_SERVER_LAYOUT_DE_CH_QWERTZ" : "Swiss German (Qwertz)", + "FIELD_OPTION_SERVER_LAYOUT_DE_DE_QWERTZ" : "German (Qwertz)", + "FIELD_OPTION_SERVER_LAYOUT_EMPTY" : "", + "FIELD_OPTION_SERVER_LAYOUT_EN_GB_QWERTY" : "UK English (Qwerty)", + "FIELD_OPTION_SERVER_LAYOUT_EN_US_QWERTY" : "US English (Qwerty)", + "FIELD_OPTION_SERVER_LAYOUT_ES_ES_QWERTY" : "Spanish (Qwerty)", + "FIELD_OPTION_SERVER_LAYOUT_ES_LATAM_QWERTY" : "Latin American (Qwerty)", + "FIELD_OPTION_SERVER_LAYOUT_FAILSAFE" : "Unicode", + "FIELD_OPTION_SERVER_LAYOUT_FR_BE_AZERTY" : "Belgian French (Azerty)", + "FIELD_OPTION_SERVER_LAYOUT_FR_CA_QWERTY" : "Canadian French (Qwerty)", + "FIELD_OPTION_SERVER_LAYOUT_FR_CH_QWERTZ" : "Swiss French (Qwertz)", + "FIELD_OPTION_SERVER_LAYOUT_FR_FR_AZERTY" : "French (Azerty)", + "FIELD_OPTION_SERVER_LAYOUT_HU_HU_QWERTZ" : "Hungarian (Qwertz)", + "FIELD_OPTION_SERVER_LAYOUT_IT_IT_QWERTY" : "Italian (Qwerty)", + "FIELD_OPTION_SERVER_LAYOUT_JA_JP_QWERTY" : "Japanese (Qwerty)", + "FIELD_OPTION_SERVER_LAYOUT_NO_NO_QWERTY" : "Norwegian (Qwerty)", + "FIELD_OPTION_SERVER_LAYOUT_PL_PL_QWERTY" : "Polish (Qwerty)", + "FIELD_OPTION_SERVER_LAYOUT_PT_BR_QWERTY" : "Portuguese Brazilian (Qwerty)", + "FIELD_OPTION_SERVER_LAYOUT_SV_SE_QWERTY" : "Swedish (Qwerty)", + "FIELD_OPTION_SERVER_LAYOUT_TR_TR_QWERTY" : "Turkish-Q (Qwerty)", + + "NAME" : "SPICE", + + "SECTION_HEADER_AUDIO" : "Audio", + "SECTION_HEADER_AUTHENTICATION": "Authentication", + "SECTION_HEADER_CLIPBOARD" : "Clipboard", + "SECTION_HEADER_DISPLAY" : "Display", + "SECTION_HEADER_FILE_TRANSFER" : "File Transfer", + "SECTION_HEADER_KEYBOARD" : "Keyboard", + "SECTION_HEADER_NETWORK" : "Network", + "SECTION_HEADER_RECORDING" : "Screen Recording", + "SECTION_HEADER_SECURITY" : "Security", + "SECTION_HEADER_SFTP" : "SFTP", + "SECTION_HEADER_WOL" : "Wake-on-LAN (WoL)" + + }, + "PROTOCOL_SSH" : { "FIELD_HEADER_BACKSPACE" : "Backspace key sends:", diff --git a/guacamole/src/main/frontend/src/translations/fr.json b/guacamole/src/main/frontend/src/translations/fr.json index cd2dc821bf..a9b6a2d0c8 100644 --- a/guacamole/src/main/frontend/src/translations/fr.json +++ b/guacamole/src/main/frontend/src/translations/fr.json @@ -61,12 +61,14 @@ "CLIENT" : { "ACTION_ACKNOWLEDGE" : "@:APP.ACTION_ACKNOWLEDGE", + "ACTION_ADD_MONITOR" : "Ajouter un écran supplémentaire", "ACTION_CANCEL" : "@:APP.ACTION_CANCEL", "ACTION_CLEAR_CLIENT_MESSAGES" : "@:APP.ACTION_CLEAR", "ACTION_CLEAR_COMPLETED_TRANSFERS" : "@:APP.ACTION_CLEAR", "ACTION_CONTINUE" : "@:APP.ACTION_CONTINUE", "ACTION_DISCONNECT" : "Déconnecter", "ACTION_FULLSCREEN" : "Plein écran", + "ACTION_FULLSCREEN_CONSENT" : "Souhaitez-vous activer le plein écran ?", "ACTION_LOGOUT" : "@:APP.ACTION_LOGOUT", "ACTION_NAVIGATE_BACK" : "@:APP.ACTION_NAVIGATE_BACK", "ACTION_NAVIGATE_HOME" : "@:APP.ACTION_NAVIGATE_HOME", @@ -153,6 +155,7 @@ "SECTION_HEADER_INPUT_METHOD" : "Méthode de saisie", "SECTION_HEADER_MOUSE_MODE" : "Mode d'émulation de la souris", + "SECTION_HEADER_MULTI_MONITOR" : "Multi-écran", "TEXT_ANONYMOUS_USER_JOINED" : "Un utilisateur anonyme a rejoint la connexion.", "TEXT_ANONYMOUS_USER_LEFT" : "Un utilisateur anonyme a quitté la connexion.", @@ -635,6 +638,7 @@ "FIELD_HEADER_REMOTE_APP_ARGS" : "Paramètres\u202F:", "FIELD_HEADER_REMOTE_APP_DIR" : "Répertoire de travail\u202F:", "FIELD_HEADER_REMOTE_APP" : "Programme\u202F:", + "FIELD_HEADER_SECONDARY_MONITORS" : "Nombre maximum de moniteurs secondaires (nécessite display-update)\u202F:", "FIELD_HEADER_SECURITY" : "Mode de Sécurité\u202F:", "FIELD_HEADER_SERVER_LAYOUT" : "Agencement clavier\u202F:", "FIELD_HEADER_SFTP_DIRECTORY" : "Répertoire d'upload par défaut\u202F:",