notifications implementation (#34)

* wip: notifications

* wip: more notifications

* wip: add getter to proxy

* wip: add setter

* wip: add static getter and method

* wip: add event handlers

* wip: add doc

* wip: add api demo
This commit is contained in:
Lynn
2017-03-21 09:15:18 -07:00
committed by GitHub
parent 939d083653
commit 0dfa3339a3
16 changed files with 1646 additions and 10 deletions

View File

@@ -12,6 +12,10 @@ Our goal is to improve the performance and development agility of Symphony's des
In order to achieve those goals Symphony is participating and working in close collaboration with the [Foundation Desktop Wrapper Working Group](https://symphonyoss.atlassian.net/wiki/display/WGDWAPI/Working+Group+-+Desktop+Wrapper+API)
## Run demo:
- npm install
- on mac: npm run demo:mac
- on windows: npm run demo:win
## Build Instructions:
- to pick up dependencies: npm install

4
demo/README.md Normal file
View File

@@ -0,0 +1,4 @@
To start the demo from command line do:
- npm install
- npm run demo:mac (on mac)
- npm run demo:win (on windows)

75
demo/index.html Normal file
View File

@@ -0,0 +1,75 @@
<html>
<head>
</head>
<body>
<h1>Symphony Electron API Demo</h1>
<hr>
<p>Notifications:<p>
<button id='notf'>show notification</button>
<p>
<label for='title'>title:</label>
<input type='text' id='title' value='Notification Demo'/>
</p>
<p>
<label for='body'>body:</label>
<input type='text' id='body' value='Some message'/>
</p>
<p>
<label for='image'>image url:</label>
<input type='text' id='image' value='https://lh3.googleusercontent.com/-s2PXL6wWMCc/AAAAAAAAAAI/AAAAAAAAAAA/AAomvV2gUNMMeFsOijwVVpihfg_anpKWQA/s32-c-mo/photo.jpg'/>
</p>
<p>
<label for='flash'>flash:</label>
<input type='checkbox' id='flash'/>
</p>
<p>
<label for='color'>color:</label>
<input type='text' id='color' value='white'/>
<br>
<hr>
<p>Badge Count:<p>
<button id='inc-badge'>increment badge count</button>
<br>
<button id='clear-badge'>clear badge count</button>
</body>
<script>
var notfEl = document.getElementById('notf');
notfEl.addEventListener('click', function() {
var title = document.getElementById('title').value;
var body = document.getElementById('body').value;
var imageUrl = document.getElementById('image').value;
var shouldFlash = document.getElementById('flash').checked;
var color = document.getElementById('color').value;
var notf = new SYM_API.Notification(title, {
body: body,
image: imageUrl,
flash: shouldFlash,
color: color || 'white'
});
notf.addEventListener('click', function() {
alert('notification clicked');
});
notf.addEventListener('close', function() {
alert('notification closed');
});
});
var badgeCount = 0;
var incBadgeEl = document.getElementById('inc-badge');
incBadgeEl.addEventListener('click', function() {
badgeCount++;
SYM_API.setBadgeCount(badgeCount);
});
var incBadgeEl = document.getElementById('clear-badge');
incBadgeEl.addEventListener('click', function() {
badgeCount = 0;
SYM_API.setBadgeCount(0);
});
</script>
</html>

View File

@@ -7,10 +7,23 @@ const cmds = keyMirror({
open: null,
registerLogger: null,
setBadgeCount: null,
badgeDataUrl: null,
badgeDataUrl: null
});
const proxyCmds = keyMirror({
createObject: null,
addEvent: null,
removeEvent: null,
eventCallback: null,
invokeMethod: null,
invokeResult: null,
get: null,
getResult: null,
set: null
});
module.exports = {
cmds: cmds,
proxyCmds: proxyCmds,
apiName: 'symphony-api'
}

View File

@@ -6,7 +6,7 @@ const nodeURL = require('url');
const squirrelStartup = require('electron-squirrel-startup');
const getConfig = require('./getConfig.js');
const { isMac } = require('./utils/misc.js');
const { isMac, isDevEnv } = require('./utils/misc.js');
// exit early for squirrel installer
if (squirrelStartup) {
@@ -28,6 +28,20 @@ const windowMgr = require('./windowMgr.js');
app.on('ready', getUrlAndOpenMainWindow);
function getUrlAndOpenMainWindow() {
// for dev env allow passing url argument
if (isDevEnv) {
let url;
process.argv.forEach((val) => {
if (val.startsWith('--url=')) {
url = val.substr(6);
}
});
if (url) {
windowMgr.createMainWindow(url);
return;
}
}
getConfig().then(function(config) {
let protocol = '';
// add https protocol if none found.

View File

@@ -13,6 +13,7 @@ const badgeCount = require('./badgeCount.js');
const apiEnums = require('./enums/api.js');
const apiCmds = apiEnums.cmds;
const apiName = apiEnums.apiName;
const apiProxyCmds = apiEnums.proxyCmds
/**
* Ensure events comes from a window that we have created.
@@ -20,6 +21,7 @@ const apiName = apiEnums.apiName;
* @return {Boolean} returns true if exists otherwise false
*/
function isValidWindow(event) {
var result = false;
if (event && event.sender) {
// validate that event sender is from window we created
const browserWin = electron.BrowserWindow.fromWebContents(event.sender);
@@ -27,10 +29,16 @@ function isValidWindow(event) {
event.sender.browserWindowOptions.webPreferences &&
event.sender.browserWindowOptions.webPreferences.winKey;
return windowMgr.hasWindow(browserWin, winKey);
result = windowMgr.hasWindow(browserWin, winKey);
}
return false;
if (!result) {
/* eslint-disable no-console */
console.log('invalid window try to perform action, ignoring action.');
/* eslint-enable no-console */
}
return result;
}
/**
@@ -39,9 +47,6 @@ function isValidWindow(event) {
*/
electron.ipcMain.on(apiName, (event, arg) => {
if (!isValidWindow(event)) {
/* eslint-disable no-console */
console.log('invalid window try to perform action, ignoring action.');
/* eslint-enable no-console */
return;
}
@@ -78,3 +83,343 @@ electron.ipcMain.on(apiName, (event, arg) => {
windowMgr.createChildWindow(arg.url, title, width, height);
}
});
const Notify = require('./notify/notifyImpl.js');
// holds all project classes that can be created.
const api = {
Notify: Notify
}
// holds all proxy object instances
let liveObjs = {};
let id = 1;
function uniqueId() {
return id++;
}
/**
* Creates instance of given class for proxy in renderer process.
*
* @param {Object} args {
* className {String} name of class to create.
* }
*
* @return {Number} unique id for class intance created.
*/
electron.ipcMain.on(apiProxyCmds.createObject, function(event, args) {
if (!isValidWindow(event)) {
setResult(null);
return;
}
function setResult(value) {
/* eslint-disable no-param-reassign */
event.returnValue = value;
/* eslint-enable no-param-reassign */
}
if (args.className && api[args.className]) {
var obj = new api[args.className](...args.constructorArgsArray);
obj._callbacks = {};
let objId = uniqueId();
liveObjs[objId] = obj;
obj.addEventListener('destroy', function() {
var liveObj = liveObjs[objId];
if (liveObj) {
delete liveObjs[objId];
}
});
setResult(objId);
} else {
setResult(null);
}
});
/**
* Invokes a method for the proxy.
*
* @param {Object} args {
* objId {Number} id of object previously created, not needed for static call.
* invokeId {Number} id used by proxy to uniquely identify this method call
* methodName {String} name of method to call
* arguments: {Array} arguments to invoke method with.
* isStatic: {bool} true is this is a static method.
* className {String} name of class on which func is invoked.
* }
*
* @return {Object} {
* returnValue {Object} result of calling method
* invokeId {Number} id so proxy can identify method call
* }
*/
electron.ipcMain.on(apiProxyCmds.invokeMethod, function(event, args) {
if (!isValidWindow(event) || !args.invokeId) {
return;
}
if (!args.isStatic && (!args.objId || !liveObjs[args.objId])) {
event.sender.send(apiProxyCmds.invokeResult, {
error: 'calling obj is not present',
invokeId: args.invokeId
});
return;
}
// static method call must have className and class must exist in api
if (args.isStatic) {
if (!args.className) {
event.sender.send(apiProxyCmds.invokeResult, {
error: 'for static method must provide class name',
invokeId: args.invokeId
});
return;
}
if (!api[args.className]) {
event.sender.send(apiProxyCmds.invokeResult, {
error: 'no class exists: ' + args.className,
invokeId: args.invokeId
});
return;
}
}
let result;
let funcArgs = args.arguments || [];
if (args.isStatic) {
let classType = api[args.className];
if (!args.methodName || !classType[args.methodName]) {
event.sender.send(apiProxyCmds.invokeResult, {
error: 'no such static method',
invokeId: args.invokeId
});
return;
}
result = classType[args.methodName](...funcArgs);
} else {
let obj = liveObjs[args.objId];
if (!args.methodName || !obj[args.methodName]) {
event.sender.send(apiProxyCmds.invokeResult, {
error: 'no such method',
invokeId: args.invokeId
});
return;
}
// special method to lose ref to obj
if (args.methodName === 'destroy') {
delete liveObjs[args.objId];
}
result = obj[args.methodName](...funcArgs);
}
event.sender.send(apiProxyCmds.invokeResult, {
returnValue: result,
invokeId: args.invokeId
});
});
/**
* Getter implementation. Allows proxy to retrieve value from implementation
* object.
*
* @param {Object} args {
* objId {Number} id of object previously created (not needed for static get)
* getterId {Number} id used by proxy to uniquely identify this getter call.
* getterProperty {String} name of getter property to retrieve.
* isStatic {boo} true if this if getter is for static property.
* className {String} name of class we are operating on here.
* }
*
* @return {Object} {
* returnValue {Object} result of calling method
* getterId {Number} id so proxy can identify getter call
* }
*/
electron.ipcMain.on(apiProxyCmds.get, function(event, args) {
if (!isValidWindow(event) || !args.getterId) {
return;
}
// non-static calls must have an live instance available
if (!args.isStatic && (!args.objId || !liveObjs[args.objId])) {
event.sender.send(apiProxyCmds.getResult, {
error: 'calling obj is not present',
getterId: args.getterId
});
return;
}
// static get must have className and class must exist in api
if (args.isStatic) {
if (!args.className) {
event.sender.send(apiProxyCmds.getResult, {
error: 'for static getter must provide class name',
getterId: args.getterId
});
return;
}
if (!api[args.className]) {
event.sender.send(apiProxyCmds.getResult, {
error: 'no class exists: ' + args.className,
getterId: args.getterId
});
return;
}
}
if (!args.getterProperty) {
event.sender.send(apiProxyCmds.getResult, {
error: 'property name not provided',
getterId: args.getterId
});
return;
}
let result;
if (args.isStatic) {
let classType = api[args.className]
result = classType[args.getterProperty];
} else {
let obj = liveObjs[args.objId];
result = obj[args.getterProperty];
}
event.sender.send(apiProxyCmds.getResult, {
returnValue: result,
getterId: args.getterId
});
});
/**
* Setter implementation. Allows proxy to set value on implementation object.
*
* @param {Object} args {
* objId {Number} id of object previously created.
* setterProperty {String} name of setter property.
* setterValue {object} new value to set.
* }
*
* @return {Object} input setter value
*/
electron.ipcMain.on(apiProxyCmds.set, function(event, args) {
if (!isValidWindow(event)) {
setResult(null);
return;
}
if (!args.objId || !liveObjs[args.objId]) {
setResult(null);
return;
}
if (!args.setterProperty) {
setResult(null);
return;
}
function setResult(value) {
/* eslint-disable no-param-reassign */
event.returnValue = value;
/* eslint-enable no-param-reassign */
}
let obj = liveObjs[args.objId];
obj[args.setterProperty] = args.setterValue;
setResult(args.setterValue);
});
/**
* Listens to an event and calls back to renderer proxy when given event occurs.
*
* @param {Object} args {
* objId {Number} id of object previously created.
* callbackId {Number} id used by proxy to uniquely identify this event.
* eventName {String} name of event to listen for.
* }
*
* @return {Object} {
* result {Object} result from invoking callback.
* callbackId {Number} id so proxy can identify event that occurred.
* }
*/
electron.ipcMain.on(apiProxyCmds.addEvent, function(event, args) {
if (!isValidWindow(event)) {
return;
}
/* eslint-disable no-console */
if (!args.objId || !liveObjs[args.objId]) {
console.log('calling obj is not present');
return;
}
if (!args.callbackId) {
console.log('no callback id provided');
return;
}
if (!args.eventName) {
console.log('no eventName provided');
return;
}
/* eslint-enable no-console */
let obj = liveObjs[args.objId];
let callbackFunc = function(result) {
event.sender.send(apiProxyCmds.eventCallback, {
callbackId: args.callbackId,
result: result
});
}
obj._callbacks[args.callbackId] = callbackFunc;
obj.addEventListener(args.eventName, callbackFunc);
});
/**
* Stops listening to given event.
*
* @param {Object} args {
* objId {Number} id of object previously created.
* callbackId {Number} id used by proxy to uniquely identify this event.
* eventName {String} name of event to listen for.
* }
*/
electron.ipcMain.on(apiProxyCmds.removeEvent, function(event, args) {
if (!isValidWindow(event)) {
return;
}
/* eslint-disable no-console */
if (!args.objId || !liveObjs[args.objId]) {
console.log('calling obj is not present');
return;
}
if (!args.callbackId) {
console.log('no callback id provided');
return;
}
if (!args.eventName) {
console.log('no eventName provided');
return;
}
/* eslint-enable no-console */
let obj = liveObjs[args.objId];
let callbackFunc = obj._callbacks[args.callbackId];
if (typeof callbackFunc === 'function') {
obj.removeEventListener(args.eventName, callbackFunc);
}
});

View File

@@ -0,0 +1,41 @@
'use strict';
// One animation at a time
const AnimationQueue = function(options) {
this.options = options;
this.queue = [];
this.running = false;
}
AnimationQueue.prototype.push = function(object) {
if (this.running) {
this.queue.push(object);
} else {
this.running = true;
this.animate(object);
}
}
AnimationQueue.prototype.animate = function(object) {
object.func.apply(null, object.args)
.then(function() {
if (this.queue.length > 0) {
// Run next animation
this.animate.call(this, this.queue.shift());
} else {
this.running = false;
}
}.bind(this))
.catch(function(err) {
/* eslint-disable no-console */
console.error('animation queue encountered an error: ' + err +
' with stack trace:' + err.stack);
/* eslint-enable no-console */
})
}
AnimationQueue.prototype.clear = function() {
this.queue = [];
}
module.exports = AnimationQueue;

View File

@@ -0,0 +1,148 @@
'use strict'
//
// BrowserWindow preload script use to create notifications window for
// elctron-notify project.
//
// code here adapted from: https://www.npmjs.com/package/electron-notify
//
const electron = require('electron');
const ipc = electron.ipcRenderer;
function setStyle(config) {
// Style it
let notiDoc = window.document;
let container = notiDoc.getElementById('container');
let header = notiDoc.getElementById('header');
let image = notiDoc.getElementById('image');
let title = notiDoc.getElementById('title');
let message = notiDoc.getElementById('message');
let close = notiDoc.getElementById('close');
// Default style
setStyleOnDomElement(config.defaultStyleContainer, container)
// Size and radius
let style = {
height: config.height - 2 * config.borderRadius - 2 * config.defaultStyleContainer.padding,
width: config.width - 2 * config.borderRadius - 2 * config.defaultStyleContainer.padding,
borderRadius: config.borderRadius + 'px'
}
setStyleOnDomElement(style, container)
setStyleOnDomElement(config.defaultStyleHeader, header);
setStyleOnDomElement(config.defaultStyleImage, image);
setStyleOnDomElement(config.defaultStyleTitle, title);
setStyleOnDomElement(config.defaultStyleText, message);
setStyleOnDomElement(config.defaultStyleClose, close);
}
function setContents(notificationObj) {
// sound
if (notificationObj.sound) {
// Check if file is accessible
try {
// If it's a local file, check it's existence
// Won't check remote files e.g. http://
if (notificationObj.sound.match(/^file:/) !== null
|| notificationObj.sound.match(/^\//) !== null) {
let audio = new window.Audio(notificationObj.sound)
audio.play()
}
} catch (e) {
log('electron-notify: ERROR could not find sound file: ' + notificationObj.sound.replace('file://', ''), e, e.stack)
}
}
let notiDoc = window.document
let container = notiDoc.getElementById('container');
if (notificationObj.color) {
container.style.backgroundColor = notificationObj.color;
}
if (notificationObj.flash) {
let origColor = container.style.backgroundColor;
setInterval(function() {
if (container.style.backgroundColor === 'red') {
container.style.backgroundColor = origColor;
} else {
container.style.backgroundColor = 'red';
}
}, 1000);
}
// Title
let titleDoc = notiDoc.getElementById('title')
titleDoc.innerHTML = notificationObj.title || ''
// message
let messageDoc = notiDoc.getElementById('message')
messageDoc.innerHTML = notificationObj.text || ''
// Image
let imageDoc = notiDoc.getElementById('image')
if (notificationObj.image) {
imageDoc.src = notificationObj.image
} else {
setStyleOnDomElement({ display: 'none'}, imageDoc)
}
const winId = notificationObj.windowId;
// Close button
let closeButton = notiDoc.getElementById('close')
closeButton.addEventListener('click', function(clickEvent) {
clickEvent.stopPropagation()
ipc.send('electron-notify-close', winId, notificationObj)
})
// URL
container.addEventListener('click', function() {
ipc.send('electron-notify-click', winId, notificationObj)
})
}
function setStyleOnDomElement(styleObj, domElement) {
try {
let styleAttr = Object.keys(styleObj);
styleAttr.forEach(function(attr) {
/* eslint-disable */
domElement.style[attr] = styleObj[attr];
/* eslint-enable */
});
} catch (e) {
throw new Error('electron-notify: Could not set style on domElement', styleObj, domElement)
}
}
function loadConfig(conf) {
setStyle(conf || {})
}
function reset() {
let notiDoc = window.document
let container = notiDoc.getElementById('container')
let closeButton = notiDoc.getElementById('close')
// Remove event listener
let newContainer = container.cloneNode(true)
container.parentNode.replaceChild(newContainer, container)
let newCloseButton = closeButton.cloneNode(true)
closeButton.parentNode.replaceChild(newCloseButton, closeButton)
}
ipc.on('electron-notify-set-contents', setContents)
ipc.on('electron-notify-load-config', loadConfig)
ipc.on('electron-notify-reset', reset)
function log() {
/* eslint-disable no-console */
console.log.apply(console, arguments)
/* eslint-enable no-console */
}

View File

@@ -0,0 +1,13 @@
<html>
<head></head>
<body style='overflow: hidden; -webkit-user-select: none;'>
<div id="container">
<div id="header">
<img src="" id="image" />
<span id="title"></span>
</div>
<p id="message"></p>
<div id="close">X</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,560 @@
'use strict'
//
// code here adapted from https://www.npmjs.com/package/electron-notify
// made following changes:
// - place notification in corner of screen
// - notification color
// - notification flash/blink
// - custom design for symphony notification style
// - if screen added/removed or size change then close all notifications
//
const path = require('path');
const fs = require('fs');
const async = require('async');
const electron = require('electron');
const app = electron.app;
const BrowserWindow = electron.BrowserWindow;
const ipc = electron.ipcMain;
let AnimationQueue = require('./AnimationQueue.js');
// Array of windows with currently showing notifications
let activeNotifications = [];
// Recycle windows
let inactiveWindows = [];
// If we cannot show all notifications, queue them
let notificationQueue = [];
// To prevent executing mutliple animations at once
let animationQueue = new AnimationQueue();
// To prevent double-close notification window
let closedNotifications = {};
// Give each notification a unique id
let latestID = 0;
let nextInsertPos = {};
let config = {
// corner to put notifications
// upper-right, upper-left, lower-right, lower-left
startCorner: 'upper-right',
width: 300,
height: 80,
padding: 4,
borderRadius: 5,
displayTime: 5000,
animationSteps: 5,
animationStepMs: 5,
animateInParallel: true,
pathToModule: '',
logging: true,
defaultStyleContainer: {
display: 'flex',
justifyContent: 'center',
flexDirection: 'column',
backgroundColor: '#f0f0f0',
overflow: 'hidden',
padding: 4,
border: '1px solid #CCC',
position: 'relative',
lineHeight: '15px'
},
defaultStyleHeader: {
flex: '0 0 auto',
display: 'flex',
flexDirection: 'row'
},
defaultStyleImage: {
flex: '0 0 auto',
overflow: 'hidden',
height: 30,
width: 30,
marginLeft: 0,
marginRight: 8
},
defaultStyleClose: {
position: 'absolute',
top: 12,
right: 12,
fontSize: 12,
color: '#CCC'
},
defaultStyleTitle: {
fontFamily: 'Arial',
fontSize: 13,
marginRight: 10,
alignSelf: 'center',
overflow: 'hidden',
display: '-webkit-box',
webkitLineClamp: 2,
webkitBoxOrient: 'vertical',
},
defaultStyleText: {
flex: '0 0 auto',
fontFamily: 'Calibri',
fontSize: 12,
margin: 0,
marginTop: 3,
overflow: 'hidden',
display: '-webkit-box',
webkitLineClamp: 2,
webkitBoxOrient: 'vertical',
cursor: 'default'
},
defaultWindow: {
alwaysOnTop: true,
skipTaskbar: true,
resizable: false,
show: false,
frame: false,
transparent: true,
acceptFirstMouse: true,
webPreferences: {
preload: path.join(__dirname, 'electron-notify-preload.js'),
sandbox: true,
nodeIntegration: false
}
}
}
// function setConfig(customConfig) {
// Object.assign(customConfig, config);
//
// calcDimensions();
// }
app.on('ready', function() {
setupConfig();
// if display added/removed/changed then re-run setup and remove all existing
// notifications. ToDo: should reposition notifications rather than closing.
electron.screen.on('display-added', setupConfig);
electron.screen.on('display-removed', setupConfig);
electron.screen.on('display-metrics-changed', setupConfig);
});
function getTemplatePath() {
let templatePath = path.join(__dirname, 'electron-notify.html');
try {
fs.statSync(templatePath).isFile();
} catch (err) {
log('electron-notify: Could not find template ("' + templatePath + '").');
}
config.templatePath = 'file://' + templatePath;
return config.templatePath;
}
function calcDimensions() {
// Calc totalHeight & totalWidth
config.totalHeight = config.height + config.padding
config.totalWidth = config.width + config.padding
let firstPosX, firstPosY;
switch (config.startCorner) {
case 'upper-right':
firstPosX = config.corner.x - config.totalWidth;
firstPosY = config.corner.y;
break;
case 'lower-right':
firstPosX = config.corner.x - config.totalWidth;
firstPosY = config.corner.y - config.totalHeight;
break;
case 'lower-left':
firstPosX = config.corner.x;
firstPosY = config.corner.y - config.totalHeight;
break;
case 'upper-left':
default:
firstPosX = config.corner.x;
firstPosY = config.corner.y;
break;
}
// Calc pos of first notification:
config.firstPos = {
x: firstPosX,
y: firstPosY
}
// Set nextInsertPos
nextInsertPos.x = config.firstPos.x
nextInsertPos.y = config.firstPos.y
}
function setupConfig() {
closeAll();
// Use primary display only
let display = electron.screen.getPrimaryDisplay();
config.corner = {};
config.corner.x = display.bounds.x + display.workArea.x;
config.corner.y = display.bounds.y + display.workArea.y;
// update corner x/y based on corner of screen where notf should appear
const workAreaWidth = display.workAreaSize.width;
const workAreaHeight = display.workAreaSize.height;
switch (config.startCorner) {
case 'upper-right':
config.corner.x += workAreaWidth;
break;
case 'lower-right':
config.corner.x += workAreaWidth;
config.corner.y += workAreaHeight;
break;
case 'lower-left':
config.corner.y += workAreaHeight;
break;
case 'upper-left':
default:
// no change needed
break;
}
calcDimensions();
// Maximum amount of Notifications we can show:
config.maxVisibleNotifications = Math.floor(display.workAreaSize.height / config.totalHeight);
config.maxVisibleNotifications = config.maxVisibleNotifications > 5 ? 5 : config.maxVisibleNotifications;
}
function notify(notification) {
// Is it an object and only one argument?
if (arguments.length === 1 && typeof notification === 'object') {
let notf = Object.assign({}, notification);
// Use object instead of supplied parameters
notf.id = latestID
latestID++
animationQueue.push({
func: showNotification,
args: [ notf ]
})
return notf.id
}
log('electron-notify: ERROR notify() only accepts a single object with notification parameters.')
return null;
}
function showNotification(notificationObj) {
return new Promise(function(resolve) {
// Can we show it?
if (activeNotifications.length < config.maxVisibleNotifications) {
// Get inactiveWindow or create new:
getWindow().then(function(notificationWindow) {
// Move window to position
calcInsertPos()
setWindowPosition(notificationWindow, nextInsertPos.x, nextInsertPos.y)
// Add to activeNotifications
activeNotifications.push(notificationWindow)
// Display time per notification basis.
let displayTime = notificationObj.displayTime ? notificationObj.displayTime : config.displayTime
// Set timeout to hide notification
let timeoutId
let closeFunc = buildCloseNotification(notificationWindow, notificationObj, function() {
return timeoutId
})
let closeNotificationSafely = buildCloseNotificationSafely(closeFunc)
timeoutId = setTimeout(function() {
closeNotificationSafely('timeout')
}, displayTime)
// Trigger onShowFunc if existent
if (notificationObj.onShowFunc) {
notificationObj.onShowFunc({
event: 'show',
id: notificationObj.id,
closeNotification: closeNotificationSafely
})
}
var updatedNotificationWindow = notificationWindow;
// Save onClickFunc in notification window
if (notificationObj.onClickFunc) {
updatedNotificationWindow.electronNotifyOnClickFunc = notificationObj.onClickFunc
} else {
delete updatedNotificationWindow.electronNotifyOnClickFunc
}
if (notificationObj.onCloseFunc) {
updatedNotificationWindow.electronNotifyOnCloseFunc = notificationObj.onCloseFunc
} else {
delete updatedNotificationWindow.electronNotifyOnCloseFunc
}
const windowId = notificationWindow.id;
// Set contents, ...
updatedNotificationWindow.webContents.send('electron-notify-set-contents',
Object.assign({ windowId: windowId}, notificationObj));
// Show window
updatedNotificationWindow.showInactive();
resolve(updatedNotificationWindow)
})
} else {
// Add to notificationQueue
notificationQueue.push(notificationObj)
resolve()
}
})
}
// Close notification function
function buildCloseNotification(notificationWindow, notificationObj, getTimeoutId) {
return function(event) {
if (closedNotifications[notificationObj.id]) {
delete closedNotifications[notificationObj.id];
return new Promise(function(exitEarly) { exitEarly() });
}
closedNotifications[notificationObj.id] = true;
if (notificationWindow.electronNotifyOnCloseFunc) {
notificationWindow.electronNotifyOnCloseFunc({
event: event,
id: notificationObj.id
});
// ToDo: fix this: shouldn't delete method on arg
/* eslint-disable */
delete notificationWindow.electronNotifyOnCloseFunc;
/* eslint-enable */
}
// reset content
notificationWindow.webContents.send('electron-notify-reset')
if (getTimeoutId && typeof getTimeoutId === 'function') {
let timeoutId = getTimeoutId();
clearTimeout(timeoutId);
}
// Recycle window
let pos = activeNotifications.indexOf(notificationWindow);
activeNotifications.splice(pos, 1);
inactiveWindows.push(notificationWindow);
// Hide notification
notificationWindow.hide();
checkForQueuedNotifications();
// Move notifications down
return moveOneDown(pos);
}
}
// Always add to animationQueue to prevent erros (e.g. notification
// got closed while it was moving will produce an error)
function buildCloseNotificationSafely(closeFunc) {
return function(reason) {
animationQueue.push({
func: closeFunc,
args: [ reason || 'closedByAPI' ]
});
}
}
ipc.on('electron-notify-close', function (event, winId, notificationObj) {
let closeFunc = buildCloseNotification(BrowserWindow.fromId(winId), notificationObj);
buildCloseNotificationSafely(closeFunc)('close');
});
ipc.on('electron-notify-click', function (event, winId, notificationObj) {
let notificationWindow = BrowserWindow.fromId(winId);
if (notificationWindow && notificationWindow.electronNotifyOnClickFunc) {
let closeFunc = buildCloseNotification(notificationWindow, notificationObj);
notificationWindow.electronNotifyOnClickFunc({
event: 'click',
id: notificationObj.id,
closeNotification: buildCloseNotificationSafely(closeFunc)
});
delete notificationWindow.electronNotifyOnClickFunc;
}
});
/*
* Checks for queued notifications and add them
* to AnimationQueue if possible
*/
function checkForQueuedNotifications() {
if (notificationQueue.length > 0 &&
activeNotifications.length < config.maxVisibleNotifications) {
// Add new notification to animationQueue
animationQueue.push({
func: showNotification,
args: [ notificationQueue.shift() ]
})
}
}
/*
* Moves the notifications one position down,
* starting with notification at startPos
*
* @param {int} startPos
*/
function moveOneDown(startPos) {
return new Promise(function(resolve) {
if (startPos >= activeNotifications || startPos === -1) {
resolve()
return
}
// Build array with index of affected notifications
let notificationPosArray = []
for (let i = startPos; i < activeNotifications.length; i++) {
notificationPosArray.push(i)
}
// Start to animate all notifications at once or in parallel
let asyncFunc = async.map // Best performance
if (config.animateInParallel === false) {
asyncFunc = async.mapSeries // Sluggish
}
asyncFunc(notificationPosArray, moveNotificationAnimation, function() {
resolve()
})
})
}
function moveNotificationAnimation(i, done) {
// Get notification to move
let notificationWindow = activeNotifications[i];
// Calc new y position
let newY;
switch(config.startCorner) {
case 'upper-right':
case 'upper-left':
newY = config.corner.y + (config.totalHeight * i);
break;
default:
case 'lower-right':
case 'lower-left':
newY = config.corner.y - (config.totalHeight * (i + 1));
break;
}
// Get startPos, calc step size and start animationInterval
let startY = notificationWindow.getPosition()[1]
let step = (newY - startY) / config.animationSteps
let curStep = 1
let animationInterval = setInterval(function() {
// Abort condition
if (curStep === config.animationSteps) {
setWindowPosition(notificationWindow, config.firstPos.x, newY);
clearInterval(animationInterval)
done(null, 'done');
return;
}
// Move one step down
setWindowPosition(notificationWindow, config.firstPos.x, startY + curStep * step)
curStep++
}, config.animationStepMs)
}
function setWindowPosition(browserWin, posX, posY) {
browserWin.setPosition(parseInt(posX, 10), parseInt(posY, 10))
}
/*
* Find next possible insert position (on top)
*/
function calcInsertPos() {
if (activeNotifications.length < config.maxVisibleNotifications) {
switch(config.startCorner) {
case 'upper-right':
case 'upper-left':
nextInsertPos.y = config.corner.y + (config.totalHeight * activeNotifications.length);
break;
default:
case 'lower-right':
case 'lower-left':
nextInsertPos.y = config.corner.y - (config.totalHeight * (activeNotifications.length + 1));
break;
}
}
}
/*
* Get a window to display a notification. Use inactiveWindows or
* create a new window
* @return {Window}
*/
function getWindow() {
return new Promise(function(resolve) {
let notificationWindow
// Are there still inactiveWindows?
if (inactiveWindows.length > 0) {
notificationWindow = inactiveWindows.pop()
resolve(notificationWindow)
} else {
// Or create a new window
let windowProperties = config.defaultWindow
windowProperties.width = config.width
windowProperties.height = config.height
notificationWindow = new BrowserWindow(windowProperties)
notificationWindow.setVisibleOnAllWorkspaces(true)
notificationWindow.loadURL(getTemplatePath())
notificationWindow.webContents.on('did-finish-load', function() {
// Done
notificationWindow.webContents.send('electron-notify-load-config', config)
resolve(notificationWindow)
})
}
})
}
function closeAll() {
// Clear out animation Queue and close windows
animationQueue.clear();
activeNotifications.forEach(function(window) {
if (window.electronNotifyOnCloseFunc) {
window.electronNotifyOnCloseFunc({
event: 'close-all'
});
// ToDo: fix this: shouldn't delete method on arg
/* eslint-disable */
delete window.electronNotifyOnCloseFunc;
/* eslint-enable */
}
window.close();
});
cleanUpInactiveWindow();
// Reset certain vars
nextInsertPos = {};
activeNotifications = [];
}
/**
/* once a minute, remove inactive windows to free up memory used.
*/
setInterval(cleanUpInactiveWindow, 60000);
function cleanUpInactiveWindow() {
inactiveWindows.forEach(function(window) {
window.close();
});
inactiveWindows = [];
}
function log() {
if (config.logging === true) {
/* eslint-disable no-console */
console.log.apply(console, arguments);
/* eslint-enable no-console */
}
}
module.exports.notify = notify
// module.exports.setConfig = setConfig
module.exports.closeAll = closeAll

78
js/notify/notifyImpl.js Normal file
View File

@@ -0,0 +1,78 @@
'use strict';
const EventEmitter = require('events');
const { notify } = require('./electron-notify.js');
/**
* implementation for notifications interface,
* wrapper around electron-notify.
*/
class Notify {
constructor(title, options) {
this.emitter = new EventEmitter();
this._id = notify({
title: title,
text: options.body,
image: options.image,
flash: options.flash,
color: options.color,
onShowFunc: onShow.bind(this),
onClickFunc: onClick.bind(this),
onCloseFunc: onClose.bind(this)
});
function onShow(arg) {
if (arg.id === this._id) {
this.emitter.emit('show');
this._closeNotification = arg.closeNotification;
}
}
function onClick(arg) {
if (arg.id === this._id) {
this.emitter.emit('click');
}
}
function onClose(arg) {
if (arg.id === this._id || arg.event === 'close-all') {
this.emitter.emit('close');
this.destroy();
}
}
}
close() {
if (typeof this._closeNotification === 'function') {
this._closeNotification('close');
}
this.destroy();
}
destroy() {
this.emitter.removeAllListeners();
}
static get permission() {
return 'granted';
}
addEventListener(event, cb) {
if (event && typeof cb === 'function') {
this.emitter.on(event, cb);
}
}
removeEventListener(event, cb) {
if (event && typeof cb === 'function') {
this.emitter.removeEventListener(event, cb);
}
}
//
// private stuff below here
//
}
module.exports = Notify;

View File

@@ -0,0 +1,65 @@
'use strict';
/**
* interface defn for notifications. Implementation of this interface
* is in notifyImpl.js
*
* Used by preloadMain.js to create proxy for actual implementation.
*
* Keep interface here in sync with implementation, in order
* to expose methods/props to renderer.
*
* Note: getters and method calls here return a promise.
*/
/* eslint-disable */
class Notify {
/**
* Dislays a notifications
*
* @param {String} title Title of notification
* @param {Object} options {
* body {string} main text to display in notifications
* image {string} url of image to show in notifications
* flash {bool} true if notification should flash (default false)
* color {string} background color for notification
* }
*/
constructor(title, options) {}
/**
* close notification
*/
close() {}
/**
* call to clean up ref held by main process to notification.
* note: calling close will also invoke destroy.
*/
destroy() {}
/**
* This returns a promise and is always 'granted'
* @return {promise} promise fullfilled with 'granted'
*/
static get permission() {}
/**
* add event listeners for 'click' and 'close' events
*
* @param {String} event event to listen for
* @param {func} cb callback invoked when event occurs
*/
addEventListener(event, cb) {}
/**
* remove event listeners for 'click' and 'close' events
*
* @param {String} event event to stop listening for.
* @param {func} cb callback associated with original addEventListener
*/
removeEventListener(event, cb) {}
}
/* eslint-enable */
module.exports = Notify;

262
js/preload/createProxy.js Normal file
View File

@@ -0,0 +1,262 @@
'use strict';
const { ipcRenderer } = require('electron');
const apiEnums = require('../enums/api.js');
const proxyCmds = apiEnums.proxyCmds;
/**
* Creates and returns a proxy (in renderer process) that will use IPC
* with main process where "real" instance is created.
*
* The constructor is executed synchronously, so take care to not block
* processes.
*
* All method calls will be sent over IPC to main process, evaulated and
* result returned back to main process. Method calls return a promise.
*
* Special method calls: "addEventListener" and "removeEventListener" allow
* attaching/detaching to events.
*
* Getters (e.g., x.y) will return a promise that gets fullfilled
* when ipc returns value.
*
* Setters are synchronously executed (so take care).
*
* Note: The "real" instance should implement a destroy method (e.g., close) that
* should be used to destroy the instance held in main process, otherwise a
* memory leak will occur - as renderer can not know when instance is no longer
* used.
*
* @param {Class} ApiClass reference to prototype/class constructor.
* @return {object} proxy for ApiClass.
*/
function createProxy(ApiClass) {
return new Proxy(ApiClass, constructorHandler);
}
let id = 1;
function uniqueId() {
return id++;
}
let constructorHandler = {
construct: function(target, argumentsList) {
var arg = {
className: target.name,
constructorArgsArray: argumentsList
};
var objId = ipcRenderer.sendSync(proxyCmds.createObject, arg);
if (!objId) {
throw new Error('can not create obj: ' + target.name);
}
var ProxyClass = new target();
ProxyClass._objId = objId;
ProxyClass._callbacks = new WeakMap();
let instanceHandler = {
get: instanceGetHandler,
set: instanceSetHandler
}
return new Proxy(ProxyClass, instanceHandler);
},
// static getter and method handler
get: staticGetHandler
}
function instanceGetHandler(target, name) {
// all methods and getters we support should be on the prototype
let prototype = Reflect.getPrototypeOf(target);
let desc = Object.getOwnPropertyDescriptor(prototype, name);
// does this have a "getter"
if (desc && desc.get) {
return getHandler(target, name, false);
}
// does this have a method
if (desc && typeof desc.value === 'function') {
if (name === 'addEventListener') {
return addEventHandler(target);
}
if (name === 'removeEventListener') {
return removeEventHandler(target);
}
return methodHandler(target, name, false);
}
return null;
}
function addEventHandler(target) {
return function(eventName, callback) {
var callbackId = eventName + uniqueId();
var args = {
callbackId: callbackId,
objId: target._objId,
eventName: eventName
};
ipcRenderer.send(proxyCmds.addEvent, args);
let callbackFunc = function(arg) {
if (arg.callbackId === callbackId) {
callback(args.result);
}
}
ipcRenderer.on(proxyCmds.eventCallback, callbackFunc);
target._callbacks.set(callback, {
callbackId: callbackId,
callbackbackFunc: callbackFunc
});
}
}
function removeEventHandler(target) {
return function(eventName, callback) {
if (target._callbacks && target._callback.has(callback)) {
let callbackObj = target._callback.get(callback);
let args = {
eventName: eventName,
callbackId: callbackObj.callbackId,
objId: target._objId
}
ipcRenderer.removeListener(proxyCmds.eventCallback,
callbackObj.callbackbackFunc);
ipcRenderer.send(proxyCmds.removeEvent, args);
target._callbacks.delete(callback);
}
}
}
function methodHandler(target, methodName, isStatic) {
return function(...argPassedToMethod) {
return new Promise(function(resolve, reject) {
var invokeId = methodName + uniqueId();
var args = {
invokeId: invokeId,
objId: target._objId,
methodName: methodName,
arguments: argPassedToMethod,
isStatic: isStatic,
className: target.name
}
if (!isStatic) {
args.objId = target._objId;
}
ipcRenderer.on(proxyCmds.invokeResult, resultCallback);
ipcRenderer.send(proxyCmds.invokeMethod, args);
function removeEventListener() {
ipcRenderer.removeListener(proxyCmds.invokeResult,
resultCallback);
}
function resultCallback(arg) {
if (arg.invokeId === invokeId) {
window.clearTimeout(timer);
removeEventListener();
if (arg.error) {
reject('method called failed: ' + arg.error);
} else {
resolve(arg.returnValue);
}
}
}
// timeout in case we never hear anything back from main process
let timer = setTimeout(function() {
removeEventListener();
reject('timeout_no_reponse');
}, 5000);
});
}
}
function getHandler(target, property, isStatic) {
return new Promise(function(resolve, reject) {
var getterId = property + uniqueId();
var args = {
getterId: getterId,
getterProperty: property,
isStatic: isStatic,
className: target.name
}
if (!isStatic) {
args.objId = target._objId;
}
ipcRenderer.on(proxyCmds.getResult, resultCallback);
ipcRenderer.send(proxyCmds.get, args);
function removeEventListener() {
ipcRenderer.removeListener(proxyCmds.getResult,
resultCallback);
}
function resultCallback(arg) {
if (arg.getterId === getterId) {
window.clearTimeout(timer);
removeEventListener();
if (arg.error) {
reject('getter called failed: ' + arg.error);
} else {
resolve(arg.returnValue);
}
}
}
// timeout in case we never hear anything back from main process
let timer = setTimeout(function() {
removeEventListener();
reject('timeout_no_reponse');
}, 5000);
});
}
function instanceSetHandler(target, property, value) {
let prototype = Reflect.getPrototypeOf(target);
let desc = Object.getOwnPropertyDescriptor(prototype, property);
if (desc && desc.set) {
var args = {
objId: target._objId,
setterProperty: property,
setterValue: value
}
ipcRenderer.sendSync(proxyCmds.set, args);
}
}
function staticGetHandler(target, name) {
// all methods and getters we support should be on the prototype
let desc = Object.getOwnPropertyDescriptor(target, name);
// does this have a static "getter"
if (desc && desc.get) {
return getHandler(target, name, true);
}
// does this have a static method
if (desc && typeof desc.value === 'function') {
return methodHandler(target, name, true);
}
return null;
}
module.exports = createProxy

View File

@@ -19,9 +19,13 @@ const apiEnums = require('../enums/api.js');
const apiCmds = apiEnums.cmds;
const apiName = apiEnums.apiName;
const notifyInterface = require('../notify/notifyInterface.js');
const createProxy = require('./createProxy.js');
// hold ref so doesn't get GC'ed
const local = {
ipcRenderer: ipcRenderer
ipcRenderer: ipcRenderer,
};
// throttle calls to this func to at most once per sec, called on leading edge.
@@ -57,6 +61,12 @@ window.SYM_API = {
throttledSetBadgeCount(count);
},
/**
* provides api similar to html5 Notification, see details
* in notify/notifyInterface.js
*/
Notification: createProxy(notifyInterface),
/**
* allows JS to register a logger that can be used by electron main process.
* @param {Object} logger function that can be called accepting

View File

@@ -11,6 +11,7 @@ const getGuid = require('./utils/getGuid.js');
const log = require('./log.js')
const logLevels = require('./enums/logLevels.js');
// show dialog when certificate errors occur
require('./dialogs/showCertError.js');
@@ -24,7 +25,7 @@ let isOnline = true;
// different preload script for main window and child windows.
// note: these files are generated by browserify prebuild process.
const preloadMainScript = path.join(__dirname, 'preload/_preloadMain.js');
const preloadChildScript = path.join(__dirname, 'preloda/_preloadChild.js');
const preloadChildScript = path.join(__dirname, 'preload/_preloadChild.js');
function addWindowKey(key, browserWin) {
windows[ key ] = browserWin;

View File

@@ -9,6 +9,9 @@
"dev:mac": "ELECTRON_DEV=true npm run start",
"dev:win": "SET ELECTRON_DEV=true && npm run start",
"start": "npm run browserify-preload && electron .",
"demo:mac": "ELECTRON_DEV=true npm run start:demo",
"demo:win": "SET ELECTRON_DEV=true && npm run start:demo",
"start:demo": "npm run browserify-preload && electron . --url=file://$(pwd)/demo/index.html",
"dist-mac": "npm run prebuild && build --mac",
"dist-win": "npm run prebuild && build --win --x64",
"dist-win-x86": "npm run prebuild && build --win --ia32",
@@ -16,7 +19,7 @@
"unpacked-win-x86": "npm run prebuild && build --win --ia32 --dir",
"prebuild": "npm run lint && npm run test && npm run browserify-preload",
"lint": "eslint js/**",
"test": "jest --coverage tests/*.test.js",
"test": "jest --coverage --testPathPattern tests",
"browserify-preload": "browserify -o js/preload/_preloadMain.js -x electron js/preload/preloadMain.js && browserify -o js/preload/_preloadChild.js -x electron js/preload/preloadChild.js"
},
"build": {