diff --git a/.gitignore b/.gitignore index 8835f084..a480d003 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ dist .DS_Store js/preload/_*.js .idea/ -coverage/ \ No newline at end of file +coverage/ +npm-debug.log \ No newline at end of file diff --git a/js/enums/api.js b/js/enums/api.js index be1f1936..269deb25 100644 --- a/js/enums/api.js +++ b/js/enums/api.js @@ -9,10 +9,12 @@ const cmds = keyMirror({ badgeDataUrl: null, activate: null, registerBoundsChange: null, + registerProtocolHandler: null, + checkProtocolAction: null, registerActivityDetection: null, }); module.exports = { cmds: cmds, apiName: 'symphony-api' -} +}; diff --git a/js/main.js b/js/main.js index 14d2ff1f..5658d7c4 100644 --- a/js/main.js +++ b/js/main.js @@ -5,8 +5,13 @@ const app = electron.app; const nodeURL = require('url'); const squirrelStartup = require('electron-squirrel-startup'); const AutoLaunch = require('auto-launch'); -const { getConfigField } = require('./config.js'); -const { isMac, isDevEnv } = require('./utils/misc.js'); +const urlParser = require('url'); +const {getConfigField} = require('./config.js'); +const {isMac, isDevEnv} = require('./utils/misc.js'); +const protocolHandler = require('./protocolHandler'); + +// used to check if a url was opened when the app was already open +let isAppAlreadyOpen = false; // exit early for squirrel installer if (squirrelStartup) { @@ -21,15 +26,17 @@ require('./memoryMonitor.js'); const windowMgr = require('./windowMgr.js'); // only allow a single instance of app. -const shouldQuit = app.makeSingleInstance(() => { +const shouldQuit = app.makeSingleInstance((argv) => { // Someone tried to run a second instance, we should focus our window. let mainWin = windowMgr.getMainWindow(); if (mainWin) { + isAppAlreadyOpen = true; if (mainWin.isMinimized()) { mainWin.restore(); } mainWin.focus(); } + processProtocolAction(argv); }); // quit if another instance is already running @@ -50,52 +57,59 @@ var symphonyAutoLauncher = new AutoLaunch({ app.on('ready', getUrlAndOpenMainWindow); function getUrlAndOpenMainWindow() { + + processProtocolAction(process.argv); + + isAppAlreadyOpen = true; + + // for dev env allow passing url argument let installMode = false; - + process.argv.some((val) => { + let flag = '--install'; if (val === flag) { installMode = true; getConfigField('launchOnStartup') - .then(setStartup); + .then(setStartup); } return false; }); - if (installMode === false){ + if (installMode === false) { openMainWindow(); } } -function setStartup(lStartup){ - if (lStartup === true){ +function setStartup(lStartup) { + if (lStartup === true) { symphonyAutoLauncher.isEnabled() - .then(function(isEnabled){ - if(isEnabled){ - app.quit(); - } - symphonyAutoLauncher.enable() - .then(function (){ - app.quit(); - }); - }) - } else{ - symphonyAutoLauncher.isEnabled() - .then(function(isEnabled){ - if(isEnabled){ - symphonyAutoLauncher.disable() - .then(function (){ + .then(function (isEnabled) { + if (isEnabled) { app.quit(); - }); - } else{ - app.quit(); - } - }) + } + symphonyAutoLauncher.enable() + .then(function () { + app.quit(); + }); + }) + } else { + symphonyAutoLauncher.isEnabled() + .then(function (isEnabled) { + if (isEnabled) { + symphonyAutoLauncher.disable() + .then(function () { + app.quit(); + }); + } else { + app.quit(); + } + }) } } -function openMainWindow(){ +function openMainWindow() { if (isDevEnv) { let url; process.argv.forEach((val) => { @@ -131,6 +145,53 @@ function createWin(urlFromConfig) { windowMgr.createMainWindow(url); } +/** + * processes protocol action for windows clients + * @param argv {Array} an array of command line arguments + */ +function processProtocolAction(argv) { + + // In case of windows, we need to handle protocol handler + // manually because electron doesn't emit + // 'open-url' event on windows + if (!(process.platform === 'win32')) { + return; + } + + let protocolUri; + + for (let i = 0; i < argv.length; i++) { + + if (argv[i].startsWith("symphony://")) { + protocolUri = argv[i]; + break; + } + + } + + if (protocolUri) { + + const parsedURL = urlParser.parse(protocolUri); + + if (!parsedURL.protocol || !parsedURL.slashes) { + return; + } + + handleProtocolAction(protocolUri); + + } +} + +function handleProtocolAction(uri) { + if (!isAppAlreadyOpen) { + // app is opened by the protocol url, cache the protocol url to be used later + protocolHandler.setProtocolUrl(uri); + } else { + // app is already open, so, just trigger the protocol action method + protocolHandler.processProtocolAction(uri); + } +} + 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 @@ -146,3 +207,15 @@ app.on('activate', function () { windowMgr.showMainWindow(); } }); + +// adds 'symphony' as a protocol +// in the system. plist file in macOS +// and registry keys in windows +app.setAsDefaultProtocolClient('symphony'); + +// This event is emitted only on macOS +// at this moment, support for windows +// is in pipeline (https://github.com/electron/electron/pull/8052) +app.on('open-url', function (event, url) { + handleProtocolAction(url); +}); diff --git a/js/mainApiMgr.js b/js/mainApiMgr.js index 4f4a2454..3d55d6f6 100644 --- a/js/mainApiMgr.js +++ b/js/mainApiMgr.js @@ -10,6 +10,7 @@ const windowMgr = require('./windowMgr.js'); const log = require('./log.js'); const activityDetection = require('./activityDetection/activityDetection'); const badgeCount = require('./badgeCount.js'); +const protocolHandler = require('./protocolHandler'); const apiEnums = require('./enums/api.js'); const apiCmds = apiEnums.cmds; @@ -69,6 +70,15 @@ electron.ipcMain.on(apiName, (event, arg) => { return; } + if (arg.cmd === apiCmds.checkProtocolAction) { + protocolHandler.checkProtocolAction(); + return; + } + + if (arg.cmd === apiCmds.registerProtocolHandler) { + protocolHandler.setProtocolWindow(event.sender); + } + if (arg.cmd === apiCmds.badgeDataUrl && typeof arg.dataUrl === 'string' && typeof arg.count === 'number') { badgeCount.setDataUrl(arg.dataUrl, arg.count); @@ -100,4 +110,4 @@ module.exports = { shouldCheckValidWindow: function (shouldCheck) { checkValidWindow = shouldCheck; } -} +}; diff --git a/js/preload/preloadMain.js b/js/preload/preloadMain.js index 0644560c..f104bcb9 100644 --- a/js/preload/preloadMain.js +++ b/js/preload/preloadMain.js @@ -32,6 +32,13 @@ const throttledSetBadgeCount = throttle(1000, function(count) { }); }); +// check to see if the app was opened via a url +const checkProtocolAction = function () { + local.ipcRenderer.send(apiName, { + cmd: apiCmds.checkProtocolAction + }); +}; + createAPI(); // creates API exposed from electron. @@ -65,6 +72,13 @@ function createAPI() { throttledSetBadgeCount(count); }, + /** + * checks to see if the app was opened from a url. + */ + checkProtocolAction: function () { + checkProtocolAction(); + }, + /** * provides api similar to html5 Notification, see details * in notify/notifyImpl.js @@ -123,6 +137,24 @@ function createAPI() { } }, + /** + * allows JS to register a protocol handler that can be used by the electron main process. + * @param protocolHandler {Object} protocolHandler a callback to register the protocol handler + */ + registerProtocolHandler: function (protocolHandler) { + + if (typeof protocolHandler === 'function') { + + local.processProtocolAction = protocolHandler; + + local.ipcRenderer.send(apiName, { + cmd: apiCmds.registerProtocolHandler + }); + + } + + }, + /** * allows JS to register a activity detector that can be used by electron main process. * @param {Object} activityDetection - function that can be called accepting @@ -235,6 +267,18 @@ function createAPI() { }); }); + /** + * an event triggered by the main process for processing protocol urls + * @type {String} arg - the protocol url + */ + local.ipcRenderer.on('protocol-action', (event, arg) => { + + if (local.processProtocolAction && arg) { + local.processProtocolAction(arg); + } + + }); + function updateOnlineStatus() { local.ipcRenderer.send(apiName, { cmd: apiCmds.isOnline, diff --git a/js/protocolHandler/index.js b/js/protocolHandler/index.js new file mode 100644 index 00000000..74a0640d --- /dev/null +++ b/js/protocolHandler/index.js @@ -0,0 +1,52 @@ +'use strict'; + +let protocolWindow; +let protocolUrl; + +/** + * processes a protocol uri + * @param {String} uri - the uri opened in the format 'symphony://...' + */ +function processProtocolAction(uri) { + if (protocolWindow && uri && uri.startsWith("symphony://")) { + protocolWindow.send('protocol-action', uri); + } +} + +/** + * sets the protocol window + * @param {Object} win - the renderer window + */ +function setProtocolWindow(win) { + protocolWindow = win; +} + +/** + * checks to see if the app was opened by a uri + */ +function checkProtocolAction() { + if (protocolUrl) { + processProtocolAction(protocolUrl); + protocolUrl = undefined; + } +} + +/** + * caches the protocol uri + * @param {String} uri - the uri opened in the format 'symphony://...' + */ +function setProtocolUrl(uri) { + protocolUrl = uri; +} + +function getProtocolUrl() { + return protocolUrl; +} + +module.exports = { + processProtocolAction: processProtocolAction, + setProtocolWindow: setProtocolWindow, + checkProtocolAction: checkProtocolAction, + setProtocolUrl: setProtocolUrl, + getProtocolUrl: getProtocolUrl +}; diff --git a/js/windowMgr.js b/js/windowMgr.js index 4129e701..8c038ae6 100644 --- a/js/windowMgr.js +++ b/js/windowMgr.js @@ -49,8 +49,7 @@ function removeWindowKey(key) { } function getParsedUrl(url) { - let parsedUrl = nodeURL.parse(url); - return parsedUrl; + return nodeURL.parse(url); } function createMainWindow(initialUrl) { @@ -82,7 +81,7 @@ function doCreateMainWindow(initialUrl, initialBounds) { } }; - // set size and postion + // set size and position let bounds = initialBounds; // if bounds if not fully contained in some display then use default size @@ -127,7 +126,7 @@ function doCreateMainWindow(initialUrl, initialBounds) { } // content can be cached and will still finish load but - // we might not have netowrk connectivity, so warn the user. + // we might not have network connectivity, so warn the user. mainWindow.webContents.on('did-finish-load', function () { url = mainWindow.webContents.getURL(); diff --git a/tests/ProtocolHandler.test.js b/tests/ProtocolHandler.test.js new file mode 100644 index 00000000..7c3e9f59 --- /dev/null +++ b/tests/ProtocolHandler.test.js @@ -0,0 +1,61 @@ +/** + * Created by vishwas on 10/05/17. + */ +const protocolHandler = require('../js/protocolHandler'); +const electron = require('./__mocks__/electron'); + +describe('protocol handler', function () { + + const url = 'symphony://?userId=100001'; + + const mainProcess = electron.ipcMain; + const protocolWindow = electron.ipcRenderer; + + beforeAll(function () { + protocolHandler.setProtocolWindow(protocolWindow); + }); + + it('process a protocol action', function (done) { + + const spy = jest.spyOn(protocolHandler, 'processProtocolAction'); + protocolHandler.processProtocolAction(url); + expect(spy).toHaveBeenCalledWith(url); + + done(); + + }); + + it('protocol url should be undefined by default', function (done) { + expect(protocolHandler.getProtocolUrl()).toBeUndefined(); + done(); + }); + + it('protocol handler open url should be called', function (done) { + + const spy = jest.spyOn(mainProcess, 'send'); + mainProcess.send('open-url', url); + + expect(spy).toHaveBeenCalled(); + + done(); + + }); + + it('check protocol action should be called', function (done) { + + const spy = jest.spyOn(protocolHandler, 'checkProtocolAction'); + const setSpy = jest.spyOn(protocolHandler, 'setProtocolUrl'); + + protocolHandler.setProtocolUrl(url); + expect(setSpy).toHaveBeenCalledWith(url); + + protocolHandler.checkProtocolAction(); + expect(spy).toHaveBeenCalled(); + + expect(protocolHandler.getProtocolUrl()).toBeUndefined(); + + done(); + + }); + +}); \ No newline at end of file diff --git a/tests/__mocks__/electron.js b/tests/__mocks__/electron.js index 1538e17e..95081f83 100644 --- a/tests/__mocks__/electron.js +++ b/tests/__mocks__/electron.js @@ -8,20 +8,44 @@ function pathToConfigDir() { return path.join(__dirname, '/../fixtures'); } +// electron app mock... +const app = { + getAppPath: pathToConfigDir, + getPath: function(type) { + if (type === 'exe') { + return path.join(pathToConfigDir(), '/Symphony.exe'); + } + return pathToConfigDir(); + }, + on: function() { + // no-op + } +}; + // simple ipc mocks for render and main process ipc using // nodes' EventEmitter const ipcMain = { on: function(event, cb) { ipcEmitter.on(event, cb); - } -} + }, + send: function (event, args) { + var senderEvent = { + sender: { + send: function (event, arg) { + ipcEmitter.emit(event, arg); + } + } + }; + ipcEmitter.emit(event, senderEvent, args); + }, +}; const ipcRenderer = { sendSync: function(event, args) { let listeners = ipcEmitter.listeners(event); if (listeners.length > 0) { let listener = listeners[0]; - var eventArg = {} + var eventArg = {}; listener(eventArg, args); return eventArg.returnValue; } @@ -34,7 +58,7 @@ const ipcRenderer = { ipcEmitter.emit(event, arg); } } - } + }; ipcEmitter.emit(event, senderEvent, args); }, on: function(eventName, cb) { @@ -43,7 +67,7 @@ const ipcRenderer = { removeListener: function(eventName, cb) { ipcEmitter.removeListener(eventName, cb); } -} +}; module.exports = { require: jest.genMockFunction(),