adobe/brackets

View on GitHub
src/LiveDevelopment/LiveDevMultiBrowser.js

Summary

Maintainability
F
6 days
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.
 *
 */

/**
 * LiveDevelopment allows Brackets to launch a browser with a "live preview" that's
 * connected to the current editor.
 *
 * # STARTING
 *
 * To start a session call `open`. This will read the currentDocument from brackets,
 * launch it in the default browser, and connect to it for live editing.
 *
 * # STOPPING
 *
 * To stop a session call `close`. This will close the connection to the browser
 * (but will not close the browser tab).
 *
 * # STATUS
 *
 * Status updates are dispatched as `statusChange` jQuery events. The status
 * is passed as the first parameter and the reason for the change as the second
 * parameter. Currently only the "Inactive" status supports the reason parameter.
 * The status codes are:
 *
 *  0: Inactive
 *  1: Connecting (waiting for a browser connection)
 *  2: Active
 *  3: Out of sync
 *  4: Sync error
 *  5: Reloading (after saving JS changes)
 *  6: Restarting (switching context to a new HTML live doc)
 *
 * The reason codes are:
 * - null (Unknown reason)
 * - "explicit_close" (LiveDevelopment.close() was called)
 * - "navigated_away" (The browser changed to a location outside of the project)
 * - "detached_target_closed" (The tab or window was closed)
 */
define(function (require, exports, module) {
    "use strict";

    // Status Codes
    var STATUS_INACTIVE      = exports.STATUS_INACTIVE       =  0;
    var STATUS_CONNECTING    = exports.STATUS_CONNECTING     =  1;
    var STATUS_ACTIVE        = exports.STATUS_ACTIVE         =  2;
    var STATUS_OUT_OF_SYNC   = exports.STATUS_OUT_OF_SYNC    =  3;
    var STATUS_SYNC_ERROR    = exports.STATUS_SYNC_ERROR     =  4;
    var STATUS_RELOADING     = exports.STATUS_RELOADING      =  5;
    var STATUS_RESTARTING    = exports.STATUS_RESTARTING     =  6;

    var CommandManager       = require("command/CommandManager"),
        Commands             = require("command/Commands"),
        Dialogs              = require("widgets/Dialogs"),
        DefaultDialogs       = require("widgets/DefaultDialogs"),
        DocumentManager      = require("document/DocumentManager"),
        EditorManager        = require("editor/EditorManager"),
        EventDispatcher      = require("utils/EventDispatcher"),
        FileUtils            = require("file/FileUtils"),
        MainViewManager      = require("view/MainViewManager"),
        PreferencesDialogs   = require("preferences/PreferencesDialogs"),
        ProjectManager       = require("project/ProjectManager"),
        Strings              = require("strings"),
        _                    = require("thirdparty/lodash"),
        LiveDevelopmentUtils = require("LiveDevelopment/LiveDevelopmentUtils"),
        LiveDevServerManager = require("LiveDevelopment/LiveDevServerManager"),
        NodeSocketTransport  = require("LiveDevelopment/MultiBrowserImpl/transports/NodeSocketTransport"),
        LiveDevProtocol      = require("LiveDevelopment/MultiBrowserImpl/protocol/LiveDevProtocol"),
        DefaultLauncher      = require("LiveDevelopment/MultiBrowserImpl/launchers/Launcher");

    // Documents
    var LiveCSSDocument      = require("LiveDevelopment/MultiBrowserImpl/documents/LiveCSSDocument"),
        LiveHTMLDocument     = require("LiveDevelopment/MultiBrowserImpl/documents/LiveHTMLDocument");

    /**
     * @private
     * The live HTML document for the currently active preview.
     * @type {LiveHTMLDocument}
     */
    var _liveDocument;

    /**
     * @private
     * Live documents related to the active HTML document - for example, CSS files
     * that are used by the document.
     * @type {Object.<string: {LiveHTMLDocument|LiveCSSDocument}>}
     */
    var _relatedDocuments = {};

    /**
     * @private
     * Protocol handler that provides the actual live development API on top of the current transport.
     */
    var _protocol = LiveDevProtocol;

    /**
     * @private
     * Current browser launcher for preview.
     */
    var _launcher;

    /**
     * @private
     * Current live preview server
     * @type {BaseServer}
     */
    var _server;

    /**
     * @private
     * Determine which live document class should be used for a given document
     * @param {Document} document The document we want to create a live document for.
     * @return {function} The constructor for the live document class; will be a subclass of LiveDocument.
     */
    function _classForDocument(doc) {
        if (doc.getLanguage().getId() === "css") {
            return LiveCSSDocument;
        }

        if (LiveDevelopmentUtils.isHtmlFileExt(doc.file.fullPath)) {
            return LiveHTMLDocument;
        }

        return null;
    }

    /**
     * Returns true if the global Live Development mode is on (might be in the middle of connecting).
     * @return {boolean}
     */
    function isActive() {
        return exports.status > STATUS_INACTIVE;
    }

    /**
     * Returns the live document for a given path, or null if there is no live document for it.
     * @param {string} path
     * @return {?LiveDocument}
     */
    function getLiveDocForPath(path) {
        if (!_server) {
            return null;
        }

        return _server.get(path);
    }

    /**
     * @private
     * Close a live document.
     * @param {LiveDocument}
     */
    function _closeDocument(liveDocument) {
        liveDocument.off(".livedev");
        liveDocument.close();
    }

    /**
     * Removes the given CSS/JSDocument from _relatedDocuments. Signals that the
     * given file is no longer associated with the HTML document that is live (e.g.
     * if the related file has been deleted on disk).
     * @param {string} url Absolute URL of the related document
     */
    function _handleRelatedDocumentDeleted(url) {
        var liveDoc = _relatedDocuments[url];
        if (liveDoc) {
            delete _relatedDocuments[url];
        }

        if (_server) {
            _server.remove(liveDoc);
        }
        _closeDocument(liveDoc);
    }

    /**
     * Update the status. Triggers a statusChange event.
     * @param {number} status new status
     * @param {?string} closeReason Optional string key suffix to display to
     *     user when closing the live development connection (see LIVE_DEV_* keys)
     */
    function _setStatus(status, closeReason) {
        // Don't send a notification when the status didn't actually change
        if (status === exports.status) {
            return;
        }

        exports.status = status;

        var reason = status === STATUS_INACTIVE ? closeReason : null;
        exports.trigger("statusChange", status, reason);
    }

    /**
     * @private
     * Close all live documents.
     */
    function _closeDocuments() {
        if (_liveDocument) {
            _closeDocument(_liveDocument);
            _liveDocument = undefined;
        }

        Object.keys(_relatedDocuments).forEach(function (url) {
            _closeDocument(_relatedDocuments[url]);
            delete _relatedDocuments[url];
        });

        // Clear all documents from request filtering
        if (_server) {
            _server.clear();
        }
    }

    /**
     * @private
     * Returns the URL that we would serve the given path at.
     * @param {string} path
     * @return {string}
     */
    function _resolveUrl(path) {
        return _server && _server.pathToUrl(path);
    }

    /**
     * @private
     * Create a LiveDocument for a Brackets editor/document to manage communication between the
     * editor and the browser.
     * @param {Document} doc
     * @param {Editor} editor
     * @param {roots} roots
     * @return {?LiveDocument} The live document, or null if this type of file doesn't support live editing.
     */
    function _createLiveDocument(doc, editor, roots) {
        var DocClass = _classForDocument(doc),
            liveDocument;

        if (!DocClass) {
            return null;
        }

        liveDocument = new DocClass(_protocol, _resolveUrl, doc, editor, roots);

        liveDocument.on("errorStatusChanged.livedev", function (event, hasErrors) {
            if (isActive()) {
                _setStatus(hasErrors ? STATUS_SYNC_ERROR : STATUS_ACTIVE);
            }
        });

        return liveDocument;
    }

    /**
     * Documents are considered to be out-of-sync if they are dirty and
     * do not have "update while editing" support
     * @param {Document} doc
     * @return {boolean}
     */
    function _docIsOutOfSync(doc) {
        var liveDoc = _server && _server.get(doc.file.fullPath),
            isLiveEditingEnabled = liveDoc && liveDoc.isLiveEditingEnabled();

        return doc.isDirty && !isLiveEditingEnabled;
    }

    /**
     * Handles a notification from the browser that a stylesheet was loaded into
     * the live HTML document. If the stylesheet maps to a file in the project, then
     * creates a live document for the stylesheet and adds it to _relatedDocuments.
     * @param {$.Event} event
     * @param {string} url The URL of the stylesheet that was added.
     * @param {array} roots The URLs of the roots of the stylesheet (the css files loaded through <link>)
     */
    function _styleSheetAdded(event, url, roots) {
        var path = _server && _server.urlToPath(url),
            alreadyAdded = !!_relatedDocuments[url];

        // path may be null if loading an external stylesheet.
        // Also, the stylesheet may already exist and be reported as added twice
        // due to Chrome reporting added/removed events after incremental changes
        // are pushed to the browser
        if (!path || alreadyAdded) {
            return;
        }

        var docPromise = DocumentManager.getDocumentForPath(path);

        docPromise.done(function (doc) {
            if ((_classForDocument(doc) === LiveCSSDocument) &&
                    (!_liveDocument || (doc !== _liveDocument.doc))) {
                var liveDoc = _createLiveDocument(doc, doc._masterEditor, roots);
                if (liveDoc) {
                    _server.add(liveDoc);
                    _relatedDocuments[doc.url] = liveDoc;
                    liveDoc.on("updateDoc", function (event, url) {
                        var path = _server.urlToPath(url),
                            doc = getLiveDocForPath(path);
                        doc._updateBrowser();
                    });
                }
            }
        });
    }

    /**
     * @private
     * Determine an index file that can be used to start Live Development.
     * This function will inspect all files in a project to find the closest index file
     * available for currently opened document. We are searching for these files:
     *  - index.html
     *  - index.htm
     *
     * If the project is configured with a custom base url for live development, then
     * the list of possible index files is extended to contain these index files too:
     *  - index.php
     *  - index.php3
     *  - index.php4
     *  - index.php5
     *  - index.phtm
     *  - index.phtml
     *  - index.cfm
     *  - index.cfml
     *  - index.asp
     *  - index.aspx
     *  - index.jsp
     *  - index.jspx
     *  - index.shm
     *  - index.shml
     *
     * If a file was found, the promise will be resolved with the full path to this file. If no file
     * was found in the whole project tree, the promise will be resolved with null.
     *
     * @return {jQuery.Promise} A promise that is resolved with a full path
     * to a file if one could been determined, or null if there was no suitable index
     * file.
     */
    function _getInitialDocFromCurrent() {
        var doc = DocumentManager.getCurrentDocument(),
            refPath,
            i;

        // Is the currently opened document already a file we can use for Live Development?
        if (doc) {
            refPath = doc.file.fullPath;
            if (LiveDevelopmentUtils.isStaticHtmlFileExt(refPath) || LiveDevelopmentUtils.isServerHtmlFileExt(refPath)) {
                return new $.Deferred().resolve(doc);
            }
        }

        var result = new $.Deferred();

        var baseUrl = ProjectManager.getBaseUrl(),
            hasOwnServerForLiveDevelopment = (baseUrl && baseUrl.length);

        ProjectManager.getAllFiles().done(function (allFiles) {
            var projectRoot = ProjectManager.getProjectRoot().fullPath,
                containingFolder,
                indexFileFound = false,
                stillInProjectTree = true;

            if (refPath) {
                containingFolder = FileUtils.getDirectoryPath(refPath);
            } else {
                containingFolder = projectRoot;
            }

            var filteredFiltered = allFiles.filter(function (item) {
                var parent = FileUtils.getParentPath(item.fullPath);

                return (containingFolder.indexOf(parent) === 0);
            });

            var filterIndexFile = function (fileInfo) {
                if (fileInfo.fullPath.indexOf(containingFolder) === 0) {
                    if (FileUtils.getFilenameWithoutExtension(fileInfo.name) === "index") {
                        if (hasOwnServerForLiveDevelopment) {
                            if ((LiveDevelopmentUtils.isServerHtmlFileExt(fileInfo.name)) ||
                                    (LiveDevelopmentUtils.isStaticHtmlFileExt(fileInfo.name))) {
                                return true;
                            }
                        } else if (LiveDevelopmentUtils.isStaticHtmlFileExt(fileInfo.name)) {
                            return true;
                        }
                    } else {
                        return false;
                    }
                }
            };

            while (!indexFileFound && stillInProjectTree) {
                i = _.findIndex(filteredFiltered, filterIndexFile);

                // We found no good match
                if (i === -1) {
                    // traverse the directory tree up one level
                    containingFolder = FileUtils.getParentPath(containingFolder);
                    // Are we still inside the project?
                    if (containingFolder.indexOf(projectRoot) === -1) {
                        stillInProjectTree = false;
                    }
                } else {
                    indexFileFound = true;
                }
            }

            if (i !== -1) {
                DocumentManager.getDocumentForPath(filteredFiltered[i].fullPath).then(result.resolve, result.resolve);
                return;
            }

            result.resolve(null);
        });

        return result.promise();
    }

    /**
     * @private
     * Close the connection and the associated window
     * @param {boolean} doCloseWindow Use true to close the window/tab in the browser
     * @param {?string} reason Optional string key suffix to display to user (see LIVE_DEV_* keys)
     */
    function _close(doCloseWindow, reason) {
        if (exports.status !== STATUS_INACTIVE) {
            // Close live documents
            _closeDocuments();
            // Close all active connections
            _protocol.closeAllConnections();

            if (_server) {
                // Stop listening for requests when disconnected
                _server.stop();

                // Dispose server
                _server = null;
            }
        }
    //TODO: implement closeWindow together with launchers.
//        if (doCloseWindow) {
//
//        }
        _setStatus(STATUS_INACTIVE, reason || "explicit_close");
    }

    /**
     * Closes all active connections.
     * Returns a resolved promise for API compatibility.
     * @return {$.Promise} A resolved promise
     */
    function close() {
        _close(true);
        return new $.Deferred().resolve().promise();
    }

    /**
     * @private
     * Displays an error when no HTML file can be found to preview.
     */
    function _showWrongDocError() {
        Dialogs.showModalDialog(
            DefaultDialogs.DIALOG_ID_ERROR,
            Strings.LIVE_DEVELOPMENT_ERROR_TITLE,
            Strings.LIVE_DEV_NEED_HTML_MESSAGE
        );
    }

    /**
     * @private
     * Displays an error when the server for live development files can't be started.
     */
    function _showLiveDevServerNotReadyError() {
        Dialogs.showModalDialog(
            DefaultDialogs.DIALOG_ID_ERROR,
            Strings.LIVE_DEVELOPMENT_ERROR_TITLE,
            Strings.LIVE_DEV_SERVER_NOT_READY_MESSAGE
        );
    }

    /**
     * @private
     * Creates the main live document for a given HTML document and notifies the server it exists.
     * TODO: we should really maintain the list of live documents, not the server.
     * @param {Document} doc
     */
    function _createLiveDocumentForFrame(doc) {
        // create live document
        doc._ensureMasterEditor();
        _liveDocument = _createLiveDocument(doc, doc._masterEditor);
        _server.add(_liveDocument);
    }


     /**
     * Launches the given URL in the default browser.
     * @param {string} url
     * TODO: launchers for multiple browsers
     */
    function _launch(url) {
        // open default browser
        // TODO: fail?
        //
        _launcher.launch(url);
    }

    /**
     * @private
     * Launches the given document in the browser, given that a live document has already
     * been created for it.
     * @param {Document} doc
     */
    function _open(doc) {
        if (doc && _liveDocument && doc === _liveDocument.doc) {
            if (_server) {
                // Launch the URL in the browser. If it's the first one to connect back to us,
                // our status will transition to ACTIVE once it does so.
                if (exports.status < STATUS_ACTIVE) {
                    _launch(_server.pathToUrl(doc.file.fullPath));
                }
                if (exports.status === STATUS_RESTARTING) {
                    // change page in browser
                    _protocol.navigate(_server.pathToUrl(doc.file.fullPath));
                }

                _protocol
                    // TODO: timeout if we don't get a connection within a certain time
                    .on("ConnectionConnect.livedev", function (event, msg) {
                        // check for the first connection
                        if (_protocol.getConnectionIds().length === 1) {
                            // check the page that connection comes from matches the current live document session
                            if (_liveDocument && (msg.url === _resolveUrl(_liveDocument.doc.file.fullPath))) {
                                _setStatus(STATUS_ACTIVE);
                            }
                        }
                    })
                    .on("ConnectionClose.livedev", function (event, msg) {
                        // close session when the last connection was closed
                        if (_protocol.getConnectionIds().length === 0) {
                            setTimeout(function () {
                                if (_protocol.getConnectionIds().length === 0 &&
                                        exports.status <= STATUS_ACTIVE) {
                                    _close(false, "detached_target_closed");
                                }
                            }, 5000);
                        }
                    })
                    // extract stylesheets and create related LiveCSSDocument instances
                    .on("DocumentRelated.livedev", function (event, msg) {
                        var relatedDocs = msg.related;
                        var docs = Object.keys(relatedDocs.stylesheets);
                        docs.forEach(function (url) {
                            _styleSheetAdded(null, url, relatedDocs.stylesheets[url]);
                        });
                    })
                    // create new LiveCSSDocument if a new stylesheet is added
                    .on("StylesheetAdded.livedev", function (event, msg) {
                        _styleSheetAdded(null, msg.href, msg.roots);
                    })
                    // remove LiveCSSDocument instance when stylesheet is removed
                    .on("StylesheetRemoved.livedev", function (event, msg) {
                        _handleRelatedDocumentDeleted(msg.href);
                    });
            } else {
                console.error("LiveDevelopment._open(): No server active");
            }
        } else {
            // Unlikely that we would get to this state where
            // a connection is in process but there is no current
            // document
            close();
        }
    }

    /**
     * @private
     * Creates the live document in preparation for launching the
     * preview of the given document, then launches it. (The live document
     * must already exist before we launch it so that the server can
     * ask it for the instrumented version of the document when the browser
     * requests it.)
     * TODO: could probably just consolidate this with _open()
     * @param {Document} doc
     */
    function _doLaunchAfterServerReady(initialDoc) {

        _createLiveDocumentForFrame(initialDoc);

        // start listening for requests
        _server.start();

        // open browser to the url
        _open(initialDoc);
    }

    /**
     * @private
     * Create the server in preparation for opening a live preview.
     * @param {Document} doc The document we want the server for. Different servers handle
     * different types of project (a static server for when no app server is configured,
     * vs. a user server when there is an app server set in File > Project Settings).
     */
    function _prepareServer(doc) {
        var deferred = new $.Deferred(),
            showBaseUrlPrompt = false;

        _server = LiveDevServerManager.getServer(doc.file.fullPath);

        // Optionally prompt for a base URL if no server was found but the
        // file is a known server file extension
        showBaseUrlPrompt = !_server && LiveDevelopmentUtils.isServerHtmlFileExt(doc.file.fullPath);

        if (showBaseUrlPrompt) {
            // Prompt for a base URL
            PreferencesDialogs.showProjectPreferencesDialog("", Strings.LIVE_DEV_NEED_BASEURL_MESSAGE)
                .done(function (id) {
                    if (id === Dialogs.DIALOG_BTN_OK && ProjectManager.getBaseUrl()) {
                        // If base url is specifed, then re-invoke _prepareServer() to continue
                        _prepareServer(doc).then(deferred.resolve, deferred.reject);
                    } else {
                        deferred.reject();
                    }
                });
        } else if (_server) {
            // Startup the server
            var readyPromise = _server.readyToServe();
            if (!readyPromise) {
                _showLiveDevServerNotReadyError();
                deferred.reject();
            } else {
                readyPromise.then(deferred.resolve, function () {
                    _showLiveDevServerNotReadyError();
                    deferred.reject();
                });
            }
        } else {
            // No server found
            deferred.reject();
        }

        return deferred.promise();
    }

    /**
     * @private
     * MainViewManager.currentFileChange event handler.
     * When switching documents, close the current preview and open a new one.
     */
    function _onFileChange() {
        var doc = DocumentManager.getCurrentDocument();
        if (!isActive() || !doc) {
            return;
        }

        // close the current session and begin a new session
        var docUrl = _server && _server.pathToUrl(doc.file.fullPath),
            isViewable = _server && _server.canServe(doc.file.fullPath);

        if (_liveDocument.doc.url !== docUrl && isViewable) {
            // clear live doc and related docs
            _closeDocuments();
            // create new live doc
            _createLiveDocumentForFrame(doc);
            _setStatus(STATUS_RESTARTING);
            _open(doc);

        }
    }


    /**
     * Open a live preview on the current docuemnt.
     */
    function open() {
        // TODO: need to run _onDocumentChange() after load if doc != currentDocument here? Maybe not, since activeEditorChange
        // doesn't trigger it, while inline editors can still cause edits in doc other than currentDoc...
        _getInitialDocFromCurrent().done(function (doc) {
            var prepareServerPromise = (doc && _prepareServer(doc)) || new $.Deferred().reject(),
                otherDocumentsInWorkingFiles;

            if (doc && !doc._masterEditor) {
                otherDocumentsInWorkingFiles = MainViewManager.getWorkingSetSize(MainViewManager.ALL_PANES);
                MainViewManager.addToWorkingSet(MainViewManager.ACTIVE_PANE, doc.file);

                if (!otherDocumentsInWorkingFiles) {
                    CommandManager.execute(Commands.CMD_OPEN, { fullPath: doc.file.fullPath });
                }
            }

            // wait for server (StaticServer, Base URL or file:)
            prepareServerPromise
                .done(function () {
                    _setStatus(STATUS_CONNECTING);
                    _doLaunchAfterServerReady(doc);
                })
                .fail(function () {
                    _showWrongDocError();
                });
        });
    }

    /**
     * For files that don't support as-you-type live editing, but are loaded by live HTML documents
     * (e.g. JS files), we want to reload the full document when they're saved.
     * @param {$.Event} event
     * @param {Document} doc
     */
    function _onDocumentSaved(event, doc) {
        if (!isActive() || !_server) {
            return;
        }

        var absolutePath            = doc.file.fullPath,
            liveDocument            = absolutePath && _server.get(absolutePath),
            liveEditingEnabled      = liveDocument && liveDocument.isLiveEditingEnabled  && liveDocument.isLiveEditingEnabled();

        // Skip reload if the saved document has live editing enabled
        if (liveEditingEnabled) {
            return;
        }

        // reload the page if the given document is a JS file related
        // to the current live document.
        if (_liveDocument.isRelated(absolutePath)) {
            if (doc.getLanguage().getId() === "javascript") {
                _setStatus(STATUS_RELOADING);
                _protocol.reload();
            }
        }
    }

    /**
     * For files that don't support as-you-type live editing, but are loaded by live HTML documents
     * (e.g. JS files), we want to show a dirty indicator on the live development icon when they
     * have unsaved changes, so the user knows s/he needs to save in order to have the page reload.
     * @param {$.Event} event
     * @param {Document} doc
     */
    function _onDirtyFlagChange(event, doc) {
        if (!isActive() || !_server) {
            return;
        }

        var absolutePath = doc.file.fullPath;

        if (_liveDocument.isRelated(absolutePath)) {
            // Set status to out of sync if dirty. Otherwise, set it to active status.
            _setStatus(_docIsOutOfSync(doc) ? STATUS_OUT_OF_SYNC : STATUS_ACTIVE);
        }
    }

    /**
     * Sets the current transport mechanism to be used by the live development protocol
     * (e.g. socket server, iframe postMessage, etc.)
     * The low-level transport. Must provide the following methods:
     *
     * - start(): Initiates transport (eg. creates Web Socket server).
     * - send(idOrArray, string): Dispatches the given protocol message (provided as a JSON string) to the given client ID
     *   or array of client IDs. (See the "connect" message for an explanation of client IDs.)
     * - close(id): Closes the connection to the given client ID.
     * - getRemoteScript(): Returns a script that should be injected into the page's HTML in order to handle the remote side
     *   of the transport. Should include the "<script>" tags. Should return null if no injection is necessary.
     *
     * It must also dispatch the following jQuery events:
     *
     * - "connect": When a target browser connects back to the transport. Must provide two parameters:
     *   - clientID - a unique number representing this connection
     *   - url - the URL of the page in the target browser that's connecting to us
     * - "message": When a message is received by the transport. Must provide two parameters:
     *   - clientID - the ID of the client sending the message
     *   - message - the text of the message as a JSON string
     * - "close": When the remote browser closes the connection. Must provide one parameter:
     *   - clientID - the ID of the client closing the connection
     *
     * @param {{launch: function(string), send: function(number|Array.<number>, string), close: function(number), getRemoteScript: function(): ?string}} transport
     */
    function setTransport(transport) {
        _protocol.setTransport(transport);
    }

    /**
     * Sets the current browser launcher mechanism to be used by live development
     * (e.g., default browser, iframe-based browser, etc.)
     * The launcher must provide the following method:
     *
     * - launch(url): Launch the given URL in the appropriate browser.
     *
     * @param {{launch: function(string)}} launcher
     */
    function setLauncher(launcher) {
        if (!(launcher && launcher.launch)) {
            console.log("Invalid launcher object: ", launcher, new Error("LiveDevMultiBrowser.setLauncher()"));
            return;
        }
        _launcher = launcher;
    }

    /**
     * Initialize the LiveDevelopment module.
     */
    function init(config) {
        exports.config = config;
        MainViewManager
            .on("currentFileChange", _onFileChange);
        DocumentManager
            .on("documentSaved", _onDocumentSaved)
            .on("dirtyFlagChange", _onDirtyFlagChange);
        ProjectManager
            .on("beforeProjectClose beforeAppClose", close);

        // Default transport for live connection messages - can be changed
        setTransport(NodeSocketTransport);

        // Default launcher for preview browser - can be changed
        setLauncher(DefaultLauncher);

        // Initialize exports.status
        _setStatus(STATUS_INACTIVE);
    }

    function getLiveDocForEditor(editor) {
        if (!editor) {
            return null;
        }
        return getLiveDocForPath(editor.document.file.fullPath);
    }

    /**
     *  Enable highlighting
     */
    function showHighlight() {
        var doc = getLiveDocForEditor(EditorManager.getActiveEditor());

        if (doc && doc.updateHighlight) {
            doc.updateHighlight();
        }
    }

    /**
     * Hide any active highlighting
     */
    function hideHighlight() {
        if (_protocol) {
            _protocol.evaluate("_LD.hideHighlight()");
        }
    }

    /**
     * Redraw highlights
     */
    function redrawHighlight() {
        if (_protocol) {
            _protocol.evaluate("_LD.redrawHighlights()");
        }
    }

    /**
     * Originally unload and reload agents. It doesn't apply for this new implementation.
     * @return {jQuery.Promise} Already resolved promise.
     */
    function reconnect() {
        return $.Deferred().resolve();
    }

    /**
     * Reload current page in all connected browsers.
     */
    function reload() {
        if (_protocol) {
            _protocol.reload();
        }
    }

    /**
     * Returns current project server config. Copied from original LiveDevelopment.
     */
    function getCurrentProjectServerConfig() {
        return {
            baseUrl: ProjectManager.getBaseUrl(),
            pathResolver: ProjectManager.makeProjectRelativeIfPossible,
            root: ProjectManager.getProjectRoot().fullPath
        };
    }

    /**
     * @private
     * Returns the base URL of the current server serving the active live document, or null if
     * there is no active live document.
     * @return {?string}
     */
    function getServerBaseUrl() {
        return _server && _server.getBaseUrl();
    }

    // for unit testing only
    function _getCurrentLiveDoc() {
        return _liveDocument;
    }

    EventDispatcher.makeEventDispatcher(exports);

    // For unit testing
    exports._server                   = _server;
    exports._getCurrentLiveDoc        = _getCurrentLiveDoc;
    exports._getInitialDocFromCurrent = _getInitialDocFromCurrent;

    // Export public functions
    exports.open                = open;
    exports.close               = close;
    exports.reconnect           = reconnect;
    exports.reload              = reload;
    exports.getLiveDocForPath   = getLiveDocForPath;
    exports.showHighlight       = showHighlight;
    exports.hideHighlight       = hideHighlight;
    exports.redrawHighlight     = redrawHighlight;
    exports.init                = init;
    exports.isActive            = isActive;
    exports.getServerBaseUrl    = getServerBaseUrl;
    exports.getCurrentProjectServerConfig = getCurrentProjectServerConfig;
    exports.setTransport        = setTransport;
    exports.setLauncher         = setLauncher;
});