adobe/brackets

View on GitHub
src/extensibility/InstallExtensionDialog.js

Summary

Maintainability
C
1 day
Test Coverage
/*
 * Copyright (c) 2012 - present Adobe Systems Incorporated. All rights reserved.
 *
 * 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.
 *
 */

/*unittests: Install Extension Dialog*/

define(function (require, exports, module) {
    "use strict";

    var Dialogs                = require("widgets/Dialogs"),
        File                   = require("filesystem/File"),
        StringUtils            = require("utils/StringUtils"),
        Strings                = require("strings"),
        FileSystem             = require("filesystem/FileSystem"),
        KeyEvent               = require("utils/KeyEvent"),
        Package                = require("extensibility/Package"),
        NativeApp              = require("utils/NativeApp"),
        InstallDialogTemplate  = require("text!htmlContent/install-extension-dialog.html"),
        Mustache               = require("thirdparty/mustache/mustache");

    var STATE_CLOSED              = 0,
        STATE_START               = 1,
        STATE_VALID_URL           = 2,
        STATE_INSTALLING          = 3,
        STATE_INSTALLED           = 4,
        STATE_INSTALL_FAILED      = 5,
        STATE_CANCELING_INSTALL   = 6,
        STATE_CANCELING_HUNG      = 7,
        STATE_INSTALL_CANCELED    = 8,
        STATE_ALREADY_INSTALLED   = 9,
        STATE_OVERWRITE_CONFIRMED = 10,
        STATE_NEEDS_UPDATE        = 11;

    /**
     * Creates a new extension installer dialog.
     * @constructor
     * @param {{install: function(url), cancel: function()}} installer The installer backend to use.
     */
    function InstallExtensionDialog(installer, _isUpdate) {
        this._installer = installer;
        this._state = STATE_CLOSED;
        this._installResult = null;
        this._isUpdate = _isUpdate;

        // Timeout before we allow user to leave STATE_INSTALL_CANCELING without waiting for a resolution
        // (per-instance so we can poke it for unit testing)
        this._cancelTimeout = 10 * 1000;
    }

    /**
     * The dialog root.
     * @type {jQuery}
     */
    InstallExtensionDialog.prototype.$dlg = null;

    /**
     * The url input field.
     * @type {jQuery}
     */
    InstallExtensionDialog.prototype.$url = null;

    /**
     * The ok button.
     * @type {jQuery}
     */
    InstallExtensionDialog.prototype.$okButton = null;

    /**
     * The cancel button.
     * @type {jQuery}
     */
    InstallExtensionDialog.prototype.$cancelButton = null;

    /**
     * The area containing the url input label and field.
     * @type {jQuery}
     */
    InstallExtensionDialog.prototype.$inputArea = null;

    /**
     * The area containing the installation message and spinner.
     * @type {jQuery}
     */
    InstallExtensionDialog.prototype.$msgArea = null;

    /**
     * The span containing the installation message.
     * @type {jQuery}
     */
    InstallExtensionDialog.prototype.$msg = null;

    /**
     * The "Browse Extensions" button.
     * @type {jQuery}
     */
    InstallExtensionDialog.prototype.$browseExtensionsButton = null;

    /**
     * A deferred that's resolved/rejected when the dialog is closed and
     * something has/hasn't been installed successfully.
     * @type {$.Deferred}
     */
    InstallExtensionDialog.prototype._dialogDeferred = null;


    /**
     * installer The installer backend for this dialog.
     * @type {{install: function(url), cancel: function()}}
     */
    InstallExtensionDialog.prototype._installer = null;

    /**
     * The current state of the dialog; one of the STATE_* constants above.
     * @type {number}
     */
    InstallExtensionDialog.prototype._state = null;

    /**
     * @private
     * Transitions the dialog into a new state as the installation proceeds.
     * @param {number} newState The state to transition into; one of the STATE_* variables.
     */
    InstallExtensionDialog.prototype._enterState = function (newState) {
        var url,
            msg,
            self = this,
            prevState = this._state;

        // Store the new state up front in case some of the processing below ends up changing
        // the state again immediately.
        this._state = newState;

        switch (newState) {
        case STATE_START:
            // This should match the default appearance of the dialog when it first opens.
            this.$msg.find(".spinner").remove();
            this.$msgArea.hide();
            this.$inputArea.show();
            this.$okButton
                .prop("disabled", true)
                .text(Strings.INSTALL);
            break;

        case STATE_VALID_URL:
            this.$okButton.prop("disabled", false);
            break;

        case STATE_INSTALLING:
            url = this.$url.val().trim();
            this.$inputArea.hide();
            this.$browseExtensionsButton.hide();
            this.$msg.text(StringUtils.format(Strings.INSTALLING_FROM, url))
                .append("<span class='spinner inline spin'/>");
            this.$msgArea.show();
            this.$okButton.prop("disabled", true);
            this._installer.install(url)
                .done(function (result) {
                    self._installResult = result;
                    if (result.installationStatus === Package.InstallationStatuses.ALREADY_INSTALLED ||
                            result.installationStatus === Package.InstallationStatuses.OLDER_VERSION ||
                            result.installationStatus === Package.InstallationStatuses.SAME_VERSION) {
                        self._enterState(STATE_ALREADY_INSTALLED);
                    } else if (result.installationStatus === Package.InstallationStatuses.NEEDS_UPDATE) {
                        self._enterState(STATE_NEEDS_UPDATE);
                    } else {
                        self._enterState(STATE_INSTALLED);
                    }
                })
                .fail(function (err) {
                    // If the "failure" is actually a user-requested cancel, don't show an error UI
                    if (err === "CANCELED") {
                        console.assert(self._state === STATE_CANCELING_INSTALL || self._state === STATE_CANCELING_HUNG);
                        self._enterState(STATE_INSTALL_CANCELED);
                    } else {
                        self._errorMessage = Package.formatError(err);
                        self._enterState(STATE_INSTALL_FAILED);
                    }
                });
            break;

        case STATE_CANCELING_INSTALL:
            // This should call back the STATE_INSTALLING fail() handler above, unless it's too late to cancel
            // in which case we'll still jump to STATE_INSTALLED after this
            this.$cancelButton.prop("disabled", true);
            this.$msg.text(Strings.CANCELING_INSTALL);
            this._installer.cancel();
            window.setTimeout(function () {
                if (self._state === STATE_CANCELING_INSTALL) {
                    self._enterState(STATE_CANCELING_HUNG);
                }
            }, this._cancelTimeout);
            break;

        case STATE_CANCELING_HUNG:
            this.$msg.text(Strings.CANCELING_HUNG);
            this.$okButton
                .removeAttr("disabled")
                .text(Strings.CLOSE);
            break;

        case STATE_INSTALLED:
        case STATE_INSTALL_FAILED:
        case STATE_INSTALL_CANCELED:
        case STATE_NEEDS_UPDATE:
            if (newState === STATE_INSTALLED) {
                msg = Strings.INSTALL_SUCCEEDED;
            } else if (newState === STATE_INSTALL_FAILED) {
                msg = Strings.INSTALL_FAILED;
            } else if (newState === STATE_NEEDS_UPDATE) {
                msg = Strings.EXTENSION_UPDATE_INSTALLED;
            } else {
                msg = Strings.INSTALL_CANCELED;
            }
            this.$msg.html($("<strong/>").text(msg));
            if (this._errorMessage) {
                this.$msg.append($("<p/>").text(this._errorMessage));
            }
            this.$okButton
                .removeAttr("disabled")
                .text(Strings.CLOSE);
            this.$cancelButton.hide();
            break;

        case STATE_ALREADY_INSTALLED:
            var installResult = this._installResult;
            var status = installResult.installationStatus;
            var msgText = Strings["EXTENSION_" + status];
            if (status === Package.InstallationStatuses.OLDER_VERSION) {
                msgText = StringUtils.format(msgText, installResult.metadata.version, installResult.installedVersion);
            }
            this.$msg.text(msgText);
            this.$okButton
                .prop("disabled", false)
                .text(Strings.OVERWRITE);
            break;

        case STATE_OVERWRITE_CONFIRMED:
            this._enterState(STATE_CLOSED);
            break;

        case STATE_CLOSED:
            $(window.document.body).off(".installDialog");

           // Only resolve as successful if we actually installed something.
            Dialogs.cancelModalDialogIfOpen("install-extension-dialog");
            if (prevState === STATE_INSTALLED || prevState === STATE_NEEDS_UPDATE ||
                    prevState === STATE_OVERWRITE_CONFIRMED) {
                this._dialogDeferred.resolve(this._installResult);
            } else {
                this._dialogDeferred.reject();
            }
            break;
        }
    };

    /**
     * @private
     * Handle a click on the Cancel button, which either cancels an ongoing installation (leaving
     * the dialog open), or closes the dialog if no installation is in progress.
     */
    InstallExtensionDialog.prototype._handleCancel = function () {
        if (this._state === STATE_INSTALLING) {
            this._enterState(STATE_CANCELING_INSTALL);
        } else if (this._state === STATE_ALREADY_INSTALLED) {
            // If we were prompting the user about overwriting a previous installation,
            // and the user cancels, we can delete the downloaded file.
            if (this._installResult && this._installResult.localPath && !this._installResult.keepFile) {
                var filename = this._installResult.localPath;
                FileSystem.getFileForPath(filename).unlink();
            }
            this._enterState(STATE_CLOSED);
        } else if (this._state !== STATE_CANCELING_INSTALL) {
            this._enterState(STATE_CLOSED);
        }
    };

    /**
     * @private
     * Handle a click on the default button, which is "Install" while we're waiting for the
     * user to enter a URL, and "Close" once we've successfully finished installation.
     */
    InstallExtensionDialog.prototype._handleOk = function () {
        if (this._state === STATE_INSTALLED ||
                this._state === STATE_INSTALL_FAILED ||
                this._state === STATE_INSTALL_CANCELED ||
                this._state === STATE_CANCELING_HUNG ||
                this._state === STATE_NEEDS_UPDATE) {
            // In these end states, this is a "Close" button: just close the dialog and indicate
            // success.
            this._enterState(STATE_CLOSED);
        } else if (this._state === STATE_VALID_URL) {
            this._enterState(STATE_INSTALLING);
        } else if (this._state === STATE_ALREADY_INSTALLED) {
            this._enterState(STATE_OVERWRITE_CONFIRMED);
        }
    };

    /**
     * @private
     * Handle key up events on the document. We use this to detect the Esc key.
     */
    InstallExtensionDialog.prototype._handleKeyUp = function (e) {
        if (e.keyCode === KeyEvent.DOM_VK_ESCAPE) {
            this._handleCancel();
        }
    };

    /**
     * @private
     * Handle typing in the URL field.
     */
    InstallExtensionDialog.prototype._handleUrlInput = function (e) {
        var url     = this.$url.val().trim(),
            valid   = (url !== "");
        if (!valid && this._state === STATE_VALID_URL) {
            this._enterState(STATE_START);
        } else if (valid && this._state === STATE_START) {
            this._enterState(STATE_VALID_URL);
        }
    };

    /**
     * @private
     * Closes the dialog if it's not already closed. For unit testing only.
     */
    InstallExtensionDialog.prototype._close = function () {
        if (this._state !== STATE_CLOSED) {
            this._enterState(STATE_CLOSED);
        }
    };

    /**
     * Initialize and show the dialog.
     * @param {string=} urlToInstall If specified, immediately starts installing the given file as if the user had
     *     specified it.
     * @return {$.Promise} A promise object that will be resolved when the selected extension
     *     has finished installing, or rejected if the dialog is cancelled.
     */
    InstallExtensionDialog.prototype.show = function (urlToInstall) {
        if (this._state !== STATE_CLOSED) {
            // Somehow the dialog got invoked twice. Just ignore this.
            return this._dialogDeferred.promise();
        }

        var context = {
            Strings: Strings,
            isUpdate: this._isUpdate,
            includeBrowseExtensions: !!brackets.config.extension_listing_url
        };

        // We ignore the promise returned by showModalDialogUsingTemplate, since we're managing the
        // lifecycle of the dialog ourselves.
        Dialogs.showModalDialogUsingTemplate(Mustache.render(InstallDialogTemplate, context), false);

        this.$dlg          = $(".install-extension-dialog.instance");
        this.$url          = this.$dlg.find(".url").focus();
        this.$okButton     = this.$dlg.find(".dialog-button[data-button-id='ok']");
        this.$cancelButton = this.$dlg.find(".dialog-button[data-button-id='cancel']");
        this.$inputArea    = this.$dlg.find(".input-field");
        this.$msgArea      = this.$dlg.find(".message-field");
        this.$msg          = this.$msgArea.find(".message");
        this.$browseExtensionsButton = this.$dlg.find(".browse-extensions");

        this.$okButton.on("click", this._handleOk.bind(this));
        this.$cancelButton.on("click", this._handleCancel.bind(this));
        this.$url.on("input", this._handleUrlInput.bind(this));
        this.$browseExtensionsButton.on("click", function () {
            NativeApp.openURLInDefaultBrowser(brackets.config.extension_listing_url);
        });
        $(window.document.body).on("keyup.installDialog", this._handleKeyUp.bind(this));

        this._enterState(STATE_START);
        if (urlToInstall) {
            // Act as if the user had manually entered the URL.
            this.$url.val(urlToInstall);
            this._enterState(STATE_VALID_URL);
            this._enterState(STATE_INSTALLING);
        }

        this._dialogDeferred = new $.Deferred();
        return this._dialogDeferred.promise();
    };


    /** Mediates between this module and the Package extension-installation utils. Mockable for unit-testing. */
    function InstallerFacade(isLocalFile) {
        this._isLocalFile = isLocalFile;
    }

    InstallerFacade.prototype.install = function (url) {
        if (this.pendingInstall) {
            console.error("Extension installation already pending");
            return new $.Deferred().reject("DOWNLOAD_ID_IN_USE").promise();
        }

        if (this._isLocalFile) {
            var deferred = new $.Deferred();

            this.pendingInstall = {
                promise: deferred.promise(),
                cancel: function () {
                    // Can't cancel local zip installs
                }
            };

            Package.installFromPath(url).then(function (installationResult) {
                // Flag to keep zip files for local file installation
                installationResult.keepFile = true;
                deferred.resolve(installationResult);
            }, deferred.reject);
        } else {
            this.pendingInstall = Package.installFromURL(url);
        }

        // Store now since we'll null pendingInstall immediately if the promise was resolved synchronously
        var promise = this.pendingInstall.promise;

        var self = this;
        this.pendingInstall.promise.always(function () {
            self.pendingInstall = null;
        });

        return promise;
    };
    InstallerFacade.prototype.cancel = function () {
        this.pendingInstall.cancel();
    };

    /**
     * @private
     * Show a dialog that allows the user to enter the URL of an extension ZIP file to install.
     * @return {$.Promise} A promise object that will be resolved when the selected extension
     *     has finished installing, or rejected if the dialog is cancelled.
     */
    function showDialog() {
        var dlg = new InstallExtensionDialog(new InstallerFacade());
        return dlg.show();
    }

    /**
     * @private
     * Show the installation dialog and automatically begin installing the given URL.
     * @param {(string|File)=} urlOrFileToInstall If specified, immediately starts installing the given file as if the user had
     *     specified it.
     * @return {$.Promise} A promise object that will be resolved when the selected extension
     *     has finished installing, or rejected if the dialog is cancelled.
     */
    function installUsingDialog(urlOrFileToInstall, _isUpdate) {
        var isLocalFile = (urlOrFileToInstall instanceof File),
            dlg = new InstallExtensionDialog(new InstallerFacade(isLocalFile), _isUpdate);

        return dlg.show(urlOrFileToInstall.fullPath || urlOrFileToInstall);
    }

    /**
     * @private
     * Show the update dialog and automatically begin downloading the update from the given URL.
     * @param {string} urlToUpdate URL to download
     * @return {$.Promise} A promise object that will be resolved when the selected extension
     *     has finished downloading, or rejected if the dialog is cancelled.
     */
    function updateUsingDialog(urlToUpdate) {
        return installUsingDialog(urlToUpdate, true);
    }

    exports.showDialog          = showDialog;
    exports.installUsingDialog  = installUsingDialog;
    exports.updateUsingDialog   = updateUsingDialog;

    // Exposed for unit testing only
    exports._Dialog = InstallExtensionDialog;
});