GUAC-963: Manage file uploads.

This commit is contained in:
Michael Jumper
2014-12-30 00:43:09 -08:00
parent d243d7520d
commit c779fff2d1
5 changed files with 382 additions and 189 deletions

View File

@@ -620,100 +620,6 @@ angular.module('client').controller('clientController', ['$scope', '$routeParams
});
});
// Mapping of upload stream index to notification object
var uploadNotifications = {};
// Mapping of upload stream index to notification ID
var uploadNotificationIDs = {};
$scope.$on('guacClientFileUploadStart', function handleClientFileUploadStart(event, guacClient, streamIndex, mimetype, filename, length) {
$scope.$apply(function() {
var notification = {
className : 'upload',
title : 'CLIENT.DIALOG_TITLE_FILE_TRANSFER',
text : filename
};
uploadNotifications[streamIndex] = notification;
uploadNotificationIDs[streamIndex] = $scope.addNotification(notification);
});
});
$scope.$on('guacClientFileUploadProgress', function handleClientFileUploadProgress(event, guacClient, streamIndex, mimetype, filename, length, offset) {
$scope.$apply(function() {
var notification = uploadNotifications[streamIndex];
if (notification)
notification.progress = getFileProgress('CLIENT.TEXT_FILE_TRANSFER_PROGRESS', offset, length);
});
});
$scope.$on('guacClientFileUploadEnd', function handleClientFileUploadEnd(event, guacClient, streamIndex, mimetype, filename, length) {
$scope.$apply(function() {
var notification = uploadNotifications[streamIndex];
var notificationID = uploadNotificationIDs[streamIndex];
/**
* Close the notification.
*/
var closeNotification = function closeNotification() {
$scope.removeNotification(notificationID);
delete uploadNotifications[streamIndex];
delete uploadNotificationIDs[streamIndex];
};
// Show that the file has uploaded successfully
if (notificationID && notification) {
delete notification.progress;
notification.actions = [
{
name : 'CLIENT.ACTION_ACKNOWLEDGE',
callback : closeNotification
}
];
}
});
});
$scope.$on('guacClientFileUploadError', function handleClientFileUploadError(event, guacClient, streamIndex, mimetype, fileName, length, status) {
$scope.$apply(function() {
var notification = uploadNotifications[streamIndex];
var notificationID = uploadNotificationIDs[streamIndex];
// Determine translation name of error
var errorName = (status in UPLOAD_ERRORS) ? status.toString(16).toUpperCase() : "DEFAULT";
/**
* Close the notification.
*/
var closeNotification = function closeNotification() {
$scope.removeNotification(notificationID);
delete uploadNotifications[streamIndex];
delete uploadNotificationIDs[streamIndex];
};
// Show that the file upload has failed
if (notificationID && notification) {
delete notification.progress;
notification.actions = [
{
name : 'CLIENT.ACTION_ACKNOWLEDGE',
callback : closeNotification
}
];
notification.text = "CLIENT.ERROR_UPLOAD_" + errorName;
notification.className = "upload error";
}
});
});
// Clean up when view destroyed
$scope.$on('$destroy', function clientViewDestroyed() {

View File

@@ -42,8 +42,11 @@ angular.module('client').directive('guacClient', [function guacClient() {
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');
var $window = $injector.get('$window');
/**
* Whether the local, hardware mouse cursor is in use.
@@ -407,26 +410,6 @@ angular.module('client').directive('guacClient', [function guacClient() {
client.sendKeyEvent(0, keysym);
});
/**
* Converts the given bytes to a base64-encoded string.
*
* @param {Uint8Array} bytes A Uint8Array which contains the data to be
* encoded as base64.
* @return {String} The base64-encoded string.
*/
function getBase64(bytes) {
var data = "";
// Produce binary string from bytes in buffer
for (var i=0; i<bytes.byteLength; i++)
data += String.fromCharCode(bytes[i]);
// Convert to base64
return $window.btoa(data);
}
/**
* Ignores the given event.
*
@@ -436,68 +419,6 @@ angular.module('client').directive('guacClient', [function guacClient() {
e.preventDefault();
e.stopPropagation();
}
/**
* Uploads the given file to the server.
*
* @param {File} file The file to upload.
*/
function uploadFile(file) {
// Construct reader for file
var reader = new FileReader();
reader.onloadend = function() {
// Open file for writing
var stream = client.createFileStream(file.type, file.name);
var valid = true;
var bytes = new Uint8Array(reader.result);
var offset = 0;
// Add upload notification
$scope.$emit('guacClientFileUploadStart', client, stream.index, file.type, file.name, bytes.length);
// Invalidate stream on all errors
// Continue upload when acknowledged
stream.onack = function(status) {
// Handle errors
if (status.isError()) {
valid = false;
$scope.$emit('guacClientFileUploadError', client, stream.index, file.type, file.name, bytes.length, status.code);
}
// Abort upload if stream is invalid
if (!valid) return false;
// Encode packet as base64
var slice = bytes.subarray(offset, offset+4096);
var base64 = getBase64(slice);
// Write packet
stream.sendBlob(base64);
// Advance to next packet
offset += 4096;
// If at end, stop upload
if (offset >= bytes.length) {
stream.sendEnd();
$scope.$emit('guacClientFileUploadProgress', client, stream.index, file.type, file.name, bytes.length, bytes.length);
$scope.$emit('guacClientFileUploadEnd', client, stream.index, file.type, file.name, bytes.length);
}
// Otherwise, update progress
else
$scope.$emit('guacClientFileUploadProgress', client, stream.index, file.type, file.name, bytes.length, offset);
};
};
reader.readAsArrayBuffer(file);
}
// Handle and ignore dragenter/dragover
displayContainer.addEventListener("dragenter", ignoreEvent, false);
@@ -510,12 +431,13 @@ angular.module('client').directive('guacClient', [function guacClient() {
e.stopPropagation();
// Ignore file drops if no attached client
if (!client) return;
if (!$scope.client)
return;
// Upload each file
var files = e.dataTransfer.files;
for (var i=0; i<files.length; i++)
uploadFile(files[i]);
ManagedClient.uploadFile($scope.client, files[i]);
}, false);

View File

@@ -30,6 +30,7 @@ angular.module('client').factory('ManagedClient', ['$rootScope', '$injector',
var ClientProperties = $injector.get('ClientProperties');
var ManagedClientState = $injector.get('ManagedClientState');
var ManagedDisplay = $injector.get('ManagedDisplay');
var ManagedFileUpload = $injector.get('ManagedFileUpload');
// Required services
var $window = $injector.get('$window');
@@ -100,18 +101,13 @@ angular.module('client').factory('ManagedClient', ['$rootScope', '$injector',
this.clipboardData = template.clipboardData;
/**
* The current width of the Guacamole display, in pixels.
* All uploaded files. As files are uploaded, their progress can be
* observed through the elements of this array. It is intended that
* this array be manipulated externally as needed.
*
* @type Number
* @type ManagedFileUpload[]
*/
this.displayWidth = template.displayWidth || 0;
/**
* The current width of the Guacamole display, in pixels.
*
* @type Number
*/
this.displayHeight = template.displayHeight || 0;
this.uploads = template.uploads || [];
/**
* The current state of the Guacamole client (idle, connecting,
@@ -433,6 +429,21 @@ angular.module('client').factory('ManagedClient', ['$rootScope', '$injector',
};
/**
* Uploads the given file to the server through the given Guacamole client.
* The file transfer can be monitored through the corresponding entry in
* the uploads array of the given managedClient.
*
* @param {ManagedClient} managedClient
* The ManagedClient through which the file is to be uploaded.
*
* @param {File} file
* The file to upload.
*/
ManagedClient.uploadFile = function uploadFile(managedClient, file) {
managedClient.uploads.push(ManagedFileUpload.getInstance(managedClient.client, file));
};
return ManagedClient;
}]);

View File

@@ -0,0 +1,136 @@
/*
* Copyright (C) 2014 Glyptodon LLC
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
/**
* Provides the ManagedFileTransferState class used by the guacClientManager
* service.
*/
angular.module('client').factory('ManagedFileTransferState', [function defineManagedFileTransferState() {
/**
* Object which represents the state of a Guacamole stream, including any
* error conditions.
*
* @constructor
* @param {ManagedFileTransferState|Object} [template={}]
* The object whose properties should be copied within the new
* ManagedFileTransferState.
*/
var ManagedFileTransferState = function ManagedFileTransferState(template) {
// Use empty object by default
template = template || {};
/**
* The current stream state. Valid values are described by
* ManagedFileTransferState.StreamState.
*
* @type String
* @default ManagedFileTransferState.StreamState.IDLE
*/
this.streamState = template.streamState || ManagedFileTransferState.StreamState.IDLE;
/**
* The status code of the current error condition, if streamState
* is ERROR. For all other streamState values, this will be
* @link{Guacamole.Status.Code.SUCCESS}.
*
* @type Number
* @default Guacamole.Status.Code.SUCCESS
*/
this.statusCode = template.statusCode || Guacamole.Status.Code.SUCCESS;
};
/**
* Valid stream state strings. Each state string is associated with a
* specific state of a Guacamole stream.
*/
ManagedFileTransferState.StreamState = {
/**
* The stream has not yet been opened.
*
* @type String
*/
IDLE : "IDLE",
/**
* The stream has been successfully established. Data can be sent or
* received.
*
* @type String
*/
OPEN : "OPEN",
/**
* The stream has terminated successfully. No errors are indicated.
*
* @type String
*/
CLOSED : "CLOSED",
/**
* The stream has terminated due to an error. The associated error code
* is stored in statusCode.
*
* @type String
*/
ERROR : "ERROR"
};
/**
* Sets the current transfer state and, if given, the associated status
* code. If an error is already represented, this function has no effect.
*
* @param {ManagedFileTransferState} transferState
* The ManagedFileTransferState to update.
*
* @param {String} streamState
* The stream state to assign to the given ManagedFileTransferState, as
* listed within ManagedFileTransferState.StreamState.
*
* @param {Number} [statusCode]
* The status code to assign to the given ManagedFileTransferState, if
* any, as listed within Guacamole.Status.Code. If no status code is
* specified, the status code of the ManagedFileTransferState is not
* touched.
*/
ManagedFileTransferState.setStreamState = function(transferState, streamState, statusCode) {
// Do not set state after an error is registered
if (transferState.streamState === ManagedFileTransferState.StreamState.ERROR)
return;
// Update stream state
transferState.streamState = streamState;
// Set status code, if given
if (statusCode)
transferState.statusCode = statusCode;
};
return ManagedFileTransferState;
}]);

View File

@@ -0,0 +1,218 @@
/*
* Copyright (C) 2014 Glyptodon LLC
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
/**
* Provides the ManagedFileUpload class used by the guacClientManager service.
*/
angular.module('client').factory('ManagedFileUpload', ['$rootScope', '$injector',
function defineManagedFileUpload($rootScope, $injector) {
// Required types
var ManagedFileTransferState = $injector.get('ManagedFileTransferState');
// Required services
var $window = $injector.get('$window');
/**
* The maximum number of bytes to include in each blob for the Guacamole
* file stream. Note that this, along with instruction opcode and protocol-
* related overhead, must not exceed the 8192 byte maximum imposed by the
* Guacamole protocol.
*
* @type Number
*/
var STREAM_BLOB_SIZE = 4096;
/**
* Object which serves as a surrogate interface, encapsulating a Guacamole
* file upload while it is active, allowing it to be detached and
* reattached from different client views.
*
* @constructor
* @param {ManagedFileUpload|Object} [template={}]
* The object whose properties should be copied within the new
* ManagedFileUpload.
*/
var ManagedFileUpload = function ManagedFileUpload(template) {
// Use empty object by default
template = template || {};
/**
* The current state of the file transfer stream.
*
* @type ManagedFileTransferState
*/
this.transferState = template.transferState || new ManagedFileTransferState();
/**
* The mimetype of the file being transferred.
*
* @type String
*/
this.mimetype = template.mimetype;
/**
* The filename of the file being transferred.
*
* @type String
*/
this.filename = template.filename;
/**
* The number of bytes transferred so far.
*
* @type Number
*/
this.progress = template.progress;
/**
* The total number of bytes in the file.
*
* @type Number
*/
this.length = template.length;
};
/**
* Converts the given bytes to a base64-encoded string.
*
* @param {Uint8Array} bytes A Uint8Array which contains the data to be
* encoded as base64.
* @return {String} The base64-encoded string.
*/
var getBase64 = function getBase64(bytes) {
var data = "";
// Produce binary string from bytes in buffer
for (var i=0; i<bytes.byteLength; i++)
data += String.fromCharCode(bytes[i]);
// Convert to base64
return $window.btoa(data);
};
/**
* Creates a new ManagedFileUpload which uploads the given file to the
* server through the given Guacamole client.
*
* @param {Guacamole.Client} client
* The Guacamole client through which the file is to be uploaded.
*
* @param {File} file
* The file to upload.
*
* @return {ManagedFileUpload}
* A new ManagedFileUpload object which can be used to track the
* progress of the upload.
*/
ManagedFileUpload.getInstance = function getInstance(client, file) {
var managedFileUpload = new ManagedFileUpload();
// Construct reader for file
var reader = new FileReader();
reader.onloadend = function() {
// Open file for writing
var stream = client.createFileStream(file.type, file.name);
var valid = true;
var bytes = new Uint8Array(reader.result);
var offset = 0;
$rootScope.$apply(function uploadStreamOpen() {
// Init managed upload
managedFileUpload.filename = file.name;
managedFileUpload.mimetype = file.type;
managedFileUpload.progress = 0;
managedFileUpload.length = bytes.length;
// Notify that stream is open
ManagedFileTransferState.setStreamState(managedFileUpload.transferState,
ManagedFileTransferState.StreamState.OPEN);
});
// Invalidate stream on all errors
// Continue upload when acknowledged
stream.onack = function(status) {
// Handle errors
if (status.isError()) {
valid = false;
$rootScope.$apply(function uploadStreamError() {
ManagedFileTransferState.setStreamState(managedFileUpload.transferState,
ManagedFileTransferState.StreamState.ERROR,
status.code);
});
}
// Abort upload if stream is invalid
if (!valid)
return false;
// Encode packet as base64
var slice = bytes.subarray(offset, offset + STREAM_BLOB_SIZE);
var base64 = getBase64(slice);
// Write packet
stream.sendBlob(base64);
// Advance to next packet
offset += STREAM_BLOB_SIZE;
$rootScope.$apply(function uploadStreamProgress() {
// If at end, stop upload
if (offset >= bytes.length) {
stream.sendEnd();
managedFileUpload.progress = bytes.length;
// Upload complete
ManagedFileTransferState.setStreamState(managedFileUpload.transferState,
ManagedFileTransferState.StreamState.CLOSED);
}
// Otherwise, update progress
else
managedFileUpload.progress = offset;
});
}; // end ack handler
};
reader.readAsArrayBuffer(file);
return managedFileUpload;
};
return ManagedFileUpload;
}]);