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 1bc5dd6fa..2db800029 100644 --- a/guacamole/src/main/frontend/src/app/client/controllers/clientController.js +++ b/guacamole/src/main/frontend/src/app/client/controllers/clientController.js @@ -24,26 +24,26 @@ angular.module('client').controller('clientController', ['$scope', '$routeParams function clientController($scope, $routeParams, $injector) { // Required types - var ConnectionGroup = $injector.get('ConnectionGroup'); - var ManagedClient = $injector.get('ManagedClient'); - var ManagedClientState = $injector.get('ManagedClientState'); - var ManagedFilesystem = $injector.get('ManagedFilesystem'); - var Protocol = $injector.get('Protocol'); - var ScrollState = $injector.get('ScrollState'); + const ConnectionGroup = $injector.get('ConnectionGroup'); + const ManagedClient = $injector.get('ManagedClient'); + const ManagedClientGroup = $injector.get('ManagedClientGroup'); + const ManagedClientState = $injector.get('ManagedClientState'); + const ManagedFilesystem = $injector.get('ManagedFilesystem'); + const Protocol = $injector.get('Protocol'); + const ScrollState = $injector.get('ScrollState'); // Required services - var $location = $injector.get('$location'); - var authenticationService = $injector.get('authenticationService'); - var connectionGroupService = $injector.get('connectionGroupService'); - var clipboardService = $injector.get('clipboardService'); - var dataSourceService = $injector.get('dataSourceService'); - var guacClientManager = $injector.get('guacClientManager'); - var guacNotification = $injector.get('guacNotification'); - var iconService = $injector.get('iconService'); - var preferenceService = $injector.get('preferenceService'); - var requestService = $injector.get('requestService'); - var tunnelService = $injector.get('tunnelService'); - var userPageService = $injector.get('userPageService'); + const $location = $injector.get('$location'); + const authenticationService = $injector.get('authenticationService'); + const connectionGroupService = $injector.get('connectionGroupService'); + const clipboardService = $injector.get('clipboardService'); + const dataSourceService = $injector.get('dataSourceService'); + const guacClientManager = $injector.get('guacClientManager'); + const iconService = $injector.get('iconService'); + const preferenceService = $injector.get('preferenceService'); + const requestService = $injector.get('requestService'); + const tunnelService = $injector.get('tunnelService'); + const userPageService = $injector.get('userPageService'); /** * The minimum number of pixels a drag gesture must move to result in the @@ -93,129 +93,6 @@ angular.module('client').controller('clientController', ['$scope', '$routeParams */ var DEL_KEY = 0xFFFF; - /** - * All client error codes handled and passed off for translation. Any error - * code not present in this list will be represented by the "DEFAULT" - * translation. - */ - var CLIENT_ERRORS = { - 0x0201: true, - 0x0202: true, - 0x0203: true, - 0x0207: true, - 0x0208: true, - 0x0209: true, - 0x020A: true, - 0x020B: true, - 0x0301: true, - 0x0303: true, - 0x0308: true, - 0x031D: true - }; - - /** - * All error codes for which automatic reconnection is appropriate when a - * client error occurs. - */ - var CLIENT_AUTO_RECONNECT = { - 0x0200: true, - 0x0202: true, - 0x0203: true, - 0x0207: true, - 0x0208: true, - 0x0301: true, - 0x0308: true - }; - - /** - * All tunnel error codes handled and passed off for translation. Any error - * code not present in this list will be represented by the "DEFAULT" - * translation. - */ - var TUNNEL_ERRORS = { - 0x0201: true, - 0x0202: true, - 0x0203: true, - 0x0204: true, - 0x0205: true, - 0x0207: true, - 0x0208: true, - 0x0301: true, - 0x0303: true, - 0x0308: true, - 0x031D: true - }; - - /** - * All error codes for which automatic reconnection is appropriate when a - * tunnel error occurs. - */ - var TUNNEL_AUTO_RECONNECT = { - 0x0200: true, - 0x0202: true, - 0x0203: true, - 0x0207: true, - 0x0208: true, - 0x0308: true - }; - - /** - * Action which logs out from Guacamole entirely. - */ - var LOGOUT_ACTION = { - name : "CLIENT.ACTION_LOGOUT", - className : "logout button", - callback : function logoutCallback() { - authenticationService.logout() - ['catch'](requestService.IGNORE); - } - }; - - /** - * Action which returns the user to the home screen. If the home page has - * not yet been determined, this will be null. - */ - var NAVIGATE_HOME_ACTION = null; - - // Assign home page action once user's home page has been determined - userPageService.getHomePage() - .then(function homePageRetrieved(homePage) { - - // Define home action only if different from current location - if ($location.path() !== homePage.url) { - NAVIGATE_HOME_ACTION = { - name : "CLIENT.ACTION_NAVIGATE_HOME", - className : "home button", - callback : function navigateHomeCallback() { - $location.url(homePage.url); - } - }; - } - - }, requestService.WARN); - - /** - * Action which replaces the current client with a newly-connected client. - */ - var RECONNECT_ACTION = { - name : "CLIENT.ACTION_RECONNECT", - className : "reconnect button", - callback : function reconnectCallback() { - $scope.client = guacClientManager.replaceManagedClient($routeParams.id, $routeParams.params); - guacNotification.showStatus(false); - } - }; - - /** - * The reconnect countdown to display if an error or status warrants an - * automatic, timed reconnect. - */ - var RECONNECT_COUNTDOWN = { - text: "CLIENT.TEXT_RECONNECT_COUNTDOWN", - callback: RECONNECT_ACTION.callback, - remaining: 15 - }; - /** * Menu-specific properties. */ @@ -228,14 +105,6 @@ angular.module('client').controller('clientController', ['$scope', '$routeParams */ shown : false, - /** - * Whether the Guacamole display should be scaled to fit the browser - * window. - * - * @type Boolean - */ - autoFit : true, - /** * The currently selected input method. This may be any of the values * defined within preferenceService.inputMethods. @@ -244,6 +113,14 @@ angular.module('client').controller('clientController', ['$scope', '$routeParams */ inputMethod : preferenceService.preferences.inputMethod, + /** + * Whether translation of touch to mouse events should emulate an + * absolute pointer device, or a relative pointer device. + * + * @type Boolean + */ + emulateAbsoluteMouse : preferenceService.preferences.emulateAbsoluteMouse, + /** * The current scroll state of the menu. * @@ -268,32 +145,179 @@ angular.module('client').controller('clientController', ['$scope', '$routeParams /** * Applies any changes to connection parameters made by the user within the - * Guacamole menu. + * Guacamole menu to the given ManagedClient. If no client is supplied, + * this function has no effect. + * + * @param {ManagedClient} client + * The client to apply parameter changes to. */ - $scope.applyParameterChanges = function applyParameterChanges() { + $scope.applyParameterChanges = function applyParameterChanges(client) { angular.forEach($scope.menu.connectionParameters, function sendArgv(value, name) { - ManagedClient.setArgument($scope.client, name, value); + if (client) + ManagedClient.setArgument(client, name, value); }); }; /** - * The client which should be attached to the client UI. + * The currently-focused client within the current ManagedClientGroup. If + * there is no current group, no client is focused, or multiple clients are + * focused, this will be null. * * @type ManagedClient */ - $scope.client = guacClientManager.getManagedClient($routeParams.id, $routeParams.params); + $scope.focusedClient = null; /** - * All active clients which are not the current client ($scope.client). - * Each key is the ID of the connection used by that client. + * The set of clients that should be attached to the client UI. This will + * be immediately initialized by a call to updateAttachedClients() below. * - * @type Object. + * @type ManagedClientGroup */ - $scope.otherClients = (function getOtherClients(clients) { - var otherClients = angular.extend({}, clients); - delete otherClients[$scope.client.id]; - return otherClients; - })(guacClientManager.getManagedClients()); + $scope.clientGroup = null; + + /** + * @borrows ManagedClientGroup.getName + */ + $scope.getName = ManagedClientGroup.getName; + + /** + * @borrows ManagedClientGroup.getTitle + */ + $scope.getTitle = ManagedClientGroup.getTitle; + + /** + * Arbitrary context that should be exposed to the guacGroupList directive + * displaying the dropdown list of available connections within the + * Guacamole menu. + */ + $scope.connectionListContext = { + + /** + * The set of clients desired within the current view. For each client + * that should be present within the current view, that client's ID + * will map to "true" here. + * + * @type {Object.} + */ + attachedClients : {}, + + /** + * Notifies that the client with the given ID has been added or + * removed from the set of clients desired within the current view, + * and the current view should be updated accordingly. + * + * @param {string} id + * The ID of the client that was added or removed from the current + * view. + */ + updateAttachedClients : function updateAttachedClients(id) { + $scope.addRemoveClient(id, !$scope.connectionListContext.attachedClients[id]); + } + + }; + + /** + * Adds or removes the client with the given ID from the set of clients + * within the current view, updating the current URL accordingly. + * + * @param {string} id + * The ID of the client to add or remove from the current view. + * + * @param {boolean} [remove=false] + * Whether the specified client should be added (false) or removed + * (true). + */ + $scope.addRemoveClient = function addRemoveClient(id, remove) { + + // Deconstruct current path into corresponding client IDs + const ids = ManagedClientGroup.getClientIdentifiers($routeParams.id); + + // Add/remove ID as requested + if (remove) + _.pull(ids, id); + else + ids.push(id); + + // Reconstruct path, updating attached clients via change in route + $location.path('/client/' + encodeURIComponent(ManagedClientGroup.getIdentifier(ids))); + + }; + + /** + * Reloads the contents of $scope.clientGroup to reflect the client IDs + * currently listed in the URL. + */ + const reparseRoute = function reparseRoute() { + + const previousClients = $scope.clientGroup ? $scope.clientGroup.clients.slice() : []; + + // Replace existing group with new group + setAttachedGroup(guacClientManager.getManagedClientGroup($routeParams.id)); + + // Store current set of attached clients for later use within the + // Guacamole menu + $scope.connectionListContext.attachedClients = {}; + $scope.clientGroup.clients.forEach((client) => { + $scope.connectionListContext.attachedClients[client.id] = true; + }); + + // Ensure menu is closed if updated view is not a modification of the + // current view (has no clients in common). The menu should remain open + // only while the current view is being modified, not when navigating + // to an entirely different view. + if (_.isEmpty(_.intersection(previousClients, $scope.clientGroup.clients))) + $scope.menu.shown = false; + + // Update newly-attached clients with current contents of clipboard + clipboardService.resyncClipboard(); + + }; + + /** + * Replaces the ManagedClientGroup currently attached to the client + * interface via $scope.clientGroup with the given ManagedClientGroup, + * safely cleaning up after the previous group. If no ManagedClientGroup is + * provided, the existing group is simply removed. + * + * @param {ManagedClientGroup} [managedClientGroup] + * The ManagedClientGroup to attach to the interface, if any. + */ + const setAttachedGroup = function setAttachedGroup(managedClientGroup) { + + if ($scope.clientGroup) { + + // Remove all disconnected clients from management (the user has + // seen their status) + _.filter($scope.clientGroup.clients, client => { + + const connectionState = client.clientState.connectionState; + return connectionState === ManagedClientState.ConnectionState.DISCONNECTED + || connectionState === ManagedClientState.ConnectionState.TUNNEL_ERROR + || connectionState === ManagedClientState.ConnectionState.CLIENT_ERROR; + + }).forEach(client => { + guacClientManager.removeManagedClient(client.id); + }); + + // Flag group as detached + $scope.clientGroup.attached = false; + + } + + if (managedClientGroup) { + $scope.clientGroup = managedClientGroup; + $scope.clientGroup.attached = true; + $scope.clientGroup.lastUsed = new Date().getTime(); + } + + }; + + // Init sets of clients based on current URL ... + reparseRoute(); + + // ... and re-initialize those sets if the URL has changed without + // reloading the route + $scope.$on('$routeUpdate', reparseRoute); /** * The root connection groups of the connection hierarchy that should be @@ -350,16 +374,6 @@ angular.module('client').controller('clientController', ['$scope', '$routeParams */ $scope.sharingProfiles = {}; - /** - * Map of all currently pressed keys by keysym. If a particular key is - * currently pressed, the value stored under that key's keysym within this - * map will be true. All keys not currently pressed will not have entries - * within this map. - * - * @type Object. - */ - var keysCurrentlyPressed = {}; - /** * Map of all substituted key presses. If one key is pressed in place of another * the value of the substituted key is stored in an object with the keysym of @@ -370,134 +384,58 @@ angular.module('client').controller('clientController', ['$scope', '$routeParams var substituteKeysPressed = {}; /** - * Map of all currently pressed keys (by keysym) to the clipboard contents - * received from the remote desktop while those keys were pressed. All keys - * not currently pressed will not have entries within this map. + * Returns whether the shortcut for showing/hiding the Guacamole menu + * (Ctrl+Alt+Shift) has been pressed. * - * @type Object. - */ - var clipboardDataFromKey = {}; - - /* - * Check to see if all currently pressed keys are in the set of menu keys. + * @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. */ - function checkMenuModeActive() { - for(var keysym in keysCurrentlyPressed) { - if(!MENU_KEYS[keysym]) { - return false; - } - } - - return true; - } + const isMenuShortcutPressed = function isMenuShortcutPressed(keyboard) { - // Hide menu when the user swipes from the right - $scope.menuDrag = function menuDrag(inProgress, startX, startY, currentX, currentY, deltaX, deltaY) { + // 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, (val, keysym) => !MENU_KEYS[keysym])) + return false; - // Hide menu if swipe gesture is detected - if (Math.abs(currentY - startY) < MENU_DRAG_VERTICAL_TOLERANCE - && startX - currentX >= MENU_DRAG_DELTA) - $scope.menu.shown = false; - - // Scroll menu by default - else { - $scope.menu.scrollState.left -= deltaX; - $scope.menu.scrollState.top -= deltaY; - } - - return false; + // Verify that one of each required key is held, regardless of + // left/right location on the keyboard + return !!( + _.findKey(SHIFT_KEYS, (val, keysym) => keyboard.pressed[keysym]) + && _.findKey(ALT_KEYS, (val, keysym) => keyboard.pressed[keysym]) + && _.findKey(CTRL_KEYS, (val, keysym) => keyboard.pressed[keysym]) + ); }; - // Update menu or client based on dragging gestures - $scope.clientDrag = function clientDrag(inProgress, startX, startY, currentX, currentY, deltaX, deltaY) { + // Show menu if the user swipes from the left, hide menu when the user + // swipes from the right, scroll menu while visible + $scope.menuDrag = function menuDrag(inProgress, startX, startY, currentX, currentY, deltaX, deltaY) { - // Show menu if the user swipes from the left - if (startX <= MENU_DRAG_MARGIN) { + if ($scope.menu.shown) { + // Hide menu if swipe-from-right gesture is detected + if (Math.abs(currentY - startY) < MENU_DRAG_VERTICAL_TOLERANCE + && startX - currentX >= MENU_DRAG_DELTA) + $scope.menu.shown = false; + + // Scroll menu by default + else { + $scope.menu.scrollState.left -= deltaX; + $scope.menu.scrollState.top -= deltaY; + } + + } + + // Show menu if swipe-from-left gesture is detected + else if (startX <= MENU_DRAG_MARGIN) { if (Math.abs(currentY - startY) < MENU_DRAG_VERTICAL_TOLERANCE && currentX - startX >= MENU_DRAG_DELTA) $scope.menu.shown = true; - } - // Scroll display if absolute mouse is in use - else if ($scope.client.clientProperties.emulateAbsoluteMouse) { - $scope.client.clientProperties.scrollLeft -= deltaX; - $scope.client.clientProperties.scrollTop -= deltaY; - } - - return false; - - }; - - /** - * If a pinch gesture is in progress, the scale of the client display when - * the pinch gesture began. - * - * @type Number - */ - var initialScale = null; - - /** - * If a pinch gesture is in progress, the X coordinate of the point on the - * client display that was centered within the pinch at the time the - * gesture began. - * - * @type Number - */ - var initialCenterX = 0; - - /** - * If a pinch gesture is in progress, the Y coordinate of the point on the - * client display that was centered within the pinch at the time the - * gesture began. - * - * @type Number - */ - var initialCenterY = 0; - - // Zoom and pan client via pinch gestures - $scope.clientPinch = function clientPinch(inProgress, startLength, currentLength, centerX, centerY) { - - // Do not handle pinch gestures if they would conflict with remote - // handling of similar gestures - if ($scope.client.multiTouchSupport > 1) - return false; - - // Do not handle pinch gestures while relative mouse is in use - if (!$scope.client.clientProperties.emulateAbsoluteMouse) - return false; - - // Stop gesture if not in progress - if (!inProgress) { - initialScale = null; - return false; - } - - // Set initial scale if gesture has just started - if (!initialScale) { - initialScale = $scope.client.clientProperties.scale; - initialCenterX = (centerX + $scope.client.clientProperties.scrollLeft) / initialScale; - initialCenterY = (centerY + $scope.client.clientProperties.scrollTop) / initialScale; - } - - // Determine new scale absolutely - var currentScale = initialScale * currentLength / startLength; - - // Fix scale within limits - scroll will be miscalculated otherwise - currentScale = Math.max(currentScale, $scope.client.clientProperties.minScale); - currentScale = Math.min(currentScale, $scope.client.clientProperties.maxScale); - - // Update scale based on pinch distance - $scope.menu.autoFit = false; - $scope.client.clientProperties.autoFit = false; - $scope.client.clientProperties.scale = currentScale; - - // Scroll display to keep original pinch location centered within current pinch - $scope.client.clientProperties.scrollLeft = initialCenterX * currentScale - centerX; - $scope.client.clientProperties.scrollTop = initialCenterY * currentScale - centerY; - return false; }; @@ -513,53 +451,50 @@ angular.module('client').controller('clientController', ['$scope', '$routeParams // Update client state/behavior as visibility of the Guacamole menu changes $scope.$watch('menu.shown', function menuVisibilityChanged(menuShown, menuShownPreviousState) { - - // Send clipboard and argument value data once menu is hidden - if (!menuShown && menuShownPreviousState) { - $scope.$broadcast('guacClipboard', $scope.client.clipboardData); - $scope.applyParameterChanges(); - } - // Obtain snapshot of current editable connection parameters when menu - // is opened - else if (menuShown) - $scope.menu.connectionParameters = ManagedClient.getArgumentModel($scope.client); + // Re-update available connection parameters, if there is a focused + // client (parameter information may not have been available at the + // time focus changed) + if (menuShown) + $scope.menu.connectionParameters = $scope.focusedClient ? + ManagedClient.getArgumentModel($scope.focusedClient) : {}; - // Disable client keyboard if the menu is shown - $scope.client.clientProperties.keyboardEnabled = !menuShown; + // Send any argument value data once menu is hidden + else if (menuShownPreviousState) + $scope.applyParameterChanges($scope.focusedClient); }); - // Update last used timestamp when the active client changes - $scope.$watch('client', function clientChanged(client) { - if (client) - client.lastUsed = new Date().getTime(); + // Automatically track and cache the currently-focused client + $scope.$on('guacClientFocused', function focusedClientChanged(event, newFocusedClient) { + + const oldFocusedClient = $scope.focusedClient; + $scope.focusedClient = newFocusedClient; + + // Apply any parameter changes when focus is changing + if (oldFocusedClient) + $scope.applyParameterChanges(oldFocusedClient); + + // Update available connection parameters, if there is a focused + // client + $scope.menu.connectionParameters = newFocusedClient ? + ManagedClient.getArgumentModel(newFocusedClient) : {}; + }); // Update page icon when thumbnail changes - $scope.$watch('client.thumbnail.canvas', function thumbnailChanged(canvas) { + $scope.$watch('focusedClient.thumbnail.canvas', function thumbnailChanged(canvas) { iconService.setIcons(canvas); }); - // Watch clipboard for new data, associating it with any pressed keys - $scope.$watch('client.clipboardData', function clipboardChanged(data) { - - // Sync local clipboard as long as the menu is not open - if (!$scope.menu.shown) - clipboardService.setLocalClipboard(data)['catch'](angular.noop); - - // Associate new clipboard data with any currently-pressed key - for (var keysym in keysCurrentlyPressed) - clipboardDataFromKey[keysym] = data; - - }); - // Pull sharing profiles once the tunnel UUID is known - $scope.$watch('client.tunnel.uuid', function retrieveSharingProfiles(uuid) { + $scope.$watch('focusedClient.tunnel.uuid', function retrieveSharingProfiles(uuid) { // Only pull sharing profiles if tunnel UUID is actually available - if (!uuid) + if (!uuid) { + $scope.sharingProfiles = {}; return; + } // Pull sharing profiles for the current connection tunnelService.getSharingProfiles(uuid) @@ -578,7 +513,8 @@ angular.module('client').controller('clientController', ['$scope', '$routeParams * The sharing profile to use to generate the sharing link. */ $scope.share = function share(sharingProfile) { - ManagedClient.createShareLink($scope.client, sharingProfile); + if ($scope.focusedClient) + ManagedClient.createShareLink($scope.focusedClient, sharingProfile); }; /** @@ -589,7 +525,7 @@ angular.module('client').controller('clientController', ['$scope', '$routeParams * link, false otherwise. */ $scope.isShared = function isShared() { - return ManagedClient.isShared($scope.client); + return !!$scope.focusedClient && ManagedClient.isShared($scope.focusedClient); }; /** @@ -602,61 +538,62 @@ angular.module('client').controller('clientController', ['$scope', '$routeParams */ $scope.getShareLinkCount = function getShareLinkCount() { + if (!$scope.focusedClient) + return 0; + // Count total number of links within the ManagedClient's share link map var linkCount = 0; - for (var dummy in $scope.client.shareLinks) + for (const dummy in $scope.focusedClient.shareLinks) linkCount++; return linkCount; }; - // Track pressed keys, opening the Guacamole menu after Ctrl+Alt+Shift, or - // send Ctrl-Alt-Delete when Ctrl-Alt-End is pressed. - $scope.$on('guacKeydown', function keydownListener(event, keysym, keyboard) { + // Opening the Guacamole menu after Ctrl+Alt+Shift, preventing those + // keypresses from reaching any Guacamole client + $scope.$on('guacBeforeKeydown', function incomingKeydown(event, keysym, keyboard) { - // Record key as pressed - keysCurrentlyPressed[keysym] = true; + // Toggle menu if menu shortcut (Ctrl+Alt+Shift) is pressed + if (isMenuShortcutPressed(keyboard)) { - var currentKeysPressedKeys = Object.keys(keysCurrentlyPressed); - - // If only menu keys are pressed, and we have one keysym from each group, - // and one of the keys is being released, show the menu. - if (checkMenuModeActive()) { + // Don't send this key event through to the client, and release + // all other keys involved in performing this shortcut + event.preventDefault(); + keyboard.reset(); - // Check that there is a key pressed for each of the required key classes - if (!_.isEmpty(_.pick(SHIFT_KEYS, currentKeysPressedKeys)) && - !_.isEmpty(_.pick(ALT_KEYS, currentKeysPressedKeys)) && - !_.isEmpty(_.pick(CTRL_KEYS, currentKeysPressedKeys)) - ) { - - // Don't send this key event through to the client - event.preventDefault(); - - // Reset the keys pressed - keysCurrentlyPressed = {}; - keyboard.reset(); - - // Toggle the menu - $scope.$apply(function() { - $scope.menu.shown = !$scope.menu.shown; - }); - } + // Toggle the menu + $scope.$apply(function() { + $scope.menu.shown = !$scope.menu.shown; + }); + } + // Prevent all keydown events while menu is open + else if ($scope.menu.shown) + event.preventDefault(); + + }); + + // Prevent all keyup events while menu is open + $scope.$on('guacBeforeKeyup', function incomingKeyup(event, keysym, keyboard) { + if ($scope.menu.shown) + event.preventDefault(); + }); + + // Send Ctrl-Alt-Delete when Ctrl-Alt-End is pressed. + $scope.$on('guacKeydown', function keydownListener(event, keysym, keyboard) { + // If one of the End keys is pressed, and we have a one keysym from each // of Ctrl and Alt groups, send Ctrl-Alt-Delete. - if (END_KEYS[keysym] && - !_.isEmpty(_.pick(ALT_KEYS, currentKeysPressedKeys)) && - !_.isEmpty(_.pick(CTRL_KEYS, currentKeysPressedKeys)) + if (END_KEYS[keysym] + && _.findKey(ALT_KEYS, (val, keysym) => keyboard.pressed[keysym]) + && _.findKey(CTRL_KEYS, (val, keysym) => keyboard.pressed[keysym]) ) { // Don't send this event through to the client. event.preventDefault(); - // Remove the original key press - delete keysCurrentlyPressed[keysym]; - // Record the substituted key press so that it can be // properly dealt with later. substituteKeysPressed[keysym] = DEL_KEY; @@ -667,16 +604,9 @@ angular.module('client').controller('clientController', ['$scope', '$routeParams }); - // Update pressed keys as they are released, synchronizing the clipboard - // with any data that appears to have come from those key presses + // Update pressed keys as they are released $scope.$on('guacKeyup', function keyupListener(event, keysym, keyboard) { - // Sync local clipboard with any clipboard data received while this - // key was pressed (if any) as long as the menu is not open - var clipboardData = clipboardDataFromKey[keysym]; - if (clipboardData && !$scope.menu.shown) - clipboardService.setLocalClipboard(clipboardData)['catch'](angular.noop); - // Deal with substitute key presses if (substituteKeysPressed[keysym]) { event.preventDefault(); @@ -684,44 +614,13 @@ angular.module('client').controller('clientController', ['$scope', '$routeParams delete substituteKeysPressed[keysym]; } - // Mark key as released - else { - delete clipboardDataFromKey[keysym]; - delete keysCurrentlyPressed[keysym]; - } - }); // Update page title when client title changes - $scope.$watch('client.title', function clientTitleChanged(title) { + $scope.$watch('getTitle(clientGroup)', function clientTitleChanged(title) { $scope.page.title = title; }); - /** - * Displays a notification at the end of a Guacamole connection, whether - * that connection is ending normally or due to an error. As the end of - * a Guacamole connection may be due to changes in authentication status, - * this will also implicitly peform a re-authentication attempt to check - * for such changes, possibly resulting in auth-related events like - * guacInvalidCredentials. - * - * @param {Notification|Boolean|Object} status - * The status notification to show, as would be accepted by - * guacNotification.showStatus(). - */ - var notifyConnectionClosed = function notifyConnectionClosed(status) { - - // Re-authenticate to verify auth status at end of connection - authenticationService.updateCurrentToken($location.search()) - ['catch'](requestService.IGNORE) - - // Show the requested status once the authentication check has finished - ['finally'](function authenticationCheckComplete() { - guacNotification.showStatus(status); - }); - - }; - /** * Returns whether the current connection has been flagged as unstable due * to an apparent network disruption. @@ -731,268 +630,45 @@ angular.module('client').controller('clientController', ['$scope', '$routeParams * otherwise. */ $scope.isConnectionUnstable = function isConnectionUnstable() { - return $scope.client && $scope.client.clientState.tunnelUnstable; + return _.findIndex($scope.clientGroup.clients, client => client.clientState.tunnelUnstable) !== -1; }; /** - * Notifies the user that the connection state has changed. - * - * @param {String} connectionState - * The current connection state, as defined by - * ManagedClientState.ConnectionState. - */ - var notifyConnectionState = function notifyConnectionState(connectionState) { - - // Hide any existing status - guacNotification.showStatus(false); - - // Do not display status if status not known - if (!connectionState) - return; - - // Build array of available actions - var actions; - if (NAVIGATE_HOME_ACTION) - actions = [ NAVIGATE_HOME_ACTION, RECONNECT_ACTION, LOGOUT_ACTION ]; - else - actions = [ RECONNECT_ACTION, LOGOUT_ACTION ]; - - // Get any associated status code - var status = $scope.client.clientState.statusCode; - - // Connecting - if (connectionState === ManagedClientState.ConnectionState.CONNECTING - || connectionState === ManagedClientState.ConnectionState.WAITING) { - guacNotification.showStatus({ - title: "CLIENT.DIALOG_HEADER_CONNECTING", - text: { - key : "CLIENT.TEXT_CLIENT_STATUS_" + connectionState.toUpperCase() - } - }); - } - - // Client error - else if (connectionState === ManagedClientState.ConnectionState.CLIENT_ERROR) { - - // Determine translation name of error - var errorName = (status in CLIENT_ERRORS) ? status.toString(16).toUpperCase() : "DEFAULT"; - - // Determine whether the reconnect countdown applies - var countdown = (status in CLIENT_AUTO_RECONNECT) ? RECONNECT_COUNTDOWN : null; - - // Show error status - notifyConnectionClosed({ - className : "error", - title : "CLIENT.DIALOG_HEADER_CONNECTION_ERROR", - text : { - key : "CLIENT.ERROR_CLIENT_" + errorName - }, - countdown : countdown, - actions : actions - }); - - } - - // Tunnel error - else if (connectionState === ManagedClientState.ConnectionState.TUNNEL_ERROR) { - - // Determine translation name of error - var errorName = (status in TUNNEL_ERRORS) ? status.toString(16).toUpperCase() : "DEFAULT"; - - // Determine whether the reconnect countdown applies - var countdown = (status in TUNNEL_AUTO_RECONNECT) ? RECONNECT_COUNTDOWN : null; - - // Show error status - notifyConnectionClosed({ - className : "error", - title : "CLIENT.DIALOG_HEADER_CONNECTION_ERROR", - text : { - key : "CLIENT.ERROR_TUNNEL_" + errorName - }, - countdown : countdown, - actions : actions - }); - - } - - // Disconnected - else if (connectionState === ManagedClientState.ConnectionState.DISCONNECTED) { - notifyConnectionClosed({ - title : "CLIENT.DIALOG_HEADER_DISCONNECTED", - text : { - key : "CLIENT.TEXT_CLIENT_STATUS_" + connectionState.toUpperCase() - }, - actions : actions - }); - } - - // Hide status and sync local clipboard once connected - else if (connectionState === ManagedClientState.ConnectionState.CONNECTED) { - - // Sync with local clipboard - clipboardService.getLocalClipboard().then(function clipboardRead(data) { - $scope.$broadcast('guacClipboard', data); - }, angular.noop); - - // Hide status notification - guacNotification.showStatus(false); - - } - - // Hide status for all other states - else - guacNotification.showStatus(false); - - }; - - /** - * Prompts the user to enter additional connection parameters. If the - * protocol and associated parameters of the underlying connection are not - * yet known, this function has no effect and should be re-invoked once - * the parameters are known. - * - * @param {Object.} requiredParameters - * The set of all parameters requested by the server via "required" - * instructions, where each object key is the name of a requested - * parameter and each value is the current value entered by the user. - */ - var notifyParametersRequired = function notifyParametersRequired(requiredParameters) { - - /** - * Action which submits the current set of parameter values, requesting - * that the connection continue. - */ - var SUBMIT_PARAMETERS = { - name : "CLIENT.ACTION_CONTINUE", - className : "button", - callback : function submitParameters() { - if ($scope.client) { - var params = $scope.client.requiredParameters; - $scope.client.requiredParameters = null; - ManagedClient.sendArguments($scope.client, params); - } - } - }; - - /** - * Action which cancels submission of additional parameters and - * disconnects from the current connection. - */ - var CANCEL_PARAMETER_SUBMISSION = { - name : "CLIENT.ACTION_CANCEL", - className : "button", - callback : function cancelSubmission() { - $scope.client.requiredParameters = null; - $scope.disconnect(); - } - }; - - // Attempt to prompt for parameters only if the parameters that apply - // to the underlying connection are known - if (!$scope.client.protocol || !$scope.client.forms) - return; - - // Hide any existing status - guacNotification.showStatus(false); - - // Prompt for parameters - guacNotification.showStatus({ - formNamespace : Protocol.getNamespace($scope.client.protocol), - forms : $scope.client.forms, - formModel : requiredParameters, - formSubmitCallback : SUBMIT_PARAMETERS.callback, - actions : [ SUBMIT_PARAMETERS, CANCEL_PARAMETER_SUBMISSION ] - }); - - }; - - /** - * Returns whether the given connection state allows for submission of - * connection parameters via "argv" instructions. - * - * @param {String} connectionState - * The connection state to test, as defined by - * ManagedClientState.ConnectionState. - * - * @returns {boolean} - * true if the given connection state allows submission of connection - * parameters via "argv" instructions, false otherwise. - */ - var canSubmitParameters = function canSubmitParameters(connectionState) { - return (connectionState === ManagedClientState.ConnectionState.WAITING || - connectionState === ManagedClientState.ConnectionState.CONNECTED); - }; - - // Show status dialog when connection status changes - $scope.$watchGroup([ - 'client.clientState.connectionState', - 'client.requiredParameters', - 'client.protocol', - 'client.forms' - ], function clientStateChanged(newValues) { - - var connectionState = newValues[0]; - var requiredParameters = newValues[1]; - - // Prompt for parameters only if parameters can actually be submitted - if (requiredParameters && canSubmitParameters(connectionState)) - notifyParametersRequired(requiredParameters); - - // Otherwise, just show general connection state - else - notifyConnectionState(connectionState); - - }); - - $scope.zoomIn = function zoomIn() { - $scope.menu.autoFit = false; - $scope.client.clientProperties.autoFit = false; - $scope.client.clientProperties.scale += 0.1; - }; - - $scope.zoomOut = function zoomOut() { - $scope.client.clientProperties.autoFit = false; - $scope.client.clientProperties.scale -= 0.1; - }; - - /** - * When zoom is manually set by entering a value - * into the controller, this method turns off autoFit, - * both in the menu and the clientProperties. - */ - $scope.zoomSet = function zoomSet() { - $scope.menu.autoFit = false; - $scope.client.clientProperties.autoFit = false; - }; - - $scope.changeAutoFit = function changeAutoFit() { - if ($scope.menu.autoFit && $scope.client.clientProperties.minScale) { - $scope.client.clientProperties.autoFit = true; - } - else { - $scope.client.clientProperties.autoFit = false; - $scope.client.clientProperties.scale = 1; - } - }; - - $scope.autoFitDisabled = function() { - return $scope.client.clientProperties.minZoom >= 1; - }; - - /** - * Immediately disconnects the currently-connected client, if any. + * Immediately disconnects all currently-focused clients, if any. */ $scope.disconnect = function disconnect() { // Disconnect if client is available - if ($scope.client) - $scope.client.client.disconnect(); + if ($scope.clientGroup) { + $scope.clientGroup.clients.forEach(client => { + if (client.clientProperties.focused) + client.client.disconnect(); + }); + } // Hide menu $scope.menu.shown = false; }; + /** + * Disconnects the given ManagedClient, removing it from the current + * view. + * + * @param {ManagedClient} client + * The client to disconnect. + */ + $scope.closeClientTile = function closeClientTile(client) { + + $scope.addRemoveClient(client.id, true); + guacClientManager.removeManagedClient(client.id); + + // Ensure at least one client has focus (the only client with + // focus may just have been removed) + ManagedClientGroup.verifyFocus($scope.clientGroup); + + }; + /** * Action which immediately disconnects the currently-connected client, if * any. @@ -1106,31 +782,27 @@ angular.module('client').controller('clientController', ['$scope', '$routeParams */ $scope.uploadFiles = function uploadFiles(files) { - // Ignore file uploads if no attached client - if (!$scope.client) - return; - // Upload each file for (var i = 0; i < files.length; i++) - ManagedClient.uploadFile($scope.client, files[i], $scope.filesystemMenuContents); + ManagedClient.uploadFile($scope.filesystemMenuContents.client, files[i], $scope.filesystemMenuContents); }; /** - * Determines whether the attached client has associated file transfers, - * regardless of those file transfers' state. + * Determines whether the attached client group has any associated file + * transfers, regardless of those file transfers' state. * * @returns {Boolean} * true if there are any file transfers associated with the - * attached client, false otherise. + * attached client group, false otherise. */ $scope.hasTransfers = function hasTransfers() { - // There are no file transfers if there is no client - if (!$scope.client) + // There are no file transfers if there is no client group + if (!$scope.clientGroup) return false; - return !!$scope.client.uploads.length; + return _.findIndex($scope.clientGroup.clients, ManagedClient.hasTransfers) !== -1; }; @@ -1156,22 +828,7 @@ angular.module('client').controller('clientController', ['$scope', '$routeParams // Clean up when view destroyed $scope.$on('$destroy', function clientViewDestroyed() { - - // Remove client from client manager if no longer connected - var managedClient = $scope.client; - if (managedClient) { - - // Get current connection state - var connectionState = managedClient.clientState.connectionState; - - // If disconnected, remove from management - if (connectionState === ManagedClientState.ConnectionState.DISCONNECTED - || connectionState === ManagedClientState.ConnectionState.TUNNEL_ERROR - || connectionState === ManagedClientState.ConnectionState.CLIENT_ERROR) - guacClientManager.removeManagedClient(managedClient.id); - - } - + setAttachedGroup(null); }); }]); 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 841d39271..717bf6e7b 100644 --- a/guacamole/src/main/frontend/src/app/client/directives/guacClient.js +++ b/guacamole/src/main/frontend/src/app/client/directives/guacClient.js @@ -22,464 +22,600 @@ */ angular.module('client').directive('guacClient', [function guacClient() { - return { - // Element only + const directive = { restrict: 'E', replace: true, - scope: { + templateUrl: 'app/client/templates/guacClient.html' + }; - /** - * The client to display within this guacClient directive. - * - * @type ManagedClient - */ - client : '=' + directive.scope = { + + /** + * The client to display within this guacClient directive. + * + * @type ManagedClient + */ + client : '=', + + /** + * Whether translation of touch to mouse events should emulate an + * absolute pointer device, or a relative pointer device. + * + * @type boolean + */ + emulateAbsoluteMouse : '=' + + }; + + directive.controller = ['$scope', '$injector', '$element', + function guacClientController($scope, $injector, $element) { + + // Required types + const ManagedClient = $injector.get('ManagedClient'); - }, - templateUrl: 'app/client/templates/guacClient.html', - controller: ['$scope', '$injector', '$element', function guacClientController($scope, $injector, $element) { - - // Required types - var ManagedClient = $injector.get('ManagedClient'); - - // Required services - var $window = $injector.get('$window'); - - /** - * Whether the local, hardware mouse cursor is in use. - * - * @type Boolean - */ - var localCursor = false; + // Required services + const $window = $injector.get('$window'); + + /** + * Whether the local, hardware mouse cursor is in use. + * + * @type Boolean + */ + let localCursor = false; - /** - * The current Guacamole client instance. - * - * @type Guacamole.Client - */ - var client = null; + /** + * The current Guacamole client instance. + * + * @type Guacamole.Client + */ + let client = null; - /** - * The display of the current Guacamole client instance. - * - * @type Guacamole.Display - */ - var display = null; + /** + * The display of the current Guacamole client instance. + * + * @type Guacamole.Display + */ + let display = null; - /** - * The element associated with the display of the current - * Guacamole client instance. - * - * @type Element - */ - var displayElement = null; + /** + * The element associated with the display of the current + * Guacamole client instance. + * + * @type Element + */ + let displayElement = null; - /** - * The element which must contain the Guacamole display element. - * - * @type Element - */ - var displayContainer = $element.find('.display')[0]; + /** + * 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 - */ - var main = $element[0]; + /** + * The main containing element for the entire directive. + * + * @type Element + */ + const main = $element[0]; - /** - * The element which functions as a detector for size changes. - * - * @type Element - */ - var resizeSensor = $element.find('.resize-sensor')[0]; + /** + * Guacamole mouse event object, wrapped around the main client + * display. + * + * @type Guacamole.Mouse + */ + const mouse = new Guacamole.Mouse(displayContainer); - /** - * Guacamole mouse event object, wrapped around the main client - * display. - * - * @type Guacamole.Mouse - */ - var mouse = new Guacamole.Mouse(displayContainer); + /** + * Guacamole absolute mouse emulation object, wrapped around the + * main client display. + * + * @type Guacamole.Mouse.Touchscreen + */ + const touchScreen = new Guacamole.Mouse.Touchscreen(displayContainer); - /** - * Guacamole absolute mouse emulation object, wrapped around the - * main client display. - * - * @type Guacamole.Mouse.Touchscreen - */ - var touchScreen = new Guacamole.Mouse.Touchscreen(displayContainer); + /** + * Guacamole relative mouse emulation object, wrapped around the + * main client display. + * + * @type Guacamole.Mouse.Touchpad + */ + const touchPad = new Guacamole.Mouse.Touchpad(displayContainer); - /** - * Guacamole relative mouse emulation object, wrapped around the - * main client display. - * - * @type Guacamole.Mouse.Touchpad - */ - var touchPad = new Guacamole.Mouse.Touchpad(displayContainer); + /** + * Guacamole touch event handling object, wrapped around the main + * client dislay. + * + * @type Guacamole.Touch + */ + const touch = new Guacamole.Touch(displayContainer); - /** - * Guacamole touch event handling object, wrapped around the main - * client dislay. - * - * @type Guacamole.Touch - */ - var touch = new Guacamole.Touch(displayContainer); + /** + * Updates the scale of the attached Guacamole.Client based on current window + * size and "auto-fit" setting. + */ + const updateDisplayScale = function updateDisplayScale() { - /** - * Updates the scale of the attached Guacamole.Client based on current window - * size and "auto-fit" setting. - */ - var updateDisplayScale = function updateDisplayScale() { + if (!display) return; - if (!display) return; + // Calculate scale to fit screen + $scope.client.clientProperties.minScale = Math.min( + main.offsetWidth / Math.max(display.getWidth(), 1), + main.offsetHeight / Math.max(display.getHeight(), 1) + ); - // Calculate scale to fit screen - $scope.client.clientProperties.minScale = Math.min( - main.offsetWidth / Math.max(display.getWidth(), 1), - main.offsetHeight / Math.max(display.getHeight(), 1) - ); + // Calculate appropriate maximum zoom level + $scope.client.clientProperties.maxScale = Math.max($scope.client.clientProperties.minScale, 3); - // Calculate appropriate maximum zoom level - $scope.client.clientProperties.maxScale = Math.max($scope.client.clientProperties.minScale, 3); + // Clamp zoom level, maintain auto-fit + if (display.getScale() < $scope.client.clientProperties.minScale || $scope.client.clientProperties.autoFit) + $scope.client.clientProperties.scale = $scope.client.clientProperties.minScale; - // Clamp zoom level, maintain auto-fit - if (display.getScale() < $scope.client.clientProperties.minScale || $scope.client.clientProperties.autoFit) - $scope.client.clientProperties.scale = $scope.client.clientProperties.minScale; + else if (display.getScale() > $scope.client.clientProperties.maxScale) + $scope.client.clientProperties.scale = $scope.client.clientProperties.maxScale; - else if (display.getScale() > $scope.client.clientProperties.maxScale) - $scope.client.clientProperties.scale = $scope.client.clientProperties.maxScale; + }; + /** + * Scrolls the client view such that the mouse cursor is visible. + * + * @param {Guacamole.Mouse.State} mouseState The current mouse + * state. + */ + const scrollToMouse = function scrollToMouse(mouseState) { + + // Determine mouse position within view + const mouse_view_x = mouseState.x + displayContainer.offsetLeft - main.scrollLeft; + const mouse_view_y = mouseState.y + displayContainer.offsetTop - main.scrollTop; + + // Determine viewport dimensions + const view_width = main.offsetWidth; + const view_height = main.offsetHeight; + + // Determine scroll amounts based on mouse position relative to document + + let scroll_amount_x; + if (mouse_view_x > view_width) + scroll_amount_x = mouse_view_x - view_width; + else if (mouse_view_x < 0) + scroll_amount_x = mouse_view_x; + else + scroll_amount_x = 0; + + let scroll_amount_y; + if (mouse_view_y > view_height) + scroll_amount_y = mouse_view_y - view_height; + else if (mouse_view_y < 0) + scroll_amount_y = mouse_view_y; + else + scroll_amount_y = 0; + + // Scroll (if necessary) to keep mouse on screen. + main.scrollLeft += scroll_amount_x; + main.scrollTop += scroll_amount_y; + + }; + + /** + * Handles a mouse event originating from the user's actual mouse. + * This differs from handleEmulatedMouseEvent() in that the + * software mouse cursor must be shown only if the user's browser + * does not support explicitly setting the hardware mouse cursor. + * + * @param {Guacamole.Mouse.MouseEvent} event + * The mouse event to handle. + */ + const handleMouseEvent = function handleMouseEvent(event) { + + // Do not attempt to handle mouse state changes if the client + // or display are not yet available + if (!client || !display) + return; + + event.stopPropagation(); + event.preventDefault(); + + // Send mouse state, show cursor if necessary + display.showCursor(!localCursor); + client.sendMouseState(event.state, true); + + }; + + /** + * Handles a mouse event originating from one of Guacamole's mouse + * emulation objects. This differs from handleMouseState() in that + * the software mouse cursor must always be shown (as the emulated + * mouse device will not have its own cursor). + * + * @param {Guacamole.Mouse.MouseEvent} event + * The mouse event to handle. + */ + const handleEmulatedMouseEvent = function handleEmulatedMouseEvent(event) { + + // Do not attempt to handle mouse state changes if the client + // or display are not yet available + if (!client || !display) + return; + + event.stopPropagation(); + event.preventDefault(); + + // Ensure software cursor is shown + display.showCursor(true); + + // Send mouse state, ensure cursor is visible + scrollToMouse(event.state); + client.sendMouseState(event.state, true); + + }; + + /** + * Handles a touch event originating from the user's device. + * + * @param {Guacamole.Touch.Event} touchEvent + * The touch event. + */ + const handleTouchEvent = function handleTouchEvent(event) { + + // Do not attempt to handle touch state changes if the client + // or display are not yet available + if (!client || !display) + return; + + event.preventDefault(); + + // Send touch state, hiding local cursor + display.showCursor(false); + client.sendTouchState(event.state, true); + + }; + + // Attach any given managed client + $scope.$watch('client', function attachManagedClient(managedClient) { + + // Remove any existing display + displayContainer.innerHTML = ""; + + // Only proceed if a client is given + if (!managedClient) + return; + + // Get Guacamole client instance + client = managedClient.client; + + // Attach possibly new display + display = client.getDisplay(); + display.scale($scope.client.clientProperties.scale); + + // Add display element + displayElement = display.getElement(); + displayContainer.appendChild(displayElement); + + // Do nothing when the display element is clicked on + display.getElement().onclick = function(e) { + e.preventDefault(); + return false; }; - /** - * Scrolls the client view such that the mouse cursor is visible. - * - * @param {Guacamole.Mouse.State} mouseState The current mouse - * state. - */ - var scrollToMouse = function scrollToMouse(mouseState) { + // Connect and update interface to match required size, deferring + // connecting until a future element resize if the main element + // size (desired display size) is not known and thus can't be sent + // during the handshake + $scope.mainElementResized(); - // Determine mouse position within view - var mouse_view_x = mouseState.x + displayContainer.offsetLeft - main.scrollLeft; - var mouse_view_y = mouseState.y + displayContainer.offsetTop - main.scrollTop; + }); - // Determine viewport dimensions - var view_width = main.offsetWidth; - var view_height = main.offsetHeight; + // Update actual view scrollLeft when scroll properties change + $scope.$watch('client.clientProperties.scrollLeft', function scrollLeftChanged(scrollLeft) { + main.scrollLeft = scrollLeft; + $scope.client.clientProperties.scrollLeft = main.scrollLeft; + }); - // Determine scroll amounts based on mouse position relative to document + // Update actual view scrollTop when scroll properties change + $scope.$watch('client.clientProperties.scrollTop', function scrollTopChanged(scrollTop) { + main.scrollTop = scrollTop; + $scope.client.clientProperties.scrollTop = main.scrollTop; + }); - var scroll_amount_x; - if (mouse_view_x > view_width) - scroll_amount_x = mouse_view_x - view_width; - else if (mouse_view_x < 0) - scroll_amount_x = mouse_view_x; - else - scroll_amount_x = 0; + // Update scale when display is resized + $scope.$watch('client.managedDisplay.size', function setDisplaySize() { + $scope.$evalAsync(updateDisplayScale); + }); - var scroll_amount_y; - if (mouse_view_y > view_height) - scroll_amount_y = mouse_view_y - view_height; - else if (mouse_view_y < 0) - scroll_amount_y = mouse_view_y; - else - scroll_amount_y = 0; + // Keep local cursor up-to-date + $scope.$watch('client.managedDisplay.cursor', function setCursor(cursor) { + if (cursor) + localCursor = mouse.setCursor(cursor.canvas, cursor.x, cursor.y); + }); - // Scroll (if necessary) to keep mouse on screen. - main.scrollLeft += scroll_amount_x; - main.scrollTop += scroll_amount_y; + // Update touch event handling depending on remote multi-touch + // support and mouse emulation mode + $scope.$watchGroup([ + 'client.multiTouchSupport', + 'emulateAbsoluteMouse' + ], function touchBehaviorChanged() { - }; + // Clear existing event handling + touch.offEach(['touchstart', 'touchmove', 'touchend'], handleTouchEvent); + touchScreen.offEach(['mousedown', 'mousemove', 'mouseup'], handleEmulatedMouseEvent); + touchPad.offEach(['mousedown', 'mousemove', 'mouseup'], handleEmulatedMouseEvent); - /** - * Handles a mouse event originating from the user's actual mouse. - * This differs from handleEmulatedMouseEvent() in that the - * software mouse cursor must be shown only if the user's browser - * does not support explicitly setting the hardware mouse cursor. - * - * @param {Guacamole.Mouse.MouseEvent} event - * The mouse event to handle. - */ - var handleMouseEvent = function handleMouseEvent(event) { + // Directly forward local touch events + if ($scope.client.multiTouchSupport) + touch.onEach(['touchstart', 'touchmove', 'touchend'], handleTouchEvent); - // Do not attempt to handle mouse state changes if the client - // or display are not yet available - if (!client || !display) - return; + // Switch to touchscreen if mouse emulation is required and + // absolute mouse emulation is preferred + else if ($scope.emulateAbsoluteMouse) + touchScreen.onEach(['mousedown', 'mousemove', 'mouseup'], handleEmulatedMouseEvent); - event.stopPropagation(); - event.preventDefault(); + // Use touchpad for mouse emulation if absolute mouse emulation + // is not preferred + else + touchPad.onEach(['mousedown', 'mousemove', 'mouseup'], handleEmulatedMouseEvent); - // Send mouse state, show cursor if necessary - display.showCursor(!localCursor); - client.sendMouseState(event.state, true); + }); - }; + // Adjust scale if modified externally + $scope.$watch('client.clientProperties.scale', function changeScale(scale) { - /** - * Handles a mouse event originating from one of Guacamole's mouse - * emulation objects. This differs from handleMouseState() in that - * the software mouse cursor must always be shown (as the emulated - * mouse device will not have its own cursor). - * - * @param {Guacamole.Mouse.MouseEvent} event - * The mouse event to handle. - */ - var handleEmulatedMouseEvent = function handleEmulatedMouseEvent(event) { + // Fix scale within limits + scale = Math.max(scale, $scope.client.clientProperties.minScale); + scale = Math.min(scale, $scope.client.clientProperties.maxScale); - // Do not attempt to handle mouse state changes if the client - // or display are not yet available - if (!client || !display) - return; + // If at minimum zoom level, hide scroll bars + if (scale === $scope.client.clientProperties.minScale) + main.style.overflow = "hidden"; - event.stopPropagation(); - event.preventDefault(); + // If not at minimum zoom level, show scroll bars + else + main.style.overflow = "auto"; - // Ensure software cursor is shown - display.showCursor(true); - - // Send mouse state, ensure cursor is visible - scrollToMouse(event.state); - client.sendMouseState(event.state, true); - - }; - - /** - * Handles a touch event originating from the user's device. - * - * @param {Guacamole.Touch.Event} touchEvent - * The touch event. - */ - var handleTouchEvent = function handleTouchEvent(event) { - - // Do not attempt to handle touch state changes if the client - // or display are not yet available - if (!client || !display) - return; - - event.preventDefault(); - - // Send touch state, hiding local cursor - display.showCursor(false); - client.sendTouchState(event.state, true); - - }; - - // Attach any given managed client - $scope.$watch('client', function attachManagedClient(managedClient) { - - // Remove any existing display - displayContainer.innerHTML = ""; - - // Only proceed if a client is given - if (!managedClient) - return; - - // Get Guacamole client instance - client = managedClient.client; - - // Attach possibly new display - display = client.getDisplay(); - display.scale($scope.client.clientProperties.scale); - - // Add display element - displayElement = display.getElement(); - displayContainer.appendChild(displayElement); - - // Do nothing when the display element is clicked on - display.getElement().onclick = function(e) { - e.preventDefault(); - return false; - }; - - // Size of newly-attached client may be different - $scope.mainElementResized(); - - }); - - // Update actual view scrollLeft when scroll properties change - $scope.$watch('client.clientProperties.scrollLeft', function scrollLeftChanged(scrollLeft) { - main.scrollLeft = scrollLeft; - $scope.client.clientProperties.scrollLeft = main.scrollLeft; - }); - - // Update actual view scrollTop when scroll properties change - $scope.$watch('client.clientProperties.scrollTop', function scrollTopChanged(scrollTop) { - main.scrollTop = scrollTop; - $scope.client.clientProperties.scrollTop = main.scrollTop; - }); - - // Update scale when display is resized - $scope.$watch('client.managedDisplay.size', function setDisplaySize() { - $scope.$evalAsync(updateDisplayScale); - }); - - // Keep local cursor up-to-date - $scope.$watch('client.managedDisplay.cursor', function setCursor(cursor) { - if (cursor) - localCursor = mouse.setCursor(cursor.canvas, cursor.x, cursor.y); - }); - - // Update touch event handling depending on remote multi-touch - // support and mouse emulation mode - $scope.$watchGroup([ - 'client.multiTouchSupport', - 'client.clientProperties.emulateAbsoluteMouse' - ], function touchBehaviorChanged(emulateAbsoluteMouse) { - - // Clear existing event handling - touch.offEach(['touchstart', 'touchmove', 'touchend'], handleTouchEvent); - touchScreen.offEach(['mousedown', 'mousemove', 'mouseup'], handleEmulatedMouseEvent); - touchPad.offEach(['mousedown', 'mousemove', 'mouseup'], handleEmulatedMouseEvent); - - // Directly forward local touch events - if ($scope.client.multiTouchSupport) - touch.onEach(['touchstart', 'touchmove', 'touchend'], handleTouchEvent); - - // Switch to touchscreen if mouse emulation is required and - // absolute mouse emulation is preferred - else if ($scope.client.clientProperties.emulateAbsoluteMouse) - touchScreen.onEach(['mousedown', 'mousemove', 'mouseup'], handleEmulatedMouseEvent); - - // Use touchpad for mouse emulation if absolute mouse emulation - // is not preferred - else - touchPad.onEach(['mousedown', 'mousemove', 'mouseup'], handleEmulatedMouseEvent); - - }); - - // Adjust scale if modified externally - $scope.$watch('client.clientProperties.scale', function changeScale(scale) { - - // Fix scale within limits - scale = Math.max(scale, $scope.client.clientProperties.minScale); - scale = Math.min(scale, $scope.client.clientProperties.maxScale); - - // If at minimum zoom level, hide scroll bars - if (scale === $scope.client.clientProperties.minScale) - main.style.overflow = "hidden"; - - // If not at minimum zoom level, show scroll bars - else - main.style.overflow = "auto"; - - // Apply scale if client attached - if (display) - display.scale(scale); - - if (scale !== $scope.client.clientProperties.scale) - $scope.client.clientProperties.scale = scale; - - }); + // Apply scale if client attached + if (display) + display.scale(scale); - // If autofit is set, the scale should be set to the minimum scale, filling the screen - $scope.$watch('client.clientProperties.autoFit', function changeAutoFit(autoFit) { - if(autoFit) - $scope.client.clientProperties.scale = $scope.client.clientProperties.minScale; - }); - - // If the element is resized, attempt to resize client - $scope.mainElementResized = function mainElementResized() { + if (scale !== $scope.client.clientProperties.scale) + $scope.client.clientProperties.scale = scale; - // Send new display size, if changed - if (client && display) { + }); + + // If autofit is set, the scale should be set to the minimum scale, filling the screen + $scope.$watch('client.clientProperties.autoFit', function changeAutoFit(autoFit) { + if(autoFit) + $scope.client.clientProperties.scale = $scope.client.clientProperties.minScale; + }); - var pixelDensity = $window.devicePixelRatio || 1; - var width = main.offsetWidth * pixelDensity; - var height = main.offsetHeight * pixelDensity; + /** + * 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. + */ + $scope.mainElementResized = function mainElementResized() { - if (display.getWidth() !== width || display.getHeight() !== height) - client.sendSize(width, height); + // Send new display size, if changed + if (client && display && main.offsetWidth && main.offsetHeight) { - } + // Connect, if not already connected + ManagedClient.connect($scope.client, main.offsetWidth, main.offsetHeight); - $scope.$evalAsync(updateDisplayScale); + const pixelDensity = $window.devicePixelRatio || 1; + const width = main.offsetWidth * pixelDensity; + const height = main.offsetHeight * pixelDensity; - }; + if (display.getWidth() !== width || display.getHeight() !== height) + client.sendSize(width, height); - // Ensure focus is regained via mousedown before forwarding event - mouse.on('mousedown', document.body.focus.bind(document.body)); - - // Forward all mouse events - mouse.onEach(['mousedown', 'mousemove', 'mouseup'], handleMouseEvent); - - // Hide software cursor when mouse leaves display - mouse.on('mouseout', function() { - if (!display) return; - display.showCursor(false); - }); - - // Update remote clipboard if local clipboard changes - $scope.$on('guacClipboard', function onClipboard(event, data) { - if (client) { - ManagedClient.setClipboard($scope.client, data); - $scope.client.clipboardData = data; - } - }); - - // Translate local keydown events to remote keydown events if keyboard is enabled - $scope.$on('guacKeydown', function keydownListener(event, keysym, keyboard) { - if ($scope.client.clientProperties.keyboardEnabled && !event.defaultPrevented) { - client.sendKeyEvent(1, keysym); - event.preventDefault(); - } - }); - - // Translate local keyup events to remote keyup events if keyboard is enabled - $scope.$on('guacKeyup', function keyupListener(event, keysym, keyboard) { - if ($scope.client.clientProperties.keyboardEnabled && !event.defaultPrevented) { - client.sendKeyEvent(0, keysym); - event.preventDefault(); - } - }); - - // Universally handle all synthetic keydown events - $scope.$on('guacSyntheticKeydown', function syntheticKeydownListener(event, keysym) { - client.sendKeyEvent(1, keysym); - }); - - // Universally handle all synthetic keyup events - $scope.$on('guacSyntheticKeyup', function syntheticKeyupListener(event, keysym) { - client.sendKeyEvent(0, keysym); - }); - - /** - * Ignores the given event. - * - * @param {Event} e The event to ignore. - */ - function ignoreEvent(e) { - e.preventDefault(); - e.stopPropagation(); } - // Handle and ignore dragenter/dragover - displayContainer.addEventListener("dragenter", ignoreEvent, false); - displayContainer.addEventListener("dragover", ignoreEvent, false); + $scope.$evalAsync(updateDisplayScale); - // File drop event handler - displayContainer.addEventListener("drop", function(e) { + }; - e.preventDefault(); - e.stopPropagation(); + // Scroll client display if absolute mouse is in use (the same drag + // gesture is needed for moving the mouse pointer with relative mouse) + $scope.clientDrag = function clientDrag(inProgress, startX, startY, currentX, currentY, deltaX, deltaY) { - // Ignore file drops if no attached client - if (!$scope.client) - return; + if ($scope.emulateAbsoluteMouse) { + $scope.client.clientProperties.scrollLeft -= deltaX; + $scope.client.clientProperties.scrollTop -= deltaY; + } - // Upload each file - var files = e.dataTransfer.files; - for (var i=0; i 1) + return false; + + // Do not handle pinch gestures while relative mouse is in use (2+ + // contact point gestures are used by relative mouse emulation to + // support right click, middle click, and scrolling) + if (!$scope.emulateAbsoluteMouse) + return false; + + // Stop gesture if not in progress + if (!inProgress) { + initialScale = null; + return false; + } + + // Set initial scale if gesture has just started + if (!initialScale) { + initialScale = $scope.client.clientProperties.scale; + initialCenterX = (centerX + $scope.client.clientProperties.scrollLeft) / initialScale; + initialCenterY = (centerY + $scope.client.clientProperties.scrollTop) / initialScale; + } + + // Determine new scale absolutely + let currentScale = initialScale * currentLength / startLength; + + // Fix scale within limits - scroll will be miscalculated otherwise + currentScale = Math.max(currentScale, $scope.client.clientProperties.minScale); + currentScale = Math.min(currentScale, $scope.client.clientProperties.maxScale); + + // Update scale based on pinch distance + $scope.client.clientProperties.autoFit = false; + $scope.client.clientProperties.scale = currentScale; + + // Scroll display to keep original pinch location centered within current pinch + $scope.client.clientProperties.scrollLeft = initialCenterX * currentScale - centerX; + $scope.client.clientProperties.scrollTop = initialCenterY * currentScale - centerY; + + return false; + + }; + + // Ensure focus is regained via mousedown before forwarding event + mouse.on('mousedown', document.body.focus.bind(document.body)); + + // Forward all mouse events + mouse.onEach(['mousedown', 'mousemove', 'mouseup'], handleMouseEvent); + + // Hide software cursor when mouse leaves display + mouse.on('mouseout', function() { + if (!display) return; + display.showCursor(false); + }); + + // Update remote clipboard if local clipboard changes + $scope.$on('guacClipboard', function onClipboard(event, data) { + ManagedClient.setClipboard($scope.client, data); + }); + + // Translate local keydown events to remote keydown events if keyboard is enabled + $scope.$on('guacKeydown', function keydownListener(event, keysym, keyboard) { + if ($scope.client.clientProperties.focused) { + client.sendKeyEvent(1, keysym); + event.preventDefault(); + } + }); + + // Translate local keyup events to remote keyup events if keyboard is enabled + $scope.$on('guacKeyup', function keyupListener(event, keysym, keyboard) { + if ($scope.client.clientProperties.focused) { + client.sendKeyEvent(0, keysym); + event.preventDefault(); + } + }); + + // Universally handle all synthetic keydown events + $scope.$on('guacSyntheticKeydown', function syntheticKeydownListener(event, keysym) { + if ($scope.client.clientProperties.focused) + client.sendKeyEvent(1, keysym); + }); + + // Universally handle all synthetic keyup events + $scope.$on('guacSyntheticKeyup', function syntheticKeyupListener(event, keysym) { + if ($scope.client.clientProperties.focused) + client.sendKeyEvent(0, keysym); + }); + + /** + * Whether a drag/drop operation is currently in progress (the user has + * dragged a file over the Guacamole connection but has not yet + * dropped it). + * + * @type boolean + */ + $scope.dropPending = false; + + /** + * Displays a visual indication that dropping the file currently + * being dragged is possible. Further propogation and default behavior + * of the given event is automatically prevented. + * + * @param {Event} e + * The event related to the in-progress drag/drop operation. + */ + const notifyDragStart = function notifyDragStart(e) { + + e.preventDefault(); + e.stopPropagation(); + + $scope.$apply(() => { + $scope.dropPending = true; + }); + + }; + + /** + * Removes the visual indication that dropping the file currently + * being dragged is possible. Further propogation and default behavior + * of the given event is automatically prevented. + * + * @param {Event} e + * The event related to the end of the former drag/drop operation. + */ + const notifyDragEnd = function notifyDragEnd(e) { + + e.preventDefault(); + e.stopPropagation(); + + $scope.$apply(() => { + $scope.dropPending = false; + }); + + }; + + main.addEventListener('dragenter', notifyDragStart, false); + main.addEventListener('dragover', notifyDragStart, false); + main.addEventListener('dragleave', notifyDragEnd, false); + + // File drop event handler + main.addEventListener('drop', function(e) { + + notifyDragEnd(e); + + // Ignore file drops if no attached client + if (!$scope.client) + return; + + // Upload each file + const files = e.dataTransfer.files; + for (let i = 0; i < files.length; i++) + ManagedClient.uploadFile($scope.client, files[i]); + + }, false); + + }]; + + return directive; - /* - * END CLIENT DIRECTIVE - */ - - }] - }; }]); diff --git a/guacamole/src/main/frontend/src/app/client/directives/guacClientNotification.js b/guacamole/src/main/frontend/src/app/client/directives/guacClientNotification.js new file mode 100644 index 000000000..fadeab4cb --- /dev/null +++ b/guacamole/src/main/frontend/src/app/client/directives/guacClientNotification.js @@ -0,0 +1,431 @@ +/* + * 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 displaying a non-global notification describing the status + * of a specific Guacamole client, including prompts for any information + * necessary to continue the connection. + */ +angular.module('client').directive('guacClientNotification', [function guacClientNotification() { + + const directive = { + restrict: 'E', + replace: true, + templateUrl: 'app/client/templates/guacClientNotification.html' + }; + + directive.scope = { + + /** + * The client whose status should be displayed. + * + * @type ManagedClient + */ + client : '=' + + }; + + directive.controller = ['$scope', '$injector', '$element', + function guacClientNotificationController($scope, $injector, $element) { + + // Required types + const ManagedClient = $injector.get('ManagedClient'); + const ManagedClientState = $injector.get('ManagedClientState'); + const Protocol = $injector.get('Protocol'); + + // Required services + const $location = $injector.get('$location'); + const authenticationService = $injector.get('authenticationService'); + const guacClientManager = $injector.get('guacClientManager'); + const requestService = $injector.get('requestService'); + const userPageService = $injector.get('userPageService'); + + /** + * A Notification object describing the client status to display as a + * dialog or prompt, as would be accepted by guacNotification.showStatus(), + * or false if no status should be shown. + * + * @type {Notification|Object|Boolean} + */ + $scope.status = false; + + /** + * All client error codes handled and passed off for translation. Any error + * code not present in this list will be represented by the "DEFAULT" + * translation. + */ + const CLIENT_ERRORS = { + 0x0201: true, + 0x0202: true, + 0x0203: true, + 0x0207: true, + 0x0208: true, + 0x0209: true, + 0x020A: true, + 0x020B: true, + 0x0301: true, + 0x0303: true, + 0x0308: true, + 0x031D: true + }; + + /** + * All error codes for which automatic reconnection is appropriate when a + * client error occurs. + */ + const CLIENT_AUTO_RECONNECT = { + 0x0200: true, + 0x0202: true, + 0x0203: true, + 0x0207: true, + 0x0208: true, + 0x0301: true, + 0x0308: true + }; + + /** + * All tunnel error codes handled and passed off for translation. Any error + * code not present in this list will be represented by the "DEFAULT" + * translation. + */ + const TUNNEL_ERRORS = { + 0x0201: true, + 0x0202: true, + 0x0203: true, + 0x0204: true, + 0x0205: true, + 0x0207: true, + 0x0208: true, + 0x0301: true, + 0x0303: true, + 0x0308: true, + 0x031D: true + }; + + /** + * All error codes for which automatic reconnection is appropriate when a + * tunnel error occurs. + */ + const TUNNEL_AUTO_RECONNECT = { + 0x0200: true, + 0x0202: true, + 0x0203: true, + 0x0207: true, + 0x0208: true, + 0x0308: true + }; + + /** + * Action which logs out from Guacamole entirely. + */ + const LOGOUT_ACTION = { + name : "CLIENT.ACTION_LOGOUT", + className : "logout button", + callback : function logoutCallback() { + authenticationService.logout() + ['catch'](requestService.IGNORE); + } + }; + + /** + * Action which returns the user to the home screen. If the home page has + * not yet been determined, this will be null. + */ + let NAVIGATE_HOME_ACTION = null; + + // Assign home page action once user's home page has been determined + userPageService.getHomePage() + .then(function homePageRetrieved(homePage) { + + // Define home action only if different from current location + if ($location.path() !== homePage.url) { + NAVIGATE_HOME_ACTION = { + name : "CLIENT.ACTION_NAVIGATE_HOME", + className : "home button", + callback : function navigateHomeCallback() { + $location.url(homePage.url); + } + }; + } + + }, requestService.WARN); + + /** + * Action which replaces the current client with a newly-connected client. + */ + const RECONNECT_ACTION = { + name : "CLIENT.ACTION_RECONNECT", + className : "reconnect button", + callback : function reconnectCallback() { + $scope.client = guacClientManager.replaceManagedClient($scope.client.id); + $scope.status = false; + } + }; + + /** + * The reconnect countdown to display if an error or status warrants an + * automatic, timed reconnect. + */ + const RECONNECT_COUNTDOWN = { + text: "CLIENT.TEXT_RECONNECT_COUNTDOWN", + callback: RECONNECT_ACTION.callback, + remaining: 15 + }; + + /** + * Displays a notification at the end of a Guacamole connection, whether + * that connection is ending normally or due to an error. As the end of + * a Guacamole connection may be due to changes in authentication status, + * this will also implicitly peform a re-authentication attempt to check + * for such changes, possibly resulting in auth-related events like + * guacInvalidCredentials. + * + * @param {Notification|Boolean|Object} status + * The status notification to show, as would be accepted by + * guacNotification.showStatus(). + */ + const notifyConnectionClosed = function notifyConnectionClosed(status) { + + // Re-authenticate to verify auth status at end of connection + authenticationService.updateCurrentToken($location.search()) + ['catch'](requestService.IGNORE) + + // Show the requested status once the authentication check has finished + ['finally'](function authenticationCheckComplete() { + $scope.status = status; + }); + + }; + + /** + * Notifies the user that the connection state has changed. + * + * @param {String} connectionState + * The current connection state, as defined by + * ManagedClientState.ConnectionState. + */ + const notifyConnectionState = function notifyConnectionState(connectionState) { + + // Hide any existing status + $scope.status = false; + + // Do not display status if status not known + if (!connectionState) + return; + + // Build array of available actions + let actions; + if (NAVIGATE_HOME_ACTION) + actions = [ NAVIGATE_HOME_ACTION, RECONNECT_ACTION, LOGOUT_ACTION ]; + else + actions = [ RECONNECT_ACTION, LOGOUT_ACTION ]; + + // Get any associated status code + const status = $scope.client.clientState.statusCode; + + // Connecting + if (connectionState === ManagedClientState.ConnectionState.CONNECTING + || connectionState === ManagedClientState.ConnectionState.WAITING) { + $scope.status = { + title: "CLIENT.DIALOG_HEADER_CONNECTING", + text: { + key : "CLIENT.TEXT_CLIENT_STATUS_" + connectionState.toUpperCase() + } + }; + } + + // Client error + else if (connectionState === ManagedClientState.ConnectionState.CLIENT_ERROR) { + + // Determine translation name of error + const errorName = (status in CLIENT_ERRORS) ? status.toString(16).toUpperCase() : "DEFAULT"; + + // Determine whether the reconnect countdown applies + const countdown = (status in CLIENT_AUTO_RECONNECT) ? RECONNECT_COUNTDOWN : null; + + // Show error status + notifyConnectionClosed({ + className : "error", + title : "CLIENT.DIALOG_HEADER_CONNECTION_ERROR", + text : { + key : "CLIENT.ERROR_CLIENT_" + errorName + }, + countdown : countdown, + actions : actions + }); + + } + + // Tunnel error + else if (connectionState === ManagedClientState.ConnectionState.TUNNEL_ERROR) { + + // Determine translation name of error + const errorName = (status in TUNNEL_ERRORS) ? status.toString(16).toUpperCase() : "DEFAULT"; + + // Determine whether the reconnect countdown applies + const countdown = (status in TUNNEL_AUTO_RECONNECT) ? RECONNECT_COUNTDOWN : null; + + // Show error status + notifyConnectionClosed({ + className : "error", + title : "CLIENT.DIALOG_HEADER_CONNECTION_ERROR", + text : { + key : "CLIENT.ERROR_TUNNEL_" + errorName + }, + countdown : countdown, + actions : actions + }); + + } + + // Disconnected + else if (connectionState === ManagedClientState.ConnectionState.DISCONNECTED) { + notifyConnectionClosed({ + title : "CLIENT.DIALOG_HEADER_DISCONNECTED", + text : { + key : "CLIENT.TEXT_CLIENT_STATUS_" + connectionState.toUpperCase() + }, + actions : actions + }); + } + + // Hide status for all other states + else + $scope.status = false; + + }; + + /** + * Prompts the user to enter additional connection parameters. If the + * protocol and associated parameters of the underlying connection are not + * yet known, this function has no effect and should be re-invoked once + * the parameters are known. + * + * @param {Object.} requiredParameters + * The set of all parameters requested by the server via "required" + * instructions, where each object key is the name of a requested + * parameter and each value is the current value entered by the user. + */ + const notifyParametersRequired = function notifyParametersRequired(requiredParameters) { + + /** + * Action which submits the current set of parameter values, requesting + * that the connection continue. + */ + const SUBMIT_PARAMETERS = { + name : "CLIENT.ACTION_CONTINUE", + className : "button", + callback : function submitParameters() { + if ($scope.client) { + const params = $scope.client.requiredParameters; + $scope.client.requiredParameters = null; + ManagedClient.sendArguments($scope.client, params); + } + } + }; + + /** + * Action which cancels submission of additional parameters and + * disconnects from the current connection. + */ + const CANCEL_PARAMETER_SUBMISSION = { + name : "CLIENT.ACTION_CANCEL", + className : "button", + callback : function cancelSubmission() { + $scope.client.requiredParameters = null; + $scope.client.client.disconnect(); + } + }; + + // Attempt to prompt for parameters only if the parameters that apply + // to the underlying connection are known + if (!$scope.client.protocol || !$scope.client.forms) + return; + + // Prompt for parameters + $scope.status = { + formNamespace : Protocol.getNamespace($scope.client.protocol), + forms : $scope.client.forms, + formModel : requiredParameters, + formSubmitCallback : SUBMIT_PARAMETERS.callback, + actions : [ SUBMIT_PARAMETERS, CANCEL_PARAMETER_SUBMISSION ] + }; + + }; + + /** + * Returns whether the given connection state allows for submission of + * connection parameters via "argv" instructions. + * + * @param {String} connectionState + * The connection state to test, as defined by + * ManagedClientState.ConnectionState. + * + * @returns {boolean} + * true if the given connection state allows submission of connection + * parameters via "argv" instructions, false otherwise. + */ + const canSubmitParameters = function canSubmitParameters(connectionState) { + return (connectionState === ManagedClientState.ConnectionState.WAITING || + connectionState === ManagedClientState.ConnectionState.CONNECTED); + }; + + // Show status dialog when connection status changes + $scope.$watchGroup([ + 'client.clientState.connectionState', + 'client.requiredParameters', + 'client.protocol', + 'client.forms' + ], function clientStateChanged(newValues) { + + const connectionState = newValues[0]; + const requiredParameters = newValues[1]; + + // Prompt for parameters only if parameters can actually be submitted + if (requiredParameters && canSubmitParameters(connectionState)) + notifyParametersRequired(requiredParameters); + + // Otherwise, just show general connection state + else + notifyConnectionState(connectionState); + + }); + + /** + * Prevents the default behavior of the given AngularJS event if a + * notification is currently shown and the client is focused. + * + * @param {event} e + * The AngularJS event to selectively prevent. + */ + const preventDefaultDuringNotification = function preventDefaultDuringNotification(e) { + if ($scope.status && $scope.client.clientProperties.focused) + e.preventDefault(); + }; + + // Block internal handling of key events (by the client) if a + // notification is visible + $scope.$on('guacBeforeKeydown', preventDefaultDuringNotification); + $scope.$on('guacBeforeKeyup', preventDefaultDuringNotification); + + }]; + + return directive; + +}]); diff --git a/guacamole/src/main/frontend/src/app/client/directives/guacClientPanel.js b/guacamole/src/main/frontend/src/app/client/directives/guacClientPanel.js index 658be2fdd..efd23932d 100644 --- a/guacamole/src/main/frontend/src/app/client/directives/guacClientPanel.js +++ b/guacamole/src/main/frontend/src/app/client/directives/guacClientPanel.js @@ -25,11 +25,12 @@ angular.module('client').directive('guacClientPanel', ['$injector', function guacClientPanel($injector) { // Required services - var guacClientManager = $injector.get('guacClientManager'); - var sessionStorageFactory = $injector.get('sessionStorageFactory'); + const guacClientManager = $injector.get('guacClientManager'); + const sessionStorageFactory = $injector.get('sessionStorageFactory'); // Required types - var ManagedClientState = $injector.get('ManagedClientState'); + const ManagedClientGroup = $injector.get('ManagedClientGroup'); + const ManagedClientState = $injector.get('ManagedClientState'); /** * Getter/setter for the boolean flag controlling whether the client panel @@ -49,12 +50,12 @@ angular.module('client').directive('guacClientPanel', ['$injector', function gua scope: { /** - * The ManagedClient instances associated with the active + * The ManagedClientGroup instances associated with the active * connections to be displayed within this panel. * - * @type ManagedClient[]|Object. + * @type ManagedClientGroup[] */ - clients : '=' + clientGroups : '=' }, templateUrl: 'app/client/templates/guacClientPanel.html', @@ -75,71 +76,68 @@ angular.module('client').directive('guacClientPanel', ['$injector', function gua $scope.panelHidden = panelHidden; /** - * Returns whether this panel currently has any clients associated - * with it. + * Returns whether this panel currently has any client groups + * associated with it. * * @return {Boolean} - * true if at least one client is associated with this panel, - * false otherwise. + * true if at least one client group is associated with this + * panel, false otherwise. */ - $scope.hasClients = function hasClients() { - return !!_.find($scope.clients, $scope.isManaged); + $scope.hasClientGroups = function hasClientGroups() { + return $scope.clientGroups && $scope.clientGroups.length; }; /** - * Returns whether the status of the given client has changed in a - * way that requires the user's attention. This may be due to an - * error, or due to a server-initiated disconnect. + * @borrows ManagedClientGroup.getIdentifier + */ + $scope.getIdentifier = ManagedClientGroup.getIdentifier; + + /** + * @borrows ManagedClientGroup.getTitle + */ + $scope.getTitle = ManagedClientGroup.getTitle; + + /** + * Returns whether the status of any client within the given client + * group has changed in a way that requires the user's attention. + * This may be due to an error, or due to a server-initiated + * disconnect. * - * @param {ManagedClient} client - * The client to test. + * @param {ManagedClientGroup} clientGroup + * The client group to test. * * @returns {Boolean} * true if the given client requires the user's attention, * false otherwise. */ - $scope.hasStatusUpdate = function hasStatusUpdate(client) { + $scope.hasStatusUpdate = function hasStatusUpdate(clientGroup) { + return _.findIndex(clientGroup.clients, (client) => { - // Test whether the client has encountered an error - switch (client.clientState.connectionState) { - case ManagedClientState.ConnectionState.CONNECTION_ERROR: - case ManagedClientState.ConnectionState.TUNNEL_ERROR: - case ManagedClientState.ConnectionState.DISCONNECTED: - return true; - } + // Test whether the client has encountered an error + switch (client.clientState.connectionState) { + case ManagedClientState.ConnectionState.CONNECTION_ERROR: + case ManagedClientState.ConnectionState.TUNNEL_ERROR: + case ManagedClientState.ConnectionState.DISCONNECTED: + return true; + } - return false; + return false; + }) !== -1; }; /** - * Returns whether the given client is currently being managed by - * the guacClientManager service. + * Initiates an orderly disconnect of all clients within the given + * group. The clients are removed from management such that + * attempting to connect to any of the same connections will result + * in new connections being established, rather than displaying a + * notification that the connection has ended. * - * @param {ManagedClient} client - * The client to test. - * - * @returns {Boolean} - * true if the given client is being managed by the - * guacClientManager service, false otherwise. + * @param {ManagedClientGroup} clientGroup + * The group of clients to disconnect. */ - $scope.isManaged = function isManaged(client) { - return !!guacClientManager.getManagedClients()[client.id]; - }; - - /** - * Initiates an orderly disconnect of the given client. The client - * is removed from management such that attempting to connect to - * the same connection will result in a new connection being - * established, rather than displaying a notification that the - * connection has ended. - * - * @param {type} client - * @returns {undefined} - */ - $scope.disconnect = function disconnect(client) { - client.client.disconnect(); - guacClientManager.removeManagedClient(client.id); + $scope.disconnect = function disconnect(clientGroup) { + guacClientManager.removeManagedClientGroup(ManagedClientGroup.getIdentifier(clientGroup)); }; /** diff --git a/guacamole/src/main/frontend/src/app/client/directives/guacClientZoom.js b/guacamole/src/main/frontend/src/app/client/directives/guacClientZoom.js new file mode 100644 index 000000000..471ca1973 --- /dev/null +++ b/guacamole/src/main/frontend/src/app/client/directives/guacClientZoom.js @@ -0,0 +1,85 @@ +/* + * 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 controlling the zoom level and scale-to-fit behavior of a + * a single Guacamole client. + */ +angular.module('client').directive('guacClientZoom', [function guacClientZoom() { + + const directive = { + restrict: 'E', + replace: true, + templateUrl: 'app/client/templates/guacClientZoom.html' + }; + + directive.scope = { + + /** + * The client to control the zoom/autofit of. + * + * @type ManagedClient + */ + client : '=' + + }; + + directive.controller = ['$scope', '$injector', '$element', + function guacClientZoomController($scope, $injector, $element) { + + /** + * Zooms in by 10%, automatically disabling autofit. + */ + $scope.zoomIn = function zoomIn() { + $scope.client.clientProperties.autoFit = false; + $scope.client.clientProperties.scale += 0.1; + }; + + /** + * Zooms out by 10%, automatically disabling autofit. + */ + $scope.zoomOut = function zoomOut() { + $scope.client.clientProperties.autoFit = false; + $scope.client.clientProperties.scale -= 0.1; + }; + + /** + * Resets the client autofit setting to false. + */ + $scope.clearAutoFit = function clearAutoFit() { + $scope.client.clientProperties.autoFit = false; + }; + + /** + * Notifies that the autofit setting has been manually changed by the + * user. + */ + $scope.autoFitChanged = function autoFitChanged() { + + // Reset to 100% scale when autofit is first disabled + if (!$scope.client.clientProperties.autoFit) + $scope.client.clientProperties.scale = 1; + + }; + + }]; + + return directive; + +}]); \ No newline at end of file diff --git a/guacamole/src/main/frontend/src/app/client/directives/guacFileBrowser.js b/guacamole/src/main/frontend/src/app/client/directives/guacFileBrowser.js index fe1a5e249..f1daf3eb6 100644 --- a/guacamole/src/main/frontend/src/app/client/directives/guacFileBrowser.js +++ b/guacamole/src/main/frontend/src/app/client/directives/guacFileBrowser.js @@ -28,14 +28,6 @@ angular.module('client').directive('guacFileBrowser', [function guacFileBrowser( replace: true, scope: { - /** - * The client whose file transfers should be managed by this - * directive. - * - * @type ManagedClient - */ - client : '=', - /** * @type ManagedFilesystem */ @@ -116,7 +108,7 @@ angular.module('client').directive('guacFileBrowser', [function guacFileBrowser( * The file to download. */ $scope.downloadFile = function downloadFile(file) { - ManagedFilesystem.downloadFile($scope.client, $scope.filesystem, file.streamName); + ManagedFilesystem.downloadFile($scope.filesystem, file.streamName); }; /** diff --git a/guacamole/src/main/frontend/src/app/client/directives/guacFileTransferManager.js b/guacamole/src/main/frontend/src/app/client/directives/guacFileTransferManager.js index 002494865..3a7c3a3ef 100644 --- a/guacamole/src/main/frontend/src/app/client/directives/guacFileTransferManager.js +++ b/guacamole/src/main/frontend/src/app/client/directives/guacFileTransferManager.js @@ -28,12 +28,12 @@ angular.module('client').directive('guacFileTransferManager', [function guacFile scope: { /** - * The client whose file transfers should be managed by this + * The client group whose file transfers should be managed by this * directive. * - * @type ManagerClient + * @type ManagedClientGroup */ - client : '=' + clientGroup : '=' }, @@ -41,7 +41,9 @@ angular.module('client').directive('guacFileTransferManager', [function guacFile controller: ['$scope', '$injector', function guacFileTransferManagerController($scope, $injector) { // Required types - var ManagedFileTransferState = $injector.get('ManagedFileTransferState'); + const ManagedClient = $injector.get('ManagedClient'); + const ManagedClientGroup = $injector.get('ManagedClientGroup'); + const ManagedFileTransferState = $injector.get('ManagedFileTransferState'); /** * Determines whether the given file transfer state indicates an @@ -74,17 +76,29 @@ angular.module('client').directive('guacFileTransferManager', [function guacFile */ $scope.clearCompletedTransfers = function clearCompletedTransfers() { - // Nothing to clear if no client attached - if (!$scope.client) + // Nothing to clear if no client group attached + if (!$scope.clientGroup) return; // Remove completed uploads - $scope.client.uploads = $scope.client.uploads.filter(function isUploadInProgress(upload) { - return isInProgress(upload.transferState); + $scope.clientGroup.clients.forEach(client => { + client.uploads = client.uploads.filter(function isUploadInProgress(upload) { + return isInProgress(upload.transferState); + }); }); }; + /** + * @borrows ManagedClientGroup.hasMultipleClients + */ + $scope.hasMultipleClients = ManagedClientGroup.hasMultipleClients; + + /** + * @borrows ManagedClient.hasTransfers + */ + $scope.hasTransfers = ManagedClient.hasTransfers; + }] }; diff --git a/guacamole/src/main/frontend/src/app/client/directives/guacThumbnail.js b/guacamole/src/main/frontend/src/app/client/directives/guacThumbnail.js index 86816759b..3b518e7df 100644 --- a/guacamole/src/main/frontend/src/app/client/directives/guacThumbnail.js +++ b/guacamole/src/main/frontend/src/app/client/directives/guacThumbnail.js @@ -43,20 +43,6 @@ angular.module('client').directive('guacThumbnail', [function guacThumbnail() { // Required services var $window = $injector.get('$window'); - /** - * The optimal thumbnail width, in pixels. - * - * @type Number - */ - var THUMBNAIL_WIDTH = 320; - - /** - * The optimal thumbnail height, in pixels. - * - * @type Number - */ - var THUMBNAIL_HEIGHT = 240; - /** * The display of the current Guacamole client instance. * @@ -126,32 +112,7 @@ angular.module('client').directive('guacThumbnail', [function guacThumbnail() { // Update scale when display is resized $scope.$watch('client.managedDisplay.size', function setDisplaySize(size) { - - var width; - var height; - - // If no display size yet, assume optimal thumbnail size - if (!size || size.width === 0 || size.height === 0) { - width = THUMBNAIL_WIDTH; - height = THUMBNAIL_HEIGHT; - } - - // Otherwise, generate size that fits within thumbnail bounds - else { - var scale = Math.min(THUMBNAIL_WIDTH / size.width, THUMBNAIL_HEIGHT / size.height, 1); - width = size.width * scale; - height = size.height * scale; - } - - // Generate dummy background image - var thumbnail = document.createElement("canvas"); - thumbnail.width = width; - thumbnail.height = height; - $scope.thumbnail = thumbnail.toDataURL("image/png"); - - // Init display scale $scope.$evalAsync($scope.updateDisplayScale); - }); }] diff --git a/guacamole/src/main/frontend/src/app/client/directives/guacTiledClients.js b/guacamole/src/main/frontend/src/app/client/directives/guacTiledClients.js new file mode 100644 index 000000000..01ecab50d --- /dev/null +++ b/guacamole/src/main/frontend/src/app/client/directives/guacTiledClients.js @@ -0,0 +1,171 @@ +/* + * 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 which displays one or more Guacamole clients in an evenly-tiled + * view. The number of rows and columns used for the arrangement of tiles is + * automatically determined by the number of clients present. + */ +angular.module('client').directive('guacTiledClients', [function guacTiledClients() { + + const directive = { + restrict: 'E', + templateUrl: 'app/client/templates/guacTiledClients.html', + }; + + directive.scope = { + + /** + * The function to invoke when the "close" button in the header of a + * client tile is clicked. The ManagedClient that is closed will be + * made available to the Angular expression defining the callback as + * "$client". + * + * @type function + */ + onClose : '&', + + /** + * The group of Guacamole clients that should be displayed in an + * evenly-tiled grid arrangement. + * + * @type ManagedClientGroup + */ + clientGroup : '=', + + /** + * Whether translation of touch to mouse events should emulate an + * absolute pointer device, or a relative pointer device. + * + * @type boolean + */ + emulateAbsoluteMouse : '=' + + }; + + directive.controller = ['$scope', '$injector', '$element', + function guacTiledClientsController($scope, $injector, $element) { + + // Required types + const ManagedClient = $injector.get('ManagedClient'); + const ManagedClientGroup = $injector.get('ManagedClientGroup'); + + /** + * Returns the currently-focused ManagedClient. If there is no such + * client, or multiple clients are focused, null is returned. + * + * @returns {ManagedClient} + * The currently-focused client, or null if there are no focused + * clients or if multiple clients are focused. + */ + $scope.getFocusedClient = function getFocusedClient() { + + const managedClientGroup = $scope.clientGroup; + if (managedClientGroup) { + const focusedClients = _.filter(managedClientGroup.clients, client => client.clientProperties.focused); + if (focusedClients.length === 1) + return focusedClients[0]; + } + + return null; + + }; + + // Notify whenever identify of currently-focused client changes + $scope.$watch('getFocusedClient()', function focusedClientChanged(focusedClient) { + $scope.$emit('guacClientFocused', focusedClient); + }); + + /** + * Returns a callback for guacClick that assigns or updates keyboard + * focus to the given client, allowing that client to receive and + * handle keyboard events. Multiple clients may have keyboard focus + * simultaneously. + * + * @param {ManagedClient} client + * The client that should receive keyboard focus. + * + * @return {guacClick~callback} + * The callback that guacClient should invoke when the given client + * has been clicked. + */ + $scope.getFocusAssignmentCallback = function getFocusAssignmentCallback(client) { + return (shift, ctrl) => { + + // Clear focus of all other clients if not selecting multiple + if (!shift && !ctrl) { + $scope.clientGroup.clients.forEach(client => { + client.clientProperties.focused = false; + }); + } + + client.clientProperties.focused = true; + + // Fill in any gaps if performing rectangular multi-selection + // via shift-click + if (shift) { + + let minRow = $scope.clientGroup.rows - 1; + let minColumn = $scope.clientGroup.columns - 1; + let maxRow = 0; + let maxColumn = 0; + + // Determine extents of selected area + ManagedClientGroup.forEach($scope.clientGroup, (client, row, column) => { + if (client.clientProperties.focused) { + minRow = Math.min(minRow, row); + minColumn = Math.min(minColumn, column); + maxRow = Math.max(maxRow, row); + maxColumn = Math.max(maxColumn, column); + } + }); + + ManagedClientGroup.forEach($scope.clientGroup, (client, row, column) => { + client.clientProperties.focused = + row >= minRow + && row <= maxRow + && column >= minColumn + && column <= maxColumn; + }); + + } + + }; + }; + + /** + * @borrows ManagedClientGroup.hasMultipleClients + */ + $scope.hasMultipleClients = ManagedClientGroup.hasMultipleClients; + + /** + * @borrows ManagedClientGroup.getClientGrid + */ + $scope.getClientGrid = ManagedClientGroup.getClientGrid; + + /** + * @borrows ManagedClient.isShared + */ + $scope.isShared = ManagedClient.isShared; + + }]; + + return directive; + +}]); diff --git a/guacamole/src/main/frontend/src/app/client/directives/guacTiledThumbnails.js b/guacamole/src/main/frontend/src/app/client/directives/guacTiledThumbnails.js new file mode 100644 index 000000000..e6417c93b --- /dev/null +++ b/guacamole/src/main/frontend/src/app/client/directives/guacTiledThumbnails.js @@ -0,0 +1,77 @@ +/* + * 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 displaying a group of Guacamole clients as a non-interactive + * thumbnail of tiled client displays. + */ +angular.module('client').directive('guacTiledThumbnails', [function guacTiledThumbnails() { + + const directive = { + restrict: 'E', + replace: true, + templateUrl: 'app/client/templates/guacTiledThumbnails.html' + }; + + directive.scope = { + + /** + * The group of clients to display as a thumbnail of tiled client + * displays. + * + * @type ManagedClientGroup + */ + clientGroup : '=' + + }; + + directive.controller = ['$scope', '$injector', '$element', + function guacTiledThumbnailsController($scope, $injector, $element) { + + // Required types + const ManagedClientGroup = $injector.get('ManagedClientGroup'); + + /** + * The overall height of the thumbnail view of the tiled grid of + * clients within the client group, in pixels. This value is + * intentionally based off a snapshot of the current browser size at + * the time the directive comes into existence to ensure the contents + * of the thumbnail are familiar in appearance and aspect ratio. + */ + $scope.height = Math.min(window.innerHeight, 128); + + /** + * The overall width of the thumbnail view of the tiled grid of + * clients within the client group, in pixels. This value is + * intentionally based off a snapshot of the current browser size at + * the time the directive comes into existence to ensure the contents + * of the thumbnail are familiar in appearance and aspect ratio. + */ + $scope.width = window.innerWidth / window.innerHeight * $scope.height; + + /** + * @borrows ManagedClientGroup.getClientGrid + */ + $scope.getClientGrid = ManagedClientGroup.getClientGrid; + + }]; + + return directive; + +}]); \ No newline at end of file diff --git a/guacamole/src/main/frontend/src/app/client/services/guacClientManager.js b/guacamole/src/main/frontend/src/app/client/services/guacClientManager.js index 4b81e6d19..990d10cde 100644 --- a/guacamole/src/main/frontend/src/app/client/services/guacClientManager.js +++ b/guacamole/src/main/frontend/src/app/client/services/guacClientManager.js @@ -24,11 +24,12 @@ angular.module('client').factory('guacClientManager', ['$injector', function guacClientManager($injector) { // Required types - var ManagedClient = $injector.get('ManagedClient'); + const ManagedClient = $injector.get('ManagedClient'); + const ManagedClientGroup = $injector.get('ManagedClientGroup'); // Required services - var $window = $injector.get('$window'); - var sessionStorageFactory = $injector.get('sessionStorageFactory'); + const $window = $injector.get('$window'); + const sessionStorageFactory = $injector.get('sessionStorageFactory'); var service = {}; @@ -56,6 +57,65 @@ angular.module('client').factory('guacClientManager', ['$injector', return storedManagedClients(); }; + /** + * Getter/setter which retrieves or sets the array of all active managed + * client groups. + * + * @type Function + */ + const storedManagedClientGroups = sessionStorageFactory.create([], function destroyClientGroupStorage() { + + // Disconnect all clients when storage is destroyed + service.clear(); + + }); + + /** + * Returns an array of all managed client groups. + * + * @returns {ManagedClientGroup[]>} + * An array of all active managed client groups. + */ + service.getManagedClientGroups = function getManagedClientGroups() { + return storedManagedClientGroups(); + }; + + /** + * Removes the ManagedClient with the given ID from all + * ManagedClientGroups, automatically adjusting the tile size of the + * clients that remain in each group. All client groups that are empty + * after the client is removed will also be removed. + * + * @param {string} id + * The ID of the ManagedClient to remove. + */ + const ungroupManagedClient = function ungroupManagedClient(id) { + + const managedClientGroups = storedManagedClientGroups(); + + // Remove client from all groups + managedClientGroups.forEach(group => { + const removed = _.remove(group.clients, client => (client.id === id)); + if (removed.length) { + + // Reset focus state if client is being removed from a group + // that isn't currently attached (focus may otherwise be + // retained and result in a newly added connection unexpectedly + // sharing focus) + if (!group.attached) + removed.forEach(client => { client.clientProperties.focused = false; }); + + // Recalculate group grid if number of clients is changing + ManagedClientGroup.recalculateTiles(group); + + } + }); + + // Remove any groups that are now empty + _.remove(managedClientGroups, group => !group.clients.length); + + }; + /** * Removes the existing ManagedClient associated with the connection having * the given ID, if any. If no such a ManagedClient already exists, this @@ -67,13 +127,16 @@ angular.module('client').factory('guacClientManager', ['$injector', * @returns {Boolean} * true if an existing client was removed, false otherwise. */ - service.removeManagedClient = function replaceManagedClient(id) { - + service.removeManagedClient = function removeManagedClient(id) { + var managedClients = storedManagedClients(); // Remove client if it exists if (id in managedClients) { + // Pull client out of any containing groups + ungroupManagedClient(id); + // Disconnect and remove managedClients[id].client.disconnect(); delete managedClients[id]; @@ -96,22 +159,37 @@ angular.module('client').factory('guacClientManager', ['$injector', * @param {String} id * The ID of the connection whose ManagedClient should be retrieved. * - * @param {String} [connectionParameters] - * Any additional HTTP parameters to pass while connecting. This - * parameter only has an effect if a new connection is established as - * a result of this function call. - * * @returns {ManagedClient} * The ManagedClient associated with the connection having the given * ID. */ - service.replaceManagedClient = function replaceManagedClient(id, connectionParameters) { + service.replaceManagedClient = function replaceManagedClient(id) { - // Disconnect any existing client - service.removeManagedClient(id); + const managedClients = storedManagedClients(); + const managedClientGroups = storedManagedClientGroups(); - // Set new client - return storedManagedClients()[id] = ManagedClient.getInstance(id, connectionParameters); + // Remove client if it exists + if (id in managedClients) { + + const hadFocus = managedClients[id].clientProperties.focused; + managedClients[id].client.disconnect(); + delete managedClients[id]; + + // Remove client from all groups + managedClientGroups.forEach(group => { + + const index = _.findIndex(group.clients, client => (client.id === id)); + if (index === -1) + return; + + group.clients[index] = managedClients[id] = ManagedClient.getInstance(id); + managedClients[id].clientProperties.focused = hadFocus; + + }); + + } + + return managedClients[id]; }; @@ -123,22 +201,21 @@ angular.module('client').factory('guacClientManager', ['$injector', * @param {String} id * The ID of the connection whose ManagedClient should be retrieved. * - * @param {String} [connectionParameters] - * Any additional HTTP parameters to pass while connecting. This - * parameter only has an effect if a new connection is established as - * a result of this function call. - * * @returns {ManagedClient} * The ManagedClient associated with the connection having the given * ID. */ - service.getManagedClient = function getManagedClient(id, connectionParameters) { + service.getManagedClient = function getManagedClient(id) { var managedClients = storedManagedClients(); + // Ensure any existing client is removed from its containing group + // prior to being returned + ungroupManagedClient(id); + // Create new managed client if it doesn't already exist if (!(id in managedClients)) - managedClients[id] = ManagedClient.getInstance(id, connectionParameters); + managedClients[id] = ManagedClient.getInstance(id); // Return existing client return managedClients[id]; @@ -146,7 +223,88 @@ angular.module('client').factory('guacClientManager', ['$injector', }; /** - * Disconnects and removes all currently-connected clients. + * Returns the ManagedClientGroup having the given ID. If no such + * ManagedClientGroup exists, a new ManagedClientGroup is created by + * extracting the relevant connections from the ID. + * + * @param {String} id + * The ID of the ManagedClientGroup to retrieve or create. + * + * @returns {ManagedClientGroup} + * The ManagedClientGroup having the given ID. + */ + service.getManagedClientGroup = function getManagedClientGroup(id) { + + const managedClientGroups = storedManagedClientGroups(); + const existingGroup = _.find(managedClientGroups, (group) => { + return id === ManagedClientGroup.getIdentifier(group); + }); + + // Prefer to return the existing group if it exactly matches + if (existingGroup) + return existingGroup; + + const clients = []; + const clientIds = ManagedClientGroup.getClientIdentifiers(id); + + // Separate active clients by whether they should be displayed within + // the current view + clientIds.forEach(function groupClients(id) { + clients.push(service.getManagedClient(id)); + }); + + const group = new ManagedClientGroup({ + clients : clients + }); + + // Focus the first client if there are no clients focused + ManagedClientGroup.verifyFocus(group); + + managedClientGroups.push(group); + return group; + + }; + + /** + * Removes the existing ManagedClientGroup having the given ID, if any, + * disconnecting and removing all ManagedClients associated with that + * group. If no such a ManagedClientGroup currently exists, this function + * has no effect. + * + * @param {String} id + * The ID of the ManagedClientGroup to remove. + * + * @returns {Boolean} + * true if a ManagedClientGroup was removed, false otherwise. + */ + service.removeManagedClientGroup = function removeManagedClientGroup(id) { + + const managedClients = storedManagedClients(); + const managedClientGroups = storedManagedClientGroups(); + + // Remove all matching groups (there SHOULD only be one) + const removed = _.remove(managedClientGroups, (group) => ManagedClientGroup.getIdentifier(group) === id); + + // Disconnect all clients associated with the removed group(s) + removed.forEach((group) => { + group.clients.forEach((client) => { + + const id = client.id; + if (managedClients[id]) { + managedClients[id].client.disconnect(); + delete managedClients[id]; + } + + }); + }); + + return !!removed.length; + + }; + + /** + * Disconnects and removes all currently-connected clients and client + * groups. */ service.clear = function clear() { @@ -156,8 +314,9 @@ angular.module('client').factory('guacClientManager', ['$injector', for (var id in managedClients) managedClients[id].client.disconnect(); - // Clear managed clients + // Clear managed clients and client groups storedManagedClients({}); + storedManagedClientGroups([]); }; 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 9ec74b6b2..59e37b1ca 100644 --- a/guacamole/src/main/frontend/src/app/client/styles/client.css +++ b/guacamole/src/main/frontend/src/app/client/styles/client.css @@ -103,7 +103,7 @@ body.client { flex: 0 0 auto; } -.client-view .client-body .main { +.client-view .client-body .tiled-client-list { position: absolute; left: 0; @@ -125,5 +125,13 @@ body.client { background-size: 1em; background-position: 0.75em center; padding-left: 2.5em; - background-image: url('images/x.png'); + background-image: url('images/x.svg'); } + +.client .drop-pending .display { + background: #3161a9; +} + +.client .drop-pending .display > *{ + opacity: 0.5; +} \ No newline at end of file diff --git a/guacamole/src/main/frontend/src/app/client/styles/connection-select-menu.css b/guacamole/src/main/frontend/src/app/client/styles/connection-select-menu.css index 3abfaa45e..69aefada4 100644 --- a/guacamole/src/main/frontend/src/app/client/styles/connection-select-menu.css +++ b/guacamole/src/main/frontend/src/app/client/styles/connection-select-menu.css @@ -55,3 +55,8 @@ overflow: hidden; text-overflow: ellipsis; } + +.connection-select-menu .menu-dropdown .menu-contents .caption .connection, +.connection-select-menu .menu-dropdown .menu-contents .caption .connection-group { + display: inline-block; +} diff --git a/guacamole/src/main/frontend/src/app/client/styles/connection-warning.css b/guacamole/src/main/frontend/src/app/client/styles/connection-warning.css index 87af0a882..daeb0f45a 100644 --- a/guacamole/src/main/frontend/src/app/client/styles/connection-warning.css +++ b/guacamole/src/main/frontend/src/app/client/styles/connection-warning.css @@ -48,7 +48,7 @@ height: 100%; margin: 0 0.375em; - background: url('images/warning.png'); + background: url('images/warning.svg'); background-size: contain; background-position: center; background-repeat: no-repeat; diff --git a/guacamole/src/main/frontend/src/app/client/styles/file-browser.css b/guacamole/src/main/frontend/src/app/client/styles/file-browser.css index dda0e9107..bafaa0328 100644 --- a/guacamole/src/main/frontend/src/app/client/styles/file-browser.css +++ b/guacamole/src/main/frontend/src/app/client/styles/file-browser.css @@ -37,13 +37,13 @@ /* Directory / file icons */ .file-browser .normal-file > .caption .icon { - background-image: url('images/file.png'); + background-image: url('images/file.svg'); } .file-browser .directory > .caption .icon { - background-image: url('images/folder-closed.png'); + background-image: url('images/folder-closed.svg'); } .file-browser .directory.previous > .caption .icon { - background-image: url('images/folder-up.png'); + background-image: url('images/folder-up.svg'); } diff --git a/guacamole/src/main/frontend/src/app/client/styles/filesystem-menu.css b/guacamole/src/main/frontend/src/app/client/styles/filesystem-menu.css index 946bfa14b..aed541aee 100644 --- a/guacamole/src/main/frontend/src/app/client/styles/filesystem-menu.css +++ b/guacamole/src/main/frontend/src/app/client/styles/filesystem-menu.css @@ -64,7 +64,7 @@ -khtml-background-size: 1.5em 1.5em; background-repeat: no-repeat; background-position: center center; - background-image: url('images/drive.png'); + background-image: url('images/drive.svg'); width: 2em; height: 2em; padding: 0; 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 aa80e093d..66df2cbd5 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 @@ -108,51 +108,13 @@ } #guac-menu #keyboard-settings .figure img { - max-width: 100%; + width: 100%; } #guac-menu #zoom-settings { text-align: center; } -#guac-menu #zoom-out, -#guac-menu #zoom-in, -#guac-menu #zoom-state { - display: inline-block; - vertical-align: middle; -} - -#guac-menu #zoom-out, -#guac-menu #zoom-in { - max-width: 3em; - border: 1px solid rgba(0, 0, 0, 0.5); - background: rgba(0, 0, 0, 0.1); - border-radius: 2em; - margin: 0.5em; - cursor: pointer; -} - -#guac-menu #zoom-out img, -#guac-menu #zoom-in img { - max-width: 100%; - opacity: 0.5; -} - -#guac-menu #zoom-out:hover, -#guac-menu #zoom-in:hover { - border: 1px solid rgba(0, 0, 0, 1); - background: #CDA; -} - -#guac-menu #zoom-out:hover img, -#guac-menu #zoom-in:hover img { - opacity: 1; -} - -#guac-menu #zoom-state { - font-size: 2em; -} - #guac-menu #devices .device { padding: 1em; @@ -176,7 +138,7 @@ } #guac-menu #devices .device.filesystem { - background-image: url('images/drive.png'); + background-image: url('images/drive.svg'); } #guac-menu #share-links { diff --git a/guacamole/src/main/frontend/src/app/client/styles/menu.css b/guacamole/src/main/frontend/src/app/client/styles/menu.css index 4021d3ce4..b5742ab5d 100644 --- a/guacamole/src/main/frontend/src/app/client/styles/menu.css +++ b/guacamole/src/main/frontend/src/app/client/styles/menu.css @@ -26,7 +26,7 @@ width: 480px; background: #EEE; box-shadow: inset -1px 0 2px white, 1px 0 2px black; - z-index: 10; + z-index: 100; -webkit-transition: left 0.125s, opacity 0.125s; -moz-transition: left 0.125s, opacity 0.125s; -ms-transition: left 0.125s, opacity 0.125s; @@ -134,27 +134,6 @@ padding-top: 1em; } -.menu-section input.zoom-ctrl { - width: 2em; - font-size: 1em; - padding: 0; - background: transparent; - border-color: rgba(0, 0, 0, 0.125); -} - -.menu-section div.zoom-ctrl { - font-size: 1.5em; - display: inline; - align-content: center; - vertical-align: middle; -} - -.menu-section .zoom-ctrl::-webkit-inner-spin-button, -.menu-section .zoom-ctrl::-webkit-outer-spin-button { - -webkit-appearance: none; - margin: 0; -} - .menu, .menu.closed { left: -480px; diff --git a/guacamole/src/main/frontend/src/app/client/styles/notification.css b/guacamole/src/main/frontend/src/app/client/styles/notification.css index 77a0a641a..86b2db3e7 100644 --- a/guacamole/src/main/frontend/src/app/client/styles/notification.css +++ b/guacamole/src/main/frontend/src/app/client/styles/notification.css @@ -17,7 +17,79 @@ * under the License. */ -.client .notification .parameters h3, -.client .notification .parameters .password-field .toggle-password { +.client-status-modal { + + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + + display: none; + background: rgba(0, 0, 0, 0.5); + +} + +.client-status-modal.shown { + display: block; +} + +.client-status-modal guac-modal { + position: absolute; +} + +.client-status-modal .notification { + background: rgba(40, 40, 40, 0.75); + color: white; + width: 100%; + max-width: 100%; + padding: 1em; + text-align: center; + border: none; +} + +.client-status-modal .notification.error { + background: rgba(112, 9, 8, 0.75) +} + +.client-status-modal .notification .title-bar { + display: none +} + +.client-status-modal .notification .button { + background: transparent; + border: 2px solid white; + box-shadow: none; + text-shadow: none; + font-weight: normal; +} + +.client-status-modal .notification .button:hover { + text-decoration: underline; + background: rgba(255, 255, 255, 0.25); +} + +.client-status-modal .notification .button:active { + background: rgba(255, 255, 255, 0.5); +} + +.client-status-modal .notification .parameters { + width: 100%; + max-width: 5in; + margin: 0 auto; +} + +.client-status-modal .notification .parameters h3, +.client-status-modal .notification .parameters .password-field .toggle-password { display: none; } + +.client-status-modal .notification .parameters input[type=email], +.client-status-modal .notification .parameters input[type=number], +.client-status-modal .notification .parameters input[type=password], +.client-status-modal .notification .parameters input[type=text], +.client-status-modal .notification .parameters textarea { + background: transparent; + border: 2px solid white; + color: white; +} diff --git a/guacamole/src/main/frontend/src/app/client/styles/share-menu.css b/guacamole/src/main/frontend/src/app/client/styles/share-menu.css index 6d6659b01..8e1ae7cba 100644 --- a/guacamole/src/main/frontend/src/app/client/styles/share-menu.css +++ b/guacamole/src/main/frontend/src/app/client/styles/share-menu.css @@ -53,6 +53,6 @@ background-repeat: no-repeat; background-size: 1em; background-position: 0.5em center; - background-image: url('images/share.png'); + background-image: url('images/share.svg'); } diff --git a/guacamole/src/main/frontend/src/app/client/styles/thumbnail-display.css b/guacamole/src/main/frontend/src/app/client/styles/thumbnail-display.css index c9c9c4f6c..468c51aca 100644 --- a/guacamole/src/main/frontend/src/app/client/styles/thumbnail-display.css +++ b/guacamole/src/main/frontend/src/app/client/styles/thumbnail-display.css @@ -25,11 +25,6 @@ div.thumbnail-main { font-size: 0px; } -.thumbnail-main img { - max-width: 100%; -} - .thumbnail-main .display { - position: absolute; pointer-events: none; } \ No newline at end of file diff --git a/guacamole/src/main/frontend/src/app/client/styles/tiled-client-grid.css b/guacamole/src/main/frontend/src/app/client/styles/tiled-client-grid.css new file mode 100644 index 000000000..209ab8e59 --- /dev/null +++ b/guacamole/src/main/frontend/src/app/client/styles/tiled-client-grid.css @@ -0,0 +1,138 @@ +/* + * 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. + */ + +/* + * Overall tiled grid layout. + */ + +.tiled-client-grid { + width: 100%; + height: 100%; +} + +.tiled-client-grid, +.tiled-client-grid .tiled-client-row, +.tiled-client-grid .tiled-client-cell, +.tiled-client-grid .client-tile { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-box-flex: 1; + -webkit-flex: 1; + -ms-flex: 1; + flex: 1; +} + +.tiled-client-grid { + -webkit-box-orient: vertical; + -webkit-box-direction: normal; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; +} + +.tiled-client-grid .tiled-client-row { + -webkit-box-orient: horizontal; + -webkit-box-direction: normal; + -webkit-flex-direction: row; + -ms-flex-direction: row; + flex-direction: row; +} + +/* + * Rendering of individual clients within tiles. + */ + +.tiled-client-grid .client-tile { + position: relative; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-box-orient: vertical; + -webkit-box-direction: normal; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + line-height: 1.5; +} + +.tiled-client-grid .client-tile .client-tile-header { + + display: -webkit-box; + + display: -webkit-flex; + + display: -ms-flexbox; + + display: flex; + -webkit-box-align: center; + -webkit-align-items: center; + -ms-flex-align: center; + align-items: center; + + margin: 0; + background: #444; + padding: 0 0.25em; + font-size: 0.8em; + color: white; + z-index: 30; + min-height: 1.5em; + +} + +.tiled-client-grid .client-tile.focused .client-tile-header { + background-color: #3161a9; +} + +.tiled-client-grid .client-tile .client-tile-header > * { + -webkit-box-flex: 0; + -webkit-flex: 0; + -ms-flex: 0; + flex: 0; +} + +.tiled-client-grid .client-tile .client-tile-header .client-tile-name { + -webkit-box-flex: 1; + -webkit-flex: 1; + -ms-flex: 1; + flex: 1; +} + +.tiled-client-grid .client-tile .main { + -webkit-box-flex: 1; + -webkit-flex: 1; + -ms-flex: 1; + flex: 1; +} + +.tiled-client-grid .client-tile-disconnect, +.tiled-client-grid .client-tile-shared-indicator { + max-height: 1em; + height: 100%; +} + +.tiled-client-grid .client-tile-shared-indicator { + display: none; +} + +.tiled-client-grid .shared .client-tile-shared-indicator { + display: inline; +} diff --git a/guacamole/src/main/frontend/src/app/client/styles/transfer-manager.css b/guacamole/src/main/frontend/src/app/client/styles/transfer-manager.css index 06d0cc841..9b47919c4 100644 --- a/guacamole/src/main/frontend/src/app/client/styles/transfer-manager.css +++ b/guacamole/src/main/frontend/src/app/client/styles/transfer-manager.css @@ -36,6 +36,14 @@ align-items: center; } +.transfer-manager h3 { + margin: 0.25em; + font-size: 1em; + margin-bottom: 0; + opacity: 0.5; + text-align: center; +} + .transfer-manager .transfers { display: table; padding: 0.25em; diff --git a/guacamole/src/main/frontend/src/app/client/styles/transfer.css b/guacamole/src/main/frontend/src/app/client/styles/transfer.css index 6ab2bd993..2c9c624d7 100644 --- a/guacamole/src/main/frontend/src/app/client/styles/transfer.css +++ b/guacamole/src/main/frontend/src/app/client/styles/transfer.css @@ -69,7 +69,7 @@ .transfer.in-progress .progress { background-color: #EEE; - background-image: url('images/progress.png'); + background-image: url('images/progress.svg'); background-size: 16px 16px; -moz-background-size: 16px 16px; diff --git a/guacamole/src/main/frontend/src/app/client/styles/zoom.css b/guacamole/src/main/frontend/src/app/client/styles/zoom.css new file mode 100644 index 000000000..03aa6826c --- /dev/null +++ b/guacamole/src/main/frontend/src/app/client/styles/zoom.css @@ -0,0 +1,75 @@ +/* + * 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. + */ + +.client-zoom .client-zoom-out, +.client-zoom .client-zoom-in, +.client-zoom .client-zoom-state { + display: inline-block; + vertical-align: middle; +} + +.client-zoom .client-zoom-out, +.client-zoom .client-zoom-in { + max-width: 3em; + border: 1px solid rgba(0, 0, 0, 0.5); + background: rgba(0, 0, 0, 0.1); + border-radius: 2em; + margin: 0.5em; + cursor: pointer; +} + +.client-zoom .client-zoom-out img, +.client-zoom .client-zoom-in img { + width: 100%; + opacity: 0.5; +} + +.client-zoom .client-zoom-out:hover, +.client-zoom .client-zoom-in:hover { + border: 1px solid rgba(0, 0, 0, 1); + background: #CDA; +} + +.client-zoom .client-zoom-out:hover img, +.client-zoom .client-zoom-in:hover img { + opacity: 1; +} + +.client-zoom .client-zoom-state { + font-size: 1.5em; +} + +.client-zoom .client-zoom-autofit { + text-align: left; + margin-top: 1em; +} + +.client-zoom .client-zoom-state input { + width: 2em; + font-size: 1em; + padding: 0; + background: transparent; + border-color: rgba(0, 0, 0, 0.125); +} + +.client-zoom .client-zoom-state input::-webkit-inner-spin-button, +.client-zoom .client-zoom-state input::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; +} 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 edc5cb665..d9c6684cb 100644 --- a/guacamole/src/main/frontend/src/app/client/templates/client.html +++ b/guacamole/src/main/frontend/src/app/client/templates/client.html @@ -6,15 +6,14 @@
-
+
- - - - -
- -
+ + +
@@ -38,7 +37,7 @@
- +
@@ -48,13 +47,13 @@