diff --git a/js/enums/logLevels.js b/js/enums/logLevels.js new file mode 100644 index 00000000..2aa1e627 --- /dev/null +++ b/js/enums/logLevels.js @@ -0,0 +1,12 @@ +'use strict'; + +var keyMirror = require('keymirror'); + +module.exports = keyMirror({ + ERROR: null, + CONFLICT: null, + WARN: null, + ACTION: null, + INFO: null, + DEBUG: null +}); diff --git a/js/log.js b/js/log.js new file mode 100644 index 00000000..c9e22e29 --- /dev/null +++ b/js/log.js @@ -0,0 +1,28 @@ +'use strict'; + +let logWindow; + +/** + * Send log messages from main process to logger hosted by + * renderer process. Allows main process to use logger + * provided by JS. + * @param {enum} level enum from ./enums/LogLevel.js + * @param {string} details msg to be logged + */ +function send(level, details) { + if (logWindow && level && details) { + logWindow.send('log', { + logLevel: level, + logDetails: details + }); + } +} + +function setLogWindow(win) { + logWindow = win; +} + +module.exports = { + send: send, + setLogWindow: setLogWindow +}; diff --git a/js/main.js b/js/main.js index 92f0e8eb..df7b6e80 100644 --- a/js/main.js +++ b/js/main.js @@ -1,190 +1,22 @@ 'use strict'; const electron = require('electron'); -const packageJSON = require('../package.json'); -const menuTemplate = require('./menuTemplate.js'); -const path = require('path'); const app = electron.app; const nodeURL = require('url'); const getConfig = require('./getConfig.js'); -const { isMac, isDevEnv, getGuid } = require('./utils.js'); -const loadErrors = require('./dialogs/showLoadError.js'); - -// show dialog when certificate errors occur -require('./dialogs/showCertError.js'); - -// Keep a global reference of the window object, if you don't, the window will -// be closed automatically when the JavaScript object is garbage collected. -let mainWindow; -let windows = {}; -let willQuitApp = false; -let isOnline = true; +const { isMac } = require('./utils.js'); +// exit early for squirrel installer if (require('electron-squirrel-startup')) { return; } -function createMainWindow (url) { - let key = getGuid(); +require('./mainApiMgr.js'); - mainWindow = new electron.BrowserWindow({ - title: 'Symphony', - width: 1024, height: 768, - show: true, - webPreferences: { - sandbox: true, - nodeIntegration: false, - preload: path.join(__dirname, '/preload.js'), - winKey: key - } - }); +// monitor memory of main process +require('./memoryMonitor.js'); - function retry() { - if (isOnline) { - mainWindow.webContents && mainWindow.webContents.reload(); - } else { - loadErrors.showNetworkConnectivityError(mainWindow, url, retry); - } - } - - // content can be cached and will still finish load but - // we might not have netowrk connectivity, so warn the user. - mainWindow.webContents.once('did-finish-load', function() { - if (!isOnline) { - loadErrors.showNetworkConnectivityError(mainWindow, url, retry); - } - }); - - mainWindow.webContents.once('did-fail-load', function(event, errorCode, - errorDesc, validatedURL, isMainFrame) { - loadErrors.showLoadFailure(mainWindow, url, errorDesc, errorCode, retry); - }); - - storeWindowKey(key, mainWindow); - mainWindow.loadURL(url); - - const menu = electron.Menu.buildFromTemplate(menuTemplate(app)); - electron.Menu.setApplicationMenu(menu); - - mainWindow.on('close', function(e) { - if (willQuitApp) { - mainWindow = null; - return; - } - // mac should hide window when hitting x close - if (isMac) { - mainWindow.hide(); - e.preventDefault(); - } - }); - - mainWindow.on('closed', function () { - // Dereference the window object, usually you would store windows - // in an array if your app supports multi windows, this is the time - // when you should delete the corresponding element. - mainWindow = null; - }); - - // open external links in default browser - window.open - mainWindow.webContents.on('new-window', function(event, url) { - event.preventDefault(); - electron.shell.openExternal(url); - }); -} - -function storeWindowKey(key, browserWin) { - windows[key] = browserWin; -} - -/** - * Ensure events comes from a window that we have created. - * @param {EventEmitter} event node emitter event to be tested - * @return {Boolean} returns true if exists otherwise false - */ -function isValidWindow(event) { - if (event && event.sender) { - // validate that event sender is from window we created - let browserWin = electron.BrowserWindow.fromWebContents(event.sender); - let winKey = event.sender.browserWindowOptions && - event.sender.browserWindowOptions.webPreferences && - event.sender.browserWindowOptions.webPreferences.winKey; - - if (browserWin instanceof electron.BrowserWindow) { - let win = windows[winKey]; - return win && win === browserWin; - } - } - - return false; -} - -/** - * Only permit certain cmds for some windows - * @param {EventEmitter} event node emitter event to be tested - * @param {String} cmd cmd name - * @return {Boolean} true if cmd is allowed for window, otherwise false - */ -function isCmdAllowed(event, cmd) { - if (event && event.sender && cmd) { - // validate that event sender is from window we created - let browserWin = electron.BrowserWindow.fromWebContents(event.sender); - - if (browserWin === mainWindow) { - // allow all commands for main window - return true; - } else { - // allow only certain cmds for child windows - // e.g., open cmd not allowed for child windows - return (arg.cmd !== 'open'); - } - } - - return false; -} - -/** - * Handle ipc messages from renderers. Only messages from windows we have - * created are allowed. - */ -electron.ipcMain.on('symphony-msg', (event, arg) => { - if (!isValidWindow(event)) { - console.log('invalid window try to perform action, ignoring action.'); - return; - } - - if (!isCmdAllowed(event, arg && arg.cmd)) { - console.log('cmd not allowed for this window: ' + arg.cmd); - return; - } - - if (arg && arg.cmd === 'isOnline') { - isOnline = arg.isOnline; - return; - } - - if (arg && arg.cmd === 'open' && arg.url) { - let width = arg.width || 1024; - let height = arg.height || 768; - let title = arg.title || 'Symphony'; - let winKey = getGuid(); - - let childWindow = new electron.BrowserWindow({ - title: title, - width: width, - height: height, - webPreferences: { - sandbox: true, - nodeIntegration: false, - preload: path.join(__dirname, '/preload.js'), - winKey: winKey - } - }); - - storeWindowKey(winKey, childWindow); - childWindow.loadURL(arg.url); - return; - } -}); +const windowMgr = require('./windowMgr.js'); /** * This method will be called when Electron has finished @@ -206,17 +38,13 @@ function getUrlAndOpenMainWindow() { slahes: true, pathname: parsedUrl.href }); - createMainWindow(url); + windowMgr.createMainWindow(url); }).catch(function(err) { let title = 'Error loading configuration'; electron.dialog.showErrorBox(title, title + ': ' + err); }); } -app.on('before-quit', function() { - willQuitApp = true; -}); - app.on('window-all-closed', function () { // On OS X it is common for applications and their menu bar // to stay active until the user quits explicitly with Cmd + Q @@ -226,9 +54,9 @@ app.on('window-all-closed', function () { }); app.on('activate', function () { - if (mainWindow === null) { + if (windowMgr.isMainWindow(null)) { getUrlAndOpenMainWindow(); } else { - mainWindow.show(); + windowMgr.showMainWindow(); } }); diff --git a/js/mainApiMgr.js b/js/mainApiMgr.js new file mode 100644 index 00000000..5e0971dc --- /dev/null +++ b/js/mainApiMgr.js @@ -0,0 +1,96 @@ +'use strict'; + +/** + * This module runs in the main process and handles api calls + * from the renderer process. + */ +const electron = require('electron'); +const path = require('path'); + +const windowMgr = require('./windowMgr.js'); +const log = require('./log.js'); + +/** + * Ensure events comes from a window that we have created. + * @param {EventEmitter} event node emitter event to be tested + * @return {Boolean} returns true if exists otherwise false + */ +function isValidWindow(event) { + if (event && event.sender) { + // validate that event sender is from window we created + let browserWin = electron.BrowserWindow.fromWebContents(event.sender); + let winKey = event.sender.browserWindowOptions && + event.sender.browserWindowOptions.webPreferences && + event.sender.browserWindowOptions.webPreferences.winKey; + + return windowMgr.hasWindow(browserWin, winKey); + } + + return false; +} + +// only these cmds are allowed by main window +let cmdBlackList = [ 'open', 'registerLogger' ]; + +/** + * Only permit certain cmds for some windows + * @param {EventEmitter} event node emitter event to be tested + * @param {String} cmd cmd name + * @return {Boolean} true if cmd is allowed for window, otherwise false + */ +function isCmdAllowed(event, cmd) { + if (event && event.sender && cmd) { + // validate that event sender is from window we created + let browserWin = electron.BrowserWindow.fromWebContents(event.sender); + + if (windowMgr.isMainWindow(browserWin)) { + // allow all commands for main window + return true; + } else { + // allow only certain cmds for child windows + // e.g., open cmd not allowed for child windows + return (cmdBlackList.indexOf(cmd) === -1) + } + } + + return false; +} + +/** + * Handle API related ipc messages from renderers. Only messages from windows + * we have created are allowed. + */ +electron.ipcMain.on('symphony-api', (event, arg) => { + if (!isValidWindow(event)) { + console.log('invalid window try to perform action, ignoring action.'); + return; + } + + if (!isCmdAllowed(event, arg && arg.cmd)) { + console.log('cmd not allowed for this window: ' + arg.cmd); + return; + } + + if (!arg) { + return; + } + + if (arg.cmd === 'isOnline') { + windowMgr.setIsOnline(arg.isOnline); + return; + } + + if (arg.cmd === 'registerLogger') { + // renderer window that has a registered logger from JS. + log.setLogWindow(event.sender); + return; + } + + if (arg.cmd === 'open' && arg.url) { + let title = arg.title || 'Symphony'; + let width = arg.width || 1024; + let height = arg.height || 768; + windowMgr.createChildWindow(arg.url, title, width, height); + return; + } +}); diff --git a/js/memoryMonitor.js b/js/memoryMonitor.js new file mode 100644 index 00000000..6dc91172 --- /dev/null +++ b/js/memoryMonitor.js @@ -0,0 +1,18 @@ +'use strict'; + +const electron = require('electron'); +const log = require('./log.js'); +const logLevels = require('./enums/logLevels.js') + +// once a minute +setInterval(gatherMemory, 1000 * 60); + +function gatherMemory() { + var memory = process.getProcessMemoryInfo(); + var details = + 'workingSetSize: ' + memory.workingSetSize + + ' peakWorkingSetSize: ' + memory.peakWorkingSetSize + + ' privatesBytes: ' + memory.privatesBytes + + ' sharedBytes: ' + memory.sharedBytes; + log.send(logLevels.INFO, details); +} diff --git a/js/preload.js b/js/rendererPreload.js similarity index 53% rename from js/preload.js rename to js/rendererPreload.js index f2279d70..4c984775 100644 --- a/js/preload.js +++ b/js/rendererPreload.js @@ -19,6 +19,8 @@ const local = { ipcRenderer: ipcRenderer }; +const api = 'symphony-api'; + // API exposed by Symphony to renderer processes: // Note: certain cmds are only allowed on some windows, this is checked by // main process. @@ -27,21 +29,51 @@ window.SYM_API = { // only allowed by main window - enforced by main process. openWindow: function(url) { - local.ipcRenderer.send('symphony-msg', { + local.ipcRenderer.send(api, { cmd: 'open', url: url }); }, - networkStatusChange: function(isOnline) { - local.ipcRenderer.send('symphony-msg', { - cmd: 'isOnline', - isOnline: isOnline - }); + + /** + * allows JS to register a logger that can be used by electron main process. + * @param {Object} logger function that can be called accepting + * object: { + * logLevel: 'ERROR'|'CONFLICT'|'WARN'|'ACTION'|'INFO'|'DEBUG', + * logDetails: String + * } + * + * note: only main window is allowed to register a logger, others are + * ignored. + */ + registerLogger: function(logger) { + if (typeof logger === 'function') { + local.logger = logger; + + // only main window can register + local.ipcRenderer.send(api, { + cmd: 'registerLogger' + }); + } } }; +// listen for log message from main process +local.ipcRenderer.on('log', (event, arg) => { + console.log('got msg:' + arg) + if (local.logger && arg && arg.level && arg.msg) { + local.logger({ + logLevel: arg.level, + logDetails: arg.msg + }); + } +}); + function updateOnlineStatus() { - window.SYM_API.networkStatusChange(navigator.onLine); + local.ipcRenderer.send(api, { + cmd: 'isOnline', + isOnline: navigator.onLine + }); } window.addEventListener('offline', updateOnlineStatus, false); diff --git a/js/windowMgr.js b/js/windowMgr.js new file mode 100644 index 00000000..0f95b53d --- /dev/null +++ b/js/windowMgr.js @@ -0,0 +1,160 @@ +'use strict'; + +const electron = require('electron'); +const app = electron.app; +const path = require('path'); + +const menuTemplate = require('./menuTemplate.js'); +const loadErrors = require('./dialogs/showLoadError.js'); +const { isMac, getGuid } = require('./utils.js'); +const log = require('./log.js') +const logLevels = require('./enums/logLevels.js'); + +// show dialog when certificate errors occur +require('./dialogs/showCertError.js'); + +// Keep a global reference of the window object, if you don't, the window will +// be closed automatically when the JavaScript object is garbage collected. +let mainWindow; +let windows = {}; +let willQuitApp = false; +let isOnline = true; +const preloadScript = path.join(__dirname, '/RendererPreload.js'); + +function addWindowKey(key, browserWin) { + windows[key] = browserWin; +} + +function removeWindowKey(key) { + delete windows[key]; +} + +function createMainWindow (url) { + let key = getGuid(); + + mainWindow = new electron.BrowserWindow({ + title: 'Symphony', + width: 1024, height: 768, + show: true, + webPreferences: { + sandbox: true, + nodeIntegration: false, + preload: preloadScript, + winKey: key + } + }); + + function retry() { + if (isOnline) { + mainWindow.webContents && mainWindow.webContents.reload(); + } else { + loadErrors.showNetworkConnectivityError(mainWindow, url, retry); + } + } + + // content can be cached and will still finish load but + // we might not have netowrk connectivity, so warn the user. + mainWindow.webContents.on('did-finish-load', function() { + if (!isOnline) { + loadErrors.showNetworkConnectivityError(mainWindow, url, retry); + } else { + log.send(logLevels.INFO, 'main window loaded'); + } + }); + + mainWindow.webContents.on('did-fail-load', function(event, errorCode, + errorDesc, validatedURL, isMainFrame) { + loadErrors.showLoadFailure(mainWindow, url, errorDesc, errorCode, retry); + }); + + addWindowKey(key, mainWindow); + mainWindow.loadURL(url); + + const menu = electron.Menu.buildFromTemplate(menuTemplate(app)); + electron.Menu.setApplicationMenu(menu); + + mainWindow.on('close', function(e) { + if (willQuitApp) { + mainWindow = null; + return; + } + // mac should hide window when hitting x close + if (isMac) { + mainWindow.hide(); + e.preventDefault(); + } + }); + + mainWindow.on('closed', function () { + removeWindowKey(key); + mainWindow.removeAllEventListeners(); + // Dereference the window object, usually you would store windows + // in an array if your app supports multi windows, this is the time + // when you should delete the corresponding element. + mainWindow = null; + }); + + // open external links in default browser - window.open + mainWindow.webContents.on('new-window', function(event, url) { + event.preventDefault(); + electron.shell.openExternal(url); + }); +} + +app.on('before-quit', function() { + willQuitApp = true; +}); + +function showMainWindow() { + mainWindow.show(); +} + +function isMainWindow(win) { + return mainWindow === win; +} + +function hasWindow(win, winKey) { + if (win instanceof electron.BrowserWindow) { + let browserWin = windows[winKey]; + return browserWin && win === browserWin; + } + + return false; +} + +function createChildWindow(url, title, width, height) { + let winKey = getGuid(); + + let childWindow = new electron.BrowserWindow({ + title: title, + width: width, + height: height, + webPreferences: { + sandbox: true, + nodeIntegration: false, + preload: preloadScript, + winKey: winKey + } + }); + + addWindowKey(winKey, childWindow); + childWindow.loadURL(url); + + childWindow.on('closed', function() { + childWindow.removeAllEventListeners(); + removeWindowKey(winKey); + }); +} + +function setIsOnline(status) { + isOnline = status; +} + +module.exports = { + createMainWindow: createMainWindow, + showMainWindow: showMainWindow, + isMainWindow: isMainWindow, + hasWindow: hasWindow, + createChildWindow: createChildWindow, + setIsOnline: setIsOnline +}; diff --git a/package.json b/package.json index 8ec60a75..e466a615 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "jest": "^19.0.2" }, "dependencies": { - "electron-squirrel-startup": "^1.0.0" + "electron-squirrel-startup": "^1.0.0", + "keymirror": "0.1.1" } } diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 00000000..dd299ab5 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,2 @@ +# to override dependencies +- use https://github.com/thlorenz/proxyquire