GUACAMOLE-1571: Merge properly wrap stream errors, and check available translations instead of hard-coding.

diff --git a/guacamole/src/main/frontend/src/app/client/directives/guacClientNotification.js b/guacamole/src/main/frontend/src/app/client/directives/guacClientNotification.js
index e1a08eb..03344b6 100644
--- a/guacamole/src/main/frontend/src/app/client/directives/guacClientNotification.js
+++ b/guacamole/src/main/frontend/src/app/client/directives/guacClientNotification.js
@@ -34,16 +34,16 @@
 
         /**
          * 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');
@@ -53,6 +53,7 @@
         const $location              = $injector.get('$location');
         const authenticationService  = $injector.get('authenticationService');
         const guacClientManager      = $injector.get('guacClientManager');
+        const guacTranslate          = $injector.get('guacTranslate');
         const requestService         = $injector.get('requestService');
         const userPageService        = $injector.get('userPageService');
 
@@ -66,26 +67,6 @@
         $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.
          */
@@ -98,26 +79,7 @@
             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.
@@ -239,7 +201,7 @@
             // Get any associated status code
             const status = $scope.client.clientState.statusCode;
 
-            // Connecting 
+            // Connecting
             if (connectionState === ManagedClientState.ConnectionState.CONNECTING
              || connectionState === ManagedClientState.ConnectionState.WAITING) {
                 $scope.status = {
@@ -254,44 +216,58 @@
             // 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";
+                // Translation IDs for this error code
+                const errorPrefix = "CLIENT.ERROR_CLIENT_";
+                const errorId = errorPrefix + status.toString(16).toUpperCase();
+                const defaultErrorId = errorPrefix + "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
-                });
+                // Use the guacTranslate service to determine if there is a translation for
+                // this error code; if not, use the default
+                guacTranslate(errorId, defaultErrorId).then(
+
+                    // Show error status
+                    translationResult => notifyConnectionClosed({
+                        className : "error",
+                        title     : "CLIENT.DIALOG_HEADER_CONNECTION_ERROR",
+                        text      : {
+                            key : translationResult.id
+                        },
+                        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";
+                // Translation IDs for this error code
+                const errorPrefix = "CLIENT.ERROR_TUNNEL_";
+                const errorId = errorPrefix + status.toString(16).toUpperCase();
+                const defaultErrorId = errorPrefix + "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
-                });
+                // Use the guacTranslate service to determine if there is a translation for
+                // this error code; if not, use the default
+                guacTranslate(errorId, defaultErrorId).then(
+
+                    // Show error status
+                    translationResult => notifyConnectionClosed({
+                        className : "error",
+                        title     : "CLIENT.DIALOG_HEADER_CONNECTION_ERROR",
+                        text      : {
+                            key : translationResult.id
+                        },
+                        countdown : countdown,
+                        actions   : actions
+                    })
+                );
 
             }
 
diff --git a/guacamole/src/main/frontend/src/app/client/directives/guacFileTransfer.js b/guacamole/src/main/frontend/src/app/client/directives/guacFileTransfer.js
index a9c09bc..d016a72 100644
--- a/guacamole/src/main/frontend/src/app/client/directives/guacFileTransfer.js
+++ b/guacamole/src/main/frontend/src/app/client/directives/guacFileTransfer.js
@@ -30,7 +30,7 @@
 
             /**
              * The file transfer to display.
-             * 
+             *
              * @type ManagedFileUpload|ManagedFileDownload
              */
             transfer : '='
@@ -40,28 +40,13 @@
         templateUrl: 'app/client/templates/guacFileTransfer.html',
         controller: ['$scope', '$injector', function guacFileTransferController($scope, $injector) {
 
+            // Required services
+            const guacTranslate = $injector.get('guacTranslate');
+
             // Required types
             var ManagedFileTransferState = $injector.get('ManagedFileTransferState');
 
             /**
-             * All upload error codes handled and passed off for translation.
-             * Any error code not present in this list will be represented by
-             * the "DEFAULT" translation.
-             */
-            var UPLOAD_ERRORS = {
-                0x0100: true,
-                0x0201: true,
-                0x0202: true,
-                0x0203: true,
-                0x0204: true,
-                0x0205: true,
-                0x0301: true,
-                0x0303: true,
-                0x0308: true,
-                0x031D: true
-            };
-
-            /**
              * Returns the unit string that is most appropriate for the
              * number of bytes transferred thus far - either 'gb', 'mb', 'kb',
              * or 'b'.
@@ -193,7 +178,7 @@
                     return;
 
                 // Save file
-                saveAs($scope.transfer.blob, $scope.transfer.filename); 
+                saveAs($scope.transfer.blob, $scope.transfer.filename);
 
             };
 
@@ -210,23 +195,20 @@
                 return $scope.transfer.transferState.streamState === ManagedFileTransferState.StreamState.ERROR;
             };
 
-            /**
-             * Returns the text of the current error as a translation string.
-             *
-             * @returns {String}
-             *     The name of the translation string containing the text
-             *     associated with the current error.
-             */
-            $scope.getErrorText = function getErrorText() {
+            // The translated error message for the current status code
+            $scope.translatedErrorMessage = '';
+
+            $scope.$watch('transfer.transferState.statusCode', function statusCodeChanged(statusCode) {
 
                 // Determine translation name of error
-                var status = $scope.transfer.transferState.statusCode;
-                var errorName = (status in UPLOAD_ERRORS) ? status.toString(16).toUpperCase() : "DEFAULT";
+                const errorName = 'CLIENT.ERROR_UPLOAD_' + statusCode.toString(16).toUpperCase();
 
-                // Return translation string
-                return 'CLIENT.ERROR_UPLOAD_' + errorName;
+                // Use translation string, or the default if no translation is found for this error code
+                guacTranslate(errorName, 'CLIENT.ERROR_UPLOAD_DEFAULT').then(
+                    translationResult => $scope.translatedErrorMessage = translationResult.message
+                );
 
-            };
+            });
 
         }] // end file transfer controller
 
diff --git a/guacamole/src/main/frontend/src/app/client/services/guacTranslate.js b/guacamole/src/main/frontend/src/app/client/services/guacTranslate.js
new file mode 100644
index 0000000..c7fe8e9
--- /dev/null
+++ b/guacamole/src/main/frontend/src/app/client/services/guacTranslate.js
@@ -0,0 +1,82 @@
+/*
+ * 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 wrapper around the angular-translate $translate service that offers a
+ * convenient way to fall back to a default translation if the requested
+ * translation is not available.
+ */
+ angular.module('client').factory('guacTranslate', ['$injector', function guacTranslate($injector) {
+
+    // Required services
+    const $q = $injector.get('$q');
+    const $translate = $injector.get('$translate');
+
+    // Required types
+    const TranslationResult = $injector.get('TranslationResult');
+
+    /**
+     * Returns a promise that will be resolved with a TranslationResult containg either the
+     * requested ID and message (if translated), or the default ID and message if translated,
+     * or the literal value of `defaultTranslationId` for both the ID and message if neither
+     * is translated.
+     *
+     * @param {String} translationId
+     *     The requested translation ID, which may or may not be translated.
+     *
+     * @param {Sting} defaultTranslationId
+     *     The translation ID that will be used if no translation is found for `translationId`.
+     *
+     * @returns {Promise.<TranslationResult>}
+     *     A promise which resolves with a TranslationResult containing the results from
+     *     the translation attempt.
+     */
+    var translateWithFallback = function translateWithFallback(translationId, defaultTranslationId) {
+        const deferredTranslation = $q.defer();
+
+        // Attempt to translate the requested translation ID
+        $translate(translationId).then(
+
+            // If the requested translation is available, use that
+            translation => deferredTranslation.resolve(new TranslationResult({
+                id: translationId, message: translation
+            })),
+
+            // Otherwise, try the default translation ID
+            () => $translate(defaultTranslationId).then(
+
+                // Default translation worked, so use that
+                defaultTranslation =>
+                    deferredTranslation.resolve(new TranslationResult({
+                        id: defaultTranslationId, message: defaultTranslation
+                    })),
+
+                // Neither translation is available; as a fallback, return default ID for both
+                () => deferredTranslation.resolve(new TranslationResult({
+                    id: defaultTranslationId, message: defaultTranslationId
+                })),
+            )
+        );
+
+        return deferredTranslation.promise;
+    };
+
+    return translateWithFallback;
+
+}]);
diff --git a/guacamole/src/main/frontend/src/app/client/templates/guacFileTransfer.html b/guacamole/src/main/frontend/src/app/client/templates/guacFileTransfer.html
index dd96baa..32ead84 100644
--- a/guacamole/src/main/frontend/src/app/client/templates/guacFileTransfer.html
+++ b/guacamole/src/main/frontend/src/app/client/templates/guacFileTransfer.html
@@ -10,7 +10,7 @@
         </div>
 
         <!-- Error text -->
-        <p class="error-text">{{getErrorText() | translate}}</p>
+        <p class="error-text">{{translatedErrorMessage}}</p>
 
     </div>
 
diff --git a/guacamole/src/main/frontend/src/app/client/types/TranslationResult.js b/guacamole/src/main/frontend/src/app/client/types/TranslationResult.js
new file mode 100644
index 0000000..0a81511
--- /dev/null
+++ b/guacamole/src/main/frontend/src/app/client/types/TranslationResult.js
@@ -0,0 +1,59 @@
+/*
+ * 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.
+ */
+
+/**
+ * Provides the TranslationResult class used by the guacTranslate service. This class contains
+ * both the translated message and the translation ID that generated the message, in the case
+ * where it's unknown whether a translation is defined or not.
+ */
+ angular.module('client').factory('TranslationResult', [function defineTranslationResult() {
+
+    /**
+     * Object which represents the result of a translation as returned from
+     * the guacTranslate service.
+     *
+     * @constructor
+     * @param {TranslationResult|Object} [template={}]
+     *     The object whose properties should be copied within the new
+     *     TranslationResult.
+     */
+    const TranslationResult = function TranslationResult(template) {
+
+        // Use empty object by default
+        template = template || {};
+
+        /**
+         * The translation ID.
+         *
+         * @type {String}
+         */
+        this.id = template.id;
+
+        /**
+         * The translated message.
+         *
+         * @type {String}
+         */
+        this.message = template.message;
+
+    };
+
+    return TranslationResult;
+
+}]);
\ No newline at end of file
diff --git a/guacamole/src/main/frontend/src/app/rest/services/tunnelService.js b/guacamole/src/main/frontend/src/app/rest/services/tunnelService.js
index 1f0cde5..33a8d71 100644
--- a/guacamole/src/main/frontend/src/app/rest/services/tunnelService.js
+++ b/guacamole/src/main/frontend/src/app/rest/services/tunnelService.js
@@ -316,7 +316,7 @@
 
             // Parse and reject with resulting JSON error
             else if (xhr.getResponseHeader('Content-Type') === 'application/json')
-                deferred.reject(angular.fromJson(xhr.responseText));
+                deferred.reject(new Error(angular.fromJson(xhr.responseText)));
 
             // Warn of lack of permission of a proxy rejects the upload
             else if (xhr.status >= 400 && xhr.status < 500)