saved layout (#62)

* saved layout

* fix fieldName check
This commit is contained in:
Lynn 2017-04-20 11:54:11 -07:00 committed by GitHub
parent 9785baacc3
commit 439f283916
11 changed files with 413 additions and 113 deletions

View File

@ -135,10 +135,10 @@
}); });
var win; var win;
var openWinButton = document.getElementById('open-win'); var openWinButton = document.getElementById('open-win');
openWinButton.addEventListener('click', function() { openWinButton.addEventListener('click', function() {
console.log('win=', win) win = window.open('win.html?x=100&y=100', 'test-window', 'height=100,width=100');
win = window.open('win.html', 'test-window', 'height=100,width=100');
}); });
var front = document.getElementById('bring-to-front'); var front = document.getElementById('bring-to-front');
@ -146,5 +146,12 @@
window.SYM_API.activate(win.name); 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)
}
</script> </script>
</html> </html>

167
js/config.js Normal file
View File

@ -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 };

View File

@ -7,7 +7,8 @@ const cmds = keyMirror({
registerLogger: null, registerLogger: null,
setBadgeCount: null, setBadgeCount: null,
badgeDataUrl: null, badgeDataUrl: null,
activate: null activate: null,
registerBoundsChange: null
}); });
module.exports = { module.exports = {

View File

@ -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

View File

@ -5,7 +5,7 @@ const app = electron.app;
const nodeURL = require('url'); const nodeURL = require('url');
const squirrelStartup = require('electron-squirrel-startup'); const squirrelStartup = require('electron-squirrel-startup');
const getConfig = require('./getConfig.js'); const { getConfigField } = require('./config.js');
const { isMac, isDevEnv } = require('./utils/misc.js'); const { isMac, isDevEnv } = require('./utils/misc.js');
@ -44,17 +44,17 @@ function getUrlAndOpenMainWindow() {
} }
} }
getConfig() getConfigField('url')
.then(createWin).catch(function (err){ .then(createWin).catch(function (err){
let title = 'Error loading configuration'; let title = 'Error loading configuration';
electron.dialog.showErrorBox(title, title + ': ' + err); electron.dialog.showErrorBox(title, title + ': ' + err);
}); });
} }
function createWin(config){ function createWin(urlFromConfig){
let protocol = ''; let protocol = '';
// add https protocol if none found. // add https protocol if none found.
let parsedUrl = nodeURL.parse(config.url); let parsedUrl = nodeURL.parse(urlFromConfig);
if (!parsedUrl.protocol) { if (!parsedUrl.protocol) {
protocol = 'https'; protocol = 'https';
} }

View File

@ -79,6 +79,10 @@ electron.ipcMain.on(apiName, (event, arg) => {
return; return;
} }
if (arg.cmd === apiCmds.registerBoundsChange) {
windowMgr.setBoundsChangeWindow(event.sender);
}
if (arg.cmd === apiCmds.registerLogger) { if (arg.cmd === apiCmds.registerLogger) {
// renderer window that has a registered logger from JS. // renderer window that has a registered logger from JS.
log.setLogWindow(event.sender); log.setLogWindow(event.sender);

View File

@ -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. * allows JS to register a logger that can be used by electron main process.
* @param {Object} logger function that can be called accepting * @param {Object} logger function that can be called accepting
@ -94,9 +110,6 @@ function createAPI() {
* logLevel: 'ERROR'|'CONFLICT'|'WARN'|'ACTION'|'INFO'|'DEBUG', * logLevel: 'ERROR'|'CONFLICT'|'WARN'|'ACTION'|'INFO'|'DEBUG',
* logDetails: String * logDetails: String
* } * }
*
* note: only main window is allowed to register a logger, others are
* ignored.
*/ */
registerLogger: function(logger) { registerLogger: function(logger) {
if (typeof logger === 'function') { 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. * 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. * If number is greater than 99 then 99+ img is returned.

View File

@ -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;

View File

@ -18,7 +18,7 @@ function throttle(throttleTime, func) {
function cancel() { function cancel() {
if (timer) { if (timer) {
window.clearTimeout(timer); clearTimeout(timer);
} }
} }

View File

@ -4,14 +4,18 @@ const electron = require('electron');
const app = electron.app; const app = electron.app;
const path = require('path'); const path = require('path');
const nodeURL = require('url'); const nodeURL = require('url');
const querystring = require('querystring');
const menuTemplate = require('./menus/menuTemplate.js'); const menuTemplate = require('./menus/menuTemplate.js');
const loadErrors = require('./dialogs/showLoadError.js'); const loadErrors = require('./dialogs/showLoadError.js');
const { isMac } = require('./utils/misc.js'); const { isMac } = require('./utils/misc.js');
const isInDisplayBounds = require('./utils/isInDisplayBounds.js');
const getGuid = require('./utils/getGuid.js'); const getGuid = require('./utils/getGuid.js');
const log = require('./log.js') const log = require('./log.js')
const logLevels = require('./enums/logLevels.js'); const logLevels = require('./enums/logLevels.js');
const notify = require('./notify/electron-notify.js'); const notify = require('./notify/electron-notify.js');
const throttle = require('./utils/throttle.js');
const { getConfigField, updateConfigField } = require('./config.js');
//context menu //context menu
const contextMenu = require('./menus/contextMenu.js'); const contextMenu = require('./menus/contextMenu.js');
@ -25,6 +29,7 @@ let mainWindow;
let windows = {}; let windows = {};
let willQuitApp = false; let willQuitApp = false;
let isOnline = true; let isOnline = true;
let boundsChangeWindow;
// note: this file is built using browserify in prebuild step. // note: this file is built using browserify in prebuild step.
const preloadMainScript = path.join(__dirname, 'preload/_preloadMain.js'); const preloadMainScript = path.join(__dirname, 'preload/_preloadMain.js');
@ -37,19 +42,29 @@ function removeWindowKey(key) {
delete windows[ key ]; delete windows[ key ];
} }
function getHostFromUrl(url) { function getParsedUrl(url) {
let parsedUrl = nodeURL.parse(url); let parsedUrl = nodeURL.parse(url);
let UrlHost = parsedUrl.host; return parsedUrl;
return UrlHost
} }
function createMainWindow(initialUrl) { 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 url = initialUrl;
let key = getGuid(); let key = getGuid();
let newWinOpts = { let newWinOpts = {
title: 'Symphony', title: 'Symphony',
width: 1024, height: 768,
show: true, show: true,
webPreferences: { webPreferences: {
sandbox: true, 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 // note: augmenting with some custom values
newWinOpts.winKey = key; newWinOpts.winKey = key;
mainWindow = new electron.BrowserWindow(newWinOpts); mainWindow = new electron.BrowserWindow(newWinOpts);
mainWindow.winName = 'main'; mainWindow.winName = 'main';
let throttledMainWinBoundsChange = throttle(5000, saveMainWinBounds);
mainWindow.on('move', throttledMainWinBoundsChange);
mainWindow.on('resize',throttledMainWinBoundsChange);
function retry() { function retry() {
if (!isOnline) { if (!isOnline) {
loadErrors.showNetworkConnectivityError(mainWindow, url, retry); loadErrors.showNetworkConnectivityError(mainWindow, url, retry);
@ -128,8 +170,11 @@ function createMainWindow(initialUrl) {
// open external links in default browser - a tag, window.open // open external links in default browser - a tag, window.open
mainWindow.webContents.on('new-window', function(event, newWinUrl, mainWindow.webContents.on('new-window', function(event, newWinUrl,
frameName, disposition, newWinOptions) { frameName, disposition, newWinOptions) {
let newWinHost = getHostFromUrl(newWinUrl); let newWinParsedUrl = getParsedUrl(newWinUrl);
let mainWinHost = getHostFromUrl(url); 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 host url doesn't match then open in external browser
if (newWinHost !== mainWinHost) { if (newWinHost !== mainWinHost) {
@ -144,44 +189,70 @@ function createMainWindow(initialUrl) {
return; return;
} }
// reposition new window let x = 0;
let mainWinPos = mainWindow.getPosition(); let y = 0;
if (mainWinPos && mainWinPos.length === 2) {
let newWinKey = getGuid();
/* eslint-disable no-param-reassign */ let width = newWinOptions.width || 300;
newWinOptions.x = mainWinPos[0] + 50; let height = newWinOptions.height || 600;
newWinOptions.y = mainWinPos[1] + 50;
newWinOptions.winKey = newWinKey; // try getting x and y position from query parameters
/* eslint-enable no-param-reassign */ 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() { // only accept if both are successfully parsed.
let browserWin = electron.BrowserWindow.fromWebContents(webContents); if (Number.isInteger(newX) && Number.isInteger(newY) &&
isInDisplayBounds(newWinRect)) {
if (browserWin) { x = newX;
browserWin.winName = frameName; y = newY;
} else {
browserWin.once('closed', function() { x = 0;
removeWindowKey(newWinKey); y = 0;
}); }
} else {
addWindowKey(newWinKey, browserWin); // create new window at slight offset from main window.
} ({ x, y } = getWindowSizeAndPosition(mainWindow));
x+=50;
// note: will use later for save-layout feature y+=50;
// 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)
// });
});
} }
/* 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; willQuitApp = true;
}); });
function saveMainWinBounds() {
let newBounds = getWindowSizeAndPosition(mainWindow);
if (newBounds) {
updateConfigField('mainWinPos', newBounds);
}
}
function getMainWindow() { function getMainWindow() {
return mainWindow; 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() { function showMainWindow() {
mainWindow.show(); mainWindow.show();
} }
@ -217,6 +313,12 @@ function setIsOnline(status) {
isOnline = 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) { function activate(windowName) {
let keys = Object.keys(windows); let keys = Object.keys(windows);
for(let i = 0, len = keys.length; i < len; i++) { 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 = { module.exports = {
createMainWindow: createMainWindow, createMainWindow: createMainWindow,
getMainWindow: getMainWindow, getMainWindow: getMainWindow,
@ -236,5 +359,6 @@ module.exports = {
isMainWindow: isMainWindow, isMainWindow: isMainWindow,
hasWindow: hasWindow, hasWindow: hasWindow,
setIsOnline: setIsOnline, setIsOnline: setIsOnline,
activate: activate activate: activate,
setBoundsChangeWindow: setBoundsChangeWindow
}; };

View File

@ -1,4 +1,4 @@
const getConfig = require('../js/getconfig'); const { getConfigField } = require('../js/config');
// mock required so getConfig reads config from correct path // mock required so getConfig reads config from correct path
jest.mock('../js/utils/misc.js', function() { 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() { test('getConfig should have proper url', function() {
return getConfig(false).then(function(result) { return getConfigField('url').then(function(url) {
expect(result.url).toBe('https://my.symphony.com'); expect(url).toBe('https://my.symphony.com');
}); });
}); });