diff --git a/README.md b/README.md index 44885fa..02d743c 100644 --- a/README.md +++ b/README.md @@ -344,13 +344,25 @@ See full usage on the [project homepage: **`notifu`**](http://www.paralint.com/p ### Usage: `NotifySend` -**Note:** `notify-send` doesn't support the `wait` flag. +**Note:** `notify-send` <0.8.2 doesn't support the `wait` flag. + +**Note:** `notify-send` >=0.8.2 supports `action` and `wait` flags. ```javascript const NotifySend = require('node-notifier').NotifySend; var notifier = new NotifySend(); +notifier.on('ok', () => { + console.log('"OK" was pressed'); +}); +notifier.on('cancel', () => { + console.log('"Cancel" was pressed'); +}); +notifier.on('activate', () => { + console.log('notification was clicked'); +}); + notifier.notify({ title: 'Foo', message: 'Hello World', @@ -363,12 +375,15 @@ notifier.notify({ 'app-name': 'node-notifier', urgency: undefined, category: undefined, - hint: undefined + hint: undefined, + actions: ['OK', 'Cancel'] // Name of action in lowercase will be used as event name; implicitly adds '--wait' as well. }); ``` See flags and options on the man page [`notify-send(1)`](http://manpages.ubuntu.com/manpages/gutsy/man1/notify-send.1.html) +Run `example/notify-send.js` to see handling of action response. **You must run this example from a real terminal, it doesn't work from inside e.g. VSCode since libnotify will be in `confined` mode then.** + ## Thanks to OSS `node-notifier` is made possible through Open Source Software. diff --git a/example/notify-send.js b/example/notify-send.js new file mode 100644 index 0000000..a8c25fb --- /dev/null +++ b/example/notify-send.js @@ -0,0 +1,84 @@ +const notifier = require('../index'); +const path = require('path'); + +notifier.on('activate', function (notifierObject, options, event) { + console.log( + 'clicked:', + JSON.stringify(notifierObject, null, 2), + JSON.stringify(options, null, 2), + JSON.stringify(event, null, 2) + ); +}); + +notifier.on('timeout', function (notifierObject, options) { + // does not work for notify-send + console.log( + 'timeout:', + JSON.stringify(notifierObject, null, 2), + JSON.stringify(options, null, 2) + ); +}); + +notifier.on('yes', (nn, options, x) => { + console.log( + 'YES', + JSON.stringify(options, null, 2), + JSON.stringify(x, null, 2) + ); +}); +notifier.on('no', (nn, options) => { + console.log('NO', JSON.stringify(options, null, 2)); +}); +notifier.on('ok', (nn, options) => { + console.log('OK', JSON.stringify(options, null, 2)); +}); + +const testNotificationWithoutActions = () => { + notifier.notify( + { + title: 'My awesome title', + message: 'Hello from node, Mr. User!', + icon: path.resolve(path.join(__dirname, 'coulson.jpg')), // Absolute path (doesn't work on balloons) + sound: true, // Only Notification Center or Windows Toasters + wait: true // Wait with callback, until user action is taken against notification, does not apply to Windows Toasters as they always wait or notify-send as it does not support the wait option + // actions: ['OK'] + }, + function (err, response, metadata) { + // Response (is response from notification + // Metadata contains activationType, activationAt, deliveredAt + console.log( + 'cb:', + JSON.stringify(err, null, 2), + JSON.stringify(response, null, 2), + JSON.stringify(metadata, null, 2) + ); + } + ); +}; + +const testNotificationWithActions = () => { + notifier.notify( + { + title: 'My awesome title', + message: 'Hello from node, Mr. User!', + icon: path.resolve(path.join(__dirname, 'coulson.jpg')), // Absolute path (doesn't work on balloons) + sound: true, // Only Notification Center or Windows Toasters + actions: ['Yes', 'No'] + }, + function (err, response, metadata) { + // Response is response from notification + // Metadata contains activationType, activationAt, deliveredAt + console.log( + 'cb:', + JSON.stringify(err, null, 2), + JSON.stringify(response, null, 2), + JSON.stringify(metadata, null, 2) + ); + } + ); +}; + +if (require.main === module) { + setTimeout(testNotificationWithActions, 10); + setTimeout(testNotificationWithoutActions, 10000); +} diff --git a/lib/utils.js b/lib/utils.js index fe4b164..07618a4 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -45,7 +45,12 @@ const notifySendFlags = { h: 'hint', hint: 'hint', a: 'app-name', - 'app-name': 'app-name' + 'app-name': 'app-name', + action: 'action', + A: 'action', + actions: 'action', + w: 'wait', + wait: 'wait' }; module.exports.command = function (notifier, options, cb) { @@ -56,14 +61,13 @@ module.exports.command = function (notifier, options, cb) { console.info('[notifier options]', options.join(' ')); } - return cp.exec(notifier + ' ' + options.join(' '), function ( - error, - stdout, - stderr - ) { - if (error) return cb(error); - cb(stderr, stdout); - }); + return cp.exec( + notifier + ' ' + options.join(' '), + function (error, stdout, stderr) { + if (error) return cb(error); + cb(stderr, stdout); + } + ); }; module.exports.fileCommand = function (notifier, options, cb) { @@ -297,6 +301,7 @@ module.exports.constructArgumentList = function (options, extra) { const explicitTrue = !!extra.explicitTrue; const keepNewlines = !!extra.keepNewlines; const wrapper = extra.wrapper === undefined ? '"' : extra.wrapper; + const arrayArgToMultipleArgs = extra.arrayArgToMultipleArgs || false; const escapeFn = function escapeFn(arg) { if (isArray(arg)) { @@ -323,7 +328,13 @@ module.exports.constructArgumentList = function (options, extra) { if (explicitTrue && options[key] === true) { args.push('-' + keyExtra + key); } else if (explicitTrue && options[key] === false) continue; - else args.push('-' + keyExtra + key, escapeFn(options[key])); + else { + if (arrayArgToMultipleArgs && isArray(options[key])) { + for (const val of options[key]) { + args.push('-' + keyExtra + key, escapeFn(val)); + } + } else args.push('-' + keyExtra + key, escapeFn(options[key])); + } } } return args; @@ -539,6 +550,8 @@ function garanteeSemverFormat(version) { return version; } +module.exports.garanteeSemverFormat = garanteeSemverFormat; + function sanitizeNotifuTypeArgument(type) { if (typeof type === 'string' || type instanceof String) { if (type.toLowerCase() === 'info') return 'info'; diff --git a/notifiers/notifysend.js b/notifiers/notifysend.js index 68dfbf6..9d571ba 100644 --- a/notifiers/notifysend.js +++ b/notifiers/notifysend.js @@ -4,96 +4,185 @@ const os = require('os'); const which = require('which'); const utils = require('../lib/utils'); - const EventEmitter = require('events').EventEmitter; -const util = require('util'); +const execSync = require('child_process').execSync; +const semver = require('semver'); const notifier = 'notify-send'; let hasNotifier; - -module.exports = NotifySend; - -function NotifySend(options) { - options = utils.clone(options || {}); - if (!(this instanceof NotifySend)) { - return new NotifySend(options); - } - - this.options = options; - - EventEmitter.call(this); -} -util.inherits(NotifySend, EventEmitter); +let hasActionsCapability; function noop() {} -function notifyRaw(options, callback) { - options = utils.clone(options || {}); - callback = callback || noop; - if (typeof callback !== 'function') { +const NotifySendActionJackerDecorator = function ( + emitter, + options, + fn, + mapper +) { + options = utils.clone(options); + fn = fn || noop; + if (typeof fn !== 'function') { throw new TypeError( 'The second argument must be a function callback. You have passed ' + - typeof callback + typeof fn ); } - if (typeof options === 'string') { - options = { title: 'node-notifier', message: options }; - } - - if (!options.message) { - callback(new Error('Message is required.')); - return this; - } - - if (os.type() !== 'Linux' && !os.type().match(/BSD$/)) { - callback(new Error('Only supported on Linux and *BSD systems')); - return this; + return function (err, data) { + let resultantData = data; + let metadata = {}; + // Allow for extra data if resultantData is an object + if (resultantData && typeof resultantData === 'object') { + metadata = resultantData; + resultantData = resultantData.activationType; + } + + // Sanitize the data + if (resultantData) { + resultantData = resultantData.trim(); + const index = Number(resultantData); + resultantData = options.action[index].toLowerCase(); + } + + fn.apply(emitter, [err, resultantData, metadata]); + if (!resultantData && !err) { + emitter.emit('activate', emitter, options, metadata); + return; + } + if (!err) { + emitter.emit(resultantData, emitter, options, metadata); + return; + } + if (err) { + emitter.emit('error', emitter, options, err); + } + }; +}; + +class NotifySend extends EventEmitter { + constructor(options) { + super(); + this.options = utils.clone(options || {}); + this.checkActionCapability(); } - if (hasNotifier === false) { - callback(new Error('notify-send must be installed on the system.')); - return this; - } + notify(options, callback) { + options = utils.clone(options || {}); + callback = callback || noop; + if (typeof callback !== 'function') { + throw new TypeError( + 'The second argument must be a function callback. You have passed ' + + typeof callback + ); + } + + if (typeof options === 'string') { + options = { title: 'node-notifier', message: options }; + } + + if (!options.message) { + callback(new Error('Message is required.')); + return this; + } + + if (os.type() !== 'Linux' && !os.type().match(/BSD$/)) { + callback(new Error('Only supported on Linux and *BSD systems')); + return this; + } + + if (hasNotifier === false) { + callback(new Error('notify-send must be installed on the system.')); + return this; + } + + if (hasNotifier || !!this.options.suppressOsdCheck) { + this._doNotification(options, callback); + return this; + } + + try { + this._doNotification(options, callback); + } catch (err) { + hasNotifier = false; + return callback(err); + } - if (hasNotifier || !!this.options.suppressOsdCheck) { - doNotification(options, callback); return this; } - try { + checkActionCapability() { + hasActionsCapability = false; hasNotifier = !!which.sync(notifier); - doNotification(options, callback); - } catch (err) { - hasNotifier = false; - return callback(err); - } - return this; -} - -Object.defineProperty(NotifySend.prototype, 'notify', { - get: function() { - if (!this._notify) this._notify = notifyRaw.bind(this); - return this._notify; + if (!hasNotifier) return; + + const notifierVersion = execSync(notifier + ' --version'); + const notifierVersionNumber = notifierVersion + .toString() + .trim() + .split(' ')[1]; + + if ( + semver.satisfies( + utils.garanteeSemverFormat(notifierVersionNumber), + '>=0.8.2' + ) + ) { + hasActionsCapability = true; + } else { + // throw new Error('notify-send version ' + notifierVersionNumber + ' does not support "actions". Upgrade to a newer version >=0.8.2'); + } } -}); - -const allowedArguments = ['urgency', 'expire-time', 'icon', 'category', 'hint', 'app-name']; -function doNotification(options, callback) { - options = utils.mapToNotifySend(options); - options.title = options.title || 'Node Notification:'; - - const initial = [options.title, options.message]; - delete options.title; - delete options.message; - - const argsList = utils.constructArgumentList(options, { - initial: initial, - keyExtra: '-', - allowedArguments: allowedArguments - }); + _doNotification(options, callback) { + if ('actions' in options) { + // rename actions to action + options.action = options.actions; + delete options.actions; + } + const originalOptions = utils.clone(options); // nearly original options + options = utils.mapToNotifySend(options); + options.title = options.title || 'Node Notification:'; + + const initial = [options.title, options.message]; + delete options.title; + delete options.message; + + const allowedArguments = [ + 'urgency', + 'expire-time', + 'icon', + 'category', + 'hint', + 'app-name' + ]; + if (hasActionsCapability) { + allowedArguments.push('action'); + allowedArguments.push('wait'); + } + + const argsList = utils.constructArgumentList(options, { + initial: initial, + keyExtra: '-', + arrayArgToMultipleArgs: hasActionsCapability, + explicitTrue: true, + allowedArguments: allowedArguments + }); + + if (!hasActionsCapability) { + return utils.command(notifier, argsList, callback); + } + + const actionJackedCallback = NotifySendActionJackerDecorator( + this, + originalOptions, + callback, + null + ); - utils.command(notifier, argsList, callback); + utils.command(notifier, argsList, actionJackedCallback); + } } + +module.exports = NotifySend; diff --git a/package-lock.json b/package-lock.json index 1674acd..61299d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1854,14 +1854,24 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001228", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001228.tgz", - "integrity": "sha512-QQmLOGJ3DEgokHbMSA8cj2a+geXqmnpyOFT0lhQV6P3/YOJvGDEwoedcwxEQ30gJIwIIunHIicunJ2rzK5gB2A==", + "version": "1.0.30001634", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001634.tgz", + "integrity": "sha512-fbBYXQ9q3+yp1q1gBk86tOFs4pyn/yxFm5ZNP18OXJDfA3txImOY9PhfxVggZ4vRHDqoU8NrKU81eN0OtzOgRA==", "dev": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - } + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] }, "node_modules/capture-exit": { "version": "2.0.0", @@ -11926,9 +11936,9 @@ "dev": true }, "caniuse-lite": { - "version": "1.0.30001228", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001228.tgz", - "integrity": "sha512-QQmLOGJ3DEgokHbMSA8cj2a+geXqmnpyOFT0lhQV6P3/YOJvGDEwoedcwxEQ30gJIwIIunHIicunJ2rzK5gB2A==", + "version": "1.0.30001634", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001634.tgz", + "integrity": "sha512-fbBYXQ9q3+yp1q1gBk86tOFs4pyn/yxFm5ZNP18OXJDfA3txImOY9PhfxVggZ4vRHDqoU8NrKU81eN0OtzOgRA==", "dev": true }, "capture-exit": { diff --git a/test/notify-send.js b/test/notify-send.js index 190fbfa..c47164f 100644 --- a/test/notify-send.js +++ b/test/notify-send.js @@ -130,4 +130,70 @@ describe('notify-send', function () { tullball: 'notValid' }); }); + + it('should add one action option per action entry', function (done) { + const expected = [ + '"title"', + '"body"', + '--icon', + '"icon-string"', + '--action', + '"Yes"', + '--action', + '"No"', + '--action', + '"May\\`be\\`"', + '--expire-time', + '"10000"' + ]; + + expectArgsListToBe(expected, done); + const notifier = new Notify({ suppressOsdCheck: true }); + notifier.notify({ + title: 'title', + message: 'body', + icon: 'icon-string', + tullball: 'notValid', + action: ['Yes', 'No', 'May`be`'] + }); + }); + + it('should add one action option per actionS entry', function (done) { + const expected = [ + '"title"', + '"body"', + '--icon', + '"icon-string"', + '--action', + '"Yes"', + '--action', + '"No"', + '--action', + '"May\\`be\\`"', + '--expire-time', + '"10000"' + ]; + + expectArgsListToBe(expected, done); + const notifier = new Notify({ suppressOsdCheck: true }); + notifier.notify({ + title: 'title', + message: 'body', + icon: 'icon-string', + tullball: 'notValid', + actions: ['Yes', 'No', 'May`be`'] + }); + }); + + it('should keep wait switch (and add expire-time option)', function (done) { + const expected = ['"title"', '"body"', '--wait', '--expire-time', '"5000"']; + + expectArgsListToBe(expected, done); + const notifier = new Notify({ suppressOsdCheck: true }); + notifier.notify({ + title: 'title', + message: 'body', + wait: true + }); + }); });