diff --git a/README.md b/README.md index fcd41c0..a8b8d30 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,10 @@ earlier Windows versions. Growl is used if none of these requirements are met. ![Input Example](https://raw.githubusercontent.com/mikaelbr/node-notifier/master/example/input-example.gif) +## Actions Example Windows SnoreToast + +![Actions Example](https://raw.githubusercontent.com/mikaelbr/node-notifier/master/example/windows-actions-example.gif) + ## Quick Usage Show a native notification on macOS, Windows, Linux: diff --git a/example/toaster-with-actions.js b/example/toaster-with-actions.js new file mode 100644 index 0000000..597ebda --- /dev/null +++ b/example/toaster-with-actions.js @@ -0,0 +1,34 @@ +const notifier = require('../index'); +const path = require('path'); + +notifier.notify( + { + message: 'Are you sure you want to continue?', + icon: path.join(__dirname, 'coulson.jpg'), + actions: ['OK', 'Cancel'] + }, + (err, data) => { + // Will also wait until notification is closed. + console.log('Waited'); + console.log(JSON.stringify({ err, data }, null, '\t')); + } +); + +// Built-in actions: +notifier.on('timeout', () => { + console.log('Timed out!'); +}); +notifier.on('activate', () => { + console.log('Clicked!'); +}); +notifier.on('dismissed', () => { + console.log('Dismissed!'); +}); + +// Buttons actions (lower-case): +notifier.on('ok', () => { + console.log('"Ok" was pressed'); +}); +notifier.on('cancel', () => { + console.log('"Cancel" was pressed'); +}); diff --git a/example/toaster.js b/example/toaster.js index fd04f80..8e5df79 100644 --- a/example/toaster.js +++ b/example/toaster.js @@ -1,5 +1,5 @@ -var notifier = require('../index'); -var path = require('path'); +const notifier = require('../index'); +const path = require('path'); notifier.notify( { @@ -10,14 +10,14 @@ notifier.notify( function(err, data) { // Will also wait until notification is closed. console.log('Waited'); - console.log(err, data); + console.log(JSON.stringify({ err, data })); } ); -notifier.on('timeout', function() { +notifier.on('timeout', () => { console.log('Timed out!'); }); -notifier.on('click', function() { +notifier.on('click', () => { console.log('Clicked!'); }); diff --git a/example/windows-actions-example.gif b/example/windows-actions-example.gif new file mode 100644 index 0000000..dc03aef Binary files /dev/null and b/example/windows-actions-example.gif differ diff --git a/lib/utils.js b/lib/utils.js index f6e803c..1870cf8 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -6,6 +6,9 @@ var path = require('path'); var url = require('url'); var os = require('os'); var fs = require('fs'); +var net = require('net'); + +const BUFFER_SIZE = 1024; function clone(obj) { return JSON.parse(JSON.stringify(obj)); @@ -225,6 +228,7 @@ module.exports.mapToMac = function(options) { function isArray(arr) { return Object.prototype.toString.call(arr) === '[object Array]'; } +module.exports.isArray = isArray; function noop() {} module.exports.actionJackerDecorator = function(emitter, options, fn, mapper) { @@ -253,6 +257,9 @@ module.exports.actionJackerDecorator = function(emitter, options, fn, mapper) { if (resultantData.match(/^activate|clicked$/)) { resultantData = 'activate'; } + if (resultantData.match(/^timedout$/)) { + resultantData = 'timeout'; + } } fn.apply(emitter, [err, resultantData, metadata]); @@ -318,13 +325,14 @@ function removeNewLines(str) { ---- Options ---- [-t] | Displayed on the first line of the toast. [-m] <message string> | Displayed on the remaining lines, wrapped. -[-b] <button1;button2 string>| Displayed on the bottom line, can list multiple buttons separated by ; +[-b] <button1;button2 string>| Displayed on the bottom line, can list multiple buttons separated by ";" [-tb] | Displayed a textbox on the bottom line, only if buttons are not presented. [-p] <image URI> | Display toast with an image, local files only. [-id] <id> | sets the id for a notification to be able to close it later. [-s] <sound URI> | Sets the sound of the notifications, for possible values see http://msdn.microsoft.com/en-us/library/windows/apps/hh761492.aspx. [-silent] | Don't play a sound file when showing the notifications. [-appID] <App.ID> | Don't create a shortcut but use the provided app id. +[-pid] <pid> | Query the appid for the process <pid>, use -appID as fallback. (Only relevant for applications that might be packaged for the store) [-pipeName] <\.\pipe\pipeName\> | Provide a name pipe which is used for callbacks. [-application] <C:\foo.exe> | Provide a application that might be started if the pipe does not exist. -close <id> | Closes a currently displayed notification. @@ -332,11 +340,15 @@ function removeNewLines(str) { var allowedToasterFlags = [ 't', 'm', + 'b', + 'tb', 'p', 'id', 's', 'silent', 'appID', + 'pid', + 'pipeName', 'close', 'install' ]; @@ -407,6 +419,11 @@ module.exports.mapToWin8 = function(options) { options.s = toasterDefaultSound; } + if (options.actions && isArray(options.actions)) { + options.b = options.actions.join(';'); + delete options.actions; + } + for (var key in options) { // Check if is allowed. If not, delete! if ( @@ -518,3 +535,21 @@ function sanitizeNotifuTypeArgument(type) { return 'info'; } + +module.exports.createNamedPipe = namedPipe => { + const buf = Buffer.alloc(BUFFER_SIZE); + + return new Promise(resolve => { + const server = net.createServer(stream => { + stream.on('data', c => { + buf.write(c.toString()); + }); + stream.on('end', () => { + server.close(); + }); + }); + server.listen(namedPipe, () => { + resolve(buf); + }); + }); +}; diff --git a/notifiers/toaster.js b/notifiers/toaster.js index 682c47e..4269d0b 100644 --- a/notifiers/toaster.js +++ b/notifiers/toaster.js @@ -6,12 +6,16 @@ var notifier = path.resolve(__dirname, '../vendor/snoreToast/snoretoast'); var utils = require('../lib/utils'); var Balloon = require('./balloon'); var os = require('os'); +const uuid = require('uuid/v4'); var EventEmitter = require('events').EventEmitter; var util = require('util'); var fallback; +const PIPE_NAME = 'notifierPipe'; +const PIPE_PATH_PREFIX = '\\\\.\\pipe\\'; + module.exports = WindowsToaster; function WindowsToaster(options) { @@ -28,17 +32,29 @@ util.inherits(WindowsToaster, EventEmitter); function noop() {} -var timeoutMessage = 'the toast has timed out'; -var successMessage = 'user clicked on the toast'; +function parseResult(data) { + if (!data) { + return {}; + } + return data.split(';').reduce((acc, cur) => { + const split = cur.split('='); + if (split && split.length === 2) { + acc[split[0]] = split[1]; + } + return acc; + }, {}); +} -function hasText(str, txt) { - return str && str.indexOf(txt) !== -1; +function getPipeName() { + return `${PIPE_PATH_PREFIX}${PIPE_NAME}-${uuid()}`; } WindowsToaster.prototype.notify = function(options, callback) { options = utils.clone(options || {}); callback = callback || noop; var is64Bit = os.arch() === 'x64'; + var resultBuffer; + const namedPipe = getPipeName(); if (typeof options === 'string') { options = { title: 'node-notifier', message: options }; @@ -51,36 +67,45 @@ WindowsToaster.prototype.notify = function(options, callback) { ); } - var actionJackedCallback = utils.actionJackerDecorator( - this, - options, - function cb(err, data) { - /* Possible exit statuses from SnoreToast, we only want to include err if it's -1 code - Exit Status : Exit Code - Failed : -1 - - Success : 0 - Hidden : 1 - Dismissed : 2 - TimedOut : 3 - ButtonPressed : 4 - TextEntered : 5 - */ - if (err && err.code !== -1) { - return callback(null, data); - } - callback(err, data); - }, - function mapper(data) { - if (hasText(data, successMessage)) { - return 'click'; - } - if (hasText(data, timeoutMessage)) { - return 'timeout'; - } - return false; + var snoreToastResultParser = (err, callback) => { + /* Possible exit statuses from SnoreToast, we only want to include err if it's -1 code + Exit Status : Exit Code + Failed : -1 + + Success : 0 + Hidden : 1 + Dismissed : 2 + TimedOut : 3 + ButtonPressed : 4 + TextEntered : 5 + */ + const result = parseResult( + resultBuffer && resultBuffer.toString('utf16le') + ); + + // parse action + if (result.action === 'buttonClicked' && result.button) { + result.activationType = result.button; + } else if (result.action) { + result.activationType = result.action; } - ); + + if (err && err.code === -1) { + callback(err, result); + } + callback(null, result); + }; + + var actionJackedCallback = err => + snoreToastResultParser( + err, + utils.actionJackerDecorator( + this, + options, + callback, + data => data || false + ) + ); options.title = options.title || 'Node Notification:'; if ( @@ -96,19 +121,25 @@ WindowsToaster.prototype.notify = function(options, callback) { return fallback.notify(options, callback); } - options = utils.mapToWin8(options); - var argsList = utils.constructArgumentList(options, { - explicitTrue: true, - wrapper: '', - keepNewlines: true, - noEscape: true + // Add pipeName option, to get the output + utils.createNamedPipe(namedPipe).then(out => { + resultBuffer = out; + options.pipeName = namedPipe; + + options = utils.mapToWin8(options); + var argsList = utils.constructArgumentList(options, { + explicitTrue: true, + wrapper: '', + keepNewlines: true, + noEscape: true + }); + + var notifierWithArch = notifier + '-x' + (is64Bit ? '64' : '86') + '.exe'; + utils.fileCommand( + this.options.customPath || notifierWithArch, + argsList, + actionJackedCallback + ); }); - - var notifierWithArch = notifier + '-x' + (is64Bit ? '64' : '86') + '.exe'; - utils.fileCommand( - this.options.customPath || notifierWithArch, - argsList, - actionJackedCallback - ); return this; }; diff --git a/package.json b/package.json index 3a793a0..e132219 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "example:mac": "node ./example/advanced.js", "example:mac:input": "node ./example/macInput.js", "example:windows": "node ./example/toaster.js", + "example:windows:actions": "node ./example/toaster-with-actions.js", "lint": "eslint example/*.js lib/*.js notifiers/*.js test/**/*.js index.js" }, "jest": { @@ -54,6 +55,7 @@ "is-wsl": "^2.1.1", "semver": "^6.3.0", "shellwords": "^0.1.1", + "uuid": "^3.3.3", "which": "^1.3.1" }, "husky": { diff --git a/test/toaster.js b/test/toaster.js index 396cba3..41024f8 100644 --- a/test/toaster.js +++ b/test/toaster.js @@ -3,9 +3,13 @@ var utils = require('../lib/utils'); var path = require('path'); var os = require('os'); var testUtils = require('./_test-utils'); +jest.mock('uuid/v4', () => { + return () => '123456789'; +}); describe('WindowsToaster', function() { var original = utils.fileCommand; + var createNamedPipe = utils.createNamedPipe; var originalType = os.type; var originalArch = os.arch; var originalRelease = os.release; @@ -17,10 +21,12 @@ describe('WindowsToaster', function() { os.type = function() { return 'Windows_NT'; }; + utils.createNamedPipe = () => Promise.resolve(Buffer.from('12345')); }); afterEach(function() { utils.fileCommand = original; + utils.createNamedPipe = createNamedPipe; os.type = originalType; os.arch = originalArch; os.release = originalRelease; @@ -30,9 +36,11 @@ describe('WindowsToaster', function() { utils.fileCommand = function(notifier, argsList, callback) { expect(testUtils.argsListHas(argsList, '-t')).toBeTruthy(); expect(testUtils.argsListHas(argsList, '-m')).toBeTruthy(); + expect(testUtils.argsListHas(argsList, '-b')).toBeTruthy(); expect(testUtils.argsListHas(argsList, '-p')).toBeTruthy(); expect(testUtils.argsListHas(argsList, '-id')).toBeTruthy(); expect(testUtils.argsListHas(argsList, '-appID')).toBeTruthy(); + expect(testUtils.argsListHas(argsList, '-pipeName')).toBeTruthy(); expect(testUtils.argsListHas(argsList, '-install')).toBeTruthy(); expect(testUtils.argsListHas(argsList, '-close')).toBeTruthy(); @@ -40,6 +48,8 @@ describe('WindowsToaster', function() { expect(testUtils.argsListHas(argsList, '-bar')).toBeFalsy(); expect(testUtils.argsListHas(argsList, '-message')).toBeFalsy(); expect(testUtils.argsListHas(argsList, '-title')).toBeFalsy(); + expect(testUtils.argsListHas(argsList, '-tb')).toBeFalsy(); + expect(testUtils.argsListHas(argsList, '-pid')).toBeFalsy(); done(); }; var notifier = new Notify(); @@ -55,7 +65,8 @@ describe('WindowsToaster', function() { appID: 123, icon: 'file:///C:/node-notifier/test/fixture/coulson.jpg', id: 1337, - sound: 'Notification.IM' + sound: 'Notification.IM', + actions: ['Ok', 'Cancel'] }); }); @@ -244,7 +255,7 @@ describe('WindowsToaster', function() { it('should parse file protocol URL of icon', function(done) { utils.fileCommand = function(notifier, argsList, callback) { - expect(argsList[1]).toBe('C:\\node-notifier\\test\\fixture\\coulson.jpg'); + expect(argsList[3]).toBe('C:\\node-notifier\\test\\fixture\\coulson.jpg'); done(); }; @@ -260,7 +271,7 @@ describe('WindowsToaster', function() { it('should not parse local path of icon', function(done) { var icon = path.join(__dirname, 'fixture', 'coulson.jpg'); utils.fileCommand = function(notifier, argsList, callback) { - expect(argsList[1]).toBe(icon); + expect(argsList[3]).toBe(icon); done(); }; @@ -271,11 +282,51 @@ describe('WindowsToaster', function() { it('should not parse normal URL of icon', function(done) { var icon = 'http://csscomb.com/img/csscomb.jpg'; utils.fileCommand = function(notifier, argsList, callback) { - expect(argsList[1]).toBe(icon); + expect(argsList[3]).toBe(icon); done(); }; var notifier = new Notify(); notifier.notify({ title: 'Heya', message: 'foo bar', icon: icon }); }); + + it('should build command-line argument for actions array properly', () => { + utils.fileCommand = function(notifier, argsList, callback) { + expect(argsList).toEqual([ + '-close', + '123', + '-install', + '/dsa/', + '-id', + '1337', + '-pipeName', + '\\\\.\\pipe\\notifierPipe-123456789', + '-p', + 'C:\\node-notifier\\test\\fixture\\coulson.jpg', + '-m', + 'foo bar', + '-t', + 'Heya', + '-s', + 'Notification.IM', + '-b', + 'Ok;Cancel' + ]); + }; + var notifier = new Notify(); + + notifier.notify({ + title: 'Heya', + message: 'foo bar', + extra: 'dsakdsa', + foo: 'bar', + close: 123, + bar: true, + install: '/dsa/', + icon: 'file:///C:/node-notifier/test/fixture/coulson.jpg', + id: 1337, + sound: 'Notification.IM', + actions: ['Ok', 'Cancel'] + }); + }); }); diff --git a/vendor/snoreToast/snoretoast-x64.exe b/vendor/snoreToast/snoretoast-x64.exe index 1a71c72..44b1422 100644 Binary files a/vendor/snoreToast/snoretoast-x64.exe and b/vendor/snoreToast/snoretoast-x64.exe differ diff --git a/vendor/snoreToast/snoretoast-x86.exe b/vendor/snoreToast/snoretoast-x86.exe index 55ddb2a..908afb9 100644 Binary files a/vendor/snoreToast/snoretoast-x86.exe and b/vendor/snoreToast/snoretoast-x86.exe differ