UBNativeMessage.js

/*
 @author xmax, mpv
 */

var UB = require('./UB');
var EventEmitter = require('./events');
/**
 * Registered features.
 * @type {Object}
 */
UBNativeMessage.features = {
    extension: {
        host: 'none', UIName: 'NMUBExtension', minVersion: '1.0.0', installer: 'pgffhmifenmomiabibdpnceahangimdi' //downloads/UBBrowserNativeMessagesHostApp.exe
    },
    dstu: {
        host: 'com.inbase.dstu', UIName: 'NMFeatureDSTU', minVersion: '1.0.0.5', installer: 'models/DSTU/ub-extension/UBHostDSTUIITSetup{0}.exe', libraryName: 'UBHostDSTU.dll'
    },
    iit: {
        host: 'com.inbase.iit', UIName: 'NMFeatureIIT', minVersion: '1.0.0.5', installer: 'models/DSTU/ub-extension/UBHostDSTUIITSetup{0}.exe', libraryName: 'UBHostIIT.dll'
    },
    pdfsigner: {
        host: 'com.inbase.pdfsigner', UIName: 'NMFeaturePDFSigner', minVersion: '1.0.0.3', installer: 'models/PDF/ub-extension/UBHostPdfSignSetup{0}.' + (UB.isMac ? 'pkg' : 'exe'), libraryName: 'SET _LIB_NAME_IN_UBNATIVENMESSAGES.dll'
    },
    scanner: {
        host: 'com.inbase.scanner', UIName: 'NMFeatureScanner', minVersion: '1.0.0.4', installer: 'models/PDF/ub-extension/UBHostScannerSetup{0}.exe', libraryName: 'UBHostScanner.dll'
    },
    docedit: {
        host: 'com.inbase.docedit', UIName: 'NMFeatureDocEdit', minVersion: '1.0.0.1', installer: 'models/UB/ub-extension/UBHostDocEditSetup{0}.exe', libraryName: 'UBHostDocEdit.dll'
    }
};

UBNativeMessage.features.iit.minVersion = UBNativeMessage.features.dstu.minVersion;

/**
 * @classdesc
 * Class for communicate with native messages plugin `content script`.
 * DOM element with `id="ubExtensionPageMessageObj"` must be present on the target page.
 *
 * If target page is loaded into iframe then parent (iframe owner) page must contains a DOM element with `id="ubExtensionPageMessageObj"`.
 *
 * The preferred way to communicate with native messages plugin feature is a  UBNativeMessage descendants, for example {@link UBNativeScanner} for scanning etc.
 *
 * Usage:
 *
 *      var nm = new UBNativeMessage('scanner');
 *      nm.connect().done(UB.logDebug);
 *      nm.connect.then(function(nm){
 *          UB.logDebug('connected to feature version', nm.featureVersion);
 *          return nm.invoke('methodName', {a: 10, b: 20})
 *      }).then(UB.logDebug).then(nm.disconnect.bind(nm));
 *
 *
 *      var nm = new UBNativeMessage();
 *      nm.onMessage = function(message){
 *         console.log(message);
 *      };
 *      nm.onDisconnected = function(sender){
 *         console.log('disconnected');
 *      };
 *      nm.connect(5000).done( function(nm){
 *          nm.sendMessage({text: 'Message : Hello!'});
 *      });
 *
 * @constructor
 * @param {String} [feature] Feature we want from plugin. Feature<->application decoding is accessible via {@link UBNativeMessage#features} object
 */
function UBNativeMessage(feature){
    var
        me = this,
        __messageCounter = 0;

    me.getMessageId = function(){
        return 'm' + (++__messageCounter);
    };

    ++UBNativeMessage.prototype.idCounter;
    me.id = 'UBPlugin' + UBNativeMessage.prototype.idCounter;

    me.pendingMessages = {};
    /**
     * @readonly
     * @property {String} Feature native messages registered for
     */
    me.feature = feature || 'extension';
    /**
     * Name of plugin interface in host application.
     * @type {string}
     */
    me.pluginName = feature;
    me.hostAppName = UBNativeMessage.features[feature].host;
    if (!me.hostAppName){
        throw new Error('unknown feature ' + feature + ' for UBNativeMessage');
    }

    /**
     * Feature version. Defined after success connect() call.
     * @property {string} featureVersion
     */
    me.featureVersion = '';
    /**
     * Default operation timeout
     * @property {number} callTimeOut
     */
    me.callTimeOut = 30000;
    if (UB.isSecureBrowser) {
        me.eventElm = {};
        EventEmitter.call(me.eventElm);
        _.assign(me.eventElm, EventEmitter.prototype);
        me.eventElm.addEventListener = me.eventElm.addListener;
    } else {
        me.eventElm = document.getElementById('ubExtensionPageMessageObj');

        if (!me.eventElm && (!window.parent || (window.parent === window))) {
            throw new Error('Message exchange element with id="ubExtensionPageMessageObj" not found');
        }
    }

    // must be defined inside constructor for removeEventListener work properly
    me.onContentMessage = function(event){
        var msg, pending, messageID, msgType, totalParts, currentPart, data;
        msg = event.detail;
        if (!msg || !msg.hasOwnProperty('msgType') || !msg.hasOwnProperty('messageID') || !msg.hasOwnProperty('clientID')){
            console.error('Empty or invalid content message');
        }
        if (msg.clientID !== me.id){ // this is message to another UBNativeMessage instance
            return;
        }

        messageID = msg['messageID'];
        msgType = msg['msgType'];
        data = msg['data'];
        pending = me.pendingMessages[messageID];
        if (pending){
            clearTimeout(pending.timerID);
        }
        if (msgType === 'disconnected') {
            if (pending) { // disconnect is sended from this
                delete me.pendingMessages[messageID];
                pending.deffer.resolve(data);
            }
            me.doOnDisconnect(data);
        } else {
            if (msgType === 'notify'){
                if (!pending && me.onMessage){ // notification from plugin without messageID
                    me.onMessage.call(me, data);
                } else { // notification to request. Increase timeout
                    pending.timerID = setTimeout(function () { me.onMsgTimeOut(messageID);}, pending.timeoutValue);
                    pending.deffer.notify(data);
                }
            } else if (!pending){
                console.error('UBNativeMessage. unknown messageID:' + messageID);
            } else if (msgType === 'resolve'){
                if (msg.hasOwnProperty('part') && msg.hasOwnProperty('totalParts')){ // partial response
                    totalParts = msg['totalParts']; currentPart = msg['part'];
                    if (!pending.partials){
                        if (totalParts > 100){ //100 Mb limit
                            pending.deffer.reject(new UB.UBError('unknownError', 'UBNativeMessage. Result exceed 100Mb limit'));
                            delete me.pendingMessages[messageID];
                            throw new Error(new UB.UBError('unknownError', 'UBNativeMessage. Result exceed 100Mb limit'));
                        }
                        pending.partials = new Array(totalParts)
                    } else {
                        if ((totalParts !== pending.partials.length) || (currentPart >= totalParts)){
                            pending.deffer.reject('Invalid part count');
                            delete me.pendingMessages[messageID];
                            throw new Error('Invalid part count');
                        }
                    }
                    pending.partials[currentPart] = data;
                    if (_.indexOf(pending.partials, undefined) ===-1){ // all parts come - ready to resolve. lodash using is important here - Array.indexOf not wok with `undefined`
                        data = pending.partials.join('');
                        delete me.pendingMessages[messageID];
                        if ((data.charAt(0) === '{') || (data.charAt(0) === '[')) { // data is JSON
                            data = JSON.parse(data);
                        }
                        pending.deffer.resolve(data)
                    } else {
                        pending.timerID = setTimeout(function () { me.onMsgTimeOut(messageID);}, pending.timeoutValue);
                    }
                } else {
                    delete me.pendingMessages[messageID];
                    pending.deffer.resolve(data);
                }
            } else if (msgType === 'reject'){
                delete me.pendingMessages[messageID];
                var isUserMessage = false, err;
                if ( /<<<.*>>>/.test(data)){
                    data = data.match(/<<<(.*)>>>/)[1];
                    isUserMessage = true;
                }
                if (isUserMessage){
                    err = new UB.UBError(data);
                }  else {
                    err = new UB.UBError('unknownError', data);
                }
//                pending.deffer.reject(new Error(data));
                pending.deffer.reject(err);

            } else {
                throw new Error('UBNativeMessage. Invalid msgType type in: ' + msg);
            }
        }
    };
    me.eventElm.addEventListener('UBExtensionMsg', me.onContentMessage);
    /**
     * Called when disconnecting the plugin.
     * @property {Function} onDisconnected
     */
     me.onDisconnected = null;
    /**
     * Called when receive new `notify` message from host application not to invoked method.
     * @property {Function} onMessage
     */
    me.onMessage = null;
}

UBNativeMessage.versionToNumber = function (versionStr){
    var
        arr = versionStr.split('.'), mutliplier = 1, i, l =arr.length, res = 0;
    if (arr.length > 4){
        throw new Error ('Invalid version number ' + versionStr);
    }
    for(i=l-1; i>=0; i--){
        res += parseInt(arr[i], 10)*mutliplier; mutliplier *= (i===l-1) ? 10000 : 1000; //last number may be 4 digit 1.2.3.1234
    }
    return res;
};

/**
 * Invoke feature method with optional params
 * @param {String} methodName
 * @param {Object} [methodParams] Do not pass empty object {} here!
 * @param {Number} [timeout] operation timeout. Default to {@link UBNativeMessage#callTimeOut}
 * @return {Promise}
 */
UBNativeMessage.prototype.invoke = function(methodName, methodParams, timeout){
    var me = this,
        msgID = me.getMessageId(),
        messageToSend, defer, pendingRequest;

    if (!me.connected && methodName.substr(0,2) !== '__'){ //allow pseudo methods
        return Q.reject(new UB.UBError('unknownError', 'UBNativeMessage. Not connected. call connect() first'));
    }

    //methodParams = methodParams || null;
    timeout = timeout || me.callTimeOut;

    messageToSend = {clientID: me.id, messageID: msgID, method: methodName, params: methodParams};
    defer = Q.defer();
    pendingRequest = {
        request: null, //MPV - do not store - we do not need it!  messageToSend,
        deffer: defer,
        timerID: setTimeout(function(){
                me.onMsgTimeOut(msgID);
            }, timeout
        ),
        partials: null,
        timeoutValue: timeout || me.callTimeOut,
        stTime: (new Date()).getTime()
    };
    me.pendingMessages[msgID] = pendingRequest;
    // if (UB.isSecureBrowser) {
    //     if (methodName === '__extensionVersion') {
    //         me.eventElm.emit('UBExtensionMsg', {
    //             detail: {
    //                 clientID: me.id,
    //                 messageID: msgID,
    //                 msgType: 'resolve',
    //                 data: UBNativeMessage.features.extension.minVersion
    //             }
    //         });
    //     } else if (methodName === '__connect') {
    //         var path = require('path'),
    //             ffi =require(path.join(path.parse(process.execPath).dir, '..', 'ffi')),
    //             Library = ffi.Library;
    //
    //         me.doInvoke = new Library(
    //             path.join(path.parse(process.execPath).dir, UBNativeMessage.features[me.feature].libraryName),
    //             {'invoke': ['void', ['string', 'pointer'], { async: true }]}
    //         ).invoke;
    //         me.funcPtr = ffi.Callback('void', [ 'string' ],
    //             function(param) {
    //                 var detail = JSON.parse(param);
    //                 me.eventElm.emit('UBExtensionMsg', {
    //                     detail: detail
    //                 });
    //             }
    //         );
    //         messageToSend.method = 'getVersion';
    //         me.doInvoke(JSON.stringify(messageToSend), me.funcPtr, function(){});
    //     } else {
    //         me.doInvoke(JSON.stringify(messageToSend), me.funcPtr, function(){});
    //     }
    // } else 
	   if (me.iFarmeMode){
           window.parent.postMessage({detail: messageToSend, messageType: 'UBPageMsg'}, "*");
       }
       else {
           me.eventElm.dispatchEvent(new CustomEvent('UBPageMsg', {detail: messageToSend}));
       }
    return defer.promise;
};

/**
 * Return true if browser extension was installed
 * @returns {boolean}
 */
UBNativeMessage.extensionExists = function(){
    if (UB.isSecureBrowser) return true;

    var e;
    e = document.getElementById('ubExtensionPageMessageObj');
    if (window.parent && (window.parent !== window) ){
        return true; //check in connect
		//e = window.parent.document.getElementById('ubExtensionPageMessageObj');
    }

    return !!e && (e.getAttribute('data-extensionAttached') === 'YES');
};

UBNativeMessage.prototype.doOnDisconnect = function(reason){
    var me = this,
        rejections = me.pendingMessages;
    me.pendingMessages = {}; // prevent several rejection
    me.connected = false;
    if (rejections) {
        _.forEach(rejections, function (pendingRequest) {
            if (pendingRequest && pendingRequest.deffer) {
                pendingRequest.deffer.reject(reason);
            }
        });
    }
};


UBNativeMessage.prototype.onParentWinMessage = function(event){
    if (!event.data || (event.data.messageType !== 'UBExtensionMsg')) {
        return;
    }
    this.onContentMessage(event.data);
};



/**
 * Connect to native messages host. Check extension & host is installed and up to date (according to UBNativeMessage.features).
 * @param {Number} [timeOut] Connection timeOut in millisecond. Default to UBNativeMessage.callTimeOut
 * @returns {Promise} resolved to UBNativeMessage or rejected to installation/upgrade message
 */
UBNativeMessage.prototype.connect = function(timeOut){
    var me = this, defer, promise, timeId, onMessage;
    if (me.connected){
        return Q.resolve(me);
    } else {
        if (!UBNativeMessage.extensionExists()){
            return Q.reject(new UB.UBError(UBNativeMessage.createFeatureUpdateMsg('extension', '-', false)));
        } else {
            if (window.parent && (window.parent !== window)){ // in iframe
                defer = Q.defer();
                onMessage = function (event){
					if (!event.data || (event.data.messageType !== 'initUbExtensionParent')){
						return event;
					}
                    clearTimeout(timeId);
                    if (event.data.detail !== 'initUbExtensionReady'){
                        defer.reject(new UB.UBError(UBNativeMessage.createFeatureUpdateMsg('extension', '-', false)));
                        return event;
                    }
                    me.iFarmeMode = true;
                    window.removeEventListener("message", onMessage);
                    window.addEventListener("message", me.onParentWinMessage.bind(me), false);
                    defer.resolve(true);
                };
                window.addEventListener("message", onMessage, false);
                window.parent.postMessage({messageType: "initUbExtension"}, "*");
                timeId = setTimeout(function(){
                    defer.reject(new UB.UBError(UBNativeMessage.createFeatureUpdateMsg('extension', '-', false)));
                }, 1500);
                promise = defer.promise;
            } else {
                promise = Q.resolve(true);
            }
            return promise.then(function(){
                  return me.invoke('__extensionVersion');
            }).then(function(extensionVersion) {
                var versionNum = UBNativeMessage.versionToNumber(extensionVersion);
                if (versionNum < UBNativeMessage.versionToNumber(UBNativeMessage.features.extension.minVersion)) {
                    UB.logDebug('browser extension version', extensionVersion, 'is smaller when required', UBNativeMessage.features.extension.minVersion);
                    throw new UB.UBError(UBNativeMessage.createFeatureUpdateMsg('extension', extensionVersion, true));
                } else {
                    if (versionNum !== UBNativeMessage.versionToNumber(UBNativeMessage.features.extension.minVersion)){
                        UB.logDebug('Current version of extension', extensionVersion, 'is more than required', UBNativeMessage.features.extension.minVersion);
                    }
                    return true;
                }
            }).then(function(){
                return me.invoke('__connect', {hostAppName: me.hostAppName}, timeOut).then(function(featureVersion) {
                    var requiredVersion = UBNativeMessage.features[me.feature].minVersion;
                    me.connected = true;
                    me.featureVersion = featureVersion;
                    if (UBNativeMessage.versionToNumber(featureVersion) < UBNativeMessage.versionToNumber(requiredVersion)){
                        throw new UB.UBError(UBNativeMessage.createFeatureUpdateMsg(me.feature, featureVersion, true));
                    } else if (featureVersion !== requiredVersion){
                        UB.logDebug('Current version of feature', me.feature, featureVersion, 'is more than required', requiredVersion)
                    }
                    return me;
                }, function(reason){
                    UB.logError(reason);
                    throw new UB.UBError(UBNativeMessage.createFeatureUpdateMsg(me.feature, '-', false));
                });
            }).fail(function(reason){
                me.disconnect().done();
                throw reason;
            });
        }
    }
};

/**
 * Disconnect from native
 * @return {*}
 */
UBNativeMessage.prototype.disconnect = function(){
    var me = this;
    if (!me.connected){
       return Q.resolve(true);
    }
    return me.invoke('__disconnect').then(function(message){
        UB.logDebug('UBNativeMessage. Disconnected with message', message);
        me.connected = false;
        if (me.eventElm) {
            me.eventElm.removeEventListener('UBExtensionMsg', me.onContentMessage);
        }
        return true;
    });
};

UBNativeMessage.prototype.onMsgTimeOut = function(msgID){
    var me = this,
        pending;
    pending = me.pendingMessages[msgID];
    if (pending){
        pending.timerID = null;
        delete me.pendingMessages[msgID];
        pending.deffer.reject(new UB.UBError('unknownError', 'pluginMethodCallTimedOut'));
    }
};

/**
 * @private
 * @type {number}
 */
UBNativeMessage.prototype.idCounter = 0;

UBNativeMessage.createFeatureUpdateMsg = function(featureName, currentVersion, isUpdate){
    var
        featureInfo = UBNativeMessage.features[featureName],
        res, msg,
        installer = UB.format(featureInfo.installer, featureInfo.minVersion /*.replace(/\./g, '_')*/);

    msg = 'NM' + (isUpdate ? 'Update' : 'Install') + ((featureName === 'extension') ? 'Extension' + (UB.isOpera ? 'Opera': 'Chrome') : 'Feature');
    res = UB.format(UB.i18n(msg), UB.i18n(featureInfo.UIName), featureInfo.minVersion, currentVersion, installer);
    return res;
};

module.exports = UBNativeMessage;