diff --git a/demo/index.html b/demo/index.html index 250b1210..ac07b374 100644 --- a/demo/index.html +++ b/demo/index.html @@ -135,10 +135,10 @@ }); var win; + var openWinButton = document.getElementById('open-win'); openWinButton.addEventListener('click', function() { - console.log('win=', win) - win = window.open('win.html', 'test-window', 'height=100,width=100'); + win = window.open('win.html?x=100&y=100', 'test-window', 'height=100,width=100'); }); var front = document.getElementById('bring-to-front'); @@ -146,5 +146,12 @@ window.SYM_API.activate(win.name); }); + // register callback to be notified when size/position changes for win. + SYM_API.registerBoundsChange(onBoundsChange); + + function onBoundsChange(arg) { + console.log('bounds changed for=', arg) + } + diff --git a/js/config.js b/js/config.js new file mode 100644 index 00000000..389ca558 --- /dev/null +++ b/js/config.js @@ -0,0 +1,167 @@ +'use strict'; + +const electron = require('electron'); +const app = electron.app; +const path = require('path'); +const fs = require('fs'); +const isDevEnv = require('./utils/misc.js').isDevEnv; +const isMac = require('./utils/misc.js').isMac; +const getRegistry = require('./utils/getRegistry.js'); +const configFileName = 'Symphony.config'; + +/** + * Tries to read given field from user config file, if field doesn't exist + * then tries reading from global config. User config is stord in directory: + * app.getPath('userData') and file called Symphony.config. Global config is + * stored in file Symphony.config in directory where executable gets installed. + * + * Config is a flat key/value file. + * e.g. { url: 'https://my.symphony.com', } + * + * @param {String} fieldName Name of field to try fetching + * @return {Promise} Returns promise that will succeed with field + * value if found in either user or global config. Otherwise will fail promise. + */ +function getConfigField(fieldName) { + return getUserConfigField(fieldName) + .then(function(value) { + // got value from user config + return value; + }, function () { + // failed to get value from user config, so try global config + return getGlobalConfigField(fieldName); + }); +} + +function getUserConfigField(fieldName) { + return readUserConfig().then(function(config) { + if (typeof fieldName === 'string' && fieldName in config) { + return config[fieldName]; + } + + throw new Error('field does not exist in user config: ' + fieldName); + }); +} + +function readUserConfig() { + return new Promise(function(resolve, reject) { + let configPath = path.join(app.getPath('userData'), configFileName); + + fs.readFile(configPath, 'utf8', function(err, data) { + if (err) { + reject('cannot open user config file: ' + configPath + ', error: ' + err); + } else { + let config = {}; + try { + // data is the contents of the text file we just read + config = JSON.parse(data); + } catch (e) { + reject('can not parse user config file data: ' + data + ', error: ' + err); + } + + resolve(config); + } + }); + }); +} + +function getGlobalConfigField(fieldName) { + return readGlobalConfig().then(function(config) { + if (typeof fieldName === 'string' && fieldName in config) { + return config[fieldName]; + } + + throw new Error('field does not exist in global config: ' + fieldName); + }); +} + +/** + * reads global configuration file: config/Symphony.config. this file is + * hold items (such as the start url) that are intended to be used as + * global (or default) values for all users running this app. for production + * this file is located relative to the executable - it is placed there by + * the installer. this makes the file easily modifable by admin (or person who + * installed app). for dev env, the file is read directly from packed asar file. + */ +function readGlobalConfig() { + return new Promise(function(resolve, reject) { + let configPath; + let globalConfigFileName = path.join('config', configFileName); + if (isDevEnv) { + // for dev env, get config file from asar + configPath = path.join(app.getAppPath(), globalConfigFileName); + } else { + // for non-dev, config file is placed by installer relative to exe. + // this is so the config can be easily be changed post install. + let execPath = path.dirname(app.getPath('exe')); + // for mac exec is stored in subdir, for linux/windows config + // dir is in the same location. + configPath = path.join(execPath, isMac ? '..' : '', globalConfigFileName); + } + + fs.readFile(configPath, 'utf8', function(err, data) { + if (err) { + reject('cannot open global config file: ' + configPath + ', error: ' + err); + } else { + let config = {}; + try { + // data is the contents of the text file we just read + config = JSON.parse(data); + } catch (e) { + reject('can not parse config file data: ' + data + ', error: ' + err); + } + getRegistry('PodUrl') + .then(function(url){ + config.url = url; + resolve(config); + }).catch(function (){ + resolve(config); + }); + } + }); + }); +} + +/** + * Updates user config with given field with new value + * @param {String} fieldName [description] + * @param {Object} newValue object to replace given value + * @return {[type]} [description] + */ +function updateConfigField(fieldName, newValue) { + return readUserConfig() + .then(function(config) { + return saveUserConfig(fieldName, newValue, config); + }, + function() { + // in case config doesn't exist, can't read or is corrupted. + return saveUserConfig(fieldName, newValue, {}); + }); +} + +function saveUserConfig(fieldName, newValue, oldConfig) { + return new Promise(function(resolve, reject) { + let configPath = path.join(app.getPath('userData'), configFileName); + + if (!oldConfig || !fieldName) { + reject('can not save config, invalid input'); + return; + } + + // clone and set new value + let newConfig = Object.assign({}, oldConfig); + newConfig[fieldName] = newValue; + + let jsonNewConfig = JSON.stringify(newConfig, null, ' '); + + fs.writeFile(configPath, jsonNewConfig, 'utf8', (err) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); +} + +module.exports = { getConfigField, updateConfigField }; diff --git a/js/enums/api.js b/js/enums/api.js index 419951a2..932f2c5a 100644 --- a/js/enums/api.js +++ b/js/enums/api.js @@ -7,7 +7,8 @@ const cmds = keyMirror({ registerLogger: null, setBadgeCount: null, badgeDataUrl: null, - activate: null + activate: null, + registerBoundsChange: null }); module.exports = { diff --git a/js/getConfig.js b/js/getConfig.js deleted file mode 100644 index 40c7b5b6..00000000 --- a/js/getConfig.js +++ /dev/null @@ -1,59 +0,0 @@ -'use strict'; - -const electron = require('electron'); -const app = electron.app; -const path = require('path'); -const fs = require('fs'); -const isDevEnv = require('./utils/misc.js').isDevEnv; -const isMac = require('./utils/misc.js').isMac; -const getRegistry = require('./utils/getRegistry.js'); - -/** - * reads global configuration file: config/Symphony.config. this file is - * hold items (such as the start url) that are intended to be used as - * global (or default) values for all users running this app. for production - * this file is located relative to the executable - it is placed there by - * the installer. this makes the file easily modifable by admin (or person who - * installed app). for dev env, the file is read directly from packed asar file. - */ -var getConfig = function () { - var promise = new Promise(function(resolve, reject) { - let configPath; - const configFile = 'config/Symphony.config'; - if (isDevEnv) { - // for dev env, get config file from asar - configPath = path.join(app.getAppPath(), configFile); - } else { - // for non-dev, config file is placed by installer relative to exe. - // this is so the config can be easily be changed post install. - let execPath = path.dirname(app.getPath('exe')); - // for mac exec is stored in subdir, for linux/windows config - // dir is in the same location. - configPath = path.join(execPath, isMac ? '..' : '', configFile); - } - - fs.readFile(configPath, 'utf8', function(err, data) { - if (err) { - reject('cannot open config file: ' + configPath + ', error: ' + err); - } else { - let config = {}; - try { - // data is the contents of the text file we just read - config = JSON.parse(data); - } catch (e) { - reject('can not parse config file data: ' + data + ', error: ' + err); - } - getRegistry('PodUrl') - .then(function(url){ - config.url = url; - resolve(config); - }).catch(function (){ - resolve(config); - }); - } - }); - }); - return promise; -} - -module.exports = getConfig diff --git a/js/main.js b/js/main.js index fe3608bb..4f318e6b 100644 --- a/js/main.js +++ b/js/main.js @@ -5,7 +5,7 @@ const app = electron.app; const nodeURL = require('url'); const squirrelStartup = require('electron-squirrel-startup'); -const getConfig = require('./getConfig.js'); +const { getConfigField } = require('./config.js'); const { isMac, isDevEnv } = require('./utils/misc.js'); @@ -44,17 +44,17 @@ function getUrlAndOpenMainWindow() { } } - getConfig() + getConfigField('url') .then(createWin).catch(function (err){ let title = 'Error loading configuration'; electron.dialog.showErrorBox(title, title + ': ' + err); }); } -function createWin(config){ +function createWin(urlFromConfig){ let protocol = ''; // add https protocol if none found. - let parsedUrl = nodeURL.parse(config.url); + let parsedUrl = nodeURL.parse(urlFromConfig); if (!parsedUrl.protocol) { protocol = 'https'; } diff --git a/js/mainApiMgr.js b/js/mainApiMgr.js index 9d934943..20e70112 100644 --- a/js/mainApiMgr.js +++ b/js/mainApiMgr.js @@ -79,6 +79,10 @@ electron.ipcMain.on(apiName, (event, arg) => { return; } + if (arg.cmd === apiCmds.registerBoundsChange) { + windowMgr.setBoundsChangeWindow(event.sender); + } + if (arg.cmd === apiCmds.registerLogger) { // renderer window that has a registered logger from JS. log.setLogWindow(event.sender); diff --git a/js/preload/preloadMain.js b/js/preload/preloadMain.js index 40b2eb94..72000a08 100644 --- a/js/preload/preloadMain.js +++ b/js/preload/preloadMain.js @@ -87,6 +87,22 @@ function createAPI() { }); }, + /** + * Allows JS to register a callback to be invoked when size/positions + * changes for any pop-out window (i.e., window.open). The main + * process will emit IPC event 'boundsChange' (see below). Currently + * only one window can register for bounds change. + * @param {Function} callback Function invoked when bounds changes. + */ + registerBoundsChange: function(callback) { + if (typeof callback === 'function') { + local.boundsChangeCallback = callback; + local.ipcRenderer.send(apiName, { + cmd: apiCmds.registerBoundsChange + }); + } + }, + /** * allows JS to register a logger that can be used by electron main process. * @param {Object} logger function that can be called accepting @@ -94,9 +110,6 @@ function createAPI() { * 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') { @@ -119,6 +132,20 @@ function createAPI() { } }); + // listen for notifications that some window size/position has changed + local.ipcRenderer.on('boundsChange', (event, arg) => { + if (local.boundsChangeCallback && arg.windowName && + arg.x && arg.y && arg.width && arg.height) { + local.boundsChangeCallback({ + x: arg.x, + y: arg.y, + width: arg.width, + height: arg.height, + windowName: arg.windowName + }); + } + }); + /** * Use render process to create badge count img and send back to main process. * If number is greater than 99 then 99+ img is returned. diff --git a/js/utils/isInDisplayBounds.js b/js/utils/isInDisplayBounds.js new file mode 100644 index 00000000..4560f0da --- /dev/null +++ b/js/utils/isInDisplayBounds.js @@ -0,0 +1,29 @@ +'use strict' + +const electron = require('electron'); + +/** + * Returns true if given rectangle is contained within the workArea of at + * least one of the screens. + * @param {x: Number, y: Number, width: Number, height: Number} rect + * @return {Boolean} true if condition in desc is met. + */ +function isInDisplayBounds(rect) { + if (!rect) { + return false; + } + let displays = electron.screen.getAllDisplays(); + + for(let i = 0, len = displays.length; i < len; i++) { + let workArea = displays[i].workArea; + if (rect.x >= workArea.x && rect.y >= workArea.y && + ((rect.x + rect.width) <= (workArea.x + workArea.width)) && + ((rect.y + rect.height) <= (workArea.y + workArea.height))) { + return true; + } + } + + return false; +} + +module.exports = isInDisplayBounds; diff --git a/js/utils/throttle.js b/js/utils/throttle.js index a91c71d7..cd7a2a08 100644 --- a/js/utils/throttle.js +++ b/js/utils/throttle.js @@ -18,7 +18,7 @@ function throttle(throttleTime, func) { function cancel() { if (timer) { - window.clearTimeout(timer); + clearTimeout(timer); } } diff --git a/js/windowMgr.js b/js/windowMgr.js index 53cbe586..ea18cf3d 100644 --- a/js/windowMgr.js +++ b/js/windowMgr.js @@ -4,14 +4,18 @@ const electron = require('electron'); const app = electron.app; const path = require('path'); const nodeURL = require('url'); +const querystring = require('querystring'); const menuTemplate = require('./menus/menuTemplate.js'); const loadErrors = require('./dialogs/showLoadError.js'); const { isMac } = require('./utils/misc.js'); +const isInDisplayBounds = require('./utils/isInDisplayBounds.js'); const getGuid = require('./utils/getGuid.js'); const log = require('./log.js') const logLevels = require('./enums/logLevels.js'); const notify = require('./notify/electron-notify.js'); +const throttle = require('./utils/throttle.js'); +const { getConfigField, updateConfigField } = require('./config.js'); //context menu const contextMenu = require('./menus/contextMenu.js'); @@ -25,6 +29,7 @@ let mainWindow; let windows = {}; let willQuitApp = false; let isOnline = true; +let boundsChangeWindow; // note: this file is built using browserify in prebuild step. const preloadMainScript = path.join(__dirname, 'preload/_preloadMain.js'); @@ -37,19 +42,29 @@ function removeWindowKey(key) { delete windows[ key ]; } -function getHostFromUrl(url) { +function getParsedUrl(url) { let parsedUrl = nodeURL.parse(url); - let UrlHost = parsedUrl.host; - return UrlHost + return parsedUrl; } function createMainWindow(initialUrl) { + getConfigField('mainWinPos').then( + function(bounds) { + doCreateMainWindow(initialUrl, bounds); + }, + function() { + // failed, use default bounds + doCreateMainWindow(initialUrl, null); + } + ) +} + +function doCreateMainWindow(initialUrl, initialBounds) { let url = initialUrl; let key = getGuid(); let newWinOpts = { title: 'Symphony', - width: 1024, height: 768, show: true, webPreferences: { sandbox: true, @@ -59,12 +74,39 @@ function createMainWindow(initialUrl) { } }; + // set size and postion + let bounds = initialBounds; + + // if bounds if not fully contained in some display then use default size + // and position. + if (!isInDisplayBounds(bounds)) { + bounds = null; + } + + if (bounds && bounds.width && bounds.height) { + newWinOpts.width = bounds.width; + newWinOpts.height = bounds.height; + } else { + newWinOpts.width = 1024; + newWinOpts.height = 768; + } + + // will center on screen if values not provided + if (bounds && bounds.x && bounds.y) { + newWinOpts.x = bounds.x; + newWinOpts.y = bounds.y; + } + // note: augmenting with some custom values newWinOpts.winKey = key; mainWindow = new electron.BrowserWindow(newWinOpts); mainWindow.winName = 'main'; + let throttledMainWinBoundsChange = throttle(5000, saveMainWinBounds); + mainWindow.on('move', throttledMainWinBoundsChange); + mainWindow.on('resize',throttledMainWinBoundsChange); + function retry() { if (!isOnline) { loadErrors.showNetworkConnectivityError(mainWindow, url, retry); @@ -128,8 +170,11 @@ function createMainWindow(initialUrl) { // open external links in default browser - a tag, window.open mainWindow.webContents.on('new-window', function(event, newWinUrl, frameName, disposition, newWinOptions) { - let newWinHost = getHostFromUrl(newWinUrl); - let mainWinHost = getHostFromUrl(url); + let newWinParsedUrl = getParsedUrl(newWinUrl); + let mainWinParsedUrl = getParsedUrl(url); + + let newWinHost = newWinParsedUrl && newWinParsedUrl.host; + let mainWinHost = mainWinParsedUrl && mainWinParsedUrl.host; // if host url doesn't match then open in external browser if (newWinHost !== mainWinHost) { @@ -144,44 +189,70 @@ function createMainWindow(initialUrl) { return; } - // reposition new window - let mainWinPos = mainWindow.getPosition(); - if (mainWinPos && mainWinPos.length === 2) { - let newWinKey = getGuid(); + let x = 0; + let y = 0; - /* eslint-disable no-param-reassign */ - newWinOptions.x = mainWinPos[0] + 50; - newWinOptions.y = mainWinPos[1] + 50; + let width = newWinOptions.width || 300; + let height = newWinOptions.height || 600; - newWinOptions.winKey = newWinKey; - /* eslint-enable no-param-reassign */ + // try getting x and y position from query parameters + var query = newWinParsedUrl && querystring.parse(newWinParsedUrl.query); + if (query && query.x && query.y) { + let newX = Number.parseInt(query.x, 10); + let newY = Number.parseInt(query.y, 10); - let webContents = newWinOptions.webContents; + let newWinRect = { x: newX, y: newY, width, height }; - webContents.once('did-finish-load', function() { - let browserWin = electron.BrowserWindow.fromWebContents(webContents); - - if (browserWin) { - browserWin.winName = frameName; - - browserWin.once('closed', function() { - removeWindowKey(newWinKey); - }); - - addWindowKey(newWinKey, browserWin); - } - - // note: will use later for save-layout feature - // browserWin.on('move', function() { - // var newPos = browserWin.getPosition(); - // console.log('new pos=', newPos) - // }); - // browserWin.on('resize', function() { - // var newSize = browserWin.getSize(); - // console.log('new size=', newSize) - // }); - }); + // only accept if both are successfully parsed. + if (Number.isInteger(newX) && Number.isInteger(newY) && + isInDisplayBounds(newWinRect)) { + x = newX; + y = newY; + } else { + x = 0; + y = 0; + } + } else { + // create new window at slight offset from main window. + ({ x, y } = getWindowSizeAndPosition(mainWindow)); + x+=50; + y+=50; } + + /* eslint-disable no-param-reassign */ + newWinOptions.x = x; + newWinOptions.y = y; + newWinOptions.width = width; + newWinOptions.height = height; + + let newWinKey = getGuid(); + + newWinOptions.winKey = newWinKey; + /* eslint-enable no-param-reassign */ + + let webContents = newWinOptions.webContents; + + webContents.once('did-finish-load', function() { + let browserWin = electron.BrowserWindow.fromWebContents(webContents); + + if (browserWin) { + browserWin.winName = frameName; + + browserWin.once('closed', function() { + removeWindowKey(newWinKey); + browserWin.removeListener('move', throttledBoundsChange); + browserWin.removeListener('resize', throttledBoundsChange); + }); + + addWindowKey(newWinKey, browserWin); + + // throttle changes so we don't flood client. + let throttledBoundsChange = throttle(1000, + sendChildWinBoundsChange.bind(null, browserWin)); + browserWin.on('move', throttledBoundsChange); + browserWin.on('resize',throttledBoundsChange); + } + }); } }); @@ -192,10 +263,35 @@ app.on('before-quit', function() { willQuitApp = true; }); +function saveMainWinBounds() { + let newBounds = getWindowSizeAndPosition(mainWindow); + + if (newBounds) { + updateConfigField('mainWinPos', newBounds); + } +} + function getMainWindow() { return mainWindow; } +function getWindowSizeAndPosition(window) { + let newPos = window.getPosition(); + let newSize = window.getSize(); + + if (newPos && newPos.length === 2 && + newSize && newSize.length === 2 ) { + return { + x: newPos[0], + y: newPos[1], + width: newSize[0], + height: newSize[1], + }; + } + + return null; +} + function showMainWindow() { mainWindow.show(); } @@ -217,6 +313,12 @@ function setIsOnline(status) { isOnline = status; } +/** + * Tries finding a window we have created with given name. If founds then + * brings to front and gives focus. + * @param {String} windowName Name of target window. Note: main window has + * name 'main'. + */ function activate(windowName) { let keys = Object.keys(windows); for(let i = 0, len = keys.length; i < len; i++) { @@ -229,6 +331,27 @@ function activate(windowName) { } } +/** + * name of renderer window to notify when bounds of child window changes. + * @param {object} window Renderer window to use IPC with to inform about size/ + * position change. + */ +function setBoundsChangeWindow(window) { + boundsChangeWindow = window; +} + +/** + * Called when bounds of child window changes size/position + * @param {object} window Child window which has changed size/position. + */ +function sendChildWinBoundsChange(window) { + let newBounds = getWindowSizeAndPosition(window); + if (newBounds && boundsChangeWindow) { + newBounds.windowName = window.winName; + boundsChangeWindow.send('boundsChange', newBounds); + } +} + module.exports = { createMainWindow: createMainWindow, getMainWindow: getMainWindow, @@ -236,5 +359,6 @@ module.exports = { isMainWindow: isMainWindow, hasWindow: hasWindow, setIsOnline: setIsOnline, - activate: activate + activate: activate, + setBoundsChangeWindow: setBoundsChangeWindow }; diff --git a/tests/getConfig.test.js b/tests/config.test.js similarity index 60% rename from tests/getConfig.test.js rename to tests/config.test.js index 55adc3dd..946cfeec 100644 --- a/tests/getConfig.test.js +++ b/tests/config.test.js @@ -1,4 +1,4 @@ -const getConfig = require('../js/getconfig'); +const { getConfigField } = require('../js/config'); // mock required so getConfig reads config from correct path jest.mock('../js/utils/misc.js', function() { @@ -9,7 +9,7 @@ jest.mock('../js/utils/misc.js', function() { }); test('getConfig should have proper url', function() { - return getConfig(false).then(function(result) { - expect(result.url).toBe('https://my.symphony.com'); + return getConfigField('url').then(function(url) { + expect(url).toBe('https://my.symphony.com'); }); });