Source: ptolemy/actor/lib/jjs/modules/userInterface/userInterface.js

// Below is the copyright agreement for the Ptolemy II system.
//
// Copyright (c) 2015-2017 The Regents of the University of California.
// All rights reserved.
//
// Permission is hereby granted, without written agreement and without
// license or royalty fees, to use, copy, modify, and distribute this
// software and its documentation for any purpose, provided that the above
// copyright notice and the following two paragraphs appear in all copies
// of this software.
//
// IN NO EVENT SHALL THE UNIVERSITY OF CALIFORNIA BE LIABLE TO ANY PARTY
// FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES
// ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN IF
// THE UNIVERSITY OF CALIFORNIA HAS BEEN ADVISED OF THE POSSIBILITY OF
// SUCH DAMAGE.
//
// THE UNIVERSITY OF CALIFORNIA SPECIFICALLY DISCLAIMS ANY WARRANTIES,
// INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
// MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE
// PROVIDED HEREUNDER IS ON AN "AS IS" BASIS, AND THE UNIVERSITY OF
// CALIFORNIA HAS NO OBLIGATION TO PROVIDE MAINTENANCE, SUPPORT, UPDATES,
// ENHANCEMENTS, OR MODIFICATIONS.
//
//
// Ptolemy II includes the work of others, to see those copyrights, follow
// the copyright link on the splash page or see copyright.htm.

/**
 * Implementation for Nashorn and CapeCode of the module supporting accessor
 * interaction through a user interface. The Server object starts
 * a server that accepts web socket connections and HTTP GET and POST requests.
 *
 * This module defines one class, UserInterface.  After constructing an instance of
 * UserInterface (using new), you can request that the user interface display HTML that
 * you provide using the display() function. Whenever display() is called,
 * whatever was previously displayed is replaced with your content in the user interface.
 * 
 * You can also specify resources that the HTML references (such as images)
 * by using the addResource() function. You should call this before providing
 * HTML that requires those resources.
 * Note that updating a resource with the same name will not normally result
 * in the web page being updated because user interfaces normally cache such resources.
 * If HTML content refers to a resource that has already been loaded (or more
 * precisely, that has the same name as a resource that has already been loaded),
 * then the user interface will not load the resource again, but rather will use the
 * previous version.  You can force the user interface to reload a resource by augmenting
 * the name with parameters (which will be ignored). For example, if you have
 * a resource named "image.jpg" that you wish to update it, then you can
 * specify HTML like this:
 * 
 *     <img src="image.jpg?count=n"/>
 * 
 * where **n** is a unique integer not previously seen by the user interface.
 * This will force the user interface to go back to the server to retrieve the resource.
 * 
 * You can also add listeners for data sent by the user interface using POST using
 * the addListener() function. So your HTML can include forms and buttons,
 * for example, that will POST JSON data.
 * 
 * You can specify optional header content when you construct the UserInterface object.
 * This is a good place to include scripts.  The HTML content provided to
 * UserInterface.display() can also include scripts. Those scripts can invoke functions
 * defined in the header or reference variables defined in the header.
 * 
 * Scripts in the header can invoke a require(**module**) function to use any
 * module supported by the browser host.  The 'util.js' module is automatically
 * included, so there is no need to explicitly require it.  For example, to use
 * the web-socket-client module, your script could include:
 * 
 *   var ws = require('web-socket-client.js');
 * 
 * The way this module works is that it starts a web server that accepts
 * websocket connections and HTTP GET and POST requests, and then it invokes
 * the system's default browser and points it at that web server. The
 * server provides HTML that contains a script to connect to the server
 * using a websocket. It then listens for messages on that websocket, and
 * when it receives them, it displays their contents (HTML) on the web page,
 * replacing whatever was there before. Hence, a highly dynamic stream of
 * pages can be provided.
 * 
 * The websocket can also accept incoming JSON data, in which case the UserInterface
 * object will emit a 'message' event.
 * 
 * @module userInterface
 * @author Edward A. Lee
 * @version $$Id$$
 */

// Stop extra messages from jslint.  Note that there should be no
// space between the / and the * and global.
/*globals Java, exports, module */
/*jshint globalstrict: true*/
"use strict";

var EventEmitter = require('events').EventEmitter;
var WebSocketHelper = Java.type('ptolemy.actor.lib.jjs.modules.webSocket.WebSocketHelper');
var WebSocketServerHelper = Java.type('ptolemy.actor.lib.jjs.modules.webSocket.WebSocketServerHelper');

/** Constructor for a UserInterface server.
 * 
 *  The options argument is a JSON object containing the following optional fields:
 *  * hostInterface: The IP address or name of the local interface for the server
 *    to listen on.  This defaults to "localhost", but if the host machine has more
 *    than one network interface, e.g. an Ethernet and WiFi interface, then you may
 *    need to specifically specify the IP address of that interface here.
 *  * port: The port on which to listen for connections (the default is 80,
 *    which is the default HTTP port).
 *    
 *    
 *  The optional header argument specifies any text to include in the header
 *  of the web page that is created.
 *  
 *  The web page is opened the first time display() is called.
 *
 *  This subclasses EventEmitter, emitting events 'listening' and 'connection'.
 *  A typical usage pattern looks like this:
 *
 *  <pre>
 *     var browser = new require('browser').UserInterface({'port':8082});
 *     browser.display('<h1>Hello</h1>');
 *     setTimeout(function() {
 *        browser.display('<h1>World!</h1>');
 *     }, 5000);
 *     browser.stop();
 *  </pre>
 *  
 *  This displays "Hello" and then changes it to "World!" after 5 seconds.
 *  The UserInterface is an event emitter, emitting events 'listening' and 'connection'.
 *  
 *  @param options The port and hostInterface options.
 *  @param header HTML to include in the page header.
 *  @param content HTML content to include in the page.
 */
exports.UserInterface = function (options, header, content) {
    this.server = null;
    this.count = 0;
    this.pendingSends = [];
    this.listeners = {};
    this.header = '';
    this.listening = false;
    if (header) {
        this.header = header;
    }
    this.content = '';
    if (content) {
        this.content = content;
    }
    this.open = false;
    
    options = options || {};
    
    if (typeof options.port === 'undefined' || options.port === null) {
        this.port = 80;
    } else {
        this.port = options.port;
    }
    
    if (typeof options.hostInterface === 'undefined' || options.hostInterface === null) {
        this.hostInterface = "localhost";
    } else {
        this.hostInterface = options.hostInterface;
    }

    // NOTE: I assume we don't need SSL because the server/browser interaction is only local.
    // Also, we are currently not using the back path through the websocket from the browser
    // to the server, but it's stype is set to JSON in case we later use it.
    this.server = WebSocketServerHelper.createServer(actor,
            this, this.hostInterface, false, '', '',
            this.port, 'application/json', 'text/html');
    var self = this;
    this.on('listening', function() {
        this.listening = true;
        // If there is content to display already, then open the page.
        // Otherwise, wait until there is content.
        if (self.content) {
            createTemplatePage.call(self);
            self.open = true;
        }        
    });
    this.server.startServer();
    
    this.browserLauncher = Java.type('ptolemy.actor.gui.BrowserLauncher');
};
util.inherits(exports.UserInterface, EventEmitter);

/** Add a listener for data posted on a specific path. Only one
 *  listener can be active for a given path. The last-added listener
 *  will be used.
 *  @param path The path to listen for POST requests on.
 *  @param listener A callback function with one argument.
 */
exports.UserInterface.prototype.addListener = function (path, listener) {
    this.listeners[path] = listener;
};

/** Add a resource to be served by the server.
 *  @param path The path to the resource.
 *  @param resource The resource to serve.
 *  @param contentType The content type of the resource.
 */
exports.UserInterface.prototype.addResource = function (path, resource, contentType) {
    if (!path.startsWith('/')) {
        path = '/' + path;
    }
    this.server.addResource(path, resource, contentType);
}

/** Invoke registered listeners upon the receipt of POST data.
 *  This is called by the helper when a POST request comes in.
 *  @param path The path where the POST was received.
 *  @param data The data that was transmitted.
 */
exports.UserInterface.prototype.post = function (path, data) {
  if (this.listeners[path]) {
    this.listeners[path].apply(null, [data]);
  } else {
    console.log("No listener registered for path: " + path);
  }
}

/** Display the specified HTML text.
 *  This replaces the main body of the page.
 *  @param html The HTML to display.
 */
exports.UserInterface.prototype.display = function (html) {
    var self = this;
    if (!this.open) {
        // Make sure the initial HTML is put in right from the start in case
        // the header or content includes onload callbacks.
        this.content = html;
        if (this.listening) {
            createTemplatePage.call(this);
            this.open = true;
        } else {
            this.on('listening', function() {
                createTemplatePage.call(self);
                self.open = true;
            });
        }
        // No need to send the HTML over the websocket.
        return;
    }
    // If there is a websocket connection, send the HTML over the
    // websocket. Otherwise, save the HTML to send it when the socket is
    // opened.
    var toSend = html.toString();
    // The string we send may have a prefix of the form <<id<<type<<
    // (see update() below), so if the html starts with '<<' we escape it.
    // Normally, it will not start that way since that is invalid HTML.
    if (toSend.startsWith('<<')) {
        toSend = toSend.replace('<<', 'REMOVEME<<');
    }
    if (this.helper && this.helper.isOpen()) {
        this.helper.send(toSend);
    } else {
        this.pendingSends.push(toSend);
    }
};

/** Stop the server. Note that this closing happens
 *  asynchronously. The server may not be closed when this returns.
 */
exports.UserInterface.prototype.stop = function () {
    if (this.helper && this.helper.isOpen()) {
        this.helper.close();
    }
    if (this.server !== null) {
        this.server.closeServer();
        this.server = null;
    }
    this.listening = false;
};

/** Update a DOM object with the specified ID.
 *  @param id The ID.
 *  @param property The type of the update. If this is "html", then the
 *   DOM object is updated by invoking the jQuery html() function it
 *   with the specified content as an argument. Otherwise, the property
 *   with name *property* is assigned the value of the content.
 *   If *property* is 'src', then in addition, the content is augmented
 *   with a suffix of the form '?count=*n*', where *n* is a unique number.
 *   This is so that the browser will be forced to reload the src rather than
 *   using any cached version it may have. This can be used, for example,
 *   to force an update to an img tag where a new image has been provided
 *   using addResource().
 *  @param content The content of the update, typically HTML to insert or
 *   a property value like src to set.
 */
exports.UserInterface.prototype.update = function(id, property, content) {
    if (property.equals('src')) {
        content += '?count=' + (this.count++);
    }
    var toSend = '<<' + id + '<<' + property + '<<' + content;
    if (this.helper && this.helper.isOpen()) {
        this.helper.send(toSend);
    } else {
        this.pendingSends.push(toSend);
    }
}

/** Notify that a handshake was successful and a websocket has been created.
 *  This is called by the helper class is not meant to be called by the JavaScript
 *  programmer. This server supports only one socket connection at a time, so if
 *  there already is one open, it first closes it.
 *  @param serverWebSocket The Java ServerWebSocket object.
 *  @param helper The helper in charge of this socket.
 */
exports.UserInterface.prototype._socketCreated = function (serverWebSocket, helper) {
    if (this.helper && this.helper.isOpen()) {
        this.helper.close();
    }
    this.helper = WebSocketHelper.createServerSocket(actor,
            this, serverWebSocket, helper, 'application/json', 'text/html');
    if (this.pendingSends.length > 0) {
        for (var i = 0; i < this.pendingSends.length; i++) {
            this.helper.send(this.pendingSends[i]);
        }
        this.pendingSends = [];
    }
};

/** Notify this object of a received message from the socket.
 *  This function attempts to parse the message as JSON and then
 *  emits a "message" event with the message as an argument.
 *  This function is called by the helper and should not be called
 *  by the user of this module.
 *  @param message The incoming message.
 */
exports.UserInterface.prototype._notifyIncoming = function (message) {
    try {
        message = JSON.parse(message);
    } catch (error) {
        this.emit('error', error);
        return;
    }
    // Assume the helper has already provided the correct type.
    this.emit("message", message);
};

/** Create the template page, which pulls in jquery, defines the
 *  require() function, and includes the util module. It uses the
 *  browsers WebSocket to open a socket to the server to listen
 *  for updates to the content. And it constructs the default page
 *  using the specified header and content.
 *  Finally, it launches the browser.
 */
function createTemplatePage() {
    // Create the template page.
    // This uses jquery because of its html() function, which unlike
    // just setting innerHTML, evaluates any scripts that might be contained.
    // NOTE: Using jquery installed in the browser host. Could use a more modern version, e.g.
    // https://ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js
    // But should use a local copy.
    // This assumes the browser host is installed here.
    var script = '<script src="/accessors/hosts/browser/modules/jquery.js"></script>\n\
        <script src="/accessors/hosts/browser/modules/require.js"></script>\n\
        <script>\n\
        var util = require("util.js");\n\
        if ("WebSocket" in window) {\n\
            var socket = new WebSocket("ws://localhost:'
        + this.port
        + '");\n\
            // Listen for new HTML content\n\
            socket.onmessage = function(event) {\n\
                var fr = new FileReader();\n\
                fr.onloadend = function() {\n\
                    var result = fr.result;\n\
                    if (result.startsWith("<<")) {\n\
                        // Received an update instead of HTML\n\
                        var split = result.split("<<");\n\
                        // NOTE: first item is an empty string.\n\
                        var id = split[1];\n\
                        var property = split[2];\n\
                        var content = split[3];\n\
                        if (property == "html") {\n\
                            // Use jQuery html function here so scripts in content are evaluated.\n\
                            $("#" + id).html(content);\n\
                        } else {\n\
                            var element = document.getElementById(id);\n\
                            var dom = element[property];\n\
                            element[property] = content;\n\
                        }\n\
                    } else {\n\
                        if (result.startsWith("REMOVEME<<")) {\n\
                            result = result.replace("REMOVEME<<", "<<");\n\
                        }\n\
                        $("body").html(result);\n\
                    }\n\
                };\n\
                fr.readAsText(event.data);\n\
            };\n\
        } else {\n\
            document.getElementById("result").innerHTML = "Your browser does not support websockets.";\n\
        }\n\
        // To ensure that the current page is unchanged after a POST, override\n\
        // the default submit behavior for any form.\n\
        jQuery(document).ready(function() {\n\
            var form = jQuery("form");\n\
            form.submit(function() {\n\
                var data = jQuery(this).serialize();\n\
                jQuery.post("/", data);\n\
                return false; // Prevent default handler from going to a new page.\n\
            });\n\
        });\n\
        </script>\n';
    var template = '<!DOCTYPE html><html><head><meta charset="UTF-8"><title>UserInterface Swarmlet Interface</title>'
            + script
            + this.header
            + '</head><body>'
            + this.content
            + '</body></html>'
    this.server.setResponse(template);
    this.browserLauncher.openURL('http://localhost:' + this.port);
}