diff --git a/guacamole-common-js/src/main/webapp/modules/Keyboard.js b/guacamole-common-js/src/main/webapp/modules/Keyboard.js index b5c2a40e4b..3b40594fdc 100644 --- a/guacamole-common-js/src/main/webapp/modules/Keyboard.js +++ b/guacamole-common-js/src/main/webapp/modules/Keyboard.js @@ -83,6 +83,14 @@ Guacamole.Keyboard = function Keyboard(element) { */ this.onkeyup = null; + /** + * Whether pressed Meta keys should be treated as Ctrl keys. When true, + * shortcuts using Meta will behave as if Ctrl were pressed. + * + * @type {!boolean} + */ + this.metaToCtrl = false; + /** * Set of known platform-specific or browser-specific quirks which must be * accounted for to properly interpret key events, even if the only way to @@ -136,6 +144,33 @@ Guacamole.Keyboard = function Keyboard(element) { } + /** + * Translates Meta keysyms to their Ctrl equivalents if configured. + * + * @private + * @param {!number} keysym + * The keysym to potentially translate. + * + * @returns {!number} + * The translated keysym. + */ + var mapMetaToCtrlKeysym = function mapMetaToCtrlKeysym(keysym) { + + // Preserve original keysyms unless Meta-to-Ctrl translation is enabled + if (!guac_keyboard.metaToCtrl) + return keysym; + + // Translate left/right Meta to left/right Ctrl + if (keysym === 0xFFE7) + return 0xFFE3; + + if (keysym === 0xFFE8) + return 0xFFE4; + + return keysym; + + }; + /** * A key event having a corresponding timestamp. This event is non-specific. * Its subclasses should be used instead when recording specific key @@ -200,6 +235,21 @@ Guacamole.Keyboard = function Keyboard(element) { */ this.modifiers = orig ? Guacamole.Keyboard.ModifierState.fromKeyboardEvent(orig) : new Guacamole.Keyboard.ModifierState(); + /** + * Whether Meta is currently physically pressed, prior to any + * metaToCtrl translation. + * + * @type {!boolean} + */ + this.physicalMeta = this.modifiers.meta; + + // Treat Meta as Ctrl when configured, while preserving the physical + // Meta state separately for browser-quirk handling. + if (guac_keyboard.metaToCtrl && this.modifiers.meta) { + this.modifiers.ctrl = true; + this.modifiers.meta = false; + } + /** * An arbitrary timestamp in milliseconds, indicating this event's * position in time relative to other events. @@ -286,7 +336,7 @@ Guacamole.Keyboard = function Keyboard(element) { // If a key is pressed while meta is held down, the keyup will // never be sent in Chrome (bug #108404) - if (this.modifiers.meta && this.keysym !== 0xFFE7 && this.keysym !== 0xFFE8) + if (this.physicalMeta && this.keysym !== 0xFFE7 && this.keysym !== 0xFFE8) this.keyupReliable = false; // We cannot rely on receiving keyup for Caps Lock on certain platforms @@ -311,6 +361,9 @@ Guacamole.Keyboard = function Keyboard(element) { || this.modifiers.hyper) this.reliable = true; + // Translate Meta keys to Ctrl keys if requested. + this.keysym = mapMetaToCtrlKeysym(this.keysym); + // Record most recently known keysym by associated key code recentKeysym[this.keyCode] = this.keysym; @@ -373,6 +426,9 @@ Guacamole.Keyboard = function Keyboard(element) { this.keysym = keysym_from_keycode(this.keyCode, this.location) || keysym_from_key_identifier(this.key, this.location); + // Translate Meta keys to Ctrl keys if requested. + this.keysym = mapMetaToCtrlKeysym(this.keysym); + // Fall back to the most recently pressed keysym associated with the // keyCode if the inferred key doesn't seem to actually be pressed if (!guac_keyboard.pressed[this.keysym]) 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 c59be16335..3ca8c24970 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 @@ -180,6 +180,11 @@ "name" : "read-only", "type" : "BOOLEAN", "options" : [ "true" ] + }, + { + "name" : "meta-to-ctrl", + "type" : "BOOLEAN", + "options" : [ "true" ] } ] }, diff --git a/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/ssh.json b/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/ssh.json index 91d1fef098..6841e65251 100644 --- a/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/ssh.json +++ b/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/ssh.json @@ -75,6 +75,11 @@ "name" : "read-only", "type" : "BOOLEAN", "options" : [ "true" ] + }, + { + "name" : "meta-to-ctrl", + "type" : "BOOLEAN", + "options" : [ "true" ] } ] }, diff --git a/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/vnc.json b/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/vnc.json index cd766f0e9e..11fe0f6c5c 100644 --- a/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/vnc.json +++ b/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/vnc.json @@ -38,6 +38,11 @@ "type" : "BOOLEAN", "options" : [ "true" ] }, + { + "name" : "meta-to-ctrl", + "type" : "BOOLEAN", + "options" : [ "true" ] + }, { "name" : "disable-server-input", "type" : "BOOLEAN", diff --git a/guacamole/src/main/frontend/src/app/client/controllers/clientController.js b/guacamole/src/main/frontend/src/app/client/controllers/clientController.js index 6a486fe9a2..6a39fc2f8e 100644 --- a/guacamole/src/main/frontend/src/app/client/controllers/clientController.js +++ b/guacamole/src/main/frontend/src/app/client/controllers/clientController.js @@ -25,6 +25,7 @@ angular.module('client').controller('clientController', ['$scope', '$routeParams // Required types const ConnectionGroup = $injector.get('ConnectionGroup'); + const ClientIdentifier = $injector.get('ClientIdentifier'); const ManagedClient = $injector.get('ManagedClient'); const ManagedClientGroup = $injector.get('ManagedClientGroup'); const ManagedClientState = $injector.get('ManagedClientState'); @@ -37,6 +38,7 @@ angular.module('client').controller('clientController', ['$scope', '$routeParams const authenticationService = $injector.get('authenticationService'); const connectionGroupService = $injector.get('connectionGroupService'); const clipboardService = $injector.get('clipboardService'); + const connectionService = $injector.get('connectionService'); const dataSourceService = $injector.get('dataSourceService'); const guacClientManager = $injector.get('guacClientManager'); const guacFullscreen = $injector.get('guacFullscreen'); @@ -483,6 +485,75 @@ angular.module('client').controller('clientController', ['$scope', '$routeParams // Hide the menu when the guacClientHideMenu event is received $scope.$on('guacHideMenu', () => $scope.menu.shown = false); + /** + * Connection options that affect browser client behavior. + * + * @type {!Array.<{event: string, defaultValue: *, getValue: function(*): *}>} + */ + const connectionClientBehaviorDefinitions = [{ + event : 'guacMetaToCtrlChanged', + defaultValue : false, + getValue : function getValue(connection) { + return connection.metaToCtrl === true; + } + }]; + + /** + * Emits all connection client behavior options. + * + * @param {*} [connection] + * Connection object returned by connectionService. + */ + const applyConnectionClientBehavior = function applyConnectionClientBehavior(connection) { + connectionClientBehaviorDefinitions.forEach(function applyDefinition(definition) { + const value = connection ? + definition.getValue(connection) : definition.defaultValue; + + $scope.$emit(definition.event, value); + }); + }; + + /** + * Refreshes connection-derived client behavior for the currently-focused + * client. + * + * @param {ManagedClient} focusedClient + * The currently-focused client, if any. + */ + const updateConnectionClientBehavior = function updateConnectionClientBehavior(focusedClient) { + + // Immediately clear previous connection behavior to defaults. + applyConnectionClientBehavior(); + + if (!focusedClient) + return; + + const identifier = ClientIdentifier.fromString(focusedClient.id); + if (identifier.type !== ClientIdentifier.Types.CONNECTION) + return; + + // Guard async callbacks: only apply behavior if this client is still focused. + const focusedClientId = focusedClient.id; + + connectionService.getConnection(identifier.dataSource, identifier.id) + .then(function connectionRetrieved(connection) { + + if (!$scope.focusedClient || $scope.focusedClient.id !== focusedClientId) + return; + + applyConnectionClientBehavior(connection); + + }, function connectionRetrievalFailed() { + + if (!$scope.focusedClient || $scope.focusedClient.id !== focusedClientId) + return; + + applyConnectionClientBehavior(); + + }); + + }; + // Automatically track and cache the currently-focused client $scope.$on('guacClientFocused', function focusedClientChanged(event, newFocusedClient) { @@ -498,6 +569,9 @@ angular.module('client').controller('clientController', ['$scope', '$routeParams $scope.menu.connectionParameters = newFocusedClient ? ManagedClient.getArgumentModel(newFocusedClient) : {}; + // Keep client behavior synced with focused connection configuration + updateConnectionClientBehavior(newFocusedClient); + }); // Automatically update connection parameters that have been modified diff --git a/guacamole/src/main/frontend/src/app/index/controllers/indexController.js b/guacamole/src/main/frontend/src/app/index/controllers/indexController.js index 2f472a6e24..2718d28042 100644 --- a/guacamole/src/main/frontend/src/app/index/controllers/indexController.js +++ b/guacamole/src/main/frontend/src/app/index/controllers/indexController.js @@ -182,6 +182,12 @@ angular.module('index').controller('indexController', ['$scope', '$injector', // Create event listeners at the global level var keyboard = new Guacamole.Keyboard($document[0]); keyboard.listenTo(sink.getElement()); + keyboard.metaToCtrl = false; + + // Track whether Meta should be treated as Ctrl for the active connection + $scope.$on('guacMetaToCtrlChanged', function metaToCtrlChanged(event, enabled) { + keyboard.metaToCtrl = !!enabled; + }); // Broadcast keydown events keyboard.onkeydown = function onkeydown(keysym) { diff --git a/guacamole/src/main/frontend/src/app/rest/types/Connection.js b/guacamole/src/main/frontend/src/app/rest/types/Connection.js index 89da4e1172..96682707fe 100644 --- a/guacamole/src/main/frontend/src/app/rest/types/Connection.js +++ b/guacamole/src/main/frontend/src/app/rest/types/Connection.js @@ -113,8 +113,16 @@ angular.module('rest').factory('Connection', [function defineConnection() { */ this.lastActive = template.lastActive; + /** + * Whether the web client should treat Meta as Ctrl for this + * connection. + * + * @type Boolean + */ + this.metaToCtrl = !!template.metaToCtrl; + }; return Connection; -}]); \ No newline at end of file +}]); diff --git a/guacamole/src/main/frontend/src/translations/en.json b/guacamole/src/main/frontend/src/translations/en.json index 1643a0dd6f..b125d8ae90 100644 --- a/guacamole/src/main/frontend/src/translations/en.json +++ b/guacamole/src/main/frontend/src/translations/en.json @@ -627,6 +627,7 @@ "FIELD_HEADER_PRINTER_NAME" : "Redirected printer name:", "FIELD_HEADER_PRECONNECTION_BLOB" : "Preconnection BLOB (VM ID):", "FIELD_HEADER_PRECONNECTION_ID" : "RDP source ID:", + "FIELD_HEADER_META_TO_CTRL" : "Treat Meta as Ctrl:", "FIELD_HEADER_READ_ONLY" : "Read-only:", "FIELD_HEADER_RECORDING_WRITE_EXISTING" : "@:APP.FIELD_HEADER_RECORDING_WRITE_EXISTING", "FIELD_HEADER_RECORDING_EXCLUDE_MOUSE" : "Exclude mouse:", @@ -753,6 +754,7 @@ "FIELD_HEADER_PORT" : "Port:", "FIELD_HEADER_PRIVATE_KEY" : "Private key:", "FIELD_HEADER_PUBLIC_KEY" : "Public key:", + "FIELD_HEADER_META_TO_CTRL" : "Treat Meta as Ctrl:", "FIELD_HEADER_SCROLLBACK" : "Maximum scrollback size:", "FIELD_HEADER_READ_ONLY" : "Read-only:", "FIELD_HEADER_RECORDING_WRITE_EXISTING" : "@:APP.FIELD_HEADER_RECORDING_WRITE_EXISTING", @@ -933,6 +935,7 @@ "FIELD_HEADER_PASSWORD" : "Password:", "FIELD_HEADER_PORT" : "Port:", "FIELD_HEADER_QUALITY_LEVEL" : "Display quality:", + "FIELD_HEADER_META_TO_CTRL" : "Treat Meta as Ctrl:", "FIELD_HEADER_READ_ONLY" : "Read-only:", "FIELD_HEADER_RECORDING_WRITE_EXISTING" : "@:APP.FIELD_HEADER_RECORDING_WRITE_EXISTING", "FIELD_HEADER_RECORDING_EXCLUDE_MOUSE" : "Exclude mouse:", diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/connection/APIConnection.java b/guacamole/src/main/java/org/apache/guacamole/rest/connection/APIConnection.java index f1e521b3db..beadee0bfa 100644 --- a/guacamole/src/main/java/org/apache/guacamole/rest/connection/APIConnection.java +++ b/guacamole/src/main/java/org/apache/guacamole/rest/connection/APIConnection.java @@ -37,6 +37,31 @@ @JsonInclude(value=Include.NON_NULL) public class APIConnection { + /** + * The name of the connection parameter that controls whether Meta should + * be treated as Ctrl by the web client. + */ + private static final String META_TO_CTRL_PARAMETER_NAME = "meta-to-ctrl"; + + /** + * Returns whether the given boolean-like connection parameter is enabled. + * + * @param configuration + * The configuration containing the parameter. + * + * @param parameterName + * The name of the parameter to evaluate. + * + * @return + * true if the parameter value parses as true, false otherwise. + */ + private static boolean isEnabledParameter(GuacamoleConfiguration configuration, + String parameterName) { + if (configuration == null) + return false; + return Boolean.parseBoolean(configuration.getParameter(parameterName)); + } + /** * The name of this connection. */ @@ -84,6 +109,11 @@ public class APIConnection { */ private Date lastActive; + /** + * Whether Meta should be treated as Ctrl by the web client. + */ + private boolean metaToCtrl; + /** * Create an empty APIConnection. */ @@ -110,6 +140,9 @@ public APIConnection(Connection connection) // Set protocol from configuration GuacamoleConfiguration configuration = connection.getConfiguration(); this.protocol = configuration.getProtocol(); + + // Set client behavior options from configuration + this.metaToCtrl = isEnabledParameter(configuration, META_TO_CTRL_PARAMETER_NAME); // Associate any attributes this.attributes = connection.getAttributes(); @@ -289,4 +322,24 @@ public void setLastActive(Date lastActive) { this.lastActive = lastActive; } + /** + * Returns whether Meta should be treated as Ctrl by the web client. + * + * @return + * true if Meta should be treated as Ctrl, false otherwise. + */ + public boolean isMetaToCtrl() { + return metaToCtrl; + } + + /** + * Sets whether Meta should be treated as Ctrl by the web client. + * + * @param metaToCtrl + * true if Meta should be treated as Ctrl, false otherwise. + */ + public void setMetaToCtrl(boolean metaToCtrl) { + this.metaToCtrl = metaToCtrl; + } + }