GUACAMOLE-724: Replace per-client clipboard with shared clipboard.

This commit is contained in:
Michael Jumper
2021-06-20 01:10:21 -07:00
parent 63452b7bc8
commit a249876bff
9 changed files with 161 additions and 138 deletions

View File

@@ -207,6 +207,9 @@ angular.module('client').controller('clientController', ['$scope', '$routeParams
if (_.isEmpty(_.intersection(previousClients, $scope.clientGroup.clients))) if (_.isEmpty(_.intersection(previousClients, $scope.clientGroup.clients)))
$scope.menu.shown = false; $scope.menu.shown = false;
// Update newly-attached clients with current contents of clipboard
clipboardService.resyncClipboard();
}; };
/** /**
@@ -341,15 +344,6 @@ angular.module('client').controller('clientController', ['$scope', '$routeParams
*/ */
var substituteKeysPressed = {}; 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.
*
* @type Object.<Number, ClipboardData>
*/
var clipboardDataFromKey = {};
/* /*
* Check to see if all currently pressed keys are in the set of menu keys. * Check to see if all currently pressed keys are in the set of menu keys.
*/ */
@@ -486,11 +480,9 @@ angular.module('client').controller('clientController', ['$scope', '$routeParams
// Update client state/behavior as visibility of the Guacamole menu changes // Update client state/behavior as visibility of the Guacamole menu changes
$scope.$watch('menu.shown', function menuVisibilityChanged(menuShown, menuShownPreviousState) { $scope.$watch('menu.shown', function menuVisibilityChanged(menuShown, menuShownPreviousState) {
// Send clipboard and argument value data once menu is hidden // Send any argument value data once menu is hidden
if (!menuShown && menuShownPreviousState) { if (!menuShown && menuShownPreviousState)
$scope.$broadcast('guacClipboard', $scope.client.clipboardData);
$scope.applyParameterChanges(); $scope.applyParameterChanges();
}
// Disable client keyboard if the menu is shown // Disable client keyboard if the menu is shown
angular.forEach($scope.clientGroup.clients, function updateKeyboardEnabled(client) { angular.forEach($scope.clientGroup.clients, function updateKeyboardEnabled(client) {
@@ -521,19 +513,6 @@ angular.module('client').controller('clientController', ['$scope', '$routeParams
iconService.setIcons(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 // Pull sharing profiles once the tunnel UUID is known
$scope.$watch('focusedClient.tunnel.uuid', function retrieveSharingProfiles(uuid) { $scope.$watch('focusedClient.tunnel.uuid', function retrieveSharingProfiles(uuid) {
@@ -651,16 +630,9 @@ angular.module('client').controller('clientController', ['$scope', '$routeParams
}); });
// Update pressed keys as they are released, synchronizing the clipboard // Update pressed keys as they are released
// with any data that appears to have come from those key presses
$scope.$on('guacKeyup', function keyupListener(event, keysym, keyboard) { $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 // Deal with substitute key presses
if (substituteKeysPressed[keysym]) { if (substituteKeysPressed[keysym]) {
event.preventDefault(); event.preventDefault();
@@ -669,10 +641,8 @@ angular.module('client').controller('clientController', ['$scope', '$routeParams
} }
// Mark key as released // Mark key as released
else { else
delete clipboardDataFromKey[keysym];
delete keysCurrentlyPressed[keysym]; delete keysCurrentlyPressed[keysym];
}
}); });

View File

@@ -413,10 +413,7 @@ angular.module('client').directive('guacClient', [function guacClient() {
// Update remote clipboard if local clipboard changes // Update remote clipboard if local clipboard changes
$scope.$on('guacClipboard', function onClipboard(event, data) { $scope.$on('guacClipboard', function onClipboard(event, data) {
if (client) { ManagedClient.setClipboard($scope.client, data);
ManagedClient.setClipboard($scope.client, data);
$scope.client.clipboardData = data;
}
}); });
// Translate local keydown events to remote keydown events if keyboard is enabled // Translate local keydown events to remote keydown events if keyboard is enabled

View File

@@ -305,23 +305,6 @@ angular.module('client').directive('guacClientNotification', [function guacClien
}); });
} }
// Hide status and sync local clipboard once connected
else if (connectionState === ManagedClientState.ConnectionState.CONNECTED) {
// TODO: Move clipboard sync elsewhere
// Sync with local clipboard
/*
clipboardService.getLocalClipboard().then(function clipboardRead(data) {
$scope.$broadcast('guacClipboard', data);
}, angular.noop);
*/
// Hide status notification
$scope.status = false;
}
// Hide status for all other states // Hide status for all other states
else else
$scope.status = false; $scope.status = false;

View File

@@ -100,7 +100,7 @@
<h3>{{'CLIENT.SECTION_HEADER_CLIPBOARD' | translate}}</h3> <h3>{{'CLIENT.SECTION_HEADER_CLIPBOARD' | translate}}</h3>
<div class="content"> <div class="content">
<p class="description">{{'CLIENT.HELP_CLIPBOARD' | translate}}</p> <p class="description">{{'CLIENT.HELP_CLIPBOARD' | translate}}</p>
<guac-clipboard data="client.clipboardData"></guac-clipboard> <guac-clipboard></guac-clipboard>
</div> </div>
</div> </div>

View File

@@ -42,6 +42,7 @@ angular.module('client').factory('ManagedClient', ['$rootScope', '$injector',
var $window = $injector.get('$window'); var $window = $injector.get('$window');
var activeConnectionService = $injector.get('activeConnectionService'); var activeConnectionService = $injector.get('activeConnectionService');
var authenticationService = $injector.get('authenticationService'); var authenticationService = $injector.get('authenticationService');
var clipboardService = $injector.get('clipboardService');
var connectionGroupService = $injector.get('connectionGroupService'); var connectionGroupService = $injector.get('connectionGroupService');
var connectionService = $injector.get('connectionService'); var connectionService = $injector.get('connectionService');
var preferenceService = $injector.get('preferenceService'); var preferenceService = $injector.get('preferenceService');
@@ -146,16 +147,6 @@ angular.module('client').factory('ManagedClient', ['$rootScope', '$injector',
*/ */
this.thumbnail = template.thumbnail; this.thumbnail = template.thumbnail;
/**
* The current clipboard contents.
*
* @type ClipboardData
*/
this.clipboardData = template.clipboardData || new ClipboardData({
type : 'text/plain',
data : ''
});
/** /**
* The current state of all parameters requested by the server via * The current state of all parameters requested by the server via
* "required" instructions, where each object key is the name of a * "required" instructions, where each object key is the name of a
@@ -448,9 +439,10 @@ angular.module('client').factory('ManagedClient', ['$rootScope', '$injector',
ManagedClientState.setConnectionState(managedClient.clientState, ManagedClientState.setConnectionState(managedClient.clientState,
ManagedClientState.ConnectionState.CONNECTED); ManagedClientState.ConnectionState.CONNECTED);
// Send any clipboard data already provided // Sync current clipboard data
if (managedClient.clipboardData) clipboardService.getClipboard().then((data) => {
ManagedClient.setClipboard(managedClient, managedClient.clipboardData); ManagedClient.setClipboard(managedClient, data);
}, angular.noop);
// Begin streaming audio input if possible // Begin streaming audio input if possible
requestAudioStream(client); requestAudioStream(client);
@@ -545,12 +537,11 @@ angular.module('client').factory('ManagedClient', ['$rootScope', '$injector',
// Set clipboard contents once stream is finished // Set clipboard contents once stream is finished
reader.onend = function textComplete() { reader.onend = function textComplete() {
$rootScope.$apply(function updateClipboard() { clipboardService.setClipboard(new ClipboardData({
managedClient.clipboardData = new ClipboardData({ source : managedClient.id,
type : mimetype, type : mimetype,
data : data data : data
}); }), managedClient)['catch'](angular.noop);
});
}; };
} }
@@ -559,12 +550,11 @@ angular.module('client').factory('ManagedClient', ['$rootScope', '$injector',
else { else {
reader = new Guacamole.BlobReader(stream, mimetype); reader = new Guacamole.BlobReader(stream, mimetype);
reader.onend = function blobComplete() { reader.onend = function blobComplete() {
$rootScope.$apply(function updateClipboard() { clipboardService.setClipboard(new ClipboardData({
managedClient.clipboardData = new ClipboardData({ source : managedClient.id,
type : mimetype, type : mimetype,
data : reader.getBlob() data : reader.getBlob()
}); }), managedClient)['catch'](angular.noop);
});
}; };
} }
@@ -693,7 +683,9 @@ angular.module('client').factory('ManagedClient', ['$rootScope', '$injector',
/** /**
* Sends the given clipboard data over the given Guacamole client, setting * Sends the given clipboard data over the given Guacamole client, setting
* the contents of the remote clipboard to the data provided. * the contents of the remote clipboard to the data provided. If the given
* clipboard data was originally received from that client, the data is
* ignored and this function has no effect.
* *
* @param {ManagedClient} managedClient * @param {ManagedClient} managedClient
* The ManagedClient over which the given clipboard data is to be sent. * The ManagedClient over which the given clipboard data is to be sent.
@@ -703,6 +695,10 @@ angular.module('client').factory('ManagedClient', ['$rootScope', '$injector',
*/ */
ManagedClient.setClipboard = function setClipboard(managedClient, data) { ManagedClient.setClipboard = function setClipboard(managedClient, data) {
// Ignore clipboard data that was received from this connection
if (data.source === managedClient.id)
return;
var writer; var writer;
// Create stream with proper mimetype // Create stream with proper mimetype

View File

@@ -18,10 +18,10 @@
*/ */
/** /**
* A directive provides an editor whose contents are exposed via a * A directive provides an editor for the clipboard content maintained by
* ClipboardData object via the "data" attribute. If this data should also be * clipboardService. Changes to the clipboard by clipboardService will
* synced to the local clipboard, or sent via a connected Guacamole client * automatically be reflected in the editor, and changes in the editor will
* using a "guacClipboard" event, it is up to external code to do so. * automatically be reflected in the clipboard by clipboardService.
*/ */
angular.module('clipboard').directive('guacClipboard', ['$injector', angular.module('clipboard').directive('guacClipboard', ['$injector',
function guacClipboard($injector) { function guacClipboard($injector) {
@@ -29,6 +29,9 @@ angular.module('clipboard').directive('guacClipboard', ['$injector',
// Required types // Required types
var ClipboardData = $injector.get('ClipboardData'); var ClipboardData = $injector.get('ClipboardData');
// Required services
var clipboardService = $injector.get('clipboardService');
/** /**
* Configuration object for the guacClipboard directive. * Configuration object for the guacClipboard directive.
* *
@@ -40,20 +43,6 @@ angular.module('clipboard').directive('guacClipboard', ['$injector',
templateUrl : 'app/clipboard/templates/guacClipboard.html' templateUrl : 'app/clipboard/templates/guacClipboard.html'
}; };
// Scope properties exposed by the guacClipboard directive
config.scope = {
/**
* The data to display within the field provided by this directive. This
* data will modified or replaced when the user manually alters the
* contents of the field.
*
* @type ClipboardData
*/
data : '='
};
// guacClipboard directive controller // guacClipboard directive controller
config.controller = ['$scope', '$injector', '$element', config.controller = ['$scope', '$injector', '$element',
function guacClipboardController($scope, $injector, $element) { function guacClipboardController($scope, $injector, $element) {
@@ -75,12 +64,27 @@ angular.module('clipboard').directive('guacClipboard', ['$injector',
var updateClipboardData = function updateClipboardData() { var updateClipboardData = function updateClipboardData() {
// Read contents of clipboard textarea // Read contents of clipboard textarea
$scope.$evalAsync(function assignClipboardText() { clipboardService.setClipboard(new ClipboardData({
$scope.data = new ClipboardData({ type : 'text/plain',
type : 'text/plain', data : element.value
data : element.value }));
});
}); };
/**
* Updates the contents of the clipboard editor to the given data.
*
* @param {ClipboardData} data
* The ClipboardData to display within the clipboard editor for
* editing.
*/
var updateClipboardEditor = function updateClipboardEditor(data) {
// If the clipboard data is a string, render it as text
if (typeof data.data === 'string')
element.value = data.data;
// Ignore other data types for now
}; };
@@ -89,17 +93,15 @@ angular.module('clipboard').directive('guacClipboard', ['$injector',
element.addEventListener('input', updateClipboardData); element.addEventListener('input', updateClipboardData);
element.addEventListener('change', updateClipboardData); element.addEventListener('change', updateClipboardData);
// Watch clipboard for new data, updating the clipboard textarea as // Update remote clipboard if local clipboard changes
// necessary $scope.$on('guacClipboard', function clipboardChanged(event, data) {
$scope.$watch('data', function clipboardDataChanged(data) { updateClipboardEditor(data);
});
// If the clipboard data is a string, render it as text // Init clipboard editor with current clipboard contents
if (typeof data.data === 'string') clipboardService.getClipboard().then((data) => {
element.value = data.data; updateClipboardEditor(data);
}, angular.noop);
// Ignore other data types for now
}); // end $scope.data watch
}]; }];

View File

@@ -18,18 +18,32 @@
*/ */
/** /**
* A service for accessing local clipboard data. * A service for maintaining and accessing clipboard data. If possible, this
* service will leverage the local clipboard. If the local clipboard is not
* available, an internal in-memory clipboard will be used instead.
*/ */
angular.module('clipboard').factory('clipboardService', ['$injector', angular.module('clipboard').factory('clipboardService', ['$injector',
function clipboardService($injector) { function clipboardService($injector) {
// Get required services // Get required services
var $q = $injector.get('$q'); var $q = $injector.get('$q');
var $window = $injector.get('$window'); var $window = $injector.get('$window');
var $rootScope = $injector.get('$rootScope');
var sessionStorageFactory = $injector.get('sessionStorageFactory');
// Required types // Required types
var ClipboardData = $injector.get('ClipboardData'); var ClipboardData = $injector.get('ClipboardData');
/**
* Getter/setter which retrieves or sets the current stored clipboard
* contents. The stored clipboard contents are strictly internal to
* Guacamole, and may not reflect the local clipboard if local clipboard
* access is unavailable.
*
* @type Function
*/
var storedClipboardData = sessionStorageFactory.create(new ClipboardData());
var service = {}; var service = {};
/** /**
@@ -175,7 +189,7 @@ angular.module('clipboard').factory('clipboardService', ['$injector',
* A promise that will resolve if setting the clipboard was successful, * A promise that will resolve if setting the clipboard was successful,
* and will reject if it failed. * and will reject if it failed.
*/ */
service.setLocalClipboard = function setLocalClipboard(data) { var setLocalClipboard = function setLocalClipboard(data) {
var deferred = $q.defer(); var deferred = $q.defer();
@@ -423,7 +437,7 @@ angular.module('clipboard').factory('clipboardService', ['$injector',
* if getting the clipboard was successful, and will reject if it * if getting the clipboard was successful, and will reject if it
* failed. * failed.
*/ */
service.getLocalClipboard = function getLocalClipboard() { var getLocalClipboard = function getLocalClipboard() {
// If the clipboard is already being read, do not overlap the read // If the clipboard is already being read, do not overlap the read
// attempts; instead share the result across all requests // attempts; instead share the result across all requests
@@ -548,6 +562,68 @@ angular.module('clipboard').factory('clipboardService', ['$injector',
}; };
/**
* Returns the current value of the internal clipboard shared across all
* active Guacamole connections running within the current browser tab. If
* access to the local clipboard is available, the internal clipboard is
* first synchronized with the current local clipboard contents. If access
* to the local clipboard is unavailable, only the internal clipboard will
* be used.
*
* @return {Promise.<ClipboardData>}
* A promise that will resolve with the contents of the internal
* clipboard, first retrieving those contents from the local clipboard
* if permission to do so has been granted. This promise is always
* resolved.
*/
service.getClipboard = function getClipboard() {
return getLocalClipboard().then((data) => storedClipboardData(data), () => storedClipboardData());
};
/**
* Sets the content of the internal clipboard shared across all active
* Guacamole connections running within the current browser tab. If
* access to the local clipboard is available, the local clipboard is
* first set to the provided clipboard content. If access to the local
* clipboard is unavailable, only the internal clipboard will be used. A
* "guacClipboard" event will be broadcast with the assigned data once the
* operation has completed.
*
* @param {ClipboardData} data
* The data to assign to the clipboard.
*
* @return {Promise}
* A promise that will resolve after the clipboard content has been
* set. This promise is always resolved.
*/
service.setClipboard = function setClipboard(data) {
return setLocalClipboard(data).finally(() => {
// Update internal clipboard and broadcast event notifying of
// updated contents
storedClipboardData(data);
$rootScope.$broadcast('guacClipboard', data);
// Ensure promise is resolved (this function may be called from
// the promise rejection handler)
return data;
});
};
/**
* Resynchronizes the local and internal clipboards, setting the contents
* of the internal clipboard to that of the local clipboard (if local
* clipboard access is granted) and broadcasting a "guacClipboard" event
* with the current internal clipboard contents for consumption by external
* components like the "guacClient" directive.
*/
service.resyncClipboard = function resyncClipboard() {
service.getClipboard().then(function clipboardRead(data) {
return service.setClipboard(data);
}, angular.noop);
};
return service; return service;
}]); }]);

View File

@@ -36,6 +36,15 @@ angular.module('clipboard').factory('ClipboardData', [function defineClipboardDa
// Use empty object by default // Use empty object by default
template = template || {}; template = template || {};
/**
* The ID of the ManagedClient handling the remote desktop connection
* that originated this clipboard data, or null if the data originated
* from the clipboard editor or local clipboard.
*
* @type {string}
*/
this.source = template.source;
/** /**
* The mimetype of the data currently stored within the clipboard. * The mimetype of the data currently stored within the clipboard.
* *

View File

@@ -208,25 +208,15 @@ angular.module('index').controller('indexController', ['$scope', '$injector',
keyboard.reset(); keyboard.reset();
}); });
/**
* Checks whether the clipboard data has changed, firing a new
* "guacClipboard" event if it has.
*/
var checkClipboard = function checkClipboard() {
clipboardService.getLocalClipboard().then(function clipboardRead(data) {
$scope.$broadcast('guacClipboard', data);
}, angular.noop);
};
// Attempt to read the clipboard if it may have changed // Attempt to read the clipboard if it may have changed
$window.addEventListener('load', checkClipboard, true); $window.addEventListener('load', clipboardService.resyncClipboard, true);
$window.addEventListener('copy', checkClipboard); $window.addEventListener('copy', clipboardService.resyncClipboard);
$window.addEventListener('cut', checkClipboard); $window.addEventListener('cut', clipboardService.resyncClipboard);
$window.addEventListener('focus', function focusGained(e) { $window.addEventListener('focus', function focusGained(e) {
// Only recheck clipboard if it's the window itself that gained focus // Only recheck clipboard if it's the window itself that gained focus
if (e.target === $window) if (e.target === $window)
checkClipboard(); clipboardService.resyncClipboard();
}, true); }, true);