/**
* Native messaging enables an extension to exchange
* messages with a native application installed on the user's computer.
* Read more in <a href="https://developer.mozilla.org/en-US/Add-ons/WebExtensions/Native_messaging">MDN Native messaging documentation</a>
*
* @module UBNativeMessage
* @memberOf module:@unitybase/ub-pub
* @author xmax, mpv
*/
module.exports = UBNativeMessage
const ubUtils = require('./utils')
const i18n = require('./i18n').i18n
const EventEmitter = require('./events')
/**
* Native messages feature description
* @typedef {Object} NMFeatureConfig
* @property {string} host NativeMessages host
* @property {string} UIName Name showed to user for feature install/update message. Will be translated using UB.i18n
* @property {string} minVersion Minimal supported feature version.
* @property {string} installer URL for downloading feature installer
* @property {string} [libraryName] In case client is a secure browser (UnityBase DE) this library is used instead of process to communicate with feature
*/
/**
* Registered features.
* Other models can add his own features here using script in initModel.js
* @type {Object.<string, NMFeatureConfig>}
*/
const NM_EXTENSION_FEATURE = {
host: 'none', UIName: 'NMUBExtension', minVersion: '1.0.0', installer: 'pgffhmifenmomiabibdpnceahangimdi' // downloads/UBBrowserNativeMessagesHostApp.exe
}
/**
* @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.
*
* @example
// without systemJS:
// const nmScannerModule = require('@ub-e/nm-scanner')
// nmScannerModule.connect().then(...)
System.import('@ub-e/nm-scanner').then(function (nmScannerModule) {
return nmScannerModule.connect()
}).then(function (nmScanner) {
return nmScanner.scan()
}).then(UB.logDebug)
// for debugging:
var nm = new UBNativeMessage();
nm.onMessage = function(message){
console.log(message);
};
nm.onDisconnected = function(sender){
console.log('disconnected');
};
nm.connect(5000).then( function(nm){
nm.sendMessage({text: 'Message : Hello!'});
});
nm.invoke('methodName', {a: 'method param'})
* @constructor
* @param {NMFeatureConfig} featureConfig Feature we want from plugin
*/
function UBNativeMessage (featureConfig) {
const me = this
let __messageCounter = 0
me.getMessageId = function () {
return 'm' + (++__messageCounter)
}
/**
* @type {NMFeatureConfig}
* @private
*/
me._cfg = Object.assign({}, featureConfig)
++UBNativeMessage.prototype.idCounter
me.id = 'UBPlugin' + UBNativeMessage.prototype.idCounter
me.pendingMessages = {}
if (!me._cfg.host) {
throw new Error('Host not defined in UBNativeMessage feature config')
}
EventEmitter.call(me)
Object.assign(me, EventEmitter.prototype)
/**
* Feature version. Defined after success connect() call.
* @property {string} featureVersion
*/
me.featureVersion = ''
/**
* Default operation timeout
* @property {number} callTimeOut
*/
me.callTimeOut = 30000
if (ubUtils.isSecureBrowser) {
me.eventElm = {}
EventEmitter.call(me.eventElm)
Object.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) {
const msg = event.detail
// eslint-disable-next-line no-prototype-builtins
if (!msg || !msg.hasOwnProperty('msgType') || !msg.hasOwnProperty('messageID') || !msg.hasOwnProperty('clientID')) {
console.error('Empty or invalid content message')
}
if ((msg.messageID === -1) && ((msg.clientID === '') || (msg.clientID === me._cfg.host)) && (msg.msgType = 'reject')) {
// native host connection error. For unknown reason clientID & messageId is lost in such type of message (Chrome)
// In FF (75 at last) clientID === me._cfg.host
// there is slight chance what message not for this Client (in case of several UBNativeMessages instances),
// but I (MPV) do not know how to solve this
const lastMsgID = Object.keys(me.pendingMessages)[0]
let message = me.pendingMessages[lastMsgID]
if (message) {
delete me.pendingMessages[lastMsgID]
} else {
message = me.__FFLastMsg
}
if (!message) return // this instance do not have any pending messages - try another UBNativeMessage instance
clearTimeout(message.timerID)
me.onMsgTimeOut(message)
return
}
if (msg.clientID !== me.id) { // this is message to another UBNativeMessage instance
return
}
const messageID = msg.messageID
const msgType = msg.msgType
let data = msg.data
const pending = me.pendingMessages[messageID]
if (pending) {
clearTimeout(pending.timerID)
}
if (msgType === 'disconnected') {
if (pending) {
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(data)
} else { // notification to request. Increase timeout
pending.timerID = setTimeout(function () { me.onMsgTimeOut(messageID) }, pending.timeoutValue)
/**
* Fired for {@link UBNativeMessage} instance when got a notify message. Event can accept 2 args (data: {], messageID: number)
* @event notify
*/
me.emit('notify', me, data, messageID)
}
} else if (!pending) {
console.error('UBNativeMessage. unknown messageID:' + messageID)
} else if (msgType === 'resolve') {
// eslint-disable-next-line no-prototype-builtins
if (msg.hasOwnProperty('part') && msg.hasOwnProperty('totalParts')) { // partial response
const totalParts = msg.totalParts
const currentPart = msg.part
if (!pending.partials) {
if (totalParts > 100) { // 100 Mb limit
pending.deffer.reject(new ubUtils.UBError('unknownError', 'UBNativeMessage. Result exceed 100Mb limit'))
delete me.pendingMessages[messageID]
throw new Error(new ubUtils.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 (!pending.partials.includes(undefined)) { // all parts come - ready to resolve
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]
let isUserMessage = false
if (/<<<.*>>>/.test(data)) {
data = data.match(/<<<(.*)>>>/)[1]
isUserMessage = true
}
const err = isUserMessage ? new ubUtils.UBError(data) : new ubUtils.UBError('unknownError', 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
/**
* In case instantiated inside iFrame targetOrign initialized to parent document Origin
* and used as targetOrign parameter for postMessage to prevent XSS attack
* @type {string}
*/
me.targetOrign = '*'
/**
* reference to the window.parent in case we are in iFrame
* @type {undefined}
*/
me.targetPage = undefined
}
/**
* Convert a semantic version `x.xx.xx.xxxx` to the integer for comparision
* @param {String} versionStr
* @return {number}
*/
function versionToNumber (versionStr) {
const arr = versionStr.split('.')
if (arr.length > 4) {
throw new Error('Invalid version number ' + versionStr)
}
let multiplier = 1
let res = 0
const L = arr.length
for (let i = L - 1; i >= 0; i--) {
res += parseInt(arr[i], 10) * multiplier; multiplier *= (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) {
const me = this
if (!me.connected && methodName.substr(0, 2) !== '__') { // allow pseudo methods
return Promise.reject(new ubUtils.UBError('unknownError', 'UBNativeMessage. Not connected. call connect() first'))
}
// methodParams = methodParams || null;
timeout = timeout || me.callTimeOut
const msgID = me.getMessageId()
const messageToSend = { clientID: me.id, messageID: msgID, method: methodName, params: methodParams }
return new Promise((resolve, reject) => {
me.pendingMessages[msgID] = {
request: null, // MPV - do not store - we do not need it! messageToSend,
deffer: { resolve, reject },
timerID: setTimeout(function () { me.onMsgTimeOut(msgID) }, timeout),
partials: null,
timeoutValue: timeout || me.callTimeOut,
stTime: Date.now()
}
// FF 75 in case of connect error me.pendingMessages became {}
me.__FFLastMsg = me.pendingMessages[msgID]
// if (UB.isSecureBrowser) {
// if (methodName === '__extensionVersion') {
// me.eventElm.emit('UBExtensionMsg', {
// detail: {
// clientID: me.id,
// messageID: msgID,
// msgType: 'resolve',
// data: NM_EXTENSION_FEATURE.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, me._cfg.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) {
me.targetPage.postMessage({ detail: messageToSend, messageType: 'UBPageMsg' }, me.targetOrign)
} else {
// eslint-disable-next-line no-undef
me.eventElm.dispatchEvent(new CustomEvent('UBPageMsg', { detail: messageToSend }))
}
})
}
/**
* Return true if browser extension was installed
* @returns {boolean}
*/
UBNativeMessage.extensionExists = function () {
if (ubUtils.isSecureBrowser) return true
const e = document.getElementById('ubExtensionPageMessageObj')
if (window.parent && (window.parent !== window)) {
return true // check in connect
}
return !!e && (e.getAttribute('data-extensionAttached') === 'YES')
}
UBNativeMessage.prototype.doOnDisconnect = function (reason) {
const me = this
const rejections = me.pendingMessages
me.pendingMessages = {} // prevent several rejection
me.connected = false
if (rejections) {
Object.keys(rejections).forEach(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 feature config passed to constructor).
* @param {Number} [timeOut] Connection timeOut in millisecond. Default to UBNativeMessage.callTimeOut
* @returns {Promise<UBNativeMessage>} resolved to UBNativeMessage or rejected to installation/upgrade message
*/
UBNativeMessage.prototype.connect = function (timeOut) {
const me = this
let promise
if (me.connected) {
return Promise.resolve(me)
} else {
if (!UBNativeMessage.extensionExists()) {
return Promise.reject(new ubUtils.UBError(createFeatureUpdateMsg(NM_EXTENSION_FEATURE, '-', false)))
} else {
if (window.parent && (window.parent !== window)) { // in iframe
me.targetOrign = new URL(document.referrer).origin
me.targetPage = window.parent
promise = new Promise((resolve, reject) => {
// eslint-disable-next-line prefer-const
let timeId
const onMessage = function (event) {
if (!event.data || (event.data.messageType !== 'initUbExtensionParent')) {
return event
}
clearTimeout(timeId)
if (event.data.detail !== 'initUbExtensionReady') {
reject(new ubUtils.UBError(createFeatureUpdateMsg(NM_EXTENSION_FEATURE, '-', false)))
return event
}
me.iFarmeMode = true
window.removeEventListener('message', onMessage)
window.addEventListener('message', me.onParentWinMessage.bind(me), false)
resolve(true)
}
window.addEventListener('message', onMessage, false)
me.targetPage.postMessage({ messageType: 'initUbExtension' }, me.targetOrign)
timeId = setTimeout(function () {
reject(new ubUtils.UBError(createFeatureUpdateMsg(NM_EXTENSION_FEATURE, '-', false)))
}, 1500)
})
} else {
promise = Promise.resolve(true)
}
return promise.then(function () {
return me.invoke('__extensionVersion')
}).then(function (extensionVersion) {
const versionNum = versionToNumber(extensionVersion)
if (versionNum < versionToNumber(NM_EXTENSION_FEATURE.minVersion)) {
ubUtils.logDebug('browser extension version', extensionVersion, 'smaller than required', NM_EXTENSION_FEATURE.minVersion)
throw new ubUtils.UBError(createFeatureUpdateMsg('extension', extensionVersion, true))
} else {
if (versionNum !== versionToNumber(NM_EXTENSION_FEATURE.minVersion)) {
ubUtils.logDebug('Current extension version', extensionVersion, 'higher than required', NM_EXTENSION_FEATURE.minVersion)
}
return true
}
}).then(function () {
return me.invoke('__connect', { hostAppName: me._cfg.host }, timeOut).then(function (featureVersion) {
const requiredVersion = me._cfg.minVersion
me.connected = true
me.featureVersion = featureVersion
if (versionToNumber(featureVersion) < versionToNumber(requiredVersion)) {
throw new ubUtils.UBError(createFeatureUpdateMsg(me._cfg, featureVersion, true))
} else if (featureVersion !== requiredVersion) {
ubUtils.logDebug('Current feature version', me._cfg.host, featureVersion, 'higher than required', requiredVersion)
}
return me
}, function (reason) {
ubUtils.logError(reason)
throw new ubUtils.UBError(createFeatureUpdateMsg(me._cfg, '-', false))
})
}).catch(function (reason) {
me.disconnect()
throw reason
})
}
}
}
/**
* Disconnect from native
* @return {*}
*/
UBNativeMessage.prototype.disconnect = function () {
const me = this
if (!me.connected) {
return Promise.resolve(true)
}
return me.invoke('__disconnect').then(function (message) {
ubUtils.logDebug('UBNativeMessage. Disconnected with message', message)
me.connected = false
if (me.eventElm) {
me.eventElm.removeEventListener('UBExtensionMsg', me.onContentMessage)
}
return true
})
}
/**
* Timeout (eitres called from setTimeout with msgIDOrMsg === [messageID]
* or from connection error with msgIDOrMsg === [message object]
* @param {string|object} msgIDOrMsg
*/
UBNativeMessage.prototype.onMsgTimeOut = function (msgIDOrMsg) {
let pending
if (typeof msgIDOrMsg === 'string') {
pending = this.pendingMessages[msgIDOrMsg]
if (pending) {
delete this.pendingMessages[msgIDOrMsg]
}
} else {
pending = msgIDOrMsg
}
if (pending) {
pending.timerID = null
pending.deffer.reject(new ubUtils.UBError('unknownError', 'pluginMethodCallTimedOut'))
}
}
/**
* @private
* @type {number}
*/
UBNativeMessage.prototype.idCounter = 0
function createFeatureUpdateMsg (featureConfig, currentVersion, isUpdate) {
const installer = ubUtils.format(featureConfig.installer, featureConfig.minVersion /* .replace(/\./g, '_') */)
const browserName = ubUtils.isOpera
? 'Opera'
: ubUtils.isChrome ? 'Chrome' : 'Firefox'
const msg = 'NM' + (isUpdate ? 'Update' : 'Install') + ((featureConfig.host === 'none') ? 'Extension' + browserName : 'Feature')
return ubUtils.format(i18n(msg), i18n(featureConfig.UIName), featureConfig.minVersion, currentVersion, installer)
}